Skip to content

Commit 1c76b4f

Browse files
authored
[9/16] refactor(layout): add resolveHeaderFooterLayout helper for decoration layouts (#2826)
* refactor(layout): lift page metadata into ResolvedPage * refactor(layout): lift fragment metadata into resolved paint items Add pmStart, pmEnd, continuesFromPrev, continuesOnNext, markerWidth, and metadata fields to resolved paint item types. Populate them in the resolvers and update the painter to prefer resolved item data over legacy Fragment reads with fallbacks. * refactor(layout): pre-compute SDT container keys in resolved layout * refactor(layout): pre-compute paragraph border data in resolved layout * refactor(layout): move change detection into resolved layout stage * refactor(layout): lift paragraph and list-item block/measure into resolved items * refactor(painter): extract block/measure resolution helper * refactor(painter): remove body blocks/measures from DomPainterInput Body block and measure data now flows exclusively through the resolved layout. The painter only builds a blockLookup from header/footer data, which is the last remaining fallback surface for fragments that do not yet have a resolved path. Complex-transaction rebuild detection now walks the resolved layout items directly instead of iterating the body blockLookup. The legacy createDomPainter wrapper derives a resolved layout from its legacyState blocks/measures on the fly so the benchmark path and direct createDomPainter(options).paint(Layout) callers keep working without setResolvedLayout. * refactor(layout): add resolveHeaderFooterLayout helper for decoration layouts * chore: fix lock file * fix: preserve per-page header/footer resolution data
1 parent 55bf52e commit 1c76b4f

11 files changed

Lines changed: 298 additions & 4 deletions

File tree

packages/layout-engine/contracts/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1901,6 +1901,16 @@ export type HeaderFooterPage = {
19011901
number: number;
19021902
fragments: Fragment[];
19031903
numberText?: string;
1904+
/**
1905+
* Optional page-local block clones backing this page's resolved fragments.
1906+
* Present when header/footer tokens were laid out per page or per bucket.
1907+
*/
1908+
blocks?: FlowBlock[];
1909+
/**
1910+
* Optional page-local measures aligned with `blocks`.
1911+
* Present when header/footer tokens were laid out per page or per bucket.
1912+
*/
1913+
measures?: Measure[];
19041914
};
19051915

19061916
export type HeaderFooterLayout = {
@@ -1980,6 +1990,8 @@ export type {
19801990
ResolvedTableItem,
19811991
ResolvedImageItem,
19821992
ResolvedDrawingItem,
1993+
ResolvedHeaderFooterPage,
1994+
ResolvedHeaderFooterLayout,
19831995
} from './resolved-layout.js';
19841996
export { isResolvedTableItem, isResolvedImageItem, isResolvedDrawingItem } from './resolved-layout.js';
19851997

packages/layout-engine/contracts/src/resolved-layout.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,22 @@ export function isResolvedDrawingItem(item: ResolvedPaintItem): item is Resolved
350350
return item.kind === 'fragment' && 'fragmentKind' in item && item.fragmentKind === 'drawing' && 'block' in item;
351351
}
352352

353+
/** A resolved header/footer page — mirrors HeaderFooterPage but with resolved items. */
354+
export type ResolvedHeaderFooterPage = {
355+
number: number;
356+
numberText?: string;
357+
items: ResolvedPaintItem[];
358+
};
359+
360+
/** A resolved header/footer layout — mirrors HeaderFooterLayout but with resolved pages. */
361+
export type ResolvedHeaderFooterLayout = {
362+
height: number;
363+
minY?: number;
364+
maxY?: number;
365+
renderHeight?: number;
366+
pages: ResolvedHeaderFooterPage[];
367+
};
368+
353369
/** Resolved list marker rendering data with pre-computed positioning. */
354370
export type ResolvedListMarkerItem = {
355371
/** Marker text content (e.g., "1.", "a)", bullet). */

packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,8 @@ export async function layoutHeaderFooterWithCache(
322322
pages: pages.map((p) => ({
323323
number: p.number,
324324
fragments: p.fragments,
325+
blocks: p.blocks,
326+
measures: p.measures,
325327
})),
326328
};
327329

packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,38 @@ describe('layoutHeaderFooterWithCache', () => {
8181
expect(measureBlock).not.toHaveBeenCalled();
8282
});
8383

84+
it('stores page-local block clones for tokenized header/footer pages', async () => {
85+
const sections = {
86+
default: [
87+
{
88+
kind: 'paragraph',
89+
id: 'page-token-header',
90+
runs: [
91+
{ text: 'Page ', fontFamily: 'Arial', fontSize: 16 },
92+
{ text: '0', token: 'pageNumber', fontFamily: 'Arial', fontSize: 16 },
93+
],
94+
} satisfies FlowBlock,
95+
],
96+
};
97+
const measureBlock = vi.fn(async () => makeMeasure(12));
98+
99+
const result = await layoutHeaderFooterWithCache(
100+
sections,
101+
{ width: 300, height: 40 },
102+
measureBlock,
103+
undefined,
104+
undefined,
105+
(pageNumber) => ({ displayText: String(pageNumber), totalPages: 2 }),
106+
'header',
107+
);
108+
109+
expect(result.default?.layout.pages).toHaveLength(2);
110+
expect(result.default?.layout.pages[0].blocks?.[0].runs[1]?.text).toBe('1');
111+
expect(result.default?.layout.pages[1].blocks?.[0].runs[1]?.text).toBe('2');
112+
expect(result.default?.layout.pages[0].measures).toHaveLength(1);
113+
expect(result.default?.layout.pages[1].measures).toHaveLength(1);
114+
});
115+
84116
describe('integration test', () => {
85117
it('full pipeline: PM JSON with page tokens → FlowBlocks → Measures → Layout', async () => {
86118
// 1. Create PM JSON with page number tokens (simulates header/footer from SuperConverter)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { resolveLayout } from './resolveLayout.js';
22
export type { ResolveLayoutInput } from './resolveLayout.js';
3+
export { resolveHeaderFooterLayout } from './resolveHeaderFooter.js';
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { resolveHeaderFooterLayout } from './resolveHeaderFooter.js';
3+
import type { FlowBlock, HeaderFooterLayout, Measure, ParaFragment, ResolvedFragmentItem } from '@superdoc/contracts';
4+
5+
describe('resolveHeaderFooterLayout', () => {
6+
it('resolves a header/footer with one paragraph fragment', () => {
7+
const paraFragment: ParaFragment = {
8+
kind: 'para',
9+
blockId: 'p1',
10+
fromLine: 0,
11+
toLine: 1,
12+
x: 72,
13+
y: 10,
14+
width: 468,
15+
};
16+
const layout: HeaderFooterLayout = {
17+
height: 50,
18+
pages: [{ number: 1, fragments: [paraFragment] }],
19+
};
20+
const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }];
21+
const measures: Measure[] = [
22+
{
23+
kind: 'paragraph',
24+
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 100, ascent: 10, descent: 3, lineHeight: 18 }],
25+
totalHeight: 18,
26+
},
27+
];
28+
29+
const result = resolveHeaderFooterLayout(layout, blocks, measures);
30+
expect(result.pages).toHaveLength(1);
31+
const item = result.pages[0].items[0] as ResolvedFragmentItem;
32+
expect(item.version).toBeDefined();
33+
expect(item.block?.kind).toBe('paragraph');
34+
expect(item.measure?.kind).toBe('paragraph');
35+
});
36+
37+
it('preserves height, minY, maxY, renderHeight from input', () => {
38+
const layout: HeaderFooterLayout = {
39+
height: 100,
40+
minY: 5,
41+
maxY: 120,
42+
renderHeight: 115,
43+
pages: [],
44+
};
45+
46+
const result = resolveHeaderFooterLayout(layout, [], []);
47+
expect(result.height).toBe(100);
48+
expect(result.minY).toBe(5);
49+
expect(result.maxY).toBe(120);
50+
expect(result.renderHeight).toBe(115);
51+
});
52+
53+
it('preserves numberText on pages', () => {
54+
const layout: HeaderFooterLayout = {
55+
height: 50,
56+
pages: [
57+
{ number: 1, fragments: [], numberText: 'i' },
58+
{ number: 2, fragments: [], numberText: 'ii' },
59+
],
60+
};
61+
62+
const result = resolveHeaderFooterLayout(layout, [], []);
63+
expect(result.pages[0].numberText).toBe('i');
64+
expect(result.pages[1].numberText).toBe('ii');
65+
});
66+
67+
it('returns empty items array for empty fragments array', () => {
68+
const layout: HeaderFooterLayout = {
69+
height: 50,
70+
pages: [{ number: 1, fragments: [] }],
71+
};
72+
73+
const result = resolveHeaderFooterLayout(layout, [], []);
74+
expect(result.pages).toHaveLength(1);
75+
expect(result.pages[0].items).toEqual([]);
76+
});
77+
78+
it('leaves block/measure undefined when block entry is missing', () => {
79+
const paraFragment: ParaFragment = {
80+
kind: 'para',
81+
blockId: 'missing-id',
82+
fromLine: 0,
83+
toLine: 1,
84+
x: 0,
85+
y: 0,
86+
width: 100,
87+
};
88+
const layout: HeaderFooterLayout = {
89+
height: 50,
90+
pages: [{ number: 1, fragments: [paraFragment] }],
91+
};
92+
93+
const result = resolveHeaderFooterLayout(layout, [], []);
94+
const item = result.pages[0].items[0] as ResolvedFragmentItem;
95+
expect(item.block).toBeUndefined();
96+
expect(item.measure).toBeUndefined();
97+
});
98+
99+
it('resolves each page against its own cloned block data', () => {
100+
const paraFragment: ParaFragment = {
101+
kind: 'para',
102+
blockId: 'page-token',
103+
fromLine: 0,
104+
toLine: 1,
105+
x: 0,
106+
y: 0,
107+
width: 120,
108+
};
109+
const pageOneBlocks: FlowBlock[] = [
110+
{
111+
kind: 'paragraph',
112+
id: 'page-token',
113+
runs: [
114+
{ text: 'Page ', fontFamily: 'Arial', fontSize: 16 },
115+
{ text: '1', token: 'pageNumber', fontFamily: 'Arial', fontSize: 16 },
116+
],
117+
},
118+
];
119+
const pageTwoBlocks: FlowBlock[] = [
120+
{
121+
kind: 'paragraph',
122+
id: 'page-token',
123+
runs: [
124+
{ text: 'Page ', fontFamily: 'Arial', fontSize: 16 },
125+
{ text: '2', token: 'pageNumber', fontFamily: 'Arial', fontSize: 16 },
126+
],
127+
},
128+
];
129+
const makeMeasure = (text: string): Measure => ({
130+
kind: 'paragraph',
131+
lines: [
132+
{ fromRun: 0, fromChar: 0, toRun: 1, toChar: text.length, width: 120, ascent: 10, descent: 3, lineHeight: 18 },
133+
],
134+
totalHeight: 18,
135+
});
136+
const layout: HeaderFooterLayout = {
137+
height: 50,
138+
pages: [
139+
{ number: 1, fragments: [paraFragment], blocks: pageOneBlocks, measures: [makeMeasure('Page 1')] },
140+
{ number: 2, fragments: [paraFragment], blocks: pageTwoBlocks, measures: [makeMeasure('Page 2')] },
141+
],
142+
};
143+
144+
const result = resolveHeaderFooterLayout(layout, pageOneBlocks, [makeMeasure('Page 1')]);
145+
const firstItem = result.pages[0].items[0] as ResolvedFragmentItem;
146+
const secondItem = result.pages[1].items[0] as ResolvedFragmentItem;
147+
148+
expect(firstItem.block?.kind).toBe('paragraph');
149+
expect(secondItem.block?.kind).toBe('paragraph');
150+
expect(firstItem.block?.runs[1]?.text).toBe('1');
151+
expect(secondItem.block?.runs[1]?.text).toBe('2');
152+
expect(firstItem.version).not.toBe(secondItem.version);
153+
});
154+
155+
it('uses document page indices for sparse header/footer pages', () => {
156+
const paraFragment: ParaFragment = {
157+
kind: 'para',
158+
blockId: 'p1',
159+
fromLine: 0,
160+
toLine: 1,
161+
x: 0,
162+
y: 0,
163+
width: 100,
164+
};
165+
const layout: HeaderFooterLayout = {
166+
height: 50,
167+
pages: [
168+
{ number: 5, fragments: [paraFragment], numberText: '5' },
169+
{ number: 50, fragments: [paraFragment], numberText: '50' },
170+
{ number: 500, fragments: [paraFragment], numberText: '500' },
171+
],
172+
};
173+
const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }];
174+
const measures: Measure[] = [
175+
{
176+
kind: 'paragraph',
177+
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0, width: 100, ascent: 10, descent: 3, lineHeight: 18 }],
178+
totalHeight: 18,
179+
},
180+
];
181+
182+
const result = resolveHeaderFooterLayout(layout, blocks, measures);
183+
184+
expect((result.pages[0].items[0] as ResolvedFragmentItem).pageIndex).toBe(4);
185+
expect((result.pages[1].items[0] as ResolvedFragmentItem).pageIndex).toBe(49);
186+
expect((result.pages[2].items[0] as ResolvedFragmentItem).pageIndex).toBe(499);
187+
});
188+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type {
2+
FlowBlock,
3+
HeaderFooterLayout,
4+
Measure,
5+
ResolvedHeaderFooterLayout,
6+
ResolvedHeaderFooterPage,
7+
} from '@superdoc/contracts';
8+
import { buildBlockMap, resolveFragmentItem } from './resolveLayout.js';
9+
10+
/**
11+
* Resolves a header/footer layout into a `ResolvedHeaderFooterLayout`.
12+
*
13+
* Standalone helper invoked per `HeaderFooterLayoutResult` from `incrementalLayout`.
14+
* The caller stores results indexed by the same key (type or rId) as the originals;
15+
* alignment between fragments and resolved items is guaranteed by construction.
16+
*/
17+
export function resolveHeaderFooterLayout(
18+
layout: HeaderFooterLayout,
19+
blocks: FlowBlock[],
20+
measures: Measure[],
21+
): ResolvedHeaderFooterLayout {
22+
const pages: ResolvedHeaderFooterPage[] = layout.pages.map((page) => {
23+
const pageBlocks = page.blocks ?? blocks;
24+
const pageMeasures = page.measures ?? measures;
25+
const blockMap = buildBlockMap(pageBlocks, pageMeasures);
26+
const blockVersionCache = new Map<string, string>();
27+
28+
return {
29+
number: page.number,
30+
numberText: page.numberText,
31+
items: page.fragments.map((fragment, fragmentIndex) =>
32+
resolveFragmentItem(fragment, fragmentIndex, page.number - 1, blockMap, blockVersionCache),
33+
),
34+
};
35+
});
36+
37+
return {
38+
height: layout.height,
39+
minY: layout.minY,
40+
maxY: layout.maxY,
41+
renderHeight: layout.renderHeight,
42+
pages,
43+
};
44+
}

packages/layout-engine/layout-resolved/src/resolveLayout.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export type ResolveLayoutInput = {
3737
measures: Measure[];
3838
};
3939

40-
function buildBlockMap(blocks: FlowBlock[], measures: Measure[]): Map<string, BlockMapEntry> {
40+
export function buildBlockMap(blocks: FlowBlock[], measures: Measure[]): Map<string, BlockMapEntry> {
4141
const map = new Map<string, BlockMapEntry>();
4242
for (let i = 0; i < blocks.length; i++) {
4343
map.set(blocks[i].id, { block: blocks[i], measure: measures[i] });
@@ -190,7 +190,7 @@ function computeBlockVersion(
190190
return version;
191191
}
192192

193-
function resolveFragmentItem(
193+
export function resolveFragmentItem(
194194
fragment: Fragment,
195195
fragmentIndex: number,
196196
pageIndex: number,

packages/layout-engine/painters/dom/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
},
2828
"devDependencies": {
2929
"@superdoc/layout-engine": "workspace:*",
30+
"@superdoc/layout-resolved": "workspace:*",
3031
"vitest": "catalog:"
3132
}
3233
}

packages/layout-engine/painters/dom/src/renderer.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2435,7 +2435,6 @@ export class DomPainter {
24352435

24362436
return separatorPositions;
24372437
}
2438-
24392438
private renderDecorationsForPage(
24402439
pageEl: HTMLElement,
24412440
page: Page,

0 commit comments

Comments
 (0)