diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 69ca4dc0cc..d49db2041e 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1942,6 +1942,8 @@ export type { ResolvedTableItem, ResolvedImageItem, ResolvedDrawingItem, + ResolvedHeaderFooterPage, + ResolvedHeaderFooterLayout, } from './resolved-layout.js'; export { isResolvedTableItem, isResolvedImageItem, isResolvedDrawingItem } from './resolved-layout.js'; diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index c7eb7550d9..bdfcd855f2 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -348,6 +348,22 @@ export function isResolvedDrawingItem(item: ResolvedPaintItem): item is Resolved return item.kind === 'fragment' && 'fragmentKind' in item && item.fragmentKind === 'drawing' && 'block' in item; } +/** A resolved header/footer page — mirrors HeaderFooterPage but with resolved items. */ +export type ResolvedHeaderFooterPage = { + number: number; + numberText?: string; + items: ResolvedPaintItem[]; +}; + +/** A resolved header/footer layout — mirrors HeaderFooterLayout but with resolved pages. */ +export type ResolvedHeaderFooterLayout = { + height: number; + minY?: number; + maxY?: number; + renderHeight?: number; + pages: ResolvedHeaderFooterPage[]; +}; + /** Resolved list marker rendering data with pre-computed positioning. */ export type ResolvedListMarkerItem = { /** Marker text content (e.g., "1.", "a)", bullet). */ diff --git a/packages/layout-engine/layout-resolved/src/index.ts b/packages/layout-engine/layout-resolved/src/index.ts index af3f0a23c7..c504917f6f 100644 --- a/packages/layout-engine/layout-resolved/src/index.ts +++ b/packages/layout-engine/layout-resolved/src/index.ts @@ -1,2 +1,3 @@ export { resolveLayout } from './resolveLayout.js'; export type { ResolveLayoutInput } from './resolveLayout.js'; +export { resolveHeaderFooterLayout } from './resolveHeaderFooter.js'; diff --git a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts new file mode 100644 index 0000000000..37473dc4e9 --- /dev/null +++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import { resolveHeaderFooterLayout } from './resolveHeaderFooter.js'; +import type { FlowBlock, HeaderFooterLayout, Measure, ParaFragment, ResolvedFragmentItem } from '@superdoc/contracts'; + +describe('resolveHeaderFooterLayout', () => { + it('resolves a header/footer with one paragraph fragment', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 10, + width: 468, + }; + const layout: HeaderFooterLayout = { + height: 50, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }]; + const measures: Measure[] = [ + { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 100, ascent: 10, descent: 3, lineHeight: 18 }], + totalHeight: 18, + }, + ]; + + const result = resolveHeaderFooterLayout(layout, blocks, measures); + expect(result.pages).toHaveLength(1); + const item = result.pages[0].items[0] as ResolvedFragmentItem; + expect(item.version).toBeDefined(); + expect(item.block?.kind).toBe('paragraph'); + expect(item.measure?.kind).toBe('paragraph'); + }); + + it('preserves height, minY, maxY, renderHeight from input', () => { + const layout: HeaderFooterLayout = { + height: 100, + minY: 5, + maxY: 120, + renderHeight: 115, + pages: [], + }; + + const result = resolveHeaderFooterLayout(layout, [], []); + expect(result.height).toBe(100); + expect(result.minY).toBe(5); + expect(result.maxY).toBe(120); + expect(result.renderHeight).toBe(115); + }); + + it('preserves numberText on pages', () => { + const layout: HeaderFooterLayout = { + height: 50, + pages: [ + { number: 1, fragments: [], numberText: 'i' }, + { number: 2, fragments: [], numberText: 'ii' }, + ], + }; + + const result = resolveHeaderFooterLayout(layout, [], []); + expect(result.pages[0].numberText).toBe('i'); + expect(result.pages[1].numberText).toBe('ii'); + }); + + it('returns empty items array for empty fragments array', () => { + const layout: HeaderFooterLayout = { + height: 50, + pages: [{ number: 1, fragments: [] }], + }; + + const result = resolveHeaderFooterLayout(layout, [], []); + expect(result.pages).toHaveLength(1); + expect(result.pages[0].items).toEqual([]); + }); + + it('leaves block/measure undefined when block entry is missing', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'missing-id', + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 100, + }; + const layout: HeaderFooterLayout = { + height: 50, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + + const result = resolveHeaderFooterLayout(layout, [], []); + const item = result.pages[0].items[0] as ResolvedFragmentItem; + expect(item.block).toBeUndefined(); + expect(item.measure).toBeUndefined(); + }); +}); diff --git a/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts new file mode 100644 index 0000000000..c3d566c15b --- /dev/null +++ b/packages/layout-engine/layout-resolved/src/resolveHeaderFooter.ts @@ -0,0 +1,40 @@ +import type { + FlowBlock, + HeaderFooterLayout, + Measure, + ResolvedHeaderFooterLayout, + ResolvedHeaderFooterPage, +} from '@superdoc/contracts'; +import { buildBlockMap, resolveFragmentItem } from './resolveLayout.js'; + +/** + * Resolves a header/footer layout into a `ResolvedHeaderFooterLayout`. + * + * Standalone helper invoked per `HeaderFooterLayoutResult` from `incrementalLayout`. + * The caller stores results indexed by the same key (type or rId) as the originals; + * alignment between fragments and resolved items is guaranteed by construction. + */ +export function resolveHeaderFooterLayout( + layout: HeaderFooterLayout, + blocks: FlowBlock[], + measures: Measure[], +): ResolvedHeaderFooterLayout { + const blockMap = buildBlockMap(blocks, measures); + const blockVersionCache = new Map(); + + const pages: ResolvedHeaderFooterPage[] = layout.pages.map((page, pageIndex) => ({ + number: page.number, + numberText: page.numberText, + items: page.fragments.map((fragment, fragmentIndex) => + resolveFragmentItem(fragment, fragmentIndex, pageIndex, blockMap, blockVersionCache), + ), + })); + + return { + height: layout.height, + minY: layout.minY, + maxY: layout.maxY, + renderHeight: layout.renderHeight, + pages, + }; +} diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index ea376cd56c..05f5dcdb72 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -37,7 +37,7 @@ export type ResolveLayoutInput = { measures: Measure[]; }; -function buildBlockMap(blocks: FlowBlock[], measures: Measure[]): Map { +export function buildBlockMap(blocks: FlowBlock[], measures: Measure[]): Map { const map = new Map(); for (let i = 0; i < blocks.length; i++) { map.set(blocks[i].id, { block: blocks[i], measure: measures[i] }); @@ -190,7 +190,7 @@ function computeBlockVersion( return version; } -function resolveFragmentItem( +export function resolveFragmentItem( fragment: Fragment, fragmentIndex: number, pageIndex: number,