@beyondwork/docx-react-component 1.0.38 → 1.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +183 -6
  3. package/src/core/commands/table-structure-commands.ts +31 -2
  4. package/src/core/commands/text-commands.ts +122 -2
  5. package/src/io/docx-session.ts +1 -0
  6. package/src/io/export/serialize-numbering.ts +42 -8
  7. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  8. package/src/io/export/serialize-run-formatting.ts +90 -0
  9. package/src/io/export/serialize-styles.ts +212 -0
  10. package/src/io/ooxml/parse-fields.ts +10 -3
  11. package/src/io/ooxml/parse-numbering.ts +41 -1
  12. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  13. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  14. package/src/io/ooxml/parse-styles.ts +31 -0
  15. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  16. package/src/io/ooxml/xml-element.ts +19 -0
  17. package/src/model/canonical-document.ts +83 -3
  18. package/src/runtime/collab/event-types.ts +165 -0
  19. package/src/runtime/collab/index.ts +22 -0
  20. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  21. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  22. package/src/runtime/document-runtime.ts +134 -18
  23. package/src/runtime/layout/index.ts +2 -0
  24. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  25. package/src/runtime/layout/layout-engine-instance.ts +69 -2
  26. package/src/runtime/layout/layout-invalidation.ts +14 -5
  27. package/src/runtime/layout/page-graph.ts +36 -0
  28. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  29. package/src/runtime/layout/paginated-layout-engine.ts +342 -28
  30. package/src/runtime/layout/project-block-fragments.ts +154 -20
  31. package/src/runtime/layout/public-facet.ts +40 -1
  32. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  33. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  34. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  35. package/src/runtime/layout/table-render-plan.ts +21 -1
  36. package/src/runtime/numbering-prefix.ts +5 -0
  37. package/src/runtime/paragraph-style-resolver.ts +194 -0
  38. package/src/runtime/render/render-kernel.ts +5 -1
  39. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  40. package/src/runtime/surface-projection.ts +129 -9
  41. package/src/runtime/table-schema.ts +11 -0
  42. package/src/ui/WordReviewEditor.tsx +285 -5
  43. package/src/ui/editor-command-bag.ts +4 -0
  44. package/src/ui/editor-runtime-boundary.ts +16 -0
  45. package/src/ui/editor-shell-view.tsx +4 -0
  46. package/src/ui/editor-surface-controller.tsx +9 -1
  47. package/src/ui/headless/chrome-registry.ts +34 -5
  48. package/src/ui/headless/scoped-chrome-policy.ts +29 -0
  49. package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
  50. package/src/ui-tailwind/chrome/role-action-sets.ts +14 -8
  51. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +7 -10
  52. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +11 -0
  53. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  54. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  55. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +101 -21
  56. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
  57. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +5 -1
  58. package/src/ui-tailwind/chrome-overlay/index.ts +0 -6
  59. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +27 -17
  60. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  61. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +28 -3
  62. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  63. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  64. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
  65. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  66. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  67. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -78
  68. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  69. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  70. package/src/ui-tailwind/index.ts +1 -5
  71. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +122 -1
  72. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +36 -3
  73. package/src/ui-tailwind/tw-review-workspace.tsx +132 -54
  74. package/src/runtime/collab-review-sync.ts +0 -254
  75. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +0 -95
  76. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -82,6 +82,7 @@ import {
82
82
  persistedSnapshotFromEditorSessionState,
83
83
  } from "../api/session-state.ts";
84
84
  import {
85
+ type CommandExecutionContext,
85
86
  executeEditorCommand,
86
87
  selectionChanged,
87
88
  type CommandOrigin,
@@ -179,6 +180,8 @@ import {
179
180
  setCaretAffinity as applyCaretAffinity,
180
181
  setActivePageRegion as applyActivePageRegion,
181
182
  setActiveObjectFrame as applyActiveObjectFrame,
183
+ setEditorRole as applyEditorRole,
184
+ setChromePin as applyChromePins,
182
185
  createEditorViewStateSnapshot,
183
186
  type ViewState,
184
187
  } from "./view-state.ts";
@@ -229,6 +232,24 @@ export interface DocumentRuntime {
229
232
  replaceText(text: string, target?: EditorAnchorProjection): void;
230
233
  applyActiveStoryTextCommand(command: ActiveStoryTextCommand): TextCommandAck;
231
234
  dispatch(command: EditorCommand): void;
235
+ /**
236
+ * Apply a command received from a remote collaborator. The command
237
+ * executes through `executeEditorCommand` exactly like a local dispatch,
238
+ * but:
239
+ * - `onCommandApplied` is NOT fired (no echo back to the network)
240
+ * - the local undo/redo history is NOT mutated (remote edits are
241
+ * not undoable by the local user)
242
+ * - local workflow/blocked-command checks are bypassed (the remote
243
+ * already performed them)
244
+ *
245
+ * Used by runtime-level collaboration sync to replay `CommandEvent`s
246
+ * from the shared event log.
247
+ */
248
+ applyRemoteCommand(
249
+ command: EditorCommand,
250
+ context: CommandExecutionContext,
251
+ meta?: Partial<CommandAppliedMeta>,
252
+ ): void;
232
253
  emitBlockedCommand(command: string, reasons: WorkflowBlockedCommandReason[]): void;
233
254
  undo(): void;
234
255
  redo(): void;
@@ -255,6 +276,8 @@ export interface DocumentRuntime {
255
276
  getProtectionSnapshot(): ProtectionSnapshot;
256
277
  setWorkspaceMode(mode: WorkspaceMode): void;
257
278
  setZoom(level: ZoomLevel): void;
279
+ setEditorRole(role: import("./view-state.ts").ViewState["editorRole"]): void;
280
+ setChromePin(surface: import("../api/public-types.ts").ChromePinSurface, pin: import("../api/public-types.ts").PinState | null): void;
258
281
  getPageLayoutSnapshot(): PageLayoutSnapshot | null;
259
282
  getDocumentNavigationSnapshot(): DocumentNavigationSnapshot;
260
283
  /**
@@ -319,6 +342,11 @@ export interface DocumentRuntime {
319
342
  ): RuntimeContextAnalyticsSnapshot | null;
320
343
  }
321
344
 
345
+ export interface CommandAppliedMeta {
346
+ preSelection: import("../core/state/editor-state.ts").SelectionSnapshot;
347
+ activeStory: EditorStoryTarget;
348
+ }
349
+
322
350
  export interface CreateDocumentRuntimeOptions {
323
351
  documentId: string;
324
352
  initialSessionState?: EditorSessionState;
@@ -338,6 +366,23 @@ export interface CreateDocumentRuntimeOptions {
338
366
  onEvent?: (event: DocumentRuntimeEvent) => void;
339
367
  onWarning?: (warning: EditorWarning) => void;
340
368
  onError?: (error: EditorError) => void;
369
+ /**
370
+ * Fired AFTER a command has been applied locally via `dispatch()` or
371
+ * `applyActiveStoryTextCommand()`. Used by collaboration sync to
372
+ * broadcast the command to remote clients. NOT fired for remote commands
373
+ * applied via `applyRemoteCommand()` — this prevents echo loops.
374
+ *
375
+ * Not fired for:
376
+ * - `history.undo` / `history.redo` (applied via `applyHistory()` which
377
+ * does not pass through `commit()`'s new hook call site)
378
+ * - Remote replays applied via `applyRemoteCommand()`
379
+ */
380
+ onCommandApplied?: (
381
+ command: EditorCommand,
382
+ transaction: EditorTransaction,
383
+ context: CommandExecutionContext,
384
+ meta: CommandAppliedMeta,
385
+ ) => void;
341
386
  initialViewState?: Partial<ViewState>;
342
387
  protectionSnapshot?: ProtectionSnapshot;
343
388
  }
@@ -1611,12 +1656,40 @@ export function createDocumentRuntime(
1611
1656
  return;
1612
1657
  }
1613
1658
  try {
1614
- const transaction = executeEditorCommand(state, command, {
1615
- timestamp: command.origin?.timestamp ?? clock(),
1659
+ const context = {
1660
+ timestamp: normalizeCommandTimestamp(command.origin?.timestamp) ?? clock(),
1616
1661
  documentMode: getEffectiveDocumentMode(commandSelection),
1617
1662
  defaultAuthorId: defaultAuthorId ?? undefined,
1618
- });
1663
+ } as const;
1664
+ const preSelection = commandSelection;
1665
+ const preActiveStory = activeStory;
1666
+ const transaction = executeEditorCommand(state, command, context);
1619
1667
  commit(transaction);
1668
+ options.onCommandApplied?.(command, transaction, context, {
1669
+ preSelection,
1670
+ activeStory: preActiveStory,
1671
+ });
1672
+ } catch (error) {
1673
+ emitError(toRuntimeError(error));
1674
+ }
1675
+ },
1676
+ applyRemoteCommand(command, context, meta) {
1677
+ try {
1678
+ if (command.type === "history.undo" || command.type === "history.redo") {
1679
+ return;
1680
+ }
1681
+ if (meta?.activeStory && !storyTargetsEqual(meta.activeStory, activeStory)) {
1682
+ activeStory = meta.activeStory;
1683
+ storySelections.set(
1684
+ storyTargetKey(activeStory),
1685
+ meta.preSelection ?? state.selection,
1686
+ );
1687
+ }
1688
+ const replayState = meta?.preSelection
1689
+ ? { ...state, selection: meta.preSelection }
1690
+ : state;
1691
+ const transaction = executeEditorCommand(replayState, command, context);
1692
+ commitRemote(transaction);
1620
1693
  } catch (error) {
1621
1694
  emitError(toRuntimeError(error));
1622
1695
  }
@@ -1890,6 +1963,20 @@ export function createDocumentRuntime(
1890
1963
  listener();
1891
1964
  }
1892
1965
  },
1966
+ setEditorRole(role) {
1967
+ viewState = applyEditorRole(viewState, role);
1968
+ cachedRenderSnapshot = refreshRenderSnapshot();
1969
+ for (const listener of listeners) {
1970
+ listener();
1971
+ }
1972
+ },
1973
+ setChromePin(surface, pin) {
1974
+ viewState = applyChromePins(viewState, surface, pin);
1975
+ cachedRenderSnapshot = refreshRenderSnapshot();
1976
+ for (const listener of listeners) {
1977
+ listener();
1978
+ }
1979
+ },
1893
1980
  getPageLayoutSnapshot() {
1894
1981
  return getCachedPageLayoutSnapshot(state, activeStory);
1895
1982
  },
@@ -2346,13 +2433,21 @@ export function createDocumentRuntime(
2346
2433
  }
2347
2434
 
2348
2435
  function commit(transaction: EditorTransaction): void {
2349
- const previous = state;
2350
-
2351
2436
  if (transaction.historyBoundary === "push") {
2352
2437
  history.past.push(state);
2353
2438
  history.future = [];
2354
2439
  }
2355
2440
 
2441
+ applyTransactionToState(transaction);
2442
+ }
2443
+
2444
+ function commitRemote(transaction: EditorTransaction): void {
2445
+ applyTransactionToState(transaction);
2446
+ }
2447
+
2448
+ function applyTransactionToState(transaction: EditorTransaction): void {
2449
+ const previous = state;
2450
+
2356
2451
  protectionSnapshot = remapProtectionSnapshot(protectionSnapshot, transaction.mapping);
2357
2452
  state = finalizeState(transaction.nextState, transaction.markDirty, clock());
2358
2453
  storySelections.set(storyTargetKey(activeStory), state.selection);
@@ -2562,13 +2657,13 @@ export function createDocumentRuntime(
2562
2657
 
2563
2658
  function applyTextCommandInActiveStory(
2564
2659
  command: ActiveStoryTextCommand,
2565
- options: {
2660
+ textOptions: {
2566
2661
  selection?: EditorState["selection"];
2567
2662
  blockedCommandName?: string;
2568
2663
  } = {},
2569
2664
  ): TextCommandAck {
2570
2665
  const opId = (command.origin as { opId?: string } | undefined)?.opId;
2571
- const selection = options.selection ?? state.selection;
2666
+ const selection = textOptions.selection ?? state.selection;
2572
2667
  if (
2573
2668
  activeStory.kind !== "main" &&
2574
2669
  getEffectiveDocumentMode(selection) === "suggesting" &&
@@ -2578,7 +2673,7 @@ export function createDocumentRuntime(
2578
2673
  emit({
2579
2674
  type: "command_blocked",
2580
2675
  documentId: state.documentId,
2581
- command: options.blockedCommandName ?? command.type,
2676
+ command: textOptions.blockedCommandName ?? command.type,
2582
2677
  reasons: [{
2583
2678
  code: "suggesting_unsupported",
2584
2679
  message,
@@ -2597,7 +2692,7 @@ export function createDocumentRuntime(
2597
2692
  emit({
2598
2693
  type: "command_blocked",
2599
2694
  documentId: state.documentId,
2600
- command: options.blockedCommandName ?? command.type,
2695
+ command: textOptions.blockedCommandName ?? command.type,
2601
2696
  reasons: blockedReasons,
2602
2697
  });
2603
2698
  return {
@@ -2608,7 +2703,7 @@ export function createDocumentRuntime(
2608
2703
  };
2609
2704
  }
2610
2705
 
2611
- const timestamp = command.origin?.timestamp ?? clock();
2706
+ const timestamp = normalizeCommandTimestamp(command.origin?.timestamp) ?? clock();
2612
2707
  const context = {
2613
2708
  timestamp,
2614
2709
  documentMode: getEffectiveDocumentMode(selection),
@@ -2621,9 +2716,15 @@ export function createDocumentRuntime(
2621
2716
  selection,
2622
2717
  };
2623
2718
 
2719
+ const preSelection = selection;
2720
+ const preActiveStory = activeStory;
2624
2721
  if (activeStory.kind === "main") {
2625
2722
  const mainTransaction = executeEditorCommand(baseState, command, context);
2626
2723
  commit(mainTransaction);
2724
+ options.onCommandApplied?.(command, mainTransaction, context, {
2725
+ preSelection,
2726
+ activeStory: preActiveStory,
2727
+ });
2627
2728
  return classifyAck({
2628
2729
  command,
2629
2730
  opId,
@@ -2682,16 +2783,17 @@ export function createDocumentRuntime(
2682
2783
  activeStory,
2683
2784
  ),
2684
2785
  };
2786
+ const broadcastCommand: EditorCommand = {
2787
+ type: "document.replace",
2788
+ document: nextDocumentWithReview,
2789
+ selection: localTransaction.nextState.selection,
2790
+ mapping: createEmptyMapping(),
2791
+ protectionSelection: selection,
2792
+ origin: command.origin,
2793
+ };
2685
2794
  const fullTransaction = executeEditorCommand(
2686
2795
  baseState,
2687
- {
2688
- type: "document.replace",
2689
- document: nextDocumentWithReview,
2690
- selection: localTransaction.nextState.selection,
2691
- mapping: createEmptyMapping(),
2692
- protectionSelection: selection,
2693
- origin: command.origin,
2694
- },
2796
+ broadcastCommand,
2695
2797
  context,
2696
2798
  );
2697
2799
 
@@ -2700,6 +2802,10 @@ export function createDocumentRuntime(
2700
2802
  effects: mergeTransactionEffects(fullTransaction.effects, localTransaction.effects),
2701
2803
  };
2702
2804
  commit(mergedTransaction);
2805
+ options.onCommandApplied?.(broadcastCommand, mergedTransaction, context, {
2806
+ preSelection,
2807
+ activeStory: preActiveStory,
2808
+ });
2703
2809
  return classifyAck({
2704
2810
  command,
2705
2811
  opId,
@@ -2988,6 +3094,16 @@ function createEntityId(
2988
3094
  return nextId;
2989
3095
  }
2990
3096
 
3097
+ function normalizeCommandTimestamp(value: unknown): string | undefined {
3098
+ if (typeof value === "string" && value.length > 0) {
3099
+ return value;
3100
+ }
3101
+ if (typeof value === "number" && Number.isFinite(value)) {
3102
+ return new Date(value).toISOString();
3103
+ }
3104
+ return undefined;
3105
+ }
3106
+
2991
3107
  function finalizeState(
2992
3108
  state: EditorState,
2993
3109
  markDirty: boolean,
@@ -158,6 +158,8 @@ export {
158
158
  type EmbeddedFontBytes,
159
159
  } from "./docx-font-loader.ts";
160
160
 
161
+ export { createCanvasBackend } from "./measurement-backend-canvas.ts";
162
+
161
163
  // ---------------------------------------------------------------------------
162
164
  // Public facet (Phase 7)
163
165
  // ---------------------------------------------------------------------------
@@ -51,6 +51,8 @@ export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
51
51
  getMeasurement: () => null,
52
52
  getMeasurementFidelity: () => fidelity,
53
53
  whenMeasurementReady: () => Promise.resolve(),
54
+ getFirstPageIndexForBlock: () => null,
55
+ swapMeasurementProvider: () => undefined,
54
56
  getTableRenderPlan: () => null,
55
57
  getDirtyFieldFamilies: () => [],
56
58
  getFieldDirtinessReport: () => emptyReport,
@@ -45,6 +45,8 @@ import {
45
45
  import {
46
46
  buildPageStack,
47
47
  buildPageStackFrom,
48
+ buildPageStackFromWithSplits,
49
+ buildPageStackWithSplits,
48
50
  type LayoutInvalidationReason,
49
51
  } from "./paginated-layout-engine.ts";
50
52
  import {
@@ -195,6 +197,14 @@ function recordFullRebuildReason(reasonKind: string): void {
195
197
  export interface CreateLayoutEngineOptions {
196
198
  /** Optional measurement provider. Defaults to empirical. */
197
199
  measurementProvider?: LayoutMeasurementProvider;
200
+ /**
201
+ * When true and a browser-like `document` global is available, the engine
202
+ * dynamically imports the Canvas2D measurement backend and swaps to it at
203
+ * init time, emitting `measurement_backend_ready`. SSR stays on the
204
+ * empirical backend. Callers that want to stay on empirical (for
205
+ * determinism or tests) pass `false`. Default: true.
206
+ */
207
+ autoUpgradeToCanvasBackend?: boolean;
198
208
  }
199
209
 
200
210
  export function createLayoutEngine(
@@ -202,6 +212,7 @@ export function createLayoutEngine(
202
212
  ): LayoutEngineInstance {
203
213
  let measurementProvider: LayoutMeasurementProvider =
204
214
  options.measurementProvider ?? createEmpiricalMeasurementProvider();
215
+ const autoUpgradeToCanvas = options.autoUpgradeToCanvasBackend !== false;
205
216
  const dirtyFieldFamilies = new Set<string>();
206
217
  const listeners = new Set<(event: LayoutEngineEvent) => void>();
207
218
  let cachedKey: CacheKey | null = null;
@@ -244,11 +255,18 @@ export function createLayoutEngine(
244
255
  MAIN_STORY_TARGET,
245
256
  );
246
257
  const sections = buildResolvedSections(document);
247
- const pages = buildPageStack(document, sections, mainSurface, measurementProvider);
258
+ const pageStack = buildPageStackWithSplits(
259
+ document,
260
+ sections,
261
+ mainSurface,
262
+ measurementProvider,
263
+ );
264
+ const pages = pageStack.pages;
248
265
  const stories = resolvePageStories(pages);
249
266
  const fragmentsByPageIndex = projectSurfaceBlocksToPageFragments(
250
267
  mainSurface,
251
268
  pages,
269
+ pageStack.splits,
252
270
  );
253
271
  const graph = buildPageGraph({
254
272
  pages,
@@ -320,7 +338,7 @@ export function createLayoutEngine(
320
338
  const sections = buildResolvedSections(document);
321
339
 
322
340
  const dirtyPage = priorGraph.pages[firstDirty]!;
323
- const freshSnapshots = buildPageStackFrom(
341
+ const freshResult = buildPageStackFromWithSplits(
324
342
  document,
325
343
  sections,
326
344
  mainSurface,
@@ -330,6 +348,7 @@ export function createLayoutEngine(
330
348
  },
331
349
  measurementProvider,
332
350
  );
351
+ const freshSnapshots = freshResult.pages;
333
352
 
334
353
  // Convert fresh DocumentPageSnapshots into RuntimePageNodes via the
335
354
  // standard buildPageGraph pipeline — this keeps region, story, and
@@ -341,10 +360,18 @@ export function createLayoutEngine(
341
360
  return null;
342
361
  }
343
362
  const freshStories = resolvePageStories(freshSnapshots);
363
+ // Project fragments for the fresh tail pages, threading paragraph
364
+ // line-range splits produced by intra-paragraph pagination.
365
+ const freshFragmentsByPageIndex = projectSurfaceBlocksToPageFragments(
366
+ mainSurface,
367
+ freshSnapshots,
368
+ freshResult.splits,
369
+ );
344
370
  const freshGraph = buildPageGraph({
345
371
  pages: freshSnapshots,
346
372
  sections,
347
373
  stories: freshStories,
374
+ fragmentsByPageIndex: freshFragmentsByPageIndex,
348
375
  });
349
376
  const freshNodes = freshGraph.pages;
350
377
 
@@ -444,6 +471,46 @@ export function createLayoutEngine(
444
471
  return cachedFormatting!;
445
472
  }
446
473
 
474
+ // -----------------------------------------------------------------------
475
+ // Auto-upgrade to the Canvas2D measurement backend in browsers. Dynamic
476
+ // import keeps SSR bundles lean. We only attempt the upgrade when the
477
+ // caller didn't provide their own provider and `document` is available.
478
+ // -----------------------------------------------------------------------
479
+ if (
480
+ autoUpgradeToCanvas &&
481
+ options.measurementProvider === undefined &&
482
+ typeof document !== "undefined" &&
483
+ typeof HTMLCanvasElement !== "undefined"
484
+ ) {
485
+ // Swallow errors silently — staying on empirical is correct behavior
486
+ // when the upgrade fails. Perf probe increments the fallback counter
487
+ // through the emitted event (listeners observe fidelity).
488
+ const readCachedRevision = (): number => cachedGraph?.revision ?? 0;
489
+ void (async () => {
490
+ try {
491
+ const mod = await import("./measurement-backend-canvas.ts");
492
+ const canvasProvider = mod.createCanvasBackend();
493
+ measurementProvider = canvasProvider;
494
+ // Invalidate the cached graph/formatting/mapper so the next read
495
+ // recomputes with canvas-measured font metrics. Without this,
496
+ // the first render after the async import still uses empirical
497
+ // numbers and the chrome shifts by a few pixels on the next
498
+ // real invalidation.
499
+ cachedKey = null;
500
+ cachedGraph = null;
501
+ cachedFormatting = null;
502
+ cachedMapper = null;
503
+ emit({
504
+ kind: "measurement_backend_ready",
505
+ revision: readCachedRevision(),
506
+ fidelity: canvasProvider.fidelity,
507
+ });
508
+ } catch {
509
+ // Stay on empirical. No-op.
510
+ }
511
+ })();
512
+ }
513
+
447
514
  return {
448
515
  get measurementFidelity() {
449
516
  return measurementProvider.fidelity;
@@ -93,12 +93,21 @@ export function analyzeInvalidation(
93
93
  return analyzeSectionChange(reason, graph);
94
94
 
95
95
  case "numbering-change":
96
- // Numbering changes can affect indentation and spacing globally,
97
- // but could be bounded to sections using that numbering instance.
98
- // For now, full recompute.
96
+ if (!reason.numberingInstanceId) {
97
+ return {
98
+ scope: "full",
99
+ requiresFullRecompute: true,
100
+ dirtyFieldFamilies: [],
101
+ };
102
+ }
99
103
  return {
100
- scope: "full",
101
- requiresFullRecompute: true,
104
+ scope: "bounded",
105
+ requiresFullRecompute: false,
106
+ dirtyPageRange: {
107
+ firstPageIndex: 0,
108
+ lastPageIndex: Math.max(0, graph.pages.length - 1),
109
+ },
110
+ dirtySectionRange: null,
102
111
  dirtyFieldFamilies: [],
103
112
  };
104
113
 
@@ -102,6 +102,42 @@ export interface RuntimeBlockFragment {
102
102
  to: number;
103
103
  /** Height consumed on this page (twips). */
104
104
  heightTwips: number;
105
+ /**
106
+ * Fragment classification.
107
+ * - `"whole"` (default): the fragment represents the entire block; no slicing.
108
+ * - `"paragraph-slice"`: one of several fragments produced by intra-paragraph
109
+ * line-box splitting. `paragraphLineRange` identifies which lines this
110
+ * slice renders.
111
+ * - `"table-slice"`: one of several fragments produced by row-boundary table
112
+ * splitting (emitted by the table-fidelity workstream).
113
+ * `tableRowRange` identifies which canonical rows this slice renders.
114
+ * Consumers that predate multi-fragment blocks may treat an absent `kind`
115
+ * as `"whole"`.
116
+ */
117
+ kind?: "whole" | "paragraph-slice" | "table-slice";
118
+ /**
119
+ * For `kind === "paragraph-slice"`, the inclusive-exclusive line-box index
120
+ * range rendered by this slice plus the total line count for the source
121
+ * paragraph. `from`/`to` still span the full paragraph offset range on
122
+ * every slice — only the visible lines differ.
123
+ */
124
+ paragraphLineRange?: {
125
+ from: number;
126
+ to: number;
127
+ totalLines: number;
128
+ };
129
+ /**
130
+ * For `kind === "table-slice"`, the inclusive-exclusive row-index range
131
+ * rendered by this slice. Repeated header rows (when the table has
132
+ * `isHeader` rows and `continuation` pages) are implied by the owning
133
+ * table's canonical row list — consumers prepend header rows for slices
134
+ * whose `from > 0`.
135
+ */
136
+ tableRowRange?: {
137
+ from: number;
138
+ to: number;
139
+ totalRows: number;
140
+ };
105
141
  }
106
142
 
107
143
  export interface RuntimeLineBox {
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Line-box splitter for paragraph pagination.
3
+ *
4
+ * Given a paragraph that doesn't fit on the current page, decide how many of
5
+ * its lines belong on the current page and how many continue on the next.
6
+ * Honors the four pagination attributes that govern this in OOXML /
7
+ * ECMA-376 §17.3.1.33:
8
+ *
9
+ * - `keepLines` — if true, never split the paragraph.
10
+ * - `widowControl` — if true (Word default), keep ≥ WIDOW_MIN lines on
11
+ * each side of any split. Applies independently at
12
+ * the top (orphan) and bottom (widow) of a split.
13
+ * - `keepNext` — orthogonal to splitting itself; the engine's
14
+ * outer loop uses it to bundle the paragraph with
15
+ * the next. We only honour it here to avoid an
16
+ * awkward split on a paragraph that is about to be
17
+ * kept with the next (no gain from splitting).
18
+ * - `pageBreakBefore` — handled upstream via an unconditional page break;
19
+ * if set, callers do not consult this splitter.
20
+ *
21
+ * Pure function. No DOM, no side effects. Unit-testable in isolation.
22
+ */
23
+
24
+ export const DEFAULT_WIDOW_MIN_LINES = 2;
25
+
26
+ export interface LineSplitInput {
27
+ /** Total number of line boxes in the paragraph, ≥ 1. */
28
+ totalLines: number;
29
+ /**
30
+ * How many lines can still fit on the current page given the remaining
31
+ * vertical space and the paragraph's resolved line height. Callers compute
32
+ * this by dividing the remaining column height by the per-line height.
33
+ * Must be ≥ 0. Zero is legal and short-circuits to "no split" (the whole
34
+ * paragraph moves to the next page).
35
+ */
36
+ availableLines: number;
37
+ /** OOXML `w:keepLines`. */
38
+ keepLines: boolean;
39
+ /** OOXML `w:widowControl` (true in Word's default). */
40
+ widowControl: boolean;
41
+ /** OOXML `w:keepNext`. */
42
+ keepNext: boolean;
43
+ /**
44
+ * Whether this paragraph is the last block on the page-in-flight. If true,
45
+ * splitting buys nothing (there's no subsequent content we're squeezing in
46
+ * below) and we leave the whole paragraph on the current page.
47
+ */
48
+ isLastBlockOnPage: boolean;
49
+ /**
50
+ * Minimum lines required on each side of a widow/orphan-controlled split.
51
+ * Defaults to {@link DEFAULT_WIDOW_MIN_LINES}. Exposed for fixtures that
52
+ * exercise narrow paragraphs.
53
+ */
54
+ widowMinLines?: number;
55
+ }
56
+
57
+ export interface LineSplitResult {
58
+ linesOnCurrent: number;
59
+ linesOnNext: number;
60
+ }
61
+
62
+ /**
63
+ * Decide how to split a paragraph across a page boundary.
64
+ *
65
+ * Returns:
66
+ * - `null` when no split should happen. The caller's existing "move the
67
+ * whole paragraph to the next page" behavior applies.
68
+ * - A `{ linesOnCurrent, linesOnNext }` split with `linesOnCurrent +
69
+ * linesOnNext === totalLines` and both values ≥ 1. The caller must
70
+ * render `linesOnCurrent` lines in the remaining space on the current
71
+ * page and `linesOnNext` lines at the top of the next page.
72
+ */
73
+ export function paginateParagraphLines(
74
+ input: LineSplitInput,
75
+ ): LineSplitResult | null {
76
+ const totalLines = Math.max(1, Math.floor(input.totalLines));
77
+ const availableLines = Math.max(0, Math.floor(input.availableLines));
78
+ const widowMin = Math.max(1, input.widowMinLines ?? DEFAULT_WIDOW_MIN_LINES);
79
+
80
+ // keepLines wins unconditionally.
81
+ if (input.keepLines) return null;
82
+
83
+ // If the paragraph fits wholesale on the current page, no split.
84
+ if (totalLines <= availableLines) return null;
85
+
86
+ // If there's nothing worth keeping on the current page (zero lines fit, or
87
+ // only a single line under widow control would fit), fall through to the
88
+ // caller's "move whole paragraph" path.
89
+ const effectiveOrphanMin = input.widowControl ? widowMin : 1;
90
+ if (availableLines < effectiveOrphanMin) return null;
91
+
92
+ // Don't split trivially-short paragraphs whose final lines could fit a
93
+ // page move (keeping them intact preserves Word's visual intent).
94
+ if (totalLines < effectiveOrphanMin * 2) return null;
95
+
96
+ // If this paragraph is the last thing on this page anyway, splitting has
97
+ // no downstream benefit — the next page is empty.
98
+ if (input.isLastBlockOnPage) return null;
99
+
100
+ // keepNext paragraphs are meant to travel with the next block. Splitting
101
+ // them would move half-and-half across the break, which usually contradicts
102
+ // author intent. Leave the paragraph intact; the outer loop will handle
103
+ // the keep-with-next pairing on the next page.
104
+ if (input.keepNext) return null;
105
+
106
+ // Candidate split: fill the current page, put the rest on the next.
107
+ let linesOnCurrent = availableLines;
108
+ let linesOnNext = totalLines - linesOnCurrent;
109
+
110
+ // Widow control: the tail on the next page needs ≥ widowMin lines too.
111
+ if (input.widowControl && linesOnNext < widowMin) {
112
+ // Pull enough lines back to the next page to meet the widow minimum.
113
+ const deficit = widowMin - linesOnNext;
114
+ linesOnCurrent -= deficit;
115
+ linesOnNext += deficit;
116
+
117
+ // If pulling back violates the orphan minimum, abandon the split —
118
+ // the whole paragraph goes on the next page.
119
+ if (linesOnCurrent < widowMin) return null;
120
+ }
121
+
122
+ // Sanity clamp (the arithmetic above should guarantee these but be
123
+ // defensive against bad input).
124
+ if (linesOnCurrent < 1 || linesOnNext < 1) return null;
125
+ if (linesOnCurrent + linesOnNext !== totalLines) return null;
126
+
127
+ return { linesOnCurrent, linesOnNext };
128
+ }