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
3 changes: 3 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions packages/layout-engine/contracts/src/resolved-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -423,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[];
};
Expand Down
43 changes: 27 additions & 16 deletions packages/layout-engine/layout-bridge/src/headerFooterUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 !== 0 && (hasOdd || hasDefault)) {
return hasOdd ? 'odd' : 'default';
}
return null;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 =
Expand Down Expand Up @@ -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) {
Expand All @@ -400,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
Expand All @@ -413,16 +417,18 @@ 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 = options?.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;

Expand Down Expand Up @@ -463,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
Expand All @@ -482,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<NonNullable<Layout['headerFooter']>[HeaderFooterType]>;
Expand All @@ -505,13 +511,18 @@ export function resolveHeaderFooterForPageAndSection(
}
const firstPageInSection = sectionFirstPageNumbers.get(sectionIndex);
const sectionPageNumber = typeof firstPageInSection === 'number' ? pageNumber - firstPageInSection + 1 : pageNumber;
const parityPageNumber = options?.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
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];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,24 @@ 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('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' },
Expand Down Expand Up @@ -687,6 +705,76 @@ 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('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[] = [
{
Expand Down
Loading
Loading