From d4ab510373514b1d608d12b84e93873a37972c1c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 14:39:02 -0300 Subject: [PATCH 01/14] 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 2fb8cc5aa1..a8d900e857 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 @@ -1736,7 +1736,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 30b8a94c60..2ada6ca48d 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 @@ -1177,5 +1177,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 ce25dfad6ae95fcc3d08fde40a84e478445634df Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 14:56:48 -0300 Subject: [PATCH 02/14] 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 | 96 ++++++++----------- .../HeaderFooterSessionManager.ts | 42 +++----- .../tests/HeaderFooterSessionManager.test.ts | 80 +++++++++++++++- 8 files changed, 344 insertions(+), 117 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 adf6c51af4..b4b550bfd4 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -117,6 +117,12 @@ export { hasExplicitSdtContainerKey, isSdtContainerMetadata, } from './sdt-container.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 32c4be6e8b..1545050ea2 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 68fcc84692..70282df74d 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -28,8 +28,14 @@ import type { SectionNumbering, FlowMode, NormalizedColumnLayout, + HeaderFooterRefIdentifier, +} from '@superdoc/contracts'; +import { + buildLayoutSourceIdentityForFragment, + getFragmentZIndex, + normalizeColumnLayout, + resolveInheritedHeaderFooterRef, } from '@superdoc/contracts'; -import { buildLayoutSourceIdentityForFragment, normalizeColumnLayout, getFragmentZIndex } from '@superdoc/contracts'; import { createFloatingObjectManager, computeAnchorX } from './floating-objects.js'; import { computeNextSectionPropsAtBreak } from './section-props'; import { @@ -1458,6 +1464,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; @@ -1642,65 +1661,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 a8d900e857..032b6e14af 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 @@ -24,7 +24,7 @@ import type { ResolvedPage, LayoutStoryLocator, } from '@superdoc/contracts'; -import { namedStoryLocator } from '@superdoc/contracts'; +import { namedStoryLocator, resolveInheritedHeaderFooterRef } from '@superdoc/contracts'; import type { PageDecorationProvider } from '@superdoc/painter-dom'; import { resolveHeaderFooterLayout } from '@superdoc/layout-resolved'; import type { HeaderFooterPartStoryLocator } from '@superdoc/document-api'; @@ -2389,40 +2389,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 2ada6ca48d..936538d52c 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 @@ -587,11 +587,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, @@ -603,7 +604,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', @@ -1036,6 +1037,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 c44d9e2b4efcfd8ad23bb0e9ffce5d41c7599679 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 15:37:16 -0300 Subject: [PATCH 03/14] 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 9619c4b30066207711e63ac9e8739e1da8054590 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 15:39:38 -0300 Subject: [PATCH 04/14] 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 b4b550bfd4..aa76e95ed2 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -119,8 +119,10 @@ export { } from './sdt-container.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 1545050ea2..74020fc2d6 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 70282df74d..76c7f784bd 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -34,7 +34,7 @@ import { buildLayoutSourceIdentityForFragment, getFragmentZIndex, normalizeColumnLayout, - resolveInheritedHeaderFooterRef, + resolveInheritedHeaderFooterRefWithType, } from '@superdoc/contracts'; import { createFloatingObjectManager, computeAnchorX } from './floating-objects.js'; import { computeNextSectionPropsAtBreak } from './section-props'; @@ -1661,26 +1661,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 d3505f416714406846b29aac8a838f6a5cc742d5 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 15:46:08 -0300 Subject: [PATCH 05/14] 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 d0d1e80d2d8a69468b6f4673275f7770683963b2 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 15:59:55 -0300 Subject: [PATCH 06/14] 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 6cdc59a299e2ceaa2bddf5f1fb7b86fc15ec2a03 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 16:22:05 -0300 Subject: [PATCH 07/14] 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 032b6e14af..8b472d8227 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 @@ -1737,7 +1737,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 8888547ff467c632fc2063751c7986da4af67c6e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 16:23:12 -0300 Subject: [PATCH 08/14] 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 328f7975fc3d39f8ffaf7e67e1798c7c7c57025b Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 17:33:55 -0300 Subject: [PATCH 09/14] 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 3a2933871e011f62d3ffc800023b16bdd01a56c2 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 17:34:30 -0300 Subject: [PATCH 10/14] 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 29cf5f6ac73ae5bff177679294666161ed4ffff7 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 17:35:37 -0300 Subject: [PATCH 11/14] 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 936538d52c..0f694d920c 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 @@ -587,9 +587,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, @@ -602,7 +609,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[] = [ @@ -612,7 +619,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', () => { @@ -772,6 +779,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 9524e4554aa7b264d61d0e1d3df6c5836fa8ca24 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 12 May 2026 17:41:41 -0300 Subject: [PATCH 12/14] 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 0f694d920c..fa98d24b00 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 @@ -861,6 +861,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 ( From 9976013a4510bb547764e7d9bb24ac1e70a955b5 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 19 May 2026 11:21:03 -0300 Subject: [PATCH 13/14] fix(header-footer): preserve negative minY from page-relative behindDoc media Stop shifting normal footer/header fragments when the layout's minY is negative purely because of explicit behindDoc anchored drawings/images (e.g. page-relative background shapes). Decoration normalization now computes its own minY that ignores those explicit behindDoc media, so in-flow content stays at its original coordinates while the negative minY is preserved on the payload for downstream painters. --- .../HeaderFooterSessionManager.ts | 44 ++++++-- .../tests/HeaderFooterSessionManager.test.ts | 102 ++++++++++++++++++ 2 files changed, 136 insertions(+), 10 deletions(-) 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 8b472d8227..bad6e597e2 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 @@ -394,21 +394,43 @@ function shiftResolvedPaintItemY(item: ResolvedPaintItem, yOffset: number): Reso }; } -function normalizeDecorationFragments(fragments: Fragment[], layoutMinY: number): Fragment[] { - if (layoutMinY >= 0) { +function isExplicitBehindDocMediaFragment(fragment: Fragment): boolean { + return (fragment.kind === 'image' || fragment.kind === 'drawing') && fragment.behindDoc === true; +} + +function getDecorationNormalizationMinY(fragments: Fragment[], layoutMinY: number): number { + if (!Number.isFinite(layoutMinY) || layoutMinY >= 0) { + return 0; + } + + let minY = Infinity; + for (const fragment of fragments) { + if (isExplicitBehindDocMediaFragment(fragment)) { + continue; + } + if (Number.isFinite(fragment.y)) { + minY = Math.min(minY, fragment.y); + } + } + + return minY < 0 ? minY : 0; +} + +function normalizeDecorationFragments(fragments: Fragment[], normalizationMinY: number): Fragment[] { + if (normalizationMinY >= 0) { return fragments; } - const yOffset = -layoutMinY; + const yOffset = -normalizationMinY; return fragments.map((fragment) => ({ ...fragment, y: fragment.y + yOffset })); } -function normalizeDecorationItems(items: ResolvedPaintItem[], layoutMinY: number): ResolvedPaintItem[] { - if (layoutMinY >= 0) { +function normalizeDecorationItems(items: ResolvedPaintItem[], normalizationMinY: number): ResolvedPaintItem[] { + if (normalizationMinY >= 0) { return items; } - const yOffset = -layoutMinY; + const yOffset = -normalizationMinY; return items.map((item) => shiftResolvedPaintItemY(item, yOffset)); } @@ -2446,8 +2468,9 @@ export class HeaderFooterSessionManager { const metrics = this.#computeMetrics(kind, rawLayoutHeight, box, pageHeight, margins?.footer ?? 0); const layoutMinY = rIdLayout.layout.minY ?? 0; - const normalizedFragments = normalizeDecorationFragments(fragments, layoutMinY); - const normalizedItems = normalizeDecorationItems(alignedItems, layoutMinY); + const normalizationMinY = getDecorationNormalizationMinY(fragments, layoutMinY); + const normalizedFragments = normalizeDecorationFragments(fragments, normalizationMinY); + const normalizedItems = normalizeDecorationItems(alignedItems, normalizationMinY); const isActiveHeaderFooter = this.#isActiveDecoration(kind, sectionRId, pageNumber); return { @@ -2512,8 +2535,9 @@ export class HeaderFooterSessionManager { const metrics = this.#computeMetrics(kind, rawLayoutHeight, box, pageHeight, margins?.footer ?? 0); const layoutMinY = variant.layout.minY ?? 0; - const normalizedFragments = normalizeDecorationFragments(fragments, layoutMinY); - const normalizedItems = normalizeDecorationItems(alignedVariantItems, layoutMinY); + const normalizationMinY = getDecorationNormalizationMinY(fragments, layoutMinY); + const normalizedFragments = normalizeDecorationFragments(fragments, normalizationMinY); + const normalizedItems = normalizeDecorationItems(alignedVariantItems, normalizationMinY); const isActiveHeaderFooter = this.#isActiveDecoration(kind, finalHeaderId, pageNumber); return { 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 fa98d24b00..ccc45742bf 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 @@ -22,6 +22,8 @@ import type { ParaFragment, ResolvedLayout, ResolvedPage, + TableFragment, + DrawingFragment, } from '@superdoc/contracts'; import { buildMultiSectionIdentifier, type HeaderFooterLayoutResult } from '@superdoc/layout-bridge'; import { @@ -779,6 +781,106 @@ describe('HeaderFooterSessionManager', () => { expect(payload!.items![0]).toMatchObject({ blockId: 'p1', x: 72, y: 0 }); }); + it('does not shift normal rId footer fragments for negative minY from page-relative behindDoc drawings', () => { + 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(() => 1), + }; + const tableFragment: TableFragment = { + kind: 'table', + blockId: 'footer-table', + fromRow: 0, + toRow: 1, + x: 72, + y: 0, + width: 468, + height: 24, + }; + const behindDocFragment: DrawingFragment = { + kind: 'drawing', + blockId: 'footer-bg', + drawingKind: 'vectorShape', + x: 0, + y: -36, + width: 612, + height: 120, + isAnchored: true, + behindDoc: true, + zIndex: 0, + geometry: { width: 612, height: 120 }, + scale: 1, + sourceAnchor: { vRelativeFrom: 'page' }, + } as DrawingFragment; + const footerResult: HeaderFooterLayoutResult = { + kind: 'footer', + type: 'default', + layout: { + height: 48, + minY: -36, + pages: [{ number: 1, fragments: [tableFragment, behindDocFragment] }], + }, + blocks: [ + { kind: 'table', id: 'footer-table', rows: [{ id: 'row-1', cells: [] }] }, + { + kind: 'drawing', + id: 'footer-bg', + drawingKind: 'vectorShape', + anchor: { isAnchored: true, vRelativeFrom: 'page', behindDoc: true }, + geometry: { width: 612, height: 120 }, + }, + ] as FlowBlock[], + measures: [ + { kind: 'table', rowHeights: [24], columnWidths: [468], cells: [], rows: [] }, + { kind: 'drawing', width: 612, height: 120 }, + ] as unknown as Measure[], + }; + + 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.headerFooterIdentifier = { + headerIds: { default: null, first: null, even: null, odd: null }, + footerIds: { default: 'rId-footer-default', first: null, even: null, odd: null }, + titlePg: false, + alternateHeaders: false, + }; + manager.footerLayoutsByRId.set('rId-footer-default', footerResult); + + const layout: Layout = { + version: 1, + flowMode: 'paginated', + pageGap: 0, + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 } } as never], + } as unknown as Layout; + const provider = manager.createDecorationProvider('footer', layout as unknown as ResolvedLayout); + const payload = provider!(1, layout.pages[0]!.margins, layout.pages[0] as unknown as ResolvedPage); + + expect(payload).not.toBeNull(); + expect(payload!.minY).toBe(-36); + expect(payload!.fragments).toHaveLength(2); + expect(payload!.fragments[0]).toMatchObject({ kind: 'table', blockId: 'footer-table', y: 0 }); + expect(payload!.fragments[1]).toMatchObject({ kind: 'drawing', blockId: 'footer-bg', y: -36, behindDoc: true }); + expect(payload!.items).toHaveLength(2); + expect(payload!.items![0]).toMatchObject({ fragmentKind: 'table', blockId: 'footer-table', y: 0 }); + expect(payload!.items![1]).toMatchObject({ fragmentKind: 'drawing', blockId: 'footer-bg', y: -36 }); + }); + it('uses section titlePg state when selecting decoration-provider variants', () => { const deps: SessionManagerDependencies = { getLayoutOptions: vi.fn(() => ({})), From 92e4a034e5a6f535eed3daaa68be21112d139ae2 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 3 Jun 2026 11:43:28 -0300 Subject: [PATCH 14/14] fix(header-footer): honor identifier alternate header state --- .../HeaderFooterSessionManager.ts | 5 +- .../tests/HeaderFooterSessionManager.test.ts | 50 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) 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 bad6e597e2..28a683277e 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 @@ -1750,9 +1750,10 @@ export class HeaderFooterSessionManager { const firstPageInSection = sectionFirstPageNumbers.get(sectionIndex); const isFirstPageOfSection = firstPageInSection === pageNumber; - // Check for alternateHeaders in converter + // Check for alternateHeaders in converter or the multi-section identifier. const converter = (this.#options.editor as EditorWithConverter).converter; - const hasAlternateHeaders = converter?.pageStyles?.alternateHeaders === true; + const hasAlternateHeaders = + this.#multiSectionIdentifier?.alternateHeaders === true || converter?.pageStyles?.alternateHeaders === true; // Only use 'first' variant when titlePg is enabled (w:titlePg element in OOXML). // Without titlePg, even the first page of a section uses 'default'. 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 ccc45742bf..aab3f8f2fe 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 @@ -1503,6 +1503,56 @@ describe('HeaderFooterSessionManager', () => { expect(manager.footerRegions.get(0)!.sectionType).toBe('even'); }); + it('uses multi-section alternateHeaders state when inferring fallback region variants', () => { + manager = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: { + ...createMainEditorStub(), + converter: { 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(() => 1), + }); + manager.setMultiSectionIdentifier( + buildMultiSectionIdentifier( + [ + { + sectionIndex: 0, + titlePg: false, + headerRefs: { default: 'rId-default', even: 'rId-even' }, + footerRefs: { default: 'rId-default-footer', even: 'rId-even-footer' }, + }, + ], + { alternateHeaders: true }, + ), + ); + + manager.rebuildRegions({ + version: 1, + flowMode: 'paginated', + pageGap: 0, + pages: [makePage({ number: 1, displayNumber: 2, height: 792 })], + }); + + 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,