@beyondwork/docx-react-component 1.0.41 → 1.0.43

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 (118) hide show
  1. package/package.json +38 -37
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/editor-state-types.ts +110 -0
  6. package/src/api/external-custody-types.ts +74 -0
  7. package/src/api/participants-types.ts +18 -0
  8. package/src/api/public-types.ts +541 -5
  9. package/src/api/scope-metadata-resolver-types.ts +88 -0
  10. package/src/core/commands/formatting-commands.ts +1 -1
  11. package/src/core/commands/index.ts +601 -9
  12. package/src/core/search/search-text.ts +15 -2
  13. package/src/index.ts +131 -1
  14. package/src/io/docx-session.ts +672 -2
  15. package/src/io/export/escape-xml-attribute.ts +26 -0
  16. package/src/io/export/external-send.ts +188 -0
  17. package/src/io/export/serialize-comments.ts +13 -16
  18. package/src/io/export/serialize-footnotes.ts +17 -24
  19. package/src/io/export/serialize-headers-footers.ts +17 -24
  20. package/src/io/export/serialize-main-document.ts +59 -62
  21. package/src/io/export/serialize-numbering.ts +20 -27
  22. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  23. package/src/io/export/serialize-tables.ts +8 -15
  24. package/src/io/export/table-properties-xml.ts +25 -32
  25. package/src/io/import/external-reimport.ts +40 -0
  26. package/src/io/load-scheduler.ts +230 -0
  27. package/src/io/normalize/normalize-text.ts +83 -0
  28. package/src/io/ooxml/bw-xml.ts +244 -0
  29. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  30. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  31. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  32. package/src/io/ooxml/external-custody-payload.ts +102 -0
  33. package/src/io/ooxml/participants-payload.ts +97 -0
  34. package/src/io/ooxml/payload-signature.ts +112 -0
  35. package/src/io/ooxml/workflow-payload-validator.ts +367 -0
  36. package/src/io/ooxml/workflow-payload.ts +317 -7
  37. package/src/runtime/awareness-identity.ts +173 -0
  38. package/src/runtime/collab/event-types.ts +27 -0
  39. package/src/runtime/collab-session-bridge.ts +157 -0
  40. package/src/runtime/collab-session-facet.ts +193 -0
  41. package/src/runtime/collab-session.ts +273 -0
  42. package/src/runtime/comment-negotiation-sync.ts +91 -0
  43. package/src/runtime/comment-negotiation.ts +158 -0
  44. package/src/runtime/comment-presentation.ts +223 -0
  45. package/src/runtime/document-runtime.ts +639 -124
  46. package/src/runtime/editor-state-channel.ts +544 -0
  47. package/src/runtime/editor-state-integration.ts +217 -0
  48. package/src/runtime/external-send-runtime.ts +117 -0
  49. package/src/runtime/layout/docx-font-loader.ts +11 -30
  50. package/src/runtime/layout/index.ts +2 -0
  51. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  52. package/src/runtime/layout/layout-engine-instance.ts +139 -14
  53. package/src/runtime/layout/page-graph.ts +79 -7
  54. package/src/runtime/layout/paginated-layout-engine.ts +441 -48
  55. package/src/runtime/layout/public-facet.ts +585 -14
  56. package/src/runtime/layout/table-row-split.ts +316 -0
  57. package/src/runtime/markdown-sanitizer.ts +132 -0
  58. package/src/runtime/participants.ts +134 -0
  59. package/src/runtime/perf-counters.ts +28 -0
  60. package/src/runtime/render/render-frame-types.ts +17 -0
  61. package/src/runtime/render/render-kernel.ts +172 -29
  62. package/src/runtime/resign-payload.ts +120 -0
  63. package/src/runtime/surface-projection.ts +10 -5
  64. package/src/runtime/tamper-gate.ts +157 -0
  65. package/src/runtime/workflow-markup.ts +80 -16
  66. package/src/runtime/workflow-rail-segments.ts +244 -5
  67. package/src/ui/WordReviewEditor.tsx +654 -45
  68. package/src/ui/editor-command-bag.ts +14 -0
  69. package/src/ui/editor-runtime-boundary.ts +111 -11
  70. package/src/ui/editor-shell-view.tsx +21 -0
  71. package/src/ui/editor-surface-controller.tsx +5 -0
  72. package/src/ui/headless/selection-helpers.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  74. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  75. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  76. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  77. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  78. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  79. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  80. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  81. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  82. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  83. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  84. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  85. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  86. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  87. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  88. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  89. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  90. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +106 -0
  91. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  92. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  93. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  94. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  95. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  96. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  97. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  98. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  99. package/src/ui-tailwind/editor-surface/pm-schema.ts +167 -17
  100. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  101. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  102. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  103. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +10 -256
  104. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  105. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  106. package/src/ui-tailwind/index.ts +37 -1
  107. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  108. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  109. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  110. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  111. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  112. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  113. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  114. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  115. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  116. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  117. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  118. package/src/ui-tailwind/tw-review-workspace.tsx +455 -118
@@ -41,6 +41,10 @@ import type {
41
41
  FootnoteCollection,
42
42
  } from "../../model/canonical-document.ts";
43
43
  import type { CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
44
+ import type {
45
+ RuntimeNoteAllocation,
46
+ RuntimeBlockFragment,
47
+ } from "./page-graph.ts";
44
48
  import {
45
49
  buildPageLayoutSnapshot,
46
50
  buildResolvedSections,
@@ -63,6 +67,12 @@ import {
63
67
  import { analyzeInvalidation as analyzeInvalidationFn } from "./layout-invalidation.ts";
64
68
  import type { LayoutMeasurementProvider } from "./layout-measurement-provider.ts";
65
69
  import { paginateParagraphLines } from "./paginate-paragraph-lines.ts";
70
+ import {
71
+ computeRepeatedHeaderHeight,
72
+ extractRowFlags,
73
+ findTableRowSplit,
74
+ measureTableRowHeights,
75
+ } from "./table-row-split.ts";
66
76
 
67
77
  // ---------------------------------------------------------------------------
68
78
  // Types
@@ -132,6 +142,19 @@ export interface BlockSplits {
132
142
  export interface PageStackResultWithSplits {
133
143
  pages: DocumentPageSnapshot[];
134
144
  splits: BlockSplits;
145
+ /**
146
+ * P8.1b — per-page note allocations emitted by the engine.
147
+ * Keyed by zero-based global page index.
148
+ * Absent entries mean the page has no footnotes.
149
+ * Endnote allocations are deferred to P8 Task 7.
150
+ */
151
+ noteAllocationsByPageIndex?: ReadonlyMap<number, RuntimeNoteAllocation[]>;
152
+ /**
153
+ * P8.1b — per-page note body fragments emitted by the engine.
154
+ * Each fragment has `regionKind: "footnote-area"`.
155
+ * Parallel to `noteAllocationsByPageIndex`.
156
+ */
157
+ noteFragmentsByPageIndex?: ReadonlyMap<number, Array<Omit<RuntimeBlockFragment, "pageId">>>;
135
158
  }
136
159
 
137
160
  // ---------------------------------------------------------------------------
@@ -176,7 +199,19 @@ export function buildPageStackWithSplits(
176
199
  ): PageStackResultWithSplits {
177
200
  const pages: DocumentPageSnapshot[] = [];
178
201
  const splitsByBlock = new Map<string, ParagraphLineSlice[]>();
202
+ // P8.1b — aggregate note allocations and fragments across all sections,
203
+ // keyed by global page index.
204
+ const globalNoteAllocationsByPageIndex = new Map<number, RuntimeNoteAllocation[]>();
205
+ const globalNoteFragmentsByPageIndex = new Map<
206
+ number,
207
+ Array<Omit<RuntimeBlockFragment, "pageId">>
208
+ >();
179
209
  let globalPageIndex = 0;
210
+ // A single cache lives for the whole pagination pass so cross-section
211
+ // re-measurement (rare but possible through keepNext heuristics) still
212
+ // reuses heights. The WeakMap frees memory automatically when the block
213
+ // snapshots go out of scope at the end of the call.
214
+ const cache = createMeasurementCache();
180
215
 
181
216
  for (let sectionIdx = 0; sectionIdx < sections.length; sectionIdx++) {
182
217
  const section = sections[sectionIdx]!;
@@ -234,6 +269,7 @@ export function buildPageStackWithSplits(
234
269
  layout,
235
270
  document.subParts?.footnoteCollection,
236
271
  measurementProvider,
272
+ cache,
237
273
  );
238
274
  const paginated = paginatedResult.pages;
239
275
 
@@ -293,6 +329,21 @@ export function buildPageStackWithSplits(
293
329
  }
294
330
  if (existing.length > 0) splitsByBlock.set(blockId, existing);
295
331
  }
332
+
333
+ // P8.1b — resolve per-section note allocations + fragments to global
334
+ // page index and merge into the global maps.
335
+ for (const [pageInSec, sectionAllocs] of paginatedResult.noteAllocationsByPageInSection) {
336
+ const globalPageIdx = pageInSectionToGlobal.get(pageInSec);
337
+ if (globalPageIdx === undefined) continue;
338
+ const existing = globalNoteAllocationsByPageIndex.get(globalPageIdx) ?? [];
339
+ globalNoteAllocationsByPageIndex.set(globalPageIdx, [...existing, ...sectionAllocs]);
340
+ }
341
+ for (const [pageInSec, sectionFrags] of paginatedResult.noteFragmentsByPageInSection) {
342
+ const globalPageIdx = pageInSectionToGlobal.get(pageInSec);
343
+ if (globalPageIdx === undefined) continue;
344
+ const existing = globalNoteFragmentsByPageIndex.get(globalPageIdx) ?? [];
345
+ globalNoteFragmentsByPageIndex.set(globalPageIdx, [...existing, ...sectionFrags]);
346
+ }
296
347
  }
297
348
 
298
349
  // Guarantee at least one page
@@ -316,6 +367,12 @@ export function buildPageStackWithSplits(
316
367
  return {
317
368
  pages,
318
369
  splits: { byBlockId: splitsByBlock, tablesByBlockId: tableSplitsByBlock },
370
+ noteAllocationsByPageIndex: globalNoteAllocationsByPageIndex.size > 0
371
+ ? globalNoteAllocationsByPageIndex
372
+ : undefined,
373
+ noteFragmentsByPageIndex: globalNoteFragmentsByPageIndex.size > 0
374
+ ? globalNoteFragmentsByPageIndex
375
+ : undefined,
319
376
  };
320
377
  }
321
378
 
@@ -560,6 +617,59 @@ export function requiresFullRecompute(reason: LayoutInvalidationReason): boolean
560
617
  const MIN_BLOCK_HEIGHT_TWIPS = 240;
561
618
  const FOOTNOTE_REFERENCE_RESERVATION_TWIPS = 180;
562
619
 
620
+ /**
621
+ * Per-invocation measurement cache keyed by `(block, columnWidth)`.
622
+ *
623
+ * Pagination re-measures the same block more than once in the hot path:
624
+ * - `keepNext` checks both the current and the next block's height
625
+ * - Intra-paragraph splits re-ask for `measureParagraphLineCount`
626
+ * - Multi-pass pagination (splitRule, column advance) may loop over the
627
+ * same block with the same column width
628
+ *
629
+ * The cache is scoped to a single `buildPageStackWithSplits` call. Block
630
+ * references are stable during one pagination pass (the `blocks` array is
631
+ * frozen for the run), so a `WeakMap<Block, Map<columnWidth, height>>` is
632
+ * cheap and never outlives the call.
633
+ *
634
+ * Canvas-backed measurement is the expensive case; the empirical backend
635
+ * does its own work inline but the cache still saves redundant iteration.
636
+ */
637
+ interface MeasurementCache {
638
+ getHeight(block: SurfaceBlockSnapshot, columnWidth: number): number | undefined;
639
+ setHeight(block: SurfaceBlockSnapshot, columnWidth: number, heightTwips: number): void;
640
+ getLineCount(block: SurfaceBlockSnapshot, columnWidth: number): number | undefined;
641
+ setLineCount(block: SurfaceBlockSnapshot, columnWidth: number, lineCount: number): void;
642
+ }
643
+
644
+ function createMeasurementCache(): MeasurementCache {
645
+ const heightByBlock = new WeakMap<SurfaceBlockSnapshot, Map<number, number>>();
646
+ const lineCountByBlock = new WeakMap<SurfaceBlockSnapshot, Map<number, number>>();
647
+ return {
648
+ getHeight(block, columnWidth) {
649
+ return heightByBlock.get(block)?.get(columnWidth);
650
+ },
651
+ setHeight(block, columnWidth, heightTwips) {
652
+ let map = heightByBlock.get(block);
653
+ if (!map) {
654
+ map = new Map();
655
+ heightByBlock.set(block, map);
656
+ }
657
+ map.set(columnWidth, heightTwips);
658
+ },
659
+ getLineCount(block, columnWidth) {
660
+ return lineCountByBlock.get(block)?.get(columnWidth);
661
+ },
662
+ setLineCount(block, columnWidth, lineCount) {
663
+ let map = lineCountByBlock.get(block);
664
+ if (!map) {
665
+ map = new Map();
666
+ lineCountByBlock.set(block, map);
667
+ }
668
+ map.set(columnWidth, lineCount);
669
+ },
670
+ };
671
+ }
672
+
563
673
  /**
564
674
  * Compute block height using resolved formatting when available.
565
675
  * Uses improved table measurement for legal contracts.
@@ -567,42 +677,76 @@ const FOOTNOTE_REFERENCE_RESERVATION_TWIPS = 180;
567
677
  * When `measurementProvider` is supplied, paragraph line counts are produced
568
678
  * by `provider.measureLineFragments(...)`; otherwise the inline empirical
569
679
  * path runs (which matches the empirical backend numerically).
680
+ *
681
+ * When `cache` is supplied, repeated measurements of the same
682
+ * `(block, columnWidth)` pair short-circuit to the cached value.
570
683
  */
571
684
  function measureBlockHeight(
572
685
  block: SurfaceBlockSnapshot | undefined,
573
686
  columnWidth: number,
574
687
  measurementProvider?: LayoutMeasurementProvider,
688
+ cache?: MeasurementCache,
575
689
  ): number {
576
690
  if (!block) return 0;
577
691
 
578
- switch (block.kind) {
579
- case "paragraph": {
580
- const formatting = resolveBlockFormatting(block);
581
- if (formatting) {
582
- const lineCount = measureParagraphLineCount(
583
- block,
584
- formatting,
585
- columnWidth,
586
- measurementProvider,
587
- );
588
- return calculateParagraphHeight(formatting, lineCount);
692
+ const cached = cache?.getHeight(block, columnWidth);
693
+ if (cached !== undefined) return cached;
694
+
695
+ const compute = (): number => {
696
+ switch (block.kind) {
697
+ case "paragraph": {
698
+ const formatting = resolveBlockFormatting(block);
699
+ if (formatting) {
700
+ // Provider path: sum per-line heights so canvas-backed measurements
701
+ // that emit variable line heights (mixed inline font sizes, etc.)
702
+ // do not collapse to `lineCount * flatLineHeight`.
703
+ if (measurementProvider) {
704
+ const measured = measurementProvider.measureLineFragments({
705
+ block,
706
+ formatting,
707
+ runs: new Map(),
708
+ columnWidth,
709
+ });
710
+ cache?.setLineCount(block, columnWidth, measured.lineCount);
711
+ const contentHeight = measured.lineHeights.reduce(
712
+ (total, lineHeight) => total + lineHeight,
713
+ 0,
714
+ );
715
+ const paragraphHeight =
716
+ contentHeight + formatting.spacingBefore + formatting.spacingAfter;
717
+ return Math.max(MIN_BLOCK_HEIGHT_TWIPS, paragraphHeight);
718
+ }
719
+ // Empirical-fallback path: flat per-line height × count.
720
+ const lineCount = measureParagraphLineCount(
721
+ block,
722
+ formatting,
723
+ columnWidth,
724
+ undefined,
725
+ );
726
+ return calculateParagraphHeight(formatting, lineCount);
727
+ }
728
+ return estimateBlockHeight(block, columnWidth);
589
729
  }
590
- return estimateBlockHeight(block, columnWidth);
730
+ case "table":
731
+ return measureTableHeight(block, columnWidth, measurementProvider, cache);
732
+ case "sdt_block":
733
+ return Math.max(
734
+ MIN_BLOCK_HEIGHT_TWIPS,
735
+ block.children.reduce(
736
+ (total, child) =>
737
+ total +
738
+ measureBlockHeight(child, columnWidth, measurementProvider, cache),
739
+ 0,
740
+ ),
741
+ );
742
+ case "opaque_block":
743
+ return block.label === "Section break" ? 0 : MIN_BLOCK_HEIGHT_TWIPS;
591
744
  }
592
- case "table":
593
- return measureTableHeight(block, columnWidth, measurementProvider);
594
- case "sdt_block":
595
- return Math.max(
596
- MIN_BLOCK_HEIGHT_TWIPS,
597
- block.children.reduce(
598
- (total, child) =>
599
- total + measureBlockHeight(child, columnWidth, measurementProvider),
600
- 0,
601
- ),
602
- );
603
- case "opaque_block":
604
- return block.label === "Section break" ? 0 : MIN_BLOCK_HEIGHT_TWIPS;
605
- }
745
+ };
746
+
747
+ const height = compute();
748
+ cache?.setHeight(block, columnWidth, height);
749
+ return height;
606
750
  }
607
751
 
608
752
  /**
@@ -621,6 +765,7 @@ function measureTableHeight(
621
765
  block: Extract<SurfaceBlockSnapshot, { kind: "table" }>,
622
766
  columnWidth: number,
623
767
  measurementProvider?: LayoutMeasurementProvider,
768
+ cache?: MeasurementCache,
624
769
  ): number {
625
770
  const TABLE_ROW_PADDING_TWIPS = 120;
626
771
  let totalHeight = 0;
@@ -665,6 +810,7 @@ function measureTableHeight(
665
810
  child,
666
811
  cellWidth,
667
812
  measurementProvider,
813
+ cache,
668
814
  );
669
815
  }
670
816
  contentHeight = Math.max(contentHeight, cellContentHeight + TABLE_ROW_PADDING_TWIPS);
@@ -883,6 +1029,10 @@ interface SectionLocalSlice {
883
1029
  interface SectionPaginationResult {
884
1030
  pages: Omit<DocumentPageSnapshot, "pageIndex">[];
885
1031
  splits: { byBlockId: Map<string, SectionLocalSlice[]> };
1032
+ /** P8.1b — per-page note allocations keyed by pageInSection index. */
1033
+ noteAllocationsByPageInSection: Map<number, RuntimeNoteAllocation[]>;
1034
+ /** P8.1b — per-page note body fragments keyed by pageInSection index. */
1035
+ noteFragmentsByPageInSection: Map<number, Array<Omit<RuntimeBlockFragment, "pageId">>>;
886
1036
  }
887
1037
 
888
1038
  /**
@@ -896,6 +1046,7 @@ function paginateSectionBlocks(
896
1046
  layout: DocumentPageSnapshot["layout"],
897
1047
  footnotes: FootnoteCollection | undefined,
898
1048
  measurementProvider?: LayoutMeasurementProvider,
1049
+ cache?: MeasurementCache,
899
1050
  ): Omit<DocumentPageSnapshot, "pageIndex">[] {
900
1051
  return paginateSectionBlocksWithSplits(
901
1052
  section,
@@ -903,15 +1054,17 @@ function paginateSectionBlocks(
903
1054
  layout,
904
1055
  footnotes,
905
1056
  measurementProvider,
1057
+ cache,
906
1058
  ).pages;
907
1059
  }
908
1060
 
909
- function paginateSectionBlocksWithSplits(
1061
+ export function paginateSectionBlocksWithSplits(
910
1062
  section: ResolvedDocumentSection,
911
1063
  blocks: readonly SurfaceBlockSnapshot[],
912
1064
  layout: DocumentPageSnapshot["layout"],
913
1065
  footnotes: FootnoteCollection | undefined,
914
1066
  measurementProvider?: LayoutMeasurementProvider,
1067
+ cache?: MeasurementCache,
915
1068
  ): SectionPaginationResult {
916
1069
  if (blocks.length === 0) {
917
1070
  return {
@@ -925,6 +1078,8 @@ function paginateSectionBlocksWithSplits(
925
1078
  },
926
1079
  ],
927
1080
  splits: { byBlockId: new Map() }, // section-local; global map includes tablesByBlockId via collectTableRowSlices
1081
+ noteAllocationsByPageInSection: new Map(),
1082
+ noteFragmentsByPageInSection: new Map(),
928
1083
  };
929
1084
  }
930
1085
 
@@ -939,15 +1094,109 @@ function paginateSectionBlocksWithSplits(
939
1094
  let pageInSection = 0;
940
1095
  let reservedNoteHeight = 0;
941
1096
  const reservedNotes = new Set<string>();
1097
+ // P6.c: per-table progress when a table is being split row-by-row
1098
+ // across pages. Map<blockId, nextRowIndexToPlace>. Cleared once a
1099
+ // table is fully placed.
1100
+ const tableProgress = new Map<string, number>();
1101
+
1102
+ // P8.1b — per-page note tracking.
1103
+ // `pendingNoteKeys` parallels `reservedNotes` but is only snapshotted on
1104
+ // page-push (finalization), NOT on column break.
1105
+ // `pendingNoteBlockFroms` records the referencing block's `from` offset
1106
+ // for each note key, enabling hit-test via `RuntimeBlockFragment.from`.
1107
+ const noteAllocationsByPageInSection = new Map<number, RuntimeNoteAllocation[]>();
1108
+ const noteFragmentsByPageInSection = new Map<
1109
+ number,
1110
+ Array<Omit<RuntimeBlockFragment, "pageId">>
1111
+ >();
1112
+ const pendingNoteKeys = new Set<string>();
1113
+ const pendingNoteBlockFroms = new Map<string, { blockFrom: number; blockTo: number }>();
1114
+ // Track the columnWidth at the time each note was accumulated so the
1115
+ // measurement is reproducible at page-close time.
1116
+ const pendingNoteColumnWidths = new Map<string, number>();
1117
+
1118
+ /**
1119
+ * Snapshot the pending note state into `noteAllocationsByPageInSection` and
1120
+ * `noteFragmentsByPageInSection` for the page that is about to close.
1121
+ * Must be called BEFORE clearing `pendingNoteKeys`.
1122
+ */
1123
+ const snapshotNoteAllocations = (closingPageInSection: number, columnWidth: number): void => {
1124
+ if (pendingNoteKeys.size === 0 || !footnotes) return;
1125
+
1126
+ const allocations: RuntimeNoteAllocation[] = [];
1127
+ const fragments: Array<Omit<RuntimeBlockFragment, "pageId">> = [];
1128
+ let orderInRegion = 0;
1129
+
1130
+ for (const noteKey of pendingNoteKeys) {
1131
+ const colonIdx = noteKey.indexOf(":");
1132
+ if (colonIdx === -1) continue;
1133
+ const noteKind = noteKey.slice(0, colonIdx) as "footnote" | "endnote";
1134
+ const noteId = noteKey.slice(colonIdx + 1);
1135
+
1136
+ // P8.1b: endnote allocations are deferred to P8 Task 7 (endnote area
1137
+ // component). The existing reservedNoteHeight math still reserves space
1138
+ // for endnotes, but we do not emit RuntimeNoteAllocation for them here.
1139
+ // See P8 plan, Task 7.
1140
+ if (noteKind === "endnote") continue;
1141
+
1142
+ const effectiveColumnWidth =
1143
+ pendingNoteColumnWidths.get(noteKey) ?? columnWidth;
1144
+ const { heightTwips } = measureNoteBody(
1145
+ noteKind,
1146
+ noteId,
1147
+ footnotes,
1148
+ effectiveColumnWidth,
1149
+ );
1150
+
1151
+ const fragmentId = `note-${closingPageInSection}-${noteKind}-${noteId}`;
1152
+ const refRange = pendingNoteBlockFroms.get(noteKey) ?? {
1153
+ blockFrom: pageStart,
1154
+ blockTo: pageStart + 1,
1155
+ };
1156
+
1157
+ const allocation: RuntimeNoteAllocation = {
1158
+ noteKind,
1159
+ noteId,
1160
+ reservedHeightTwips: heightTwips + FOOTNOTE_REFERENCE_RESERVATION_TWIPS,
1161
+ fragmentId,
1162
+ };
1163
+
1164
+ const fragment: Omit<RuntimeBlockFragment, "pageId"> = {
1165
+ fragmentId,
1166
+ blockId: `note-body-${noteKind}-${noteId}`,
1167
+ orderInRegion,
1168
+ regionKind: "footnote-area",
1169
+ from: refRange.blockFrom,
1170
+ to: refRange.blockTo,
1171
+ heightTwips: heightTwips + FOOTNOTE_REFERENCE_RESERVATION_TWIPS,
1172
+ kind: "whole",
1173
+ };
1174
+
1175
+ allocations.push(allocation);
1176
+ fragments.push(fragment);
1177
+ orderInRegion += 1;
1178
+ }
1179
+
1180
+ if (allocations.length > 0) {
1181
+ noteAllocationsByPageInSection.set(closingPageInSection, allocations);
1182
+ noteFragmentsByPageInSection.set(closingPageInSection, fragments);
1183
+ }
1184
+ };
942
1185
 
943
1186
  const pushPage = (endOffset: number): void => {
944
1187
  const boundedEnd = Math.max(pageStart, Math.min(endOffset, section.end));
945
1188
  if (boundedEnd === pageStart && pages.length > 0) {
946
1189
  return;
947
1190
  }
1191
+ const closingPageInSection = pageInSection;
1192
+ const columnWidth =
1193
+ columnMetrics[Math.min(columnIndex, columnMetrics.length - 1)]?.width ??
1194
+ getUsableColumnWidth(layout);
1195
+ // Snapshot note allocations for the page being closed BEFORE clearing state.
1196
+ snapshotNoteAllocations(closingPageInSection, columnWidth);
948
1197
  pages.push({
949
1198
  sectionIndex: section.index,
950
- pageInSection,
1199
+ pageInSection: closingPageInSection,
951
1200
  startOffset: pageStart,
952
1201
  endOffset: boundedEnd,
953
1202
  layout,
@@ -958,6 +1207,10 @@ function paginateSectionBlocksWithSplits(
958
1207
  columnIndex = 0;
959
1208
  reservedNoteHeight = 0;
960
1209
  reservedNotes.clear();
1210
+ // P8.1b: also clear pending note tracking on page finalization.
1211
+ pendingNoteKeys.clear();
1212
+ pendingNoteBlockFroms.clear();
1213
+ pendingNoteColumnWidths.clear();
961
1214
  };
962
1215
 
963
1216
  for (let index = 0; index < blocks.length; index += 1) {
@@ -967,13 +1220,13 @@ function paginateSectionBlocksWithSplits(
967
1220
  const columnWidth =
968
1221
  columnMetrics[Math.min(columnIndex, columnMetrics.length - 1)]?.width ??
969
1222
  getUsableColumnWidth(layout);
970
- const baseHeight = measureBlockHeight(block, columnWidth, measurementProvider);
1223
+ const baseHeight = measureBlockHeight(block, columnWidth, measurementProvider, cache);
971
1224
 
972
1225
  // keepNext: this paragraph must stay with the next one on the same page
973
1226
  const keepWithNextHeight =
974
1227
  block.kind === "paragraph" && block.keepNext
975
1228
  ? baseHeight +
976
- measureBlockHeight(blocks[index + 1], columnWidth, measurementProvider)
1229
+ measureBlockHeight(blocks[index + 1], columnWidth, measurementProvider, cache)
977
1230
  : baseHeight;
978
1231
 
979
1232
  // keepLines: the entire paragraph must fit on one page.
@@ -990,13 +1243,100 @@ function paginateSectionBlocksWithSplits(
990
1243
  continue;
991
1244
  }
992
1245
 
1246
+ // P6.c: row-boundary split for tables that overflow the remaining
1247
+ // page space (or that, on a fresh page, exceed one full page).
1248
+ // Replaces the generic overflow path for table blocks. Tables fall
1249
+ // into one of four cases:
1250
+ //
1251
+ // 1. Remainder fits → place atomically, clear progress, break.
1252
+ // 2. Remainder overflows AND splittable → place rows that fit,
1253
+ // push at the row-boundary offset, continue (next iteration
1254
+ // resumes from `splitRowIndex`).
1255
+ // 3. Remainder overflows AND can't split AND has prior content →
1256
+ // push the whole remainder to the next page.
1257
+ // 4. Remainder overflows AND can't split AND on fresh page →
1258
+ // degrade to atomic placement (visual overflow, but offset
1259
+ // ranges stay clean — same as pre-P6.c behavior).
1260
+ if (block.kind === "table") {
1261
+ const startRow = tableProgress.get(block.blockId) ?? 0;
1262
+ const remainingForTable =
1263
+ usableHeight - columnHeight - reservedNoteHeight;
1264
+ const rowHeights = measureTableRowHeights({
1265
+ block,
1266
+ columnWidth,
1267
+ measurementProvider,
1268
+ });
1269
+ const { cantSplitFlags, isHeaderFlags } = extractRowFlags(block);
1270
+ const repeatedHeaderHeightTwips = computeRepeatedHeaderHeight(
1271
+ rowHeights,
1272
+ isHeaderFlags,
1273
+ );
1274
+ const headerReservation =
1275
+ startRow > 0 ? repeatedHeaderHeightTwips : 0;
1276
+ let remainderHeight = headerReservation;
1277
+ for (let r = startRow; r < rowHeights.length; r += 1) {
1278
+ remainderHeight += rowHeights[r] ?? 0;
1279
+ }
1280
+
1281
+ // Helper: best-effort offset for the start of row K. Falls back
1282
+ // to the table block's `from` when the row has no inner block.
1283
+ const rowOffset = (rowIndex: number): number => {
1284
+ const row = block.rows[rowIndex];
1285
+ const firstChild = row?.cells[0]?.content[0];
1286
+ return firstChild?.from ?? block.from;
1287
+ };
1288
+
1289
+ // Case 1: remainder fits — place and break.
1290
+ if (remainderHeight <= remainingForTable) {
1291
+ columnHeight += startRow > 0 ? remainderHeight : baseHeight;
1292
+ if (startRow > 0) tableProgress.delete(block.blockId);
1293
+ if (index === blocks.length - 1) pushPage(section.end);
1294
+ break;
1295
+ }
1296
+
1297
+ // Case 2: try a row-boundary split.
1298
+ const decision = findTableRowSplit({
1299
+ rowHeights,
1300
+ cantSplitFlags,
1301
+ isHeaderFlags,
1302
+ remainingHeightTwips: remainingForTable,
1303
+ repeatedHeaderHeightTwips,
1304
+ startRow,
1305
+ });
1306
+ if (decision.rowsOnCurrentPage > 0) {
1307
+ tableProgress.set(block.blockId, decision.splitRowIndex);
1308
+ pushPage(rowOffset(decision.splitRowIndex));
1309
+ continue;
1310
+ }
1311
+
1312
+ // Case 3: can't split here. If there's content above (or we're
1313
+ // resuming), push everything from the resume point to the next
1314
+ // page so the next iteration starts fresh.
1315
+ if (columnHeight > 0 || startRow > 0) {
1316
+ pushPage(startRow > 0 ? rowOffset(startRow) : block.from);
1317
+ continue;
1318
+ }
1319
+
1320
+ // Case 4: degraded atomic placement (table on a fresh page that
1321
+ // it doesn't fit on, AND the first row alone exceeds page
1322
+ // height). Preserve pre-P6.c semantics so offsets stay clean.
1323
+ columnHeight += baseHeight;
1324
+ if (index === blocks.length - 1) pushPage(section.end);
1325
+ break;
1326
+ }
1327
+
993
1328
  // Overflow check — paragraph doesn't fit on current page
994
1329
  if (projectedHeight > usableHeight && pageStart < block.from) {
995
1330
  if (columnIndex < maxColumns - 1) {
1331
+ // Advance to next column without a page break — do NOT snapshot.
996
1332
  columnIndex += 1;
997
1333
  columnHeight = 0;
998
1334
  reservedNoteHeight = 0;
999
1335
  reservedNotes.clear();
1336
+ // P8.1b: clear pending note state WITHOUT snapshotting.
1337
+ pendingNoteKeys.clear();
1338
+ pendingNoteBlockFroms.clear();
1339
+ pendingNoteColumnWidths.clear();
1000
1340
  continue;
1001
1341
  }
1002
1342
 
@@ -1017,12 +1357,18 @@ function paginateSectionBlocksWithSplits(
1017
1357
  !block.keepNext
1018
1358
  ) {
1019
1359
  const availableHeight = usableHeight - columnHeight - reservedNoteHeight;
1020
- const totalLines = measureParagraphLineCount(
1021
- block,
1022
- formatting,
1023
- columnWidth,
1024
- measurementProvider,
1025
- );
1360
+ const cachedLineCount = cache?.getLineCount(block, columnWidth);
1361
+ const totalLines =
1362
+ cachedLineCount ??
1363
+ measureParagraphLineCount(
1364
+ block,
1365
+ formatting,
1366
+ columnWidth,
1367
+ measurementProvider,
1368
+ );
1369
+ if (cachedLineCount === undefined) {
1370
+ cache?.setLineCount(block, columnWidth, totalLines);
1371
+ }
1026
1372
  const availableLines =
1027
1373
  formatting.lineHeight > 0
1028
1374
  ? Math.max(0, Math.floor(availableHeight / formatting.lineHeight))
@@ -1068,10 +1414,15 @@ function paginateSectionBlocksWithSplits(
1068
1414
  // span the full page if it's truly larger than a page).
1069
1415
  if (keepLinesActive && columnHeight > 0 && baseHeight > usableHeight - columnHeight && pageStart < block.from) {
1070
1416
  if (columnIndex < maxColumns - 1) {
1417
+ // Column advance without page break — do NOT snapshot.
1071
1418
  columnIndex += 1;
1072
1419
  columnHeight = 0;
1073
1420
  reservedNoteHeight = 0;
1074
1421
  reservedNotes.clear();
1422
+ // P8.1b: clear pending note state WITHOUT snapshotting.
1423
+ pendingNoteKeys.clear();
1424
+ pendingNoteBlockFroms.clear();
1425
+ pendingNoteColumnWidths.clear();
1075
1426
  continue;
1076
1427
  }
1077
1428
  pushPage(block.from);
@@ -1086,14 +1437,32 @@ function paginateSectionBlocksWithSplits(
1086
1437
  );
1087
1438
  columnHeight += baseHeight;
1088
1439
  reservedNoteHeight += effectiveNoteHeight;
1089
- currentPageNoteIds(block).forEach((noteKey) => reservedNotes.add(noteKey));
1440
+ currentPageNoteIds(block).forEach((noteKey) => {
1441
+ reservedNotes.add(noteKey);
1442
+ // P8.1b: also track the referencing block range for hit-test.
1443
+ // Only record the first reference (earliest block.from) per noteKey
1444
+ // so the fragment's from/to points to the paragraph that introduced it.
1445
+ if (!pendingNoteKeys.has(noteKey)) {
1446
+ pendingNoteKeys.add(noteKey);
1447
+ pendingNoteBlockFroms.set(noteKey, { blockFrom: block.from, blockTo: block.to });
1448
+ pendingNoteColumnWidths.set(noteKey, columnWidth);
1449
+ }
1450
+ });
1090
1451
 
1091
1452
  if (hasColumnBreak(block)) {
1092
1453
  if (columnIndex < maxColumns - 1) {
1454
+ // Column break within a multi-column layout: advance to next column.
1455
+ // DO NOT snapshot note allocations — only page-push triggers snapshotting.
1456
+ // Clear pending note state alongside reservedNotes so notes that only
1457
+ // appeared before the column break don't get double-counted.
1093
1458
  columnIndex += 1;
1094
1459
  columnHeight = 0;
1095
1460
  reservedNoteHeight = 0;
1096
1461
  reservedNotes.clear();
1462
+ // P8.1b: clear pending note state WITHOUT snapshotting.
1463
+ pendingNoteKeys.clear();
1464
+ pendingNoteBlockFroms.clear();
1465
+ pendingNoteColumnWidths.clear();
1097
1466
  } else {
1098
1467
  pushPage(nextBoundary);
1099
1468
  }
@@ -1121,9 +1490,37 @@ function paginateSectionBlocksWithSplits(
1121
1490
  },
1122
1491
  ],
1123
1492
  splits: { byBlockId: splitsByBlock },
1493
+ noteAllocationsByPageInSection,
1494
+ noteFragmentsByPageInSection,
1124
1495
  };
1125
1496
  }
1126
1497
 
1498
+ /**
1499
+ * Measure the height consumed by one note's body blocks, plus return those
1500
+ * blocks for use in building a `RuntimeBlockFragment`.
1501
+ *
1502
+ * Factored out of `estimateFootnoteReservation` so both the reservation-math
1503
+ * path and the P8.1b allocation-emission path share the same measurement.
1504
+ */
1505
+ function measureNoteBody(
1506
+ noteKind: "footnote" | "endnote",
1507
+ noteId: string,
1508
+ footnotes: FootnoteCollection,
1509
+ columnWidth: number,
1510
+ ): { heightTwips: number } {
1511
+ const noteCollection =
1512
+ noteKind === "endnote" ? footnotes.endnotes : footnotes.footnotes;
1513
+ const note = noteCollection[noteId];
1514
+ if (!note) {
1515
+ return { heightTwips: 0 };
1516
+ }
1517
+ const heightTwips = note.blocks.reduce(
1518
+ (total, child) => total + estimateCanonicalNoteBlockHeight(child, columnWidth),
1519
+ 0,
1520
+ );
1521
+ return { heightTwips };
1522
+ }
1523
+
1127
1524
  function estimateFootnoteReservation(
1128
1525
  block: SurfaceBlockSnapshot,
1129
1526
  footnotes: FootnoteCollection | undefined,
@@ -1140,17 +1537,13 @@ function estimateFootnoteReservation(
1140
1537
  continue;
1141
1538
  }
1142
1539
 
1143
- const [noteKind, noteId] = noteKey.split(":");
1144
- const noteCollection =
1145
- noteKind === "endnote" ? footnotes.endnotes : footnotes.footnotes;
1146
- const note = noteCollection[noteId];
1147
- reservation += FOOTNOTE_REFERENCE_RESERVATION_TWIPS;
1148
- if (note) {
1149
- reservation += note.blocks.reduce(
1150
- (total, child) => total + estimateCanonicalNoteBlockHeight(child, columnWidth),
1151
- 0,
1152
- );
1153
- }
1540
+ const colonIdx = noteKey.indexOf(":");
1541
+ if (colonIdx === -1) continue;
1542
+ const noteKind = noteKey.slice(0, colonIdx) as "footnote" | "endnote";
1543
+ const noteId = noteKey.slice(colonIdx + 1);
1544
+ // Use measureNoteBody so reservation math and emission share the same path.
1545
+ const { heightTwips } = measureNoteBody(noteKind, noteId, footnotes, columnWidth);
1546
+ reservation += FOOTNOTE_REFERENCE_RESERVATION_TWIPS + heightTwips;
1154
1547
  }
1155
1548
 
1156
1549
  return reservation;