Skip to content

Commit e06e7f4

Browse files
fix(layout): use effective page number for header parity
1 parent 09df6d1 commit e06e7f4

8 files changed

Lines changed: 166 additions & 17 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2032,6 +2032,8 @@ export type Page = {
20322032
/** Numeric page number after section numbering restart/offset. Used for OOXML odd/even parity. */
20332033
displayNumber?: number;
20342034
numberText?: string;
2035+
/** Numeric page number after section page numbering settings are applied. */
2036+
effectivePageNumber?: number;
20352037
size?: { w: number; h: number };
20362038
orientation?: 'portrait' | 'landscape';
20372039
sectionRefs?: {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export type ResolvedPage = {
5858
displayNumber?: number;
5959
/** Formatted page number text (e.g. "i", "ii" for Roman numeral sections). */
6060
numberText?: string;
61+
/** Numeric page number after section page numbering settings are applied. */
62+
effectivePageNumber?: number;
6163
/** Vertical alignment of content within this page. */
6264
vAlign?: SectionVerticalAlign;
6365
/** Base section margins before header/footer inflation. Used for vAlign centering calculations. */

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ export function buildMultiSectionIdentifier(
356356
* This function determines which header/footer variant (default, first, even, odd)
357357
* should be used for a given page number within a specific section. It respects:
358358
* - Per-section titlePg (first page of section uses 'first' variant)
359-
* - Alternate headers (even/odd pages based on section-aware page numbering)
359+
* - Alternate headers (even/odd pages based on the effective Word page number)
360360
* - Fallback to default variant
361361
*
362362
* **Important**: When `titlePg` is enabled, this function returns 'first' even if the
@@ -365,7 +365,7 @@ export function buildMultiSectionIdentifier(
365365
* sections. The rendering layer is responsible for resolving the actual content ID
366366
* through inheritance fallback logic.
367367
*
368-
* @param pageNumber - Physical page number (1-indexed)
368+
* @param pageNumber - Effective Word page number (1-indexed), after section page numbering settings
369369
* @param sectionIndex - Index of the section this page belongs to
370370
* @param identifier - Multi-section identifier with per-section mappings
371371
* @param options - Optional settings (kind, sectionPageNumber, parityPageNumber)
@@ -442,10 +442,10 @@ export function getHeaderFooterIdForPage(
442442
const kind = options?.kind ?? 'header';
443443
const sectionIndex = page.sectionIndex ?? 0;
444444
const sectionPageNumber = options?.sectionPageNumber ?? page.number;
445-
const parityPageNumber = options?.parityPageNumber ?? page.displayNumber ?? page.number;
445+
const effectivePageNumber = options?.parityPageNumber ?? page.effectivePageNumber ?? page.displayNumber ?? page.number;
446446
const sectionTitlePg = getSectionTitlePg(identifier, sectionIndex);
447447
const variantType = selectHeaderFooterVariantForPage({
448-
documentPageNumber: parityPageNumber,
448+
documentPageNumber: effectivePageNumber,
449449
sectionPageNumber,
450450
titlePg: sectionTitlePg,
451451
alternateHeaders: identifier.alternateHeaders,
@@ -505,6 +505,7 @@ export function resolveHeaderFooterForPageAndSection(
505505
const kind = options?.kind ?? 'header';
506506
const sectionIndex = page.sectionIndex ?? 0;
507507
const pageNumber = page.number;
508+
const effectivePageNumber = options?.parityPageNumber ?? page.effectivePageNumber ?? page.displayNumber ?? pageNumber;
508509
const sectionFirstPageNumbers = new Map<number, number>();
509510
for (const layoutPage of layout.pages) {
510511
const idx = layoutPage.sectionIndex ?? 0;
@@ -514,11 +515,10 @@ export function resolveHeaderFooterForPageAndSection(
514515
}
515516
const firstPageInSection = sectionFirstPageNumbers.get(sectionIndex);
516517
const sectionPageNumber = typeof firstPageInSection === 'number' ? pageNumber - firstPageInSection + 1 : pageNumber;
517-
const parityPageNumber = options?.parityPageNumber ?? page.displayNumber ?? pageNumber;
518518

519519
const sectionTitlePg = getSectionTitlePg(identifier, sectionIndex);
520520
const type = selectHeaderFooterVariantForPage({
521-
documentPageNumber: parityPageNumber,
521+
documentPageNumber: effectivePageNumber,
522522
sectionPageNumber,
523523
titlePg: sectionTitlePg,
524524
alternateHeaders: identifier.alternateHeaders,

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6285,6 +6285,53 @@ describe('alternateHeaders (odd/even header differentiation)', () => {
62856285
expect(p4Fragment!.y).toBeCloseTo(70, 0);
62866286
});
62876287

6288+
it('uses restarted section page numbering for even/odd header selection', () => {
6289+
const sb1: SectionBreakBlock = {
6290+
kind: 'sectionBreak',
6291+
id: 'sb1-restart',
6292+
attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 },
6293+
pageSize: { w: 600, h: 800 },
6294+
margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
6295+
};
6296+
const sb2: SectionBreakBlock = {
6297+
kind: 'sectionBreak',
6298+
id: 'sb2-restart',
6299+
type: 'nextPage',
6300+
attrs: { source: 'sectPr', sectionIndex: 1 },
6301+
pageSize: { w: 600, h: 800 },
6302+
margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
6303+
};
6304+
6305+
const options: LayoutOptions = {
6306+
pageSize: { w: 600, h: 800 },
6307+
margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
6308+
alternateHeaders: true,
6309+
sectionMetadata: [
6310+
{ sectionIndex: 0 },
6311+
{ sectionIndex: 1, numbering: { start: 2 }, headerRefs: { odd: 'h-odd', even: 'h-even' } },
6312+
],
6313+
headerContentHeightsByRId: new Map([
6314+
['h-odd', 80],
6315+
['h-even', 40],
6316+
]),
6317+
};
6318+
6319+
const layout = layoutDocument(
6320+
[sb1, tallBlock('p1'), tallBlock('p2'), sb2, tallBlock('p3')],
6321+
[{ kind: 'sectionBreak' }, tallMeasure, tallMeasure, { kind: 'sectionBreak' }, tallMeasure],
6322+
options,
6323+
);
6324+
6325+
expect(layout.pages.length).toBeGreaterThanOrEqual(3);
6326+
expect(layout.pages[2].number).toBe(3);
6327+
expect(layout.pages[2].effectivePageNumber).toBe(2);
6328+
expect(layout.pages[2].numberText).toBe('2');
6329+
6330+
const p3Fragment = layout.pages[2]?.fragments.find((f) => f.blockId === 'p3');
6331+
expect(p3Fragment).toBeDefined();
6332+
expect(p3Fragment!.y).toBeCloseTo(70, 0);
6333+
});
6334+
62886335
it('selects even/odd footer heights when alternateHeaders is true', () => {
62896336
// The footer-height path uses the per-rId map + sectionMetadata.footerRefs.
62906337
// Exposing the variant selection through `footerContentHeights` alone is not

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1708,6 +1708,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
17081708
if (state?.page) {
17091709
state.page.displayNumber = activePageCounter;
17101710
state.page.numberText = formatPageNumber(activePageCounter, activeNumberFormat);
1711+
state.page.effectivePageNumber = activePageCounter;
17111712
// Stamp section index on the page for section-aware page numbering and header/footer selection
17121713
state.page.sectionIndex = activeSectionIndex;
17131714
layoutLog(`[Layout] Page ${state.page.number}: Stamped sectionIndex:`, activeSectionIndex);

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,7 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout {
332332
footnoteReserved: page.footnoteReserved,
333333
displayNumber: page.displayNumber,
334334
numberText: page.numberText,
335+
effectivePageNumber: page.effectivePageNumber,
335336
vAlign: page.vAlign,
336337
baseMargins: page.baseMargins,
337338
sectionIndex: page.sectionIndex,

packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1764,6 +1764,7 @@ export class HeaderFooterSessionManager {
17641764
sectionFirstPageNumbers: Map<number, number>,
17651765
): string {
17661766
const pageNumber = page.number;
1767+
const effectivePageNumber = page.effectivePageNumber ?? page.displayNumber ?? pageNumber;
17671768
const sectionIndex = page.sectionIndex ?? 0;
17681769
const firstPageInSection = sectionFirstPageNumbers.get(sectionIndex);
17691770
const isFirstPageOfSection = firstPageInSection === pageNumber;
@@ -1785,8 +1786,7 @@ export class HeaderFooterSessionManager {
17851786
return 'first';
17861787
}
17871788
if (hasAlternateHeaders) {
1788-
const parityPageNumber = page.displayNumber ?? page.number;
1789-
return parityPageNumber % 2 === 0 ? 'even' : 'odd';
1789+
return effectivePageNumber % 2 === 0 ? 'even' : 'odd';
17901790
}
17911791
return 'default';
17921792
}
@@ -2418,17 +2418,13 @@ export class HeaderFooterSessionManager {
24182418

24192419
return (pageNumber, pageMargins, page) => {
24202420
const sectionIndex = page?.sectionIndex ?? 0;
2421+
const effectivePageNumber = page?.effectivePageNumber ?? page?.displayNumber ?? pageNumber;
24212422
const firstPageInSection = sectionFirstPageNumbers.get(sectionIndex);
24222423
const sectionPageNumber =
24232424
typeof firstPageInSection === 'number' ? pageNumber - firstPageInSection + 1 : pageNumber;
2424-
const parityPageNumber = page?.displayNumber ?? pageNumber;
24252425
const headerFooterType = hasSectionResolution
2426-
? getHeaderFooterTypeForSection(pageNumber, sectionIndex, multiSectionId, {
2427-
kind,
2428-
sectionPageNumber,
2429-
parityPageNumber,
2430-
})
2431-
: getHeaderFooterType(pageNumber, legacyIdentifier, { kind, parityPageNumber });
2426+
? getHeaderFooterTypeForSection(effectivePageNumber, sectionIndex, multiSectionId, { kind, sectionPageNumber })
2427+
: getHeaderFooterType(pageNumber, legacyIdentifier, { kind, parityPageNumber: effectivePageNumber });
24322428

24332429
if (!headerFooterType) {
24342430
return null;

packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -926,6 +926,84 @@ describe('HeaderFooterSessionManager', () => {
926926
expect(payload!.items?.[0]?.blockId).toBe('p1');
927927
});
928928

929+
it('uses the effective Word page number for section odd/even selection', () => {
930+
const deps: SessionManagerDependencies = {
931+
getLayoutOptions: vi.fn(() => ({})),
932+
getPageElement: vi.fn(() => null),
933+
scrollPageIntoView: vi.fn(),
934+
waitForPageMount: vi.fn(async () => true),
935+
convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })),
936+
isViewLocked: vi.fn(() => false),
937+
getBodyPageHeight: vi.fn(() => 800),
938+
notifyInputBridgeTargetChanged: vi.fn(),
939+
scheduleRerender: vi.fn(),
940+
setPendingDocChange: vi.fn(),
941+
getBodyPageCount: vi.fn(() => 3),
942+
};
943+
944+
manager = new HeaderFooterSessionManager({
945+
painterHost,
946+
visibleHost,
947+
selectionOverlay,
948+
editor: createMainEditorStub(),
949+
defaultPageSize: { w: 612, h: 792 },
950+
defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 },
951+
});
952+
manager.setDependencies(deps);
953+
manager.headerFooterIdentifier = {
954+
headerIds: { default: null, first: null, even: 'rId-header-even', odd: 'rId-header-odd' },
955+
footerIds: { default: null, first: null, even: null, odd: null },
956+
titlePg: false,
957+
alternateHeaders: true,
958+
};
959+
manager.multiSectionIdentifier = {
960+
headerIds: { default: null, first: null, even: 'rId-header-even', odd: 'rId-header-odd' },
961+
footerIds: { default: null, first: null, even: null, odd: null },
962+
titlePg: false,
963+
alternateHeaders: true,
964+
sectionCount: 2,
965+
sectionHeaderIds: new Map([
966+
[1, { default: null, first: null, even: 'rId-header-even', odd: 'rId-header-odd' }],
967+
]),
968+
sectionFooterIds: new Map(),
969+
sectionTitlePg: new Map(),
970+
sections: [
971+
{ sectionIndex: 0, titlePg: false },
972+
{
973+
sectionIndex: 1,
974+
titlePg: false,
975+
headerRefs: { default: null, first: null, even: 'rId-header-even', odd: 'rId-header-odd' },
976+
},
977+
],
978+
};
979+
manager.setLayoutResults([{ ...buildHeaderResult(), type: 'even' }], null);
980+
981+
const layout: Layout = {
982+
version: 1,
983+
flowMode: 'paginated',
984+
pageGap: 0,
985+
pageSize: { w: 612, h: 792 },
986+
pages: [
987+
{ number: 1, sectionIndex: 0 } as never,
988+
{ number: 2, sectionIndex: 0 } as never,
989+
{
990+
number: 3,
991+
effectivePageNumber: 2,
992+
sectionIndex: 1,
993+
sectionRefs: { headerRefs: { even: 'rId-header-even', odd: 'rId-header-odd' }, footerRefs: {} },
994+
margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 },
995+
} as never,
996+
],
997+
} as unknown as Layout;
998+
const provider = manager.createDecorationProvider('header', layout as unknown as ResolvedLayout);
999+
const payload = provider!(3, layout.pages[2]!.margins, layout.pages[2] as unknown as ResolvedPage);
1000+
1001+
expect(payload).not.toBeNull();
1002+
expect(payload!.headerFooterRefId).toBe('rId-header-even');
1003+
expect(payload!.sectionType).toBe('even');
1004+
expect(payload!.items?.[0]?.blockId).toBe('p1');
1005+
});
1006+
9291007
it('recomputes variant items when cached resolved items become misaligned', () => {
9301008
const deps: SessionManagerDependencies = {
9311009
getLayoutOptions: vi.fn(() => ({})),
@@ -1617,7 +1695,7 @@ describe('HeaderFooterSessionManager', () => {
16171695
});
16181696

16191697
describe('rebuildRegions — ResolvedLayout entry', () => {
1620-
function buildManager(): HeaderFooterSessionManager {
1698+
function buildManager(editor: Editor = createMainEditorStub()): HeaderFooterSessionManager {
16211699
const deps: SessionManagerDependencies = {
16221700
getLayoutOptions: vi.fn(() => ({})),
16231701
getPageElement: vi.fn(() => null),
@@ -1636,7 +1714,7 @@ describe('HeaderFooterSessionManager', () => {
16361714
painterHost,
16371715
visibleHost,
16381716
selectionOverlay,
1639-
editor: createMainEditorStub(),
1717+
editor,
16401718
defaultPageSize: { w: 612, h: 792 },
16411719
defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 },
16421720
});
@@ -1820,5 +1898,27 @@ describe('HeaderFooterSessionManager', () => {
18201898
expect(manager.headerRegions.get(1)!.sectionType).toBe('default');
18211899
expect(manager.footerRegions.get(1)!.sectionType).toBe('default');
18221900
});
1901+
1902+
it('uses effective Word page number for fallback odd/even region type', () => {
1903+
manager = buildManager({
1904+
...createMainEditorStub(),
1905+
converter: { pageStyles: { alternateHeaders: true } },
1906+
} as unknown as Editor);
1907+
const layout: ResolvedLayout = {
1908+
version: 1,
1909+
flowMode: 'paginated',
1910+
pageGap: 0,
1911+
pages: [
1912+
makePage({ number: 1, height: 792, sectionIndex: 0 }),
1913+
makePage({ number: 2, height: 792, sectionIndex: 0 }),
1914+
makePage({ number: 3, effectivePageNumber: 2, height: 792, sectionIndex: 1 }),
1915+
],
1916+
};
1917+
1918+
manager.rebuildRegions(layout);
1919+
1920+
expect(manager.headerRegions.get(2)!.sectionType).toBe('even');
1921+
expect(manager.footerRegions.get(2)!.sectionType).toBe('even');
1922+
});
18231923
});
18241924
});

0 commit comments

Comments
 (0)