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-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/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..5ef1853134 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/rtl-date-parity.test.ts @@ -0,0 +1,162 @@ +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); + }); + + // 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/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 0000000000..5d2181475f Binary files /dev/null and b/tests/behavior/tests/formatting/fixtures/rtl-dates.docx differ 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 0000000000..8983fba753 Binary files /dev/null and b/tests/behavior/tests/formatting/fixtures/rtl-mixed-run-line.docx differ 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(); +}); diff --git a/tests/behavior/tests/formatting/rtl-mixed-run-line.spec.ts b/tests/behavior/tests/formatting/rtl-mixed-run-line.spec.ts new file mode 100644 index 0000000000..7e0c1d98fb --- /dev/null +++ b/tests/behavior/tests/formatting/rtl-mixed-run-line.spec.ts @@ -0,0 +1,28 @@ +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-mixed-run-line.docx'); + +// SD-3098 negative test: Hebrew + date + Hebrew on one line, where Word +// only marks the Hebrew runs with . 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); +});