@beyondwork/docx-react-component 1.0.36 → 1.0.38

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