Skip to content

Commit f31c05b

Browse files
artem-harbourcaiopizzolcaio-pizzol
authored
fix: align rtl date token rendering with word parity (#3250)
* fix: align rtl date token rendering with word parity Fix RTL date rendering parity with Word per SD-3098. The browser's UBA does not reorder numerics inside an RTL run the way Word does, and run-boundary separator drift breaks mixed-direction date strings like `-03-23` + `2026`. The fix is paint-time only - it never touches PM/model/export: 1. DomPainter sets dir="rtl" on the span when run.bidi.rtl === true (per-run bidi isolation eliminates run-boundary separator drift) 2. DomPainter sets dir="ltr" on date-like LTR runs (per regex) inside RTL contexts (prevents the third case where a non-rtl date inside an rtl paragraph reorders unpredictably) 3. normalizeRtlDateTokenForWordParity injects U+200F (RLM) around `./- ` separators inside RTL date-like text so Word and SuperDoc render the same visual order (e.g. XML `23/03/2026` -> visual `2026/03/23`) Three test cases in rtl-dates.docx (the Linear-attached "Date Being Weird" fixture): - Header: single <w:rtl/> run `23/03/2026` -> Word visual `2026/03/23` - Body 1: LTR run `-03-23` + RTL run `2026` -> Word visual `2026-03-23` - Body 2 (control): single LTR run `2026/03/26` -> unchanged Other changes: - bidiCompatible guard in mergeAdjacentRuns: prevents a <w:rtl/> run from merging with a plain run and silently losing the bidi flag - run-visual-marks.ts and versionSignature.ts: include run.bidi in the caching hashes so a rtl-only edit invalidates measure/DOM cache - New painter unit tests (rtl-date-parity.test.ts) verifying dir + RLM - New behavior spec (rtl-dates-word-parity.spec.ts) using the real fixture This PR builds on the run-level bidi metadata SD-2781 (#3203) added to the TextRun contract - reads from TextRun.bidi.rtl (the merged shape), not a parallel RunMarks.bidiContext field. * test(rtl-date-parity): cover bidi hash + block-version + painter edge cases Adds pre-merge coverage for SD-3098 rendering invariants: - hashRunVisualMarks: bidi field changes the dirty-run hash (rtl=true vs absent, rtl=true vs rtl=false, embedding-only changes). Stale hashes would let an edit that flips just <w:rtl/> reuse stale measure/DOM. - deriveBlockVersion: bidi flips invalidate the cached block version. Without this, the painter would reuse a cached block snapshot after an rtl-only edit. - DomPainter painter tests: mixed rtl + ltr runs on the same line stay as separate spans with distinct dir attrs; non-date-like rtl runs keep dir="rtl" without RLM injection; non-rtl plain text leaves the span without a dir attribute. Also adds rtl-mixed-run-line.docx + behavior spec as a negative test asserting Hebrew + date + Hebrew paragraphs don't regress (Hebrew runs stay rtl, date run is not RLM-injected since it isn't rtl-tagged). --------- Co-authored-by: Caio Pizzol <caiopizzol@gmail.com> Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Co-authored-by: Caio Pizzol <caio@superdoc.dev>
1 parent d0db62c commit f31c05b

12 files changed

Lines changed: 399 additions & 5 deletions

File tree

packages/layout-engine/layout-bridge/src/run-visual-marks.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ export const hashRunVisualMarks = (run: Run): string => {
2020
const fontFamily = 'fontFamily' in run ? run.fontFamily : undefined;
2121
const highlight = 'highlight' in run ? run.highlight : undefined;
2222
const link = 'link' in run ? run.link : undefined;
23+
// SD-3098: DomPainter now reads `bidi.rtl` to apply dir="rtl"/dir="ltr" and the
24+
// RLM separator injection for date-like tokens. Include it here so dirty-run
25+
// detection picks up rtl-only changes; otherwise an edit that flips just
26+
// <w:rtl/> could reuse stale measure/DOM.
27+
const bidi = 'bidi' in run ? run.bidi : undefined;
2328

2429
return [
2530
bold ? 'b' : '',
@@ -31,5 +36,6 @@ export const hashRunVisualMarks = (run: Run): string => {
3136
fontFamily ? `ff:${fontFamily}` : '',
3237
highlight ? `hl:${highlight}` : '',
3338
link ? `ln:${JSON.stringify(link)}` : '',
39+
bidi ? `bd:${JSON.stringify(bidi)}` : '',
3440
].join('');
3541
};

packages/layout-engine/layout-bridge/test/run-visual-marks.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,39 @@ describe('hashRunVisualMarks', () => {
103103

104104
expect(hashRunVisualMarks(a)).toBe(hashRunVisualMarks(b));
105105
});
106+
107+
// SD-3098: DomPainter applies dir="rtl" + RLM injection based on run.bidi.rtl,
108+
// so the dirty-run hash must change when bidi changes, otherwise an edit that
109+
// flips just <w:rtl/> reuses the stale measure/DOM.
110+
describe('bidi (SD-3098)', () => {
111+
const base = {
112+
text: '23.03.2026',
113+
fontFamily: 'David, sans-serif',
114+
fontSize: 16,
115+
} as Run;
116+
117+
it('produces a different hash when bidi.rtl is set vs absent', () => {
118+
const hashPlain = hashRunVisualMarks(base);
119+
const hashRtl = hashRunVisualMarks({ ...base, bidi: { rtl: true } } as Run);
120+
expect(hashRtl).not.toBe(hashPlain);
121+
});
122+
123+
it('produces a different hash for bidi.rtl=true vs bidi.rtl=false', () => {
124+
const hashTrue = hashRunVisualMarks({ ...base, bidi: { rtl: true } } as Run);
125+
const hashFalse = hashRunVisualMarks({ ...base, bidi: { rtl: false } } as Run);
126+
expect(hashTrue).not.toBe(hashFalse);
127+
});
128+
129+
it('produces a different hash when only bidi.embedding changes', () => {
130+
const hashLtr = hashRunVisualMarks({ ...base, bidi: { rtl: false, embedding: 'ltr' } } as Run);
131+
const hashRtlEmbed = hashRunVisualMarks({ ...base, bidi: { rtl: false, embedding: 'rtl' } } as Run);
132+
expect(hashRtlEmbed).not.toBe(hashLtr);
133+
});
134+
135+
it('is stable for identical bidi shapes', () => {
136+
const a = hashRunVisualMarks({ ...base, bidi: { rtl: true } } as Run);
137+
const b = hashRunVisualMarks({ ...base, bidi: { rtl: true } } as Run);
138+
expect(a).toBe(b);
139+
});
140+
});
106141
});

packages/layout-engine/layout-resolved/src/versionSignature.test.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from 'vitest';
2-
import { sourceAnchorSignature } from './versionSignature.js';
3-
import type { SourceAnchor } from '@superdoc/contracts';
2+
import { deriveBlockVersion, sourceAnchorSignature } from './versionSignature.js';
3+
import type { FlowBlock, SourceAnchor, TextRun } from '@superdoc/contracts';
44

55
describe('sourceAnchorSignature', () => {
66
it('is stable for equivalent source anchors with different object key order', () => {
@@ -28,3 +28,41 @@ describe('sourceAnchorSignature', () => {
2828
expect(sourceAnchorSignature(anchorA)).toBe(sourceAnchorSignature(anchorB));
2929
});
3030
});
31+
32+
describe('deriveBlockVersion - bidi', () => {
33+
const makeParagraph = (bidi?: TextRun['bidi']): FlowBlock => ({
34+
kind: 'paragraph',
35+
id: 'p1',
36+
attrs: { direction: 'rtl' },
37+
runs: [
38+
{
39+
text: '23.03.2026',
40+
fontFamily: 'David, sans-serif',
41+
fontSize: 16,
42+
pmStart: 1,
43+
pmEnd: 11,
44+
...(bidi ? { bidi } : {}),
45+
} as TextRun,
46+
],
47+
});
48+
49+
// SD-3098: flipping only run.bidi must invalidate the cached block hash,
50+
// otherwise an edit that toggles <w:rtl/> reuses stale DOM in DomPainter.
51+
it('produces a different version when bidi.rtl is added', () => {
52+
const versionPlain = deriveBlockVersion(makeParagraph());
53+
const versionRtl = deriveBlockVersion(makeParagraph({ rtl: true }));
54+
expect(versionRtl).not.toBe(versionPlain);
55+
});
56+
57+
it('produces a different version for bidi.rtl=true vs bidi.rtl=false', () => {
58+
const versionTrue = deriveBlockVersion(makeParagraph({ rtl: true }));
59+
const versionFalse = deriveBlockVersion(makeParagraph({ rtl: false }));
60+
expect(versionTrue).not.toBe(versionFalse);
61+
});
62+
63+
it('is stable when bidi is identical', () => {
64+
const a = deriveBlockVersion(makeParagraph({ rtl: true }));
65+
const b = deriveBlockVersion(makeParagraph({ rtl: true }));
66+
expect(a).toBe(b);
67+
});
68+
});

packages/layout-engine/layout-resolved/src/versionSignature.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,8 @@ export const deriveBlockVersion = (block: FlowBlock): string => {
271271
textRun.token ?? '',
272272
textRun.trackedChange ? 1 : 0,
273273
textRun.comments?.length ?? 0,
274+
// SD-3098: DomPainter reads run.bidi to apply dir + RLM injection; signature must include it.
275+
textRun.bidi ? JSON.stringify(textRun.bidi) : '',
274276
].join(',');
275277
})
276278
.join('|');
@@ -459,6 +461,9 @@ export const deriveBlockVersion = (block: FlowBlock): string => {
459461
hash = hashString(hash, getRunBooleanProp(run, 'strike') ? '1' : '');
460462
hash = hashString(hash, getRunStringProp(run, 'vertAlign'));
461463
hash = hashNumber(hash, getRunNumberProp(run, 'baselineShift'));
464+
// SD-3098: include run.bidi so rtl-only changes invalidate the cached block hash.
465+
const bidi = (run as { bidi?: unknown }).bidi;
466+
hash = hashString(hash, bidi ? JSON.stringify(bidi) : '');
462467
}
463468
}
464469
}

packages/layout-engine/painters/dom/src/renderer.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5567,7 +5567,10 @@ export class DomPainter {
55675567
const isActiveLink = !!(linkData && !linkData.blocked && linkData.href);
55685568
const elem = isActiveLink ? this.doc.createElement('a') : this.doc.createElement('span');
55695569
const text = resolveRunText(run, context);
5570-
this.setTextContentWithFormattingSpaceMarks(elem, text);
5570+
const textRun = run as TextRun;
5571+
const effectiveText =
5572+
textRun.bidi?.rtl === true && typeof text === 'string' ? normalizeRtlDateTokenForWordParity(text) : text;
5573+
this.setTextContentWithFormattingSpaceMarks(elem, effectiveText);
55715574

55725575
if (linkData?.dataset) {
55735576
applyLinkDataset(elem, linkData.dataset);
@@ -5593,7 +5596,13 @@ export class DomPainter {
55935596

55945597
// Pass isLink flag to skip applying inline color/decoration styles for links
55955598
applyRunStyles(elem as HTMLElement, run, isActiveLink);
5596-
const textRun = run as TextRun;
5599+
// SD-3098 Word-parity: rtl-tagged runs get dir="rtl" so per-run bidi is isolated;
5600+
// non-rtl date-like runs in RTL context get dir="ltr" to prevent separator drift.
5601+
if (textRun.bidi?.rtl === true) {
5602+
elem.setAttribute('dir', 'rtl');
5603+
} else if (typeof textRun.text === 'string' && RTL_DATE_LIKE_TOKEN_RE.test(textRun.text)) {
5604+
elem.setAttribute('dir', 'ltr');
5605+
}
55975606
const commentAnnotations = textRun.comments;
55985607
const hasAnyComment = !!commentAnnotations?.length;
55995608
// Comment highlight styles are applied post-paint by CommentHighlightDecorator (super-editor).
@@ -6352,6 +6361,7 @@ export class DomPainter {
63526361
link: run.link ?? null,
63536362
comments: run.comments ?? null,
63546363
dataAttrs: stableDataAttrs(run.dataAttrs) ?? null,
6364+
bidi: run.bidi ?? null,
63556365
});
63566366

63576367
const isWhitespaceOnly = (text: string): boolean => {
@@ -8266,3 +8276,18 @@ const resolveRunText = (run: Run, context: FragmentRenderContext): string => {
82668276
}
82678277
return run.text ?? '';
82688278
};
8279+
8280+
const RTL_DATE_LIKE_TOKEN_RE = /^-?\d+(?:[./-]\d+)+$/;
8281+
const RLM = '\u200F';
8282+
8283+
// AIDEV-NOTE: SD-3098 Word-parity workaround for RTL date-like tokens. We inject
8284+
// RLM around separators at paint time only (DOM text), never into PM/model/export.
8285+
// Word reorders numerics inside RTL date strings via internal RLM treatment; the
8286+
// browser's UBA does not. This is intentionally narrow - only matches date-like
8287+
// numeric patterns - so non-date numeric content is unaffected.
8288+
const normalizeRtlDateTokenForWordParity = (text: string): string => {
8289+
if (!RTL_DATE_LIKE_TOKEN_RE.test(text)) {
8290+
return text;
8291+
}
8292+
return text.replace(/[./-]/g, (separator) => `${RLM}${separator}${RLM}`);
8293+
};
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { FlowBlock, Layout, Measure } from '@superdoc/contracts';
3+
import { createTestPainter } from './_test-utils.js';
4+
5+
const makeLayout = (blockId: string): Layout => ({
6+
pageSize: { w: 400, h: 500 },
7+
pages: [
8+
{
9+
number: 1,
10+
fragments: [{ kind: 'para', blockId, fromLine: 0, toLine: 1, x: 20, y: 20, width: 300 }],
11+
},
12+
],
13+
});
14+
15+
const makeMeasure = (runLength: number): Measure => ({
16+
kind: 'paragraph',
17+
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: runLength, width: 200, ascent: 12, descent: 4, lineHeight: 20 }],
18+
totalHeight: 20,
19+
});
20+
21+
describe('RTL date parity', () => {
22+
it('injects RLM around date separators for rtl date-like text runs', () => {
23+
const blockId = 'rtl-date';
24+
const runText = '23.03.2026';
25+
const block: FlowBlock = {
26+
kind: 'paragraph',
27+
id: blockId,
28+
attrs: { direction: 'rtl' },
29+
runs: [
30+
{
31+
text: runText,
32+
fontFamily: 'David, sans-serif',
33+
fontSize: 16,
34+
bidi: { rtl: true },
35+
pmStart: 1,
36+
pmEnd: 11,
37+
},
38+
],
39+
};
40+
41+
const mount = document.createElement('div');
42+
const painter = createTestPainter({ blocks: [block], measures: [makeMeasure(runText.length)] });
43+
painter.paint(makeLayout(blockId), mount);
44+
45+
const span = mount.querySelector('.superdoc-line span');
46+
expect(span).toBeTruthy();
47+
expect(span?.getAttribute('dir')).toBe('rtl');
48+
expect(span?.textContent).toBe('23\u200F.\u200F03\u200F.\u200F2026');
49+
});
50+
51+
it('forces ltr direction for non-rtl date-like text runs', () => {
52+
const blockId = 'ltr-date';
53+
const runText = '-03-23';
54+
const block: FlowBlock = {
55+
kind: 'paragraph',
56+
id: blockId,
57+
attrs: { direction: 'rtl' },
58+
runs: [{ text: runText, fontFamily: 'David, sans-serif', fontSize: 16, pmStart: 1, pmEnd: 7 }],
59+
};
60+
61+
const mount = document.createElement('div');
62+
const painter = createTestPainter({ blocks: [block], measures: [makeMeasure(runText.length)] });
63+
painter.paint(makeLayout(blockId), mount);
64+
65+
const span = mount.querySelector('.superdoc-line span');
66+
expect(span).toBeTruthy();
67+
expect(span?.getAttribute('dir')).toBe('ltr');
68+
expect(span?.textContent).toBe(runText);
69+
});
70+
71+
// SD-3098: mixed runs on the same line - the bidiCompatible merge guard keeps
72+
// them as separate spans, so each can carry its own dir attribute.
73+
it('paints mixed rtl + ltr runs on the same line as separate spans with distinct dir attrs', () => {
74+
const blockId = 'mixed';
75+
const ltrText = '-03-23';
76+
const rtlText = '2026';
77+
const totalLen = ltrText.length + rtlText.length;
78+
const block: FlowBlock = {
79+
kind: 'paragraph',
80+
id: blockId,
81+
attrs: { direction: 'rtl' },
82+
runs: [
83+
{ text: ltrText, fontFamily: 'David, sans-serif', fontSize: 16, pmStart: 1, pmEnd: 7 },
84+
{ text: rtlText, fontFamily: 'David, sans-serif', fontSize: 16, bidi: { rtl: true }, pmStart: 7, pmEnd: 11 },
85+
],
86+
};
87+
88+
const measure = {
89+
kind: 'paragraph' as const,
90+
lines: [
91+
{
92+
fromRun: 0,
93+
fromChar: 0,
94+
toRun: 1,
95+
toChar: rtlText.length,
96+
width: 200,
97+
ascent: 12,
98+
descent: 4,
99+
lineHeight: 20,
100+
},
101+
],
102+
totalHeight: 20,
103+
};
104+
105+
const mount = document.createElement('div');
106+
const painter = createTestPainter({ blocks: [block], measures: [measure] });
107+
painter.paint(makeLayout(blockId), mount);
108+
109+
const spans = mount.querySelectorAll('.superdoc-line span');
110+
expect(spans.length).toBe(2);
111+
expect(spans[0].getAttribute('dir')).toBe('ltr');
112+
expect(spans[0].textContent).toBe(ltrText);
113+
expect(spans[1].getAttribute('dir')).toBe('rtl');
114+
expect(spans[1].textContent).toBe(rtlText);
115+
});
116+
117+
// SD-3098: rtl-tagged runs that are NOT date-like keep dir="rtl" but get no
118+
// RLM injection. Plain integers (`2026`) don't match the date regex.
119+
it('does not inject RLM into rtl runs whose text is not date-like', () => {
120+
const blockId = 'rtl-numeric';
121+
const runText = '2026';
122+
const block: FlowBlock = {
123+
kind: 'paragraph',
124+
id: blockId,
125+
attrs: { direction: 'rtl' },
126+
runs: [
127+
{ text: runText, fontFamily: 'David, sans-serif', fontSize: 16, bidi: { rtl: true }, pmStart: 1, pmEnd: 5 },
128+
],
129+
};
130+
131+
const mount = document.createElement('div');
132+
const painter = createTestPainter({ blocks: [block], measures: [makeMeasure(runText.length)] });
133+
painter.paint(makeLayout(blockId), mount);
134+
135+
const span = mount.querySelector('.superdoc-line span');
136+
expect(span?.getAttribute('dir')).toBe('rtl');
137+
expect(span?.textContent).toBe(runText);
138+
expect(span?.textContent).not.toContain('\u200F');
139+
});
140+
141+
// SD-3098: non-rtl plain text in RTL paragraphs must NOT get dir="ltr"
142+
// (only date-like non-rtl runs get the LTR force). Otherwise we'd override
143+
// browser bidi everywhere and break legitimate Hebrew/Arabic-only paragraphs.
144+
it('leaves non-rtl plain text runs without a dir attribute', () => {
145+
const blockId = 'plain';
146+
const runText = 'Hello world';
147+
const block: FlowBlock = {
148+
kind: 'paragraph',
149+
id: blockId,
150+
attrs: { direction: 'rtl' },
151+
runs: [{ text: runText, fontFamily: 'David, sans-serif', fontSize: 16, pmStart: 1, pmEnd: 12 }],
152+
};
153+
154+
const mount = document.createElement('div');
155+
const painter = createTestPainter({ blocks: [block], measures: [makeMeasure(runText.length)] });
156+
painter.paint(makeLayout(blockId), mount);
157+
158+
const span = mount.querySelector('.superdoc-line span');
159+
expect(span?.getAttribute('dir')).toBeNull();
160+
expect(span?.textContent).toBe(runText);
161+
});
162+
});

0 commit comments

Comments
 (0)