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);
+});