From d887b95a9a30560f66dd1268f76f92414545c652 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 14:39:02 -0300 Subject: [PATCH 01/12] fix(super-editor): honor per-section titlePg when inferring fallback regions When inferring header/footer region variants without explicit instance metadata, the fallback path only consulted the document-level titlePg flag. Multi-section documents that override titlePg per section ended up classifying the first page as 'default' instead of 'first'. Use the multi-section identifier's sectionTitlePg map when available so each section's variant is respected. --- .../HeaderFooterSessionManager.ts | 5 +- .../tests/HeaderFooterSessionManager.test.ts | 65 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) 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..120c929f8c 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 @@ -1701,7 +1701,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'; 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..b0384fc933 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 @@ -1173,5 +1173,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'); + }); }); }); From 5f4d592b7b0e75085d889e2fec2043e3ec83514e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 14:56:48 -0300 Subject: [PATCH 02/12] refactor(layout-engine): centralize header/footer ref inheritance Extract the OOXML header/footer ref inheritance logic into a shared helper (`resolveInheritedHeaderFooterRef`) in `@superdoc/contracts` and use it from layout-engine, layout-bridge, and HeaderFooterSessionManager. This replaces three near-duplicate copies of the same resolution rules. While unifying the logic, fix inheritance through intermediate sections that omit `first`/`even` refs: previously the resolver only looked at the immediately prior section, so a `first` ref defined in section 0 was lost once section 1 (with only a `default` ref) sat between section 0 and a later section that also lacked an explicit `first` ref. The shared resolver now walks back to the nearest prior section that defines the requested variant. --- .../src/header-footer-inheritance.ts | 57 ++++++++++++ packages/layout-engine/contracts/src/index.ts | 6 ++ .../layout-bridge/src/headerFooterUtils.ts | 41 ++++----- .../test/headerFooterUtils.test.ts | 80 ++++++++++++++++ .../layout-engine/src/index.test.ts | 59 ++++++++++++ .../layout-engine/layout-engine/src/index.ts | 91 +++++++------------ .../HeaderFooterSessionManager.ts | 41 +++------ .../tests/HeaderFooterSessionManager.test.ts | 80 +++++++++++++++- 8 files changed, 339 insertions(+), 116 deletions(-) create mode 100644 packages/layout-engine/contracts/src/header-footer-inheritance.ts 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..bf0d91eb42 --- /dev/null +++ b/packages/layout-engine/contracts/src/header-footer-inheritance.ts @@ -0,0 +1,57 @@ +type HeaderFooterType = 'default' | 'first' | 'even' | 'odd'; + +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; +}; + +function resolveVariantRef(refs: HeaderFooterRefMap | undefined, variantType: HeaderFooterType): string | null { + if (!refs) return null; + const direct = refs[variantType]; + if (direct) return direct; + if (variantType === 'odd' && refs.default) return refs.default; + return null; +} + +export function resolveInheritedHeaderFooterRef({ + identifier, + sectionIndex, + kind, + variantType, + pageRefs, +}: ResolveInheritedHeaderFooterRefInput): string | 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; + + const hasSectionAwareRefs = + sectionMap != null && (sectionMap.has(sectionIndex) || (identifier.sectionCount ?? 0) > sectionIndex); + if (hasSectionAwareRefs) { + for (let index = sectionIndex - 1; index >= 0; index -= 1) { + const inherited = resolveVariantRef(sectionMap.get(index), variantType); + if (inherited) return inherited; + } + return null; + } + + return resolveVariantRef(legacyIds, variantType); +} diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 19232a90c5..9f5c638824 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -75,6 +75,12 @@ 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, + type HeaderFooterRefIdentifier, + type HeaderFooterRefMap, + 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..2340580b28 100644 --- a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts +++ b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts @@ -1,4 +1,12 @@ -import type { HeaderFooterType, Layout, SectionMetadata, Page } from '@superdoc/contracts'; +import { + resolveInheritedHeaderFooterRef, + type HeaderFooterRefIdentifier, + type HeaderFooterType, + type Layout, + type SectionMetadata, + type Page, +} from '@superdoc/contracts'; +export { resolveInheritedHeaderFooterRef, type HeaderFooterRefIdentifier } from '@superdoc/contracts'; export type HeaderFooterIdentifier = { headerIds: Record<'default' | 'first' | 'even' | 'odd', string | null>; @@ -432,31 +440,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..dc8b92a60a 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[] = [ { diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 0be800dbae..12155868b6 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -6334,6 +6334,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..4b4560a6e1 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, resolveInheritedHeaderFooterRef } 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,65 +1409,26 @@ 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 headerRef = + resolveInheritedHeaderFooterRef({ + identifier: headerFooterRefIdentifier, + sectionIndex: activeSectionIndex, + kind: 'header', + variantType, + pageRefs: activeSectionRefs?.headerRefs, + }) ?? undefined; + const footerRef = + resolveInheritedHeaderFooterRef({ + identifier: headerFooterRefIdentifier, + sectionIndex: activeSectionIndex, + kind: 'footer', + variantType, + pageRefs: activeSectionRefs?.footerRefs, + }) ?? undefined; // 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 footerHeight = getFooterHeightForPage( - variantType !== 'default' && !activeSectionRefs?.footerRefs?.[variantType] ? 'default' : variantType, - footerRef, - activeSectionIndex, - ); + const headerHeight = getHeaderHeightForPage(variantType, headerRef, activeSectionIndex); + const footerHeight = getFooterHeightForPage(variantType, footerRef, activeSectionIndex); // Adjust margins based on the actual header/footer for this page. // Always recalculate to ensure pages without headers reset to base margin 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 120c929f8c..f4195858e7 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'; @@ -2353,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 b0384fc933..cfbbc4da7f 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,12 @@ describe('HeaderFooterSessionManager', () => { }); describe('createDecorationProvider — resolved items', () => { - function buildHeaderResult(options?: { y?: number; minY?: number }): HeaderFooterLayoutResult { + function buildHeaderResult(options?: { y?: number; minY?: number; blockId?: string }): HeaderFooterLayoutResult { const y = options?.y ?? 10; + const blockId = options?.blockId ?? 'p1'; const paraFragment: ParaFragment = { kind: 'para', - blockId: 'p1', + blockId, fromLine: 0, toLine: 1, x: 72, @@ -599,7 +600,7 @@ describe('HeaderFooterSessionManager', () => { ...(options?.minY != null ? { minY: options.minY } : {}), pages: [{ number: 1, fragments: [paraFragment] }], }; - const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }]; + const blocks: FlowBlock[] = [{ kind: 'paragraph', id: blockId, runs: [] }]; const measures: Measure[] = [ { kind: 'paragraph', @@ -1032,6 +1033,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', () => { From 5df72f767a068a5c3ea6adcf055e48ca11a64fcf Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 15:37:16 -0300 Subject: [PATCH 03/12] fix(contracts): preserve header footer fallback refs --- .../contracts/src/header-footer-inheritance.ts | 4 +++- .../test/headerFooterUtils.test.ts | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/contracts/src/header-footer-inheritance.ts b/packages/layout-engine/contracts/src/header-footer-inheritance.ts index bf0d91eb42..0e3fc8b678 100644 --- a/packages/layout-engine/contracts/src/header-footer-inheritance.ts +++ b/packages/layout-engine/contracts/src/header-footer-inheritance.ts @@ -44,7 +44,9 @@ export function resolveInheritedHeaderFooterRef({ if (fromSection) return fromSection; const hasSectionAwareRefs = - sectionMap != null && (sectionMap.has(sectionIndex) || (identifier.sectionCount ?? 0) > sectionIndex); + sectionMap != null && + sectionMap.size > 0 && + (sectionMap.has(sectionIndex) || (identifier.sectionCount ?? 0) > sectionIndex); if (hasSectionAwareRefs) { for (let index = sectionIndex - 1; index >= 0; index -= 1) { const inherited = resolveVariantRef(sectionMap.get(index), variantType); diff --git a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts index dc8b92a60a..a0d6cf2844 100644 --- a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts +++ b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts @@ -959,5 +959,23 @@ describe('headerFooterUtils', () => { }); expect(inheritedDefaultType).toBeNull(); }); + + 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'); + }); }); }); From ac4c50885eb97fffd9e1d5d3b0c6b715fedc005b Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 15:39:38 -0300 Subject: [PATCH 04/12] fix(layout-engine): use resolved header footer height slot --- .../src/header-footer-inheritance.ts | 22 +++++++-- packages/layout-engine/contracts/src/index.ts | 2 + .../layout-engine/src/index.test.ts | 21 +++++++++ .../layout-engine/layout-engine/src/index.ts | 46 +++++++++++-------- 4 files changed, 67 insertions(+), 24 deletions(-) diff --git a/packages/layout-engine/contracts/src/header-footer-inheritance.ts b/packages/layout-engine/contracts/src/header-footer-inheritance.ts index 0e3fc8b678..9678775af5 100644 --- a/packages/layout-engine/contracts/src/header-footer-inheritance.ts +++ b/packages/layout-engine/contracts/src/header-footer-inheritance.ts @@ -18,21 +18,29 @@ export type ResolveInheritedHeaderFooterRefInput = { pageRefs?: HeaderFooterRefMap; }; -function resolveVariantRef(refs: HeaderFooterRefMap | undefined, variantType: HeaderFooterType): string | null { +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 direct; - if (variantType === 'odd' && refs.default) return refs.default; + if (direct) return { ref: direct, variantType }; + if (variantType === 'odd' && refs.default) return { ref: refs.default, variantType: 'default' }; return null; } -export function resolveInheritedHeaderFooterRef({ +export function resolveInheritedHeaderFooterRefWithType({ identifier, sectionIndex, kind, variantType, pageRefs, -}: ResolveInheritedHeaderFooterRefInput): string | null { +}: ResolveInheritedHeaderFooterRefInput): ResolvedInheritedHeaderFooterRef | null { const fromPage = resolveVariantRef(pageRefs, variantType); if (fromPage) return fromPage; @@ -57,3 +65,7 @@ export function resolveInheritedHeaderFooterRef({ 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 9f5c638824..d087ee6360 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -77,8 +77,10 @@ export { cloneColumnLayout, normalizeColumnLayout, widthsEqual } from './column- export type { NormalizedColumnLayout } from './column-layout.js'; export { resolveInheritedHeaderFooterRef, + resolveInheritedHeaderFooterRefWithType, type HeaderFooterRefIdentifier, type HeaderFooterRefMap, + type ResolvedInheritedHeaderFooterRef, type ResolveInheritedHeaderFooterRefInput, } from './header-footer-inheritance.js'; export { diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 12155868b6..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 }, diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 4b4560a6e1..8ab6821da9 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -30,7 +30,7 @@ import type { NormalizedColumnLayout, HeaderFooterRefIdentifier, } from '@superdoc/contracts'; -import { normalizeColumnLayout, getFragmentZIndex, resolveInheritedHeaderFooterRef } from '@superdoc/contracts'; +import { normalizeColumnLayout, getFragmentZIndex, resolveInheritedHeaderFooterRefWithType } from '@superdoc/contracts'; import { createFloatingObjectManager, computeAnchorX } from './floating-objects.js'; import { computeNextSectionPropsAtBreak } from './section-props'; import { @@ -1409,26 +1409,34 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options alternateHeaders, }); - const headerRef = - resolveInheritedHeaderFooterRef({ - identifier: headerFooterRefIdentifier, - sectionIndex: activeSectionIndex, - kind: 'header', - variantType, - pageRefs: activeSectionRefs?.headerRefs, - }) ?? undefined; - const footerRef = - resolveInheritedHeaderFooterRef({ - identifier: headerFooterRefIdentifier, - sectionIndex: activeSectionIndex, - kind: 'footer', - variantType, - pageRefs: activeSectionRefs?.footerRefs, - }) ?? undefined; + 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 - const headerHeight = getHeaderHeightForPage(variantType, headerRef, activeSectionIndex); - const footerHeight = getFooterHeightForPage(variantType, footerRef, activeSectionIndex); + const headerHeight = getHeaderHeightForPage( + headerResolution?.variantType ?? variantType, + headerRef, + activeSectionIndex, + ); + const footerHeight = getFooterHeightForPage( + footerResolution?.variantType ?? variantType, + footerRef, + activeSectionIndex, + ); // Adjust margins based on the actual header/footer for this page. // Always recalculate to ensure pages without headers reset to base margin From 25d62c5e2e49f0e9f900ef6a9a9f760f59659e86 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 15:46:08 -0300 Subject: [PATCH 05/12] fix(contracts): ignore later refs for fallback resolution --- .../src/header-footer-inheritance.ts | 14 +++++++++---- .../test/headerFooterUtils.test.ts | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/layout-engine/contracts/src/header-footer-inheritance.ts b/packages/layout-engine/contracts/src/header-footer-inheritance.ts index 9678775af5..1ab4f14f11 100644 --- a/packages/layout-engine/contracts/src/header-footer-inheritance.ts +++ b/packages/layout-engine/contracts/src/header-footer-inheritance.ts @@ -51,10 +51,16 @@ export function resolveInheritedHeaderFooterRefWithType({ const fromSection = resolveVariantRef(sectionIds, variantType); if (fromSection) return fromSection; - const hasSectionAwareRefs = - sectionMap != null && - sectionMap.size > 0 && - (sectionMap.has(sectionIndex) || (identifier.sectionCount ?? 0) > sectionIndex); + let hasPriorSectionRefs = false; + if (sectionMap) { + for (const index of sectionMap.keys()) { + if (index < sectionIndex) { + hasPriorSectionRefs = true; + break; + } + } + } + const hasSectionAwareRefs = sectionMap != null && (sectionMap.has(sectionIndex) || hasPriorSectionRefs); if (hasSectionAwareRefs) { for (let index = sectionIndex - 1; index >= 0; index -= 1) { const inherited = resolveVariantRef(sectionMap.get(index), variantType); diff --git a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts index a0d6cf2844..8e39284eff 100644 --- a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts +++ b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts @@ -977,5 +977,25 @@ describe('headerFooterUtils', () => { 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'); + }); }); }); From b1de7837231d88256269769a4050e94d877dacdc Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 15:59:55 -0300 Subject: [PATCH 06/12] fix(layout-bridge): render inherited default refs --- .../layout-bridge/src/headerFooterUtils.ts | 11 ++++++ .../test/headerFooterUtils.test.ts | 34 +++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts index 2340580b28..e164fc85af 100644 --- a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts +++ b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts @@ -400,6 +400,17 @@ export function getHeaderFooterTypeForSection( return 'default'; } + if ( + resolveInheritedHeaderFooterRef({ + identifier, + sectionIndex, + kind, + variantType: 'default', + }) + ) { + return 'default'; + } + return null; } diff --git a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts index 8e39284eff..9611e80514 100644 --- a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts +++ b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts @@ -940,7 +940,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, @@ -957,7 +957,37 @@ 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', () => { From 2dbb8392f8471878b470e2168a8990e8538a7248 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 16:22:05 -0300 Subject: [PATCH 07/12] fix(super-editor): tolerate missing section titlePg map --- .../header-footer/HeaderFooterSessionManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f4195858e7..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 @@ -1703,7 +1703,7 @@ export class HeaderFooterSessionManager { const headerIds = converter?.headerIds as { titlePg?: boolean } | undefined; const footerIds = converter?.footerIds as { titlePg?: boolean } | undefined; let titlePgEnabled = headerIds?.titlePg === true || footerIds?.titlePg === true; - if (this.#multiSectionIdentifier?.sectionTitlePg.has(sectionIndex)) { + if (this.#multiSectionIdentifier?.sectionTitlePg?.has(sectionIndex)) { titlePgEnabled = this.#multiSectionIdentifier.sectionTitlePg.get(sectionIndex) === true; } From 8b98a1e0622239c95ef04af22798e39535c544bb Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 16:23:12 -0300 Subject: [PATCH 08/12] fix(contracts): inherit converter fallback refs --- .../src/header-footer-inheritance.ts | 1 - .../test/headerFooterUtils.test.ts | 23 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/contracts/src/header-footer-inheritance.ts b/packages/layout-engine/contracts/src/header-footer-inheritance.ts index 1ab4f14f11..81236affff 100644 --- a/packages/layout-engine/contracts/src/header-footer-inheritance.ts +++ b/packages/layout-engine/contracts/src/header-footer-inheritance.ts @@ -66,7 +66,6 @@ export function resolveInheritedHeaderFooterRefWithType({ const inherited = resolveVariantRef(sectionMap.get(index), variantType); if (inherited) return inherited; } - return null; } return resolveVariantRef(legacyIds, variantType); diff --git a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts index 9611e80514..372cc3f519 100644 --- a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts +++ b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts @@ -1027,5 +1027,28 @@ describe('headerFooterUtils', () => { 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'); + }); }); }); From 84e0fa9512d25fedea944908b4ebfb5a165a3308 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 17:33:55 -0300 Subject: [PATCH 09/12] test(contracts): cover header footer inheritance helper --- .../src/header-footer-inheritance.test.ts | 98 +++++++++++++++++++ .../src/header-footer-inheritance.ts | 12 +-- 2 files changed, 99 insertions(+), 11 deletions(-) create mode 100644 packages/layout-engine/contracts/src/header-footer-inheritance.test.ts 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 index 81236affff..5db15a76bc 100644 --- a/packages/layout-engine/contracts/src/header-footer-inheritance.ts +++ b/packages/layout-engine/contracts/src/header-footer-inheritance.ts @@ -1,4 +1,4 @@ -type HeaderFooterType = 'default' | 'first' | 'even' | 'odd'; +import type { HeaderFooterType } from './index.js'; export type HeaderFooterRefMap = Partial>; @@ -51,17 +51,7 @@ export function resolveInheritedHeaderFooterRefWithType({ const fromSection = resolveVariantRef(sectionIds, variantType); if (fromSection) return fromSection; - let hasPriorSectionRefs = false; if (sectionMap) { - for (const index of sectionMap.keys()) { - if (index < sectionIndex) { - hasPriorSectionRefs = true; - break; - } - } - } - const hasSectionAwareRefs = sectionMap != null && (sectionMap.has(sectionIndex) || hasPriorSectionRefs); - if (hasSectionAwareRefs) { for (let index = sectionIndex - 1; index >= 0; index -= 1) { const inherited = resolveVariantRef(sectionMap.get(index), variantType); if (inherited) return inherited; From 96825b0b0be479beed76f183f5d23b98e0a16aa5 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 17:34:30 -0300 Subject: [PATCH 10/12] fix(layout-bridge): drop unused inheritance re-export --- packages/layout-engine/layout-bridge/src/headerFooterUtils.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts index e164fc85af..b487c4b854 100644 --- a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts +++ b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts @@ -1,12 +1,10 @@ import { resolveInheritedHeaderFooterRef, - type HeaderFooterRefIdentifier, type HeaderFooterType, type Layout, type SectionMetadata, type Page, } from '@superdoc/contracts'; -export { resolveInheritedHeaderFooterRef, type HeaderFooterRefIdentifier } from '@superdoc/contracts'; export type HeaderFooterIdentifier = { headerIds: Record<'default' | 'first' | 'even' | 'odd', string | null>; From 1cabbb024e53dfd8c5a3fc7cafe10559bf829f9d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 17:35:37 -0300 Subject: [PATCH 11/12] test(super-editor): cover section titlePg decoration provider --- .../tests/HeaderFooterSessionManager.test.ts | 95 ++++++++++++++++++- 1 file changed, 92 insertions(+), 3 deletions(-) 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 cfbbc4da7f..610692e190 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,9 +583,16 @@ describe('HeaderFooterSessionManager', () => { }); describe('createDecorationProvider — resolved items', () => { - function buildHeaderResult(options?: { y?: number; minY?: number; blockId?: string }): 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, @@ -598,7 +605,7 @@ 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: blockId, runs: [] }]; const measures: Measure[] = [ @@ -608,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', () => { @@ -768,6 +775,88 @@ 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('normalizes resolved items when per-rId layout minY is negative', async () => { mockLayoutPerRIdHeaderFooters.mockImplementation( async ( From 719d1dac3bc54a2326f9ba7dad6fffe95b1d195f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 17:41:41 -0300 Subject: [PATCH 12/12] fix(layout-bridge): skip missing even header refs --- .../layout-bridge/src/headerFooterUtils.ts | 12 +++- .../test/headerFooterUtils.test.ts | 10 ++-- .../tests/HeaderFooterSessionManager.test.ts | 60 +++++++++++++++++++ 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts index b487c4b854..bc83e5758e 100644 --- a/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts +++ b/packages/layout-engine/layout-bridge/src/headerFooterUtils.ts @@ -388,10 +388,16 @@ 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) { diff --git a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts index 372cc3f519..a785892c7b 100644 --- a/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts +++ b/packages/layout-engine/layout-bridge/test/headerFooterUtils.test.ts @@ -855,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, @@ -881,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, @@ -915,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', () => { 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 610692e190..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 @@ -857,6 +857,66 @@ describe('HeaderFooterSessionManager', () => { 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 (