From 7ef0198d012e6c0477f640e217dbb88a87bdeff1 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 7 May 2026 17:54:08 -0300 Subject: [PATCH 1/2] feat(pm-adapter): plumb body section direction context through resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per ECMA §17.3.1.41, paragraph w:textDirection inherits from the parent section when omitted. The previous call site built sectionContext from `undefined`, so directionContext.writingMode was always 'horizontal-tb' even when the body's w:sectPr declared a vertical writing-mode. This wires SectionDirectionContext through ConverterContext, populated once at top-level conversion from the body sectPr. Paragraphs that omit their own w:textDirection now correctly inherit writing-mode. Scope: - Body-level sectPr only. Per-paragraph-section variation (each section with its own sectPr) and table-cell direction context are not yet plumbed through. Both gaps are documented inline and tracked under SD-2777 (migrate remaining direction-aware consumers). - No consumer currently reads directionContext.writingMode in production, so this fixes the data contract before the first consumer arrives. Tests: - New: paragraph inherits body sectionDirectionContext.writingMode - New: paragraph w:textDirection still wins as explicit override --- .../src/attributes/paragraph.test.ts | 49 +++++++++++++++++++ .../pm-adapter/src/attributes/paragraph.ts | 12 +++-- .../pm-adapter/src/converter-context.ts | 12 ++++- .../layout-engine/pm-adapter/src/internal.ts | 11 ++++- 4 files changed, 78 insertions(+), 6 deletions(-) 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 32e672b12f..b95daa025f 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts @@ -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'); + }); }); /* diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts index d7dd40ce57..769e0f4c55 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts @@ -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'; @@ -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, diff --git a/packages/layout-engine/pm-adapter/src/converter-context.ts b/packages/layout-engine/pm-adapter/src/converter-context.ts index bf83cc21f2..608c774a6c 100644 --- a/packages/layout-engine/pm-adapter/src/converter-context.ts +++ b/packages/layout-engine/pm-adapter/src/converter-context.ts @@ -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'; /** @@ -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; translatedNumbering: NumberingProperties; translatedLinkedStyles: StylesDocumentProperties; diff --git a/packages/layout-engine/pm-adapter/src/internal.ts b/packages/layout-engine/pm-adapter/src/internal.ts index d097e78224..ccc73c166d 100644 --- a/packages/layout-engine/pm-adapter/src/internal.ts +++ b/packages/layout-engine/pm-adapter/src/internal.ts @@ -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, @@ -193,9 +194,15 @@ 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. + converterContext.sectionDirectionContext = + converterContext.sectionDirectionContext ?? resolveSectionDirection(firstSectPr); publishSectionMetadata(sectionRanges, options); // Emit first section break before content to set initial properties. From 78f793d202ed49cc0463c8d05ecccd3fabb6b4ec Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 7 May 2026 19:23:01 -0300 Subject: [PATCH 2/2] fix(pm-adapter): recompute sectionDirectionContext per-document Codex finding on PR #3199: when callers reuse one ConverterContext across documents (toFlowBlocksMap does this), the previous `??` cache let the first document's body sectPr resolve once and stick. A vertical doc 1 followed by a horizontal doc 2 would have doc 2's paragraphs inherit doc 1's writing-mode. Fix: drop the `??` and always overwrite. The shared ConverterContext is mutated freshly each call before children read it, so per-document recomputation is enough. (The pre-existing `sectionDirection` field on the line above has the same pattern but is out of scope for this PR.) Test added: toFlowBlocksMap with two docs (vertical w:textDirection then horizontal w:textDirection) sharing one converterContext - asserts each doc's paragraphs get their own writingMode. Failed before the fix because the cached vertical-rl persisted into the horizontal doc; passes after. --- .../pm-adapter/src/integration.test.ts | 57 ++++++++++++++++++- .../layout-engine/pm-adapter/src/internal.ts | 6 +- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/packages/layout-engine/pm-adapter/src/integration.test.ts b/packages/layout-engine/pm-adapter/src/integration.test.ts index e9bdc14328..c2ea728a30 100644 --- a/packages/layout-engine/pm-adapter/src/integration.test.ts +++ b/packages/layout-engine/pm-adapter/src/integration.test.ts @@ -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'; @@ -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'); + }); }); diff --git a/packages/layout-engine/pm-adapter/src/internal.ts b/packages/layout-engine/pm-adapter/src/internal.ts index ccc73c166d..f2e39387c1 100644 --- a/packages/layout-engine/pm-adapter/src/internal.ts +++ b/packages/layout-engine/pm-adapter/src/internal.ts @@ -201,8 +201,10 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions): // 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. - converterContext.sectionDirectionContext = - converterContext.sectionDirectionContext ?? resolveSectionDirection(firstSectPr); + // 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.