-
Notifications
You must be signed in to change notification settings - Fork 152
Expand file tree
/
Copy pathresolvePageTokens.ts
More file actions
306 lines (276 loc) · 11.1 KB
/
resolvePageTokens.ts
File metadata and controls
306 lines (276 loc) · 11.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
/**
* Page Number Token Resolution Module
*
* Resolves dynamic page number tokens (pageNumber, totalPageCount) in layout fragments.
* This module follows the same pattern as resolvePageRefs.ts for PAGEREF token resolution.
*
* Tokens are created during PM-to-FlowBlock conversion with placeholder text ('0'),
* which is used for initial measurement. After layout completes and pages are numbered,
* this module replaces the placeholder text with actual page numbers.
*
* Features:
* - Supports section-aware display page numbers via numbering context
* - Returns updated block clones (doesn't mutate originals)
* - Integrates with two-pass convergence loop in incrementalLayout
*/
import type { Layout, FlowBlock, ParagraphBlock, Measure } from '@superdoc/contracts';
import { formatPageNumberFieldValue, type DisplayPageInfo } from './pageNumbering';
/**
* Numbering context for page token resolution.
* Contains display page information for each physical page in the document.
*/
export interface NumberingContext {
/** Total number of pages in the document */
totalPages: number;
/** Display page information for each page (indexed by physical page number - 1) */
displayPages: DisplayPageInfo[];
}
/**
* Result of page number token resolution.
* Contains affected block IDs and their updated clones.
*/
export interface ResolvePageTokensResult {
/** Set of block IDs that had tokens resolved */
affectedBlockIds: Set<string>;
/** Map of block ID to updated block clone (only for affected blocks) */
updatedBlocks: Map<string, FlowBlock>;
}
/**
* Resolves page number and total page count tokens in document blocks using layout and numbering context.
*
* This function walks through all pages and fragments in the layout, finding blocks with
* page number tokens. For each affected block, it creates a clone with resolved token text,
* using section-aware display page numbers from the numbering context.
*
* The function does NOT mutate original blocks - it returns clones of affected blocks.
* This ensures thread-safety and allows for clean rollback if re-measurement fails.
*
* @param layout - Completed layout with page-numbered fragments
* @param blocks - Original FlowBlocks array (will not be mutated)
* @param measures - Measure array (parallel to blocks, used to detect hasPageTokens flag)
* @param numberingCtx - Numbering context with display page info and total pages
* @returns Result containing affected block IDs and updated block clones
*
* @example
* ```typescript
* const layout = layoutDocument(blocks, measures, options);
* const numberingCtx = buildNumberingContext(layout, sections);
* const result = resolvePageNumberTokens(layout, blocks, measures, numberingCtx);
*
* if (result.affectedBlockIds.size > 0) {
* // Re-measure affected blocks and re-run pagination
* const updatedBlocks = blocks.map(b => result.updatedBlocks.get(b.id) ?? b);
* // ... remeasure and re-layout ...
* }
* ```
*/
export function resolvePageNumberTokens(
layout: Layout,
blocks: FlowBlock[],
measures: Measure[],
numberingCtx: NumberingContext,
): ResolvePageTokensResult {
const affectedBlockIds = new Set<string>();
const updatedBlocks = new Map<string, FlowBlock>();
// Validate inputs
if (!layout?.pages || layout.pages.length === 0) {
return { affectedBlockIds, updatedBlocks };
}
if (!numberingCtx || !numberingCtx.displayPages || numberingCtx.totalPages < 1) {
console.warn('[resolvePageTokens] Invalid numbering context - skipping resolution');
return { affectedBlockIds, updatedBlocks };
}
// Build block lookup map for O(1) access
const blockMap = new Map<string, FlowBlock>();
const blockHasTokensFlags = new Map<string, boolean>();
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
blockMap.set(block.id, block);
// Check if block has hasPageTokens flag for optimization
// This flag should be set during import when tokens are detected
if (block.kind === 'paragraph' && block.attrs && 'hasPageTokens' in block.attrs) {
blockHasTokensFlags.set(block.id, Boolean(block.attrs.hasPageTokens));
}
}
const totalPagesStr = String(numberingCtx.totalPages);
// Track which blocks we've already processed to avoid duplicate work
const processedBlocks = new Set<string>();
// Iterate through all pages and fragments
for (const page of layout.pages) {
// Get display page info for this physical page
const pageIndex = page.number - 1; // Convert to 0-indexed
const displayPageInfo = numberingCtx.displayPages[pageIndex];
if (!displayPageInfo) {
console.warn(`[resolvePageTokens] No display page info for page ${page.number} - skipping`);
continue;
}
for (const fragment of page.fragments) {
// Paragraph fragments — original behaviour.
if (fragment.kind === 'para') {
const blockId = fragment.blockId;
if (processedBlocks.has(blockId)) continue;
const hasTokensFlag = blockHasTokensFlags.get(blockId);
if (hasTokensFlag === false) continue;
const block = blockMap.get(blockId);
if (!block || block.kind !== 'paragraph') continue;
if (!hasPageTokens(block)) {
processedBlocks.add(blockId);
continue;
}
const clonedBlock = cloneBlockWithResolvedTokens(
block,
displayPageInfo,
totalPagesStr,
numberingCtx.totalPages,
);
updatedBlocks.set(blockId, clonedBlock);
affectedBlockIds.add(blockId);
processedBlocks.add(blockId);
continue;
}
// Body tables are intentionally NOT processed here.
//
// A body table can span multiple physical pages: the layout engine emits
// one `kind === 'table'` fragment per page, all sharing the same
// table.blockId, each with its own fromRow..toRow. Cloning the entire
// table once with a single page's displayPageText would resolve every
// PAGE field — including ones rendered on later pages — to the first
// fragment's number. The correct fix is per-fragment substitution
// (synthetic per-page block IDs + targeted row cloning), which is a
// larger layout-pipeline change. Defer until a body-table-with-PAGE
// fixture surfaces it.
//
// SD-1332 (the Linear ticket motivating this comment) is a footer-side
// bug. Headers/footers go through layout-bridge/resolveHeaderFooterTokens,
// which is page-local — each H/F page owns its own block clone — so
// recursing into table cells THERE is safe and correct (see
// forEachParagraphBlock in resolveHeaderFooterTokens.ts).
}
}
return { affectedBlockIds, updatedBlocks };
}
/**
* Checks if a paragraph block contains any page number tokens.
*
* @param block - Paragraph block to check
* @returns True if block contains pageNumber or totalPageCount tokens
*/
function hasPageTokens(block: ParagraphBlock): boolean {
for (const run of block.runs) {
if ('token' in run && (run.token === 'pageNumber' || run.token === 'totalPageCount')) {
return true;
}
}
return false;
}
/**
* Clones a paragraph block and resolves all page number tokens in its runs.
*
* This creates a deep clone of the block's runs array and resolves any pageNumber
* or totalPageCount tokens by replacing the text and clearing the token metadata.
*
* @param block - Original paragraph block (will not be mutated)
* @param displayPageInfo - Section-aware page number data for this physical page
* @param totalPagesStr - Total page count as string
* @returns Cloned block with resolved tokens
*/
function cloneBlockWithResolvedTokens(
block: ParagraphBlock,
displayPageInfo: DisplayPageInfo,
totalPagesStr: string,
totalPages: number,
): ParagraphBlock {
// Clone the runs array and resolve tokens
const clonedRuns = block.runs.map((run) => {
// Check if this run has a page token
if ('token' in run && run.token) {
if (run.token === 'pageNumber') {
// Clone the run and resolve the token
const { token: _token, pageNumberFieldFormat, ...runWithoutToken } = run;
return {
...runWithoutToken,
text: pageNumberFieldFormat
? formatPageNumberFieldValue(displayPageInfo.displayNumber, pageNumberFieldFormat)
: displayPageInfo.displayText,
};
} else if (run.token === 'totalPageCount') {
// Clone the run and resolve the token
const { token: _token, ...runWithoutToken } = run;
return {
...runWithoutToken,
text: run.pageNumberFieldFormat
? formatPageNumberFieldValue(totalPages, run.pageNumberFieldFormat)
: totalPagesStr,
};
}
}
// No token or different token - return as-is
return run;
});
// Return cloned block with new runs
return {
...block,
runs: clonedRuns,
};
}
/**
* Resolves page number tokens in paragraph blocks.
*
* This is a helper function that processes a single paragraph block's runs,
* resolving any pageNumber or totalPageCount tokens. It's designed to be called
* from the layout pipeline where both the layout and blocks are available.
*
* @param block - Paragraph block to process
* @param pageNumber - Current page number (1-indexed)
* @param totalPages - Total number of pages in the document
* @returns True if any tokens were resolved in this block
*
* @example
* ```typescript
* const wasModified = resolveTokensInBlock(paragraphBlock, 3, 10);
* if (wasModified) {
* // Block was modified, may need re-measurement
* }
* ```
*/
export function resolveTokensInBlock(block: ParagraphBlock, pageNumber: number, totalPages: number): boolean {
if (block.kind !== 'paragraph') {
return false;
}
// Validate inputs
if (!Number.isFinite(pageNumber) || pageNumber < 1) {
console.warn('[resolvePageTokens] Invalid pageNumber:', pageNumber, '- using 1 as fallback');
pageNumber = 1;
}
if (!Number.isFinite(totalPages) || totalPages < 1) {
console.warn('[resolvePageTokens] Invalid totalPages:', totalPages, '- using 1 as fallback');
totalPages = 1;
}
const pageNumberStr = String(pageNumber);
const totalPagesStr = String(totalPages);
let blockModified = false;
// Iterate through runs in the paragraph
for (const run of block.runs) {
// Type guard: only TextRun can have token property
if ('token' in run && run.token) {
if (run.token === 'pageNumber') {
// Replace placeholder text with actual page number
run.text = run.pageNumberFieldFormat
? formatPageNumberFieldValue(pageNumber, run.pageNumberFieldFormat)
: pageNumberStr;
// Clear token metadata to treat as normal text after resolution
delete run.token;
delete run.pageNumberFieldFormat;
blockModified = true;
} else if (run.token === 'totalPageCount') {
// Replace placeholder text with total page count
run.text = totalPagesStr;
// Clear token metadata to treat as normal text after resolution
delete run.token;
blockModified = true;
}
// Note: pageReference tokens are handled by resolvePageRefs.ts
}
}
return blockModified;
}