Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { HeaderFooterType } from './index.js';

export type HeaderFooterRefMap = Partial<Record<HeaderFooterType, string | null | undefined>>;

export type HeaderFooterRefIdentifier = {
headerIds?: HeaderFooterRefMap;
footerIds?: HeaderFooterRefMap;
sectionCount?: number;
sectionHeaderIds?: Map<number, HeaderFooterRefMap>;
sectionFooterIds?: Map<number, HeaderFooterRefMap>;
};

export type ResolveInheritedHeaderFooterRefInput = {
identifier: HeaderFooterRefIdentifier;
sectionIndex: number;
kind: 'header' | 'footer';
variantType: HeaderFooterType;
pageRefs?: HeaderFooterRefMap;
};

export type ResolvedInheritedHeaderFooterRef = {
ref: string;
variantType: HeaderFooterType;
};

function resolveVariantRef(
refs: HeaderFooterRefMap | undefined,
variantType: HeaderFooterType,
): ResolvedInheritedHeaderFooterRef | null {
if (!refs) return null;
const direct = refs[variantType];
if (direct) return { ref: direct, variantType };
if (variantType === 'odd' && refs.default) return { ref: refs.default, variantType: 'default' };
return null;
}

export function resolveInheritedHeaderFooterRefWithType({
identifier,
sectionIndex,
kind,
variantType,
pageRefs,
}: ResolveInheritedHeaderFooterRefInput): ResolvedInheritedHeaderFooterRef | null {
const fromPage = resolveVariantRef(pageRefs, variantType);
if (fromPage) return fromPage;

const sectionMap = kind === 'header' ? identifier.sectionHeaderIds : identifier.sectionFooterIds;
const legacyIds = kind === 'header' ? identifier.headerIds : identifier.footerIds;

const sectionIds = sectionMap?.get(sectionIndex);
const fromSection = resolveVariantRef(sectionIds, variantType);
if (fromSection) return fromSection;

if (sectionMap) {
for (let index = sectionIndex - 1; index >= 0; index -= 1) {
const inherited = resolveVariantRef(sectionMap.get(index), variantType);
if (inherited) return inherited;
}
}

return resolveVariantRef(legacyIds, variantType);
}

export function resolveInheritedHeaderFooterRef(input: ResolveInheritedHeaderFooterRefInput): string | null {
return resolveInheritedHeaderFooterRefWithType(input)?.ref ?? null;
}
8 changes: 8 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ export {
export { computeFragmentPmRange, computeLinePmRange, type LinePmRange } from './pm-range.js';
export { cloneColumnLayout, normalizeColumnLayout, widthsEqual } from './column-layout.js';
export type { NormalizedColumnLayout } from './column-layout.js';
export {
resolveInheritedHeaderFooterRef,
resolveInheritedHeaderFooterRefWithType,
type HeaderFooterRefIdentifier,
type HeaderFooterRefMap,
type ResolvedInheritedHeaderFooterRef,
type ResolveInheritedHeaderFooterRefInput,
} from './header-footer-inheritance.js';
export {
formatPageNumber,
formatPageNumberFieldValue,
Expand Down
62 changes: 34 additions & 28 deletions packages/layout-engine/layout-bridge/src/headerFooterUtils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { HeaderFooterType, Layout, SectionMetadata, Page } from '@superdoc/contracts';
import {
resolveInheritedHeaderFooterRef,
type HeaderFooterType,
type Layout,
type SectionMetadata,
type Page,
} from '@superdoc/contracts';

export type HeaderFooterIdentifier = {
headerIds: Record<'default' | 'first' | 'even' | 'odd', string | null>;
Expand Down Expand Up @@ -382,16 +388,33 @@ export function getHeaderFooterTypeForSection(
}

if (identifier.alternateHeaders) {
// Keep parity-based variant selection even when this section doesn't
// explicitly define that variant. Resolution/inheritance happens later.
if (!hasAny) return null;
return parityPageNumber % 2 === 0 ? 'even' : 'odd';
const parityVariant = parityPageNumber % 2 === 0 ? 'even' : 'odd';
return resolveInheritedHeaderFooterRef({
identifier,
sectionIndex,
kind,
variantType: parityVariant,
})
? parityVariant
: null;
}

if (hasDefault) {
return 'default';
}

if (
resolveInheritedHeaderFooterRef({
identifier,
sectionIndex,
kind,
variantType: 'default',
})
) {
return 'default';
}

return null;
}

Expand Down Expand Up @@ -432,31 +455,14 @@ export function getHeaderFooterIdForPage(
});
if (!variantType) return null;

const resolveVariantId = (ids: Partial<SectionHeaderFooterIds> | 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,
});
}

/**
Expand Down
Loading
Loading