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/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/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-bridge/test/cache.test.ts b/packages/layout-engine/layout-bridge/test/cache.test.ts index d8625947dc..2ed5e25ffc 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(); @@ -2061,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-bridge/test/position-hit.test.ts b/packages/layout-engine/layout-bridge/test/position-hit.test.ts index 1eb0210384..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', @@ -61,4 +57,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: { 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, 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/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/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 }], }; 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..e985738314 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,8 +369,12 @@ describe('computeParagraphAttrs', () => { const { paragraphAttrs } = computeParagraphAttrs(paragraph as never); - expect(paragraphAttrs.direction).toBe(expected); 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); }); } }); 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/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 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]);