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
133 changes: 133 additions & 0 deletions packages/layout-engine/layout-engine/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
23 changes: 16 additions & 7 deletions packages/layout-engine/layout-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -667,23 +674,26 @@ 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' => {
// First page of section with titlePg enabled uses 'first' variant
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';
};
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export type ResolvedLayoutOptions =
margins: ResolvedMarginsBase;
columns?: { count: number; gap: number };
sectionMetadata: SectionMetadata[];
alternateHeaders?: boolean;
}
| {
flowMode: 'semantic';
Expand Down
Loading