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
27 changes: 7 additions & 20 deletions packages/layout-engine/contracts/src/direction-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
19 changes: 5 additions & 14 deletions packages/layout-engine/contracts/src/direction-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -172,25 +172,16 @@ export function getParagraphInlineDirection(
attrs:
| {
directionContext?: { inlineDirection?: BaseDirection | null } | null;
direction?: string | null;
dir?: string | null;
rtl?: boolean | null;
paragraphProperties?: { rightToLeft?: boolean | null } | null;
}
| null
| undefined,
): 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;
}

Expand Down
13 changes: 4 additions & 9 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion packages/layout-engine/layout-bridge/src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
26 changes: 7 additions & 19 deletions packages/layout-engine/layout-bridge/src/position-hit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string, unknown> | 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 => {
Expand Down
10 changes: 7 additions & 3 deletions packages/layout-engine/layout-bridge/test/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 11 additions & 3 deletions packages/layout-engine/layout-bridge/test/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -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)];
Expand Down
23 changes: 14 additions & 9 deletions packages/layout-engine/layout-bridge/test/position-hit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
8 changes: 4 additions & 4 deletions packages/layout-engine/painters/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5006,7 +5006,7 @@ describe('DomPainter', () => {
],
attrs: {
alignment: 'center',
direction: 'rtl',
directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' },
},
};
const footerMeasure: Measure = {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down
Loading
Loading