@beyondwork/docx-react-component 1.0.37 → 1.0.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +319 -1
  3. package/src/core/commands/section-layout-commands.ts +58 -0
  4. package/src/core/commands/table-grid.ts +431 -0
  5. package/src/core/commands/table-structure-commands.ts +815 -55
  6. package/src/io/export/serialize-main-document.ts +2 -11
  7. package/src/io/export/serialize-numbering.ts +1 -2
  8. package/src/io/export/serialize-tables.ts +74 -0
  9. package/src/io/export/table-properties-xml.ts +139 -4
  10. package/src/io/normalize/normalize-text.ts +15 -0
  11. package/src/io/ooxml/parse-footnotes.ts +60 -0
  12. package/src/io/ooxml/parse-headers-footers.ts +60 -0
  13. package/src/io/ooxml/parse-main-document.ts +137 -0
  14. package/src/io/ooxml/parse-tables.ts +249 -0
  15. package/src/model/canonical-document.ts +34 -0
  16. package/src/runtime/document-layout.ts +4 -2
  17. package/src/runtime/document-navigation.ts +1 -1
  18. package/src/runtime/document-runtime.ts +114 -0
  19. package/src/runtime/layout/default-page-format.ts +96 -0
  20. package/src/runtime/layout/index.ts +45 -0
  21. package/src/runtime/layout/inert-layout-facet.ts +14 -0
  22. package/src/runtime/layout/layout-engine-instance.ts +33 -23
  23. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  24. package/src/runtime/layout/page-format-catalog.ts +233 -0
  25. package/src/runtime/layout/page-graph.ts +19 -0
  26. package/src/runtime/layout/paginated-layout-engine.ts +142 -9
  27. package/src/runtime/layout/project-block-fragments.ts +91 -0
  28. package/src/runtime/layout/public-facet.ts +709 -16
  29. package/src/runtime/layout/table-render-plan.ts +229 -0
  30. package/src/runtime/render/block-fragment-projection.ts +35 -0
  31. package/src/runtime/render/decoration-resolver.ts +189 -0
  32. package/src/runtime/render/index.ts +57 -0
  33. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  34. package/src/runtime/render/render-frame-types.ts +317 -0
  35. package/src/runtime/render/render-kernel.ts +755 -0
  36. package/src/runtime/view-state.ts +67 -0
  37. package/src/runtime/workflow-markup.ts +1 -5
  38. package/src/runtime/workflow-rail-segments.ts +280 -0
  39. package/src/ui/WordReviewEditor.tsx +84 -15
  40. package/src/ui/editor-shell-view.tsx +6 -0
  41. package/src/ui/headless/chrome-registry.ts +280 -14
  42. package/src/ui/headless/scoped-chrome-policy.ts +20 -1
  43. package/src/ui/headless/selection-tool-types.ts +10 -0
  44. package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
  45. package/src/ui-tailwind/chrome/role-action-sets.ts +74 -0
  46. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  47. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +163 -0
  48. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +57 -92
  49. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  50. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  51. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +274 -138
  52. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +90 -0
  53. package/src/ui-tailwind/chrome-overlay/index.ts +22 -0
  54. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +86 -0
  55. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  56. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +95 -0
  57. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +4 -0
  58. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +11 -0
  59. package/src/ui-tailwind/editor-surface/perf-probe.ts +7 -1
  60. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +12 -1
  61. package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
  62. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +3 -0
  63. package/src/ui-tailwind/index.ts +33 -0
  64. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  65. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  66. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  67. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  68. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  69. package/src/ui-tailwind/theme/editor-theme.css +498 -163
  70. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +559 -0
  71. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  72. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  73. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +69 -0
  74. package/src/ui-tailwind/tw-review-workspace.tsx +136 -1
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Page-format catalog — named page sizes that back the section page-size
3
+ * picker and the real-dimension page frame.
4
+ *
5
+ * The canonical storage of page geometry remains
6
+ * `SectionProperties.pageSize` in twips. This catalog adds a layer of
7
+ * semantic naming on top so the UI can render "A4" or "US Letter" and
8
+ * the render kernel can pick a sensible default per locale.
9
+ *
10
+ * Unit reference (OOXML):
11
+ * - 1 inch = 1440 twips
12
+ * - 1 mm = 56.6929 twips
13
+ *
14
+ * The catalog deliberately ships a single default match tolerance (1 twip)
15
+ * so legacy documents whose page sizes were round-tripped through third-
16
+ * party tools still identify as the named format they were authored at.
17
+ */
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Public types
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export type PageFormatId =
24
+ | "letter"
25
+ | "legal"
26
+ | "tabloid"
27
+ | "executive"
28
+ | "a3"
29
+ | "a4"
30
+ | "a5"
31
+ | "b4-iso"
32
+ | "b5-iso"
33
+ | "custom";
34
+
35
+ export type PageFormatRegion = "us" | "iso" | "jp" | "custom";
36
+
37
+ export type PageFormatLocaleDefault = "en-us" | "en-gb" | "eu" | "jp";
38
+
39
+ export interface PageFormatDisplay {
40
+ inches?: string;
41
+ millimeters?: string;
42
+ }
43
+
44
+ export interface PageFormatDefinition {
45
+ /** Stable identifier. */
46
+ id: PageFormatId;
47
+ /** Human-facing label used in pickers. */
48
+ label: string;
49
+ /** Locale or region hint. */
50
+ region: PageFormatRegion;
51
+ /** Width in twips at portrait orientation. */
52
+ portraitWidthTwips: number;
53
+ /** Height in twips at portrait orientation. */
54
+ portraitHeightTwips: number;
55
+ /** Default-for-locale hint so consumers can pick a sensible default. */
56
+ localeDefault?: PageFormatLocaleDefault;
57
+ /** Pre-computed inches/millimeters strings for the picker. */
58
+ display: PageFormatDisplay;
59
+ }
60
+
61
+ export interface ActivePageFormat {
62
+ sectionIndex: number;
63
+ format: PageFormatDefinition;
64
+ orientation: "portrait" | "landscape";
65
+ /** True when the section matches a catalog format within the match tolerance. */
66
+ matchesCatalog: boolean;
67
+ /** When matchesCatalog is false, the raw twips (custom format). */
68
+ customTwips?: { width: number; height: number };
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Catalog
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * Pre-defined named page formats. Ordering intentionally puts the most
77
+ * common sizes (Letter, A4) first because UI dropdowns render in list order.
78
+ */
79
+ export const PAGE_FORMAT_CATALOG: readonly PageFormatDefinition[] = Object.freeze([
80
+ {
81
+ id: "letter",
82
+ label: "US Letter",
83
+ region: "us",
84
+ portraitWidthTwips: 12240,
85
+ portraitHeightTwips: 15840,
86
+ localeDefault: "en-us",
87
+ display: { inches: "8.5 × 11 in", millimeters: "215.9 × 279.4 mm" },
88
+ },
89
+ {
90
+ id: "a4",
91
+ label: "A4",
92
+ region: "iso",
93
+ portraitWidthTwips: 11906,
94
+ portraitHeightTwips: 16838,
95
+ localeDefault: "eu",
96
+ display: { inches: "8.27 × 11.69 in", millimeters: "210 × 297 mm" },
97
+ },
98
+ {
99
+ id: "legal",
100
+ label: "US Legal",
101
+ region: "us",
102
+ portraitWidthTwips: 12240,
103
+ portraitHeightTwips: 20160,
104
+ display: { inches: "8.5 × 14 in", millimeters: "215.9 × 355.6 mm" },
105
+ },
106
+ {
107
+ id: "tabloid",
108
+ label: "Tabloid / Ledger",
109
+ region: "us",
110
+ portraitWidthTwips: 15840,
111
+ portraitHeightTwips: 24480,
112
+ display: { inches: "11 × 17 in", millimeters: "279.4 × 431.8 mm" },
113
+ },
114
+ {
115
+ id: "executive",
116
+ label: "Executive",
117
+ region: "us",
118
+ portraitWidthTwips: 10440,
119
+ portraitHeightTwips: 15120,
120
+ display: { inches: "7.25 × 10.5 in", millimeters: "184.1 × 266.7 mm" },
121
+ },
122
+ {
123
+ id: "a3",
124
+ label: "A3",
125
+ region: "iso",
126
+ portraitWidthTwips: 16838,
127
+ portraitHeightTwips: 23811,
128
+ display: { inches: "11.69 × 16.54 in", millimeters: "297 × 420 mm" },
129
+ },
130
+ {
131
+ id: "a5",
132
+ label: "A5",
133
+ region: "iso",
134
+ portraitWidthTwips: 8391,
135
+ portraitHeightTwips: 11906,
136
+ display: { inches: "5.83 × 8.27 in", millimeters: "148 × 210 mm" },
137
+ },
138
+ {
139
+ id: "b4-iso",
140
+ label: "B4 (ISO)",
141
+ region: "iso",
142
+ portraitWidthTwips: 14173,
143
+ portraitHeightTwips: 20016,
144
+ display: { inches: "9.84 × 13.90 in", millimeters: "250 × 353 mm" },
145
+ },
146
+ {
147
+ id: "b5-iso",
148
+ label: "B5 (ISO)",
149
+ region: "iso",
150
+ portraitWidthTwips: 9977,
151
+ portraitHeightTwips: 14173,
152
+ display: { inches: "6.93 × 9.84 in", millimeters: "176 × 250 mm" },
153
+ },
154
+ {
155
+ id: "custom",
156
+ label: "Custom",
157
+ region: "custom",
158
+ portraitWidthTwips: 0,
159
+ portraitHeightTwips: 0,
160
+ display: {},
161
+ },
162
+ ]);
163
+
164
+ /**
165
+ * Match tolerance in twips. Historic round-trips through third-party tools
166
+ * sometimes drift by sub-twip amounts; a 1-twip window catches those while
167
+ * keeping intentionally custom sizes genuinely custom.
168
+ */
169
+ const MATCH_TOLERANCE_TWIPS = 1;
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Matching
173
+ // ---------------------------------------------------------------------------
174
+
175
+ export interface MatchPageFormatInput {
176
+ sectionIndex: number;
177
+ widthTwips: number;
178
+ heightTwips: number;
179
+ }
180
+
181
+ /**
182
+ * Match a section's page dimensions against the catalog.
183
+ *
184
+ * The matcher tolerates either orientation — if the supplied (width, height)
185
+ * match a catalog entry rotated by 90°, the active format is reported as
186
+ * `landscape`. Custom sizes fall through to the `custom` entry.
187
+ */
188
+ export function matchPageFormat(input: MatchPageFormatInput): ActivePageFormat {
189
+ const { sectionIndex, widthTwips, heightTwips } = input;
190
+
191
+ for (const format of PAGE_FORMAT_CATALOG) {
192
+ if (format.id === "custom") continue;
193
+
194
+ const portraitMatch =
195
+ near(widthTwips, format.portraitWidthTwips) &&
196
+ near(heightTwips, format.portraitHeightTwips);
197
+ const landscapeMatch =
198
+ near(widthTwips, format.portraitHeightTwips) &&
199
+ near(heightTwips, format.portraitWidthTwips);
200
+
201
+ if (portraitMatch || landscapeMatch) {
202
+ return {
203
+ sectionIndex,
204
+ format,
205
+ orientation: portraitMatch ? "portrait" : "landscape",
206
+ matchesCatalog: true,
207
+ };
208
+ }
209
+ }
210
+
211
+ const custom = PAGE_FORMAT_CATALOG.find((f) => f.id === "custom")!;
212
+ return {
213
+ sectionIndex,
214
+ format: custom,
215
+ orientation: widthTwips > heightTwips ? "landscape" : "portrait",
216
+ matchesCatalog: false,
217
+ customTwips: { width: widthTwips, height: heightTwips },
218
+ };
219
+ }
220
+
221
+ function near(a: number, b: number): boolean {
222
+ return Math.abs(a - b) <= MATCH_TOLERANCE_TWIPS;
223
+ }
224
+
225
+ /**
226
+ * Look up a format by id. Returns the `custom` entry for unknown ids so
227
+ * callers can chain `.format` reads without null-checking.
228
+ */
229
+ export function getPageFormatById(id: string): PageFormatDefinition {
230
+ const found = PAGE_FORMAT_CATALOG.find((f) => f.id === id);
231
+ if (found) return found;
232
+ return PAGE_FORMAT_CATALOG.find((f) => f.id === "custom")!;
233
+ }
@@ -148,6 +148,16 @@ export interface BuildPageGraphInput {
148
148
  /** Optional block fragments pre-computed by pagination; when omitted the
149
149
  * graph produces one fragment per page spanning its entire offset range. */
150
150
  fragments?: readonly RuntimeBlockFragment[];
151
+ /**
152
+ * Optional block fragments keyed by pageIndex with the `pageId` omitted.
153
+ * `buildPageGraph` fills in the pageId using the graph's fresh revision
154
+ * stamp. Use this when the caller wants to emit per-block fragments but
155
+ * cannot know the pageId in advance.
156
+ */
157
+ fragmentsByPageIndex?: ReadonlyMap<
158
+ number,
159
+ ReadonlyArray<Omit<RuntimeBlockFragment, "pageId">>
160
+ >;
151
161
  /** Optional per-page line boxes. */
152
162
  lineBoxes?: ReadonlyMap<string, RuntimeLineBox[]>;
153
163
  /** Optional per-page note allocations. */
@@ -177,6 +187,15 @@ export function buildPageGraph(
177
187
 
178
188
  const pages: RuntimePageNode[] = [];
179
189
  const aggregatedFragments: RuntimeBlockFragment[] = [...(input.fragments ?? [])];
190
+ // Rehydrate fragmentsByPageIndex with the fresh graphRevision's pageIds.
191
+ if (input.fragmentsByPageIndex) {
192
+ for (const [pageIndex, fragments] of input.fragmentsByPageIndex) {
193
+ const pageId = `page-${graphRevision}-${pageIndex}`;
194
+ for (const fragment of fragments) {
195
+ aggregatedFragments.push({ ...fragment, pageId });
196
+ }
197
+ }
198
+ }
180
199
  const anchors: RuntimePageAnchor[] = [];
181
200
 
182
201
  for (let index = 0; index < input.pages.length; index += 1) {
@@ -61,6 +61,7 @@ import {
61
61
  resolveTextWidth,
62
62
  } from "./resolved-formatting-state.ts";
63
63
  import { analyzeInvalidation as analyzeInvalidationFn } from "./layout-invalidation.ts";
64
+ import type { LayoutMeasurementProvider } from "./layout-measurement-provider.ts";
64
65
 
65
66
  // ---------------------------------------------------------------------------
66
67
  // Types
@@ -90,11 +91,18 @@ export interface PageStackResult {
90
91
  * This is the single entry point for page composition. All consumers
91
92
  * (document-navigation, view-state, page mode) should call this instead
92
93
  * of directly using the estimation helpers.
94
+ *
95
+ * `measurementProvider` is optional. When supplied, paragraph line counts
96
+ * and block heights consult the provider's `measureLineFragments` so the
97
+ * canvas backend can participate. When omitted, measurement falls through
98
+ * to the empirical path baked into this module — which matches the
99
+ * provider's empirical backend numerically, keeping pagination stable.
93
100
  */
94
101
  export function buildPageStack(
95
102
  document: CanonicalDocumentEnvelope,
96
103
  sections: ResolvedDocumentSection[],
97
104
  mainSurface: EditorSurfaceSnapshot,
105
+ measurementProvider?: LayoutMeasurementProvider,
98
106
  ): DocumentPageSnapshot[] {
99
107
  const pages: DocumentPageSnapshot[] = [];
100
108
  let globalPageIndex = 0;
@@ -154,6 +162,7 @@ export function buildPageStack(
154
162
  sectionBlocks,
155
163
  layout,
156
164
  document.subParts?.footnoteCollection,
165
+ measurementProvider,
157
166
  );
158
167
 
159
168
  // continuous / nextColumn: merge the first page of this section into the
@@ -228,6 +237,7 @@ export function buildPageStackFrom(
228
237
  sections: readonly ResolvedDocumentSection[],
229
238
  mainSurface: EditorSurfaceSnapshot,
230
239
  resumeAt: { startPageIndex: number; startOffset: number },
240
+ measurementProvider?: LayoutMeasurementProvider,
231
241
  ): DocumentPageSnapshot[] {
232
242
  // Correctness-first: run the full pipeline and return pages from the
233
243
  // requested start. `startOffset` is accepted for forward compatibility
@@ -237,6 +247,7 @@ export function buildPageStackFrom(
237
247
  document,
238
248
  sections as ResolvedDocumentSection[],
239
249
  mainSurface,
250
+ measurementProvider,
240
251
  );
241
252
  const startIndex = Math.max(0, resumeAt.startPageIndex);
242
253
  return full.slice(startIndex);
@@ -342,10 +353,15 @@ const FOOTNOTE_REFERENCE_RESERVATION_TWIPS = 180;
342
353
  /**
343
354
  * Compute block height using resolved formatting when available.
344
355
  * Uses improved table measurement for legal contracts.
356
+ *
357
+ * When `measurementProvider` is supplied, paragraph line counts are produced
358
+ * by `provider.measureLineFragments(...)`; otherwise the inline empirical
359
+ * path runs (which matches the empirical backend numerically).
345
360
  */
346
361
  function measureBlockHeight(
347
362
  block: SurfaceBlockSnapshot | undefined,
348
363
  columnWidth: number,
364
+ measurementProvider?: LayoutMeasurementProvider,
349
365
  ): number {
350
366
  if (!block) return 0;
351
367
 
@@ -353,17 +369,26 @@ function measureBlockHeight(
353
369
  case "paragraph": {
354
370
  const formatting = resolveBlockFormatting(block);
355
371
  if (formatting) {
356
- const lineCount = measureParagraphLineCount(block, formatting, columnWidth);
372
+ const lineCount = measureParagraphLineCount(
373
+ block,
374
+ formatting,
375
+ columnWidth,
376
+ measurementProvider,
377
+ );
357
378
  return calculateParagraphHeight(formatting, lineCount);
358
379
  }
359
380
  return estimateBlockHeight(block, columnWidth);
360
381
  }
361
382
  case "table":
362
- return measureTableHeight(block, columnWidth);
383
+ return measureTableHeight(block, columnWidth, measurementProvider);
363
384
  case "sdt_block":
364
385
  return Math.max(
365
386
  MIN_BLOCK_HEIGHT_TWIPS,
366
- block.children.reduce((total, child) => total + measureBlockHeight(child, columnWidth), 0),
387
+ block.children.reduce(
388
+ (total, child) =>
389
+ total + measureBlockHeight(child, columnWidth, measurementProvider),
390
+ 0,
391
+ ),
367
392
  );
368
393
  case "opaque_block":
369
394
  return block.label === "Section break" ? 0 : MIN_BLOCK_HEIGHT_TWIPS;
@@ -372,33 +397,76 @@ function measureBlockHeight(
372
397
 
373
398
  /**
374
399
  * Improved table height estimation.
400
+ *
375
401
  * Uses resolved formatting for cell content paragraphs and respects
376
402
  * explicit row heights and height rules.
403
+ *
404
+ * Per-cell width is derived from the table's `gridColumns` and each
405
+ * cell's `colspan` (honoring `gridBefore`/`gridAfter` row padding).
406
+ * This replaces the prior `columnWidth / cellCount` approximation,
407
+ * which was wrong whenever columns carried non-uniform widths or any
408
+ * cell had `colspan > 1`.
377
409
  */
378
410
  function measureTableHeight(
379
411
  block: Extract<SurfaceBlockSnapshot, { kind: "table" }>,
380
412
  columnWidth: number,
413
+ measurementProvider?: LayoutMeasurementProvider,
381
414
  ): number {
382
415
  const TABLE_ROW_PADDING_TWIPS = 120;
383
416
  let totalHeight = 0;
384
417
 
418
+ const gridColumnCount = block.gridColumns.length;
419
+ const totalGridTwips = block.gridColumns.reduce((sum, w) => sum + w, 0);
420
+ // Scale the canonical gridColumns to the available column width so that
421
+ // a table defined in 9000-twip grid on a 12240-twip canvas measures
422
+ // against the actual canvas width, not the OOXML-declared width.
423
+ const gridScale =
424
+ totalGridTwips > 0 && columnWidth > 0 ? columnWidth / totalGridTwips : 1;
425
+
385
426
  for (const row of block.rows) {
386
427
  const explicitHeight = row.height ?? 0;
387
428
  const heightRule = row.heightRule ?? "auto";
429
+ const gridBefore = row.gridBefore ?? 0;
388
430
 
389
- // Calculate content-driven height
431
+ // Calculate content-driven height using real per-cell widths.
390
432
  let contentHeight = MIN_BLOCK_HEIGHT_TWIPS;
391
- const cellCount = Math.max(1, row.cells.length);
392
- const cellWidth = Math.max(720, Math.floor(columnWidth / cellCount));
433
+ let columnCursor = gridBefore;
393
434
 
394
435
  for (const cell of row.cells) {
436
+ const span = Math.max(1, cell.colspan ?? 1);
437
+ const cellWidth = resolveCellWidth(
438
+ block.gridColumns,
439
+ columnCursor,
440
+ span,
441
+ columnWidth,
442
+ gridScale,
443
+ );
444
+ columnCursor += span;
445
+
446
+ if (cell.verticalMerge === "continue") {
447
+ // Continuation cells don't contribute their own content height —
448
+ // the origin cell's height covers the whole span.
449
+ continue;
450
+ }
451
+
395
452
  let cellContentHeight = 0;
396
453
  for (const child of cell.content) {
397
- cellContentHeight += measureBlockHeight(child, cellWidth);
454
+ cellContentHeight += measureBlockHeight(
455
+ child,
456
+ cellWidth,
457
+ measurementProvider,
458
+ );
398
459
  }
399
460
  contentHeight = Math.max(contentHeight, cellContentHeight + TABLE_ROW_PADDING_TWIPS);
400
461
  }
401
462
 
463
+ // Sanity fallback if the row declared more columns than the grid
464
+ // (malformed input) — clamp the cursor back so subsequent rows
465
+ // continue to measure without throwing.
466
+ if (gridColumnCount > 0 && columnCursor > gridColumnCount) {
467
+ // no-op; kept for documentation — width resolution handles overflow.
468
+ }
469
+
402
470
  if (heightRule === "exact" && explicitHeight > 0) {
403
471
  totalHeight += explicitHeight;
404
472
  } else if (heightRule === "atLeast" && explicitHeight > 0) {
@@ -413,15 +481,78 @@ function measureTableHeight(
413
481
  return Math.max(MIN_BLOCK_HEIGHT_TWIPS, totalHeight);
414
482
  }
415
483
 
484
+ /**
485
+ * Sum the widths of `columnSpan` columns starting at `startColumn` from
486
+ * the table's `gridColumns`, scaled to the available column width.
487
+ *
488
+ * Falls back to an even split of `fallbackColumnWidth` when the grid has
489
+ * no entries (no `gridColumns` declared).
490
+ *
491
+ * Exported via `__resolveCellWidth` for unit tests; not part of the
492
+ * stable surface.
493
+ */
494
+ export function __resolveCellWidth(
495
+ gridColumns: readonly number[],
496
+ startColumn: number,
497
+ columnSpan: number,
498
+ fallbackColumnWidth: number,
499
+ gridScale: number,
500
+ ): number {
501
+ return resolveCellWidth(gridColumns, startColumn, columnSpan, fallbackColumnWidth, gridScale);
502
+ }
503
+
504
+ function resolveCellWidth(
505
+ gridColumns: readonly number[],
506
+ startColumn: number,
507
+ columnSpan: number,
508
+ fallbackColumnWidth: number,
509
+ gridScale: number,
510
+ ): number {
511
+ if (gridColumns.length === 0) {
512
+ // No grid declared — best-effort even split of the canvas.
513
+ return Math.max(240, Math.floor(fallbackColumnWidth));
514
+ }
515
+ let gridWidth = 0;
516
+ for (let i = 0; i < columnSpan; i += 1) {
517
+ const column = startColumn + i;
518
+ if (column < 0 || column >= gridColumns.length) continue;
519
+ gridWidth += gridColumns[column] ?? 0;
520
+ }
521
+ const scaled = Math.floor(gridWidth * gridScale);
522
+ return Math.max(240, scaled);
523
+ }
524
+
416
525
  /**
417
526
  * Count lines in a paragraph using resolved formatting.
418
527
  * Accounts for proper indentation, font metrics, and numbering geometry.
528
+ *
529
+ * When a measurement provider is supplied, delegates line counting to the
530
+ * provider's `measureLineFragments`. The provider's empirical backend
531
+ * returns the same numerical result as the inline path, so switching does
532
+ * not change pagination behavior; the canvas backend returns canvas-
533
+ * measured line counts once fonts resolve.
419
534
  */
420
535
  function measureParagraphLineCount(
421
536
  block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
422
537
  formatting: import("./resolved-formatting-state.ts").ResolvedParagraphFormatting,
423
538
  columnWidth: number,
539
+ measurementProvider?: LayoutMeasurementProvider,
424
540
  ): number {
541
+ if (measurementProvider) {
542
+ const measured = measurementProvider.measureLineFragments({
543
+ block,
544
+ formatting,
545
+ // The paginated pipeline currently resolves formatting at the
546
+ // paragraph level only; per-run formatting is not yet threaded
547
+ // through. Pass an empty map; the provider's empirical backend
548
+ // does not consult per-run metrics and the canvas backend falls
549
+ // back to the paragraph defaults when a run is missing.
550
+ runs: new Map(),
551
+ columnWidth,
552
+ });
553
+ return Math.max(1, measured.lineCount);
554
+ }
555
+
425
556
  const firstLineWidth = resolveTextWidth(formatting, columnWidth, true);
426
557
  const subsequentLineWidth = resolveTextWidth(formatting, columnWidth, false);
427
558
  const firstLineCapacity = resolveCharsPerLine(firstLineWidth, formatting.averageCharWidthTwips);
@@ -534,6 +665,7 @@ function paginateSectionBlocks(
534
665
  blocks: readonly SurfaceBlockSnapshot[],
535
666
  layout: DocumentPageSnapshot["layout"],
536
667
  footnotes: FootnoteCollection | undefined,
668
+ measurementProvider?: LayoutMeasurementProvider,
537
669
  ): Omit<DocumentPageSnapshot, "pageIndex">[] {
538
670
  if (blocks.length === 0) {
539
671
  return [
@@ -585,12 +717,13 @@ function paginateSectionBlocks(
585
717
  const columnWidth =
586
718
  columnMetrics[Math.min(columnIndex, columnMetrics.length - 1)]?.width ??
587
719
  getUsableColumnWidth(layout);
588
- const baseHeight = measureBlockHeight(block, columnWidth);
720
+ const baseHeight = measureBlockHeight(block, columnWidth, measurementProvider);
589
721
 
590
722
  // keepNext: this paragraph must stay with the next one on the same page
591
723
  const keepWithNextHeight =
592
724
  block.kind === "paragraph" && block.keepNext
593
- ? baseHeight + measureBlockHeight(blocks[index + 1], columnWidth)
725
+ ? baseHeight +
726
+ measureBlockHeight(blocks[index + 1], columnWidth, measurementProvider)
594
727
  : baseHeight;
595
728
 
596
729
  // keepLines: the entire paragraph must fit on one page.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Project surface blocks → per-page RuntimeBlockFragments (P4).
3
+ *
4
+ * The pagination engine produces `DocumentPageSnapshot[]` keyed by offset
5
+ * ranges, but for render-kernel consumers to get per-block geometry (and
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.
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.
15
+ */
16
+
17
+ import type {
18
+ DocumentPageSnapshot,
19
+ EditorSurfaceSnapshot,
20
+ SurfaceBlockSnapshot,
21
+ } from "../../api/public-types";
22
+ import type { RuntimeBlockFragment } from "./page-graph.ts";
23
+
24
+ type FragmentWithoutPageId = Omit<RuntimeBlockFragment, "pageId">;
25
+
26
+ export function projectSurfaceBlocksToPageFragments(
27
+ surface: EditorSurfaceSnapshot,
28
+ pages: readonly DocumentPageSnapshot[],
29
+ ): Map<number, FragmentWithoutPageId[]> {
30
+ const byPage = new Map<number, FragmentWithoutPageId[]>();
31
+ const perPageCounter = new Map<number, number>();
32
+
33
+ for (const block of surface.blocks) {
34
+ const pageIndex = findPageIndexForOffset(pages, block.from);
35
+ if (pageIndex === null) continue;
36
+ const orderInRegion = perPageCounter.get(pageIndex) ?? 0;
37
+ perPageCounter.set(pageIndex, orderInRegion + 1);
38
+
39
+ const fragment: FragmentWithoutPageId = {
40
+ fragmentId: `fragment-${block.blockId}`,
41
+ blockId: block.blockId,
42
+ orderInRegion,
43
+ regionKind: "body",
44
+ from: block.from,
45
+ 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
+ heightTwips: estimateBlockHeightFromSpan(block),
50
+ };
51
+
52
+ const existing = byPage.get(pageIndex);
53
+ if (existing) {
54
+ existing.push(fragment);
55
+ } else {
56
+ byPage.set(pageIndex, [fragment]);
57
+ }
58
+ }
59
+
60
+ return byPage;
61
+ }
62
+
63
+ function findPageIndexForOffset(
64
+ pages: readonly DocumentPageSnapshot[],
65
+ offset: number,
66
+ ): number | null {
67
+ for (let i = 0; i < pages.length; i += 1) {
68
+ const page = pages[i]!;
69
+ if (offset >= page.startOffset && offset < page.endOffset) {
70
+ return page.pageIndex;
71
+ }
72
+ }
73
+ // Fall back to the last page for offsets that equal storySize.
74
+ const last = pages[pages.length - 1];
75
+ if (last && offset >= last.startOffset && offset <= last.endOffset) {
76
+ return last.pageIndex;
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Rough height estimate from the block's offset span. Only used as a
83
+ * fallback when the caller did not pre-measure. Chrome consumers that
84
+ * need accurate heights should read `facet.getMeasurement(blockId)`.
85
+ */
86
+ function estimateBlockHeightFromSpan(block: SurfaceBlockSnapshot): number {
87
+ // 240 twips per line; approximate 80 chars per line for paragraphs.
88
+ const span = Math.max(0, block.to - block.from);
89
+ const lines = Math.max(1, Math.ceil(span / 80));
90
+ return lines * 240;
91
+ }