From 9afe8bcf27744f1d29b29f8a94ed314f995eea93 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 12 May 2026 15:52:37 -0300 Subject: [PATCH 1/2] 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 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 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. --- .../layout-bridge/src/run-visual-marks.ts | 6 ++ .../layout-resolved/src/versionSignature.ts | 5 ++ .../painters/dom/src/renderer.ts | 29 +++++++- .../painters/dom/src/rtl-date-parity.test.ts | 70 ++++++++++++++++++ .../src/converters/paragraph.test.ts | 53 +++++++++++++ .../pm-adapter/src/converters/paragraph.ts | 17 ++++- .../tests/formatting/fixtures/rtl-dates.docx | Bin 0 -> 18262 bytes .../formatting/rtl-dates-word-parity.spec.ts | 27 +++++++ 8 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/rtl-date-parity.test.ts create mode 100644 tests/behavior/tests/formatting/fixtures/rtl-dates.docx create mode 100644 tests/behavior/tests/formatting/rtl-dates-word-parity.spec.ts diff --git a/packages/layout-engine/layout-bridge/src/run-visual-marks.ts b/packages/layout-engine/layout-bridge/src/run-visual-marks.ts index 08b60ce75d..235209e1df 100644 --- a/packages/layout-engine/layout-bridge/src/run-visual-marks.ts +++ b/packages/layout-engine/layout-bridge/src/run-visual-marks.ts @@ -20,6 +20,11 @@ export const hashRunVisualMarks = (run: Run): string => { const fontFamily = 'fontFamily' in run ? run.fontFamily : undefined; const highlight = 'highlight' in run ? run.highlight : undefined; const link = 'link' in run ? run.link : undefined; + // SD-3098: DomPainter now reads `bidi.rtl` to apply dir="rtl"/dir="ltr" and the + // RLM separator injection for date-like tokens. Include it here so dirty-run + // detection picks up rtl-only changes; otherwise an edit that flips just + // could reuse stale measure/DOM. + const bidi = 'bidi' in run ? run.bidi : undefined; return [ bold ? 'b' : '', @@ -31,5 +36,6 @@ export const hashRunVisualMarks = (run: Run): string => { fontFamily ? `ff:${fontFamily}` : '', highlight ? `hl:${highlight}` : '', link ? `ln:${JSON.stringify(link)}` : '', + bidi ? `bd:${JSON.stringify(bidi)}` : '', ].join(''); }; diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index dffd2eb19e..522c0f8924 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -271,6 +271,8 @@ export const deriveBlockVersion = (block: FlowBlock): string => { textRun.token ?? '', textRun.trackedChange ? 1 : 0, textRun.comments?.length ?? 0, + // SD-3098: DomPainter reads run.bidi to apply dir + RLM injection; signature must include it. + textRun.bidi ? JSON.stringify(textRun.bidi) : '', ].join(','); }) .join('|'); @@ -459,6 +461,9 @@ export const deriveBlockVersion = (block: FlowBlock): string => { hash = hashString(hash, getRunBooleanProp(run, 'strike') ? '1' : ''); hash = hashString(hash, getRunStringProp(run, 'vertAlign')); hash = hashNumber(hash, getRunNumberProp(run, 'baselineShift')); + // SD-3098: include run.bidi so rtl-only changes invalidate the cached block hash. + const bidi = (run as { bidi?: unknown }).bidi; + hash = hashString(hash, bidi ? JSON.stringify(bidi) : ''); } } } diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 407565773a..e780ebc710 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -5567,7 +5567,10 @@ export class DomPainter { const isActiveLink = !!(linkData && !linkData.blocked && linkData.href); const elem = isActiveLink ? this.doc.createElement('a') : this.doc.createElement('span'); const text = resolveRunText(run, context); - this.setTextContentWithFormattingSpaceMarks(elem, text); + const textRun = run as TextRun; + const effectiveText = + textRun.bidi?.rtl === true && typeof text === 'string' ? normalizeRtlDateTokenForWordParity(text) : text; + this.setTextContentWithFormattingSpaceMarks(elem, effectiveText); if (linkData?.dataset) { applyLinkDataset(elem, linkData.dataset); @@ -5593,7 +5596,13 @@ export class DomPainter { // Pass isLink flag to skip applying inline color/decoration styles for links applyRunStyles(elem as HTMLElement, run, isActiveLink); - const textRun = run as TextRun; + // SD-3098 Word-parity: rtl-tagged runs get dir="rtl" so per-run bidi is isolated; + // non-rtl date-like runs in RTL context get dir="ltr" to prevent separator drift. + if (textRun.bidi?.rtl === true) { + elem.setAttribute('dir', 'rtl'); + } else if (typeof textRun.text === 'string' && RTL_DATE_LIKE_TOKEN_RE.test(textRun.text)) { + elem.setAttribute('dir', 'ltr'); + } const commentAnnotations = textRun.comments; const hasAnyComment = !!commentAnnotations?.length; // Comment highlight styles are applied post-paint by CommentHighlightDecorator (super-editor). @@ -6352,6 +6361,7 @@ export class DomPainter { link: run.link ?? null, comments: run.comments ?? null, dataAttrs: stableDataAttrs(run.dataAttrs) ?? null, + bidi: run.bidi ?? null, }); const isWhitespaceOnly = (text: string): boolean => { @@ -8266,3 +8276,18 @@ const resolveRunText = (run: Run, context: FragmentRenderContext): string => { } return run.text ?? ''; }; + +const RTL_DATE_LIKE_TOKEN_RE = /^-?\d+(?:[./-]\d+)+$/; +const RLM = '\u200F'; + +// AIDEV-NOTE: SD-3098 Word-parity workaround for RTL date-like tokens. We inject +// RLM around separators at paint time only (DOM text), never into PM/model/export. +// Word reorders numerics inside RTL date strings via internal RLM treatment; the +// browser's UBA does not. This is intentionally narrow - only matches date-like +// numeric patterns - so non-date numeric content is unaffected. +const normalizeRtlDateTokenForWordParity = (text: string): string => { + if (!RTL_DATE_LIKE_TOKEN_RE.test(text)) { + return text; + } + return text.replace(/[./-]/g, (separator) => `${RLM}${separator}${RLM}`); +}; diff --git a/packages/layout-engine/painters/dom/src/rtl-date-parity.test.ts b/packages/layout-engine/painters/dom/src/rtl-date-parity.test.ts new file mode 100644 index 0000000000..ed83160c24 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/rtl-date-parity.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import type { FlowBlock, Layout, Measure } from '@superdoc/contracts'; +import { createTestPainter } from './_test-utils.js'; + +const makeLayout = (blockId: string): Layout => ({ + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId, fromLine: 0, toLine: 1, x: 20, y: 20, width: 300 }], + }, + ], +}); + +const makeMeasure = (runLength: number): Measure => ({ + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: runLength, width: 200, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, +}); + +describe('RTL date parity', () => { + it('injects RLM around date separators for rtl date-like text runs', () => { + const blockId = 'rtl-date'; + const runText = '23.03.2026'; + const block: FlowBlock = { + kind: 'paragraph', + id: blockId, + attrs: { direction: 'rtl' }, + runs: [ + { + text: runText, + fontFamily: 'David, sans-serif', + fontSize: 16, + bidi: { rtl: true }, + pmStart: 1, + pmEnd: 11, + }, + ], + }; + + const mount = document.createElement('div'); + const painter = createTestPainter({ blocks: [block], measures: [makeMeasure(runText.length)] }); + painter.paint(makeLayout(blockId), mount); + + const span = mount.querySelector('.superdoc-line span'); + expect(span).toBeTruthy(); + expect(span?.getAttribute('dir')).toBe('rtl'); + expect(span?.textContent).toBe('23\u200F.\u200F03\u200F.\u200F2026'); + }); + + it('forces ltr direction for non-rtl date-like text runs', () => { + const blockId = 'ltr-date'; + const runText = '-03-23'; + const block: FlowBlock = { + kind: 'paragraph', + id: blockId, + attrs: { direction: 'rtl' }, + runs: [{ text: runText, fontFamily: 'David, sans-serif', fontSize: 16, pmStart: 1, pmEnd: 7 }], + }; + + const mount = document.createElement('div'); + const painter = createTestPainter({ blocks: [block], measures: [makeMeasure(runText.length)] }); + painter.paint(makeLayout(blockId), mount); + + const span = mount.querySelector('.superdoc-line span'); + expect(span).toBeTruthy(); + expect(span?.getAttribute('dir')).toBe('ltr'); + expect(span?.textContent).toBe(runText); + }); +}); diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts index 68045b9c54..78d0862571 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts @@ -852,6 +852,59 @@ describe('paragraph converters', () => { expect(result[0].text).toBe('world'); }); + // SD-3098: a w:rtl run adjacent to a plain run must NOT merge, otherwise the + // merged result keeps only the first run's bidi field and the DomPainter + // dir="rtl" + RLM injection paint-time fix is lost. + it('should not merge ltr run with following rtl run', () => { + const run1: TextRun = { + text: '-03-23', + fontFamily: 'Arial', + fontSize: 16, + pmStart: 0, + pmEnd: 6, + }; + const run2: TextRun = { + text: '2026', + fontFamily: 'Arial', + fontSize: 16, + bidi: { rtl: true }, + pmStart: 6, + pmEnd: 10, + }; + + vi.mocked(trackedChangesCompatible).mockReturnValue(true); + + const result = mergeAdjacentRuns([run1, run2]); + expect(result).toHaveLength(2); + expect((result[0] as TextRun).bidi).toBeUndefined(); + expect((result[1] as TextRun).bidi).toEqual({ rtl: true }); + }); + + it('should not merge rtl run with following ltr run', () => { + const run1: TextRun = { + text: '2026', + fontFamily: 'Arial', + fontSize: 16, + bidi: { rtl: true }, + pmStart: 0, + pmEnd: 4, + }; + const run2: TextRun = { + text: '-03-23', + fontFamily: 'Arial', + fontSize: 16, + pmStart: 4, + pmEnd: 10, + }; + + vi.mocked(trackedChangesCompatible).mockReturnValue(true); + + const result = mergeAdjacentRuns([run1, run2]); + expect(result).toHaveLength(2); + expect((result[0] as TextRun).bidi).toEqual({ rtl: true }); + expect((result[1] as TextRun).bidi).toBeUndefined(); + }); + it('should handle long sequences of runs efficiently', () => { const runs: TextRun[] = []; for (let i = 0; i < 100; i++) { diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts index 83fca1ee5a..a3f5d964e8 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -169,6 +169,20 @@ export const commentsCompatible = (a: TextRun, b: TextRun): boolean => { return true; }; +/** + * SD-3098: Two adjacent text runs can only merge when their RunBidiContext matches. + * A `` run merged with a plain run would lose the rtl flag (and with it the + * DomPainter `dir="rtl"` + RLM injection paint-time fix). The merge result keeps the + * first run's fields, so we must reject the merge when bidi differs. + */ +const bidiCompatible = (a: TextRun, b: TextRun): boolean => { + const aBidi = a.bidi; + const bBidi = b.bidi; + if (!aBidi && !bBidi) return true; + if (!aBidi || !bBidi) return false; + return aBidi.rtl === bBidi.rtl && aBidi.embedding === bBidi.embedding && aBidi.override === bBidi.override; +}; + /** * Merges adjacent text runs with continuous PM positions and compatible styling. * Optimization to reduce run fragmentation after PM operations. @@ -211,7 +225,8 @@ export function mergeAdjacentRuns(runs: Run[]): Run[] { (current.letterSpacing ?? 0) === (next.letterSpacing ?? 0) && trackedChangesCompatible(current, next) && dataAttrsCompatible(current, next) && - commentsCompatible(current, next); + commentsCompatible(current, next) && + bidiCompatible(current, next); if (canMerge) { // Merge next into current diff --git a/tests/behavior/tests/formatting/fixtures/rtl-dates.docx b/tests/behavior/tests/formatting/fixtures/rtl-dates.docx new file mode 100644 index 0000000000000000000000000000000000000000..5d2181475f44cf76de362bd029b9d2e1a7358ef1 GIT binary patch literal 18262 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
oM{0xu0mx}Ds^ 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 literal 0 HcmV?d00001 diff --git a/tests/behavior/tests/formatting/rtl-dates-word-parity.spec.ts b/tests/behavior/tests/formatting/rtl-dates-word-parity.spec.ts new file mode 100644 index 0000000000..fb0dc361b5 --- /dev/null +++ b/tests/behavior/tests/formatting/rtl-dates-word-parity.spec.ts @@ -0,0 +1,27 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve(__dirname, 'fixtures/rtl-dates.docx'); + +test('rtl dates render in the same visual order as Word', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const headerRuns = superdoc.page.locator('.superdoc-page-header .superdoc-line span'); + await expect(headerRuns.last()).toHaveAttribute('dir', 'rtl'); + const headerText = await headerRuns.last().evaluate((el) => el.textContent ?? ''); + expect(headerText.includes('\u200F/\u200F')).toBe(true); + + const bodyDateRuns = superdoc.page + .locator('.superdoc-page .superdoc-fragment .superdoc-line span') + .filter({ hasText: '-03-23' }); + await expect(bodyDateRuns.first()).toHaveAttribute('dir', 'ltr'); + + const bodyRtlNumericRun = superdoc.page + .locator('.superdoc-page .superdoc-fragment .superdoc-line span[dir="rtl"]') + .filter({ hasText: '2026' }) + .first(); + await expect(bodyRtlNumericRun).toBeVisible(); +}); From 9c490e6062548473c464314f9edec6988bbd8e52 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 12 May 2026 16:20:11 -0300 Subject: [PATCH 2/2] 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 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). --- .../test/run-visual-marks.test.ts | 35 +++++++ .../src/versionSignature.test.ts | 42 +++++++- .../painters/dom/src/rtl-date-parity.test.ts | 92 ++++++++++++++++++ .../fixtures/rtl-mixed-run-line.docx | Bin 0 -> 13576 bytes .../formatting/rtl-mixed-run-line.spec.ts | 28 ++++++ 5 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 tests/behavior/tests/formatting/fixtures/rtl-mixed-run-line.docx create mode 100644 tests/behavior/tests/formatting/rtl-mixed-run-line.spec.ts diff --git a/packages/layout-engine/layout-bridge/test/run-visual-marks.test.ts b/packages/layout-engine/layout-bridge/test/run-visual-marks.test.ts index e08ee1e58c..58ff4b6a96 100644 --- a/packages/layout-engine/layout-bridge/test/run-visual-marks.test.ts +++ b/packages/layout-engine/layout-bridge/test/run-visual-marks.test.ts @@ -103,4 +103,39 @@ describe('hashRunVisualMarks', () => { expect(hashRunVisualMarks(a)).toBe(hashRunVisualMarks(b)); }); + + // SD-3098: DomPainter applies dir="rtl" + RLM injection based on run.bidi.rtl, + // so the dirty-run hash must change when bidi changes, otherwise an edit that + // flips just reuses the stale measure/DOM. + describe('bidi (SD-3098)', () => { + const base = { + text: '23.03.2026', + fontFamily: 'David, sans-serif', + fontSize: 16, + } as Run; + + it('produces a different hash when bidi.rtl is set vs absent', () => { + const hashPlain = hashRunVisualMarks(base); + const hashRtl = hashRunVisualMarks({ ...base, bidi: { rtl: true } } as Run); + expect(hashRtl).not.toBe(hashPlain); + }); + + it('produces a different hash for bidi.rtl=true vs bidi.rtl=false', () => { + const hashTrue = hashRunVisualMarks({ ...base, bidi: { rtl: true } } as Run); + const hashFalse = hashRunVisualMarks({ ...base, bidi: { rtl: false } } as Run); + expect(hashTrue).not.toBe(hashFalse); + }); + + it('produces a different hash when only bidi.embedding changes', () => { + const hashLtr = hashRunVisualMarks({ ...base, bidi: { rtl: false, embedding: 'ltr' } } as Run); + const hashRtlEmbed = hashRunVisualMarks({ ...base, bidi: { rtl: false, embedding: 'rtl' } } as Run); + expect(hashRtlEmbed).not.toBe(hashLtr); + }); + + it('is stable for identical bidi shapes', () => { + const a = hashRunVisualMarks({ ...base, bidi: { rtl: true } } as Run); + const b = hashRunVisualMarks({ ...base, bidi: { rtl: true } } as Run); + expect(a).toBe(b); + }); + }); }); diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts index 425b3f0df5..f5ba0ede5d 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { sourceAnchorSignature } from './versionSignature.js'; -import type { SourceAnchor } from '@superdoc/contracts'; +import { deriveBlockVersion, sourceAnchorSignature } from './versionSignature.js'; +import type { FlowBlock, SourceAnchor, TextRun } from '@superdoc/contracts'; describe('sourceAnchorSignature', () => { it('is stable for equivalent source anchors with different object key order', () => { @@ -28,3 +28,41 @@ describe('sourceAnchorSignature', () => { expect(sourceAnchorSignature(anchorA)).toBe(sourceAnchorSignature(anchorB)); }); }); + +describe('deriveBlockVersion - bidi', () => { + const makeParagraph = (bidi?: TextRun['bidi']): FlowBlock => ({ + kind: 'paragraph', + id: 'p1', + attrs: { direction: 'rtl' }, + runs: [ + { + text: '23.03.2026', + fontFamily: 'David, sans-serif', + fontSize: 16, + pmStart: 1, + pmEnd: 11, + ...(bidi ? { bidi } : {}), + } as TextRun, + ], + }); + + // SD-3098: flipping only run.bidi must invalidate the cached block hash, + // otherwise an edit that toggles reuses stale DOM in DomPainter. + it('produces a different version when bidi.rtl is added', () => { + const versionPlain = deriveBlockVersion(makeParagraph()); + const versionRtl = deriveBlockVersion(makeParagraph({ rtl: true })); + expect(versionRtl).not.toBe(versionPlain); + }); + + it('produces a different version for bidi.rtl=true vs bidi.rtl=false', () => { + const versionTrue = deriveBlockVersion(makeParagraph({ rtl: true })); + const versionFalse = deriveBlockVersion(makeParagraph({ rtl: false })); + expect(versionTrue).not.toBe(versionFalse); + }); + + it('is stable when bidi is identical', () => { + const a = deriveBlockVersion(makeParagraph({ rtl: true })); + const b = deriveBlockVersion(makeParagraph({ rtl: true })); + expect(a).toBe(b); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/rtl-date-parity.test.ts b/packages/layout-engine/painters/dom/src/rtl-date-parity.test.ts index ed83160c24..5ef1853134 100644 --- a/packages/layout-engine/painters/dom/src/rtl-date-parity.test.ts +++ b/packages/layout-engine/painters/dom/src/rtl-date-parity.test.ts @@ -67,4 +67,96 @@ describe('RTL date parity', () => { expect(span?.getAttribute('dir')).toBe('ltr'); expect(span?.textContent).toBe(runText); }); + + // SD-3098: mixed runs on the same line - the bidiCompatible merge guard keeps + // them as separate spans, so each can carry its own dir attribute. + it('paints mixed rtl + ltr runs on the same line as separate spans with distinct dir attrs', () => { + const blockId = 'mixed'; + const ltrText = '-03-23'; + const rtlText = '2026'; + const totalLen = ltrText.length + rtlText.length; + const block: FlowBlock = { + kind: 'paragraph', + id: blockId, + attrs: { direction: 'rtl' }, + runs: [ + { text: ltrText, fontFamily: 'David, sans-serif', fontSize: 16, pmStart: 1, pmEnd: 7 }, + { text: rtlText, fontFamily: 'David, sans-serif', fontSize: 16, bidi: { rtl: true }, pmStart: 7, pmEnd: 11 }, + ], + }; + + const measure = { + kind: 'paragraph' as const, + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 1, + toChar: rtlText.length, + width: 200, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const mount = document.createElement('div'); + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(makeLayout(blockId), mount); + + const spans = mount.querySelectorAll('.superdoc-line span'); + expect(spans.length).toBe(2); + expect(spans[0].getAttribute('dir')).toBe('ltr'); + expect(spans[0].textContent).toBe(ltrText); + expect(spans[1].getAttribute('dir')).toBe('rtl'); + expect(spans[1].textContent).toBe(rtlText); + }); + + // SD-3098: rtl-tagged runs that are NOT date-like keep dir="rtl" but get no + // RLM injection. Plain integers (`2026`) don't match the date regex. + it('does not inject RLM into rtl runs whose text is not date-like', () => { + const blockId = 'rtl-numeric'; + const runText = '2026'; + const block: FlowBlock = { + kind: 'paragraph', + id: blockId, + attrs: { direction: 'rtl' }, + runs: [ + { text: runText, fontFamily: 'David, sans-serif', fontSize: 16, bidi: { rtl: true }, pmStart: 1, pmEnd: 5 }, + ], + }; + + const mount = document.createElement('div'); + const painter = createTestPainter({ blocks: [block], measures: [makeMeasure(runText.length)] }); + painter.paint(makeLayout(blockId), mount); + + const span = mount.querySelector('.superdoc-line span'); + expect(span?.getAttribute('dir')).toBe('rtl'); + expect(span?.textContent).toBe(runText); + expect(span?.textContent).not.toContain('\u200F'); + }); + + // SD-3098: non-rtl plain text in RTL paragraphs must NOT get dir="ltr" + // (only date-like non-rtl runs get the LTR force). Otherwise we'd override + // browser bidi everywhere and break legitimate Hebrew/Arabic-only paragraphs. + it('leaves non-rtl plain text runs without a dir attribute', () => { + const blockId = 'plain'; + const runText = 'Hello world'; + const block: FlowBlock = { + kind: 'paragraph', + id: blockId, + attrs: { direction: 'rtl' }, + runs: [{ text: runText, fontFamily: 'David, sans-serif', fontSize: 16, pmStart: 1, pmEnd: 12 }], + }; + + const mount = document.createElement('div'); + const painter = createTestPainter({ blocks: [block], measures: [makeMeasure(runText.length)] }); + painter.paint(makeLayout(blockId), mount); + + const span = mount.querySelector('.superdoc-line span'); + expect(span?.getAttribute('dir')).toBeNull(); + expect(span?.textContent).toBe(runText); + }); }); diff --git a/tests/behavior/tests/formatting/fixtures/rtl-mixed-run-line.docx b/tests/behavior/tests/formatting/fixtures/rtl-mixed-run-line.docx new file mode 100644 index 0000000000000000000000000000000000000000..8983fba753f533aa1a5edf11a02b2846a151be33 GIT binary patch literal 13576 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 V1kgNwdzb. The date run has no rtl flag, +// so neither the RLM injection nor the dir="ltr" force should kick in. +// Confirms we don't break standard mixed-language paragraphs. +test('mixed Hebrew + date line keeps Hebrew runs rtl and does not inject RLM into the date', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const lineSpans = superdoc.page.locator('.superdoc-page .superdoc-fragment .superdoc-line span'); + + const hebrewSpans = lineSpans.filter({ hasText: /[\u0590-\u05FF]/ }); + expect(await hebrewSpans.count()).toBeGreaterThan(0); + for (let i = 0; i < (await hebrewSpans.count()); i++) { + await expect(hebrewSpans.nth(i)).toHaveAttribute('dir', 'rtl'); + } + + const dateSpan = lineSpans.filter({ hasText: '23/03/2026' }).first(); + await expect(dateSpan).toBeVisible(); + const dateText = await dateSpan.evaluate((el) => el.textContent ?? ''); + expect(dateText.includes('\u200F')).toBe(false); +});