Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,55 @@ describe('computeParagraphAttrs', () => {
const { paragraphAttrs } = computeParagraphAttrs(paragraph as never, converterContext as never);
expect(paragraphAttrs.direction).toBeUndefined();
});

it('inherits writing mode from body section context (§17.3.1.41)', () => {
// When the paragraph omits w:textDirection, it should pick up writing-mode
// from the section. This test feeds a pre-resolved sectionDirectionContext
// (the production wiring populates this from the body sectPr).
const paragraph: PMNode = {
type: { name: 'paragraph' },
attrs: {
paragraphProperties: {},
},
};

const converterContext = {
sectionDirectionContext: {
pageDirection: 'ltr',
writingMode: 'vertical-rl',
rtlGutter: false,
},
translatedNumbering: {},
translatedLinkedStyles: { docDefaults: {}, styles: {} },
tableInfo: null,
};

const { paragraphAttrs } = computeParagraphAttrs(paragraph as never, converterContext as never);
expect(paragraphAttrs.directionContext?.writingMode).toBe('vertical-rl');
});

it('paragraph w:textDirection wins over section writing-mode (§17.3.1.41 explicit override)', () => {
const paragraph: PMNode = {
type: { name: 'paragraph' },
attrs: {
paragraphProperties: { textDirection: 'lrTb' },
},
};

const converterContext = {
sectionDirectionContext: {
pageDirection: 'ltr',
writingMode: 'vertical-rl',
rtlGutter: false,
},
translatedNumbering: {},
translatedLinkedStyles: { docDefaults: {}, styles: {} },
tableInfo: null,
};

const { paragraphAttrs } = computeParagraphAttrs(paragraph as never, converterContext as never);
expect(paragraphAttrs.directionContext?.writingMode).toBe('horizontal-tb');
});
});

/*
Expand Down
12 changes: 9 additions & 3 deletions packages/layout-engine/pm-adapter/src/attributes/paragraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {
type ParagraphIndent,
type DropCapDescriptor,
type DropCapRun,
type ParagraphFrame, ParagraphDirectionContext
type ParagraphFrame,
ParagraphDirectionContext,
} from '@superdoc/contracts';
import type { PMNode, ParagraphFont } from '../types.js';
import type { ResolvedRunProperties } from '@superdoc/word-layout';
Expand Down Expand Up @@ -322,12 +323,17 @@ export const computeParagraphAttrs = (
// Inputs:
// - resolvedParagraphProperties.rightToLeft already reflects the style cascade
// including docDefaults/pPrDefault/pPr/bidi (style-engine §17.7.2 cascade).
// - The section context provides writing-mode inheritance only.
// - The section context provides writing-mode inheritance per ECMA §17.3.1.41
// when the paragraph omits w:textDirection. Pulled from converterContext when
// available, defaulted otherwise so this function works in test contexts.
//
// The resolver intentionally does NOT consume sectionDirection or run content as
// fallbacks for inline direction. Per ECMA §17.6.1 section bidi affects section
// chrome only, and run rtl is per-run script formatting, not paragraph state.
const sectionContext = resolveSectionDirection(undefined);
//
// Cell direction context (paragraphs in vertical table cells) and per-paragraph
// sectPr variation are not yet plumbed through - SD-2777 closes that gap.
const sectionContext = converterContext?.sectionDirectionContext ?? resolveSectionDirection(undefined);
const directionContext: ParagraphDirectionContext = resolveParagraphDirection(
resolvedParagraphProperties,
sectionContext,
Expand Down
12 changes: 11 additions & 1 deletion packages/layout-engine/pm-adapter/src/converter-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* should always guard for undefined fields and degrade gracefully.
*/

import type { ParagraphSpacing } from '@superdoc/contracts';
import type { ParagraphSpacing, SectionDirectionContext } from '@superdoc/contracts';
import type { NumberingProperties, StylesDocumentProperties, TableInfo } from '@superdoc/style-engine/ooxml';

/**
Expand All @@ -21,6 +21,16 @@ export type TableStyleParagraphProps = {

export type ConverterContext = {
sectionDirection?: 'ltr' | 'rtl';
/**
* Resolved direction context for the body section (page direction, writing mode,
* gutter). Computed once from the body's `w:sectPr` and used by the paragraph
* resolver chain so paragraph writing-mode can inherit from the section per
* ECMA §17.3.1.41. Per-paragraph-section variation and table cell direction
* context are not yet plumbed through; paragraphs in vertical sections covered
* by paragraph-level `w:sectPr` and paragraphs in vertical table cells will
* still see `writingMode: 'horizontal-tb'` until SD-2777 lands.
*/
sectionDirectionContext?: SectionDirectionContext;
docx?: Record<string, unknown>;
translatedNumbering: NumberingProperties;
translatedLinkedStyles: StylesDocumentProperties;
Expand Down
57 changes: 55 additions & 2 deletions packages/layout-engine/pm-adapter/src/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
*/

import { describe, it, expect } from 'vitest';
import { toFlowBlocks as baseToFlowBlocks } from './index.js';
import type { PMNode, AdapterOptions } from './index.js';
import { toFlowBlocks as baseToFlowBlocks, toFlowBlocksMap } from './index.js';
import type { PMNode, AdapterOptions, PMDocumentMap } from './index.js';
import { measureBlock } from '@superdoc/measuring-dom';
import { layoutDocument } from '@superdoc/layout-engine';
import { createDomPainter } from '@superdoc/painter-dom';
Expand Down Expand Up @@ -982,4 +982,57 @@ describe('page break integration tests', () => {
expect(exhibitPageIndex).toBe(1);
expect(layout.pages[1].fragments.length).toBeGreaterThan(0);
});

// SD-2768 (codex round-2 finding): when toFlowBlocksMap reuses one
// converterContext across documents, the body-level sectionDirectionContext
// must be recomputed per document. The original `??` cache let the first
// doc's context stick, so a vertical doc 1 followed by a horizontal doc 2
// would have doc 2's paragraphs inherit doc 1's writing-mode.
it('recomputes body sectionDirectionContext per document in toFlowBlocksMap', () => {
const docs: PMDocumentMap = {
'doc-vertical': {
type: 'doc',
attrs: {
bodySectPr: {
type: 'element',
name: 'w:sectPr',
attributes: {},
elements: [{ type: 'element', name: 'w:textDirection', attributes: { 'w:val': 'tbRl' } }],
},
},
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'vertical text' }] }],
},
'doc-horizontal': {
type: 'doc',
attrs: {
bodySectPr: {
type: 'element',
name: 'w:sectPr',
attributes: {},
elements: [{ type: 'element', name: 'w:textDirection', attributes: { 'w:val': 'lrTb' } }],
},
},
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'horizontal text' }] }],
},
};

// Reuse a single converterContext across both calls (the toFlowBlocksMap
// pattern). With the bug, doc-horizontal's writingMode would still be
// 'vertical-rl' because the cached field from doc-vertical wins.
const sharedContext = {
...DEFAULT_CONVERTER_CONTEXT,
};

const results = toFlowBlocksMap(docs, { converterContext: sharedContext });

const verticalParagraph = results['doc-vertical']?.find((b) => b.kind === 'paragraph');
const horizontalParagraph = results['doc-horizontal']?.find((b) => b.kind === 'paragraph');

expect(verticalParagraph?.kind).toBe('paragraph');
expect(horizontalParagraph?.kind).toBe('paragraph');
if (verticalParagraph?.kind !== 'paragraph' || horizontalParagraph?.kind !== 'paragraph') return;

expect(verticalParagraph.attrs?.directionContext?.writingMode).toBe('vertical-rl');
expect(horizontalParagraph.attrs?.directionContext?.writingMode).toBe('horizontal-tb');
});
});
13 changes: 11 additions & 2 deletions packages/layout-engine/pm-adapter/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

import type { FlowBlock, ParagraphBlock } from '@superdoc/contracts';
import { resolveSectionDirection } from './direction/resolveSectionDirection.js';
import { isValidTrackedMode } from './tracked-changes.js';
import {
analyzeSectionRanges,
Expand Down Expand Up @@ -193,9 +194,17 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions):
// Range-aware section analysis (matches toFlowBlocks semantics)
const bodySectionProps = doc.attrs?.bodySectPr ?? doc.attrs?.sectPr;
const sectionRanges = options?.emitSectionBreaks ? analyzeSectionRanges(doc, bodySectionProps) : [];
const firstSectPr = sectionRanges[0]?.sectPr ?? bodySectionProps;
converterContext.sectionDirection =
converterContext.sectionDirection ??
resolveSectionDirectionFromSectPr(sectionRanges[0]?.sectPr ?? bodySectionProps);
converterContext.sectionDirection ?? resolveSectionDirectionFromSectPr(firstSectPr);
// Body-level section direction context: writing-mode inherits to paragraphs that
// omit their own w:textDirection, per ECMA §17.3.1.41. Multi-section docs (each
// section with its own sectPr) and table-cell direction context are not yet
// plumbed through - SD-2777 closes that gap.
// Always recompute per call: when callers reuse the same ConverterContext
// across documents (toFlowBlocksMap is the obvious case), the prior `??`
// cache let the first document's writing-mode survive into later documents.
converterContext.sectionDirectionContext = resolveSectionDirection(firstSectPr);
publishSectionMetadata(sectionRanges, options);

// Emit first section break before content to set initial properties.
Expand Down
Loading