Skip to content

Commit 8e46277

Browse files
fix(layout): use effective page number for header parity
1 parent fa24d9a commit 8e46277

8 files changed

Lines changed: 166 additions & 9 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2014,6 +2014,8 @@ export type Page = {
20142014
*/
20152015
footnoteLedger?: FootnotePageLedger;
20162016
numberText?: string;
2017+
/** Numeric page number after section page numbering settings are applied. */
2018+
effectivePageNumber?: number;
20172019
size?: { w: number; h: number };
20182020
orientation?: 'portrait' | 'landscape';
20192021
sectionRefs?: {

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

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

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ export function buildMultiSectionIdentifier(
353353
* This function determines which header/footer variant (default, first, even, odd)
354354
* should be used for a given page number within a specific section. It respects:
355355
* - Per-section titlePg (first page of section uses 'first' variant)
356-
* - Alternate headers (even/odd pages based on physical page number)
356+
* - Alternate headers (even/odd pages based on the effective Word page number)
357357
* - Fallback to default variant
358358
*
359359
* **Important**: When `titlePg` is enabled, this function returns 'first' even if the
@@ -362,7 +362,7 @@ export function buildMultiSectionIdentifier(
362362
* sections. The rendering layer is responsible for resolving the actual content ID
363363
* through inheritance fallback logic.
364364
*
365-
* @param pageNumber - Physical page number (1-indexed)
365+
* @param pageNumber - Effective Word page number (1-indexed), after section page numbering settings
366366
* @param sectionIndex - Index of the section this page belongs to
367367
* @param identifier - Multi-section identifier with per-section mappings
368368
* @param options - Optional settings (kind: 'header' | 'footer', sectionPageNumber)
@@ -437,10 +437,11 @@ export function getHeaderFooterIdForPage(
437437
): string | null {
438438
const kind = options?.kind ?? 'header';
439439
const sectionIndex = page.sectionIndex ?? 0;
440+
const effectivePageNumber = page.effectivePageNumber ?? page.number;
440441
const sectionPageNumber = options?.sectionPageNumber ?? page.number;
441442
const sectionTitlePg = getSectionTitlePg(identifier, sectionIndex);
442443
const variantType = selectHeaderFooterVariantForPage({
443-
documentPageNumber: page.number,
444+
documentPageNumber: effectivePageNumber,
444445
sectionPageNumber,
445446
titlePg: sectionTitlePg,
446447
alternateHeaders: identifier.alternateHeaders,
@@ -500,6 +501,7 @@ export function resolveHeaderFooterForPageAndSection(
500501
const kind = options?.kind ?? 'header';
501502
const sectionIndex = page.sectionIndex ?? 0;
502503
const pageNumber = page.number;
504+
const effectivePageNumber = page.effectivePageNumber ?? pageNumber;
503505
const sectionFirstPageNumbers = new Map<number, number>();
504506
for (const layoutPage of layout.pages) {
505507
const idx = layoutPage.sectionIndex ?? 0;
@@ -512,7 +514,7 @@ export function resolveHeaderFooterForPageAndSection(
512514

513515
const sectionTitlePg = getSectionTitlePg(identifier, sectionIndex);
514516
const type = selectHeaderFooterVariantForPage({
515-
documentPageNumber: pageNumber,
517+
documentPageNumber: effectivePageNumber,
516518
sectionPageNumber,
517519
titlePg: sectionTitlePg,
518520
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
@@ -6237,6 +6237,53 @@ describe('alternateHeaders (odd/even header differentiation)', () => {
62376237
expect(p4Fragment!.y).toBeCloseTo(70, 0);
62386238
});
62396239

6240+
it('uses restarted section page numbering for even/odd header selection', () => {
6241+
const sb1: SectionBreakBlock = {
6242+
kind: 'sectionBreak',
6243+
id: 'sb1-restart',
6244+
attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 },
6245+
pageSize: { w: 600, h: 800 },
6246+
margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
6247+
};
6248+
const sb2: SectionBreakBlock = {
6249+
kind: 'sectionBreak',
6250+
id: 'sb2-restart',
6251+
type: 'nextPage',
6252+
attrs: { source: 'sectPr', sectionIndex: 1 },
6253+
pageSize: { w: 600, h: 800 },
6254+
margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
6255+
};
6256+
6257+
const options: LayoutOptions = {
6258+
pageSize: { w: 600, h: 800 },
6259+
margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 },
6260+
alternateHeaders: true,
6261+
sectionMetadata: [
6262+
{ sectionIndex: 0 },
6263+
{ sectionIndex: 1, numbering: { start: 2 }, headerRefs: { odd: 'h-odd', even: 'h-even' } },
6264+
],
6265+
headerContentHeightsByRId: new Map([
6266+
['h-odd', 80],
6267+
['h-even', 40],
6268+
]),
6269+
};
6270+
6271+
const layout = layoutDocument(
6272+
[sb1, tallBlock('p1'), tallBlock('p2'), sb2, tallBlock('p3')],
6273+
[{ kind: 'sectionBreak' }, tallMeasure, tallMeasure, { kind: 'sectionBreak' }, tallMeasure],
6274+
options,
6275+
);
6276+
6277+
expect(layout.pages.length).toBeGreaterThanOrEqual(3);
6278+
expect(layout.pages[2].number).toBe(3);
6279+
expect(layout.pages[2].effectivePageNumber).toBe(2);
6280+
expect(layout.pages[2].numberText).toBe('2');
6281+
6282+
const p3Fragment = layout.pages[2]?.fragments.find((f) => f.blockId === 'p3');
6283+
expect(p3Fragment).toBeDefined();
6284+
expect(p3Fragment!.y).toBeCloseTo(70, 0);
6285+
});
6286+
62406287
it('selects even/odd footer heights when alternateHeaders is true', () => {
62416288
// The footer-height path uses the per-rId map + sectionMetadata.footerRefs.
62426289
// Exposing the variant selection through `footerContentHeights` alone is not

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1644,7 +1644,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
16441644
// Determine which header/footer variant applies to this page.
16451645
const variantType = selectHeaderFooterVariantForPage({
16461646
sectionPageNumber,
1647-
documentPageNumber: newPageNumber,
1647+
documentPageNumber: activePageCounter,
16481648
titlePg: titlePgEnabled,
16491649
alternateHeaders,
16501650
});
@@ -1707,6 +1707,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
17071707
// second callback: after page creation -> stamp display number, section refs, section index, and advance counter
17081708
if (state?.page) {
17091709
state.page.numberText = formatPageNumber(activePageCounter, activeNumberFormat);
1710+
state.page.effectivePageNumber = activePageCounter;
17101711
// Stamp section index on the page for section-aware page numbering and header/footer selection
17111712
state.page.sectionIndex = activeSectionIndex;
17121713
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
@@ -331,6 +331,7 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout {
331331
margins: page.margins,
332332
footnoteReserved: page.footnoteReserved,
333333
numberText: page.numberText,
334+
effectivePageNumber: page.effectivePageNumber,
334335
vAlign: page.vAlign,
335336
baseMargins: page.baseMargins,
336337
sectionIndex: page.sectionIndex,

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1742,6 +1742,7 @@ export class HeaderFooterSessionManager {
17421742
sectionFirstPageNumbers: Map<number, number>,
17431743
): string {
17441744
const pageNumber = page.number;
1745+
const effectivePageNumber = page.effectivePageNumber ?? pageNumber;
17451746
const sectionIndex = page.sectionIndex ?? 0;
17461747
const firstPageInSection = sectionFirstPageNumbers.get(sectionIndex);
17471748
const isFirstPageOfSection = firstPageInSection === pageNumber;
@@ -1760,7 +1761,7 @@ export class HeaderFooterSessionManager {
17601761
return 'first';
17611762
}
17621763
if (hasAlternateHeaders) {
1763-
return page.number % 2 === 0 ? 'even' : 'odd';
1764+
return effectivePageNumber % 2 === 0 ? 'even' : 'odd';
17641765
}
17651766
return 'default';
17661767
}
@@ -2391,11 +2392,12 @@ export class HeaderFooterSessionManager {
23912392

23922393
return (pageNumber, pageMargins, page) => {
23932394
const sectionIndex = page?.sectionIndex ?? 0;
2395+
const effectivePageNumber = page?.effectivePageNumber ?? pageNumber;
23942396
const firstPageInSection = sectionFirstPageNumbers.get(sectionIndex);
23952397
const sectionPageNumber =
23962398
typeof firstPageInSection === 'number' ? pageNumber - firstPageInSection + 1 : pageNumber;
23972399
const headerFooterType = hasSectionResolution
2398-
? getHeaderFooterTypeForSection(pageNumber, sectionIndex, multiSectionId, { kind, sectionPageNumber })
2400+
? getHeaderFooterTypeForSection(effectivePageNumber, sectionIndex, multiSectionId, { kind, sectionPageNumber })
23992401
: getHeaderFooterType(pageNumber, legacyIdentifier, { kind });
24002402

24012403
if (!headerFooterType) {

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
@@ -893,6 +893,84 @@ describe('HeaderFooterSessionManager', () => {
893893
expect(payload!.items?.[0]?.blockId).toBe('p1');
894894
});
895895

896+
it('uses the effective Word page number for section odd/even selection', () => {
897+
const deps: SessionManagerDependencies = {
898+
getLayoutOptions: vi.fn(() => ({})),
899+
getPageElement: vi.fn(() => null),
900+
scrollPageIntoView: vi.fn(),
901+
waitForPageMount: vi.fn(async () => true),
902+
convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })),
903+
isViewLocked: vi.fn(() => false),
904+
getBodyPageHeight: vi.fn(() => 800),
905+
notifyInputBridgeTargetChanged: vi.fn(),
906+
scheduleRerender: vi.fn(),
907+
setPendingDocChange: vi.fn(),
908+
getBodyPageCount: vi.fn(() => 3),
909+
};
910+
911+
manager = new HeaderFooterSessionManager({
912+
painterHost,
913+
visibleHost,
914+
selectionOverlay,
915+
editor: createMainEditorStub(),
916+
defaultPageSize: { w: 612, h: 792 },
917+
defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 },
918+
});
919+
manager.setDependencies(deps);
920+
manager.headerFooterIdentifier = {
921+
headerIds: { default: null, first: null, even: 'rId-header-even', odd: 'rId-header-odd' },
922+
footerIds: { default: null, first: null, even: null, odd: null },
923+
titlePg: false,
924+
alternateHeaders: true,
925+
};
926+
manager.multiSectionIdentifier = {
927+
headerIds: { default: null, first: null, even: 'rId-header-even', odd: 'rId-header-odd' },
928+
footerIds: { default: null, first: null, even: null, odd: null },
929+
titlePg: false,
930+
alternateHeaders: true,
931+
sectionCount: 2,
932+
sectionHeaderIds: new Map([
933+
[1, { default: null, first: null, even: 'rId-header-even', odd: 'rId-header-odd' }],
934+
]),
935+
sectionFooterIds: new Map(),
936+
sectionTitlePg: new Map(),
937+
sections: [
938+
{ sectionIndex: 0, titlePg: false },
939+
{
940+
sectionIndex: 1,
941+
titlePg: false,
942+
headerRefs: { default: null, first: null, even: 'rId-header-even', odd: 'rId-header-odd' },
943+
},
944+
],
945+
};
946+
manager.setLayoutResults([{ ...buildHeaderResult(), type: 'even' }], null);
947+
948+
const layout: Layout = {
949+
version: 1,
950+
flowMode: 'paginated',
951+
pageGap: 0,
952+
pageSize: { w: 612, h: 792 },
953+
pages: [
954+
{ number: 1, sectionIndex: 0 } as never,
955+
{ number: 2, sectionIndex: 0 } as never,
956+
{
957+
number: 3,
958+
effectivePageNumber: 2,
959+
sectionIndex: 1,
960+
sectionRefs: { headerRefs: { even: 'rId-header-even', odd: 'rId-header-odd' }, footerRefs: {} },
961+
margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 },
962+
} as never,
963+
],
964+
} as unknown as Layout;
965+
const provider = manager.createDecorationProvider('header', layout as unknown as ResolvedLayout);
966+
const payload = provider!(3, layout.pages[2]!.margins, layout.pages[2] as unknown as ResolvedPage);
967+
968+
expect(payload).not.toBeNull();
969+
expect(payload!.headerFooterRefId).toBe('rId-header-even');
970+
expect(payload!.sectionType).toBe('even');
971+
expect(payload!.items?.[0]?.blockId).toBe('p1');
972+
});
973+
896974
it('recomputes variant items when cached resolved items become misaligned', () => {
897975
const deps: SessionManagerDependencies = {
898976
getLayoutOptions: vi.fn(() => ({})),
@@ -1189,7 +1267,7 @@ describe('HeaderFooterSessionManager', () => {
11891267
});
11901268

11911269
describe('rebuildRegions — ResolvedLayout entry', () => {
1192-
function buildManager(): HeaderFooterSessionManager {
1270+
function buildManager(editor: Editor = createMainEditorStub()): HeaderFooterSessionManager {
11931271
const deps: SessionManagerDependencies = {
11941272
getLayoutOptions: vi.fn(() => ({})),
11951273
getPageElement: vi.fn(() => null),
@@ -1208,7 +1286,7 @@ describe('HeaderFooterSessionManager', () => {
12081286
painterHost,
12091287
visibleHost,
12101288
selectionOverlay,
1211-
editor: createMainEditorStub(),
1289+
editor,
12121290
defaultPageSize: { w: 612, h: 792 },
12131291
defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 },
12141292
});
@@ -1290,5 +1368,27 @@ describe('HeaderFooterSessionManager', () => {
12901368
expect(manager.headerRegions.get(2)!.sectionIndex).toBe(1);
12911369
expect(manager.footerRegions.get(2)!.sectionIndex).toBe(1);
12921370
});
1371+
1372+
it('uses effective Word page number for fallback odd/even region type', () => {
1373+
manager = buildManager({
1374+
...createMainEditorStub(),
1375+
converter: { pageStyles: { alternateHeaders: true } },
1376+
} as unknown as Editor);
1377+
const layout: ResolvedLayout = {
1378+
version: 1,
1379+
flowMode: 'paginated',
1380+
pageGap: 0,
1381+
pages: [
1382+
makePage({ number: 1, height: 792, sectionIndex: 0 }),
1383+
makePage({ number: 2, height: 792, sectionIndex: 0 }),
1384+
makePage({ number: 3, effectivePageNumber: 2, height: 792, sectionIndex: 1 }),
1385+
],
1386+
};
1387+
1388+
manager.rebuildRegions(layout);
1389+
1390+
expect(manager.headerRegions.get(2)!.sectionType).toBe('even');
1391+
expect(manager.footerRegions.get(2)!.sectionType).toBe('even');
1392+
});
12931393
});
12941394
});

0 commit comments

Comments
 (0)