Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d4ab510
fix(super-editor): honor per-section titlePg when inferring fallback …
luccas-harbour May 12, 2026
ce25dfa
refactor(layout-engine): centralize header/footer ref inheritance
luccas-harbour May 12, 2026
c44d9e2
fix(contracts): preserve header footer fallback refs
luccas-harbour May 12, 2026
9619c4b
fix(layout-engine): use resolved header footer height slot
luccas-harbour May 12, 2026
d3505f4
fix(contracts): ignore later refs for fallback resolution
luccas-harbour May 12, 2026
d0d1e80
fix(layout-bridge): render inherited default refs
luccas-harbour May 12, 2026
6cdc59a
fix(super-editor): tolerate missing section titlePg map
luccas-harbour May 12, 2026
8888547
fix(contracts): inherit converter fallback refs
luccas-harbour May 12, 2026
328f797
test(contracts): cover header footer inheritance helper
luccas-harbour May 12, 2026
3a29338
fix(layout-bridge): drop unused inheritance re-export
luccas-harbour May 12, 2026
29cf5f6
test(super-editor): cover section titlePg decoration provider
luccas-harbour May 12, 2026
9524e45
fix(layout-bridge): skip missing even header refs
luccas-harbour May 12, 2026
9976013
fix(header-footer): preserve negative minY from page-relative behindD…
luccas-harbour May 19, 2026
92e4a03
fix(header-footer): honor identifier alternate header state
luccas-harbour Jun 3, 2026
60db41f
refactor(layout): centralize header/footer ref resolution in a shared…
luccas-harbour May 29, 2026
197a9b4
fix(layout): preserve converter header refs in section resolver
luccas-harbour May 29, 2026
d021660
fix(layout): resolve sparse section metadata by index
luccas-harbour May 29, 2026
6520a79
refactor(layout): trim header footer resolver surface
luccas-harbour May 29, 2026
a3a77b6
chore(editor): remove stale header footer imports
luccas-harbour May 29, 2026
973757f
fix(layout): preserve section header ref resolution
luccas-harbour Jun 1, 2026
9796404
fix(layout): preserve converter title page refs
luccas-harbour Jun 1, 2026
aaa788e
fix(document-api): inherit converter header refs
luccas-harbour Jun 1, 2026
2fd00fb
fix(editor): resolve per-rId header refs for decorations
luccas-harbour Jun 1, 2026
7aa2db9
fix(editor): align header footer fallback variant
luccas-harbour Jun 1, 2026
9138f05
fix(layout): stop leaking converter fallback refs into section resolu…
luccas-harbour Jun 2, 2026
bade825
fix(layout): use effective page number for header parity
luccas-harbour Jun 2, 2026
a1ced0b
feat(page-number): per-field PAGE value-format switches & case-insens…
luccas-harbour Jun 3, 2026
fad8252
Merge branch 'luccas/sd-2990-feature-headerfooter-page-numbers' into …
harbournick Jun 3, 2026
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
121 changes: 121 additions & 0 deletions packages/layout-engine/contracts/src/header-footer-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest';
import { resolveEffectiveHeaderFooterRef, selectHeaderFooterVariantForPage } from './header-footer-resolution.js';
import type { HeaderFooterResolutionSection } from './header-footer-resolution.js';

describe('header/footer effective ref resolution', () => {
it('inherits matching variants across more than one previous section', () => {
const sections: HeaderFooterResolutionSection[] = [
{ sectionIndex: 0, titlePg: true, headerRefs: { first: 'h0-first' } },
{ sectionIndex: 1, titlePg: true, headerRefs: { default: 'h1-default' } },
{ sectionIndex: 2, titlePg: true, headerRefs: {} },
];

expect(
resolveEffectiveHeaderFooterRef({ sections, sectionIndex: 2, kind: 'header', variant: 'first' }),
).toMatchObject({
refId: 'h0-first',
matchedSectionIndex: 0,
matchedVariant: 'first',
});
});

it('preserves inherited missing variants when a later section partially overrides another variant', () => {
const sections: HeaderFooterResolutionSection[] = [
{ sectionIndex: 0, footerRefs: { default: 'f0-default', even: 'f0-even' } },
{ sectionIndex: 1, footerRefs: { default: 'f1-default' } },
];

expect(
resolveEffectiveHeaderFooterRef({ sections, sectionIndex: 1, kind: 'footer', variant: 'even' }),
).toMatchObject({
refId: 'f0-even',
matchedSectionIndex: 0,
matchedVariant: 'even',
});
});

it('does not let first inherit default when titlePg selects first', () => {
const sections: HeaderFooterResolutionSection[] = [
{ sectionIndex: 0, titlePg: true, headerRefs: { default: 'h0-default' } },
];

const variant = selectHeaderFooterVariantForPage({
documentPageNumber: 1,
sectionPageNumber: 1,
titlePg: true,
alternateHeaders: false,
});

expect(variant).toBe('first');
expect(resolveEffectiveHeaderFooterRef({ sections, sectionIndex: 0, kind: 'header', variant: 'first' })).toBeNull();
});

it('does not let even inherit default when odd/even headers are enabled', () => {
const sections: HeaderFooterResolutionSection[] = [{ sectionIndex: 0, headerRefs: { default: 'h0-default' } }];

expect(resolveEffectiveHeaderFooterRef({ sections, sectionIndex: 0, kind: 'header', variant: 'even' })).toBeNull();
});

it('resolves odd from explicit odd before OOXML default', () => {
const sections: HeaderFooterResolutionSection[] = [
{ sectionIndex: 0, headerRefs: { default: 'h0-default' } },
{ sectionIndex: 1, headerRefs: { odd: 'h1-odd', default: 'h1-default' } },
];

expect(
resolveEffectiveHeaderFooterRef({ sections, sectionIndex: 1, kind: 'header', variant: 'odd' }),
).toMatchObject({
refId: 'h1-odd',
matchedVariant: 'odd',
});
});

it('resolves odd from OOXML default when explicit odd is absent', () => {
const sections: HeaderFooterResolutionSection[] = [{ sectionIndex: 0, headerRefs: { default: 'h0-default' } }];

expect(
resolveEffectiveHeaderFooterRef({ sections, sectionIndex: 0, kind: 'header', variant: 'odd' }),
).toMatchObject({
refId: 'h0-default',
matchedVariant: 'default',
});
});

it('uses document page number for even/odd selection', () => {
expect(
selectHeaderFooterVariantForPage({
documentPageNumber: 4,
sectionPageNumber: 1,
titlePg: false,
alternateHeaders: true,
}),
).toBe('even');
});

it('returns null for non-positive page numbers', () => {
expect(
selectHeaderFooterVariantForPage({
documentPageNumber: 0,
sectionPageNumber: 1,
titlePg: false,
alternateHeaders: false,
}),
).toBeNull();
expect(
selectHeaderFooterVariantForPage({
documentPageNumber: 1,
sectionPageNumber: 0,
titlePg: false,
alternateHeaders: false,
}),
).toBeNull();
expect(
selectHeaderFooterVariantForPage({
documentPageNumber: -1,
sectionPageNumber: -1,
titlePg: false,
alternateHeaders: true,
}),
).toBeNull();
});
});
87 changes: 87 additions & 0 deletions packages/layout-engine/contracts/src/header-footer-resolution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
export type HeaderFooterKind = 'header' | 'footer';
export type HeaderFooterVariant = 'default' | 'first' | 'even' | 'odd';

export type HeaderFooterSectionRefs = Partial<Record<HeaderFooterVariant, string | null>>;

export type HeaderFooterResolutionSection = {
sectionIndex: number;
titlePg?: boolean;
headerRefs?: HeaderFooterSectionRefs | null;
footerRefs?: HeaderFooterSectionRefs | null;
};

export type HeaderFooterVariantSelectionInput = {
documentPageNumber: number;
sectionPageNumber: number;
titlePg?: boolean;
alternateHeaders?: boolean;
};

export type HeaderFooterEffectiveRefInput = {
sections: readonly HeaderFooterResolutionSection[];
sectionIndex: number;
kind: HeaderFooterKind;
variant: HeaderFooterVariant;
};

export type HeaderFooterEffectiveRefResult = {
refId: string;
matchedSectionIndex: number;
matchedVariant: HeaderFooterVariant;
};

export function selectHeaderFooterVariantForPage({
documentPageNumber,
sectionPageNumber,
titlePg,
alternateHeaders,
}: HeaderFooterVariantSelectionInput): HeaderFooterVariant | null {
if (documentPageNumber <= 0 || sectionPageNumber <= 0) return null;
if (sectionPageNumber === 1 && titlePg === true) return 'first';
if (alternateHeaders === true) return documentPageNumber % 2 === 0 ? 'even' : 'odd';
return 'default';
}

function candidateVariantsFor(variant: HeaderFooterVariant): readonly HeaderFooterVariant[] {
return variant === 'odd' ? ['odd', 'default'] : [variant];
}

function sectionRefsFor(
section: HeaderFooterResolutionSection | undefined,
kind: HeaderFooterKind,
): HeaderFooterSectionRefs | null | undefined {
return kind === 'header' ? section?.headerRefs : section?.footerRefs;
}

export function resolveEffectiveHeaderFooterRef({
sections,
sectionIndex,
kind,
variant,
}: HeaderFooterEffectiveRefInput): HeaderFooterEffectiveRefResult | null {
if (sectionIndex < 0) return null;

const sectionsByIndex = new Map<number, HeaderFooterResolutionSection>();
for (const section of sections) {
sectionsByIndex.set(section.sectionIndex, section);
}

const candidates = candidateVariantsFor(variant);
for (let currentIndex = sectionIndex; currentIndex >= 0; currentIndex -= 1) {
const refs = sectionRefsFor(sectionsByIndex.get(currentIndex), kind);
if (!refs) continue;

for (const candidate of candidates) {
const refId = refs[candidate];
if (refId) {
return {
refId,
matchedSectionIndex: currentIndex,
matchedVariant: candidate,
};
}
}
}

return null;
}
17 changes: 16 additions & 1 deletion packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ export {

export { effectiveTableCellSpacing } from './table-cell-spacing.js';

export {
selectHeaderFooterVariantForPage,
resolveEffectiveHeaderFooterRef,
type HeaderFooterKind,
type HeaderFooterVariant,
type HeaderFooterSectionRefs,
type HeaderFooterResolutionSection,
type HeaderFooterVariantSelectionInput,
type HeaderFooterEffectiveRefInput,
type HeaderFooterEffectiveRefResult,
} from './header-footer-resolution.js';

// Table column rescaling (moved from layout-engine for cross-stage use)
export { rescaleColumnWidths } from './table-column-rescale.js';

Expand Down Expand Up @@ -2020,6 +2032,8 @@ export type Page = {
/** Numeric page number after section numbering restart/offset. Used for OOXML odd/even parity. */
displayNumber?: number;
numberText?: string;
/** Numeric page number after section page numbering settings are applied. */
effectivePageNumber?: number;
size?: { w: number; h: number };
orientation?: 'portrait' | 'landscape';
sectionRefs?: {
Expand Down Expand Up @@ -2246,8 +2260,9 @@ export type HeaderFooterType = 'default' | 'first' | 'even' | 'odd';
export type HeaderFooterPage = {
number: number;
fragments: Fragment[];
displayNumber?: number;
numberText?: string;
/** Section-aware numeric page value before formatting. */
displayNumber?: number;
/**
* Optional page-local block clones backing this page's resolved fragments.
* Present when header/footer tokens were laid out per page or per bucket.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ describe('page number formatting', () => {
expect(formatPageNumber(5, 'upperRoman')).toBe('V');
expect(formatPageNumber(5, 'lowerRoman')).toBe('v');
expect(formatPageNumber(27, 'upperLetter')).toBe('AA');
expect(formatPageNumber(703, 'lowerLetter')).toBe('aaa');
expect(formatPageNumber(12, 'numberInDash')).toBe('-12-');
expect(formatPageNumber(28, 'upperLetter')).toBe('BB');
expect(formatPageNumber(703, 'lowerLetter')).toBe('a'.repeat(28));
expect(formatPageNumber(12, 'numberInDash')).toBe('- 12 -');
});

it('normalizes page numbers before formatting', () => {
Expand All @@ -17,6 +18,10 @@ describe('page number formatting', () => {
expect(formatPageNumber(Number.NaN, 'decimal')).toBe('1');
});

it('falls back to decimal for unsupported runtime formats', () => {
expect(formatPageNumber(5, 'chicago' as never)).toBe('5');
});

it('falls back to decimal for roman numerals beyond 3999', () => {
expect(formatPageNumber(4000, 'upperRoman')).toBe('4000');
});
Expand Down
16 changes: 5 additions & 11 deletions packages/layout-engine/contracts/src/page-number-formatting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,10 @@ function toUpperRoman(value: number): string {
}

function toUpperLetter(value: number): string {
let n = Math.max(1, value);
let result = '';

while (n > 0) {
const remainder = (n - 1) % 26;
result = String.fromCharCode(65 + remainder) + result;
n = Math.floor((n - 1) / 26);
}

return result;
const normalized = Math.max(1, value);
const index = (normalized - 1) % 26;
const repeatCount = Math.floor((normalized - 1) / 26) + 1;
return String.fromCharCode(65 + index).repeat(repeatCount);
}

export function formatPageNumber(pageNumber: number, format: PageNumberFormat): string {
Expand All @@ -49,7 +43,7 @@ export function formatPageNumber(pageNumber: number, format: PageNumberFormat):
case 'lowerLetter':
return toUpperLetter(value).toLowerCase();
case 'numberInDash':
return `-${value}-`;
return `- ${value} -`;
case 'decimal':
default:
return String(value);
Expand Down
6 changes: 4 additions & 2 deletions packages/layout-engine/contracts/src/resolved-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export type ResolvedPage = {
displayNumber?: number;
/** Formatted page number text (e.g. "i", "ii" for Roman numeral sections). */
numberText?: string;
/** Numeric page number after section page numbering settings are applied. */
effectivePageNumber?: number;
/** Vertical alignment of content within this page. */
vAlign?: SectionVerticalAlign;
/** Base section margins before header/footer inflation. Used for vAlign centering calculations. */
Expand Down Expand Up @@ -450,9 +452,9 @@ export function isResolvedDrawingItem(item: ResolvedPaintItem): item is Resolved
/** A resolved header/footer page — mirrors HeaderFooterPage but with resolved items. */
export type ResolvedHeaderFooterPage = {
number: number;
/** Numeric page number after section numbering restart/offset. Used for OOXML odd/even parity. */
displayNumber?: number;
numberText?: string;
/** Section-aware numeric page value before formatting. */
displayNumber?: number;
items: ResolvedPaintItem[];
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export function computeHeaderFooterContentHash(blocks: FlowBlock[]): string {
if ('bold' in run && run.bold) parts.push('b');
if ('italic' in run && run.italic) parts.push('i');
if ('token' in run && run.token) parts.push(`token:${run.token}`);
if ('pageNumberFormat' in run && run.pageNumberFormat) parts.push(`pnf:${run.pageNumberFormat}`);
}
}
}
Expand Down
Loading
Loading