Skip to content

Commit 59c6da1

Browse files
fix(header-footer): use section-aware page numbering for odd/even parity (SD-2991) (#3236)
* fix(layout-engine): use section-aware page number for odd/even header parity 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. * fix(layout-bridge): handle negative odd header parity * fix(layout-resolved): expose header footer display numbers * test(super-editor): cover header footer display parity * fix(layout-bridge): allow section parity override
1 parent 07815d3 commit 59c6da1

11 files changed

Lines changed: 318 additions & 48 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2001,6 +2001,8 @@ export type Page = {
20012001
* (in later phases) by body pagination itself.
20022002
*/
20032003
footnoteLedger?: FootnotePageLedger;
2004+
/** Numeric page number after section numbering restart/offset. Used for OOXML odd/even parity. */
2005+
displayNumber?: number;
20042006
numberText?: string;
20052007
size?: { w: number; h: number };
20062008
orientation?: 'portrait' | 'landscape';
@@ -2228,6 +2230,7 @@ export type HeaderFooterType = 'default' | 'first' | 'even' | 'odd';
22282230
export type HeaderFooterPage = {
22292231
number: number;
22302232
fragments: Fragment[];
2233+
displayNumber?: number;
22312234
numberText?: string;
22322235
/**
22332236
* Optional page-local block clones backing this page's resolved fragments.

packages/layout-engine/contracts/src/resolved-layout.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ export type ResolvedPage = {
5454
margins?: PageMargins;
5555
/** Extra bottom space reserved for footnotes (px). Used for footer space calculation. */
5656
footnoteReserved?: number;
57+
/** Numeric page number after section numbering restart/offset. Used for OOXML odd/even parity. */
58+
displayNumber?: number;
5759
/** Formatted page number text (e.g. "i", "ii" for Roman numeral sections). */
5860
numberText?: string;
5961
/** Vertical alignment of content within this page. */
@@ -448,6 +450,8 @@ export function isResolvedDrawingItem(item: ResolvedPaintItem): item is Resolved
448450
/** A resolved header/footer page — mirrors HeaderFooterPage but with resolved items. */
449451
export type ResolvedHeaderFooterPage = {
450452
number: number;
453+
/** Numeric page number after section numbering restart/offset. Used for OOXML odd/even parity. */
454+
displayNumber?: number;
451455
numberText?: string;
452456
items: ResolvedPaintItem[];
453457
};

packages/layout-engine/layout-bridge/src/headerFooterUtils.ts

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,12 @@ export const extractIdentifierFromConverter = (converter?: ConverterLike | null)
6464
export const getHeaderFooterType = (
6565
pageNumber: number,
6666
identifier: HeaderFooterIdentifier,
67-
options?: { kind?: 'header' | 'footer' },
67+
options?: { kind?: 'header' | 'footer'; parityPageNumber?: number },
6868
): HeaderFooterType | null => {
6969
if (pageNumber <= 0) return null;
7070

7171
const kind = options?.kind ?? 'header';
72+
const parityPageNumber = options?.parityPageNumber ?? pageNumber;
7273
const ids = kind === 'header' ? identifier.headerIds : identifier.footerIds;
7374

7475
const hasFirst = Boolean(ids.first);
@@ -83,10 +84,10 @@ export const getHeaderFooterType = (
8384
}
8485

8586
if (identifier.alternateHeaders) {
86-
if (pageNumber % 2 === 0 && hasEven) {
87+
if (parityPageNumber % 2 === 0 && hasEven) {
8788
return 'even';
8889
}
89-
if (pageNumber % 2 === 1 && (hasOdd || hasDefault)) {
90+
if (parityPageNumber % 2 !== 0 && (hasOdd || hasDefault)) {
9091
return hasOdd ? 'odd' : 'default';
9192
}
9293
return null;
@@ -103,10 +104,12 @@ export const resolveHeaderFooterForPage = (
103104
layout: Layout,
104105
pageIndex: number,
105106
identifier: HeaderFooterIdentifier,
106-
options?: { kind?: 'header' | 'footer' },
107+
options?: { kind?: 'header' | 'footer'; parityPageNumber?: number },
107108
) => {
108-
const pageNumber = layout.pages[pageIndex]?.number ?? pageIndex + 1;
109-
const type = getHeaderFooterType(pageNumber, identifier, options);
109+
const layoutPage = layout.pages[pageIndex];
110+
const pageNumber = layoutPage?.number ?? pageIndex + 1;
111+
const parityPageNumber = options?.parityPageNumber ?? layoutPage?.displayNumber ?? pageNumber;
112+
const type = getHeaderFooterType(pageNumber, identifier, { ...options, parityPageNumber });
110113
if (!type) {
111114
return null;
112115
}
@@ -295,7 +298,7 @@ export function buildMultiSectionIdentifier(
295298
* This function determines which header/footer variant (default, first, even, odd)
296299
* should be used for a given page number within a specific section. It respects:
297300
* - Per-section titlePg (first page of section uses 'first' variant)
298-
* - Alternate headers (even/odd pages based on physical page number)
301+
* - Alternate headers (even/odd pages based on section-aware page numbering)
299302
* - Fallback to default variant
300303
*
301304
* **Important**: When `titlePg` is enabled, this function returns 'first' even if the
@@ -307,7 +310,7 @@ export function buildMultiSectionIdentifier(
307310
* @param pageNumber - Physical page number (1-indexed)
308311
* @param sectionIndex - Index of the section this page belongs to
309312
* @param identifier - Multi-section identifier with per-section mappings
310-
* @param options - Optional settings (kind: 'header' | 'footer', sectionPageNumber)
313+
* @param options - Optional settings (kind, sectionPageNumber, parityPageNumber)
311314
* @returns HeaderFooterType ('default' | 'first' | 'even' | 'odd') or null if no header/footer content exists
312315
*
313316
* @example
@@ -326,12 +329,13 @@ export function getHeaderFooterTypeForSection(
326329
pageNumber: number,
327330
sectionIndex: number,
328331
identifier: MultiSectionHeaderFooterIdentifier,
329-
options?: { kind?: 'header' | 'footer'; sectionPageNumber?: number },
332+
options?: { kind?: 'header' | 'footer'; sectionPageNumber?: number; parityPageNumber?: number },
330333
): HeaderFooterType | null {
331334
if (pageNumber <= 0) return null;
332335

333336
const kind = options?.kind ?? 'header';
334337
const sectionPageNumber = options?.sectionPageNumber ?? pageNumber;
338+
const parityPageNumber = options?.parityPageNumber ?? pageNumber;
335339

336340
// Get section-specific IDs, falling back to legacy IDs for backward compatibility
337341
const sectionIds =
@@ -381,7 +385,7 @@ export function getHeaderFooterTypeForSection(
381385
// Keep parity-based variant selection even when this section doesn't
382386
// explicitly define that variant. Resolution/inheritance happens later.
383387
if (!hasAny) return null;
384-
return pageNumber % 2 === 0 ? 'even' : 'odd';
388+
return parityPageNumber % 2 === 0 ? 'even' : 'odd';
385389
}
386390

387391
if (hasDefault) {
@@ -400,7 +404,7 @@ export function getHeaderFooterTypeForSection(
400404
*
401405
* @param page - The Page object containing sectionIndex and sectionRefs
402406
* @param identifier - Multi-section identifier (can be used for variant resolution)
403-
* @param options - Optional settings (kind: 'header' | 'footer')
407+
* @param options - Optional settings (kind, sectionPageNumber, parityPageNumber)
404408
* @returns The content ID string, or null if not available
405409
*
406410
* @example
@@ -413,16 +417,18 @@ export function getHeaderFooterTypeForSection(
413417
export function getHeaderFooterIdForPage(
414418
page: Page,
415419
identifier: MultiSectionHeaderFooterIdentifier,
416-
options?: { kind?: 'header' | 'footer'; sectionPageNumber?: number },
420+
options?: { kind?: 'header' | 'footer'; sectionPageNumber?: number; parityPageNumber?: number },
417421
): string | null {
418422
const kind = options?.kind ?? 'header';
419423
const sectionIndex = page.sectionIndex ?? 0;
420424
const sectionPageNumber = options?.sectionPageNumber ?? page.number;
425+
const parityPageNumber = options?.parityPageNumber ?? page.displayNumber ?? page.number;
421426

422427
// Determine which variant type to use (default, first, even, odd)
423428
const variantType = getHeaderFooterTypeForSection(page.number, sectionIndex, identifier, {
424429
kind,
425430
sectionPageNumber,
431+
parityPageNumber,
426432
});
427433
if (!variantType) return null;
428434

@@ -463,7 +469,7 @@ export function getHeaderFooterIdForPage(
463469
* @param layout - The complete Layout object with pages and headerFooter slots
464470
* @param pageIndex - Index of the page in layout.pages array (0-indexed)
465471
* @param identifier - Multi-section identifier with per-section mappings
466-
* @param options - Optional settings (kind: 'header' | 'footer')
472+
* @param options - Optional settings (kind, parityPageNumber)
467473
* @returns Resolution result with type, layout slot, page, and section info, or null
468474
*
469475
* @example
@@ -482,7 +488,7 @@ export function resolveHeaderFooterForPageAndSection(
482488
layout: Layout,
483489
pageIndex: number,
484490
identifier: MultiSectionHeaderFooterIdentifier,
485-
options?: { kind?: 'header' | 'footer' },
491+
options?: { kind?: 'header' | 'footer'; parityPageNumber?: number },
486492
): {
487493
type: HeaderFooterType;
488494
layout: NonNullable<NonNullable<Layout['headerFooter']>[HeaderFooterType]>;
@@ -505,13 +511,18 @@ export function resolveHeaderFooterForPageAndSection(
505511
}
506512
const firstPageInSection = sectionFirstPageNumbers.get(sectionIndex);
507513
const sectionPageNumber = typeof firstPageInSection === 'number' ? pageNumber - firstPageInSection + 1 : pageNumber;
514+
const parityPageNumber = options?.parityPageNumber ?? page.displayNumber ?? pageNumber;
508515

509516
// Determine variant type for this section
510-
const type = getHeaderFooterTypeForSection(pageNumber, sectionIndex, identifier, { kind, sectionPageNumber });
517+
const type = getHeaderFooterTypeForSection(pageNumber, sectionIndex, identifier, {
518+
kind,
519+
sectionPageNumber,
520+
parityPageNumber,
521+
});
511522
if (!type) return null;
512523

513524
// Get content ID for this page/section
514-
const contentId = getHeaderFooterIdForPage(page, identifier, { kind, sectionPageNumber });
525+
const contentId = getHeaderFooterIdForPage(page, identifier, { kind, sectionPageNumber, parityPageNumber });
515526

516527
// Look up the header/footer layout slot
517528
const slot = layout.headerFooter?.[type];

packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,24 @@ describe('headerFooterUtils', () => {
7272
expect(getHeaderFooterType(3, identifier)).toBe('odd');
7373
});
7474

75+
it('uses display page number parity when provided', () => {
76+
const identifier = extractIdentifierFromConverter({
77+
headerIds: { default: 'rId1', even: 'rIdEven', odd: 'rIdOdd' },
78+
pageStyles: { alternateHeaders: true },
79+
});
80+
81+
expect(getHeaderFooterType(1, identifier, { parityPageNumber: 2 })).toBe('even');
82+
});
83+
84+
it('treats negative odd display page numbers as odd', () => {
85+
const identifier = extractIdentifierFromConverter({
86+
headerIds: { default: 'rId1', even: 'rIdEven', odd: 'rIdOdd' },
87+
pageStyles: { alternateHeaders: true },
88+
});
89+
90+
expect(getHeaderFooterType(1, identifier, { parityPageNumber: -1 })).toBe('odd');
91+
});
92+
7593
it('uses default only for odd pages when alternating slots are missing', () => {
7694
const identifier = extractIdentifierFromConverter({
7795
headerIds: { default: 'rId1' },
@@ -687,6 +705,76 @@ describe('headerFooterUtils', () => {
687705
expect(oddPageHeader?.contentId).toBe('h0-default');
688706
});
689707

708+
it('uses section-aware display page number for odd/even parity', () => {
709+
const sectionMetadata: SectionMetadata[] = [
710+
{
711+
sectionIndex: 0,
712+
headerRefs: { default: 'h0-odd', even: 'h0-even' },
713+
},
714+
];
715+
716+
const identifier = buildMultiSectionIdentifier(sectionMetadata, { alternateHeaders: true });
717+
const layout: Layout = {
718+
pageSize: { w: 600, h: 800 },
719+
pages: [
720+
{
721+
number: 1,
722+
displayNumber: 2,
723+
fragments: [],
724+
sectionIndex: 0,
725+
sectionRefs: { headerRefs: { default: 'h0-odd', even: 'h0-even' } },
726+
},
727+
],
728+
headerFooter: {
729+
even: { pages: [{ number: 1, fragments: [] }] },
730+
},
731+
};
732+
733+
const type = getHeaderFooterTypeForSection(1, 0, identifier, {
734+
kind: 'header',
735+
sectionPageNumber: 1,
736+
parityPageNumber: 2,
737+
});
738+
const evenPageHeader = resolveHeaderFooterForPageAndSection(layout, 0, identifier, { kind: 'header' });
739+
740+
expect(type).toBe('even');
741+
expect(evenPageHeader?.type).toBe('even');
742+
expect(evenPageHeader?.contentId).toBe('h0-even');
743+
});
744+
745+
it('allows callers to override section-aware odd/even parity', () => {
746+
const sectionMetadata: SectionMetadata[] = [
747+
{
748+
sectionIndex: 0,
749+
headerRefs: { default: 'h0-odd', even: 'h0-even' },
750+
},
751+
];
752+
753+
const identifier = buildMultiSectionIdentifier(sectionMetadata, { alternateHeaders: true });
754+
const layout: Layout = {
755+
pageSize: { w: 600, h: 800 },
756+
pages: [
757+
{
758+
number: 1,
759+
fragments: [],
760+
sectionIndex: 0,
761+
sectionRefs: { headerRefs: { default: 'h0-odd', even: 'h0-even' } },
762+
},
763+
],
764+
headerFooter: {
765+
even: { pages: [{ number: 1, fragments: [] }] },
766+
},
767+
};
768+
769+
const evenPageHeader = resolveHeaderFooterForPageAndSection(layout, 0, identifier, {
770+
kind: 'header',
771+
parityPageNumber: 2,
772+
});
773+
774+
expect(evenPageHeader?.type).toBe('even');
775+
expect(evenPageHeader?.contentId).toBe('h0-even');
776+
});
777+
690778
it('does not use section default content id for even pages when alternate header even ref is missing', () => {
691779
const sectionMetadata: SectionMetadata[] = [
692780
{

0 commit comments

Comments
 (0)