Skip to content

Commit b0f0936

Browse files
committed
feat(layout): enable odd/even header-footer support (w:evenAndOddHeaders)
Thread alternateHeaders from document settings through the layout engine so the paginator selects the correct header/footer variant per page. Fixes margin calculation for documents with different odd and even page headers. Also fixes getVariantTypeForPage to use document page number (not section-relative) for even/odd selection, matching the rendering side (headerFooterUtils.ts). Closes #2803
1 parent 47777f5 commit b0f0936

File tree

4 files changed

+155
-10
lines changed

4 files changed

+155
-10
lines changed

packages/layout-engine/layout-engine/src/index.test.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5290,3 +5290,136 @@ describe('requirePageBoundary edge cases', () => {
52905290
});
52915291
});
52925292
});
5293+
5294+
describe('alternateHeaders (odd/even header differentiation)', () => {
5295+
// Two tall paragraphs (400px each) that force a 2-page layout.
5296+
const tallBlock = (id: string): FlowBlock => ({
5297+
kind: 'paragraph',
5298+
id,
5299+
runs: [],
5300+
});
5301+
const tallMeasure = makeMeasure([400]);
5302+
5303+
it('selects even/odd header heights when alternateHeaders is true', () => {
5304+
const options: LayoutOptions = {
5305+
pageSize: { w: 600, h: 800 },
5306+
margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
5307+
alternateHeaders: true,
5308+
headerContentHeights: {
5309+
odd: 80, // Odd pages: header pushes body start down
5310+
even: 40, // Even pages: smaller header
5311+
},
5312+
};
5313+
5314+
const layout = layoutDocument([tallBlock('p1'), tallBlock('p2')], [tallMeasure, tallMeasure], options);
5315+
5316+
expect(layout.pages).toHaveLength(2);
5317+
5318+
// Page 1 is odd (documentPageNumber=1) → uses 'odd' header height (80px)
5319+
// Body should start at max(margin.top, margin.header + headerContentHeight) = max(50, 30+80) = 110
5320+
const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1');
5321+
expect(p1Fragment).toBeDefined();
5322+
expect(p1Fragment!.y).toBeCloseTo(110, 0);
5323+
5324+
// Page 2 is even (documentPageNumber=2) → uses 'even' header height (40px)
5325+
// Body should start at max(margin.top, margin.header + headerContentHeight) = max(50, 30+40) = 70
5326+
const p2Fragment = layout.pages[1].fragments.find((f) => f.blockId === 'p2');
5327+
expect(p2Fragment).toBeDefined();
5328+
expect(p2Fragment!.y).toBeCloseTo(70, 0);
5329+
});
5330+
5331+
it('uses default header height for all pages when alternateHeaders is false', () => {
5332+
const options: LayoutOptions = {
5333+
pageSize: { w: 600, h: 800 },
5334+
margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
5335+
alternateHeaders: false,
5336+
headerContentHeights: {
5337+
default: 60,
5338+
odd: 80,
5339+
even: 40,
5340+
},
5341+
};
5342+
5343+
const layout = layoutDocument([tallBlock('p1'), tallBlock('p2')], [tallMeasure, tallMeasure], options);
5344+
5345+
expect(layout.pages).toHaveLength(2);
5346+
5347+
// Both pages use 'default' header height (60px)
5348+
// Body start = max(50, 30+60) = 90
5349+
const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1');
5350+
const p2Fragment = layout.pages[1].fragments.find((f) => f.blockId === 'p2');
5351+
expect(p1Fragment!.y).toBeCloseTo(90, 0);
5352+
expect(p2Fragment!.y).toBeCloseTo(90, 0);
5353+
});
5354+
5355+
it('defaults to false when alternateHeaders is omitted', () => {
5356+
const options: LayoutOptions = {
5357+
pageSize: { w: 600, h: 800 },
5358+
margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
5359+
// alternateHeaders not set
5360+
headerContentHeights: {
5361+
default: 60,
5362+
odd: 80,
5363+
even: 40,
5364+
},
5365+
};
5366+
5367+
const layout = layoutDocument([tallBlock('p1'), tallBlock('p2')], [tallMeasure, tallMeasure], options);
5368+
5369+
expect(layout.pages).toHaveLength(2);
5370+
5371+
// Both pages should use 'default' (60px), not odd/even
5372+
const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1');
5373+
const p2Fragment = layout.pages[1].fragments.find((f) => f.blockId === 'p2');
5374+
expect(p1Fragment!.y).toBeCloseTo(90, 0);
5375+
expect(p2Fragment!.y).toBeCloseTo(90, 0);
5376+
});
5377+
5378+
it('first page uses first variant when titlePg is enabled with alternateHeaders', () => {
5379+
const sectionBreak: SectionBreakBlock = {
5380+
kind: 'sectionBreak',
5381+
id: 'sb',
5382+
attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 },
5383+
pageSize: { w: 600, h: 800 },
5384+
margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
5385+
};
5386+
5387+
const options: LayoutOptions = {
5388+
pageSize: { w: 600, h: 800 },
5389+
margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
5390+
alternateHeaders: true,
5391+
sectionMetadata: [{ sectionIndex: 0, titlePg: true }],
5392+
headerContentHeights: {
5393+
first: 100, // First page: tallest header
5394+
odd: 80,
5395+
even: 40,
5396+
},
5397+
};
5398+
5399+
const layout = layoutDocument(
5400+
[sectionBreak, tallBlock('p1'), tallBlock('p2'), tallBlock('p3')],
5401+
[{ kind: 'sectionBreak' }, tallMeasure, tallMeasure, tallMeasure],
5402+
options,
5403+
);
5404+
5405+
expect(layout.pages.length).toBeGreaterThanOrEqual(3);
5406+
5407+
// Page 1 (first page of section, titlePg=true) → 'first' variant → 100px
5408+
// Body start = max(50, 30+100) = 130
5409+
const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1');
5410+
expect(p1Fragment).toBeDefined();
5411+
expect(p1Fragment!.y).toBeCloseTo(130, 0);
5412+
5413+
// Page 2 (documentPageNumber=2, even) → 'even' variant → 40px
5414+
// Body start = max(50, 30+40) = 70
5415+
const p2Fragment = layout.pages[1].fragments.find((f) => f.blockId === 'p2');
5416+
expect(p2Fragment).toBeDefined();
5417+
expect(p2Fragment!.y).toBeCloseTo(70, 0);
5418+
5419+
// Page 3 (documentPageNumber=3, odd) → 'odd' variant → 80px
5420+
// Body start = max(50, 30+80) = 110
5421+
const p3Fragment = layout.pages[2].fragments.find((f) => f.blockId === 'p3');
5422+
expect(p3Fragment).toBeDefined();
5423+
expect(p3Fragment!.y).toBeCloseTo(110, 0);
5424+
});
5425+
});

packages/layout-engine/layout-engine/src/index.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,13 @@ export type LayoutOptions = {
526526
* behavior for paragraph-free overlays.
527527
*/
528528
allowSectionBreakOnlyPageFallback?: boolean;
529+
/**
530+
* Whether the document has odd/even header/footer differentiation enabled.
531+
* Corresponds to the w:evenAndOddHeaders element in OOXML settings.xml.
532+
* When true, odd pages use the 'odd' variant and even pages use the 'even' variant.
533+
* When false or omitted, all pages use the 'default' variant.
534+
*/
535+
alternateHeaders?: boolean;
529536
};
530537

531538
export type HeaderFooterConstraints = {
@@ -667,23 +674,26 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
667674
/**
668675
* Determines the header/footer variant type for a given page based on section settings.
669676
*
670-
* @param sectionPageNumber - The page number within the current section (1-indexed)
677+
* @param sectionPageNumber - The page number within the current section (1-indexed), used for titlePg
678+
* @param documentPageNumber - The absolute document page number (1-indexed), used for even/odd
671679
* @param titlePgEnabled - Whether the section has "different first page" enabled
672-
* @param alternateHeaders - Whether the section has odd/even differentiation enabled
680+
* @param alternateHeaders - Whether the document has odd/even differentiation enabled
673681
* @returns The variant type: 'first', 'even', 'odd', or 'default'
674682
*/
675683
const getVariantTypeForPage = (
676684
sectionPageNumber: number,
685+
documentPageNumber: number,
677686
titlePgEnabled: boolean,
678687
alternateHeaders: boolean,
679688
): 'default' | 'first' | 'even' | 'odd' => {
680689
// First page of section with titlePg enabled uses 'first' variant
681690
if (sectionPageNumber === 1 && titlePgEnabled) {
682691
return 'first';
683692
}
684-
// Alternate headers (even/odd differentiation)
693+
// Alternate headers: even/odd based on document page number, matching
694+
// the rendering side (getHeaderFooterTypeForSection in headerFooterUtils.ts)
685695
if (alternateHeaders) {
686-
return sectionPageNumber % 2 === 0 ? 'even' : 'odd';
696+
return documentPageNumber % 2 === 0 ? 'even' : 'odd';
687697
}
688698
return 'default';
689699
};
@@ -1284,11 +1294,10 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
12841294
// Get section metadata for titlePg setting
12851295
const sectionMetadata = sectionMetadataList[activeSectionIndex];
12861296
const titlePgEnabled = sectionMetadata?.titlePg ?? false;
1287-
// TODO: Support alternateHeaders (odd/even) when needed
1288-
const alternateHeaders = false;
1297+
const alternateHeaders = options.alternateHeaders ?? false;
12891298

12901299
// Determine which header/footer variant applies to this page
1291-
const variantType = getVariantTypeForPage(sectionPageNumber, titlePgEnabled, alternateHeaders);
1300+
const variantType = getVariantTypeForPage(sectionPageNumber, newPageNumber, titlePgEnabled, alternateHeaders);
12921301

12931302
// Resolve header/footer refs for margin calculation using OOXML inheritance model.
12941303
// This must match the rendering logic in PresentationEditor to ensure margins

packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4238,10 +4238,12 @@ export class PresentationEditor extends EventEmitter {
42384238
? buildSemanticFootnoteBlocks(footnotesLayoutInput, this.#layoutOptions.semanticOptions?.footnotesMode)
42394239
: [];
42404240
const blocksForLayout = semanticFootnoteBlocks.length > 0 ? [...blocks, ...semanticFootnoteBlocks] : blocks;
4241-
const layoutOptions =
4242-
!isSemanticFlow && footnotesLayoutInput
4241+
const layoutOptions = {
4242+
...(!isSemanticFlow && footnotesLayoutInput
42434243
? { ...baseLayoutOptions, footnotes: footnotesLayoutInput }
4244-
: baseLayoutOptions;
4244+
: baseLayoutOptions),
4245+
alternateHeaders: Boolean((this.#editor as EditorWithConverter).converter?.pageStyles?.alternateHeaders),
4246+
};
42454247
const previousBlocks = this.#layoutState.blocks;
42464248
const previousLayout = this.#layoutState.layout;
42474249
const previousMeasures = this.#layoutState.measures;

packages/super-editor/src/editors/v1/core/presentation-editor/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export type ResolvedLayoutOptions =
122122
margins: ResolvedMarginsBase;
123123
columns?: { count: number; gap: number };
124124
sectionMetadata: SectionMetadata[];
125+
alternateHeaders?: boolean;
125126
}
126127
| {
127128
flowMode: 'semantic';

0 commit comments

Comments
 (0)