From eb977f06e18e98cd20815b22a471d8e9eb82c51b Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 14 May 2026 08:29:55 -0300 Subject: [PATCH 1/6] refactor(direction): centralize last paragraph isRtl reads on helper (SD-2778) Migrate the two remaining direct reads of attrs.direction/.dir on the paragraph inline-direction axis onto getParagraphInlineDirection: - layout-bridge/src/position-hit.ts: isRtlBlock - layout-resolved/src/resolveParagraph.ts: isRtl Behavior is unchanged on the typed directionContext path and strictly broader on fallback (the helper also covers paragraphProperties.rightToLeft). After this, no consumer outside the helper reads the legacy scalar fields; a follow-up can stop pm-adapter from writing them and drop them from ParagraphAttrs. --- .../layout-bridge/src/position-hit.ts | 26 +++++-------------- .../layout-resolved/src/resolveParagraph.ts | 4 +-- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/packages/layout-engine/layout-bridge/src/position-hit.ts b/packages/layout-engine/layout-bridge/src/position-hit.ts index a504642c09..fbcb239695 100644 --- a/packages/layout-engine/layout-bridge/src/position-hit.ts +++ b/packages/layout-engine/layout-bridge/src/position-hit.ts @@ -23,7 +23,12 @@ import type { ParagraphBlock, ParagraphMeasure, } from '@superdoc/contracts'; -import { adjustAvailableWidthForTextIndent, computeLinePmRange, getFirstLineIndentOffset } from '@superdoc/contracts'; +import { + adjustAvailableWidthForTextIndent, + computeLinePmRange, + getFirstLineIndentOffset, + getParagraphInlineDirection, +} from '@superdoc/contracts'; import { charOffsetToPm, findCharacterAtX } from './text-measurement.js'; import type { PageGeometryHelper } from './page-geometry-helper.js'; @@ -120,26 +125,9 @@ export const getAtomicPmRange = (fragment: AtomicFragment, block: FlowBlock): { export const isRtlBlock = (block: FlowBlock): boolean => { if (block.kind !== 'paragraph') return false; - const attrs = block.attrs as Record | undefined; - if (!attrs) return false; - // AIDEV-NOTE: The typed directionContext.inlineDirection (SD-2776) is the source of - // truth for paragraph inline direction. Check the value, not the key — `inlineDirection` - // can be `undefined` per the resolver contract (no explicit w:bidi anywhere in the - // cascade), and we should fall through to the legacy field in that case. // Do NOT consult attrs.textDirection here: that's writing-mode (ECMA §17.18.93, // values lrTb/tbRl/btLr/lrTbV/tbRlV/tbLrV) which is a separate axis from inline RTL. - const directionContext = attrs.directionContext as { inlineDirection?: string } | undefined; - if (directionContext?.inlineDirection != null) { - return directionContext.inlineDirection === 'rtl'; - } - // AIDEV-NOTE: compat-fallback — `attrs.direction` / `attrs.dir` are the legacy scalar - // duplicates of `directionContext.inlineDirection`. Retire once SD-2778 collapses - // them on `ParagraphAttrs`. - const directionAttr = attrs.direction ?? attrs.dir; - if (typeof directionAttr === 'string' && directionAttr.toLowerCase() === 'rtl') { - return true; - } - return false; + return getParagraphInlineDirection(block.attrs) === 'rtl'; }; export const determineColumn = (layout: Layout, fragmentX: number): number => { diff --git a/packages/layout-engine/layout-resolved/src/resolveParagraph.ts b/packages/layout-engine/layout-resolved/src/resolveParagraph.ts index 60b005e037..de829837d8 100644 --- a/packages/layout-engine/layout-resolved/src/resolveParagraph.ts +++ b/packages/layout-engine/layout-resolved/src/resolveParagraph.ts @@ -9,7 +9,7 @@ import type { ResolvedDropCapItem, ResolvedListMarkerItem, } from '@superdoc/contracts'; -import { adjustAvailableWidthForTextIndent } from '@superdoc/contracts'; +import { adjustAvailableWidthForTextIndent, getParagraphInlineDirection } from '@superdoc/contracts'; import { isMinimalWordLayout, resolveListMarkerGeometry, @@ -93,7 +93,7 @@ export function resolveParagraphContent( const paraIndent = (block.attrs as ParagraphAttrs | undefined)?.indent; const paraIndentLeft = paraIndent?.left ?? 0; const paraIndentRight = paraIndent?.right ?? 0; - const isRtl = (block.attrs as ParagraphAttrs | undefined)?.direction === 'rtl'; + const isRtl = getParagraphInlineDirection(block.attrs) === 'rtl'; const { anchorIndentPx: paraMarkerAnchorIndent, firstLinePx: markerFirstLine, From 2699e3ae8b341ea201e3d088be73d9c83283204b Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 14 May 2026 09:05:10 -0300 Subject: [PATCH 2/6] test(direction): pin SD-2778 migration via typed-path + broader-fallback cases Two coverage gaps caught in review: - layout-resolved/src/resolveLayout.test.ts ("preserves increasing first-line marker anchor for nested RTL list levels") used attrs.direction: 'rtl'. The pre-migration code read attrs.direction directly, so that fixture would have passed against the old implementation. Switch to directionContext.inlineDirection so the test only passes through the new helper-driven typed path. - layout-bridge/test/position-hit.test.ts: switching isRtlBlock to getParagraphInlineDirection is strictly broader on fallback (the helper also picks up paragraphProperties.rightToLeft when no directionContext is present). Pin that case so the broadening is intentional and not a regression vector. --- .../layout-bridge/test/position-hit.test.ts | 9 +++++++++ .../layout-resolved/src/resolveLayout.test.ts | 7 ++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/layout-bridge/test/position-hit.test.ts b/packages/layout-engine/layout-bridge/test/position-hit.test.ts index 1eb0210384..eddb24e2fb 100644 --- a/packages/layout-engine/layout-bridge/test/position-hit.test.ts +++ b/packages/layout-engine/layout-bridge/test/position-hit.test.ts @@ -61,4 +61,13 @@ describe('isRtlBlock', () => { ), ).toBe(true); }); + + // SD-2778: switching to getParagraphInlineDirection is strictly broader on + // fallback than the prior inline read. Specifically, the helper picks up + // paragraphProperties.rightToLeft when neither directionContext nor the legacy + // scalar field is present. Pin that case so the broader fallback is intentional. + it('falls back to paragraphProperties.rightToLeft when no other direction signal is present', () => { + expect(isRtlBlock(paragraph({ paragraphProperties: { rightToLeft: true } }))).toBe(true); + expect(isRtlBlock(paragraph({ paragraphProperties: { rightToLeft: false } }))).toBe(false); + }); }); diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts index 525329512c..dc8f46a19e 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts @@ -1777,7 +1777,12 @@ describe('resolveLayout', () => { id, runs: [{ kind: 'text', text: 'RTL list item' }], attrs: { - direction: 'rtl', + // SD-2778: use directionContext so this test only passes through the + // new helper-driven typed path. The pre-migration code read + // attrs.direction directly, so the prior `direction: 'rtl'` fixture + // would have passed against the old implementation too and didn't + // actually prove the migration. + directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' }, indent: { right, hanging: -24 }, wordLayout: { marker: { From edd24a63f18bb51557a80ce9fb8456eed4c35774 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 14 May 2026 08:50:04 -0300 Subject: [PATCH 3/6] refactor(direction): drop ParagraphAttrs.direction scalar field (SD-2778) After #3289 centralized every paragraph isRtl read on getParagraphInlineDirection, the scalar attrs.direction field has no remaining consumers. Drop it from the producer and the type: - pm-adapter no longer writes the conditional direction spread on ParagraphAttrs. directionContext.inlineDirection is the only source. - contracts/index.ts: remove direction?: 'ltr' | 'rtl' from ParagraphAttrs and point the directionContext doc at the helper. - contracts/direction-context.ts: tighten getParagraphInlineDirection's signature and body to drop the attrs.direction/.dir/.rtl fallbacks. paragraphProperties.rightToLeft fallback stays for PM-node / editor paths that read direction off the raw OOXML properties. Tests that asserted on attrs.direction or constructed hand-rolled FlowBlocks with { direction: 'rtl' } now use directionContext.inlineDirection. Expect additive layout JSON snapshot drift once the direction field disappears from paragraph attrs in serialized layouts. Tests: contracts 229, pm-adapter 1838, layout-bridge 1210, layout-resolved 118, painter-dom 1070, super-editor 12836, style-engine 129 - all green. --- .../contracts/src/direction-context.test.ts | 27 +++++-------------- .../contracts/src/direction-context.ts | 19 ++++--------- packages/layout-engine/contracts/src/index.ts | 13 +++------ .../layout-bridge/test/cache.test.ts | 8 ++++-- .../layout-bridge/test/position-hit.test.ts | 14 ++++------ .../painters/dom/src/formatting-marks.test.ts | 6 ++++- .../painters/dom/src/index.test.ts | 8 +++--- .../src/attributes/paragraph.test.ts | 19 +++++-------- .../pm-adapter/src/attributes/paragraph.ts | 1 - .../pm-adapter/src/index.test.ts | 18 ++++++------- .../tests/CaretGeometry.test.ts | 8 +++--- 11 files changed, 56 insertions(+), 85 deletions(-) diff --git a/packages/layout-engine/contracts/src/direction-context.test.ts b/packages/layout-engine/contracts/src/direction-context.test.ts index 8517d07e2e..34efd70a58 100644 --- a/packages/layout-engine/contracts/src/direction-context.test.ts +++ b/packages/layout-engine/contracts/src/direction-context.test.ts @@ -7,37 +7,24 @@ describe('getParagraphInlineDirection', () => { expect(getParagraphInlineDirection(null)).toBeUndefined(); }); - it('prefers directionContext.inlineDirection over legacy fields', () => { + it('prefers directionContext.inlineDirection over paragraphProperties.rightToLeft', () => { const attrs = { directionContext: { inlineDirection: 'rtl' as const }, - direction: 'ltr', - rtl: false, + paragraphProperties: { rightToLeft: false }, }; expect(getParagraphInlineDirection(attrs)).toBe('rtl'); }); it('falls back past directionContext when inlineDirection is null', () => { // Per resolver semantics, inlineDirection=null/undefined means "no explicit - // w:bidi"; the legacy `direction` field is the next fallback in the chain. - const attrs = { directionContext: { inlineDirection: null }, direction: 'rtl' }; + // w:bidi"; paragraphProperties.rightToLeft is the PM-node/editor fallback. + const attrs = { + directionContext: { inlineDirection: null }, + paragraphProperties: { rightToLeft: true }, + }; expect(getParagraphInlineDirection(attrs)).toBe('rtl'); }); - it('falls back to attrs.direction', () => { - expect(getParagraphInlineDirection({ direction: 'rtl' })).toBe('rtl'); - expect(getParagraphInlineDirection({ direction: 'ltr' })).toBe('ltr'); - }); - - it('falls back to attrs.dir', () => { - expect(getParagraphInlineDirection({ dir: 'rtl' })).toBe('rtl'); - expect(getParagraphInlineDirection({ dir: 'ltr' })).toBe('ltr'); - }); - - it('falls back to attrs.rtl boolean', () => { - expect(getParagraphInlineDirection({ rtl: true })).toBe('rtl'); - expect(getParagraphInlineDirection({ rtl: false })).toBe('ltr'); - }); - it('falls back to paragraphProperties.rightToLeft', () => { expect(getParagraphInlineDirection({ paragraphProperties: { rightToLeft: true } })).toBe('rtl'); expect(getParagraphInlineDirection({ paragraphProperties: { rightToLeft: false } })).toBe('ltr'); diff --git a/packages/layout-engine/contracts/src/direction-context.ts b/packages/layout-engine/contracts/src/direction-context.ts index 922d7f8031..b4f956333a 100644 --- a/packages/layout-engine/contracts/src/direction-context.ts +++ b/packages/layout-engine/contracts/src/direction-context.ts @@ -161,9 +161,9 @@ export type RunScriptContext = { * Read a paragraph's inline base direction from its attributes. * * Prefers the resolved {@link ParagraphDirectionContext} (SD-2776) when - * present, then falls back to the legacy scalar fields (`direction`, - * `dir`, `rtl`, `paragraphProperties.rightToLeft`) for compatibility - * until SD-2778 collapses the duplicates. + * present. Falls back to `paragraphProperties.rightToLeft` for PM-node / + * editor paths that store direction on the raw OOXML properties rather + * than the typed direction context. * * Consumers should call this instead of inspecting attrs ad hoc so the * direction source check stays in one place. @@ -172,9 +172,6 @@ export function getParagraphInlineDirection( attrs: | { directionContext?: { inlineDirection?: BaseDirection | null } | null; - direction?: string | null; - dir?: string | null; - rtl?: boolean | null; paragraphProperties?: { rightToLeft?: boolean | null } | null; } | null @@ -182,15 +179,9 @@ export function getParagraphInlineDirection( ): BaseDirection | undefined { const fromContext = attrs?.directionContext?.inlineDirection; if (fromContext != null) return fromContext; - // AIDEV-NOTE: compat-fallback - used when ParagraphAttrs.directionContext.inlineDirection is absent. - // Retire once SD-2778 collapses the duplicate scalar fields onto directionContext. const ppRtl = attrs?.paragraphProperties?.rightToLeft; - if (attrs?.direction === 'rtl' || attrs?.dir === 'rtl' || attrs?.rtl === true || ppRtl === true) { - return 'rtl'; - } - if (attrs?.direction === 'ltr' || attrs?.dir === 'ltr' || attrs?.rtl === false || ppRtl === false) { - return 'ltr'; - } + if (ppRtl === true) return 'rtl'; + if (ppRtl === false) return 'ltr'; return undefined; } diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index f4af770e78..075369404d 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1533,19 +1533,14 @@ export type ParagraphAttrs = { trackedChangesEnabled?: boolean; /** Marks an empty paragraph that only exists to carry section properties. */ sectPrMarker?: boolean; - /** - * Resolved paragraph inline base direction. Populated from `directionContext.inlineDirection` - * during pm-adapter conversion; left undefined when no explicit bidi is set so the browser - * can apply UBA via missing `dir` attribute. - * - * Prefer reading `directionContext` (typed, complete) over this scalar field. The scalar - * remains for backwards compatibility with consumers that only need inline direction. - */ - direction?: 'ltr' | 'rtl'; /** * Resolved direction context for the paragraph (inline direction + writing mode). * Single source of truth for paragraph direction-aware rendering decisions. * + * Read via `getParagraphInlineDirection(attrs)` rather than inspecting this + * field directly so the helper can normalize `null` vs `undefined` and fall + * back to `paragraphProperties.rightToLeft` for PM-node / editor paths. + * * See `@superdoc/contracts/direction-context` for axis semantics. */ directionContext?: ParagraphDirectionContext; diff --git a/packages/layout-engine/layout-bridge/test/cache.test.ts b/packages/layout-engine/layout-bridge/test/cache.test.ts index d8625947dc..169d723543 100644 --- a/packages/layout-engine/layout-bridge/test/cache.test.ts +++ b/packages/layout-engine/layout-bridge/test/cache.test.ts @@ -2024,8 +2024,12 @@ describe('MeasureCache', () => { describe('other paragraph attribute changes', () => { it('invalidates cache when direction changes', () => { - const block1 = paragraphWithAttrs('p1', 'Hello', { direction: 'ltr' }); - const block2 = paragraphWithAttrs('p1', 'Hello', { direction: 'rtl' }); + const block1 = paragraphWithAttrs('p1', 'Hello', { + directionContext: { inlineDirection: 'ltr', writingMode: 'horizontal-tb' }, + }); + const block2 = paragraphWithAttrs('p1', 'Hello', { + directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' }, + }); cache.set(block1, 400, 600, { totalHeight: 20 }); expect(cache.get(block2, 400, 600)).toBeUndefined(); diff --git a/packages/layout-engine/layout-bridge/test/position-hit.test.ts b/packages/layout-engine/layout-bridge/test/position-hit.test.ts index eddb24e2fb..6e16e3e954 100644 --- a/packages/layout-engine/layout-bridge/test/position-hit.test.ts +++ b/packages/layout-engine/layout-bridge/test/position-hit.test.ts @@ -23,19 +23,15 @@ describe('isRtlBlock', () => { ).toBe(true); }); - it('keeps legacy paragraph direction as a fallback', () => { - expect(isRtlBlock(paragraph({ direction: 'rtl' }))).toBe(true); - }); - it('does not treat writing mode as inline RTL direction', () => { expect(isRtlBlock(paragraph({ textDirection: 'tbRl' }))).toBe(false); }); - it('lets resolved direction context override legacy scalar direction', () => { + it('lets resolved direction context override paragraphProperties.rightToLeft', () => { expect( isRtlBlock( paragraph({ - direction: 'rtl', + paragraphProperties: { rightToLeft: true }, directionContext: { inlineDirection: 'ltr', writingMode: 'horizontal-tb', @@ -45,14 +41,14 @@ describe('isRtlBlock', () => { ).toBe(false); }); - it('falls through to legacy direction when directionContext.inlineDirection is undefined', () => { + it('falls through to paragraphProperties.rightToLeft when directionContext.inlineDirection is undefined', () => { // The resolver may produce inlineDirection: undefined when no paragraph w:bidi is set // anywhere in the cascade. In that case the typed context carries no inline-direction - // signal, and the legacy `direction` / `dir` field (if any) should still be honored. + // signal, and the PM-node paragraphProperties.rightToLeft fallback still applies. expect( isRtlBlock( paragraph({ - direction: 'rtl', + paragraphProperties: { rightToLeft: true }, directionContext: { inlineDirection: undefined, writingMode: 'horizontal-tb', diff --git a/packages/layout-engine/painters/dom/src/formatting-marks.test.ts b/packages/layout-engine/painters/dom/src/formatting-marks.test.ts index c7773ec21e..907531c287 100644 --- a/packages/layout-engine/painters/dom/src/formatting-marks.test.ts +++ b/packages/layout-engine/painters/dom/src/formatting-marks.test.ts @@ -159,7 +159,11 @@ describe('DomPainter formatting marks', () => { container.innerHTML = ''; const rtlPainter = createDomPainter({ - blocks: [createParagraphBlock(text, { direction: 'rtl' })], + blocks: [ + createParagraphBlock(text, { + directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' }, + }), + ], measures: [measure], showFormattingMarks: true, }); diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 9c7c3cb49f..828c5fd685 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -5006,7 +5006,7 @@ describe('DomPainter', () => { ], attrs: { alignment: 'center', - direction: 'rtl', + directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' }, }, }; const footerMeasure: Measure = { @@ -6072,7 +6072,7 @@ describe('DomPainter', () => { kind: 'paragraph', id: 'resolved-rtl-marker', runs: [{ text: 'RTL nested item', fontFamily: 'Arial', fontSize: 12, pmStart: 1, pmEnd: 16 }], - attrs: { direction: 'rtl' as const }, + attrs: { directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' } }, }; const paragraphMeasure: Measure = { @@ -8536,7 +8536,7 @@ describe('DomPainter', () => { kind: 'paragraph', id: 'rtl-block', runs: [{ text: 'مرحبا', fontFamily: 'Arial', fontSize: 16 }], - attrs: { direction: 'rtl' as const, ...attrs }, + attrs: { directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' }, ...attrs }, }); const rtlMeasure: Measure = { @@ -8591,7 +8591,7 @@ describe('DomPainter', () => { { kind: 'tab', width: 40, fontFamily: 'Arial', fontSize: 16 } as any, { text: 'عالم', fontFamily: 'Arial', fontSize: 16 }, ], - attrs: { direction: 'rtl' as const }, + attrs: { directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' } }, }; const tabMeasure: Measure = { diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts index b59b6b97b4..60b5d34b27 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts @@ -324,7 +324,7 @@ describe('computeParagraphAttrs', () => { const { paragraphAttrs } = computeParagraphAttrs(paragraph as never); - expect(paragraphAttrs.direction).toBe('rtl'); + expect(paragraphAttrs.directionContext?.inlineDirection).toBe('rtl'); }); it('does NOT inherit section direction for paragraph inline direction (§17.6.1)', () => { @@ -345,17 +345,13 @@ describe('computeParagraphAttrs', () => { }; const { paragraphAttrs } = computeParagraphAttrs(paragraph as never, converterContext as never); - expect(paragraphAttrs.direction).toBeUndefined(); + expect(paragraphAttrs.directionContext?.inlineDirection).toBeUndefined(); }); - // SD-2777 invariant: pm-adapter must keep `direction` and - // `directionContext.inlineDirection` aligned (or both absent). This is the - // load-bearing property that lets `getParagraphInlineDirection` produce a - // hash byte-identical to the legacy `attrs.direction` read across the - // entire R2 corpus. If this breaks, the helper-based hash sites in - // layout-bridge / layout-resolved / painter-dom will drift from the - // pre-migration output. - describe('SD-2777: direction and directionContext.inlineDirection are paired', () => { + // SD-2778: pm-adapter writes inline direction onto `directionContext.inlineDirection` + // as the single source of truth. The legacy scalar `attrs.direction` field has been + // removed; `getParagraphInlineDirection` reads `directionContext` directly. + describe('SD-2778: directionContext.inlineDirection mirrors paragraphProperties.rightToLeft', () => { const cases: Array<{ name: string; rightToLeft: boolean | undefined; expected: 'rtl' | 'ltr' | undefined }> = [ { name: 'rightToLeft=true', rightToLeft: true, expected: 'rtl' }, { name: 'rightToLeft=false', rightToLeft: false, expected: 'ltr' }, @@ -363,7 +359,7 @@ describe('computeParagraphAttrs', () => { ]; for (const { name, rightToLeft, expected } of cases) { - it(`${name}: attrs.direction === directionContext.inlineDirection (${String(expected)})`, () => { + it(`${name}: directionContext.inlineDirection === ${String(expected)}`, () => { const paragraph: PMNode = { type: { name: 'paragraph' }, attrs: { @@ -373,7 +369,6 @@ describe('computeParagraphAttrs', () => { const { paragraphAttrs } = computeParagraphAttrs(paragraph as never); - expect(paragraphAttrs.direction).toBe(expected); expect(paragraphAttrs.directionContext?.inlineDirection).toBe(expected); }); } diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts index 769e0f4c55..f20e7967d5 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts @@ -388,7 +388,6 @@ export const computeParagraphAttrs = ( keepLines: resolvedParagraphProperties.keepLines, floatAlignment: floatAlignment, pageBreakBefore: resolvedParagraphProperties.pageBreakBefore, - ...(normalizedDirection ? { direction: normalizedDirection } : {}), directionContext, }; diff --git a/packages/layout-engine/pm-adapter/src/index.test.ts b/packages/layout-engine/pm-adapter/src/index.test.ts index af949c4f0c..6fe7e37f45 100644 --- a/packages/layout-engine/pm-adapter/src/index.test.ts +++ b/packages/layout-engine/pm-adapter/src/index.test.ts @@ -3188,7 +3188,7 @@ describe('toFlowBlocks', () => { expect(blocks).toHaveLength(1); const paragraph = blocks[0]; expect(paragraph.kind).toBe('paragraph'); - expect(paragraph.attrs?.direction).toBe('rtl'); + expect(paragraph.attrs?.directionContext?.inlineDirection).toBe('rtl'); expect(paragraph.attrs?.indent?.left).toBe(12); expect(paragraph.attrs?.indent?.right).toBe(24); }); @@ -3214,7 +3214,7 @@ describe('toFlowBlocks', () => { expect(blocks).toHaveLength(1); const paragraph = blocks[0]; expect(paragraph.kind).toBe('paragraph'); - expect(paragraph.attrs?.direction).toBe('ltr'); + expect(paragraph.attrs?.directionContext?.inlineDirection).toBe('ltr'); }); it('does NOT inherit paragraph inline direction from body sectPr w:bidi (§17.6.1)', () => { @@ -3247,7 +3247,7 @@ describe('toFlowBlocks', () => { expect(paragraph.kind).toBe('paragraph'); // Paragraph inline direction stays undefined; the browser applies UBA via // the missing dir attribute. Section pageDirection is preserved separately. - expect(paragraph.attrs?.direction).toBeUndefined(); + expect(paragraph.attrs?.directionContext?.inlineDirection).toBeUndefined(); }); it('section bidi=0 also does not affect paragraph inline direction', () => { @@ -3275,7 +3275,7 @@ describe('toFlowBlocks', () => { expect(blocks).toHaveLength(1); const paragraph = blocks[0]; expect(paragraph.kind).toBe('paragraph'); - expect(paragraph.attrs?.direction).toBeUndefined(); + expect(paragraph.attrs?.directionContext?.inlineDirection).toBeUndefined(); }); it('handles multiple page breaks', () => { @@ -4620,7 +4620,7 @@ describe('toFlowBlocks', () => { const { blocks } = toFlowBlocks(pmDoc); expect(blocks).toHaveLength(1); - expect(blocks[0].attrs?.direction).toBe('rtl'); + expect(blocks[0].attrs?.directionContext?.inlineDirection).toBe('rtl'); expect(blocks[0].attrs?.alignment).toBeUndefined(); }); @@ -4649,7 +4649,7 @@ describe('toFlowBlocks', () => { const { blocks } = toFlowBlocks(pmDoc); expect(blocks).toHaveLength(1); - expect(blocks[0].attrs?.direction).toBe('rtl'); + expect(blocks[0].attrs?.directionContext?.inlineDirection).toBe('rtl'); expect(blocks[0].attrs).toMatchObject({ alignment: 'center', }); @@ -4681,7 +4681,7 @@ describe('toFlowBlocks', () => { const { blocks } = toFlowBlocks(pmDoc); expect(blocks).toHaveLength(1); - expect(blocks[0].attrs?.direction).toBe('rtl'); + expect(blocks[0].attrs?.directionContext?.inlineDirection).toBe('rtl'); expect(blocks[0].attrs).toMatchObject({ alignment: 'right', }); @@ -4712,7 +4712,7 @@ describe('toFlowBlocks', () => { const { blocks } = toFlowBlocks(pmDoc); expect(blocks).toHaveLength(1); - expect(blocks[0].attrs?.direction).toBe('rtl'); + expect(blocks[0].attrs?.directionContext?.inlineDirection).toBe('rtl'); expect(blocks[0].attrs).toMatchObject({ alignment: 'left', }); @@ -4776,7 +4776,7 @@ describe('toFlowBlocks', () => { for (const jc of ['both', 'distribute', 'numTab', 'thaiDistribute']) { const { blocks } = toFlowBlocks(makeDoc(jc)); - expect(blocks[0].attrs?.direction).toBe('rtl'); + expect(blocks[0].attrs?.directionContext?.inlineDirection).toBe('rtl'); expect(blocks[0].attrs).toMatchObject({ alignment: 'justify' }); } }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/CaretGeometry.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/CaretGeometry.test.ts index 262682ed04..74194d9c28 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/CaretGeometry.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/CaretGeometry.test.ts @@ -440,7 +440,7 @@ describe('CaretGeometry', () => { kind: 'paragraph', id: 'rtl-para', runs: [{ text: 'אבגדה', fontFamily: 'Arial', fontSize: 14, pmStart: 1, pmEnd: 6 }], - attrs: { direction: 'rtl' }, + attrs: { directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' } }, }; const line: Line = { fromRun: 0, @@ -476,7 +476,7 @@ describe('CaretGeometry', () => { kind: 'paragraph', id: 'rtl-empty', runs: [{ text: '', fontFamily: 'Arial', fontSize: 14, pmStart: 1, pmEnd: 1 }], - attrs: { direction: 'rtl' }, + attrs: { directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' } }, }; const line: Line = { fromRun: 0, @@ -512,7 +512,7 @@ describe('CaretGeometry', () => { kind: 'paragraph', id: 'rtl-line-end', runs: [{ text: 'אבגדה', fontFamily: 'Arial', fontSize: 14, pmStart: 1, pmEnd: 6 }], - attrs: { direction: 'rtl' }, + attrs: { directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' } }, }; const line: Line = { fromRun: 0, @@ -546,7 +546,7 @@ describe('CaretGeometry', () => { it('computes decreasing X across mid-line positions for RTL paragraphs without DOM fallback', () => { const block: FlowBlock = { ...createMockParagraphBlock('rtl-midline', 1, 12), - attrs: { direction: 'rtl' }, + attrs: { directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' } }, }; const line = createMockLine(1, 12, 16); const measure = createMockParagraphMeasure([line]); From a77894c69f3c615bc0706eb44a5b3ced040b9c48 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 14 May 2026 08:56:08 -0300 Subject: [PATCH 4/6] refactor(direction): finish dropping attrs.direction (diff.ts + stale fixtures) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 19b13fcbc. Two gaps caught on review: - layout-bridge/src/diff.ts:372 still compared a.direction !== b.direction inside paragraphAttrsEqual. Vitest passed because esbuild/swc skips cross-package types; the tsup DTS build was the one that caught it. Migrate to getParagraphInlineDirection(a) !== getParagraphInlineDirection(b) so the diff respects directionContext + paragraphProperties fallback. - Test fixtures across versionSignature.test.ts, rtl-date-parity.test.ts, cache.test.ts, and diff.test.ts still constructed hand-rolled FlowBlocks with { direction: 'rtl' }. They typecheck via Record casts but no longer model the actual ParagraphAttrs shape. Migrate them all onto directionContext. Build sweep (contracts → pm-adapter → layout-bridge → layout-resolved → painter-dom) now passes; tests still green across all five. --- packages/layout-engine/layout-bridge/src/diff.ts | 3 ++- .../layout-engine/layout-bridge/test/cache.test.ts | 2 +- .../layout-engine/layout-bridge/test/diff.test.ts | 14 +++++++++++--- .../layout-resolved/src/versionSignature.test.ts | 2 +- .../painters/dom/src/rtl-date-parity.test.ts | 10 +++++----- 5 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/layout-engine/layout-bridge/src/diff.ts b/packages/layout-engine/layout-bridge/src/diff.ts index d388727494..82ef81fead 100644 --- a/packages/layout-engine/layout-bridge/src/diff.ts +++ b/packages/layout-engine/layout-bridge/src/diff.ts @@ -20,6 +20,7 @@ import type { DropCapDescriptor, ParagraphFrame, } from '@superdoc/contracts'; +import { getParagraphInlineDirection } from '@superdoc/contracts'; import { fieldAnnotationKey } from './field-annotation-key.js'; import { hashRunVisualMarks } from './run-visual-marks.js'; import { hasTrackedChange, resolveTrackedChangesEnabled } from './tracked-changes-utils.js'; @@ -369,7 +370,7 @@ const paragraphAttrsEqual = (a?: ParagraphAttrs, b?: ParagraphAttrs): boolean => a.tabIntervalTwips !== b.tabIntervalTwips || a.keepNext !== b.keepNext || a.keepLines !== b.keepLines || - a.direction !== b.direction || + getParagraphInlineDirection(a) !== getParagraphInlineDirection(b) || a.floatAlignment !== b.floatAlignment ) { return false; diff --git a/packages/layout-engine/layout-bridge/test/cache.test.ts b/packages/layout-engine/layout-bridge/test/cache.test.ts index 169d723543..2ed5e25ffc 100644 --- a/packages/layout-engine/layout-bridge/test/cache.test.ts +++ b/packages/layout-engine/layout-bridge/test/cache.test.ts @@ -2065,7 +2065,7 @@ describe('MeasureCache', () => { { val: 'end', pos: 8640, leader: 'dot' }, ], keepNext: true, - direction: 'ltr', + directionContext: { inlineDirection: 'ltr', writingMode: 'horizontal-tb' }, }; const block1 = paragraphWithAttrs('p1', 'Hello', complexAttrs); diff --git a/packages/layout-engine/layout-bridge/test/diff.test.ts b/packages/layout-engine/layout-bridge/test/diff.test.ts index 7c762a635f..51c195f114 100644 --- a/packages/layout-engine/layout-bridge/test/diff.test.ts +++ b/packages/layout-engine/layout-bridge/test/diff.test.ts @@ -434,8 +434,16 @@ describe('computeDirtyRegions', () => { describe('other paragraph attribute changes', () => { it('detects direction change', () => { - const prev = [paragraphWithAttrs('p1', 'Hello', { direction: 'ltr' })]; - const next = [paragraphWithAttrs('p1', 'Hello', { direction: 'rtl' })]; + const prev = [ + paragraphWithAttrs('p1', 'Hello', { + directionContext: { inlineDirection: 'ltr', writingMode: 'horizontal-tb' }, + }), + ]; + const next = [ + paragraphWithAttrs('p1', 'Hello', { + directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' }, + }), + ]; const result = computeDirtyRegions(prev, next); expect(result.firstDirtyIndex).toBe(0); }); @@ -492,7 +500,7 @@ describe('computeDirtyRegions', () => { { val: 'end' as const, pos: 8640, leader: 'dot' as const }, ], keepNext: true, - direction: 'ltr' as const, + directionContext: { inlineDirection: 'ltr' as const, writingMode: 'horizontal-tb' as const }, }; const prev = [paragraphWithAttrs('p1', 'Hello', complexAttrs)]; const next = [paragraphWithAttrs('p1', 'Hello', complexAttrs)]; diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts index f5ba0ede5d..4385b9453d 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts @@ -33,7 +33,7 @@ describe('deriveBlockVersion - bidi', () => { const makeParagraph = (bidi?: TextRun['bidi']): FlowBlock => ({ kind: 'paragraph', id: 'p1', - attrs: { direction: 'rtl' }, + attrs: { directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' } }, runs: [ { text: '23.03.2026', 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 5ef1853134..dc278e4f1c 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 @@ -25,7 +25,7 @@ describe('RTL date parity', () => { const block: FlowBlock = { kind: 'paragraph', id: blockId, - attrs: { direction: 'rtl' }, + attrs: { directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' } }, runs: [ { text: runText, @@ -54,7 +54,7 @@ describe('RTL date parity', () => { const block: FlowBlock = { kind: 'paragraph', id: blockId, - attrs: { direction: 'rtl' }, + attrs: { directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' } }, runs: [{ text: runText, fontFamily: 'David, sans-serif', fontSize: 16, pmStart: 1, pmEnd: 7 }], }; @@ -78,7 +78,7 @@ describe('RTL date parity', () => { const block: FlowBlock = { kind: 'paragraph', id: blockId, - attrs: { direction: 'rtl' }, + attrs: { directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' } }, 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 }, @@ -122,7 +122,7 @@ describe('RTL date parity', () => { const block: FlowBlock = { kind: 'paragraph', id: blockId, - attrs: { direction: 'rtl' }, + attrs: { directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' } }, runs: [ { text: runText, fontFamily: 'David, sans-serif', fontSize: 16, bidi: { rtl: true }, pmStart: 1, pmEnd: 5 }, ], @@ -147,7 +147,7 @@ describe('RTL date parity', () => { const block: FlowBlock = { kind: 'paragraph', id: blockId, - attrs: { direction: 'rtl' }, + attrs: { directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' } }, runs: [{ text: runText, fontFamily: 'David, sans-serif', fontSize: 16, pmStart: 1, pmEnd: 12 }], }; From 79f7d2c348f2a8a291031d1411975cebb090c2ca Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 14 May 2026 09:18:05 -0300 Subject: [PATCH 5/6] docs(direction): retire ParagraphAttrs.direction mention in pm-adapter direction README After SD-2778 drops the scalar `direction` field, the README sentence that pointed consumers at it is stale. Point them at `getParagraphInlineDirection` instead so the next reader lands on the helper that knows about the typed context + paragraphProperties fallback. --- packages/layout-engine/pm-adapter/src/direction/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/pm-adapter/src/direction/README.md b/packages/layout-engine/pm-adapter/src/direction/README.md index 9896aba93f..509d66c2e8 100644 --- a/packages/layout-engine/pm-adapter/src/direction/README.md +++ b/packages/layout-engine/pm-adapter/src/direction/README.md @@ -75,8 +75,10 @@ const inline = block.attrs.directionContext?.inlineDirection; const writingMode = block.attrs.directionContext?.writingMode; ``` -`ParagraphAttrs.direction` is also populated for consumers that only need the -inline-direction scalar. +Consumers that only need the inline-direction scalar should call +`getParagraphInlineDirection(attrs)` from `@superdoc/contracts`. The helper +prefers `directionContext.inlineDirection` and falls back to +`paragraphProperties.rightToLeft` for PM-node / editor paths. ## Logical-to-Physical Helpers From 213917f73a0bdbf09a218cbb540e9904e6f5a465 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 14 May 2026 11:29:38 -0300 Subject: [PATCH 6/6] test(direction): pin producer contract that attrs.direction is not emitted SD-2778 removes ParagraphAttrs.direction from the type, but TypeScript's structural typing permits index-signature extra keys. Add a runtime assertion inside the SD-2778 describe block that pm-adapter never emits the legacy scalar field. Catches accidental re-introduction via a future spread that TypeScript would let through. --- .../pm-adapter/src/attributes/paragraph.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts index 60b5d34b27..e985738314 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts @@ -370,6 +370,11 @@ describe('computeParagraphAttrs', () => { const { paragraphAttrs } = computeParagraphAttrs(paragraph as never); expect(paragraphAttrs.directionContext?.inlineDirection).toBe(expected); + // Pin the producer contract: pm-adapter must not emit the legacy + // scalar `direction` field. A future accidental spread that + // re-introduced it would slip past the TypeScript check (since + // index signatures permit extra keys) but fail this runtime guard. + expect(Object.hasOwn(paragraphAttrs, 'direction')).toBe(false); }); } });