@beyondwork/docx-react-component 1.0.37 → 1.0.39

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 (116) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +496 -1
  3. package/src/core/commands/section-layout-commands.ts +58 -0
  4. package/src/core/commands/table-grid.ts +431 -0
  5. package/src/core/commands/table-structure-commands.ts +845 -56
  6. package/src/core/commands/text-commands.ts +122 -2
  7. package/src/io/docx-session.ts +1 -0
  8. package/src/io/export/serialize-main-document.ts +2 -11
  9. package/src/io/export/serialize-numbering.ts +43 -10
  10. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  11. package/src/io/export/serialize-run-formatting.ts +90 -0
  12. package/src/io/export/serialize-styles.ts +212 -0
  13. package/src/io/export/serialize-tables.ts +74 -0
  14. package/src/io/export/table-properties-xml.ts +139 -4
  15. package/src/io/normalize/normalize-text.ts +15 -0
  16. package/src/io/ooxml/parse-fields.ts +10 -3
  17. package/src/io/ooxml/parse-footnotes.ts +60 -0
  18. package/src/io/ooxml/parse-headers-footers.ts +60 -0
  19. package/src/io/ooxml/parse-main-document.ts +137 -0
  20. package/src/io/ooxml/parse-numbering.ts +41 -1
  21. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  22. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  23. package/src/io/ooxml/parse-styles.ts +31 -0
  24. package/src/io/ooxml/parse-tables.ts +249 -0
  25. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  26. package/src/io/ooxml/xml-element.ts +19 -0
  27. package/src/model/canonical-document.ts +117 -3
  28. package/src/runtime/collab/event-types.ts +165 -0
  29. package/src/runtime/collab/index.ts +22 -0
  30. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  31. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  32. package/src/runtime/document-layout.ts +4 -2
  33. package/src/runtime/document-navigation.ts +1 -1
  34. package/src/runtime/document-runtime.ts +248 -18
  35. package/src/runtime/layout/default-page-format.ts +96 -0
  36. package/src/runtime/layout/index.ts +47 -0
  37. package/src/runtime/layout/inert-layout-facet.ts +16 -0
  38. package/src/runtime/layout/layout-engine-instance.ts +100 -23
  39. package/src/runtime/layout/layout-invalidation.ts +14 -5
  40. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  41. package/src/runtime/layout/page-format-catalog.ts +233 -0
  42. package/src/runtime/layout/page-graph.ts +55 -0
  43. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  44. package/src/runtime/layout/paginated-layout-engine.ts +484 -37
  45. package/src/runtime/layout/project-block-fragments.ts +225 -0
  46. package/src/runtime/layout/public-facet.ts +748 -16
  47. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  48. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  49. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  50. package/src/runtime/layout/table-render-plan.ts +249 -0
  51. package/src/runtime/numbering-prefix.ts +5 -0
  52. package/src/runtime/paragraph-style-resolver.ts +194 -0
  53. package/src/runtime/render/block-fragment-projection.ts +35 -0
  54. package/src/runtime/render/decoration-resolver.ts +189 -0
  55. package/src/runtime/render/index.ts +57 -0
  56. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  57. package/src/runtime/render/render-frame-types.ts +317 -0
  58. package/src/runtime/render/render-kernel.ts +759 -0
  59. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  60. package/src/runtime/surface-projection.ts +129 -9
  61. package/src/runtime/table-schema.ts +11 -0
  62. package/src/runtime/view-state.ts +67 -0
  63. package/src/runtime/workflow-markup.ts +1 -5
  64. package/src/runtime/workflow-rail-segments.ts +280 -0
  65. package/src/ui/WordReviewEditor.tsx +368 -19
  66. package/src/ui/editor-command-bag.ts +4 -0
  67. package/src/ui/editor-runtime-boundary.ts +16 -0
  68. package/src/ui/editor-shell-view.tsx +10 -0
  69. package/src/ui/editor-surface-controller.tsx +9 -1
  70. package/src/ui/headless/chrome-registry.ts +310 -15
  71. package/src/ui/headless/scoped-chrome-policy.ts +49 -1
  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/review-queue-bar.tsx +2 -14
  75. package/src/ui-tailwind/chrome/role-action-sets.ts +80 -0
  76. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  77. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +160 -0
  78. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +68 -92
  79. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  80. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  81. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  82. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  83. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +356 -140
  84. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
  85. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +94 -0
  86. package/src/ui-tailwind/chrome-overlay/index.ts +16 -0
  87. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +96 -0
  88. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  89. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +4 -0
  90. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +11 -0
  91. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  92. package/src/ui-tailwind/editor-surface/perf-probe.ts +7 -1
  93. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +40 -4
  94. package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
  95. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  96. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  97. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
  98. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  99. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  100. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -75
  101. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  102. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  103. package/src/ui-tailwind/index.ts +29 -0
  104. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  105. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  106. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  107. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  108. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  109. package/src/ui-tailwind/theme/editor-theme.css +498 -163
  110. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +680 -0
  111. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  112. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  113. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +104 -2
  114. package/src/ui-tailwind/tw-review-workspace.tsx +234 -21
  115. package/src/runtime/collab-review-sync.ts +0 -254
  116. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -61,6 +61,8 @@ import {
61
61
  resolveTextWidth,
62
62
  } from "./resolved-formatting-state.ts";
63
63
  import { analyzeInvalidation as analyzeInvalidationFn } from "./layout-invalidation.ts";
64
+ import type { LayoutMeasurementProvider } from "./layout-measurement-provider.ts";
65
+ import { paginateParagraphLines } from "./paginate-paragraph-lines.ts";
64
66
 
65
67
  // ---------------------------------------------------------------------------
66
68
  // Types
@@ -80,6 +82,58 @@ export interface PageStackResult {
80
82
  sections: ResolvedDocumentSection[];
81
83
  }
82
84
 
85
+ /**
86
+ * One slice of a paragraph split across a page boundary.
87
+ *
88
+ * Emitted when intra-paragraph line-box pagination produces a block that
89
+ * renders on multiple pages. The offset range on the page snapshot still
90
+ * spans the whole paragraph (pages are contiguous, non-overlapping); the
91
+ * slice tells consumers which lines of the paragraph render on which page.
92
+ */
93
+ export interface ParagraphLineSlice {
94
+ /** Global (whole-document) pageIndex this slice renders on. */
95
+ pageIndex: number;
96
+ /** Inclusive-exclusive line range rendered by this slice. */
97
+ lineRange: { from: number; to: number; totalLines: number };
98
+ }
99
+
100
+ /**
101
+ * R3: one slice of a table split at a row boundary across a page break.
102
+ *
103
+ * Emitted when a table's total measured height exceeds the remaining page
104
+ * space and pagination commits to row-level splitting. Consumers render
105
+ * the rows in `rowRange` on the indicated `pageIndex`; when `rowRange.from > 0`
106
+ * and the source table has rows with `isHeader: true`, consumers are
107
+ * expected to prepend those header rows visually so the table header
108
+ * repeats on every continuation page.
109
+ */
110
+ export interface TableRowSlice {
111
+ /** Global (whole-document) pageIndex this slice renders on. */
112
+ pageIndex: number;
113
+ /** Inclusive-exclusive row range rendered by this slice. */
114
+ rowRange: { from: number; to: number; totalRows: number };
115
+ }
116
+
117
+ /**
118
+ * Per-block slice information produced by pagination. `byBlockId` is keyed
119
+ * by `SurfaceBlockSnapshot.blockId`. Absence of a key means the block is
120
+ * unsplit and renders as a single fragment on exactly one page.
121
+ *
122
+ * Paragraph-slice and table-slice keys are disjoint (a block is either a
123
+ * paragraph or a table, never both). Consumers read the appropriate map
124
+ * for the block kind they're projecting.
125
+ */
126
+ export interface BlockSplits {
127
+ byBlockId: Map<string, ParagraphLineSlice[]>;
128
+ /** R3: table blocks split at row boundaries. */
129
+ tablesByBlockId: Map<string, TableRowSlice[]>;
130
+ }
131
+
132
+ export interface PageStackResultWithSplits {
133
+ pages: DocumentPageSnapshot[];
134
+ splits: BlockSplits;
135
+ }
136
+
83
137
  // ---------------------------------------------------------------------------
84
138
  // Facade
85
139
  // ---------------------------------------------------------------------------
@@ -90,13 +144,38 @@ export interface PageStackResult {
90
144
  * This is the single entry point for page composition. All consumers
91
145
  * (document-navigation, view-state, page mode) should call this instead
92
146
  * of directly using the estimation helpers.
147
+ *
148
+ * `measurementProvider` is optional. When supplied, paragraph line counts
149
+ * and block heights consult the provider's `measureLineFragments` so the
150
+ * canvas backend can participate. When omitted, measurement falls through
151
+ * to the empirical path baked into this module — which matches the
152
+ * provider's empirical backend numerically, keeping pagination stable.
93
153
  */
94
154
  export function buildPageStack(
95
155
  document: CanonicalDocumentEnvelope,
96
156
  sections: ResolvedDocumentSection[],
97
157
  mainSurface: EditorSurfaceSnapshot,
158
+ measurementProvider?: LayoutMeasurementProvider,
98
159
  ): DocumentPageSnapshot[] {
160
+ return buildPageStackWithSplits(document, sections, mainSurface, measurementProvider).pages;
161
+ }
162
+
163
+ /**
164
+ * Full page-stack computation that also reports per-block slice metadata
165
+ * produced by intra-paragraph line-box pagination.
166
+ *
167
+ * Consumers that need to project blocks into per-page render targets
168
+ * (see `project-block-fragments.ts`) use the `splits` map to emit one
169
+ * `RuntimeBlockFragment` per slice.
170
+ */
171
+ export function buildPageStackWithSplits(
172
+ document: CanonicalDocumentEnvelope,
173
+ sections: ResolvedDocumentSection[],
174
+ mainSurface: EditorSurfaceSnapshot,
175
+ measurementProvider?: LayoutMeasurementProvider,
176
+ ): PageStackResultWithSplits {
99
177
  const pages: DocumentPageSnapshot[] = [];
178
+ const splitsByBlock = new Map<string, ParagraphLineSlice[]>();
100
179
  let globalPageIndex = 0;
101
180
 
102
181
  for (let sectionIdx = 0; sectionIdx < sections.length; sectionIdx++) {
@@ -149,18 +228,21 @@ export function buildPageStack(
149
228
  }
150
229
  }
151
230
 
152
- const paginated = paginateSectionBlocks(
231
+ const paginatedResult = paginateSectionBlocksWithSplits(
153
232
  section,
154
233
  sectionBlocks,
155
234
  layout,
156
235
  document.subParts?.footnoteCollection,
236
+ measurementProvider,
157
237
  );
238
+ const paginated = paginatedResult.pages;
158
239
 
159
240
  // continuous / nextColumn: merge the first page of this section into the
160
241
  // previous section's last page (same visual sheet of paper, different
161
242
  // semantic section). The merged page keeps the PREVIOUS section's
162
243
  // layout because page geometry cannot change mid-page in OOXML.
163
244
  let firstPageHandled = false;
245
+ let mergedIntoGlobalPageIndex: number | null = null;
164
246
  if (
165
247
  isContinuous &&
166
248
  pages.length > 0 &&
@@ -177,17 +259,40 @@ export function buildPageStack(
177
259
  firstNewPage.endOffset,
178
260
  );
179
261
  firstPageHandled = true;
262
+ mergedIntoGlobalPageIndex = previousPage.pageIndex;
180
263
  }
181
264
 
265
+ // Map pageInSection → global pageIndex so we can resolve slice metadata.
266
+ const pageInSectionToGlobal = new Map<number, number>();
182
267
  for (let i = 0; i < paginated.length; i += 1) {
183
- if (firstPageHandled && i === 0) continue;
184
268
  const page = paginated[i]!;
269
+ if (firstPageHandled && i === 0) {
270
+ if (mergedIntoGlobalPageIndex !== null) {
271
+ pageInSectionToGlobal.set(page.pageInSection, mergedIntoGlobalPageIndex);
272
+ }
273
+ continue;
274
+ }
275
+ pageInSectionToGlobal.set(page.pageInSection, globalPageIndex);
185
276
  pages.push({
186
277
  ...page,
187
278
  pageIndex: globalPageIndex,
188
279
  });
189
280
  globalPageIndex += 1;
190
281
  }
282
+
283
+ // Resolve per-section splits to global pageIndex and merge into splitsByBlock.
284
+ for (const [blockId, localSlices] of paginatedResult.splits.byBlockId) {
285
+ const existing = splitsByBlock.get(blockId) ?? [];
286
+ for (const localSlice of localSlices) {
287
+ const globalPageIdx = pageInSectionToGlobal.get(localSlice.pageInSection);
288
+ if (globalPageIdx === undefined) continue; // defensive — paginated might include filler
289
+ existing.push({
290
+ pageIndex: globalPageIdx,
291
+ lineRange: localSlice.lineRange,
292
+ });
293
+ }
294
+ if (existing.length > 0) splitsByBlock.set(blockId, existing);
295
+ }
191
296
  }
192
297
 
193
298
  // Guarantee at least one page
@@ -207,7 +312,11 @@ export function buildPageStack(
207
312
  }
208
313
 
209
314
  applyWidowControlPass(pages, mainSurface);
210
- return pages;
315
+ const tableSplitsByBlock = collectTableRowSlices(mainSurface.blocks, pages);
316
+ return {
317
+ pages,
318
+ splits: { byBlockId: splitsByBlock, tablesByBlockId: tableSplitsByBlock },
319
+ };
211
320
  }
212
321
 
213
322
  /**
@@ -228,18 +337,130 @@ export function buildPageStackFrom(
228
337
  sections: readonly ResolvedDocumentSection[],
229
338
  mainSurface: EditorSurfaceSnapshot,
230
339
  resumeAt: { startPageIndex: number; startOffset: number },
340
+ measurementProvider?: LayoutMeasurementProvider,
231
341
  ): 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.
342
+ return buildPageStackFromWithSplits(
343
+ document,
344
+ sections,
345
+ mainSurface,
346
+ resumeAt,
347
+ measurementProvider,
348
+ ).pages;
349
+ }
350
+
351
+ /**
352
+ * Resumable variant that also reports per-block slices (filtered to pages at
353
+ * or after `resumeAt.startPageIndex`).
354
+ */
355
+ export function buildPageStackFromWithSplits(
356
+ document: CanonicalDocumentEnvelope,
357
+ sections: readonly ResolvedDocumentSection[],
358
+ mainSurface: EditorSurfaceSnapshot,
359
+ resumeAt: { startPageIndex: number; startOffset: number },
360
+ measurementProvider?: LayoutMeasurementProvider,
361
+ ): PageStackResultWithSplits {
235
362
  void resumeAt.startOffset;
236
- const full = buildPageStack(
363
+ const full = buildPageStackWithSplits(
237
364
  document,
238
365
  sections as ResolvedDocumentSection[],
239
366
  mainSurface,
367
+ measurementProvider,
240
368
  );
241
369
  const startIndex = Math.max(0, resumeAt.startPageIndex);
242
- return full.slice(startIndex);
370
+ const tailPages = full.pages.slice(startIndex);
371
+ const tailSplits = new Map<string, ParagraphLineSlice[]>();
372
+ for (const [blockId, slices] of full.splits.byBlockId) {
373
+ const tail = slices.filter((s) => s.pageIndex >= startIndex);
374
+ if (tail.length > 0) tailSplits.set(blockId, tail);
375
+ }
376
+ const tailTableSplits = new Map<string, TableRowSlice[]>();
377
+ for (const [blockId, slices] of full.splits.tablesByBlockId) {
378
+ const tail = slices.filter((s) => s.pageIndex >= startIndex);
379
+ if (tail.length > 0) tailTableSplits.set(blockId, tail);
380
+ }
381
+ return {
382
+ pages: tailPages,
383
+ splits: { byBlockId: tailSplits, tablesByBlockId: tailTableSplits },
384
+ };
385
+ }
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // R3: table row-slice collection
389
+ // ---------------------------------------------------------------------------
390
+
391
+ /**
392
+ * Compute `TableRowSlice[]` entries for table blocks that span multiple pages.
393
+ *
394
+ * Tables are currently placed atomically by `paginateSectionBlocks` — the
395
+ * engine never splits a table mid-row. As a consequence the offset range of
396
+ * every table block falls inside exactly one page's `[startOffset, endOffset)`,
397
+ * and this function returns an empty map.
398
+ *
399
+ * The function is wired so that when row-level table pagination lands (the
400
+ * engine emits `pushPage` mid-table, making `pages[]` contain a page whose
401
+ * `startOffset` lands between two rows of a table), the same walk
402
+ * automatically groups rows by page and emits `TableRowSlice` entries
403
+ * without any further schema change.
404
+ *
405
+ * Row offsets are derived from `row.cells[0].content[0].from` — the first
406
+ * cell's first inner block. Rows whose first cell has no paragraph fall
407
+ * back to the table's own `from` so they group with the table's origin page.
408
+ */
409
+ function collectTableRowSlices(
410
+ blocks: readonly SurfaceBlockSnapshot[],
411
+ pages: readonly DocumentPageSnapshot[],
412
+ ): Map<string, TableRowSlice[]> {
413
+ const result = new Map<string, TableRowSlice[]>();
414
+ if (pages.length === 0) return result;
415
+
416
+ const findPageIndex = (offset: number): number | null => {
417
+ for (const page of pages) {
418
+ if (offset >= page.startOffset && offset < page.endOffset) {
419
+ return page.pageIndex;
420
+ }
421
+ }
422
+ const last = pages[pages.length - 1];
423
+ if (last && offset >= last.startOffset && offset <= last.endOffset) {
424
+ return last.pageIndex;
425
+ }
426
+ return null;
427
+ };
428
+
429
+ for (const block of blocks) {
430
+ if (block.kind !== "table") continue;
431
+ const totalRows = block.rows.length;
432
+ if (totalRows === 0) continue;
433
+
434
+ // Walk rows and assign each to the page containing its start offset.
435
+ const perPageRange = new Map<number, { from: number; to: number }>();
436
+ for (let rowIndex = 0; rowIndex < totalRows; rowIndex += 1) {
437
+ const row = block.rows[rowIndex]!;
438
+ const rowStart = row.cells[0]?.content[0]?.from ?? block.from;
439
+ const pageIndex = findPageIndex(rowStart);
440
+ if (pageIndex === null) continue;
441
+ const existing = perPageRange.get(pageIndex);
442
+ if (existing) {
443
+ existing.to = rowIndex + 1;
444
+ } else {
445
+ perPageRange.set(pageIndex, { from: rowIndex, to: rowIndex + 1 });
446
+ }
447
+ }
448
+
449
+ // Single-page tables produce no slices.
450
+ if (perPageRange.size <= 1) continue;
451
+
452
+ const slices: TableRowSlice[] = [];
453
+ for (const [pageIndex, range] of perPageRange) {
454
+ slices.push({
455
+ pageIndex,
456
+ rowRange: { from: range.from, to: range.to, totalRows },
457
+ });
458
+ }
459
+ slices.sort((a, b) => a.pageIndex - b.pageIndex);
460
+ result.set(block.blockId, slices);
461
+ }
462
+
463
+ return result;
243
464
  }
244
465
 
245
466
  // ---------------------------------------------------------------------------
@@ -342,10 +563,15 @@ const FOOTNOTE_REFERENCE_RESERVATION_TWIPS = 180;
342
563
  /**
343
564
  * Compute block height using resolved formatting when available.
344
565
  * Uses improved table measurement for legal contracts.
566
+ *
567
+ * When `measurementProvider` is supplied, paragraph line counts are produced
568
+ * by `provider.measureLineFragments(...)`; otherwise the inline empirical
569
+ * path runs (which matches the empirical backend numerically).
345
570
  */
346
571
  function measureBlockHeight(
347
572
  block: SurfaceBlockSnapshot | undefined,
348
573
  columnWidth: number,
574
+ measurementProvider?: LayoutMeasurementProvider,
349
575
  ): number {
350
576
  if (!block) return 0;
351
577
 
@@ -353,17 +579,26 @@ function measureBlockHeight(
353
579
  case "paragraph": {
354
580
  const formatting = resolveBlockFormatting(block);
355
581
  if (formatting) {
356
- const lineCount = measureParagraphLineCount(block, formatting, columnWidth);
582
+ const lineCount = measureParagraphLineCount(
583
+ block,
584
+ formatting,
585
+ columnWidth,
586
+ measurementProvider,
587
+ );
357
588
  return calculateParagraphHeight(formatting, lineCount);
358
589
  }
359
590
  return estimateBlockHeight(block, columnWidth);
360
591
  }
361
592
  case "table":
362
- return measureTableHeight(block, columnWidth);
593
+ return measureTableHeight(block, columnWidth, measurementProvider);
363
594
  case "sdt_block":
364
595
  return Math.max(
365
596
  MIN_BLOCK_HEIGHT_TWIPS,
366
- block.children.reduce((total, child) => total + measureBlockHeight(child, columnWidth), 0),
597
+ block.children.reduce(
598
+ (total, child) =>
599
+ total + measureBlockHeight(child, columnWidth, measurementProvider),
600
+ 0,
601
+ ),
367
602
  );
368
603
  case "opaque_block":
369
604
  return block.label === "Section break" ? 0 : MIN_BLOCK_HEIGHT_TWIPS;
@@ -372,33 +607,76 @@ function measureBlockHeight(
372
607
 
373
608
  /**
374
609
  * Improved table height estimation.
610
+ *
375
611
  * Uses resolved formatting for cell content paragraphs and respects
376
612
  * explicit row heights and height rules.
613
+ *
614
+ * Per-cell width is derived from the table's `gridColumns` and each
615
+ * cell's `colspan` (honoring `gridBefore`/`gridAfter` row padding).
616
+ * This replaces the prior `columnWidth / cellCount` approximation,
617
+ * which was wrong whenever columns carried non-uniform widths or any
618
+ * cell had `colspan > 1`.
377
619
  */
378
620
  function measureTableHeight(
379
621
  block: Extract<SurfaceBlockSnapshot, { kind: "table" }>,
380
622
  columnWidth: number,
623
+ measurementProvider?: LayoutMeasurementProvider,
381
624
  ): number {
382
625
  const TABLE_ROW_PADDING_TWIPS = 120;
383
626
  let totalHeight = 0;
384
627
 
628
+ const gridColumnCount = block.gridColumns.length;
629
+ const totalGridTwips = block.gridColumns.reduce((sum, w) => sum + w, 0);
630
+ // Scale the canonical gridColumns to the available column width so that
631
+ // a table defined in 9000-twip grid on a 12240-twip canvas measures
632
+ // against the actual canvas width, not the OOXML-declared width.
633
+ const gridScale =
634
+ totalGridTwips > 0 && columnWidth > 0 ? columnWidth / totalGridTwips : 1;
635
+
385
636
  for (const row of block.rows) {
386
637
  const explicitHeight = row.height ?? 0;
387
638
  const heightRule = row.heightRule ?? "auto";
639
+ const gridBefore = row.gridBefore ?? 0;
388
640
 
389
- // Calculate content-driven height
641
+ // Calculate content-driven height using real per-cell widths.
390
642
  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));
643
+ let columnCursor = gridBefore;
393
644
 
394
645
  for (const cell of row.cells) {
646
+ const span = Math.max(1, cell.colspan ?? 1);
647
+ const cellWidth = resolveCellWidth(
648
+ block.gridColumns,
649
+ columnCursor,
650
+ span,
651
+ columnWidth,
652
+ gridScale,
653
+ );
654
+ columnCursor += span;
655
+
656
+ if (cell.verticalMerge === "continue") {
657
+ // Continuation cells don't contribute their own content height —
658
+ // the origin cell's height covers the whole span.
659
+ continue;
660
+ }
661
+
395
662
  let cellContentHeight = 0;
396
663
  for (const child of cell.content) {
397
- cellContentHeight += measureBlockHeight(child, cellWidth);
664
+ cellContentHeight += measureBlockHeight(
665
+ child,
666
+ cellWidth,
667
+ measurementProvider,
668
+ );
398
669
  }
399
670
  contentHeight = Math.max(contentHeight, cellContentHeight + TABLE_ROW_PADDING_TWIPS);
400
671
  }
401
672
 
673
+ // Sanity fallback if the row declared more columns than the grid
674
+ // (malformed input) — clamp the cursor back so subsequent rows
675
+ // continue to measure without throwing.
676
+ if (gridColumnCount > 0 && columnCursor > gridColumnCount) {
677
+ // no-op; kept for documentation — width resolution handles overflow.
678
+ }
679
+
402
680
  if (heightRule === "exact" && explicitHeight > 0) {
403
681
  totalHeight += explicitHeight;
404
682
  } else if (heightRule === "atLeast" && explicitHeight > 0) {
@@ -413,15 +691,78 @@ function measureTableHeight(
413
691
  return Math.max(MIN_BLOCK_HEIGHT_TWIPS, totalHeight);
414
692
  }
415
693
 
694
+ /**
695
+ * Sum the widths of `columnSpan` columns starting at `startColumn` from
696
+ * the table's `gridColumns`, scaled to the available column width.
697
+ *
698
+ * Falls back to an even split of `fallbackColumnWidth` when the grid has
699
+ * no entries (no `gridColumns` declared).
700
+ *
701
+ * Exported via `__resolveCellWidth` for unit tests; not part of the
702
+ * stable surface.
703
+ */
704
+ export function __resolveCellWidth(
705
+ gridColumns: readonly number[],
706
+ startColumn: number,
707
+ columnSpan: number,
708
+ fallbackColumnWidth: number,
709
+ gridScale: number,
710
+ ): number {
711
+ return resolveCellWidth(gridColumns, startColumn, columnSpan, fallbackColumnWidth, gridScale);
712
+ }
713
+
714
+ function resolveCellWidth(
715
+ gridColumns: readonly number[],
716
+ startColumn: number,
717
+ columnSpan: number,
718
+ fallbackColumnWidth: number,
719
+ gridScale: number,
720
+ ): number {
721
+ if (gridColumns.length === 0) {
722
+ // No grid declared — best-effort even split of the canvas.
723
+ return Math.max(240, Math.floor(fallbackColumnWidth));
724
+ }
725
+ let gridWidth = 0;
726
+ for (let i = 0; i < columnSpan; i += 1) {
727
+ const column = startColumn + i;
728
+ if (column < 0 || column >= gridColumns.length) continue;
729
+ gridWidth += gridColumns[column] ?? 0;
730
+ }
731
+ const scaled = Math.floor(gridWidth * gridScale);
732
+ return Math.max(240, scaled);
733
+ }
734
+
416
735
  /**
417
736
  * Count lines in a paragraph using resolved formatting.
418
737
  * Accounts for proper indentation, font metrics, and numbering geometry.
738
+ *
739
+ * When a measurement provider is supplied, delegates line counting to the
740
+ * provider's `measureLineFragments`. The provider's empirical backend
741
+ * returns the same numerical result as the inline path, so switching does
742
+ * not change pagination behavior; the canvas backend returns canvas-
743
+ * measured line counts once fonts resolve.
419
744
  */
420
745
  function measureParagraphLineCount(
421
746
  block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
422
747
  formatting: import("./resolved-formatting-state.ts").ResolvedParagraphFormatting,
423
748
  columnWidth: number,
749
+ measurementProvider?: LayoutMeasurementProvider,
424
750
  ): number {
751
+ if (measurementProvider) {
752
+ const measured = measurementProvider.measureLineFragments({
753
+ block,
754
+ formatting,
755
+ // The paginated pipeline currently resolves formatting at the
756
+ // paragraph level only; per-run formatting is not yet threaded
757
+ // through. Pass an empty map; the provider's empirical backend
758
+ // does not consult per-run metrics and the canvas backend falls
759
+ // back to the paragraph defaults when a run is missing.
760
+ runs: new Map(),
761
+ columnWidth,
762
+ });
763
+ return Math.max(1, measured.lineCount);
764
+ }
765
+
425
766
  const firstLineWidth = resolveTextWidth(formatting, columnWidth, true);
426
767
  const subsequentLineWidth = resolveTextWidth(formatting, columnWidth, false);
427
768
  const firstLineCapacity = resolveCharsPerLine(firstLineWidth, formatting.averageCharWidthTwips);
@@ -529,25 +870,66 @@ function collectSectionBlocks(
529
870
  return blocks.filter((block) => block.to > start && block.from < end);
530
871
  }
531
872
 
873
+ /**
874
+ * Per-section slice key: paragraphs carry `pageInSection` scoped to this
875
+ * section's pagination. The outer `buildPageStackWithSplits` resolves these
876
+ * to global page indices.
877
+ */
878
+ interface SectionLocalSlice {
879
+ pageInSection: number;
880
+ lineRange: { from: number; to: number; totalLines: number };
881
+ }
882
+
883
+ interface SectionPaginationResult {
884
+ pages: Omit<DocumentPageSnapshot, "pageIndex">[];
885
+ splits: { byBlockId: Map<string, SectionLocalSlice[]> };
886
+ }
887
+
888
+ /**
889
+ * Backwards-compatible wrapper that returns only the pages. Internal to the
890
+ * module; kept to avoid threading the extra map through ancient call sites
891
+ * that don't need it.
892
+ */
532
893
  function paginateSectionBlocks(
533
894
  section: ResolvedDocumentSection,
534
895
  blocks: readonly SurfaceBlockSnapshot[],
535
896
  layout: DocumentPageSnapshot["layout"],
536
897
  footnotes: FootnoteCollection | undefined,
898
+ measurementProvider?: LayoutMeasurementProvider,
537
899
  ): Omit<DocumentPageSnapshot, "pageIndex">[] {
900
+ return paginateSectionBlocksWithSplits(
901
+ section,
902
+ blocks,
903
+ layout,
904
+ footnotes,
905
+ measurementProvider,
906
+ ).pages;
907
+ }
908
+
909
+ function paginateSectionBlocksWithSplits(
910
+ section: ResolvedDocumentSection,
911
+ blocks: readonly SurfaceBlockSnapshot[],
912
+ layout: DocumentPageSnapshot["layout"],
913
+ footnotes: FootnoteCollection | undefined,
914
+ measurementProvider?: LayoutMeasurementProvider,
915
+ ): SectionPaginationResult {
538
916
  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
- ];
917
+ return {
918
+ pages: [
919
+ {
920
+ sectionIndex: section.index,
921
+ pageInSection: 0,
922
+ startOffset: section.start,
923
+ endOffset: section.end,
924
+ layout,
925
+ },
926
+ ],
927
+ splits: { byBlockId: new Map() }, // section-local; global map includes tablesByBlockId via collectTableRowSlices
928
+ };
548
929
  }
549
930
 
550
931
  const pages: Omit<DocumentPageSnapshot, "pageIndex">[] = [];
932
+ const splitsByBlock = new Map<string, SectionLocalSlice[]>();
551
933
  const usableHeight = getUsablePageHeight(layout);
552
934
  const columnMetrics = getUsableColumnMetrics(layout);
553
935
  const maxColumns = Math.max(1, columnMetrics.length);
@@ -585,12 +967,13 @@ function paginateSectionBlocks(
585
967
  const columnWidth =
586
968
  columnMetrics[Math.min(columnIndex, columnMetrics.length - 1)]?.width ??
587
969
  getUsableColumnWidth(layout);
588
- const baseHeight = measureBlockHeight(block, columnWidth);
970
+ const baseHeight = measureBlockHeight(block, columnWidth, measurementProvider);
589
971
 
590
972
  // keepNext: this paragraph must stay with the next one on the same page
591
973
  const keepWithNextHeight =
592
974
  block.kind === "paragraph" && block.keepNext
593
- ? baseHeight + measureBlockHeight(blocks[index + 1], columnWidth)
975
+ ? baseHeight +
976
+ measureBlockHeight(blocks[index + 1], columnWidth, measurementProvider)
594
977
  : baseHeight;
595
978
 
596
979
  // keepLines: the entire paragraph must fit on one page.
@@ -616,6 +999,66 @@ function paginateSectionBlocks(
616
999
  reservedNotes.clear();
617
1000
  continue;
618
1001
  }
1002
+
1003
+ // Intra-paragraph line-box split attempt. When the overflowing block
1004
+ // is a paragraph with enough lines to split, we record a split
1005
+ // metadata entry so renderers can bleed the first `linesOnCurrent`
1006
+ // lines up onto the prior page's body. Offset-wise the paragraph
1007
+ // still lives entirely on the new page — the splits map is the
1008
+ // contract that tells fragment projection to emit a bleed-up slice.
1009
+ //
1010
+ // This keeps page offset ranges contiguous and non-overlapping
1011
+ // (preserving `findPageNodeForOffset` invariants), while still
1012
+ // letting the UI render the paragraph across the boundary.
1013
+ if (
1014
+ block.kind === "paragraph" &&
1015
+ formatting &&
1016
+ !keepLinesActive &&
1017
+ !block.keepNext
1018
+ ) {
1019
+ const availableHeight = usableHeight - columnHeight - reservedNoteHeight;
1020
+ const totalLines = measureParagraphLineCount(
1021
+ block,
1022
+ formatting,
1023
+ columnWidth,
1024
+ measurementProvider,
1025
+ );
1026
+ const availableLines =
1027
+ formatting.lineHeight > 0
1028
+ ? Math.max(0, Math.floor(availableHeight / formatting.lineHeight))
1029
+ : 0;
1030
+ const splitRule = paginateParagraphLines({
1031
+ totalLines,
1032
+ availableLines,
1033
+ keepLines: keepLinesActive,
1034
+ widowControl: formatting.widowControl,
1035
+ keepNext: Boolean(block.keepNext),
1036
+ isLastBlockOnPage: index === blocks.length - 1,
1037
+ });
1038
+ if (splitRule) {
1039
+ const bleedUpPageInSection = pageInSection;
1040
+ const anchorPageInSection = pageInSection + 1;
1041
+ splitsByBlock.set(block.blockId, [
1042
+ {
1043
+ pageInSection: bleedUpPageInSection,
1044
+ lineRange: {
1045
+ from: 0,
1046
+ to: splitRule.linesOnCurrent,
1047
+ totalLines,
1048
+ },
1049
+ },
1050
+ {
1051
+ pageInSection: anchorPageInSection,
1052
+ lineRange: {
1053
+ from: splitRule.linesOnCurrent,
1054
+ to: totalLines,
1055
+ totalLines,
1056
+ },
1057
+ },
1058
+ ]);
1059
+ }
1060
+ }
1061
+
619
1062
  pushPage(block.from);
620
1063
  continue;
621
1064
  }
@@ -664,17 +1107,21 @@ function paginateSectionBlocks(
664
1107
  }
665
1108
  }
666
1109
 
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
- ];
1110
+ return {
1111
+ pages:
1112
+ pages.length > 0
1113
+ ? pages
1114
+ : [
1115
+ {
1116
+ sectionIndex: section.index,
1117
+ pageInSection: 0,
1118
+ startOffset: section.start,
1119
+ endOffset: section.end,
1120
+ layout,
1121
+ },
1122
+ ],
1123
+ splits: { byBlockId: splitsByBlock },
1124
+ };
678
1125
  }
679
1126
 
680
1127
  function estimateFootnoteReservation(