diff --git a/packages/layout-engine/contracts/src/header-footer-inheritance.test.ts b/packages/layout-engine/contracts/src/header-footer-inheritance.test.ts new file mode 100644 index 0000000000..acff874e5c --- /dev/null +++ b/packages/layout-engine/contracts/src/header-footer-inheritance.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { + resolveInheritedHeaderFooterRef, + resolveInheritedHeaderFooterRefWithType, +} from './header-footer-inheritance.js'; + +describe('header/footer inheritance', () => { + it('uses legacy refs when section maps are empty', () => { + const ref = resolveInheritedHeaderFooterRef({ + identifier: { + headerIds: { default: 'legacy-default' }, + sectionHeaderIds: new Map(), + }, + sectionIndex: 0, + kind: 'header', + variantType: 'default', + }); + + expect(ref).toBe('legacy-default'); + }); + + it('returns null for section zero when no page, section, or legacy refs exist', () => { + const ref = resolveInheritedHeaderFooterRef({ + identifier: { sectionHeaderIds: new Map() }, + sectionIndex: 0, + kind: 'header', + variantType: 'default', + }); + + expect(ref).toBeNull(); + }); + + it('walks back past intermediate sections with no entry', () => { + const ref = resolveInheritedHeaderFooterRef({ + identifier: { + sectionHeaderIds: new Map([[0, { first: 'section-0-first' }]]), + }, + sectionIndex: 3, + kind: 'header', + variantType: 'first', + }); + + expect(ref).toBe('section-0-first'); + }); + + it('prefers page refs over section refs', () => { + const resolved = resolveInheritedHeaderFooterRefWithType({ + identifier: { + sectionFooterIds: new Map([[0, { default: 'section-default' }]]), + }, + sectionIndex: 0, + kind: 'footer', + variantType: 'default', + pageRefs: { default: 'page-default' }, + }); + + expect(resolved).toEqual({ ref: 'page-default', variantType: 'default' }); + }); + + it('uses default refs for odd pages and reports the effective variant', () => { + const resolved = resolveInheritedHeaderFooterRefWithType({ + identifier: { + headerIds: { default: 'legacy-default' }, + }, + sectionIndex: 0, + kind: 'header', + variantType: 'odd', + }); + + expect(resolved).toEqual({ ref: 'legacy-default', variantType: 'default' }); + }); + + it('does not fall back from first to default', () => { + const ref = resolveInheritedHeaderFooterRef({ + identifier: { + headerIds: { default: 'legacy-default' }, + }, + sectionIndex: 0, + kind: 'header', + variantType: 'first', + }); + + expect(ref).toBeNull(); + }); + + it('does not fall back from even to default', () => { + const ref = resolveInheritedHeaderFooterRef({ + identifier: { + headerIds: { default: 'legacy-default' }, + }, + sectionIndex: 0, + kind: 'header', + variantType: 'even', + }); + + expect(ref).toBeNull(); + }); +}); diff --git a/packages/layout-engine/contracts/src/header-footer-inheritance.ts b/packages/layout-engine/contracts/src/header-footer-inheritance.ts new file mode 100644 index 0000000000..5db15a76bc --- /dev/null +++ b/packages/layout-engine/contracts/src/header-footer-inheritance.ts @@ -0,0 +1,66 @@ +import type { HeaderFooterType } from './index.js'; + +export type HeaderFooterRefMap = Partial>; + +export type HeaderFooterRefIdentifier = { + headerIds?: HeaderFooterRefMap; + footerIds?: HeaderFooterRefMap; + sectionCount?: number; + sectionHeaderIds?: Map; + sectionFooterIds?: Map; +}; + +export type ResolveInheritedHeaderFooterRefInput = { + identifier: HeaderFooterRefIdentifier; + sectionIndex: number; + kind: 'header' | 'footer'; + variantType: HeaderFooterType; + pageRefs?: HeaderFooterRefMap; +}; + +export type ResolvedInheritedHeaderFooterRef = { + ref: string; + variantType: HeaderFooterType; +}; + +function resolveVariantRef( + refs: HeaderFooterRefMap | undefined, + variantType: HeaderFooterType, +): ResolvedInheritedHeaderFooterRef | null { + if (!refs) return null; + const direct = refs[variantType]; + if (direct) return { ref: direct, variantType }; + if (variantType === 'odd' && refs.default) return { ref: refs.default, variantType: 'default' }; + return null; +} + +export function resolveInheritedHeaderFooterRefWithType({ + identifier, + sectionIndex, + kind, + variantType, + pageRefs, +}: ResolveInheritedHeaderFooterRefInput): ResolvedInheritedHeaderFooterRef | null { + const fromPage = resolveVariantRef(pageRefs, variantType); + if (fromPage) return fromPage; + + const sectionMap = kind === 'header' ? identifier.sectionHeaderIds : identifier.sectionFooterIds; + const legacyIds = kind === 'header' ? identifier.headerIds : identifier.footerIds; + + const sectionIds = sectionMap?.get(sectionIndex); + const fromSection = resolveVariantRef(sectionIds, variantType); + if (fromSection) return fromSection; + + if (sectionMap) { + for (let index = sectionIndex - 1; index >= 0; index -= 1) { + const inherited = resolveVariantRef(sectionMap.get(index), variantType); + if (inherited) return inherited; + } + } + + return resolveVariantRef(legacyIds, variantType); +} + +export function resolveInheritedHeaderFooterRef(input: ResolveInheritedHeaderFooterRefInput): string | null { + return resolveInheritedHeaderFooterRefWithType(input)?.ref ?? null; +} diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 19232a90c5..d087ee6360 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -75,6 +75,14 @@ export { export { computeFragmentPmRange, computeLinePmRange, type LinePmRange } from './pm-range.js'; export { cloneColumnLayout, normalizeColumnLayout, widthsEqual } from './column-layout.js'; export type { NormalizedColumnLayout } from './column-layout.js'; +export { + resolveInheritedHeaderFooterRef, + resolveInheritedHeaderFooterRefWithType, + type HeaderFooterRefIdentifier, + type HeaderFooterRefMap, + type ResolvedInheritedHeaderFooterRef, + type ResolveInheritedHeaderFooterRefInput, +} from './header-footer-inheritance.js'; export { formatPageNumber, formatPageNumberFieldValue, diff --git a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts index 384c467e33..bc83e5758e 100644 --- a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts +++ b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts @@ -1,4 +1,10 @@ -import type { HeaderFooterType, Layout, SectionMetadata, Page } from '@superdoc/contracts'; +import { + resolveInheritedHeaderFooterRef, + type HeaderFooterType, + type Layout, + type SectionMetadata, + type Page, +} from '@superdoc/contracts'; export type HeaderFooterIdentifier = { headerIds: Record<'default' | 'first' | 'even' | 'odd', string | null>; @@ -382,16 +388,33 @@ export function getHeaderFooterTypeForSection( } if (identifier.alternateHeaders) { - // Keep parity-based variant selection even when this section doesn't - // explicitly define that variant. Resolution/inheritance happens later. if (!hasAny) return null; - return parityPageNumber % 2 === 0 ? 'even' : 'odd'; + const parityVariant = parityPageNumber % 2 === 0 ? 'even' : 'odd'; + return resolveInheritedHeaderFooterRef({ + identifier, + sectionIndex, + kind, + variantType: parityVariant, + }) + ? parityVariant + : null; } if (hasDefault) { return 'default'; } + if ( + resolveInheritedHeaderFooterRef({ + identifier, + sectionIndex, + kind, + variantType: 'default', + }) + ) { + return 'default'; + } + return null; } @@ -432,31 +455,14 @@ export function getHeaderFooterIdForPage( }); if (!variantType) return null; - const resolveVariantId = (ids: Partial | undefined): string | null => { - if (!ids) return null; - const direct = ids[variantType]; - if (direct) return direct; - // With w:evenAndOddHeaders enabled, OOXML `default` is the primary/odd - // page slot. It must not be used as a replacement for a missing even ref. - if (variantType === 'odd' && ids.default) return ids.default; - return null; - }; - - // First try to get from page's sectionRefs (most specific, stamped during layout) const pageRefs = kind === 'header' ? page.sectionRefs?.headerRefs : page.sectionRefs?.footerRefs; - const idFromPage = resolveVariantId(pageRefs); - if (idFromPage) return idFromPage; - - // Fall back to identifier's section mappings - const sectionIds = - kind === 'header' ? identifier.sectionHeaderIds.get(sectionIndex) : identifier.sectionFooterIds.get(sectionIndex); - - const idFromSection = resolveVariantId(sectionIds); - if (idFromSection) return idFromSection; - - // Final fallback to legacy identifier fields - const legacyIds = kind === 'header' ? identifier.headerIds : identifier.footerIds; - return legacyIds[variantType] ?? null; + return resolveInheritedHeaderFooterRef({ + identifier, + sectionIndex, + kind, + variantType, + pageRefs, + }); } /** diff --git a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts index 8ce200a8ab..a785892c7b 100644 --- a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts +++ b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts @@ -647,6 +647,86 @@ describe('headerFooterUtils', () => { expect(section1FirstPage).toBe('first'); }); + it('resolves first-page header refs through intermediate sections that omit first refs', () => { + const sectionMetadata: SectionMetadata[] = [ + { + sectionIndex: 0, + headerRefs: { default: 'h0-default', first: 'h0-first' }, + titlePg: true, + }, + { + sectionIndex: 1, + headerRefs: { default: 'h1-default' }, + titlePg: true, + }, + { + sectionIndex: 2, + headerRefs: { default: 'h2-default' }, + titlePg: true, + }, + ]; + + const identifier = buildMultiSectionIdentifier(sectionMetadata); + const layout: Layout = { + pageSize: { w: 600, h: 800 }, + pages: [ + { number: 1, fragments: [], sectionIndex: 0 }, + { number: 2, fragments: [], sectionIndex: 1 }, + { + number: 3, + fragments: [], + sectionIndex: 2, + sectionRefs: { headerRefs: { default: 'h2-default' } }, + }, + ], + headerFooter: { + first: { pages: [{ number: 1, fragments: [] }] }, + }, + }; + + const resolved = resolveHeaderFooterForPageAndSection(layout, 2, identifier, { kind: 'header' }); + + expect(resolved?.type).toBe('first'); + expect(resolved?.contentId).toBe('h0-first'); + }); + + it('inherits from the nearest prior section when the current section has no explicit refs map', () => { + const sectionMetadata: SectionMetadata[] = [ + { + sectionIndex: 0, + headerRefs: { default: 'h0-default', first: 'h0-first' }, + titlePg: true, + }, + { + sectionIndex: 1, + headerRefs: { default: 'h1-default', first: 'h1-first' }, + titlePg: true, + }, + { + sectionIndex: 2, + titlePg: true, + }, + ]; + + const identifier = buildMultiSectionIdentifier(sectionMetadata); + const layout: Layout = { + pageSize: { w: 600, h: 800 }, + pages: [ + { number: 1, fragments: [], sectionIndex: 0 }, + { number: 2, fragments: [], sectionIndex: 1 }, + { number: 3, fragments: [], sectionIndex: 2 }, + ], + headerFooter: { + first: { pages: [{ number: 1, fragments: [] }] }, + }, + }; + + const resolved = resolveHeaderFooterForPageAndSection(layout, 2, identifier, { kind: 'header' }); + + expect(resolved?.type).toBe('first'); + expect(resolved?.contentId).toBe('h1-first'); + }); + it('returns even/odd variants for alternate headers even when section defines only default', () => { const sectionMetadata: SectionMetadata[] = [ { @@ -775,7 +855,7 @@ describe('headerFooterUtils', () => { expect(evenPageHeader?.contentId).toBe('h0-even'); }); - it('does not use section default content id for even pages when alternate header even ref is missing', () => { + it('does not resolve a header for even pages when alternate header even ref is missing', () => { const sectionMetadata: SectionMetadata[] = [ { sectionIndex: 0, @@ -801,11 +881,10 @@ describe('headerFooterUtils', () => { }; const evenPageHeader = resolveHeaderFooterForPageAndSection(layout, 1, identifier, { kind: 'header' }); - expect(evenPageHeader?.type).toBe('even'); - expect(evenPageHeader?.contentId).toBeNull(); + expect(evenPageHeader).toBeNull(); }); - it('keeps parity variant but does not infer default content id for missing alternate refs', () => { + it('does not resolve a footer for even pages when alternate footer even ref is missing', () => { const sectionMetadata: SectionMetadata[] = [ { sectionIndex: 0, @@ -835,8 +914,7 @@ describe('headerFooterUtils', () => { }; const evenPageFooterId = resolveHeaderFooterForPageAndSection(layout, 1, identifier, { kind: 'footer' }); - expect(evenPageFooterId?.type).toBe('even'); - expect(evenPageFooterId?.contentId).toBeNull(); + expect(evenPageFooterId).toBeNull(); }); it('keeps inherited parity selection when the current section has no explicit refs', () => { @@ -860,7 +938,7 @@ describe('headerFooterUtils', () => { expect(evenPageType).toBe('even'); }); - it('returns null when a later section has no explicit default ref', () => { + it('returns default when a later section inherits a default ref', () => { const sectionMetadata: SectionMetadata[] = [ { sectionIndex: 0, @@ -877,7 +955,98 @@ describe('headerFooterUtils', () => { kind: 'header', sectionPageNumber: 1, }); - expect(inheritedDefaultType).toBeNull(); + expect(inheritedDefaultType).toBe('default'); + }); + + it('uses inherited default refs when alternate headers are disabled', () => { + const sectionMetadata: SectionMetadata[] = [ + { + sectionIndex: 0, + headerRefs: { default: 'h0-default' }, + }, + { + sectionIndex: 1, + headerRefs: { even: 'h1-even' }, + }, + ]; + + const identifier = buildMultiSectionIdentifier(sectionMetadata); + const layout: Layout = { + pageSize: { w: 600, h: 800 }, + pages: [ + { number: 1, fragments: [], sectionIndex: 0 }, + { number: 2, fragments: [], sectionIndex: 1, sectionRefs: { headerRefs: { even: 'h1-even' } } }, + ], + headerFooter: { + default: { pages: [{ number: 2, fragments: [] }] }, + }, + }; + + const resolved = resolveHeaderFooterForPageAndSection(layout, 1, identifier, { kind: 'header' }); + + expect(resolved?.type).toBe('default'); + expect(resolved?.contentId).toBe('h0-default'); + }); + + it('uses converter fallback refs when section metadata has no explicit refs', () => { + const identifier = buildMultiSectionIdentifier([{ sectionIndex: 0 }], undefined, { + headerIds: { default: 'converter-default' }, + }); + const layout: Layout = { + pageSize: { w: 600, h: 800 }, + pages: [{ number: 1, fragments: [], sectionIndex: 0 }], + headerFooter: { + default: { pages: [{ number: 1, fragments: [] }] }, + }, + }; + + const resolved = resolveHeaderFooterForPageAndSection(layout, 0, identifier, { kind: 'header' }); + + expect(resolved?.type).toBe('default'); + expect(resolved?.contentId).toBe('converter-default'); + }); + + it('uses converter fallback refs when only later sections define refs', () => { + const identifier = buildMultiSectionIdentifier( + [{ sectionIndex: 0 }, { sectionIndex: 1, headerRefs: { default: 'section-1-default' } }], + undefined, + { headerIds: { default: 'converter-default' } }, + ); + const layout: Layout = { + pageSize: { w: 600, h: 800 }, + pages: [{ number: 1, fragments: [], sectionIndex: 0 }], + headerFooter: { + default: { pages: [{ number: 1, fragments: [] }] }, + }, + }; + + const resolved = resolveHeaderFooterForPageAndSection(layout, 0, identifier, { kind: 'header' }); + + expect(resolved?.type).toBe('default'); + expect(resolved?.contentId).toBe('converter-default'); + }); + + it('inherits converter fallback refs into later sections with partial refs', () => { + const identifier = buildMultiSectionIdentifier( + [{ sectionIndex: 0 }, { sectionIndex: 1, headerRefs: { even: 'section-1-even' } }], + undefined, + { headerIds: { default: 'converter-default' } }, + ); + const layout: Layout = { + pageSize: { w: 600, h: 800 }, + pages: [ + { number: 1, fragments: [], sectionIndex: 0 }, + { number: 2, fragments: [], sectionIndex: 1, sectionRefs: { headerRefs: { even: 'section-1-even' } } }, + ], + headerFooter: { + default: { pages: [{ number: 2, fragments: [] }] }, + }, + }; + + const resolved = resolveHeaderFooterForPageAndSection(layout, 1, identifier, { kind: 'header' }); + + expect(resolved?.type).toBe('default'); + expect(resolved?.contentId).toBe('converter-default'); }); }); }); diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 0be800dbae..e80a069e82 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -6090,6 +6090,27 @@ describe('alternateHeaders (odd/even header differentiation)', () => { expect(p2Fragment!.y).toBeCloseTo(70, 0); }); + it('uses default header height when odd pages resolve through the default ref', () => { + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + alternateHeaders: true, + sectionMetadata: [{ sectionIndex: 0, headerRefs: { default: 'rIdDefault' } }], + headerContentHeights: { + default: 80, + }, + }; + + const layout = layoutDocument([tallBlock('p1')], [tallMeasure], options); + + expect(layout.pages).toHaveLength(1); + + const p1Fragment = layout.pages[0].fragments.find((f) => f.blockId === 'p1'); + expect(p1Fragment).toBeDefined(); + expect(p1Fragment!.y).toBeCloseTo(110, 0); + expect(layout.pages[0].margins.top).toBeCloseTo(110, 0); + }); + it('uses section page-numbering start for odd/even header parity', () => { const options: LayoutOptions = { pageSize: { w: 600, h: 800 }, @@ -6334,6 +6355,65 @@ describe('alternateHeaders (odd/even header differentiation)', () => { expect(layout.pages[0].margins?.top).toBeCloseTo(130, 0); }); + it('uses inherited first-page header height through intermediate sections that omit first refs', () => { + const sb1: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'sb1', + attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 }, + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + }; + const sb2: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'sb2', + type: 'nextPage', + attrs: { source: 'sectPr', sectionIndex: 1 }, + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + }; + const sb3: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'sb3', + type: 'nextPage', + attrs: { source: 'sectPr', sectionIndex: 2 }, + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + }; + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 50, right: 50, bottom: 50, left: 50, header: 30 }, + sectionMetadata: [ + { sectionIndex: 0, titlePg: true, headerRefs: { first: 'rIdS0First', default: 'rIdS0Default' } }, + { sectionIndex: 1, titlePg: true, headerRefs: { default: 'rIdS1Default' } }, + { sectionIndex: 2, titlePg: true, headerRefs: { default: 'rIdS2Default' } }, + ], + headerContentHeightsByRId: new Map([ + ['rIdS0First', 100], + ['rIdS2Default', 10], + ]), + }; + + const layout = layoutDocument( + [sb1, tallBlock('p1'), sb2, tallBlock('p2'), sb3, tallBlock('p3')], + [ + { kind: 'sectionBreak' }, + tallMeasure, + { kind: 'sectionBreak' }, + tallMeasure, + { kind: 'sectionBreak' }, + tallMeasure, + ], + options, + ); + + expect(layout.pages.length).toBeGreaterThanOrEqual(3); + + const p3Fragment = layout.pages[2]?.fragments.find((fragment) => fragment.blockId === 'p3'); + expect(p3Fragment).toBeDefined(); + expect(p3Fragment!.y).toBeCloseTo(130, 0); + expect(layout.pages[2]?.margins?.top).toBeCloseTo(130, 0); + }); + it('multi-section + titlePg + alternateHeaders: first page of section 2 lands on an even doc-page', () => { // Most realistic mixed case. Section 1 has 3 pages (display numbers 1-3). Section 2 // has titlePg=true and starts with display number 4. diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 377b2750f5..8ab6821da9 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -28,8 +28,9 @@ import type { SectionNumbering, FlowMode, NormalizedColumnLayout, + HeaderFooterRefIdentifier, } from '@superdoc/contracts'; -import { normalizeColumnLayout, getFragmentZIndex } from '@superdoc/contracts'; +import { normalizeColumnLayout, getFragmentZIndex, resolveInheritedHeaderFooterRefWithType } from '@superdoc/contracts'; import { createFloatingObjectManager, computeAnchorX } from './floating-objects.js'; import { computeNextSectionPropsAtBreak } from './section-props'; import { @@ -1218,6 +1219,19 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options }; }; const sectionMetadataList = options.sectionMetadata ?? []; + const headerFooterRefIdentifier: HeaderFooterRefIdentifier = { + sectionCount: sectionMetadataList.length, + sectionHeaderIds: new Map(), + sectionFooterIds: new Map(), + }; + for (const metadata of sectionMetadataList) { + if (metadata.headerRefs) { + headerFooterRefIdentifier.sectionHeaderIds?.set(metadata.sectionIndex, metadata.headerRefs); + } + if (metadata.footerRefs) { + headerFooterRefIdentifier.sectionFooterIds?.set(metadata.sectionIndex, metadata.footerRefs); + } + } const initialSectionMetadata = sectionMetadataList[0]; if (initialSectionMetadata?.numbering?.format) { activeNumberFormat = initialSectionMetadata.numbering.format; @@ -1395,62 +1409,31 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options alternateHeaders, }); - // Resolve header/footer refs for margin calculation using OOXML inheritance model. - // This must match the rendering logic in PresentationEditor to ensure margins - // are calculated based on the same header/footer content that will be rendered. - // - // Resolution order: - // 1. Current section's variant ref (e.g., 'first' for first page with titlePg) - // 2. Previous section's same variant ref (inheritance) - // 3. Current section's 'default' ref (final fallback) - let headerRef = activeSectionRefs?.headerRefs?.[variantType]; - let footerRef = activeSectionRefs?.footerRefs?.[variantType]; - let effectiveVariantType = variantType; - - // Step 2: Inherit from previous section if variant not found - if (!headerRef && variantType !== 'default' && activeSectionIndex > 0) { - const prevSectionMetadata = sectionMetadataList[activeSectionIndex - 1]; - if (prevSectionMetadata?.headerRefs?.[variantType]) { - headerRef = prevSectionMetadata.headerRefs[variantType]; - layoutLog( - `[Layout] Page ${newPageNumber}: Inheriting header '${variantType}' from section ${activeSectionIndex - 1}: ${headerRef}`, - ); - } - } - if (!footerRef && variantType !== 'default' && activeSectionIndex > 0) { - const prevSectionMetadata = sectionMetadataList[activeSectionIndex - 1]; - if (prevSectionMetadata?.footerRefs?.[variantType]) { - footerRef = prevSectionMetadata.footerRefs[variantType]; - layoutLog( - `[Layout] Page ${newPageNumber}: Inheriting footer '${variantType}' from section ${activeSectionIndex - 1}: ${footerRef}`, - ); - } - } - - // Step 3: Fall back to current section's default only when that ref is - // the selected OOXML slot. With even/odd headers enabled, `default` - // represents the odd-page header, not a replacement for a missing even - // header. - const defaultHeaderRef = activeSectionRefs?.headerRefs?.default; - const defaultFooterRef = activeSectionRefs?.footerRefs?.default; - const shouldUseDefaultHeaderRef = - variantType !== 'default' && defaultHeaderRef && (!alternateHeaders || variantType === 'odd'); - const shouldUseDefaultFooterRef = - variantType !== 'default' && defaultFooterRef && (!alternateHeaders || variantType === 'odd'); - - if (!headerRef && shouldUseDefaultHeaderRef) { - headerRef = defaultHeaderRef; - effectiveVariantType = 'default'; - } - if (!footerRef && shouldUseDefaultFooterRef) { - footerRef = defaultFooterRef; - } + const headerResolution = resolveInheritedHeaderFooterRefWithType({ + identifier: headerFooterRefIdentifier, + sectionIndex: activeSectionIndex, + kind: 'header', + variantType, + pageRefs: activeSectionRefs?.headerRefs, + }); + const footerResolution = resolveInheritedHeaderFooterRefWithType({ + identifier: headerFooterRefIdentifier, + sectionIndex: activeSectionIndex, + kind: 'footer', + variantType, + pageRefs: activeSectionRefs?.footerRefs, + }); + const headerRef = headerResolution?.ref; + const footerRef = footerResolution?.ref; // Calculate the actual header/footer heights for this page's variant - // Use effectiveVariantType for header height lookup to match the fallback - const headerHeight = getHeaderHeightForPage(effectiveVariantType, headerRef, activeSectionIndex); + const headerHeight = getHeaderHeightForPage( + headerResolution?.variantType ?? variantType, + headerRef, + activeSectionIndex, + ); const footerHeight = getFooterHeightForPage( - variantType !== 'default' && !activeSectionRefs?.footerRefs?.[variantType] ? 'default' : variantType, + footerResolution?.variantType ?? variantType, footerRef, activeSectionIndex, ); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts index c3d328781b..50fe038a4a 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts @@ -23,6 +23,7 @@ import type { ResolvedLayout, ResolvedPage, } from '@superdoc/contracts'; +import { resolveInheritedHeaderFooterRef } from '@superdoc/contracts'; import type { PageDecorationProvider } from '@superdoc/painter-dom'; import { resolveHeaderFooterLayout } from '@superdoc/layout-resolved'; import type { HeaderFooterPartStoryLocator } from '@superdoc/document-api'; @@ -1701,7 +1702,10 @@ export class HeaderFooterSessionManager { // Without titlePg, even the first page of a section uses 'default'. const headerIds = converter?.headerIds as { titlePg?: boolean } | undefined; const footerIds = converter?.footerIds as { titlePg?: boolean } | undefined; - const titlePgEnabled = headerIds?.titlePg === true || footerIds?.titlePg === true; + let titlePgEnabled = headerIds?.titlePg === true || footerIds?.titlePg === true; + if (this.#multiSectionIdentifier?.sectionTitlePg?.has(sectionIndex)) { + titlePgEnabled = this.#multiSectionIdentifier.sectionTitlePg.get(sectionIndex) === true; + } if (isFirstPageOfSection && titlePgEnabled) { return 'first'; @@ -2350,40 +2354,20 @@ export class HeaderFooterSessionManager { }) : getHeaderFooterType(pageNumber, legacyIdentifier, { kind, parityPageNumber }); - // Resolve section-specific rId using Word's OOXML inheritance model - let sectionRId: string | undefined; - if (page?.sectionRefs && kind === 'header') { - sectionRId = page.sectionRefs.headerRefs?.[headerFooterType as keyof typeof page.sectionRefs.headerRefs]; - if (!sectionRId && headerFooterType && headerFooterType !== 'default' && sectionIndex > 0 && multiSectionId) { - const prevSectionIds = multiSectionId.sectionHeaderIds.get(sectionIndex - 1); - sectionRId = prevSectionIds?.[headerFooterType as keyof typeof prevSectionIds] ?? undefined; - } - const shouldUseDefaultHeaderRef = - headerFooterType !== 'default' && - page.sectionRefs.headerRefs?.default && - (!multiSectionId?.alternateHeaders || headerFooterType === 'odd'); - if (!sectionRId && shouldUseDefaultHeaderRef) { - sectionRId = page.sectionRefs.headerRefs?.default; - } - } else if (page?.sectionRefs && kind === 'footer') { - sectionRId = page.sectionRefs.footerRefs?.[headerFooterType as keyof typeof page.sectionRefs.footerRefs]; - if (!sectionRId && headerFooterType && headerFooterType !== 'default' && sectionIndex > 0 && multiSectionId) { - const prevSectionIds = multiSectionId.sectionFooterIds.get(sectionIndex - 1); - sectionRId = prevSectionIds?.[headerFooterType as keyof typeof prevSectionIds] ?? undefined; - } - const shouldUseDefaultFooterRef = - headerFooterType !== 'default' && - page.sectionRefs.footerRefs?.default && - (!multiSectionId?.alternateHeaders || headerFooterType === 'odd'); - if (!sectionRId && shouldUseDefaultFooterRef) { - sectionRId = page.sectionRefs.footerRefs?.default; - } - } - if (!headerFooterType) { return null; } + const pageRefs = kind === 'header' ? page?.sectionRefs?.headerRefs : page?.sectionRefs?.footerRefs; + const sectionRId = + resolveInheritedHeaderFooterRef({ + identifier: multiSectionId ?? legacyIdentifier, + sectionIndex, + kind, + variantType: headerFooterType, + pageRefs, + }) ?? undefined; + // PRIORITY 1: Try per-rId layout (composite key first for per-section margins, then plain rId) const compositeKey = sectionRId ? `${sectionRId}::s${sectionIndex}` : undefined; const rIdLayoutKey = diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts index 0696f284b2..3ae0aa9a9c 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.test.ts @@ -583,11 +583,19 @@ describe('HeaderFooterSessionManager', () => { }); describe('createDecorationProvider — resolved items', () => { - function buildHeaderResult(options?: { y?: number; minY?: number }): HeaderFooterLayoutResult { + function buildHeaderResult(options?: { + y?: number; + minY?: number; + blockId?: string; + pageNumber?: number; + type?: HeaderFooterLayoutResult['type']; + }): HeaderFooterLayoutResult { const y = options?.y ?? 10; + const blockId = options?.blockId ?? 'p1'; + const pageNumber = options?.pageNumber ?? 1; const paraFragment: ParaFragment = { kind: 'para', - blockId: 'p1', + blockId, fromLine: 0, toLine: 1, x: 72, @@ -597,9 +605,9 @@ describe('HeaderFooterSessionManager', () => { const layout: HeaderFooterLayout = { height: 50, ...(options?.minY != null ? { minY: options.minY } : {}), - pages: [{ number: 1, fragments: [paraFragment] }], + pages: [{ number: pageNumber, fragments: [paraFragment] }], }; - const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }]; + const blocks: FlowBlock[] = [{ kind: 'paragraph', id: blockId, runs: [] }]; const measures: Measure[] = [ { kind: 'paragraph', @@ -607,7 +615,7 @@ describe('HeaderFooterSessionManager', () => { totalHeight: 18, }, ]; - return { kind: 'header', type: 'default', layout, blocks, measures }; + return { kind: 'header', type: options?.type ?? 'default', layout, blocks, measures }; } it('delivers items aligned 1:1 with fragments when variant layout is used', () => { @@ -767,6 +775,148 @@ describe('HeaderFooterSessionManager', () => { expect(payload!.items![0]).toMatchObject({ blockId: 'p1', x: 72, y: 0 }); }); + it('uses section titlePg state when selecting decoration-provider variants', () => { + const deps: SessionManagerDependencies = { + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 2), + }; + + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: { + ...createMainEditorStub(), + converter: { + headerIds: { titlePg: true }, + }, + } as unknown as Editor, + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + manager.setDependencies(deps); + manager.setMultiSectionIdentifier( + buildMultiSectionIdentifier([ + { + sectionIndex: 0, + titlePg: true, + headerRefs: { first: 'rId-section0-first', default: 'rId-section0-default' }, + }, + { + sectionIndex: 1, + titlePg: false, + headerRefs: { first: 'rId-section1-first', default: 'rId-section1-default' }, + }, + ]), + ); + manager.setLayoutResults( + [ + buildHeaderResult({ type: 'first', blockId: 'first-block', pageNumber: 2 }), + buildHeaderResult({ type: 'default', blockId: 'default-block', pageNumber: 2 }), + ], + null, + ); + + const layout: Layout = { + version: 1, + flowMode: 'paginated', + pageGap: 0, + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + sectionIndex: 0, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }, + { + number: 2, + sectionIndex: 1, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + sectionRefs: { + headerRefs: { first: 'rId-section1-first', default: 'rId-section1-default' }, + footerRefs: {}, + }, + }, + ] as never, + } as unknown as Layout; + + const provider = manager.createDecorationProvider('header', layout as unknown as ResolvedLayout); + const payload = provider!(2, layout.pages[1]!.margins, layout.pages[1] as unknown as ResolvedPage); + + expect(payload).not.toBeNull(); + expect(payload!.sectionType).toBe('default'); + expect(payload!.items![0]!.blockId).toBe('default-block'); + }); + + it('does not render default headers on even pages when alternate headers are enabled', () => { + const deps: SessionManagerDependencies = { + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 2), + }; + + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: createMainEditorStub(), + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + manager.setDependencies(deps); + manager.setMultiSectionIdentifier( + buildMultiSectionIdentifier([{ sectionIndex: 0, headerRefs: { default: 'rId-header-default' } }], { + alternateHeaders: true, + }), + ); + manager.setLayoutResults([buildHeaderResult({ type: 'even', blockId: 'even-block', pageNumber: 2 })], null); + + const layout: Layout = { + version: 1, + flowMode: 'paginated', + pageGap: 0, + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + sectionIndex: 0, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }, + { + number: 2, + sectionIndex: 0, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + sectionRefs: { + headerRefs: { default: 'rId-header-default' }, + footerRefs: {}, + }, + }, + ] as never, + } as unknown as Layout; + + const provider = manager.createDecorationProvider('header', layout as unknown as ResolvedLayout); + const payload = provider!(2, layout.pages[1]!.margins, layout.pages[1] as unknown as ResolvedPage); + + expect(payload).toBeNull(); + }); + it('normalizes resolved items when per-rId layout minY is negative', async () => { mockLayoutPerRIdHeaderFooters.mockImplementation( async ( @@ -1032,6 +1182,79 @@ describe('HeaderFooterSessionManager', () => { expect(payload!.headerFooterRefId).toBe('rId-even'); expect(payload!.fragments[0]!.blockId).toBe('even-header'); }); + + it('inherits first-page header refs through intermediate sections that omit first refs', () => { + const deps: SessionManagerDependencies = { + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 3), + }; + + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: createMainEditorStub(), + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + manager.setDependencies(deps); + manager.setMultiSectionIdentifier( + buildMultiSectionIdentifier([ + { sectionIndex: 0, titlePg: true, headerRefs: { first: 'rId-s0-first', default: 'rId-s0-default' } }, + { sectionIndex: 1, titlePg: true, headerRefs: { default: 'rId-s1-default' } }, + { sectionIndex: 2, titlePg: true, headerRefs: { default: 'rId-s2-default' } }, + ]), + ); + manager.headerLayoutsByRId.set('rId-s0-first', buildHeaderResult({ blockId: 's0-first-header' })); + manager.headerLayoutsByRId.set('rId-s2-default', buildHeaderResult({ blockId: 's2-default-header' })); + + const layout: ResolvedLayout = { + version: 1, + flowMode: 'paginated', + pageGap: 0, + pages: [ + { + number: 1, + sectionIndex: 0, + height: 792, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + sectionRefs: { headerRefs: { first: 'rId-s0-first', default: 'rId-s0-default' }, footerRefs: {} }, + } as unknown as ResolvedPage, + { + number: 2, + sectionIndex: 1, + height: 792, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + sectionRefs: { headerRefs: { default: 'rId-s1-default' }, footerRefs: {} }, + } as unknown as ResolvedPage, + { + number: 3, + sectionIndex: 2, + height: 792, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + sectionRefs: { headerRefs: { default: 'rId-s2-default' }, footerRefs: {} }, + } as unknown as ResolvedPage, + ], + }; + + const provider = manager.createDecorationProvider('header', layout); + const page = layout.pages[2]!; + const payload = provider!(page.number, page.margins, page); + + expect(payload).not.toBeNull(); + expect(payload!.sectionType).toBe('first'); + expect(payload!.headerFooterRefId).toBe('rId-s0-first'); + expect(payload!.fragments[0]!.blockId).toBe('s0-first-header'); + }); }); describe('rebuildRegions — ResolvedLayout entry', () => { @@ -1173,5 +1396,70 @@ describe('HeaderFooterSessionManager', () => { expect(manager.headerRegions.get(0)!.sectionType).toBe('even'); expect(manager.footerRegions.get(0)!.sectionType).toBe('even'); }); + + it('uses section titlePg state when inferring fallback region variants', () => { + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: { + ...createMainEditorStub(), + converter: { + headerIds: { titlePg: true }, + footerIds: { titlePg: true }, + pageStyles: { alternateHeaders: false }, + }, + } as unknown as Editor, + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + manager.setDependencies({ + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 2), + }); + manager.setMultiSectionIdentifier( + buildMultiSectionIdentifier( + [ + { + sectionIndex: 0, + titlePg: true, + headerRefs: { first: 'rId-section0-first', default: 'rId-section0-default' }, + footerRefs: { first: 'rId-section0-first-footer', default: 'rId-section0-default-footer' }, + }, + { + sectionIndex: 1, + titlePg: false, + headerRefs: { first: 'rId-section1-first', default: 'rId-section1-default' }, + footerRefs: { first: 'rId-section1-first-footer', default: 'rId-section1-default-footer' }, + }, + ], + { alternateHeaders: false }, + ), + ); + + manager.rebuildRegions({ + version: 1, + flowMode: 'paginated', + pageGap: 0, + pages: [ + makePage({ number: 1, height: 792, sectionIndex: 0 }), + makePage({ number: 2, height: 792, sectionIndex: 1 }), + ], + }); + + expect(manager.headerRegions.get(0)!.sectionType).toBe('first'); + expect(manager.footerRegions.get(0)!.sectionType).toBe('first'); + expect(manager.headerRegions.get(1)!.sectionType).toBe('default'); + expect(manager.footerRegions.get(1)!.sectionType).toBe('default'); + }); }); });