@beyondwork/docx-react-component 1.0.38 → 1.0.40

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 (85) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +305 -6
  3. package/src/core/commands/table-structure-commands.ts +31 -2
  4. package/src/core/commands/text-commands.ts +122 -2
  5. package/src/index.ts +9 -0
  6. package/src/io/docx-session.ts +1 -0
  7. package/src/io/export/serialize-numbering.ts +42 -8
  8. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  9. package/src/io/export/serialize-run-formatting.ts +90 -0
  10. package/src/io/export/serialize-styles.ts +212 -0
  11. package/src/io/ooxml/parse-fields.ts +10 -3
  12. package/src/io/ooxml/parse-numbering.ts +41 -1
  13. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  14. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  15. package/src/io/ooxml/parse-styles.ts +31 -0
  16. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  17. package/src/io/ooxml/xml-element.ts +19 -0
  18. package/src/model/canonical-document.ts +83 -3
  19. package/src/runtime/collab/event-types.ts +165 -0
  20. package/src/runtime/collab/index.ts +22 -0
  21. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  22. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  23. package/src/runtime/document-runtime.ts +141 -18
  24. package/src/runtime/layout/docx-font-loader.ts +30 -11
  25. package/src/runtime/layout/index.ts +2 -0
  26. package/src/runtime/layout/inert-layout-facet.ts +3 -0
  27. package/src/runtime/layout/layout-engine-instance.ts +69 -2
  28. package/src/runtime/layout/layout-invalidation.ts +14 -5
  29. package/src/runtime/layout/page-graph.ts +36 -0
  30. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  31. package/src/runtime/layout/paginated-layout-engine.ts +342 -28
  32. package/src/runtime/layout/project-block-fragments.ts +154 -20
  33. package/src/runtime/layout/public-facet.ts +81 -1
  34. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  35. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  36. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  37. package/src/runtime/layout/table-render-plan.ts +21 -1
  38. package/src/runtime/numbering-prefix.ts +5 -0
  39. package/src/runtime/paragraph-style-resolver.ts +194 -0
  40. package/src/runtime/render/render-kernel.ts +5 -1
  41. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  42. package/src/runtime/surface-projection.ts +129 -9
  43. package/src/runtime/table-schema.ts +11 -0
  44. package/src/runtime/workflow-rail-segments.ts +149 -1
  45. package/src/ui/WordReviewEditor.tsx +302 -5
  46. package/src/ui/editor-command-bag.ts +4 -0
  47. package/src/ui/editor-runtime-boundary.ts +16 -0
  48. package/src/ui/editor-shell-view.tsx +22 -0
  49. package/src/ui/editor-surface-controller.tsx +9 -1
  50. package/src/ui/headless/chrome-registry.ts +34 -5
  51. package/src/ui/headless/scoped-chrome-policy.ts +29 -0
  52. package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
  53. package/src/ui-tailwind/chrome/role-action-sets.ts +14 -8
  54. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +80 -0
  55. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +7 -10
  56. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +11 -0
  57. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  58. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  59. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +101 -21
  60. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +353 -0
  61. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +5 -1
  62. package/src/ui-tailwind/chrome-overlay/index.ts +2 -6
  63. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +82 -18
  64. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +133 -0
  65. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +386 -0
  66. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +140 -69
  67. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  68. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +28 -3
  69. package/src/ui-tailwind/editor-surface/pm-decorations.ts +7 -2
  70. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  71. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  72. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +170 -63
  73. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  74. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  75. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -78
  76. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  77. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  78. package/src/ui-tailwind/index.ts +6 -5
  79. package/src/ui-tailwind/theme/editor-theme.css +108 -15
  80. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +122 -1
  81. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +36 -3
  82. package/src/ui-tailwind/tw-review-workspace.tsx +207 -54
  83. package/src/runtime/collab-review-sync.ts +0 -254
  84. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +0 -95
  85. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -62,6 +62,7 @@ import {
62
62
  } from "./resolved-formatting-state.ts";
63
63
  import { analyzeInvalidation as analyzeInvalidationFn } from "./layout-invalidation.ts";
64
64
  import type { LayoutMeasurementProvider } from "./layout-measurement-provider.ts";
65
+ import { paginateParagraphLines } from "./paginate-paragraph-lines.ts";
65
66
 
66
67
  // ---------------------------------------------------------------------------
67
68
  // Types
@@ -81,6 +82,58 @@ export interface PageStackResult {
81
82
  sections: ResolvedDocumentSection[];
82
83
  }
83
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
+
84
137
  // ---------------------------------------------------------------------------
85
138
  // Facade
86
139
  // ---------------------------------------------------------------------------
@@ -104,7 +157,25 @@ export function buildPageStack(
104
157
  mainSurface: EditorSurfaceSnapshot,
105
158
  measurementProvider?: LayoutMeasurementProvider,
106
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 {
107
177
  const pages: DocumentPageSnapshot[] = [];
178
+ const splitsByBlock = new Map<string, ParagraphLineSlice[]>();
108
179
  let globalPageIndex = 0;
109
180
 
110
181
  for (let sectionIdx = 0; sectionIdx < sections.length; sectionIdx++) {
@@ -157,19 +228,21 @@ export function buildPageStack(
157
228
  }
158
229
  }
159
230
 
160
- const paginated = paginateSectionBlocks(
231
+ const paginatedResult = paginateSectionBlocksWithSplits(
161
232
  section,
162
233
  sectionBlocks,
163
234
  layout,
164
235
  document.subParts?.footnoteCollection,
165
236
  measurementProvider,
166
237
  );
238
+ const paginated = paginatedResult.pages;
167
239
 
168
240
  // continuous / nextColumn: merge the first page of this section into the
169
241
  // previous section's last page (same visual sheet of paper, different
170
242
  // semantic section). The merged page keeps the PREVIOUS section's
171
243
  // layout because page geometry cannot change mid-page in OOXML.
172
244
  let firstPageHandled = false;
245
+ let mergedIntoGlobalPageIndex: number | null = null;
173
246
  if (
174
247
  isContinuous &&
175
248
  pages.length > 0 &&
@@ -186,17 +259,40 @@ export function buildPageStack(
186
259
  firstNewPage.endOffset,
187
260
  );
188
261
  firstPageHandled = true;
262
+ mergedIntoGlobalPageIndex = previousPage.pageIndex;
189
263
  }
190
264
 
265
+ // Map pageInSection → global pageIndex so we can resolve slice metadata.
266
+ const pageInSectionToGlobal = new Map<number, number>();
191
267
  for (let i = 0; i < paginated.length; i += 1) {
192
- if (firstPageHandled && i === 0) continue;
193
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);
194
276
  pages.push({
195
277
  ...page,
196
278
  pageIndex: globalPageIndex,
197
279
  });
198
280
  globalPageIndex += 1;
199
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
+ }
200
296
  }
201
297
 
202
298
  // Guarantee at least one page
@@ -216,7 +312,11 @@ export function buildPageStack(
216
312
  }
217
313
 
218
314
  applyWidowControlPass(pages, mainSurface);
219
- return pages;
315
+ const tableSplitsByBlock = collectTableRowSlices(mainSurface.blocks, pages);
316
+ return {
317
+ pages,
318
+ splits: { byBlockId: splitsByBlock, tablesByBlockId: tableSplitsByBlock },
319
+ };
220
320
  }
221
321
 
222
322
  /**
@@ -239,18 +339,128 @@ export function buildPageStackFrom(
239
339
  resumeAt: { startPageIndex: number; startOffset: number },
240
340
  measurementProvider?: LayoutMeasurementProvider,
241
341
  ): 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.
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 {
245
362
  void resumeAt.startOffset;
246
- const full = buildPageStack(
363
+ const full = buildPageStackWithSplits(
247
364
  document,
248
365
  sections as ResolvedDocumentSection[],
249
366
  mainSurface,
250
367
  measurementProvider,
251
368
  );
252
369
  const startIndex = Math.max(0, resumeAt.startPageIndex);
253
- 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;
254
464
  }
255
465
 
256
466
  // ---------------------------------------------------------------------------
@@ -660,6 +870,26 @@ function collectSectionBlocks(
660
870
  return blocks.filter((block) => block.to > start && block.from < end);
661
871
  }
662
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
+ */
663
893
  function paginateSectionBlocks(
664
894
  section: ResolvedDocumentSection,
665
895
  blocks: readonly SurfaceBlockSnapshot[],
@@ -667,19 +897,39 @@ function paginateSectionBlocks(
667
897
  footnotes: FootnoteCollection | undefined,
668
898
  measurementProvider?: LayoutMeasurementProvider,
669
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 {
670
916
  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
- ];
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
+ };
680
929
  }
681
930
 
682
931
  const pages: Omit<DocumentPageSnapshot, "pageIndex">[] = [];
932
+ const splitsByBlock = new Map<string, SectionLocalSlice[]>();
683
933
  const usableHeight = getUsablePageHeight(layout);
684
934
  const columnMetrics = getUsableColumnMetrics(layout);
685
935
  const maxColumns = Math.max(1, columnMetrics.length);
@@ -749,6 +999,66 @@ function paginateSectionBlocks(
749
999
  reservedNotes.clear();
750
1000
  continue;
751
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
+
752
1062
  pushPage(block.from);
753
1063
  continue;
754
1064
  }
@@ -797,17 +1107,21 @@ function paginateSectionBlocks(
797
1107
  }
798
1108
  }
799
1109
 
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
- ];
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
+ };
811
1125
  }
812
1126
 
813
1127
  function estimateFootnoteReservation(
@@ -1,17 +1,22 @@
1
1
  /**
2
- * Project surface blocks → per-page RuntimeBlockFragments (P4).
2
+ * Project surface blocks → per-page RuntimeBlockFragments (P4 + R3).
3
3
  *
4
4
  * The pagination engine produces `DocumentPageSnapshot[]` keyed by offset
5
5
  * ranges, but for render-kernel consumers to get per-block geometry (and
6
6
  * for the chrome to anchor tables, images, etc.) we need one block
7
- * fragment per top-level surface block, assigned to the page that contains
8
- * its offset range.
7
+ * fragment per top-level surface block, assigned to the page that
8
+ * contains its offset range.
9
9
  *
10
- * Today the engine does not split tables across pages, so this emission
11
- * pass treats each top-level SurfaceBlockSnapshot as an atomic fragment
12
- * with `pageIndex` resolved from `pages[i].startOffset..endOffset`. Row-
13
- * level splitting (for header-row repeat + per-row cantSplit) will refine
14
- * this to multiple fragments per block.
10
+ * The engine also reports **split metadata** (see `BlockSplits` in
11
+ * `paginated-layout-engine.ts`):
12
+ * - paragraph-slice: one fragment per slice, keyed by `paragraphLineRange`
13
+ * - table-slice: one fragment per row-range slice, keyed by `tableRowRange`
14
+ * - otherwise: one atomic fragment per top-level block (the original P4
15
+ * behavior).
16
+ *
17
+ * Consumers read `fragment.kind` to choose between whole-block rendering
18
+ * and slice-aware rendering (line-range clipping for paragraphs, row-range
19
+ * rendering + repeated header row for tables).
15
20
  */
16
21
 
17
22
  import type {
@@ -20,46 +25,175 @@ import type {
20
25
  SurfaceBlockSnapshot,
21
26
  } from "../../api/public-types";
22
27
  import type { RuntimeBlockFragment } from "./page-graph.ts";
28
+ import type {
29
+ BlockSplits,
30
+ ParagraphLineSlice,
31
+ TableRowSlice,
32
+ } from "./paginated-layout-engine.ts";
23
33
 
24
34
  type FragmentWithoutPageId = Omit<RuntimeBlockFragment, "pageId">;
25
35
 
26
36
  export function projectSurfaceBlocksToPageFragments(
27
37
  surface: EditorSurfaceSnapshot,
28
38
  pages: readonly DocumentPageSnapshot[],
39
+ splits?: BlockSplits,
29
40
  ): Map<number, FragmentWithoutPageId[]> {
30
41
  const byPage = new Map<number, FragmentWithoutPageId[]>();
31
42
  const perPageCounter = new Map<number, number>();
32
43
 
44
+ const pushFragment = (
45
+ pageIndex: number,
46
+ fragment: FragmentWithoutPageId,
47
+ ): void => {
48
+ const existing = byPage.get(pageIndex);
49
+ if (existing) {
50
+ existing.push(fragment);
51
+ } else {
52
+ byPage.set(pageIndex, [fragment]);
53
+ }
54
+ };
55
+
56
+ const nextOrder = (pageIndex: number): number => {
57
+ const n = perPageCounter.get(pageIndex) ?? 0;
58
+ perPageCounter.set(pageIndex, n + 1);
59
+ return n;
60
+ };
61
+
33
62
  for (const block of surface.blocks) {
63
+ // R3: table split across pages — emit one fragment per row slice.
64
+ // Consumers read `tableRowRange` and prepend header rows when from > 0.
65
+ if (block.kind === "table") {
66
+ const tableSliceList = splits?.tablesByBlockId.get(block.blockId);
67
+ if (tableSliceList && tableSliceList.length > 1) {
68
+ emitSlicedTable(
69
+ block,
70
+ tableSliceList,
71
+ (pageIndex, fragment) => {
72
+ pushFragment(pageIndex, {
73
+ ...fragment,
74
+ orderInRegion: nextOrder(pageIndex),
75
+ });
76
+ },
77
+ );
78
+ continue;
79
+ }
80
+ }
81
+
82
+ // Paragraph split across pages (bleed-up slice metadata): emit one
83
+ // fragment per slice with `paragraphLineRange` set.
84
+ const sliceList = splits?.byBlockId.get(block.blockId);
85
+ if (sliceList && sliceList.length > 1) {
86
+ emitSlicedParagraph(
87
+ block,
88
+ sliceList,
89
+ (pageIndex, fragment) => {
90
+ pushFragment(pageIndex, {
91
+ ...fragment,
92
+ orderInRegion: nextOrder(pageIndex),
93
+ });
94
+ },
95
+ );
96
+ continue;
97
+ }
98
+
99
+ // Unsplit block: single atomic fragment on the page containing its
100
+ // start offset.
34
101
  const pageIndex = findPageIndexForOffset(pages, block.from);
35
102
  if (pageIndex === null) continue;
36
- const orderInRegion = perPageCounter.get(pageIndex) ?? 0;
37
- perPageCounter.set(pageIndex, orderInRegion + 1);
38
103
 
39
104
  const fragment: FragmentWithoutPageId = {
40
105
  fragmentId: `fragment-${block.blockId}`,
41
106
  blockId: block.blockId,
42
- orderInRegion,
107
+ orderInRegion: nextOrder(pageIndex),
43
108
  regionKind: "body",
44
109
  from: block.from,
45
110
  to: block.to,
46
- // Height is not recomputed here — the pagination engine already
47
- // measured the block to assign it to this page. Chrome surfaces
48
- // that need precise heights can consult layout facet measurements.
49
111
  heightTwips: estimateBlockHeightFromSpan(block),
112
+ kind: "whole",
50
113
  };
51
114
 
52
- const existing = byPage.get(pageIndex);
53
- if (existing) {
54
- existing.push(fragment);
55
- } else {
56
- byPage.set(pageIndex, [fragment]);
57
- }
115
+ pushFragment(pageIndex, fragment);
58
116
  }
59
117
 
60
118
  return byPage;
61
119
  }
62
120
 
121
+ /**
122
+ * Emit one fragment per slice for a paragraph that pagination split across
123
+ * pages. The source `block` offset range stays intact on every slice; the
124
+ * `paragraphLineRange` identifies which lines the slice renders.
125
+ */
126
+ function emitSlicedParagraph(
127
+ block: SurfaceBlockSnapshot,
128
+ slices: readonly ParagraphLineSlice[],
129
+ emit: (pageIndex: number, fragment: FragmentWithoutPageId) => void,
130
+ ): void {
131
+ for (let i = 0; i < slices.length; i += 1) {
132
+ const slice = slices[i]!;
133
+ const fragment: FragmentWithoutPageId = {
134
+ fragmentId: `fragment-${block.blockId}-slice-${i}`,
135
+ blockId: block.blockId,
136
+ orderInRegion: 0, // re-assigned by caller via nextOrder(pageIndex).
137
+ regionKind: "body",
138
+ from: block.from,
139
+ to: block.to,
140
+ heightTwips: estimateSliceHeightFromLines(slice.lineRange),
141
+ kind: "paragraph-slice",
142
+ paragraphLineRange: slice.lineRange,
143
+ };
144
+ emit(slice.pageIndex, fragment);
145
+ }
146
+ }
147
+
148
+ function estimateSliceHeightFromLines(lineRange: {
149
+ from: number;
150
+ to: number;
151
+ totalLines: number;
152
+ }): number {
153
+ const lines = Math.max(0, lineRange.to - lineRange.from);
154
+ return lines * 240;
155
+ }
156
+
157
+ /**
158
+ * R3: emit one fragment per row-slice when a table spans multiple pages.
159
+ * The source `block` offset range stays intact on every slice (so selection
160
+ * math and incremental relayout see a single logical block); `tableRowRange`
161
+ * tells downstream consumers which rows to render on this page. When
162
+ * `rowRange.from > 0` and the owning table has `isHeader` rows, consumers
163
+ * prepend those header rows visually so the table's header repeats on
164
+ * every continuation page.
165
+ */
166
+ function emitSlicedTable(
167
+ block: SurfaceBlockSnapshot,
168
+ slices: readonly TableRowSlice[],
169
+ emit: (pageIndex: number, fragment: FragmentWithoutPageId) => void,
170
+ ): void {
171
+ for (let i = 0; i < slices.length; i += 1) {
172
+ const slice = slices[i]!;
173
+ const fragment: FragmentWithoutPageId = {
174
+ fragmentId: `fragment-${block.blockId}-rowslice-${i}`,
175
+ blockId: block.blockId,
176
+ orderInRegion: 0, // re-assigned by caller via nextOrder(pageIndex).
177
+ regionKind: "body",
178
+ from: block.from,
179
+ to: block.to,
180
+ heightTwips: estimateSliceHeightFromRows(slice.rowRange),
181
+ kind: "table-slice",
182
+ tableRowRange: slice.rowRange,
183
+ };
184
+ emit(slice.pageIndex, fragment);
185
+ }
186
+ }
187
+
188
+ function estimateSliceHeightFromRows(rowRange: {
189
+ from: number;
190
+ to: number;
191
+ totalRows: number;
192
+ }): number {
193
+ const rows = Math.max(0, rowRange.to - rowRange.from);
194
+ return rows * 360; // ~1 line + padding per row, approximate
195
+ }
196
+
63
197
  function findPageIndexForOffset(
64
198
  pages: readonly DocumentPageSnapshot[],
65
199
  offset: number,