From 2556e42f9acafe712636e726d59a881d7bd02512 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 11 May 2026 11:50:26 -0300 Subject: [PATCH 1/5] fix(layout-engine): use section-aware page number for odd/even header parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OOXML (ECMA-376 §17.10.1) selects even/odd headers based on the printed page number — which respects per-section numbering restarts and offsets — not the physical page index. Track the post-restart/offset value as `displayNumber` on each page and thread it through pagination, header/footer resolution, and the HeaderFooterSessionManager so a section that starts at page 2 picks the `even` variant on its first page. --- packages/layout-engine/contracts/src/index.ts | 3 + .../contracts/src/resolved-layout.ts | 2 + .../layout-bridge/src/headerFooterUtils.ts | 33 ++++++--- .../test/headerFooterUtils.test.ts | 46 ++++++++++++ .../layout-engine/src/index.test.ts | 71 +++++++++++++------ .../layout-engine/layout-engine/src/index.ts | 13 ++-- .../src/resolveHeaderFooter.ts | 1 + .../layout-resolved/src/resolveLayout.ts | 1 + .../HeaderFooterSessionManager.ts | 13 +++- 9 files changed, 141 insertions(+), 42 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index f22d14296e..7e4fd87a2a 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1806,6 +1806,8 @@ export type Page = { * decoration boxes anchored to the real bottom margin while the body shrinks. */ footnoteReserved?: number; + /** Numeric page number after section numbering restart/offset. Used for OOXML odd/even parity. */ + displayNumber?: number; numberText?: string; size?: { w: number; h: number }; orientation?: 'portrait' | 'landscape'; @@ -2016,6 +2018,7 @@ export type HeaderFooterType = 'default' | 'first' | 'even' | 'odd'; export type HeaderFooterPage = { number: number; fragments: Fragment[]; + displayNumber?: number; numberText?: string; /** * Optional page-local block clones backing this page's resolved fragments. diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index fb564a57e3..17c911b318 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -53,6 +53,8 @@ export type ResolvedPage = { margins?: PageMargins; /** Extra bottom space reserved for footnotes (px). Used for footer space calculation. */ footnoteReserved?: number; + /** Numeric page number after section numbering restart/offset. Used for OOXML odd/even parity. */ + displayNumber?: number; /** Formatted page number text (e.g. "i", "ii" for Roman numeral sections). */ numberText?: string; /** Vertical alignment of content within this page. */ diff --git a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts index 754eaff259..cec5fe64ab 100644 --- a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts +++ b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts @@ -64,11 +64,12 @@ export const extractIdentifierFromConverter = (converter?: ConverterLike | null) export const getHeaderFooterType = ( pageNumber: number, identifier: HeaderFooterIdentifier, - options?: { kind?: 'header' | 'footer' }, + options?: { kind?: 'header' | 'footer'; parityPageNumber?: number }, ): HeaderFooterType | null => { if (pageNumber <= 0) return null; const kind = options?.kind ?? 'header'; + const parityPageNumber = options?.parityPageNumber ?? pageNumber; const ids = kind === 'header' ? identifier.headerIds : identifier.footerIds; const hasFirst = Boolean(ids.first); @@ -83,10 +84,10 @@ export const getHeaderFooterType = ( } if (identifier.alternateHeaders) { - if (pageNumber % 2 === 0 && hasEven) { + if (parityPageNumber % 2 === 0 && hasEven) { return 'even'; } - if (pageNumber % 2 === 1 && (hasOdd || hasDefault)) { + if (parityPageNumber % 2 === 1 && (hasOdd || hasDefault)) { return hasOdd ? 'odd' : 'default'; } return null; @@ -103,10 +104,12 @@ export const resolveHeaderFooterForPage = ( layout: Layout, pageIndex: number, identifier: HeaderFooterIdentifier, - options?: { kind?: 'header' | 'footer' }, + options?: { kind?: 'header' | 'footer'; parityPageNumber?: number }, ) => { - const pageNumber = layout.pages[pageIndex]?.number ?? pageIndex + 1; - const type = getHeaderFooterType(pageNumber, identifier, options); + const layoutPage = layout.pages[pageIndex]; + const pageNumber = layoutPage?.number ?? pageIndex + 1; + const parityPageNumber = options?.parityPageNumber ?? layoutPage?.displayNumber ?? pageNumber; + const type = getHeaderFooterType(pageNumber, identifier, { ...options, parityPageNumber }); if (!type) { return null; } @@ -295,7 +298,7 @@ export function buildMultiSectionIdentifier( * This function determines which header/footer variant (default, first, even, odd) * should be used for a given page number within a specific section. It respects: * - Per-section titlePg (first page of section uses 'first' variant) - * - Alternate headers (even/odd pages based on physical page number) + * - Alternate headers (even/odd pages based on section-aware page numbering) * - Fallback to default variant * * **Important**: When `titlePg` is enabled, this function returns 'first' even if the @@ -307,7 +310,7 @@ export function buildMultiSectionIdentifier( * @param pageNumber - Physical page number (1-indexed) * @param sectionIndex - Index of the section this page belongs to * @param identifier - Multi-section identifier with per-section mappings - * @param options - Optional settings (kind: 'header' | 'footer', sectionPageNumber) + * @param options - Optional settings (kind, sectionPageNumber, parityPageNumber) * @returns HeaderFooterType ('default' | 'first' | 'even' | 'odd') or null if no header/footer content exists * * @example @@ -326,12 +329,13 @@ export function getHeaderFooterTypeForSection( pageNumber: number, sectionIndex: number, identifier: MultiSectionHeaderFooterIdentifier, - options?: { kind?: 'header' | 'footer'; sectionPageNumber?: number }, + options?: { kind?: 'header' | 'footer'; sectionPageNumber?: number; parityPageNumber?: number }, ): HeaderFooterType | null { if (pageNumber <= 0) return null; const kind = options?.kind ?? 'header'; const sectionPageNumber = options?.sectionPageNumber ?? pageNumber; + const parityPageNumber = options?.parityPageNumber ?? pageNumber; // Get section-specific IDs, falling back to legacy IDs for backward compatibility const sectionIds = @@ -381,7 +385,7 @@ export function getHeaderFooterTypeForSection( // Keep parity-based variant selection even when this section doesn't // explicitly define that variant. Resolution/inheritance happens later. if (!hasAny) return null; - return pageNumber % 2 === 0 ? 'even' : 'odd'; + return parityPageNumber % 2 === 0 ? 'even' : 'odd'; } if (hasDefault) { @@ -418,11 +422,13 @@ export function getHeaderFooterIdForPage( const kind = options?.kind ?? 'header'; const sectionIndex = page.sectionIndex ?? 0; const sectionPageNumber = options?.sectionPageNumber ?? page.number; + const parityPageNumber = page.displayNumber ?? page.number; // Determine which variant type to use (default, first, even, odd) const variantType = getHeaderFooterTypeForSection(page.number, sectionIndex, identifier, { kind, sectionPageNumber, + parityPageNumber, }); if (!variantType) return null; @@ -505,9 +511,14 @@ export function resolveHeaderFooterForPageAndSection( } const firstPageInSection = sectionFirstPageNumbers.get(sectionIndex); const sectionPageNumber = typeof firstPageInSection === 'number' ? pageNumber - firstPageInSection + 1 : pageNumber; + const parityPageNumber = page.displayNumber ?? pageNumber; // Determine variant type for this section - const type = getHeaderFooterTypeForSection(pageNumber, sectionIndex, identifier, { kind, sectionPageNumber }); + const type = getHeaderFooterTypeForSection(pageNumber, sectionIndex, identifier, { + kind, + sectionPageNumber, + parityPageNumber, + }); if (!type) return null; // Get content ID for this page/section diff --git a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts index b731147d6a..0a68700949 100644 --- a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts +++ b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts @@ -72,6 +72,15 @@ describe('headerFooterUtils', () => { expect(getHeaderFooterType(3, identifier)).toBe('odd'); }); + it('uses display page number parity when provided', () => { + const identifier = extractIdentifierFromConverter({ + headerIds: { default: 'rId1', even: 'rIdEven', odd: 'rIdOdd' }, + pageStyles: { alternateHeaders: true }, + }); + + expect(getHeaderFooterType(1, identifier, { parityPageNumber: 2 })).toBe('even'); + }); + it('uses default only for odd pages when alternating slots are missing', () => { const identifier = extractIdentifierFromConverter({ headerIds: { default: 'rId1' }, @@ -687,6 +696,43 @@ describe('headerFooterUtils', () => { expect(oddPageHeader?.contentId).toBe('h0-default'); }); + it('uses section-aware display page number for odd/even parity', () => { + const sectionMetadata: SectionMetadata[] = [ + { + sectionIndex: 0, + headerRefs: { default: 'h0-odd', even: 'h0-even' }, + }, + ]; + + const identifier = buildMultiSectionIdentifier(sectionMetadata, { alternateHeaders: true }); + const layout: Layout = { + pageSize: { w: 600, h: 800 }, + pages: [ + { + number: 1, + displayNumber: 2, + fragments: [], + sectionIndex: 0, + sectionRefs: { headerRefs: { default: 'h0-odd', even: 'h0-even' } }, + }, + ], + headerFooter: { + even: { pages: [{ number: 1, fragments: [] }] }, + }, + }; + + const type = getHeaderFooterTypeForSection(1, 0, identifier, { + kind: 'header', + sectionPageNumber: 1, + parityPageNumber: 2, + }); + const evenPageHeader = resolveHeaderFooterForPageAndSection(layout, 0, identifier, { kind: 'header' }); + + expect(type).toBe('even'); + expect(evenPageHeader?.type).toBe('even'); + expect(evenPageHeader?.contentId).toBe('h0-even'); + }); + it('does not use section default content id for even pages when alternate header even ref is missing', () => { const sectionMetadata: SectionMetadata[] = [ { diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index f6e3f4fe97..0be800dbae 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -6077,19 +6077,46 @@ describe('alternateHeaders (odd/even header differentiation)', () => { expect(layout.pages).toHaveLength(2); - // Page 1 is odd (documentPageNumber=1) → uses 'odd' header height (80px) + // Page 1 has display number 1 (odd) -> uses 'odd' header height (80px) // Body should start at max(margin.top, margin.header + headerContentHeight) = max(50, 30+80) = 110 const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1'); expect(p1Fragment).toBeDefined(); expect(p1Fragment!.y).toBeCloseTo(110, 0); - // Page 2 is even (documentPageNumber=2) → uses 'even' header height (40px) + // Page 2 has display number 2 (even) -> uses 'even' header height (40px) // Body should start at max(margin.top, margin.header + headerContentHeight) = max(50, 30+40) = 70 const p2Fragment = layout.pages[1].fragments.find((f) => f.blockId === 'p2'); expect(p2Fragment).toBeDefined(); expect(p2Fragment!.y).toBeCloseTo(70, 0); }); + it('uses section page-numbering start for odd/even header parity', () => { + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + alternateHeaders: true, + sectionMetadata: [{ sectionIndex: 0, numbering: { start: 2 } }], + headerContentHeights: { + odd: 80, + even: 40, + }, + }; + + const layout = layoutDocument([tallBlock('p1'), tallBlock('p2')], [tallMeasure, tallMeasure], options); + + expect(layout.pages).toHaveLength(2); + expect(layout.pages[0].displayNumber).toBe(2); + expect(layout.pages[0].numberText).toBe('2'); + expect(layout.pages[1].displayNumber).toBe(3); + + const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1'); + const p2Fragment = layout.pages[1].fragments.find((f) => f.blockId === 'p2'); + expect(p1Fragment).toBeDefined(); + expect(p2Fragment).toBeDefined(); + expect(p1Fragment!.y).toBeCloseTo(70, 0); + expect(p2Fragment!.y).toBeCloseTo(110, 0); + }); + it('uses default header height for all pages when alternateHeaders is false', () => { const options: LayoutOptions = { pageSize: { w: 600, h: 800 }, @@ -6166,29 +6193,29 @@ describe('alternateHeaders (odd/even header differentiation)', () => { expect(layout.pages.length).toBeGreaterThanOrEqual(3); - // Page 1 (first page of section, titlePg=true) → 'first' variant → 100px + // Page 1 (first page of section, titlePg=true) -> 'first' variant -> 100px // Body start = max(50, 30+100) = 130 const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1'); expect(p1Fragment).toBeDefined(); expect(p1Fragment!.y).toBeCloseTo(130, 0); - // Page 2 (documentPageNumber=2, even) → 'even' variant → 40px + // Page 2 has display number 2 (even) -> 'even' variant -> 40px // Body start = max(50, 30+40) = 70 const p2Fragment = layout.pages[1].fragments.find((f) => f.blockId === 'p2'); expect(p2Fragment).toBeDefined(); expect(p2Fragment!.y).toBeCloseTo(70, 0); - // Page 3 (documentPageNumber=3, odd) → 'odd' variant → 80px + // Page 3 has display number 3 (odd) -> 'odd' variant -> 80px // Body start = max(50, 30+80) = 110 const p3Fragment = layout.pages[2].fragments.find((f) => f.blockId === 'p3'); expect(p3Fragment).toBeDefined(); expect(p3Fragment!.y).toBeCloseTo(110, 0); }); - it('multi-section: uses document page number for even/odd, not section-relative', () => { + it('multi-section: uses display page number for even/odd, not section-relative', () => { // Section 1 has 3 pages (pages 1-3), section 2 starts on page 4. - // Page 4 is even by document number, but sectionPageNumber=1 (odd). - // The fix ensures document page number is used for even/odd. + // Page 4 has display number 4 (even), but sectionPageNumber=1 (odd). + // The fix ensures the page-numbering value is used for even/odd. const sb1: SectionBreakBlock = { kind: 'sectionBreak', id: 'sb1', @@ -6224,7 +6251,7 @@ describe('alternateHeaders (odd/even header differentiation)', () => { expect(layout.pages.length).toBeGreaterThanOrEqual(4); - // Page 4 (documentPageNumber=4, even) → should use 'even' header (40px) + // Page 4 has display number 4 (even) -> should use 'even' header (40px) // NOT 'odd' which would happen if sectionPageNumber (1) were used // Body start = max(50, 30+40) = 70 const p4Fragment = layout.pages[3]?.fragments.find((f) => f.blockId === 'p4'); @@ -6253,8 +6280,8 @@ describe('alternateHeaders (odd/even header differentiation)', () => { expect(layout.pages).toHaveLength(2); - // Page 1 is odd → 'odd' footer (80px) → bottom = max(50, 30+80) = 110 - // Page 2 is even → 'even' footer (40px) → bottom = max(50, 30+40) = 70 + // Page 1 has display number 1 (odd) -> 'odd' footer (80px) -> bottom = max(50, 30+80) = 110 + // Page 2 has display number 2 (even) -> 'even' footer (40px) -> bottom = max(50, 30+40) = 70 // Body-top Y is footer-independent, so assert on the effective bottom margin // the paginator stamped on each page. expect(layout.pages[0].margins?.bottom).toBeCloseTo(110, 0); @@ -6308,14 +6335,14 @@ describe('alternateHeaders (odd/even header differentiation)', () => { }); it('multi-section + titlePg + alternateHeaders: first page of section 2 lands on an even doc-page', () => { - // Most realistic mixed case. Section 1 has 3 pages (docPN 1-3). Section 2 - // has titlePg=true and starts on docPN=4. - // - Page 4 is sectionPageNumber=1 for section 2 + titlePg=true → 'first' - // - Page 5 is docPN=5 (odd) → 'odd' (regardless of section-relative number) - // - Page 6 is docPN=6 (even) → 'even' + // Most realistic mixed case. Section 1 has 3 pages (display numbers 1-3). Section 2 + // has titlePg=true and starts with display number 4. + // - Page 4 is sectionPageNumber=1 for section 2 + titlePg=true -> 'first' + // - Page 5 has display number 5 (odd) -> 'odd' (regardless of section-relative number) + // - Page 6 has display number 6 (even) -> 'even' // If the code used sectionPageNumber for even/odd, pages 5 and 6 would be // swapped (section-relative 2 and 3 respectively). This guards both titlePg - // and the docPN rule across a section boundary. + // and the page-numbering parity rule across a section boundary. const sb1: SectionBreakBlock = { kind: 'sectionBreak', id: 'sb1', @@ -6361,19 +6388,19 @@ describe('alternateHeaders (odd/even header differentiation)', () => { expect(layout.pages.length).toBeGreaterThanOrEqual(6); - // Page 4: section 2 first page + titlePg → 'first' (100px) → y = max(50, 30+100) = 130 + // Page 4: section 2 first page + titlePg -> 'first' (100px) -> y = max(50, 30+100) = 130 const p4Fragment = layout.pages[3]?.fragments.find((f) => f.blockId === 'p4'); expect(p4Fragment).toBeDefined(); expect(p4Fragment!.y).toBeCloseTo(130, 0); - // Page 5: docPN=5, odd → 'odd' (80px) → y = max(50, 30+80) = 110 - // If sectionPageNumber were used: sectionPN=2 → 'even' (40) → y = 70 (wrong) + // Page 5: display number 5, odd -> 'odd' (80px) -> y = max(50, 30+80) = 110 + // If sectionPageNumber were used: sectionPN=2 -> 'even' (40) -> y = 70 (wrong) const p5Fragment = layout.pages[4]?.fragments.find((f) => f.blockId === 'p5'); expect(p5Fragment).toBeDefined(); expect(p5Fragment!.y).toBeCloseTo(110, 0); - // Page 6: docPN=6, even → 'even' (40px) → y = max(50, 30+40) = 70 - // If sectionPageNumber were used: sectionPN=3 → 'odd' (80) → y = 110 (wrong) + // Page 6: display number 6, even -> 'even' (40px) -> y = max(50, 30+40) = 70 + // If sectionPageNumber were used: sectionPN=3 -> 'odd' (80) -> y = 110 (wrong) const p6Fragment = layout.pages[5]?.fragments.find((f) => f.blockId === 'p6'); expect(p6Fragment).toBeDefined(); expect(p6Fragment!.y).toBeCloseTo(70, 0); diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index c6d5e91903..973d28e11b 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -704,14 +704,14 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options * names and types — a positional call site is easy to get wrong. * * @param sectionPageNumber - The page number within the current section (1-indexed), used for titlePg - * @param documentPageNumber - The absolute document page number (1-indexed), used for even/odd + * @param parityPageNumber - The section-aware page number used for even/odd * @param titlePgEnabled - Whether the section has "different first page" enabled * @param alternateHeaders - Whether the document has odd/even differentiation enabled * @returns The variant type: 'first', 'even', 'odd', or 'default' */ const getVariantTypeForPage = (args: { sectionPageNumber: number; - documentPageNumber: number; + parityPageNumber: number; titlePgEnabled: boolean; alternateHeaders: boolean; }): 'default' | 'first' | 'even' | 'odd' => { @@ -719,10 +719,10 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options if (args.sectionPageNumber === 1 && args.titlePgEnabled) { return 'first'; } - // Alternate headers: even/odd based on document page number, matching - // the rendering side (getHeaderFooterTypeForSection in headerFooterUtils.ts) + // Alternate headers: even/odd based on the section-aware page number, + // matching ECMA-376 section 17.10.1. if (args.alternateHeaders) { - return args.documentPageNumber % 2 === 0 ? 'even' : 'odd'; + return args.parityPageNumber % 2 === 0 ? 'even' : 'odd'; } return 'default'; }; @@ -1390,7 +1390,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // Determine which header/footer variant applies to this page const variantType = getVariantTypeForPage({ sectionPageNumber, - documentPageNumber: newPageNumber, + parityPageNumber: activePageCounter, titlePgEnabled, alternateHeaders, }); @@ -1481,6 +1481,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // second callback: after page creation -> stamp display number, section refs, section index, and advance counter if (state?.page) { + state.page.displayNumber = activePageCounter; state.page.numberText = formatPageNumber(activePageCounter, activeNumberFormat); // Stamp section index on the page for section-aware page numbering and header/footer selection state.page.sectionIndex = activeSectionIndex; diff --git a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts index 9988a337c3..695abb1b82 100644 --- a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts +++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts @@ -27,6 +27,7 @@ export function resolveHeaderFooterLayout( return { number: page.number, + displayNumber: page.displayNumber, numberText: page.numberText, items: page.fragments.map((fragment, fragmentIndex) => resolveFragmentItem(fragment, fragmentIndex, page.number - 1, blockMap, blockVersionCache), diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 28f63bb75b..d38d68dba3 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -318,6 +318,7 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout { ), margins: page.margins, footnoteReserved: page.footnoteReserved, + displayNumber: page.displayNumber, numberText: page.numberText, vAlign: page.vAlign, baseMargins: page.baseMargins, diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts index e53ef72288..c3d328781b 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts @@ -1707,7 +1707,8 @@ export class HeaderFooterSessionManager { return 'first'; } if (hasAlternateHeaders) { - return page.number % 2 === 0 ? 'even' : 'odd'; + const parityPageNumber = page.displayNumber ?? page.number; + return parityPageNumber % 2 === 0 ? 'even' : 'odd'; } return 'default'; } @@ -2202,6 +2203,7 @@ export class HeaderFooterSessionManager { pageSize: { w: pageWidth, h: pageHeight }, pages: activeLayoutResult.layout.pages.map((page: Page) => ({ number: page.number, + displayNumber: page.displayNumber, numberText: page.numberText, fragments: page.fragments, })), @@ -2339,9 +2341,14 @@ export class HeaderFooterSessionManager { const firstPageInSection = sectionFirstPageNumbers.get(sectionIndex); const sectionPageNumber = typeof firstPageInSection === 'number' ? pageNumber - firstPageInSection + 1 : pageNumber; + const parityPageNumber = page?.displayNumber ?? pageNumber; const headerFooterType = multiSectionId - ? getHeaderFooterTypeForSection(pageNumber, sectionIndex, multiSectionId, { kind, sectionPageNumber }) - : getHeaderFooterType(pageNumber, legacyIdentifier, { kind }); + ? getHeaderFooterTypeForSection(pageNumber, sectionIndex, multiSectionId, { + kind, + sectionPageNumber, + parityPageNumber, + }) + : getHeaderFooterType(pageNumber, legacyIdentifier, { kind, parityPageNumber }); // Resolve section-specific rId using Word's OOXML inheritance model let sectionRId: string | undefined; From b1bfe474d102e93619e9e39ddef584839e9255ef Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 11 May 2026 13:20:51 -0300 Subject: [PATCH 2/5] fix(layout-bridge): handle negative odd header parity --- .../layout-engine/layout-bridge/src/headerFooterUtils.ts | 2 +- .../layout-bridge/test/headerFooterUtils.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts index cec5fe64ab..4e44421fed 100644 --- a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts +++ b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts @@ -87,7 +87,7 @@ export const getHeaderFooterType = ( if (parityPageNumber % 2 === 0 && hasEven) { return 'even'; } - if (parityPageNumber % 2 === 1 && (hasOdd || hasDefault)) { + if (parityPageNumber % 2 !== 0 && (hasOdd || hasDefault)) { return hasOdd ? 'odd' : 'default'; } return null; diff --git a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts index 0a68700949..91191a5470 100644 --- a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts +++ b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts @@ -81,6 +81,15 @@ describe('headerFooterUtils', () => { expect(getHeaderFooterType(1, identifier, { parityPageNumber: 2 })).toBe('even'); }); + it('treats negative odd display page numbers as odd', () => { + const identifier = extractIdentifierFromConverter({ + headerIds: { default: 'rId1', even: 'rIdEven', odd: 'rIdOdd' }, + pageStyles: { alternateHeaders: true }, + }); + + expect(getHeaderFooterType(1, identifier, { parityPageNumber: -1 })).toBe('odd'); + }); + it('uses default only for odd pages when alternating slots are missing', () => { const identifier = extractIdentifierFromConverter({ headerIds: { default: 'rId1' }, From f6df9d7ec56fd97f74ead755d801fc57924ab062 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 11 May 2026 14:11:27 -0300 Subject: [PATCH 3/5] fix(layout-resolved): expose header footer display numbers --- .../layout-engine/contracts/src/resolved-layout.ts | 2 ++ .../layout-resolved/src/resolveHeaderFooter.test.ts | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index 17c911b318..beebde5fd3 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -425,6 +425,8 @@ export function isResolvedDrawingItem(item: ResolvedPaintItem): item is Resolved /** A resolved header/footer page — mirrors HeaderFooterPage but with resolved items. */ export type ResolvedHeaderFooterPage = { number: number; + /** Numeric page number after section numbering restart/offset. Used for OOXML odd/even parity. */ + displayNumber?: number; numberText?: string; items: ResolvedPaintItem[]; }; diff --git a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts index 7862da9026..17d080c580 100644 --- a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts @@ -64,6 +64,16 @@ describe('resolveHeaderFooterLayout', () => { expect(result.pages[1].numberText).toBe('ii'); }); + it('preserves displayNumber on pages', () => { + const layout: HeaderFooterLayout = { + height: 50, + pages: [{ number: 1, displayNumber: 2, fragments: [] }], + }; + + const result = resolveHeaderFooterLayout(layout, [], []); + expect(result.pages[0].displayNumber).toBe(2); + }); + it('returns empty items array for empty fragments array', () => { const layout: HeaderFooterLayout = { height: 50, From 566ea8b157968e3e08fd92698c813a1456980a94 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 11 May 2026 14:47:53 -0300 Subject: [PATCH 4/5] test(super-editor): cover header footer display parity --- .../tests/HeaderFooterSessionManager.test.ts | 119 +++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts index 4c447fceb7..b94b3a2527 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts @@ -23,7 +23,7 @@ import type { ResolvedLayout, ResolvedPage, } from '@superdoc/contracts'; -import type { HeaderFooterLayoutResult } from '@superdoc/layout-bridge'; +import { buildMultiSectionIdentifier, type HeaderFooterLayoutResult } from '@superdoc/layout-bridge'; import { HeaderFooterSessionManager, type SessionManagerDependencies, @@ -929,6 +929,86 @@ describe('HeaderFooterSessionManager', () => { expect(payload!.items).toHaveLength(2); expect(payload!.items!.every((item) => item.blockId === 'p1')).toBe(true); }); + + it('uses displayNumber parity when resolving per-rId header layouts', () => { + const deps: SessionManagerDependencies = { + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 1), + }; + + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: createMainEditorStub(), + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + manager.setDependencies(deps); + manager.setMultiSectionIdentifier( + buildMultiSectionIdentifier([{ sectionIndex: 0, headerRefs: { default: 'rId-default', even: 'rId-even' } }], { + alternateHeaders: true, + }), + ); + + const evenFragment: ParaFragment = { + kind: 'para', + blockId: 'even-header', + fromLine: 0, + toLine: 1, + x: 72, + y: 10, + width: 468, + }; + manager.headerLayoutsByRId.set('rId-default', buildHeaderResult()); + manager.headerLayoutsByRId.set('rId-even', { + kind: 'header', + type: 'even', + layout: { height: 50, pages: [{ number: 1, fragments: [evenFragment] }] }, + blocks: [{ kind: 'paragraph', id: 'even-header', runs: [] }], + measures: [ + { + kind: 'paragraph', + lines: [ + { fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 100, ascent: 10, descent: 3, lineHeight: 18 }, + ], + totalHeight: 18, + }, + ], + }); + + const page = { + number: 1, + displayNumber: 2, + sectionIndex: 0, + height: 792, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + sectionRefs: { headerRefs: { default: 'rId-default', even: 'rId-even' }, footerRefs: {} }, + } as unknown as ResolvedPage; + const layout: ResolvedLayout = { + version: 1, + flowMode: 'paginated', + pageGap: 0, + pages: [page], + }; + + const provider = manager.createDecorationProvider('header', layout); + const payload = provider!(1, page.margins, page); + + expect(payload).not.toBeNull(); + expect(payload!.sectionType).toBe('even'); + expect(payload!.headerFooterRefId).toBe('rId-even'); + expect(payload!.fragments[0]!.blockId).toBe('even-header'); + }); }); describe('rebuildRegions — ResolvedLayout entry', () => { @@ -1033,5 +1113,42 @@ describe('HeaderFooterSessionManager', () => { expect(manager.headerRegions.get(2)!.sectionIndex).toBe(1); expect(manager.footerRegions.get(2)!.sectionIndex).toBe(1); }); + + it('uses displayNumber parity when inferring header/footer region variants', () => { + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: { + ...createMainEditorStub(), + converter: { pageStyles: { alternateHeaders: true } }, + } as unknown as Editor, + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + manager.setDependencies({ + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 1), + }); + + manager.rebuildRegions({ + version: 1, + flowMode: 'paginated', + pageGap: 0, + pages: [makePage({ number: 1, displayNumber: 2, height: 792 })], + }); + + expect(manager.headerRegions.get(0)!.sectionType).toBe('even'); + expect(manager.footerRegions.get(0)!.sectionType).toBe('even'); + }); }); }); From 23a279f0f90ae1ce50c7c92f24d79e3807f7ede0 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 11 May 2026 14:49:57 -0300 Subject: [PATCH 5/5] fix(layout-bridge): allow section parity override --- .../layout-bridge/src/headerFooterUtils.ts | 14 ++++---- .../test/headerFooterUtils.test.ts | 33 +++++++++++++++++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts index 4e44421fed..384c467e33 100644 --- a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts +++ b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts @@ -404,7 +404,7 @@ export function getHeaderFooterTypeForSection( * * @param page - The Page object containing sectionIndex and sectionRefs * @param identifier - Multi-section identifier (can be used for variant resolution) - * @param options - Optional settings (kind: 'header' | 'footer') + * @param options - Optional settings (kind, sectionPageNumber, parityPageNumber) * @returns The content ID string, or null if not available * * @example @@ -417,12 +417,12 @@ export function getHeaderFooterTypeForSection( export function getHeaderFooterIdForPage( page: Page, identifier: MultiSectionHeaderFooterIdentifier, - options?: { kind?: 'header' | 'footer'; sectionPageNumber?: number }, + options?: { kind?: 'header' | 'footer'; sectionPageNumber?: number; parityPageNumber?: number }, ): string | null { const kind = options?.kind ?? 'header'; const sectionIndex = page.sectionIndex ?? 0; const sectionPageNumber = options?.sectionPageNumber ?? page.number; - const parityPageNumber = page.displayNumber ?? page.number; + const parityPageNumber = options?.parityPageNumber ?? page.displayNumber ?? page.number; // Determine which variant type to use (default, first, even, odd) const variantType = getHeaderFooterTypeForSection(page.number, sectionIndex, identifier, { @@ -469,7 +469,7 @@ export function getHeaderFooterIdForPage( * @param layout - The complete Layout object with pages and headerFooter slots * @param pageIndex - Index of the page in layout.pages array (0-indexed) * @param identifier - Multi-section identifier with per-section mappings - * @param options - Optional settings (kind: 'header' | 'footer') + * @param options - Optional settings (kind, parityPageNumber) * @returns Resolution result with type, layout slot, page, and section info, or null * * @example @@ -488,7 +488,7 @@ export function resolveHeaderFooterForPageAndSection( layout: Layout, pageIndex: number, identifier: MultiSectionHeaderFooterIdentifier, - options?: { kind?: 'header' | 'footer' }, + options?: { kind?: 'header' | 'footer'; parityPageNumber?: number }, ): { type: HeaderFooterType; layout: NonNullable[HeaderFooterType]>; @@ -511,7 +511,7 @@ export function resolveHeaderFooterForPageAndSection( } const firstPageInSection = sectionFirstPageNumbers.get(sectionIndex); const sectionPageNumber = typeof firstPageInSection === 'number' ? pageNumber - firstPageInSection + 1 : pageNumber; - const parityPageNumber = page.displayNumber ?? pageNumber; + const parityPageNumber = options?.parityPageNumber ?? page.displayNumber ?? pageNumber; // Determine variant type for this section const type = getHeaderFooterTypeForSection(pageNumber, sectionIndex, identifier, { @@ -522,7 +522,7 @@ export function resolveHeaderFooterForPageAndSection( if (!type) return null; // Get content ID for this page/section - const contentId = getHeaderFooterIdForPage(page, identifier, { kind, sectionPageNumber }); + const contentId = getHeaderFooterIdForPage(page, identifier, { kind, sectionPageNumber, parityPageNumber }); // Look up the header/footer layout slot const slot = layout.headerFooter?.[type]; diff --git a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts index 91191a5470..8ce200a8ab 100644 --- a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts +++ b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts @@ -742,6 +742,39 @@ describe('headerFooterUtils', () => { expect(evenPageHeader?.contentId).toBe('h0-even'); }); + it('allows callers to override section-aware odd/even parity', () => { + const sectionMetadata: SectionMetadata[] = [ + { + sectionIndex: 0, + headerRefs: { default: 'h0-odd', even: 'h0-even' }, + }, + ]; + + const identifier = buildMultiSectionIdentifier(sectionMetadata, { alternateHeaders: true }); + const layout: Layout = { + pageSize: { w: 600, h: 800 }, + pages: [ + { + number: 1, + fragments: [], + sectionIndex: 0, + sectionRefs: { headerRefs: { default: 'h0-odd', even: 'h0-even' } }, + }, + ], + headerFooter: { + even: { pages: [{ number: 1, fragments: [] }] }, + }, + }; + + const evenPageHeader = resolveHeaderFooterForPageAndSection(layout, 0, identifier, { + kind: 'header', + parityPageNumber: 2, + }); + + expect(evenPageHeader?.type).toBe('even'); + expect(evenPageHeader?.contentId).toBe('h0-even'); + }); + it('does not use section default content id for even pages when alternate header even ref is missing', () => { const sectionMetadata: SectionMetadata[] = [ {