-
Notifications
You must be signed in to change notification settings - Fork 152
Expand file tree
/
Copy pathlayoutHeaderFooter.ts
More file actions
369 lines (332 loc) · 13.1 KB
/
layoutHeaderFooter.ts
File metadata and controls
369 lines (332 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
import type { FlowBlock, HeaderFooterLayout, Measure, ParagraphBlock, TableBlock } from '@superdoc/contracts';
import { layoutHeaderFooter, type HeaderFooterConstraints } from '@superdoc/layout-engine';
import { MeasureCache } from './cache';
import { resolveHeaderFooterTokens, cloneHeaderFooterBlocks } from './resolveHeaderFooterTokens';
import { FeatureFlags } from './featureFlags';
import { HeaderFooterCacheLogger } from './instrumentation';
export type HeaderFooterBatch = Partial<Record<'default' | 'first' | 'even' | 'odd', FlowBlock[]>>;
export type MeasureResolver = (
block: FlowBlock,
constraints: { maxWidth: number; maxHeight: number },
) => Promise<Measure>;
export type HeaderFooterBatchResult = Partial<
Record<'default' | 'first' | 'even' | 'odd', { blocks: FlowBlock[]; measures: Measure[]; layout: HeaderFooterLayout }>
>;
/**
* Page resolver callback for header/footer token resolution.
* Provides display page information for a specific physical page number.
*
* @param pageNumber - Physical page number (1-indexed)
* @returns Display page information including formatted text and total pages
*/
export type PageResolver = (pageNumber: number) => {
displayText: string;
totalPages: number;
};
/**
* Digit bucket for page number caching strategy.
* Different digit lengths require different measurements due to width changes.
*/
export type DigitBucket = 'd1' | 'd2' | 'd3' | 'd4';
/**
* Minimum document size to enable digit bucketing optimization.
* Below this threshold, we create per-page layouts for simplicity.
*/
const MIN_PAGES_FOR_BUCKETING = 100;
/**
* Determines the digit bucket for a given page number.
*
* Bucket strategy:
* - d1: 1-9 (single digit)
* - d2: 10-99 (two digits)
* - d3: 100-999 (three digits)
* - d4: 1000+ (four or more digits)
*
* This bucketing allows us to cache header/footer layouts by digit width,
* reducing the number of unique layouts we need to measure and store.
*
* @param pageNumber - Page number to bucket (1-indexed)
* @returns Digit bucket identifier
*
* @example
* ```typescript
* getBucketForPageNumber(5); // 'd1'
* getBucketForPageNumber(42); // 'd2'
* getBucketForPageNumber(123); // 'd3'
* getBucketForPageNumber(1000); // 'd4'
* ```
*/
export function getBucketForPageNumber(pageNumber: number): DigitBucket {
if (pageNumber < 10) return 'd1';
if (pageNumber < 100) return 'd2';
if (pageNumber < 1000) return 'd3';
return 'd4';
}
/**
* Gets a representative page number for a digit bucket.
*
* The representative page is used for measurement and layout.
* We choose mid-range values to get realistic measurements:
* - d1: 5 (middle of 1-9)
* - d2: 50 (middle of 10-99)
* - d3: 500 (middle of 100-999)
* - d4: 5000 (representative of 1000+)
*
* @param bucket - Digit bucket identifier
* @returns Representative page number for the bucket
*
* @example
* ```typescript
* getBucketRepresentative('d1'); // 5
* getBucketRepresentative('d2'); // 50
* getBucketRepresentative('d3'); // 500
* ```
*/
export function getBucketRepresentative(bucket: DigitBucket): number {
switch (bucket) {
case 'd1':
return 5;
case 'd2':
return 50;
case 'd3':
return 500;
case 'd4':
return 5000;
}
}
/**
* Checks if a variant has any page number tokens.
*
* This is an optimization to skip bucketing and token resolution
* for header/footer variants that don't contain page tokens.
*
* @param blocks - FlowBlocks to check for tokens
* @returns True if any block contains pageNumber or totalPageCount tokens
*/
function paragraphHasPageToken(para: ParagraphBlock): boolean {
for (const run of para.runs) {
if ('token' in run && (run.token === 'pageNumber' || run.token === 'totalPageCount')) {
return true;
}
}
return false;
}
function hasPageTokens(blocks: FlowBlock[]): boolean {
for (const block of blocks) {
if (block.kind === 'paragraph') {
if (paragraphHasPageToken(block as ParagraphBlock)) return true;
} else if (block.kind === 'table') {
// SD-1332: PAGE fields can live inside table cells in headers/footers
// (Word's typical layout). Skipping tables here would take the
// "no tokens" fast path and reuse a single layout for every page,
// so the digit would never substitute per page.
const table = block as TableBlock;
for (const row of table.rows ?? []) {
for (const cell of row.cells ?? []) {
const cellBlocks: FlowBlock[] = cell.blocks
? (cell.blocks as FlowBlock[])
: cell.paragraph
? [cell.paragraph]
: [];
if (hasPageTokens(cellBlocks)) return true;
}
}
}
}
return false;
}
export class HeaderFooterLayoutCache {
private readonly cache = new MeasureCache<Measure>();
public async measureBlocks(
blocks: FlowBlock[],
constraints: { width: number; height: number },
measureBlock: MeasureResolver,
// The document resolver's mapping signature. This cache is a cross-document singleton, so the
// signature must key it - otherwise two documents that map the same logical header font
// differently would share one measure. Defaults to '' (no overrides => all default docs share).
fontSignature: string = '',
): Promise<Measure[]> {
const measures: Measure[] = [];
for (const block of blocks) {
const cached = this.cache.get(block, constraints.width, constraints.height, fontSignature);
if (cached) {
measures.push(cached);
continue;
}
const measurement = await measureBlock(block, {
maxWidth: constraints.width,
maxHeight: constraints.height,
});
this.cache.set(block, constraints.width, constraints.height, measurement, fontSignature);
measures.push(measurement);
}
return measures;
}
public invalidate(blockIds: string[]): void {
this.cache.invalidate(blockIds);
}
/**
* Gets cache statistics for monitoring and debugging.
*
* @returns Cache statistics object
*/
public getStats(): ReturnType<MeasureCache<Measure>['getStats']> {
return this.cache.getStats();
}
}
const sharedHeaderFooterCache = new HeaderFooterLayoutCache();
/**
* Layouts header/footer variants with intelligent caching and page number resolution.
*
* Features:
* - Resolves page tokens using section-aware display page numbers
* - Uses digit bucketing to optimize caching for large documents
* - Clones blocks per page/bucket to avoid mutating shared source data
* - Produces HeaderFooterLayout with per-page fragments
*
* Key behaviors:
* 1. If no pageResolver provided: falls back to simple single-page layout (backward compatibility)
* 2. If variant has no tokens: creates one layout reused across all pages (fast path)
* 3. For small docs (<100 pages): creates per-page layouts
* 4. For large docs (>=100 pages): uses digit bucketing (d1, d2, d3, d4)
*
* @param sections - Header/footer variants (default, first, even, odd)
* @param constraints - Layout constraints (width, height, margins)
* @param measureBlock - Function to measure individual blocks
* @param cache - Measurement cache instance (optional, uses shared cache by default)
* @param totalPages - Total page count for backward compatibility (deprecated, use pageResolver)
* @param pageResolver - Callback to resolve display page info for a physical page number
* @returns Batch result with layouts, blocks, and measures for each variant
*/
export async function layoutHeaderFooterWithCache(
sections: HeaderFooterBatch,
constraints: HeaderFooterConstraints,
measureBlock: MeasureResolver,
cache: HeaderFooterLayoutCache = sharedHeaderFooterCache,
totalPages?: number,
pageResolver?: PageResolver,
kind?: 'header' | 'footer',
// The calling document's font-mapping signature, forwarded to the (cross-document) measure cache
// so header/footer measures cannot leak between documents with different mappings. '' = default.
fontSignature: string = '',
): Promise<HeaderFooterBatchResult> {
const result: HeaderFooterBatchResult = {};
// Backward compatibility: If no pageResolver, use simple single-page layout
if (!pageResolver) {
const numPages = totalPages ?? 1;
for (const [type, blocks] of Object.entries(sections) as [keyof HeaderFooterBatch, FlowBlock[] | undefined][]) {
if (!blocks || blocks.length === 0) continue;
// Clone blocks to avoid mutating the original shared data structure
const clonedBlocks = cloneHeaderFooterBlocks(blocks);
// Resolve page number tokens BEFORE measurement
resolveHeaderFooterTokens(clonedBlocks, 1, numPages);
const measures = await cache.measureBlocks(clonedBlocks, constraints, measureBlock, fontSignature);
const layout = layoutHeaderFooter(clonedBlocks, measures, constraints, kind);
result[type] = { blocks: clonedBlocks, measures, layout };
}
return result;
}
// Page resolver path with digit bucketing
const { totalPages: docTotalPages } = pageResolver(1);
if (!Number.isFinite(docTotalPages) || docTotalPages <= 0) {
return result;
}
const useBucketing = FeatureFlags.HF_DIGIT_BUCKETING && docTotalPages >= MIN_PAGES_FOR_BUCKETING;
for (const [type, blocks] of Object.entries(sections) as [keyof HeaderFooterBatch, FlowBlock[] | undefined][]) {
if (!blocks || blocks.length === 0) {
continue;
}
// Fast path: if variant has no page tokens, create one layout for all pages
const hasTokens = hasPageTokens(blocks);
if (!hasTokens) {
const measures = await cache.measureBlocks(blocks, constraints, measureBlock, fontSignature);
const layout = layoutHeaderFooter(blocks, measures, constraints, kind);
result[type] = { blocks, measures, layout };
continue;
}
// Determine which pages to create layouts for
let pagesToLayout: number[];
if (!useBucketing) {
// Small doc: create layout for every page
pagesToLayout = Array.from({ length: docTotalPages }, (_, i) => i + 1);
HeaderFooterCacheLogger.logBucketingDecision(docTotalPages, false);
} else {
// Large doc: create layouts for bucket representatives
// Determine which buckets are needed
const bucketsNeeded = new Set<DigitBucket>();
for (let p = 1; p <= docTotalPages; p++) {
bucketsNeeded.add(getBucketForPageNumber(p));
}
// Map each bucket to its representative page
pagesToLayout = Array.from(bucketsNeeded).map((bucket) => getBucketRepresentative(bucket));
HeaderFooterCacheLogger.logBucketingDecision(docTotalPages, true, Array.from(bucketsNeeded));
}
// Create layouts for each page (or bucket representative)
const pages: Array<{
number: number;
blocks: FlowBlock[];
measures: Measure[];
fragments: HeaderFooterLayout['pages'][0]['fragments'];
}> = [];
for (const pageNum of pagesToLayout) {
// Clone blocks for this page
const clonedBlocks = cloneHeaderFooterBlocks(blocks);
// Resolve page number tokens for this specific page
const { displayText, totalPages: totalPagesForPage } = pageResolver(pageNum);
resolveHeaderFooterTokens(clonedBlocks, pageNum, totalPagesForPage, displayText);
// Measure and layout
const measures = await cache.measureBlocks(clonedBlocks, constraints, measureBlock, fontSignature);
const pageLayout = layoutHeaderFooter(clonedBlocks, measures, constraints, kind);
const measuresById = new Map<string, Measure>();
for (let i = 0; i < clonedBlocks.length; i += 1) {
measuresById.set(clonedBlocks[i].id, measures[i]);
}
const fragmentsWithLines =
pageLayout.pages[0]?.fragments.map((fragment) => {
if (fragment.kind !== 'para') {
return fragment;
}
const measure = measuresById.get(fragment.blockId);
if (!measure || measure.kind !== 'paragraph') {
return fragment;
}
return {
...fragment,
lines: measure.lines.slice(fragment.fromLine, fragment.toLine),
};
}) ?? [];
// Store page-specific data
pages.push({
number: pageNum,
blocks: clonedBlocks,
measures,
fragments: fragmentsWithLines,
});
}
// Construct final HeaderFooterLayout with all pages
// Use the first page's measurements for overall dimensions
const firstPageLayout = pages[0]
? layoutHeaderFooter(pages[0].blocks, pages[0].measures, constraints, kind)
: { height: 0, pages: [] };
const finalLayout: HeaderFooterLayout = {
height: firstPageLayout.height,
minY: firstPageLayout.minY,
maxY: firstPageLayout.maxY,
renderHeight: firstPageLayout.renderHeight,
pages: pages.map((p) => ({
number: p.number,
fragments: p.fragments,
blocks: p.blocks,
measures: p.measures,
})),
};
// Return the first page's blocks and measures for backward compatibility
// Painters will use layout.pages to find the correct fragments per page
result[type] = {
blocks: pages[0]?.blocks ?? blocks,
measures: pages[0]?.measures ?? [],
layout: finalLayout,
};
}
return result;
}