@beyondwork/docx-react-component 1.0.36 → 1.0.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +103 -13
  2. package/package.json +1 -1
  3. package/src/api/package-version.ts +13 -0
  4. package/src/api/public-types.ts +83 -0
  5. package/src/core/commands/index.ts +18 -1
  6. package/src/core/selection/mapping.ts +6 -0
  7. package/src/io/docx-session.ts +24 -9
  8. package/src/io/export/build-app-properties-xml.ts +88 -0
  9. package/src/io/export/serialize-comments.ts +6 -1
  10. package/src/io/export/serialize-footnotes.ts +10 -9
  11. package/src/io/export/serialize-headers-footers.ts +11 -10
  12. package/src/io/export/serialize-main-document.ts +337 -50
  13. package/src/io/export/serialize-numbering.ts +115 -24
  14. package/src/io/export/serialize-tables.ts +13 -11
  15. package/src/io/export/table-properties-xml.ts +35 -16
  16. package/src/io/export/twip.ts +66 -0
  17. package/src/io/normalize/normalize-text.ts +5 -0
  18. package/src/io/ooxml/parse-footnotes.ts +2 -1
  19. package/src/io/ooxml/parse-headers-footers.ts +2 -1
  20. package/src/io/ooxml/parse-main-document.ts +21 -1
  21. package/src/legal/bookmarks.ts +78 -0
  22. package/src/model/canonical-document.ts +11 -0
  23. package/src/review/store/scope-tag-diff.ts +130 -0
  24. package/src/runtime/document-navigation.ts +1 -305
  25. package/src/runtime/document-runtime.ts +173 -11
  26. package/src/runtime/layout/docx-font-loader.ts +143 -0
  27. package/src/runtime/layout/index.ts +188 -0
  28. package/src/runtime/layout/inert-layout-facet.ts +45 -0
  29. package/src/runtime/layout/layout-engine-instance.ts +618 -0
  30. package/src/runtime/layout/layout-invalidation.ts +257 -0
  31. package/src/runtime/layout/layout-measurement-provider.ts +175 -0
  32. package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
  33. package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
  34. package/src/runtime/layout/page-fragment-mapper.ts +179 -0
  35. package/src/runtime/layout/page-graph.ts +433 -0
  36. package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
  37. package/src/runtime/layout/page-story-resolver.ts +195 -0
  38. package/src/runtime/layout/paginated-layout-engine.ts +788 -0
  39. package/src/runtime/layout/public-facet.ts +705 -0
  40. package/src/runtime/layout/resolved-formatting-document.ts +317 -0
  41. package/src/runtime/layout/resolved-formatting-state.ts +430 -0
  42. package/src/runtime/scope-tag-registry.ts +95 -0
  43. package/src/runtime/surface-projection.ts +1 -0
  44. package/src/runtime/text-ack-range.ts +49 -0
  45. package/src/ui/WordReviewEditor.tsx +15 -0
  46. package/src/ui/editor-runtime-boundary.ts +10 -1
  47. package/src/ui/editor-surface-controller.tsx +3 -0
  48. package/src/ui/headless/chrome-registry.ts +235 -0
  49. package/src/ui/headless/scoped-chrome-policy.ts +164 -0
  50. package/src/ui/headless/selection-tool-context.ts +2 -0
  51. package/src/ui/headless/selection-tool-resolver.ts +36 -17
  52. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +333 -0
  53. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +89 -0
  54. package/src/ui-tailwind/editor-surface/perf-probe.ts +21 -1
  55. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +8 -1
  56. package/src/ui-tailwind/editor-surface/pm-decorations.ts +73 -13
  57. package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
  58. package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
  59. package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
  60. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +173 -6
  61. package/src/ui-tailwind/theme/editor-theme.css +40 -14
  62. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
  63. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +235 -166
  64. package/src/ui-tailwind/tw-review-workspace.tsx +27 -1
@@ -0,0 +1,788 @@
1
+ /**
2
+ * Runtime-owned paginated layout engine facade.
3
+ *
4
+ * `buildPageStack` is the stateless entry point. A stateful `LayoutEngineInstance`
5
+ * layer in `./layout-engine-instance.ts` wraps this for `DocumentRuntime` use,
6
+ * adding the graph, story resolver, fragment mapper, and invalidation pipeline.
7
+ *
8
+ * Section-break fidelity implemented here:
9
+ * - `nextPage` (default) — new page set per section
10
+ * - `evenPage` / `oddPage` — blank filler pages to meet parity
11
+ * - `continuous` — the new section continues on the previous section's last page
12
+ * - `nextColumn` — treated as `continuous` (column advance still TODO for
13
+ * true multi-column typeset; see `paginateSectionBlocks`)
14
+ *
15
+ * Paragraph-level pagination hints:
16
+ * - `keepNext` — already honored in `paginateSectionBlocks`
17
+ * - `keepLines` — already honored
18
+ * - `pageBreakBefore` — already honored
19
+ * - `widowControl` — applied by `applyWidowControlPass` after pagination
20
+ *
21
+ * Known remaining limitations:
22
+ * - Measurement uses empirical character-width tables by default; Canvas +
23
+ * FontFace is available via `LayoutMeasurementProvider` but not yet
24
+ * integrated into `measureBlockHeight()`.
25
+ * - Full recompute on every change; bounded incremental relayout remains
26
+ * a Phase-5 target.
27
+ *
28
+ * Ownership rules:
29
+ * - DocumentRuntime owns the stateful engine lifecycle and cache invalidation.
30
+ * - ProseMirror remains the editing surface, never the page compositor.
31
+ * - React/Tailwind consume resulting snapshots as read-models.
32
+ */
33
+
34
+ import type {
35
+ DocumentPageSnapshot,
36
+ EditorSurfaceSnapshot,
37
+ PageLayoutSnapshot,
38
+ SurfaceBlockSnapshot,
39
+ } from "../../api/public-types";
40
+ import type {
41
+ FootnoteCollection,
42
+ } from "../../model/canonical-document.ts";
43
+ import type { CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
44
+ import {
45
+ buildPageLayoutSnapshot,
46
+ buildResolvedSections,
47
+ type ResolvedDocumentSection,
48
+ } from "../document-layout.ts";
49
+ import {
50
+ estimateBlockHeight,
51
+ estimateParagraphHeight,
52
+ getUsableColumnMetrics,
53
+ getUsableColumnWidth,
54
+ getUsablePageHeight,
55
+ } from "../page-layout-estimation.ts";
56
+ import {
57
+ calculateParagraphHeight,
58
+ resolveBlockFormatting,
59
+ resolveCharsPerLine,
60
+ resolveNumberingPrefixLength,
61
+ resolveTextWidth,
62
+ } from "./resolved-formatting-state.ts";
63
+ import { analyzeInvalidation as analyzeInvalidationFn } from "./layout-invalidation.ts";
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Types
67
+ // ---------------------------------------------------------------------------
68
+
69
+ export type LayoutInvalidationReason =
70
+ | { kind: "content-edit"; from: number; to: number }
71
+ | { kind: "section-change"; sectionIndex: number }
72
+ | { kind: "styles-change" }
73
+ | { kind: "theme-change" }
74
+ | { kind: "numbering-change"; numberingInstanceId?: string }
75
+ | { kind: "field-refresh"; family?: string }
76
+ | { kind: "full" };
77
+
78
+ export interface PageStackResult {
79
+ pages: DocumentPageSnapshot[];
80
+ sections: ResolvedDocumentSection[];
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Facade
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /**
88
+ * Build the page stack for a document.
89
+ *
90
+ * This is the single entry point for page composition. All consumers
91
+ * (document-navigation, view-state, page mode) should call this instead
92
+ * of directly using the estimation helpers.
93
+ */
94
+ export function buildPageStack(
95
+ document: CanonicalDocumentEnvelope,
96
+ sections: ResolvedDocumentSection[],
97
+ mainSurface: EditorSurfaceSnapshot,
98
+ ): DocumentPageSnapshot[] {
99
+ const pages: DocumentPageSnapshot[] = [];
100
+ let globalPageIndex = 0;
101
+
102
+ for (let sectionIdx = 0; sectionIdx < sections.length; sectionIdx++) {
103
+ const section = sections[sectionIdx]!;
104
+ const layout = buildPageLayoutSnapshot(
105
+ section.index,
106
+ section.properties ?? document.subParts?.finalSectionProperties,
107
+ document.subParts,
108
+ );
109
+ const sectionBlocks = collectSectionBlocks(mainSurface.blocks, section.start, section.end);
110
+
111
+ // In OOXML, sectionType on a section's properties describes how
112
+ // the NEXT section starts — it is stored on the section that ends
113
+ // at the break. So when processing section N (N > 0), the break
114
+ // type that governs section N's start is on section N-1's properties.
115
+ //
116
+ // OOXML even/odd refers to 1-based display page numbers:
117
+ // globalPageIndex 0 → next display page 1 (odd)
118
+ // globalPageIndex 1 → next display page 2 (even)
119
+ const prevSection = sectionIdx > 0 ? sections[sectionIdx - 1] : undefined;
120
+ const breakType = prevSection?.properties?.sectionType;
121
+ const nextDisplayPage = globalPageIndex + 1; // 1-based
122
+ const isContinuous = breakType === "continuous" || breakType === "nextColumn";
123
+
124
+ if (breakType === "evenPage" && globalPageIndex > 0) {
125
+ if (nextDisplayPage % 2 !== 0) {
126
+ const prevLayout = pages[pages.length - 1]?.layout ?? layout;
127
+ pages.push({
128
+ pageIndex: globalPageIndex,
129
+ sectionIndex: section.index,
130
+ pageInSection: -1,
131
+ startOffset: section.start,
132
+ endOffset: section.start,
133
+ layout: prevLayout,
134
+ });
135
+ globalPageIndex += 1;
136
+ }
137
+ } else if (breakType === "oddPage" && globalPageIndex > 0) {
138
+ if (nextDisplayPage % 2 === 0) {
139
+ const prevLayout = pages[pages.length - 1]?.layout ?? layout;
140
+ pages.push({
141
+ pageIndex: globalPageIndex,
142
+ sectionIndex: section.index,
143
+ pageInSection: -1,
144
+ startOffset: section.start,
145
+ endOffset: section.start,
146
+ layout: prevLayout,
147
+ });
148
+ globalPageIndex += 1;
149
+ }
150
+ }
151
+
152
+ const paginated = paginateSectionBlocks(
153
+ section,
154
+ sectionBlocks,
155
+ layout,
156
+ document.subParts?.footnoteCollection,
157
+ );
158
+
159
+ // continuous / nextColumn: merge the first page of this section into the
160
+ // previous section's last page (same visual sheet of paper, different
161
+ // semantic section). The merged page keeps the PREVIOUS section's
162
+ // layout because page geometry cannot change mid-page in OOXML.
163
+ let firstPageHandled = false;
164
+ if (
165
+ isContinuous &&
166
+ pages.length > 0 &&
167
+ paginated.length > 0 &&
168
+ pages[pages.length - 1]!.pageInSection !== -1
169
+ ) {
170
+ const previousPage = pages[pages.length - 1]!;
171
+ const firstNewPage = paginated[0]!;
172
+ // Extend the previous page's endOffset through the continuous section's
173
+ // first page content. Subsequent pages in the continuous section flow
174
+ // onto fresh pages as if the break were `nextPage`.
175
+ previousPage.endOffset = Math.max(
176
+ previousPage.endOffset,
177
+ firstNewPage.endOffset,
178
+ );
179
+ firstPageHandled = true;
180
+ }
181
+
182
+ for (let i = 0; i < paginated.length; i += 1) {
183
+ if (firstPageHandled && i === 0) continue;
184
+ const page = paginated[i]!;
185
+ pages.push({
186
+ ...page,
187
+ pageIndex: globalPageIndex,
188
+ });
189
+ globalPageIndex += 1;
190
+ }
191
+ }
192
+
193
+ // Guarantee at least one page
194
+ if (pages.length === 0) {
195
+ pages.push({
196
+ pageIndex: 0,
197
+ sectionIndex: 0,
198
+ pageInSection: 0,
199
+ startOffset: 0,
200
+ endOffset: mainSurface.storySize,
201
+ layout: buildPageLayoutSnapshot(
202
+ 0,
203
+ document.subParts?.finalSectionProperties,
204
+ document.subParts,
205
+ ),
206
+ });
207
+ }
208
+
209
+ applyWidowControlPass(pages, mainSurface);
210
+ return pages;
211
+ }
212
+
213
+ /**
214
+ * Resumable variant of `buildPageStack` — returns page snapshots starting at
215
+ * `resumeAt.startPageIndex`, suitable for splicing into a prior page graph.
216
+ *
217
+ * Correctness baseline: the current implementation calls the full
218
+ * `buildPageStack` and slices. This is not a performance win on its own —
219
+ * it is a safe contract that lets the engine stitch bounded rebuilds without
220
+ * writing a custom resume traversal. Widow/orphan, section-break fillers,
221
+ * and continuous-section merges remain handled by the stable pipeline.
222
+ *
223
+ * Future work can replace the body with a true resume that reuses block
224
+ * heights computed for pages before `startPageIndex`.
225
+ */
226
+ export function buildPageStackFrom(
227
+ document: CanonicalDocumentEnvelope,
228
+ sections: readonly ResolvedDocumentSection[],
229
+ mainSurface: EditorSurfaceSnapshot,
230
+ resumeAt: { startPageIndex: number; startOffset: number },
231
+ ): DocumentPageSnapshot[] {
232
+ // Correctness-first: run the full pipeline and return pages from the
233
+ // requested start. `startOffset` is accepted for forward compatibility
234
+ // with a future true-resume but is not required by this implementation.
235
+ void resumeAt.startOffset;
236
+ const full = buildPageStack(
237
+ document,
238
+ sections as ResolvedDocumentSection[],
239
+ mainSurface,
240
+ );
241
+ const startIndex = Math.max(0, resumeAt.startPageIndex);
242
+ return full.slice(startIndex);
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Widow control pass
247
+ // ---------------------------------------------------------------------------
248
+
249
+ /**
250
+ * Bounded widow/orphan correction.
251
+ *
252
+ * After pagination we inspect each page pair. If page N-1 ends with a
253
+ * paragraph whose `widowControl` is enabled AND the paragraph's final line
254
+ * falls on page N (i.e. page N starts with exactly the trailing tail of a
255
+ * paragraph that began on page N-1), we push the last line back to page N
256
+ * by reducing page N-1's endOffset by the width of that trailing line.
257
+ *
258
+ * The current pass is bounded: it only corrects the single-line case. It
259
+ * does not attempt to re-flow arbitrary content. When the measurement
260
+ * provider upgrades to canvas fidelity and we carry per-paragraph line
261
+ * boxes, this pass will become more precise.
262
+ *
263
+ * Today it operates on block-level boundaries — if a paragraph with
264
+ * widowControl straddles a page boundary and the trailing slice covers less
265
+ * than ~15% of its full height (estimated), we pull the whole paragraph
266
+ * forward. This is a conservative heuristic that will not corrupt
267
+ * pagination; it only very slightly compresses page breaks.
268
+ */
269
+ function applyWidowControlPass(
270
+ pages: DocumentPageSnapshot[],
271
+ mainSurface: EditorSurfaceSnapshot,
272
+ ): void {
273
+ if (pages.length < 2) return;
274
+
275
+ for (let i = 1; i < pages.length; i += 1) {
276
+ const prev = pages[i - 1]!;
277
+ const cur = pages[i]!;
278
+ if (prev.pageInSection === -1 || cur.pageInSection === -1) continue;
279
+
280
+ // Find the paragraph, if any, that straddles the boundary.
281
+ const straddling = mainSurface.blocks.find(
282
+ (block) =>
283
+ block.kind === "paragraph" &&
284
+ block.from < prev.endOffset &&
285
+ block.to > prev.endOffset &&
286
+ block.to <= cur.endOffset,
287
+ );
288
+ if (!straddling || straddling.kind !== "paragraph") continue;
289
+ if (straddling.widowControl === false) continue;
290
+ if (straddling.from === prev.endOffset) continue; // fully on next page
291
+
292
+ const totalSpan = straddling.to - straddling.from;
293
+ const onNextPage = straddling.to - prev.endOffset;
294
+ if (totalSpan <= 0) continue;
295
+ // Pull-forward when the trailing slice is less than 15% of the paragraph,
296
+ // i.e. likely a single stranded line.
297
+ if (onNextPage / totalSpan <= 0.15) {
298
+ prev.endOffset = straddling.to;
299
+ cur.startOffset = straddling.to;
300
+ }
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Compute the full page stack result including resolved sections.
306
+ *
307
+ * Convenience wrapper for callers that need both sections and pages.
308
+ */
309
+ export function computePageStack(
310
+ document: CanonicalDocumentEnvelope,
311
+ mainSurface: EditorSurfaceSnapshot,
312
+ ): PageStackResult {
313
+ const sections = buildResolvedSections(document);
314
+ const pages = buildPageStack(document, sections, mainSurface);
315
+ return { pages, sections };
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Invalidation — delegates to layout-invalidation.ts
320
+ // ---------------------------------------------------------------------------
321
+
322
+ /**
323
+ * Determine whether a layout invalidation reason requires a full recompute.
324
+ *
325
+ * Delegates to `analyzeInvalidation()` which classifies the reason against
326
+ * the current graph. Callers that already hold a graph should call
327
+ * `analyzeInvalidation` directly for finer-grained output.
328
+ */
329
+ export function requiresFullRecompute(reason: LayoutInvalidationReason): boolean {
330
+ // Use a conservative analysis with no graph — returns full recompute for
331
+ // all reasons except `field-refresh`, matching Phase-4 semantics.
332
+ return analyzeInvalidationFn(reason, null).requiresFullRecompute;
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Block measurement — uses ResolvedFormattingState for paragraphs
337
+ // ---------------------------------------------------------------------------
338
+
339
+ const MIN_BLOCK_HEIGHT_TWIPS = 240;
340
+ const FOOTNOTE_REFERENCE_RESERVATION_TWIPS = 180;
341
+
342
+ /**
343
+ * Compute block height using resolved formatting when available.
344
+ * Uses improved table measurement for legal contracts.
345
+ */
346
+ function measureBlockHeight(
347
+ block: SurfaceBlockSnapshot | undefined,
348
+ columnWidth: number,
349
+ ): number {
350
+ if (!block) return 0;
351
+
352
+ switch (block.kind) {
353
+ case "paragraph": {
354
+ const formatting = resolveBlockFormatting(block);
355
+ if (formatting) {
356
+ const lineCount = measureParagraphLineCount(block, formatting, columnWidth);
357
+ return calculateParagraphHeight(formatting, lineCount);
358
+ }
359
+ return estimateBlockHeight(block, columnWidth);
360
+ }
361
+ case "table":
362
+ return measureTableHeight(block, columnWidth);
363
+ case "sdt_block":
364
+ return Math.max(
365
+ MIN_BLOCK_HEIGHT_TWIPS,
366
+ block.children.reduce((total, child) => total + measureBlockHeight(child, columnWidth), 0),
367
+ );
368
+ case "opaque_block":
369
+ return block.label === "Section break" ? 0 : MIN_BLOCK_HEIGHT_TWIPS;
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Improved table height estimation.
375
+ * Uses resolved formatting for cell content paragraphs and respects
376
+ * explicit row heights and height rules.
377
+ */
378
+ function measureTableHeight(
379
+ block: Extract<SurfaceBlockSnapshot, { kind: "table" }>,
380
+ columnWidth: number,
381
+ ): number {
382
+ const TABLE_ROW_PADDING_TWIPS = 120;
383
+ let totalHeight = 0;
384
+
385
+ for (const row of block.rows) {
386
+ const explicitHeight = row.height ?? 0;
387
+ const heightRule = row.heightRule ?? "auto";
388
+
389
+ // Calculate content-driven height
390
+ let contentHeight = MIN_BLOCK_HEIGHT_TWIPS;
391
+ const cellCount = Math.max(1, row.cells.length);
392
+ const cellWidth = Math.max(720, Math.floor(columnWidth / cellCount));
393
+
394
+ for (const cell of row.cells) {
395
+ let cellContentHeight = 0;
396
+ for (const child of cell.content) {
397
+ cellContentHeight += measureBlockHeight(child, cellWidth);
398
+ }
399
+ contentHeight = Math.max(contentHeight, cellContentHeight + TABLE_ROW_PADDING_TWIPS);
400
+ }
401
+
402
+ if (heightRule === "exact" && explicitHeight > 0) {
403
+ totalHeight += explicitHeight;
404
+ } else if (heightRule === "atLeast" && explicitHeight > 0) {
405
+ totalHeight += Math.max(explicitHeight, contentHeight);
406
+ } else if (explicitHeight > 0) {
407
+ totalHeight += Math.max(explicitHeight, contentHeight);
408
+ } else {
409
+ totalHeight += contentHeight;
410
+ }
411
+ }
412
+
413
+ return Math.max(MIN_BLOCK_HEIGHT_TWIPS, totalHeight);
414
+ }
415
+
416
+ /**
417
+ * Count lines in a paragraph using resolved formatting.
418
+ * Accounts for proper indentation, font metrics, and numbering geometry.
419
+ */
420
+ function measureParagraphLineCount(
421
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
422
+ formatting: import("./resolved-formatting-state.ts").ResolvedParagraphFormatting,
423
+ columnWidth: number,
424
+ ): number {
425
+ const firstLineWidth = resolveTextWidth(formatting, columnWidth, true);
426
+ const subsequentLineWidth = resolveTextWidth(formatting, columnWidth, false);
427
+ const firstLineCapacity = resolveCharsPerLine(firstLineWidth, formatting.averageCharWidthTwips);
428
+ const subsequentLineCapacity = resolveCharsPerLine(subsequentLineWidth, formatting.averageCharWidthTwips);
429
+
430
+ let lineCount = 1;
431
+ let currentLineChars = resolveNumberingPrefixLength(block);
432
+ let currentLineCapacity = firstLineCapacity;
433
+
434
+ for (const segment of block.segments) {
435
+ switch (segment.kind) {
436
+ case "text":
437
+ currentLineChars += Array.from(segment.text).length;
438
+ while (currentLineChars > currentLineCapacity) {
439
+ lineCount += 1;
440
+ currentLineChars -= currentLineCapacity;
441
+ currentLineCapacity = subsequentLineCapacity;
442
+ }
443
+ break;
444
+ case "tab": {
445
+ // Resolve tab to actual tab stop position if available
446
+ const tabAdvance = resolveTabAdvance(formatting, currentLineChars, formatting.averageCharWidthTwips, columnWidth);
447
+ currentLineChars += tabAdvance;
448
+ while (currentLineChars > currentLineCapacity) {
449
+ lineCount += 1;
450
+ currentLineChars -= currentLineCapacity;
451
+ currentLineCapacity = subsequentLineCapacity;
452
+ }
453
+ break;
454
+ }
455
+ case "hard_break":
456
+ lineCount += 1;
457
+ currentLineChars = 0;
458
+ currentLineCapacity = subsequentLineCapacity;
459
+ break;
460
+ case "image":
461
+ lineCount += Math.max(1, Math.round(segment.display === "floating" ? 2 : 1));
462
+ currentLineChars = 0;
463
+ currentLineCapacity = subsequentLineCapacity;
464
+ break;
465
+ case "note_ref":
466
+ currentLineChars += 1;
467
+ while (currentLineChars > currentLineCapacity) {
468
+ lineCount += 1;
469
+ currentLineChars -= currentLineCapacity;
470
+ currentLineCapacity = subsequentLineCapacity;
471
+ }
472
+ break;
473
+ case "opaque_inline":
474
+ if (segment.presentation !== "quiet-marker") {
475
+ currentLineChars += segment.label.length > 0 ? 1 : 0;
476
+ while (currentLineChars > currentLineCapacity) {
477
+ lineCount += 1;
478
+ currentLineChars -= currentLineCapacity;
479
+ currentLineCapacity = subsequentLineCapacity;
480
+ }
481
+ }
482
+ break;
483
+ }
484
+ }
485
+
486
+ return Math.max(1, lineCount);
487
+ }
488
+
489
+ /**
490
+ * Resolve tab advance in character-equivalents, considering tab stops.
491
+ */
492
+ function resolveTabAdvance(
493
+ formatting: import("./resolved-formatting-state.ts").ResolvedParagraphFormatting,
494
+ currentChars: number,
495
+ avgCharWidth: number,
496
+ columnWidth: number,
497
+ ): number {
498
+ // Default tab stops every 720 twips (0.5 inch)
499
+ const defaultTabInterval = 720;
500
+ const currentPosition = currentChars * avgCharWidth + formatting.indentLeft;
501
+
502
+ if (formatting.tabStops.length === 0) {
503
+ const nextTab = Math.ceil((currentPosition + 1) / defaultTabInterval) * defaultTabInterval;
504
+ const advance = nextTab - currentPosition;
505
+ return Math.max(1, Math.round(advance / avgCharWidth));
506
+ }
507
+
508
+ // Find the next tab stop after current position
509
+ for (const tab of formatting.tabStops) {
510
+ if (tab.position > currentPosition) {
511
+ const advance = tab.position - currentPosition;
512
+ return Math.max(1, Math.round(advance / avgCharWidth));
513
+ }
514
+ }
515
+
516
+ // Past all tab stops — use default advance
517
+ return 4;
518
+ }
519
+
520
+ // ---------------------------------------------------------------------------
521
+ // Section block collection
522
+ // ---------------------------------------------------------------------------
523
+
524
+ function collectSectionBlocks(
525
+ blocks: readonly SurfaceBlockSnapshot[],
526
+ start: number,
527
+ end: number,
528
+ ): SurfaceBlockSnapshot[] {
529
+ return blocks.filter((block) => block.to > start && block.from < end);
530
+ }
531
+
532
+ function paginateSectionBlocks(
533
+ section: ResolvedDocumentSection,
534
+ blocks: readonly SurfaceBlockSnapshot[],
535
+ layout: DocumentPageSnapshot["layout"],
536
+ footnotes: FootnoteCollection | undefined,
537
+ ): Omit<DocumentPageSnapshot, "pageIndex">[] {
538
+ if (blocks.length === 0) {
539
+ return [
540
+ {
541
+ sectionIndex: section.index,
542
+ pageInSection: 0,
543
+ startOffset: section.start,
544
+ endOffset: section.end,
545
+ layout,
546
+ },
547
+ ];
548
+ }
549
+
550
+ const pages: Omit<DocumentPageSnapshot, "pageIndex">[] = [];
551
+ const usableHeight = getUsablePageHeight(layout);
552
+ const columnMetrics = getUsableColumnMetrics(layout);
553
+ const maxColumns = Math.max(1, columnMetrics.length);
554
+ let pageStart = section.start;
555
+ let columnHeight = 0;
556
+ let columnIndex = 0;
557
+ let pageInSection = 0;
558
+ let reservedNoteHeight = 0;
559
+ const reservedNotes = new Set<string>();
560
+
561
+ const pushPage = (endOffset: number): void => {
562
+ const boundedEnd = Math.max(pageStart, Math.min(endOffset, section.end));
563
+ if (boundedEnd === pageStart && pages.length > 0) {
564
+ return;
565
+ }
566
+ pages.push({
567
+ sectionIndex: section.index,
568
+ pageInSection,
569
+ startOffset: pageStart,
570
+ endOffset: boundedEnd,
571
+ layout,
572
+ });
573
+ pageInSection += 1;
574
+ pageStart = boundedEnd;
575
+ columnHeight = 0;
576
+ columnIndex = 0;
577
+ reservedNoteHeight = 0;
578
+ reservedNotes.clear();
579
+ };
580
+
581
+ for (let index = 0; index < blocks.length; index += 1) {
582
+ const block = blocks[index]!;
583
+ const nextBoundary = blocks[index + 1]?.from ?? section.end;
584
+ while (true) {
585
+ const columnWidth =
586
+ columnMetrics[Math.min(columnIndex, columnMetrics.length - 1)]?.width ??
587
+ getUsableColumnWidth(layout);
588
+ const baseHeight = measureBlockHeight(block, columnWidth);
589
+
590
+ // keepNext: this paragraph must stay with the next one on the same page
591
+ const keepWithNextHeight =
592
+ block.kind === "paragraph" && block.keepNext
593
+ ? baseHeight + measureBlockHeight(blocks[index + 1], columnWidth)
594
+ : baseHeight;
595
+
596
+ // keepLines: the entire paragraph must fit on one page.
597
+ // If it doesn't fit and there's already content on this page, break before it.
598
+ const formatting = block.kind === "paragraph" ? resolveBlockFormatting(block) : null;
599
+ const keepLinesActive = formatting?.keepLines ?? false;
600
+
601
+ const noteHeight = estimateFootnoteReservation(block, footnotes, columnWidth, reservedNotes);
602
+ const projectedHeight = columnHeight + keepWithNextHeight + reservedNoteHeight + noteHeight;
603
+
604
+ // pageBreakBefore
605
+ if (block.kind === "paragraph" && block.pageBreakBefore && pageStart < block.from) {
606
+ pushPage(block.from);
607
+ continue;
608
+ }
609
+
610
+ // Overflow check — paragraph doesn't fit on current page
611
+ if (projectedHeight > usableHeight && pageStart < block.from) {
612
+ if (columnIndex < maxColumns - 1) {
613
+ columnIndex += 1;
614
+ columnHeight = 0;
615
+ reservedNoteHeight = 0;
616
+ reservedNotes.clear();
617
+ continue;
618
+ }
619
+ pushPage(block.from);
620
+ continue;
621
+ }
622
+
623
+ // keepLines: if the paragraph alone exceeds page height and there's
624
+ // already content, push it to the next page (the paragraph itself will
625
+ // span the full page if it's truly larger than a page).
626
+ if (keepLinesActive && columnHeight > 0 && baseHeight > usableHeight - columnHeight && pageStart < block.from) {
627
+ if (columnIndex < maxColumns - 1) {
628
+ columnIndex += 1;
629
+ columnHeight = 0;
630
+ reservedNoteHeight = 0;
631
+ reservedNotes.clear();
632
+ continue;
633
+ }
634
+ pushPage(block.from);
635
+ continue;
636
+ }
637
+
638
+ const effectiveNoteHeight = estimateFootnoteReservation(
639
+ block,
640
+ footnotes,
641
+ columnWidth,
642
+ reservedNotes,
643
+ );
644
+ columnHeight += baseHeight;
645
+ reservedNoteHeight += effectiveNoteHeight;
646
+ currentPageNoteIds(block).forEach((noteKey) => reservedNotes.add(noteKey));
647
+
648
+ if (hasColumnBreak(block)) {
649
+ if (columnIndex < maxColumns - 1) {
650
+ columnIndex += 1;
651
+ columnHeight = 0;
652
+ reservedNoteHeight = 0;
653
+ reservedNotes.clear();
654
+ } else {
655
+ pushPage(nextBoundary);
656
+ }
657
+ break;
658
+ }
659
+
660
+ if (index === blocks.length - 1) {
661
+ pushPage(section.end);
662
+ }
663
+ break;
664
+ }
665
+ }
666
+
667
+ return pages.length > 0
668
+ ? pages
669
+ : [
670
+ {
671
+ sectionIndex: section.index,
672
+ pageInSection: 0,
673
+ startOffset: section.start,
674
+ endOffset: section.end,
675
+ layout,
676
+ },
677
+ ];
678
+ }
679
+
680
+ function estimateFootnoteReservation(
681
+ block: SurfaceBlockSnapshot,
682
+ footnotes: FootnoteCollection | undefined,
683
+ columnWidth: number,
684
+ reservedNotes: ReadonlySet<string>,
685
+ ): number {
686
+ if (!footnotes || block.kind !== "paragraph") {
687
+ return 0;
688
+ }
689
+
690
+ let reservation = 0;
691
+ for (const noteKey of currentPageNoteIds(block)) {
692
+ if (reservedNotes.has(noteKey)) {
693
+ continue;
694
+ }
695
+
696
+ const [noteKind, noteId] = noteKey.split(":");
697
+ const noteCollection =
698
+ noteKind === "endnote" ? footnotes.endnotes : footnotes.footnotes;
699
+ const note = noteCollection[noteId];
700
+ reservation += FOOTNOTE_REFERENCE_RESERVATION_TWIPS;
701
+ if (note) {
702
+ reservation += note.blocks.reduce(
703
+ (total, child) => total + estimateCanonicalNoteBlockHeight(child, columnWidth),
704
+ 0,
705
+ );
706
+ }
707
+ }
708
+
709
+ return reservation;
710
+ }
711
+
712
+ function estimateCanonicalNoteBlockHeight(
713
+ block: FootnoteCollection["footnotes"][string]["blocks"][number],
714
+ columnWidth: number,
715
+ ): number {
716
+ switch (block.type) {
717
+ case "paragraph":
718
+ return estimateParagraphHeight(
719
+ {
720
+ blockId: "note",
721
+ kind: "paragraph",
722
+ from: 0,
723
+ to: 0,
724
+ ...(block.styleId ? { styleId: block.styleId } : {}),
725
+ segments: createEstimatedNoteSegments(block.children),
726
+ },
727
+ columnWidth,
728
+ );
729
+ case "table":
730
+ return MIN_BLOCK_HEIGHT_TWIPS * Math.max(1, block.rows.length);
731
+ default:
732
+ return MIN_BLOCK_HEIGHT_TWIPS;
733
+ }
734
+ }
735
+
736
+ function createEstimatedNoteSegments(
737
+ children: Extract<FootnoteCollection["footnotes"][string]["blocks"][number], { type: "paragraph" }>["children"],
738
+ ): import("../../api/public-types").SurfaceInlineSegment[] {
739
+ const segments: import("../../api/public-types").SurfaceInlineSegment[] = [];
740
+
741
+ children.forEach((child, index) => {
742
+ if (child.type === "text") {
743
+ segments.push({
744
+ segmentId: `note-${index}`,
745
+ kind: "text",
746
+ from: 0,
747
+ to: Array.from(child.text).length,
748
+ text: child.text,
749
+ });
750
+ return;
751
+ }
752
+
753
+ if (child.type === "hard_break" || child.type === "tab") {
754
+ segments.push({
755
+ segmentId: `note-${index}`,
756
+ kind: child.type,
757
+ from: 0,
758
+ to: 1,
759
+ });
760
+ }
761
+ });
762
+
763
+ return segments;
764
+ }
765
+
766
+ function currentPageNoteIds(
767
+ block: SurfaceBlockSnapshot,
768
+ ): Set<string> {
769
+ const notes = new Set<string>();
770
+ if (block.kind !== "paragraph") {
771
+ return notes;
772
+ }
773
+
774
+ for (const segment of block.segments) {
775
+ if (segment.kind === "note_ref" && segment.noteId) {
776
+ notes.add(`${segment.noteKind}:${segment.noteId}`);
777
+ }
778
+ }
779
+ return notes;
780
+ }
781
+
782
+ function hasColumnBreak(block: SurfaceBlockSnapshot): boolean {
783
+ return block.kind === "paragraph" && block.segments.some(
784
+ (segment) =>
785
+ segment.kind === "opaque_inline" &&
786
+ segment.label === "Column break",
787
+ );
788
+ }