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
141 changes: 141 additions & 0 deletions packages/layout-engine/contracts/src/direction-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* Direction Context Types
*
* Typed direction information that propagates from section to paragraph to run
* during pm-adapter conversion. Each container has its own context type so the
* orthogonal axes do not collapse:
*
* - section page direction (page numbers, columns) is independent of
* - paragraph inline base direction (ind, jc, tab) which is independent of
* - table visual direction (cell ordering) which is independent of
* - writing mode (horizontal vs vertical text flow).
*
* Per ECMA-376:
* - Section w:bidi affects section chrome only (§17.6.1).
* - Paragraph w:bidi affects paragraph-level properties only (§17.3.1.6).
* - Table w:bidiVisual affects cell ordering only (§17.4.1).
* - Writing mode (w:textDirection) inherits across containers when absent
* (§17.3.1.41) — the one exception that does propagate.
*
* The resolver chain in `pm-adapter/src/direction/` produces these contexts.
* Downstream consumers (DomPainter, layout-bridge, hit testing) read the
* resolved fields and never re-derive direction from raw attributes.
*/

/**
* Inline base direction of text within a container.
* Maps to OOXML w:bidi (paragraph) and w:rtl (run).
*/
export type BaseDirection = 'ltr' | 'rtl';

/**
* Text flow direction ("writing mode").
* Maps to OOXML w:textDirection (ST_TextDirection §17.18.93).
*
* Limited to the values SuperDoc renders today; the full OOXML enum has
* twelve values for vertical-text variants (Wave 4 expands this).
*/
export type WritingMode = 'horizontal-tb' | 'vertical-rl' | 'vertical-lr';

/**
* Direction context for a section.
* Used by section-level chrome only (page numbers, columns, gutters).
* MUST NOT be used to determine paragraph inline direction.
*/
export type SectionDirectionContext = {
/** Section page direction; from w:sectPr/w:bidi. */
pageDirection: BaseDirection;
/** Default writing mode for paragraphs and cells in the section. */
writingMode: WritingMode;
/** Whether the page gutter is on the right; from w:sectPr/w:rtlGutter. */
rtlGutter: boolean;
};

/**
* Direction context for a table.
* Carries visual cell ordering only. Cell paragraph direction is independent.
*/
export type TableDirectionContext = {
/**
* Visual direction of cell ordering; from w:tblPr/w:bidiVisual.
* Undefined when not specified.
*/
visualDirection: BaseDirection | undefined;
/** Inherited from the parent section. */
parentSection: SectionDirectionContext;
};

/**
* Direction context for a table cell.
* Carries cell-level writing mode (vertical text). Cell paragraph inline
* direction is decided per-paragraph, not by the cell.
*/
export type CellDirectionContext = {
/**
* Cell text flow direction; from w:tcPr/w:textDirection.
* Falls back to the section writing mode when absent.
*/
writingMode: WritingMode;
/** Inherited from the parent table. */
parentTable: TableDirectionContext;
};

/**
* Direction context for a paragraph.
* The single source of truth for paragraph direction-aware decisions.
*
* Consumers (logical-side resolution, alignment, indent, hit testing,
* DomPainter direction styling) read from here. They do NOT re-derive
* direction from raw attributes.
*/
export type ParagraphDirectionContext = {
/**
* Paragraph inline base direction; from w:pPr/w:bidi.
* Undefined when no explicit bidi is set; consumers should let the browser
* apply the Unicode Bidi Algorithm via missing or empty `dir` attribute.
*
* Section page direction MUST NOT propagate into this field. Per §17.6.1,
* section bidi only affects section chrome, not paragraph layout.
*/
inlineDirection: BaseDirection | undefined;
/**
* Writing mode (text flow) for the paragraph; from w:pPr/w:textDirection.
* Inherits from the section when absent. This is the one OOXML direction
* field that propagates across containers per §17.3.1.41.
*/
writingMode: WritingMode;
};

/**
* Run-level bidi signals: explicit overrides and embeddings.
* Direction signals only — script formatting lives in RunScriptContext.
*
* Maps to OOXML w:rPr/w:rtl (§17.3.2.30), w:dir (§17.3.2.8 embedding),
* w:bdo (§17.3.2.3 override).
*/
export type RunBidiContext = {
/** w:rPr/w:rtl. Forces complex-script formatting; see RunScriptContext. */
rtl: boolean;
/** w:dir; bidi embedding direction (RLE/LRE). Wave 1c. */
embedding?: BaseDirection;
/** w:bdo; bidi override direction (RLO/LRO). Wave 1c. */
override?: BaseDirection;
};

/**
* Run-level script context: which formatting stack applies.
*
* Per ECMA Annex I, when w:rtl is set or w:cs is set, the run's formatting
* comes from the complex-script variants (bCs, iCs, szCs, rFonts/@cs).
* Otherwise it comes from the Latin variants (b, i, sz, rFonts/@ascii).
*
* This context is preservation-only in Wave 1a. Wave 1b implements the
* stack-selection logic (resolveRunScriptContext returns whether to render
* with the CS or Latin stack).
*/
export type RunScriptContext = {
/** w:rPr/w:cs. Forces complex-script formatting regardless of Unicode. */
complexScript: boolean;
/** w:rPr/w:lang/@bidi. Complex-script language metadata (spellcheck). */
language?: string;
};
29 changes: 29 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ export { computeTabStops, layoutWithTabs, calculateTabWidth } from './engines/ta
// Re-export TabStop for external consumers
export type { TabStop };

// Direction context types (orthogonal axes for RTL/BIDI).
// See `direction-context.ts` for the spec rationale and axis semantics.
export type {
BaseDirection,
WritingMode,
SectionDirectionContext,
TableDirectionContext,
CellDirectionContext,
ParagraphDirectionContext,
RunBidiContext,
RunScriptContext,
} from './direction-context.js';
import type { ParagraphDirectionContext } from './direction-context.js';

// Export table contracts
export {
OOXML_PCT_DIVISOR,
Expand Down Expand Up @@ -1466,7 +1480,22 @@ 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.
*
* See `@superdoc/contracts/direction-context` for axis semantics.
*/
directionContext?: ParagraphDirectionContext;
isTocEntry?: boolean;
tocInstruction?: string;
/** Floating alignment for positioned paragraphs (from w:framePr/@w:xAlign). */
Expand Down
158 changes: 20 additions & 138 deletions packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
normalizeFramePr,
normalizeDropCap,
computeParagraphAttrs,
resolveEffectiveParagraphDirection,
computeRunAttrs,
hasExplicitParagraphRunProperties,
} from './paragraph.js';
Expand Down Expand Up @@ -328,7 +327,9 @@ describe('computeParagraphAttrs', () => {
expect(paragraphAttrs.direction).toBe('rtl');
});

it('does not use section direction fallback when paragraph direction is not explicit', () => {
it('does NOT inherit section direction for paragraph inline direction (§17.6.1)', () => {
// Section bidi affects section chrome only; paragraph inline direction
// must come from paragraph w:bidi or its style cascade, never the section.
const paragraph: PMNode = {
type: { name: 'paragraph' },
attrs: {
Expand All @@ -348,142 +349,23 @@ describe('computeParagraphAttrs', () => {
});
});

describe('resolveEffectiveParagraphDirection', () => {
it('prefers resolved paragraph rightToLeft over section direction', () => {
const paragraph: PMNode = {
type: { name: 'paragraph' },
attrs: {
paragraphProperties: {
rightToLeft: true,
},
},
};

const direction = resolveEffectiveParagraphDirection(paragraph as never, { rightToLeft: true } as never, 'ltr');
expect(direction).toBe('rtl');
});

it('does not use section direction when paragraph direction is not explicit', () => {
const paragraph: PMNode = {
type: { name: 'paragraph' },
attrs: {
paragraphProperties: {},
},
};

const direction = resolveEffectiveParagraphDirection(paragraph as never, {} as never, 'rtl');
expect(direction).toBeUndefined();
});

it('uses run inference before docDefaults direction', () => {
const paragraph: PMNode = {
type: { name: 'paragraph' },
content: [
{ type: 'run', attrs: { runProperties: { rightToLeft: true } }, content: [{ type: 'text', text: 'אבג' }] },
],
};

const direction = resolveEffectiveParagraphDirection(paragraph as never, {} as never, undefined, 'ltr');
expect(direction).toBe('rtl');
});

it('uses run inference when rtl is set on runProperties', () => {
const paragraph: PMNode = {
type: { name: 'paragraph' },
content: [{ type: 'run', attrs: { runProperties: { rtl: true } }, content: [{ type: 'text', text: 'אבג' }] }],
};

const direction = resolveEffectiveParagraphDirection(paragraph as never, {} as never, undefined, 'ltr');
expect(direction).toBe('rtl');
});

it('uses docDefaults when no explicit run direction exists', () => {
const paragraph: PMNode = {
type: { name: 'paragraph' },
content: [{ type: 'run', attrs: { runProperties: {} }, content: [{ type: 'text', text: 'abc' }] }],
};

const direction = resolveEffectiveParagraphDirection(paragraph as never, {} as never, undefined, 'rtl');
expect(direction).toBe('rtl');
});

it('infers rtl when all runs with explicit direction are rtl', () => {
const paragraph: PMNode = {
type: { name: 'paragraph' },
content: [
{ type: 'run', attrs: { runProperties: { rightToLeft: true } }, content: [{ type: 'text', text: 'אבג' }] },
{ type: 'run', attrs: { runProperties: { rightToLeft: true } }, content: [{ type: 'text', text: 'דהו' }] },
],
};

const direction = resolveEffectiveParagraphDirection(paragraph as never, {} as never);
expect(direction).toBe('rtl');
});

it('does not infer rtl when any explicit ltr run is present', () => {
const paragraph: PMNode = {
type: { name: 'paragraph' },
content: [
{ type: 'run', attrs: { runProperties: { rightToLeft: true } }, content: [{ type: 'text', text: 'אבג' }] },
{ type: 'run', attrs: { runProperties: { rightToLeft: false } }, content: [{ type: 'text', text: 'abc' }] },
{ type: 'run', attrs: { runProperties: { rightToLeft: false } }, content: [{ type: 'text', text: 'def' }] },
],
};

const direction = resolveEffectiveParagraphDirection(paragraph as never, {} as never);
expect(direction).toBeUndefined();
});

it('does not infer rtl when rtl and explicit ltr rtl=false are mixed', () => {
const paragraph: PMNode = {
type: { name: 'paragraph' },
content: [
{ type: 'run', attrs: { runProperties: { rtl: true } }, content: [{ type: 'text', text: 'אבג' }] },
{ type: 'run', attrs: { runProperties: { rtl: false } }, content: [{ type: 'text', text: 'abc' }] },
],
};

const direction = resolveEffectiveParagraphDirection(paragraph as never, {} as never);
expect(direction).toBeUndefined();
});

it('does not infer rtl when explicit rtl and ltr runs are mixed', () => {
const paragraph: PMNode = {
type: { name: 'paragraph' },
content: [
{ type: 'run', attrs: { runProperties: { rightToLeft: false } }, content: [{ type: 'text', text: 'abc' }] },
{ type: 'run', attrs: { runProperties: { rightToLeft: true } }, content: [{ type: 'text', text: 'אבג' }] },
{ type: 'run', attrs: { runProperties: { rightToLeft: true } }, content: [{ type: 'text', text: 'דהו' }] },
],
};

const direction = resolveEffectiveParagraphDirection(paragraph as never, {} as never);
expect(direction).toBeUndefined();
});

it('does not infer rtl on mixed explicit directions (tie case)', () => {
const paragraph: PMNode = {
type: { name: 'paragraph' },
content: [
{ type: 'run', attrs: { runProperties: { rightToLeft: true } }, content: [{ type: 'text', text: 'אבג' }] },
{ type: 'run', attrs: { runProperties: { rightToLeft: false } }, content: [{ type: 'text', text: 'abc' }] },
],
};

const direction = resolveEffectiveParagraphDirection(paragraph as never, {} as never);
expect(direction).toBeUndefined();
});

it('returns undefined when no direction signal exists', () => {
const paragraph: PMNode = {
type: { name: 'paragraph' },
content: [{ type: 'run', attrs: { runProperties: {} }, content: [{ type: 'text', text: 'plain text' }] }],
};

const direction = resolveEffectiveParagraphDirection(paragraph as never, {} as never);
expect(direction).toBeUndefined();
});
});
/*
* The previous tests for `resolveEffectiveParagraphDirection` codified
* direction-resolution behavior that the new resolver chain replaces:
*
* - Section bidi propagating to paragraph inline direction (§17.6.1
* violation — section bidi affects section chrome only).
* - A run-content heuristic for paragraph base direction (UAX #9 P2/P3
* specifies first-strong-character; the browser handles this via UBA
* when `dir` is omitted, so SuperDoc does not need a server-side
* classifier).
* - A docDefaults parameter (redundant — the style-engine cascade
* already resolves docDefaults/pPrDefault/pPr/bidi into
* `paragraphProperties.rightToLeft` before this resolver runs).
*
* Direction-axis correctness is now tested in
* `direction/non-collapse.test.ts` where each axis stays separate.
*/

describe('computeRunAttrs', () => {
it('normalizes font family, font size, and color', () => {
Expand Down
Loading
Loading