Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <w:rtl/> could reuse stale measure/DOM.
const bidi = 'bidi' in run ? run.bidi : undefined;

return [
bold ? 'b' : '',
Expand All @@ -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('');
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 <w:rtl/> 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);
});
});
});
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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 <w:rtl/> 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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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('|');
Expand Down Expand Up @@ -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) : '');
}
}
}
Expand Down
29 changes: 27 additions & 2 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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).
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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}`);
};
162 changes: 162 additions & 0 deletions packages/layout-engine/painters/dom/src/rtl-date-parity.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading