@beyondwork/docx-react-component 1.0.85 → 1.0.87

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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +49 -0
  3. package/src/api/v3/ui/chrome-composition.ts +2 -11
  4. package/src/api/v3/ui/chrome.ts +6 -8
  5. package/src/index.ts +5 -0
  6. package/src/io/export/serialize-main-document.ts +215 -6
  7. package/src/io/ooxml/parse-drawing.ts +15 -1
  8. package/src/io/ooxml/parse-fields.ts +410 -12
  9. package/src/model/canonical-document.ts +177 -2
  10. package/src/model/layout/page-layout-snapshot.ts +2 -0
  11. package/src/model/layout/runtime-page-graph-types.ts +6 -0
  12. package/src/preservation/store.ts +4 -5
  13. package/src/runtime/document-outline.ts +80 -0
  14. package/src/runtime/document-runtime.ts +338 -13
  15. package/src/runtime/formatting/field/page-number-format.ts +49 -0
  16. package/src/runtime/formatting/field/resolver.ts +61 -40
  17. package/src/runtime/layout/layout-engine-instance.ts +18 -1
  18. package/src/runtime/layout/layout-engine-version.ts +19 -1
  19. package/src/runtime/layout/measurement-backend-canvas.ts +21 -9
  20. package/src/runtime/layout/measurement-backend-empirical.ts +18 -4
  21. package/src/runtime/layout/page-graph.ts +13 -2
  22. package/src/runtime/layout/paginated-layout-engine.ts +440 -117
  23. package/src/runtime/layout/project-block-fragments.ts +87 -4
  24. package/src/runtime/layout/resolve-page-fields.ts +8 -5
  25. package/src/runtime/layout/table-row-split.ts +97 -23
  26. package/src/runtime/surface-projection.ts +227 -27
  27. package/src/shell/session-bootstrap.ts +6 -1
  28. package/src/ui/WordReviewEditor.tsx +112 -33
  29. package/src/ui/editor-command-bag.ts +4 -0
  30. package/src/ui/editor-shell-view.tsx +1 -0
  31. package/src/ui/editor-surface-controller.tsx +1 -0
  32. package/src/ui/headless/revision-decoration-model.ts +11 -13
  33. package/src/ui-tailwind/chrome/editor-action-registry.ts +7 -26
  34. package/src/ui-tailwind/chrome/responsive-chrome.ts +2 -2
  35. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +27 -0
  36. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +6 -2
  37. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +57 -6
  38. package/src/ui-tailwind/editor-surface/page-slice-util.ts +17 -0
  39. package/src/ui-tailwind/editor-surface/perf-probe.ts +5 -0
  40. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +34 -0
  41. package/src/ui-tailwind/editor-surface/pm-decorations.ts +146 -20
  42. package/src/ui-tailwind/editor-surface/pm-position-map.ts +8 -2
  43. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +41 -44
  44. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +1 -1
  45. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +8 -3
  46. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -0
  47. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +75 -31
  48. package/src/ui-tailwind/review/tw-workflow-tab.tsx +159 -8
  49. package/src/ui-tailwind/review-workspace/types.ts +4 -0
  50. package/src/ui-tailwind/review-workspace/use-review-rail-state.ts +1 -1
  51. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +8 -10
  52. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +21 -17
  53. package/src/ui-tailwind/tw-review-workspace.tsx +27 -2
@@ -120,6 +120,12 @@ export interface ParagraphLineSlice {
120
120
  pageIndex: number;
121
121
  /** Inclusive-exclusive line range rendered by this slice. */
122
122
  lineRange: { from: number; to: number; totalLines: number };
123
+ /** Height consumed by this visible slice, when measured by pagination. */
124
+ heightTwips?: number;
125
+ /** Resolved line height used for this paragraph slice. */
126
+ lineHeightTwips?: number;
127
+ /** Available inline width for this slice. */
128
+ widthTwips?: number;
123
129
  }
124
130
 
125
131
  /**
@@ -137,6 +143,10 @@ export interface TableRowSlice {
137
143
  pageIndex: number;
138
144
  /** Inclusive-exclusive row range rendered by this slice. */
139
145
  rowRange: { from: number; to: number; totalRows: number };
146
+ /** Height consumed by this row slice, including repeated headers. */
147
+ heightTwips?: number;
148
+ /** Column occupied by this slice when the table flows across columns. */
149
+ columnIndex?: number;
140
150
  }
141
151
 
142
152
  /**
@@ -154,6 +164,17 @@ export interface BlockSplits {
154
164
  tablesByBlockId: Map<string, TableRowSlice[]>;
155
165
  }
156
166
 
167
+ export interface FragmentMeasurement {
168
+ /** Height consumed by this block fragment on the page. */
169
+ heightTwips: number;
170
+ /** Paragraph line count for this fragment, when known. */
171
+ lineCount?: number;
172
+ /** Resolved paragraph line height for generated body line boxes. */
173
+ lineHeightTwips?: number;
174
+ /** Available inline width used during measurement. */
175
+ widthTwips?: number;
176
+ }
177
+
157
178
  export interface PageStackResultWithSplits {
158
179
  pages: DocumentPageSnapshot[];
159
180
  splits: BlockSplits;
@@ -178,6 +199,13 @@ export interface PageStackResultWithSplits {
178
199
  * `RuntimePageRegions.columns[i].fragmentIds`.
179
200
  */
180
201
  columnByBlockIdByPageIndex?: ReadonlyMap<number, ReadonlyMap<string, number>>;
202
+ /**
203
+ * Measured whole-fragment geometry keyed by global page index and blockId.
204
+ * Split paragraph/table slices carry their own height on the slice object;
205
+ * this map covers unsplit/whole fragments and keeps projection from
206
+ * recomputing geometry with heuristic span math.
207
+ */
208
+ fragmentMeasurementsByPageIndex?: ReadonlyMap<number, ReadonlyMap<string, FragmentMeasurement>>;
181
209
  }
182
210
 
183
211
  // ---------------------------------------------------------------------------
@@ -234,6 +262,7 @@ export function buildPageStackWithSplits(
234
262
  : undefined;
235
263
  const pages: DocumentPageSnapshot[] = [];
236
264
  const splitsByBlock = new Map<string, ParagraphLineSlice[]>();
265
+ const tableSplitsByBlock = new Map<string, TableRowSlice[]>();
237
266
  // P8.1b — aggregate note allocations and fragments across all sections,
238
267
  // keyed by global page index.
239
268
  const globalNoteAllocationsByPageIndex = new Map<number, RuntimeNoteAllocation[]>();
@@ -245,6 +274,7 @@ export function buildPageStackWithSplits(
245
274
  // sections, keyed by global page index. Populated only for blocks that
246
275
  // paginated under a multi-column layout; absent entries mean single-column.
247
276
  const globalColumnByBlockIdByPageIndex = new Map<number, Map<string, number>>();
277
+ const globalFragmentMeasurementsByPageIndex = new Map<number, Map<string, FragmentMeasurement>>();
248
278
  // Refactor/04 post-Slice-4 — end-column per global page, used by the
249
279
  // next section's `nextColumn` handling to decide whether to seed
250
280
  // section N at `prevEnd + 1` or start fresh on a new page.
@@ -407,10 +437,27 @@ export function buildPageStackWithSplits(
407
437
  existing.push({
408
438
  pageIndex: globalPageIdx,
409
439
  lineRange: localSlice.lineRange,
440
+ ...(localSlice.heightTwips !== undefined ? { heightTwips: localSlice.heightTwips } : {}),
441
+ ...(localSlice.lineHeightTwips !== undefined ? { lineHeightTwips: localSlice.lineHeightTwips } : {}),
442
+ ...(localSlice.widthTwips !== undefined ? { widthTwips: localSlice.widthTwips } : {}),
410
443
  });
411
444
  }
412
445
  if (existing.length > 0) splitsByBlock.set(blockId, existing);
413
446
  }
447
+ for (const [blockId, localSlices] of paginatedResult.splits.tablesByBlockId) {
448
+ const existing = tableSplitsByBlock.get(blockId) ?? [];
449
+ for (const localSlice of localSlices) {
450
+ const globalPageIdx = pageInSectionToGlobal.get(localSlice.pageInSection);
451
+ if (globalPageIdx === undefined) continue;
452
+ existing.push({
453
+ pageIndex: globalPageIdx,
454
+ rowRange: localSlice.rowRange,
455
+ ...(localSlice.heightTwips !== undefined ? { heightTwips: localSlice.heightTwips } : {}),
456
+ ...(localSlice.columnIndex !== undefined ? { columnIndex: localSlice.columnIndex } : {}),
457
+ });
458
+ }
459
+ if (existing.length > 0) tableSplitsByBlock.set(blockId, existing);
460
+ }
414
461
 
415
462
  // P8.1b — resolve per-section note allocations + fragments to global
416
463
  // page index and merge into the global maps.
@@ -451,6 +498,31 @@ export function buildPageStackWithSplits(
451
498
  }
452
499
  endColumnByGlobalPageIndex.set(globalPageIdx, maxCol);
453
500
  }
501
+ for (const [pageInSec, sectionMeasurements] of paginatedResult.fragmentMeasurementsByPageInSection) {
502
+ const globalPageIdx = pageInSectionToGlobal.get(pageInSec);
503
+ if (globalPageIdx === undefined) continue;
504
+ let forPage = globalFragmentMeasurementsByPageIndex.get(globalPageIdx);
505
+ if (!forPage) {
506
+ forPage = new Map<string, FragmentMeasurement>();
507
+ globalFragmentMeasurementsByPageIndex.set(globalPageIdx, forPage);
508
+ }
509
+ for (const [blockId, measurement] of sectionMeasurements) {
510
+ const existing = forPage.get(blockId);
511
+ if (!existing) {
512
+ forPage.set(blockId, measurement);
513
+ continue;
514
+ }
515
+ forPage.set(blockId, {
516
+ heightTwips: existing.heightTwips + measurement.heightTwips,
517
+ lineCount:
518
+ existing.lineCount !== undefined || measurement.lineCount !== undefined
519
+ ? (existing.lineCount ?? 0) + (measurement.lineCount ?? 0)
520
+ : undefined,
521
+ lineHeightTwips: measurement.lineHeightTwips ?? existing.lineHeightTwips,
522
+ widthTwips: measurement.widthTwips ?? existing.widthTwips,
523
+ });
524
+ }
525
+ }
454
526
  }
455
527
 
456
528
  // Guarantee at least one page
@@ -470,7 +542,7 @@ export function buildPageStackWithSplits(
470
542
  }
471
543
 
472
544
  applyWidowControlPass(pages, mainSurface);
473
- const tableSplitsByBlock = collectTableRowSlices(mainSurface.blocks, pages);
545
+ applyChapterPageNumbering(pages, mainSurface);
474
546
  return {
475
547
  pages,
476
548
  splits: { byBlockId: splitsByBlock, tablesByBlockId: tableSplitsByBlock },
@@ -483,9 +555,133 @@ export function buildPageStackWithSplits(
483
555
  columnByBlockIdByPageIndex: globalColumnByBlockIdByPageIndex.size > 0
484
556
  ? globalColumnByBlockIdByPageIndex
485
557
  : undefined,
558
+ fragmentMeasurementsByPageIndex: globalFragmentMeasurementsByPageIndex.size > 0
559
+ ? globalFragmentMeasurementsByPageIndex
560
+ : undefined,
486
561
  };
487
562
  }
488
563
 
564
+ function applyChapterPageNumbering(
565
+ pages: DocumentPageSnapshot[],
566
+ mainSurface: EditorSurfaceSnapshot,
567
+ ): void {
568
+ const pagesNeedingChapterNumber = pages.filter(
569
+ (page) => page.layout.pageNumbering?.chapterStyle,
570
+ );
571
+ if (pagesNeedingChapterNumber.length === 0) {
572
+ return;
573
+ }
574
+
575
+ for (const page of pagesNeedingChapterNumber) {
576
+ const pageNumbering = page.layout.pageNumbering;
577
+ if (!pageNumbering?.chapterStyle) {
578
+ continue;
579
+ }
580
+ const chapterNumber = resolveChapterNumberForPage(
581
+ mainSurface.blocks,
582
+ page,
583
+ pageNumbering.chapterStyle,
584
+ );
585
+ if (!chapterNumber) {
586
+ continue;
587
+ }
588
+ page.layout = {
589
+ ...page.layout,
590
+ pageNumbering: {
591
+ ...pageNumbering,
592
+ chapterNumber,
593
+ },
594
+ };
595
+ }
596
+ }
597
+
598
+ function resolveChapterNumberForPage(
599
+ blocks: readonly SurfaceBlockSnapshot[],
600
+ page: DocumentPageSnapshot,
601
+ chapterStyle: string,
602
+ ): string | undefined {
603
+ let current: string | undefined;
604
+ let firstOnPage: string | undefined;
605
+ for (const block of blocks) {
606
+ if (block.kind !== "paragraph") {
607
+ continue;
608
+ }
609
+ if (block.from >= page.endOffset) {
610
+ break;
611
+ }
612
+ if (!paragraphMatchesChapterStyle(block, chapterStyle)) {
613
+ continue;
614
+ }
615
+ const chapterNumber = normalizeChapterNumberMarker(
616
+ block.numberingPrefix ?? block.resolvedNumbering?.text,
617
+ );
618
+ if (!chapterNumber) {
619
+ continue;
620
+ }
621
+ if (block.from >= page.startOffset && firstOnPage === undefined) {
622
+ firstOnPage = chapterNumber;
623
+ }
624
+ if (block.from <= page.startOffset) {
625
+ current = chapterNumber;
626
+ }
627
+ }
628
+ return current ?? firstOnPage;
629
+ }
630
+
631
+ function paragraphMatchesChapterStyle(
632
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
633
+ chapterStyle: string,
634
+ ): boolean {
635
+ const normalizedChapterStyle = normalizeStyleToken(chapterStyle);
636
+ if (!normalizedChapterStyle) {
637
+ return false;
638
+ }
639
+ const normalizedStyleId = normalizeStyleToken(block.styleId);
640
+ if (normalizedStyleId && normalizedStyleId === normalizedChapterStyle) {
641
+ return true;
642
+ }
643
+
644
+ const chapterLevel = chapterStyleToHeadingLevel(chapterStyle);
645
+ if (chapterLevel === undefined) {
646
+ return false;
647
+ }
648
+ if (block.outlineLevel === chapterLevel - 1) {
649
+ return true;
650
+ }
651
+ const headingLevel = styleIdToHeadingLevel(block.styleId);
652
+ return headingLevel === chapterLevel;
653
+ }
654
+
655
+ function chapterStyleToHeadingLevel(value: string): number | undefined {
656
+ const trimmed = value.trim();
657
+ if (!trimmed) {
658
+ return undefined;
659
+ }
660
+ if (/^[1-9]$/u.test(trimmed)) {
661
+ return Number(trimmed);
662
+ }
663
+ return styleIdToHeadingLevel(trimmed);
664
+ }
665
+
666
+ function styleIdToHeadingLevel(value: string | undefined): number | undefined {
667
+ const normalized = normalizeStyleToken(value);
668
+ const match = /^heading([1-9])$/u.exec(normalized);
669
+ return match ? Number(match[1]) : undefined;
670
+ }
671
+
672
+ function normalizeStyleToken(value: string | undefined): string {
673
+ return (value ?? "").trim().replace(/[\s_-]+/gu, "").toLowerCase();
674
+ }
675
+
676
+ function normalizeChapterNumberMarker(value: string | undefined): string | undefined {
677
+ const trimmed = value?.trim();
678
+ if (!trimmed) {
679
+ return undefined;
680
+ }
681
+ const normalized = trimmed.replace(/[\s.):-]+$/u, "");
682
+ return normalized.length > 0 ? normalized : trimmed;
683
+ }
684
+
489
685
  /**
490
686
  * Resumable variant of `buildPageStack` — returns page snapshots starting at
491
687
  * `resumeAt.startPageIndex`, suitable for splicing into a prior page graph.
@@ -582,6 +778,14 @@ export function buildPageStackFromWithSplits(
582
778
  const tail = slices.filter((s) => s.pageIndex >= dirtyPageNumberOffset);
583
779
  if (tail.length > 0) tailTableSplits.set(blockId, tail);
584
780
  }
781
+ const tailFragmentMeasurements =
782
+ full.fragmentMeasurementsByPageIndex && full.fragmentMeasurementsByPageIndex.size > 0
783
+ ? new Map(
784
+ Array.from(full.fragmentMeasurementsByPageIndex.entries()).filter(
785
+ ([pi]) => pi >= dirtyPageNumberOffset,
786
+ ),
787
+ )
788
+ : undefined;
585
789
  return {
586
790
  pages: tailPages,
587
791
  splits: { byBlockId: tailSplits, tablesByBlockId: tailTableSplits },
@@ -594,6 +798,9 @@ export function buildPageStackFromWithSplits(
594
798
  ),
595
799
  }
596
800
  : {}),
801
+ ...(tailFragmentMeasurements !== undefined
802
+ ? { fragmentMeasurementsByPageIndex: tailFragmentMeasurements }
803
+ : {}),
597
804
  };
598
805
  }
599
806
 
@@ -616,6 +823,7 @@ export function buildPageStackFromWithSplits(
616
823
  dirtySurface,
617
824
  measurementProvider,
618
825
  );
826
+ applyChapterPageNumbering(tailResult.pages, mainSurface);
619
827
 
620
828
  // Shift global page indices on all returned pages and splits so they align
621
829
  // with the global page graph (the caller's spliceGraph prepends the head).
@@ -666,6 +874,15 @@ export function buildPageStackFromWithSplits(
666
874
  ]),
667
875
  )
668
876
  : undefined;
877
+ const shiftedFragmentMeasurements =
878
+ tailResult.fragmentMeasurementsByPageIndex && tailResult.fragmentMeasurementsByPageIndex.size > 0
879
+ ? new Map<number, ReadonlyMap<string, FragmentMeasurement>>(
880
+ Array.from(tailResult.fragmentMeasurementsByPageIndex.entries()).map(([pi, measurements]) => [
881
+ pi + dirtyPageNumberOffset,
882
+ measurements,
883
+ ]),
884
+ )
885
+ : undefined;
669
886
 
670
887
  return {
671
888
  pages: shiftedPages,
@@ -679,88 +896,12 @@ export function buildPageStackFromWithSplits(
679
896
  ...(shiftedColumnByBlockId !== undefined
680
897
  ? { columnByBlockIdByPageIndex: shiftedColumnByBlockId }
681
898
  : {}),
899
+ ...(shiftedFragmentMeasurements !== undefined
900
+ ? { fragmentMeasurementsByPageIndex: shiftedFragmentMeasurements }
901
+ : {}),
682
902
  };
683
903
  }
684
904
 
685
- // ---------------------------------------------------------------------------
686
- // R3: table row-slice collection
687
- // ---------------------------------------------------------------------------
688
-
689
- /**
690
- * Compute `TableRowSlice[]` entries for table blocks that span multiple pages.
691
- *
692
- * Tables are currently placed atomically by `paginateSectionBlocks` — the
693
- * engine never splits a table mid-row. As a consequence the offset range of
694
- * every table block falls inside exactly one page's `[startOffset, endOffset)`,
695
- * and this function returns an empty map.
696
- *
697
- * The function is wired so that when row-level table pagination lands (the
698
- * engine emits `pushPage` mid-table, making `pages[]` contain a page whose
699
- * `startOffset` lands between two rows of a table), the same walk
700
- * automatically groups rows by page and emits `TableRowSlice` entries
701
- * without any further schema change.
702
- *
703
- * Row offsets are derived from `row.cells[0].content[0].from` — the first
704
- * cell's first inner block. Rows whose first cell has no paragraph fall
705
- * back to the table's own `from` so they group with the table's origin page.
706
- */
707
- function collectTableRowSlices(
708
- blocks: readonly SurfaceBlockSnapshot[],
709
- pages: readonly DocumentPageSnapshot[],
710
- ): Map<string, TableRowSlice[]> {
711
- const result = new Map<string, TableRowSlice[]>();
712
- if (pages.length === 0) return result;
713
-
714
- const findPageIndex = (offset: number): number | null => {
715
- for (const page of pages) {
716
- if (offset >= page.startOffset && offset < page.endOffset) {
717
- return page.pageIndex;
718
- }
719
- }
720
- const last = pages[pages.length - 1];
721
- if (last && offset >= last.startOffset && offset <= last.endOffset) {
722
- return last.pageIndex;
723
- }
724
- return null;
725
- };
726
-
727
- for (const block of blocks) {
728
- if (block.kind !== "table") continue;
729
- const totalRows = block.rows.length;
730
- if (totalRows === 0) continue;
731
-
732
- // Walk rows and assign each to the page containing its start offset.
733
- const perPageRange = new Map<number, { from: number; to: number }>();
734
- for (let rowIndex = 0; rowIndex < totalRows; rowIndex += 1) {
735
- const row = block.rows[rowIndex]!;
736
- const rowStart = row.cells[0]?.content[0]?.from ?? block.from;
737
- const pageIndex = findPageIndex(rowStart);
738
- if (pageIndex === null) continue;
739
- const existing = perPageRange.get(pageIndex);
740
- if (existing) {
741
- existing.to = rowIndex + 1;
742
- } else {
743
- perPageRange.set(pageIndex, { from: rowIndex, to: rowIndex + 1 });
744
- }
745
- }
746
-
747
- // Single-page tables produce no slices.
748
- if (perPageRange.size <= 1) continue;
749
-
750
- const slices: TableRowSlice[] = [];
751
- for (const [pageIndex, range] of perPageRange) {
752
- slices.push({
753
- pageIndex,
754
- rowRange: { from: range.from, to: range.to, totalRows },
755
- });
756
- }
757
- slices.sort((a, b) => a.pageIndex - b.pageIndex);
758
- result.set(block.blockId, slices);
759
- }
760
-
761
- return result;
762
- }
763
-
764
905
  // ---------------------------------------------------------------------------
765
906
  // Widow control pass
766
907
  // ---------------------------------------------------------------------------
@@ -1273,6 +1414,10 @@ function measureParagraphLineCount(
1273
1414
  currentLineCapacity = subsequentLineCapacity;
1274
1415
  break;
1275
1416
  case "image":
1417
+ // Keep the heuristic paginator calibrated to the historical CCEP
1418
+ // page-count locks. Intrinsic object extent is preserved by the
1419
+ // measurement backends; true object placement belongs to the frame
1420
+ // layout path, not paragraph line-count inflation.
1276
1421
  lineCount += Math.max(1, Math.round(segment.display === "floating" ? 2 : 1));
1277
1422
  currentLineChars = 0;
1278
1423
  currentLineCapacity = subsequentLineCapacity;
@@ -1351,11 +1496,24 @@ function collectSectionBlocks(
1351
1496
  interface SectionLocalSlice {
1352
1497
  pageInSection: number;
1353
1498
  lineRange: { from: number; to: number; totalLines: number };
1499
+ heightTwips?: number;
1500
+ lineHeightTwips?: number;
1501
+ widthTwips?: number;
1502
+ }
1503
+
1504
+ interface SectionLocalTableSlice {
1505
+ pageInSection: number;
1506
+ rowRange: { from: number; to: number; totalRows: number };
1507
+ heightTwips?: number;
1508
+ columnIndex?: number;
1354
1509
  }
1355
1510
 
1356
1511
  interface SectionPaginationResult {
1357
1512
  pages: Omit<DocumentPageSnapshot, "pageIndex">[];
1358
- splits: { byBlockId: Map<string, SectionLocalSlice[]> };
1513
+ splits: {
1514
+ byBlockId: Map<string, SectionLocalSlice[]>;
1515
+ tablesByBlockId: Map<string, SectionLocalTableSlice[]>;
1516
+ };
1359
1517
  /** P8.1b — per-page note allocations keyed by pageInSection index. */
1360
1518
  noteAllocationsByPageInSection: Map<number, RuntimeNoteAllocation[]>;
1361
1519
  /** P8.1b — per-page note body fragments keyed by pageInSection index. */
@@ -1366,6 +1524,7 @@ interface SectionPaginationResult {
1366
1524
  * single-column sections; absent for note/footnote blocks.
1367
1525
  */
1368
1526
  columnByBlockIdByPageInSection: Map<number, Map<string, number>>;
1527
+ fragmentMeasurementsByPageInSection: Map<number, Map<string, FragmentMeasurement>>;
1369
1528
  }
1370
1529
 
1371
1530
  /**
@@ -1431,15 +1590,18 @@ export function paginateSectionBlocksWithSplits(
1431
1590
  layout,
1432
1591
  },
1433
1592
  ],
1434
- splits: { byBlockId: new Map() }, // section-local; global map includes tablesByBlockId via collectTableRowSlices
1593
+ splits: { byBlockId: new Map(), tablesByBlockId: new Map() },
1435
1594
  noteAllocationsByPageInSection: new Map(),
1436
1595
  noteFragmentsByPageInSection: new Map(),
1437
1596
  columnByBlockIdByPageInSection: new Map(),
1597
+ fragmentMeasurementsByPageInSection: new Map(),
1438
1598
  };
1439
1599
  }
1440
1600
 
1441
1601
  const pages: Omit<DocumentPageSnapshot, "pageIndex">[] = [];
1442
1602
  const splitsByBlock = new Map<string, SectionLocalSlice[]>();
1603
+ const tableSplitsByBlock = new Map<string, SectionLocalTableSlice[]>();
1604
+ const fragmentMeasurementsByPageInSection = new Map<number, Map<string, FragmentMeasurement>>();
1443
1605
  const usableHeight = getUsablePageHeight(layout);
1444
1606
  const columnMetrics = getUsableColumnMetrics(layout);
1445
1607
  const maxColumns = Math.max(1, columnMetrics.length);
@@ -1476,6 +1638,38 @@ export function paginateSectionBlocksWithSplits(
1476
1638
  forPage.set(blockId, columnIndex);
1477
1639
  }
1478
1640
  };
1641
+ const recordFragmentMeasurement = (
1642
+ blockId: string,
1643
+ measurement: FragmentMeasurement,
1644
+ ): void => {
1645
+ let forPage = fragmentMeasurementsByPageInSection.get(pageInSection);
1646
+ if (!forPage) {
1647
+ forPage = new Map<string, FragmentMeasurement>();
1648
+ fragmentMeasurementsByPageInSection.set(pageInSection, forPage);
1649
+ }
1650
+ const existing = forPage.get(blockId);
1651
+ if (existing) {
1652
+ forPage.set(blockId, {
1653
+ heightTwips: existing.heightTwips + measurement.heightTwips,
1654
+ lineCount:
1655
+ existing.lineCount !== undefined || measurement.lineCount !== undefined
1656
+ ? (existing.lineCount ?? 0) + (measurement.lineCount ?? 0)
1657
+ : undefined,
1658
+ lineHeightTwips: measurement.lineHeightTwips ?? existing.lineHeightTwips,
1659
+ widthTwips: measurement.widthTwips ?? existing.widthTwips,
1660
+ });
1661
+ return;
1662
+ }
1663
+ forPage.set(blockId, measurement);
1664
+ };
1665
+ const appendTableSlice = (
1666
+ blockId: string,
1667
+ slice: SectionLocalTableSlice,
1668
+ ): void => {
1669
+ const list = tableSplitsByBlock.get(blockId) ?? [];
1670
+ list.push(slice);
1671
+ tableSplitsByBlock.set(blockId, list);
1672
+ };
1479
1673
  // P6.c: per-table progress when a table is being split row-by-row
1480
1674
  // across pages. Map<blockId, nextRowIndexToPlace>. Cleared once a
1481
1675
  // table is fully placed.
@@ -1501,6 +1695,30 @@ export function paginateSectionBlocksWithSplits(
1501
1695
  if (delta === 0) return baseHeight;
1502
1696
  return Math.max(MIN_BLOCK_HEIGHT_TWIPS, baseHeight - delta);
1503
1697
  };
1698
+ const measureParagraphFragment = (
1699
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
1700
+ formatting: import("./resolved-formatting-state.ts").ResolvedParagraphFormatting | null,
1701
+ columnWidth: number,
1702
+ heightTwips: number,
1703
+ lineRange?: { from: number; to: number; totalLines: number },
1704
+ ): FragmentMeasurement => {
1705
+ if (!formatting) return { heightTwips, widthTwips: columnWidth };
1706
+ const lineCount = lineRange
1707
+ ? Math.max(0, lineRange.to - lineRange.from)
1708
+ : (
1709
+ cache?.getLineCount(block, columnWidth) ??
1710
+ measureParagraphLineCount(block, formatting, columnWidth, measurementProvider)
1711
+ );
1712
+ if (!lineRange && cache?.getLineCount(block, columnWidth) === undefined) {
1713
+ cache?.setLineCount(block, columnWidth, lineCount);
1714
+ }
1715
+ return {
1716
+ heightTwips,
1717
+ lineCount: Math.max(1, lineCount),
1718
+ lineHeightTwips: formatting.lineHeight,
1719
+ widthTwips: columnWidth,
1720
+ };
1721
+ };
1504
1722
 
1505
1723
  // P8.1b — per-page note tracking.
1506
1724
  // `pendingNoteKeys` parallels `reservedNotes` but is only snapshotted on
@@ -1700,6 +1918,8 @@ export function paginateSectionBlocksWithSplits(
1700
1918
  block,
1701
1919
  columnWidth,
1702
1920
  measurementProvider,
1921
+ defaultTabInterval,
1922
+ themeFonts,
1703
1923
  });
1704
1924
  const { cantSplitFlags, isHeaderFlags } = extractRowFlags(block);
1705
1925
  const repeatedHeaderHeightTwips = computeRepeatedHeaderHeight(
@@ -1720,10 +1940,29 @@ export function paginateSectionBlocksWithSplits(
1720
1940
  const firstChild = row?.cells[0]?.content[0];
1721
1941
  return firstChild?.from ?? block.from;
1722
1942
  };
1943
+ const sliceHeight = (fromRow: number, toRow: number): number => {
1944
+ let height = fromRow > 0 ? repeatedHeaderHeightTwips : 0;
1945
+ for (let r = fromRow; r < toRow; r += 1) {
1946
+ height += rowHeights[r] ?? 0;
1947
+ }
1948
+ return height;
1949
+ };
1723
1950
 
1724
1951
  // Case 1: remainder fits — place and break.
1725
1952
  if (remainderHeight <= remainingForTable) {
1726
1953
  recordColumnPlacement(block.blockId);
1954
+ if (startRow > 0) {
1955
+ appendTableSlice(block.blockId, {
1956
+ pageInSection,
1957
+ rowRange: { from: startRow, to: rowHeights.length, totalRows: rowHeights.length },
1958
+ heightTwips: remainderHeight,
1959
+ ...(isMultiColumn ? { columnIndex } : {}),
1960
+ });
1961
+ }
1962
+ recordFragmentMeasurement(block.blockId, {
1963
+ heightTwips: startRow > 0 ? remainderHeight : baseHeight,
1964
+ widthTwips: columnWidth,
1965
+ });
1727
1966
  columnHeight += startRow > 0 ? remainderHeight : baseHeight;
1728
1967
  if (startRow > 0) tableProgress.delete(block.blockId);
1729
1968
  if (index === blocks.length - 1) pushPage(section.end);
@@ -1740,16 +1979,83 @@ export function paginateSectionBlocksWithSplits(
1740
1979
  startRow,
1741
1980
  });
1742
1981
  if (decision.rowsOnCurrentPage > 0) {
1982
+ const splitHeight = sliceHeight(startRow, decision.splitRowIndex);
1743
1983
  recordColumnPlacement(block.blockId);
1984
+ appendTableSlice(block.blockId, {
1985
+ pageInSection,
1986
+ rowRange: {
1987
+ from: startRow,
1988
+ to: decision.splitRowIndex,
1989
+ totalRows: rowHeights.length,
1990
+ },
1991
+ heightTwips: splitHeight,
1992
+ ...(isMultiColumn ? { columnIndex } : {}),
1993
+ });
1994
+ recordFragmentMeasurement(block.blockId, {
1995
+ heightTwips: splitHeight,
1996
+ widthTwips: columnWidth,
1997
+ });
1998
+ columnHeight += splitHeight;
1744
1999
  tableProgress.set(block.blockId, decision.splitRowIndex);
2000
+ if (columnIndex < maxColumns - 1) {
2001
+ columnIndex += 1;
2002
+ columnHeight = 0;
2003
+ continue;
2004
+ }
1745
2005
  pushPage(rowOffset(decision.splitRowIndex));
1746
2006
  continue;
1747
2007
  }
1748
2008
 
2009
+ // Degraded row-boundary placement for an oversized continuation row.
2010
+ // If no row fits on a fresh continuation page, pushing at startRow's
2011
+ // offset would re-open the same page forever. Keep the row intact,
2012
+ // mark it as visually overflowing this page/column, and advance to the
2013
+ // next row boundary.
2014
+ if (startRow > 0 && columnHeight === 0) {
2015
+ const forcedEndRow = Math.min(rowHeights.length, startRow + 1);
2016
+ const forcedHeight = sliceHeight(startRow, forcedEndRow);
2017
+ recordColumnPlacement(block.blockId);
2018
+ appendTableSlice(block.blockId, {
2019
+ pageInSection,
2020
+ rowRange: {
2021
+ from: startRow,
2022
+ to: forcedEndRow,
2023
+ totalRows: rowHeights.length,
2024
+ },
2025
+ heightTwips: forcedHeight,
2026
+ ...(isMultiColumn ? { columnIndex } : {}),
2027
+ });
2028
+ recordFragmentMeasurement(block.blockId, {
2029
+ heightTwips: forcedHeight,
2030
+ widthTwips: columnWidth,
2031
+ });
2032
+ columnHeight += forcedHeight;
2033
+ if (forcedEndRow >= rowHeights.length) {
2034
+ tableProgress.delete(block.blockId);
2035
+ if (index === blocks.length - 1) pushPage(section.end);
2036
+ break;
2037
+ }
2038
+ tableProgress.set(block.blockId, forcedEndRow);
2039
+ if (columnIndex < maxColumns - 1) {
2040
+ columnIndex += 1;
2041
+ columnHeight = 0;
2042
+ continue;
2043
+ }
2044
+ const nextRowOffset = rowOffset(forcedEndRow);
2045
+ const fallbackOffset = Math.min(section.end, pageStart + 1);
2046
+ pushPage(nextRowOffset > pageStart ? nextRowOffset : fallbackOffset);
2047
+ continue;
2048
+ }
2049
+
1749
2050
  // Case 3: can't split here. If there's content above (or we're
1750
2051
  // resuming), push everything from the resume point to the next
1751
2052
  // page so the next iteration starts fresh.
1752
2053
  if (columnHeight > 0 || startRow > 0) {
2054
+ if (columnIndex < maxColumns - 1) {
2055
+ columnIndex += 1;
2056
+ columnHeight = 0;
2057
+ continue;
2058
+ }
1753
2059
  pushPage(startRow > 0 ? rowOffset(startRow) : block.from);
1754
2060
  continue;
1755
2061
  }
@@ -1758,23 +2064,22 @@ export function paginateSectionBlocksWithSplits(
1758
2064
  // it doesn't fit on, AND the first row alone exceeds page
1759
2065
  // height). Preserve pre-P6.c semantics so offsets stay clean.
1760
2066
  recordColumnPlacement(block.blockId);
2067
+ recordFragmentMeasurement(block.blockId, {
2068
+ heightTwips: baseHeight,
2069
+ widthTwips: columnWidth,
2070
+ });
1761
2071
  columnHeight += baseHeight;
1762
2072
  if (index === blocks.length - 1) pushPage(section.end);
1763
2073
  break;
1764
2074
  }
1765
2075
 
1766
- // Overflow check — paragraph doesn't fit on current page
2076
+ // Overflow check — block doesn't fit on current page/column.
1767
2077
  if (projectedHeight > usableHeight && pageStart < block.from) {
1768
2078
  if (columnIndex < maxColumns - 1) {
1769
- // Advance to next column without a page break do NOT snapshot.
2079
+ // Advance to next column without a page break. Footnotes are
2080
+ // page-scoped, so keep pending note state alive until pushPage().
1770
2081
  columnIndex += 1;
1771
2082
  columnHeight = 0;
1772
- reservedNoteHeight = 0;
1773
- reservedNotes.clear();
1774
- // P8.1b: clear pending note state WITHOUT snapshotting.
1775
- pendingNoteKeys.clear();
1776
- pendingNoteBlockFroms.clear();
1777
- pendingNoteColumnWidths.clear();
1778
2083
  continue;
1779
2084
  }
1780
2085
 
@@ -1822,24 +2127,42 @@ export function paginateSectionBlocksWithSplits(
1822
2127
  if (splitRule) {
1823
2128
  const bleedUpPageInSection = pageInSection;
1824
2129
  const anchorPageInSection = pageInSection + 1;
2130
+ const currentLineRange = {
2131
+ from: 0,
2132
+ to: splitRule.linesOnCurrent,
2133
+ totalLines,
2134
+ };
2135
+ const nextLineRange = {
2136
+ from: splitRule.linesOnCurrent,
2137
+ to: totalLines,
2138
+ totalLines,
2139
+ };
1825
2140
  splitsByBlock.set(block.blockId, [
1826
2141
  {
1827
2142
  pageInSection: bleedUpPageInSection,
1828
- lineRange: {
1829
- from: 0,
1830
- to: splitRule.linesOnCurrent,
1831
- totalLines,
1832
- },
2143
+ lineRange: currentLineRange,
2144
+ heightTwips: splitRule.linesOnCurrent * formatting.lineHeight,
2145
+ lineHeightTwips: formatting.lineHeight,
2146
+ widthTwips: columnWidth,
1833
2147
  },
1834
2148
  {
1835
2149
  pageInSection: anchorPageInSection,
1836
- lineRange: {
1837
- from: splitRule.linesOnCurrent,
1838
- to: totalLines,
1839
- totalLines,
1840
- },
2150
+ lineRange: nextLineRange,
2151
+ heightTwips: Math.max(0, totalLines - splitRule.linesOnCurrent) * formatting.lineHeight,
2152
+ lineHeightTwips: formatting.lineHeight,
2153
+ widthTwips: columnWidth,
1841
2154
  },
1842
2155
  ]);
2156
+ recordFragmentMeasurement(
2157
+ block.blockId,
2158
+ measureParagraphFragment(
2159
+ block,
2160
+ formatting,
2161
+ columnWidth,
2162
+ splitRule.linesOnCurrent * formatting.lineHeight,
2163
+ currentLineRange,
2164
+ ),
2165
+ );
1843
2166
  }
1844
2167
  }
1845
2168
 
@@ -1852,15 +2175,10 @@ export function paginateSectionBlocksWithSplits(
1852
2175
  // span the full page if it's truly larger than a page).
1853
2176
  if (keepLinesActive && columnHeight > 0 && baseHeight > usableHeight - columnHeight && pageStart < block.from) {
1854
2177
  if (columnIndex < maxColumns - 1) {
1855
- // Column advance without page break do NOT snapshot.
2178
+ // Column advance without page break. Keep page-scoped footnote
2179
+ // reservations/pending allocations until the page closes.
1856
2180
  columnIndex += 1;
1857
2181
  columnHeight = 0;
1858
- reservedNoteHeight = 0;
1859
- reservedNotes.clear();
1860
- // P8.1b: clear pending note state WITHOUT snapshotting.
1861
- pendingNoteKeys.clear();
1862
- pendingNoteBlockFroms.clear();
1863
- pendingNoteColumnWidths.clear();
1864
2182
  continue;
1865
2183
  }
1866
2184
  pushPage(block.from);
@@ -1877,6 +2195,17 @@ export function paginateSectionBlocksWithSplits(
1877
2195
  // branch below advances `columnIndex`; the block itself lives in the
1878
2196
  // PRE-advance column.
1879
2197
  recordColumnPlacement(block.blockId);
2198
+ if (block.kind === "paragraph") {
2199
+ recordFragmentMeasurement(
2200
+ block.blockId,
2201
+ measureParagraphFragment(block, formatting, columnWidth, baseHeight),
2202
+ );
2203
+ } else {
2204
+ recordFragmentMeasurement(block.blockId, {
2205
+ heightTwips: baseHeight,
2206
+ widthTwips: columnWidth,
2207
+ });
2208
+ }
1880
2209
  columnHeight += baseHeight;
1881
2210
  reservedNoteHeight += effectiveNoteHeight;
1882
2211
  currentPageNoteIds(block).forEach((noteKey) => {
@@ -1904,17 +2233,10 @@ export function paginateSectionBlocksWithSplits(
1904
2233
  if (hasColumnBreak(block)) {
1905
2234
  if (columnIndex < maxColumns - 1) {
1906
2235
  // Column break within a multi-column layout: advance to next column.
1907
- // DO NOT snapshot note allocations only page-push triggers snapshotting.
1908
- // Clear pending note state alongside reservedNotes so notes that only
1909
- // appeared before the column break don't get double-counted.
2236
+ // Do not snapshot note allocations here; they remain attached to
2237
+ // the physical page and are emitted when pushPage() closes it.
1910
2238
  columnIndex += 1;
1911
2239
  columnHeight = 0;
1912
- reservedNoteHeight = 0;
1913
- reservedNotes.clear();
1914
- // P8.1b: clear pending note state WITHOUT snapshotting.
1915
- pendingNoteKeys.clear();
1916
- pendingNoteBlockFroms.clear();
1917
- pendingNoteColumnWidths.clear();
1918
2240
  } else {
1919
2241
  pushPage(nextBoundary);
1920
2242
  }
@@ -1941,10 +2263,11 @@ export function paginateSectionBlocksWithSplits(
1941
2263
  layout,
1942
2264
  },
1943
2265
  ],
1944
- splits: { byBlockId: splitsByBlock },
2266
+ splits: { byBlockId: splitsByBlock, tablesByBlockId: tableSplitsByBlock },
1945
2267
  noteAllocationsByPageInSection,
1946
2268
  noteFragmentsByPageInSection,
1947
2269
  columnByBlockIdByPageInSection,
2270
+ fragmentMeasurementsByPageInSection,
1948
2271
  };
1949
2272
  }
1950
2273