Skip to content

Commit 9e7b817

Browse files
committed
fix: add support for odd/even headers
1 parent d583fb1 commit 9e7b817

File tree

8 files changed

+177
-63
lines changed

8 files changed

+177
-63
lines changed

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,32 @@ export function buildMultiSectionIdentifier(
285285
identifier.footerIds.odd = identifier.footerIds.odd ?? converterIds.footerIds.odd ?? null;
286286
}
287287

288+
// PM section metadata often lists only headerReference types present in that sectPr snapshot;
289+
// converter.headerIds still has even/odd from the full package. Merge those into per-section
290+
// rows so getHeaderFooterTypeForSection sees hasEven and returns 'even' on even pages.
291+
if (converterIds?.headerIds) {
292+
const c = converterIds.headerIds;
293+
for (const [idx, row] of identifier.sectionHeaderIds) {
294+
identifier.sectionHeaderIds.set(idx, {
295+
default: row.default ?? c.default ?? null,
296+
first: row.first ?? c.first ?? null,
297+
even: row.even ?? c.even ?? null,
298+
odd: row.odd ?? c.odd ?? null,
299+
});
300+
}
301+
}
302+
if (converterIds?.footerIds) {
303+
const c = converterIds.footerIds;
304+
for (const [idx, row] of identifier.sectionFooterIds) {
305+
identifier.sectionFooterIds.set(idx, {
306+
default: row.default ?? c.default ?? null,
307+
first: row.first ?? c.first ?? null,
308+
even: row.even ?? c.even ?? null,
309+
odd: row.odd ?? c.odd ?? null,
310+
});
311+
}
312+
}
313+
288314
return identifier;
289315
}
290316

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,10 +397,22 @@ describe('headerFooterUtils', () => {
397397
expect(identifier.headerIds.first).toBe('section-h-first');
398398
// Converter IDs should only fill in gaps
399399
expect(identifier.headerIds.even).toBe('converter-h-even');
400+
expect(identifier.sectionHeaderIds.get(0)?.even).toBe('converter-h-even');
400401
expect(identifier.footerIds.default).toBe('section-f-default');
401402
expect(identifier.footerIds.odd).toBe('converter-f-odd');
402403
});
403404

405+
it('fills per-section header maps from converter when section metadata omits even', () => {
406+
const sectionMetadata: SectionMetadata[] = [{ sectionIndex: 0, headerRefs: { default: 'r-default' } }];
407+
const identifier = buildMultiSectionIdentifier(
408+
sectionMetadata,
409+
{ alternateHeaders: true },
410+
{ headerIds: { default: 'r-default', even: 'r-even' } },
411+
);
412+
expect(identifier.sectionHeaderIds.get(0)?.even).toBe('r-even');
413+
expect(getHeaderFooterTypeForSection(2, 0, identifier, { kind: 'header', sectionPageNumber: 2 })).toBe('even');
414+
});
415+
404416
it('should handle missing converterIds parameter gracefully', () => {
405417
const sectionMetadata: SectionMetadata[] = [
406418
{

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
PageBreakBlock,
1717
TableBlock,
1818
TableMeasure,
19+
SectionMetadata,
1920
} from '@superdoc/contracts';
2021
import { layoutDocument, layoutHeaderFooter, type LayoutOptions } from './index.js';
2122

@@ -761,6 +762,70 @@ describe('layoutDocument', () => {
761762
expect(fragment.pmEnd).toBe(12);
762763
});
763764

765+
it('inflates top margin using transitive inherited first-header rId (multi-hop section metadata)', () => {
766+
const m = { top: 72, bottom: 72, left: 72, right: 72, header: 72, footer: 72 };
767+
const sb0: FlowBlock = {
768+
kind: 'sectionBreak',
769+
id: 'sb-0',
770+
type: 'continuous',
771+
margins: m,
772+
headerRefs: { default: 'd0', first: 'f0' },
773+
attrs: { isFirstSection: true, sectionIndex: 0 },
774+
};
775+
const sb1: FlowBlock = {
776+
kind: 'sectionBreak',
777+
id: 'sb-1',
778+
type: 'nextPage',
779+
margins: m,
780+
headerRefs: { default: 'd1' },
781+
attrs: { sectionIndex: 1 },
782+
};
783+
const sb2: FlowBlock = {
784+
kind: 'sectionBreak',
785+
id: 'sb-2',
786+
type: 'nextPage',
787+
margins: m,
788+
headerRefs: { default: 'd2' },
789+
attrs: { sectionIndex: 2 },
790+
};
791+
792+
const lineHeight = 40;
793+
const blocks: FlowBlock[] = [
794+
sb0,
795+
{ kind: 'paragraph', id: 'p0', runs: [] },
796+
sb1,
797+
{ kind: 'paragraph', id: 'p1', runs: [] },
798+
sb2,
799+
{ kind: 'paragraph', id: 'p2', runs: [] },
800+
];
801+
const measures: Measure[] = [
802+
{ kind: 'sectionBreak' },
803+
makeMeasure(Array(20).fill(lineHeight)),
804+
{ kind: 'sectionBreak' },
805+
makeMeasure(Array(20).fill(lineHeight)),
806+
{ kind: 'sectionBreak' },
807+
makeMeasure([lineHeight]),
808+
];
809+
810+
const tallFirst = 220;
811+
const sectionMetadata: SectionMetadata[] = [
812+
{ sectionIndex: 0, titlePg: true, headerRefs: { default: 'd0', first: 'f0' } },
813+
{ sectionIndex: 1, titlePg: true, headerRefs: { default: 'd1' } },
814+
{ sectionIndex: 2, titlePg: true, headerRefs: { default: 'd2' } },
815+
];
816+
817+
const layout = layoutDocument(blocks, measures, {
818+
pageSize: { w: 500, h: 600 },
819+
margins: m,
820+
sectionMetadata,
821+
headerContentHeightsByRId: new Map([['f0', tallFirst]]),
822+
});
823+
824+
const section2Page = layout.pages.find((p) => p.sectionIndex === 2);
825+
expect(section2Page).toBeDefined();
826+
expect(section2Page!.margins.top).toBeGreaterThanOrEqual(72 + tallFirst - 1);
827+
});
828+
764829
it('applies section break margins to subsequent pages', () => {
765830
const sectionBreakBlock: FlowBlock = {
766831
kind: 'sectionBreak',

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

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,11 @@ export type LayoutOptions = {
510510
* Values are the actual content heights in pixels.
511511
*/
512512
footerContentHeightsByRId?: Map<string, number>;
513+
/**
514+
* When true, odd and even pages use different header/footer variants (w:evenAndOddHeaders).
515+
* Drives per-page margin inflation to match the variant actually rendered.
516+
*/
517+
oddEvenHeadersFooters?: boolean;
513518
};
514519

515520
export type HeaderFooterConstraints = {
@@ -647,12 +652,14 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
647652
/**
648653
* Determines the header/footer variant type for a given page based on section settings.
649654
*
655+
* @param physicalPageNumber - Document-wide page number (1-indexed), used for odd/even parity
650656
* @param sectionPageNumber - The page number within the current section (1-indexed)
651657
* @param titlePgEnabled - Whether the section has "different first page" enabled
652-
* @param alternateHeaders - Whether the section has odd/even differentiation enabled
658+
* @param alternateHeaders - Whether odd/even headers are enabled (document-level in OOXML)
653659
* @returns The variant type: 'first', 'even', 'odd', or 'default'
654660
*/
655661
const getVariantTypeForPage = (
662+
physicalPageNumber: number,
656663
sectionPageNumber: number,
657664
titlePgEnabled: boolean,
658665
alternateHeaders: boolean,
@@ -661,9 +668,9 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
661668
if (sectionPageNumber === 1 && titlePgEnabled) {
662669
return 'first';
663670
}
664-
// Alternate headers (even/odd differentiation)
671+
// Alternate headers: parity follows physical page number (matches Word / headerFooterUtils)
665672
if (alternateHeaders) {
666-
return sectionPageNumber % 2 === 0 ? 'even' : 'odd';
673+
return physicalPageNumber % 2 === 0 ? 'even' : 'odd';
667674
}
668675
return 'default';
669676
};
@@ -1094,6 +1101,22 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
10941101
};
10951102
};
10961103
const sectionMetadataList = options.sectionMetadata ?? [];
1104+
1105+
/** Walk backward through section metadata for OOXML transitive header/footer inheritance. */
1106+
const findInheritedHeaderFooterRef = (
1107+
variant: 'default' | 'first' | 'even' | 'odd',
1108+
fromSectionIndex: number,
1109+
kind: 'header' | 'footer',
1110+
): string | undefined => {
1111+
for (let s = fromSectionIndex - 1; s >= 0; s--) {
1112+
const meta = sectionMetadataList[s];
1113+
const refs = kind === 'header' ? meta?.headerRefs : meta?.footerRefs;
1114+
const id = refs?.[variant];
1115+
if (id) return id;
1116+
}
1117+
return undefined;
1118+
};
1119+
10971120
const initialSectionMetadata = sectionMetadataList[0];
10981121
if (initialSectionMetadata?.numbering?.format) {
10991122
activeNumberFormat = initialSectionMetadata.numbering.format;
@@ -1241,11 +1264,10 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
12411264
// Get section metadata for titlePg setting
12421265
const sectionMetadata = sectionMetadataList[activeSectionIndex];
12431266
const titlePgEnabled = sectionMetadata?.titlePg ?? false;
1244-
// TODO: Support alternateHeaders (odd/even) when needed
1245-
const alternateHeaders = false;
1267+
const alternateHeaders = options.oddEvenHeadersFooters === true;
12461268

12471269
// Determine which header/footer variant applies to this page
1248-
const variantType = getVariantTypeForPage(sectionPageNumber, titlePgEnabled, alternateHeaders);
1270+
const variantType = getVariantTypeForPage(newPageNumber, sectionPageNumber, titlePgEnabled, alternateHeaders);
12491271

12501272
// Resolve header/footer refs for margin calculation using OOXML inheritance model.
12511273
// This must match the rendering logic in PresentationEditor to ensure margins
@@ -1259,22 +1281,20 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
12591281
let footerRef = activeSectionRefs?.footerRefs?.[variantType];
12601282
let effectiveVariantType = variantType;
12611283

1262-
// Step 2: Inherit from previous section if variant not found
1284+
// Step 2: Inherit from nearest previous section that defines this variant (transitive)
12631285
if (!headerRef && variantType !== 'default' && activeSectionIndex > 0) {
1264-
const prevSectionMetadata = sectionMetadataList[activeSectionIndex - 1];
1265-
if (prevSectionMetadata?.headerRefs?.[variantType]) {
1266-
headerRef = prevSectionMetadata.headerRefs[variantType];
1286+
headerRef = findInheritedHeaderFooterRef(variantType, activeSectionIndex, 'header');
1287+
if (headerRef) {
12671288
layoutLog(
1268-
`[Layout] Page ${newPageNumber}: Inheriting header '${variantType}' from section ${activeSectionIndex - 1}: ${headerRef}`,
1289+
`[Layout] Page ${newPageNumber}: Inheriting header '${variantType}' from an earlier section: ${headerRef}`,
12691290
);
12701291
}
12711292
}
12721293
if (!footerRef && variantType !== 'default' && activeSectionIndex > 0) {
1273-
const prevSectionMetadata = sectionMetadataList[activeSectionIndex - 1];
1274-
if (prevSectionMetadata?.footerRefs?.[variantType]) {
1275-
footerRef = prevSectionMetadata.footerRefs[variantType];
1294+
footerRef = findInheritedHeaderFooterRef(variantType, activeSectionIndex, 'footer');
1295+
if (footerRef) {
12761296
layoutLog(
1277-
`[Layout] Page ${newPageNumber}: Inheriting footer '${variantType}' from section ${activeSectionIndex - 1}: ${footerRef}`,
1297+
`[Layout] Page ${newPageNumber}: Inheriting footer '${variantType}' from an earlier section: ${footerRef}`,
12781298
);
12791299
}
12801300
}

packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts

Lines changed: 27 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2430,21 +2430,7 @@ export class PresentationEditor extends EventEmitter {
24302430
const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body';
24312431

24322432
if (layout && sessionMode === 'body') {
2433-
let pageIndex: number | null = null;
2434-
for (let idx = 0; idx < layout.pages.length; idx++) {
2435-
const page = layout.pages[idx];
2436-
for (const fragment of page.fragments) {
2437-
if (isFootnoteLayoutBlockId((fragment as { blockId?: string }).blockId)) {
2438-
continue;
2439-
}
2440-
const frag = fragment as { pmStart?: number; pmEnd?: number };
2441-
if (frag.pmStart != null && frag.pmEnd != null && clampedPos >= frag.pmStart && clampedPos <= frag.pmEnd) {
2442-
pageIndex = idx;
2443-
break;
2444-
}
2445-
}
2446-
if (pageIndex != null) break;
2447-
}
2433+
const pageIndex = this.#findPageIndexForPosition(layout, clampedPos);
24482434

24492435
if (pageIndex != null) {
24502436
const pageEl = getPageElementByIndex(this.#viewportHost, pageIndex);
@@ -2565,6 +2551,26 @@ export class PresentationEditor extends EventEmitter {
25652551
};
25662552
}
25672553

2554+
/**
2555+
* Find the 0-based page index whose body fragments contain `pos`, skipping footnote
2556+
* layout blocks. Returns null when no fragment reports pmStart/pmEnd for `pos`.
2557+
*/
2558+
#findPageIndexForPosition(layout: Layout, pos: number): number | null {
2559+
for (let idx = 0; idx < layout.pages.length; idx++) {
2560+
const page = layout.pages[idx];
2561+
for (const fragment of page.fragments) {
2562+
if (isFootnoteLayoutBlockId((fragment as { blockId?: string }).blockId)) {
2563+
continue;
2564+
}
2565+
const frag = fragment as { pmStart?: number; pmEnd?: number };
2566+
if (frag.pmStart != null && frag.pmEnd != null && pos >= frag.pmStart && pos <= frag.pmEnd) {
2567+
return idx;
2568+
}
2569+
}
2570+
}
2571+
return null;
2572+
}
2573+
25682574
/**
25692575
* Find the DOM element containing a specific document position.
25702576
* Returns the most specific (smallest range) matching element.
@@ -2632,21 +2638,7 @@ export class PresentationEditor extends EventEmitter {
26322638
const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body';
26332639
if (!layout || sessionMode !== 'body') return false;
26342640

2635-
let pageIndex: number | null = null;
2636-
for (let idx = 0; idx < layout.pages.length; idx++) {
2637-
const page = layout.pages[idx];
2638-
for (const fragment of page.fragments) {
2639-
if (isFootnoteLayoutBlockId((fragment as { blockId?: string }).blockId)) {
2640-
continue;
2641-
}
2642-
const frag = fragment as { pmStart?: number; pmEnd?: number };
2643-
if (frag.pmStart != null && frag.pmEnd != null && clampedPos >= frag.pmStart && clampedPos <= frag.pmEnd) {
2644-
pageIndex = idx;
2645-
break;
2646-
}
2647-
}
2648-
if (pageIndex != null) break;
2649-
}
2641+
const pageIndex = this.#findPageIndexForPosition(layout, clampedPos);
26502642
if (pageIndex == null) return false;
26512643

26522644
// Trigger virtualization to render the page
@@ -5371,6 +5363,8 @@ export class PresentationEditor extends EventEmitter {
53715363
this.#layoutOptions.pageSize = pageSize;
53725364
this.#layoutOptions.margins = margins;
53735365
const flowMode = this.#layoutOptions.flowMode ?? 'paginated';
5366+
const oddEvenHeadersFooters =
5367+
(this.#editor as EditorWithConverter)?.converter?.pageStyles?.alternateHeaders === true;
53745368

53755369
const resolvedMargins = {
53765370
top: margins.top!,
@@ -5410,6 +5404,7 @@ export class PresentationEditor extends EventEmitter {
54105404
marginBottom: semanticMargins.bottom,
54115405
},
54125406
sectionMetadata,
5407+
...(oddEvenHeadersFooters ? { oddEvenHeadersFooters: true } : {}),
54135408
};
54145409
}
54155410

@@ -5421,6 +5416,7 @@ export class PresentationEditor extends EventEmitter {
54215416
margins: resolvedMargins,
54225417
...(columns ? { columns } : {}),
54235418
sectionMetadata,
5419+
...(oddEvenHeadersFooters ? { oddEvenHeadersFooters: true } : {}),
54245420
};
54255421
}
54265422

@@ -6562,23 +6558,7 @@ export class PresentationEditor extends EventEmitter {
65626558

65636559
// Fallback: scan pages to find which one contains this position via fragments
65646560
// Note: pmStart/pmEnd are only present on some fragment types (ParaFragment, ImageFragment, DrawingFragment)
6565-
const pos = selection.from;
6566-
for (let pageIdx = 0; pageIdx < layout.pages.length; pageIdx++) {
6567-
const page = layout.pages[pageIdx];
6568-
for (const fragment of page.fragments) {
6569-
if (isFootnoteLayoutBlockId((fragment as { blockId?: string }).blockId)) {
6570-
continue;
6571-
}
6572-
const frag = fragment as { pmStart?: number; pmEnd?: number };
6573-
if (frag.pmStart != null && frag.pmEnd != null) {
6574-
if (pos >= frag.pmStart && pos <= frag.pmEnd) {
6575-
return pageIdx;
6576-
}
6577-
}
6578-
}
6579-
}
6580-
6581-
return 0;
6561+
return this.#findPageIndexForPosition(layout, selection.from) ?? 0;
65826562
}
65836563

65846564
#findRegionForPage(kind: 'header' | 'footer', pageIndex: number): HeaderFooterRegion | null {

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1614,6 +1614,10 @@ export class HeaderFooterSessionManager {
16141614
let sectionRId: string | undefined;
16151615
if (page?.sectionRefs && kind === 'header') {
16161616
sectionRId = page.sectionRefs.headerRefs?.[headerFooterType as keyof typeof page.sectionRefs.headerRefs];
1617+
if (!sectionRId && headerFooterType && multiSectionId) {
1618+
const row = multiSectionId.sectionHeaderIds.get(sectionIndex);
1619+
sectionRId = row?.[headerFooterType] ?? multiSectionId.headerIds[headerFooterType] ?? undefined;
1620+
}
16171621
if (!sectionRId && headerFooterType && headerFooterType !== 'default' && sectionIndex > 0 && multiSectionId) {
16181622
const prevSectionIds = multiSectionId.sectionHeaderIds.get(sectionIndex - 1);
16191623
sectionRId = prevSectionIds?.[headerFooterType as keyof typeof prevSectionIds] ?? undefined;
@@ -1623,6 +1627,10 @@ export class HeaderFooterSessionManager {
16231627
}
16241628
} else if (page?.sectionRefs && kind === 'footer') {
16251629
sectionRId = page.sectionRefs.footerRefs?.[headerFooterType as keyof typeof page.sectionRefs.footerRefs];
1630+
if (!sectionRId && headerFooterType && multiSectionId) {
1631+
const row = multiSectionId.sectionFooterIds.get(sectionIndex);
1632+
sectionRId = row?.[headerFooterType] ?? multiSectionId.footerIds[headerFooterType] ?? undefined;
1633+
}
16261634
if (!sectionRId && headerFooterType && headerFooterType !== 'default' && sectionIndex > 0 && multiSectionId) {
16271635
const prevSectionIds = multiSectionId.sectionFooterIds.get(sectionIndex - 1);
16281636
sectionRId = prevSectionIds?.[headerFooterType as keyof typeof prevSectionIds] ?? undefined;

0 commit comments

Comments
 (0)