diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 43c58b74a4..dad7c4c9d8 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -5290,3 +5290,136 @@ describe('requirePageBoundary edge cases', () => { }); }); }); + +describe('alternateHeaders (odd/even header differentiation)', () => { + // Two tall paragraphs (400px each) that force a 2-page layout. + const tallBlock = (id: string): FlowBlock => ({ + kind: 'paragraph', + id, + runs: [], + }); + const tallMeasure = makeMeasure([400]); + + it('selects even/odd header heights when alternateHeaders is true', () => { + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + alternateHeaders: true, + headerContentHeights: { + odd: 80, // Odd pages: header pushes body start down + even: 40, // Even pages: smaller header + }, + }; + + const layout = layoutDocument([tallBlock('p1'), tallBlock('p2')], [tallMeasure, tallMeasure], options); + + expect(layout.pages).toHaveLength(2); + + // Page 1 is odd (documentPageNumber=1) → 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) + // 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 default header height for all pages when alternateHeaders is false', () => { + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + alternateHeaders: false, + headerContentHeights: { + default: 60, + odd: 80, + even: 40, + }, + }; + + const layout = layoutDocument([tallBlock('p1'), tallBlock('p2')], [tallMeasure, tallMeasure], options); + + expect(layout.pages).toHaveLength(2); + + // Both pages use 'default' header height (60px) + // Body start = max(50, 30+60) = 90 + const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1'); + const p2Fragment = layout.pages[1].fragments.find((f) => f.blockId === 'p2'); + expect(p1Fragment!.y).toBeCloseTo(90, 0); + expect(p2Fragment!.y).toBeCloseTo(90, 0); + }); + + it('defaults to false when alternateHeaders is omitted', () => { + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + // alternateHeaders not set + headerContentHeights: { + default: 60, + odd: 80, + even: 40, + }, + }; + + const layout = layoutDocument([tallBlock('p1'), tallBlock('p2')], [tallMeasure, tallMeasure], options); + + expect(layout.pages).toHaveLength(2); + + // Both pages should use 'default' (60px), not odd/even + const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1'); + const p2Fragment = layout.pages[1].fragments.find((f) => f.blockId === 'p2'); + expect(p1Fragment!.y).toBeCloseTo(90, 0); + expect(p2Fragment!.y).toBeCloseTo(90, 0); + }); + + it('first page uses first variant when titlePg is enabled with alternateHeaders', () => { + const sectionBreak: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'sb', + attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 }, + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + }; + + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + alternateHeaders: true, + sectionMetadata: [{ sectionIndex: 0, titlePg: true }], + headerContentHeights: { + first: 100, // First page: tallest header + odd: 80, + even: 40, + }, + }; + + const layout = layoutDocument( + [sectionBreak, tallBlock('p1'), tallBlock('p2'), tallBlock('p3')], + [{ kind: 'sectionBreak' }, tallMeasure, tallMeasure, tallMeasure], + options, + ); + + expect(layout.pages.length).toBeGreaterThanOrEqual(3); + + // 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 + // 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 + // 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); + }); +}); diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 1cb5e155ae..2ed055ce9e 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -526,6 +526,13 @@ export type LayoutOptions = { * behavior for paragraph-free overlays. */ allowSectionBreakOnlyPageFallback?: boolean; + /** + * Whether the document has odd/even header/footer differentiation enabled. + * Corresponds to the w:evenAndOddHeaders element in OOXML settings.xml. + * When true, odd pages use the 'odd' variant and even pages use the 'even' variant. + * When false or omitted, all pages use the 'default' variant. + */ + alternateHeaders?: boolean; }; export type HeaderFooterConstraints = { @@ -667,13 +674,15 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options /** * Determines the header/footer variant type for a given page based on section settings. * - * @param sectionPageNumber - The page number within the current section (1-indexed) + * @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 titlePgEnabled - Whether the section has "different first page" enabled - * @param alternateHeaders - Whether the section has odd/even differentiation enabled + * @param alternateHeaders - Whether the document has odd/even differentiation enabled * @returns The variant type: 'first', 'even', 'odd', or 'default' */ const getVariantTypeForPage = ( sectionPageNumber: number, + documentPageNumber: number, titlePgEnabled: boolean, alternateHeaders: boolean, ): 'default' | 'first' | 'even' | 'odd' => { @@ -681,9 +690,10 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options if (sectionPageNumber === 1 && titlePgEnabled) { return 'first'; } - // Alternate headers (even/odd differentiation) + // Alternate headers: even/odd based on document page number, matching + // the rendering side (getHeaderFooterTypeForSection in headerFooterUtils.ts) if (alternateHeaders) { - return sectionPageNumber % 2 === 0 ? 'even' : 'odd'; + return documentPageNumber % 2 === 0 ? 'even' : 'odd'; } return 'default'; }; @@ -1284,11 +1294,10 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // Get section metadata for titlePg setting const sectionMetadata = sectionMetadataList[activeSectionIndex]; const titlePgEnabled = sectionMetadata?.titlePg ?? false; - // TODO: Support alternateHeaders (odd/even) when needed - const alternateHeaders = false; + const alternateHeaders = options.alternateHeaders ?? false; // Determine which header/footer variant applies to this page - const variantType = getVariantTypeForPage(sectionPageNumber, titlePgEnabled, alternateHeaders); + const variantType = getVariantTypeForPage(sectionPageNumber, newPageNumber, titlePgEnabled, alternateHeaders); // Resolve header/footer refs for margin calculation using OOXML inheritance model. // This must match the rendering logic in PresentationEditor to ensure margins diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 64498efb5d..b55bfa4dfa 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -4238,10 +4238,12 @@ export class PresentationEditor extends EventEmitter { ? buildSemanticFootnoteBlocks(footnotesLayoutInput, this.#layoutOptions.semanticOptions?.footnotesMode) : []; const blocksForLayout = semanticFootnoteBlocks.length > 0 ? [...blocks, ...semanticFootnoteBlocks] : blocks; - const layoutOptions = - !isSemanticFlow && footnotesLayoutInput + const layoutOptions = { + ...(!isSemanticFlow && footnotesLayoutInput ? { ...baseLayoutOptions, footnotes: footnotesLayoutInput } - : baseLayoutOptions; + : baseLayoutOptions), + alternateHeaders: Boolean((this.#editor as EditorWithConverter).converter?.pageStyles?.alternateHeaders), + }; const previousBlocks = this.#layoutState.blocks; const previousLayout = this.#layoutState.layout; const previousMeasures = this.#layoutState.measures; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts index 0328fcb44c..e442a6f34e 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts @@ -122,6 +122,7 @@ export type ResolvedLayoutOptions = margins: ResolvedMarginsBase; columns?: { count: number; gap: number }; sectionMetadata: SectionMetadata[]; + alternateHeaders?: boolean; } | { flowMode: 'semantic';