@beyondwork/docx-react-component 1.0.41 → 1.0.42

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 (88) hide show
  1. package/package.json +13 -1
  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/external-custody-types.ts +74 -0
  6. package/src/api/participants-types.ts +18 -0
  7. package/src/api/public-types.ts +347 -4
  8. package/src/api/scope-metadata-resolver-types.ts +88 -0
  9. package/src/core/commands/formatting-commands.ts +1 -1
  10. package/src/core/commands/index.ts +568 -1
  11. package/src/index.ts +118 -1
  12. package/src/io/export/escape-xml-attribute.ts +26 -0
  13. package/src/io/export/external-send.ts +188 -0
  14. package/src/io/export/serialize-comments.ts +13 -16
  15. package/src/io/export/serialize-footnotes.ts +17 -24
  16. package/src/io/export/serialize-headers-footers.ts +17 -24
  17. package/src/io/export/serialize-main-document.ts +59 -62
  18. package/src/io/export/serialize-numbering.ts +20 -27
  19. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  20. package/src/io/export/serialize-tables.ts +8 -15
  21. package/src/io/export/table-properties-xml.ts +25 -32
  22. package/src/io/import/external-reimport.ts +40 -0
  23. package/src/io/ooxml/bw-xml.ts +244 -0
  24. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  25. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  26. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  27. package/src/io/ooxml/external-custody-payload.ts +102 -0
  28. package/src/io/ooxml/participants-payload.ts +97 -0
  29. package/src/io/ooxml/payload-signature.ts +112 -0
  30. package/src/io/ooxml/workflow-payload-validator.ts +271 -0
  31. package/src/io/ooxml/workflow-payload.ts +146 -7
  32. package/src/runtime/awareness-identity.ts +173 -0
  33. package/src/runtime/collab/event-types.ts +27 -0
  34. package/src/runtime/collab-session-bridge.ts +157 -0
  35. package/src/runtime/collab-session-facet.ts +193 -0
  36. package/src/runtime/collab-session.ts +273 -0
  37. package/src/runtime/comment-negotiation-sync.ts +91 -0
  38. package/src/runtime/comment-negotiation.ts +158 -0
  39. package/src/runtime/comment-presentation.ts +223 -0
  40. package/src/runtime/document-runtime.ts +280 -93
  41. package/src/runtime/external-send-runtime.ts +117 -0
  42. package/src/runtime/layout/docx-font-loader.ts +11 -30
  43. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  44. package/src/runtime/layout/layout-engine-instance.ts +122 -12
  45. package/src/runtime/layout/page-graph.ts +79 -7
  46. package/src/runtime/layout/paginated-layout-engine.ts +230 -34
  47. package/src/runtime/layout/public-facet.ts +185 -13
  48. package/src/runtime/layout/table-row-split.ts +316 -0
  49. package/src/runtime/markdown-sanitizer.ts +132 -0
  50. package/src/runtime/participants.ts +134 -0
  51. package/src/runtime/resign-payload.ts +120 -0
  52. package/src/runtime/tamper-gate.ts +157 -0
  53. package/src/runtime/workflow-markup.ts +9 -0
  54. package/src/runtime/workflow-rail-segments.ts +244 -5
  55. package/src/ui/WordReviewEditor.tsx +587 -0
  56. package/src/ui/editor-runtime-boundary.ts +1 -0
  57. package/src/ui/editor-shell-view.tsx +11 -0
  58. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  59. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  60. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  61. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  62. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  63. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  64. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  65. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  66. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  67. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  68. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  69. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  70. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  71. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  72. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  73. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +58 -0
  74. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  75. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  76. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  77. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  78. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  79. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  80. package/src/ui-tailwind/editor-surface/pm-schema.ts +15 -13
  81. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  82. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -14
  83. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  84. package/src/ui-tailwind/index.ts +32 -0
  85. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  86. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  87. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  88. package/src/ui-tailwind/tw-review-workspace.tsx +293 -34
@@ -319,6 +319,7 @@ export interface DocumentRuntime {
319
319
  getSuggestionsSnapshot(): SuggestionsSnapshot;
320
320
  setWorkflowOverlay(overlay: WorkflowOverlay): void;
321
321
  clearWorkflowOverlay(): void;
322
+ getWorkflowOverlay(): WorkflowOverlay | null;
322
323
  getWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null;
323
324
  getInteractionGuardSnapshot(): InteractionGuardSnapshot;
324
325
  getWorkflowMarkupSnapshot(): WorkflowMarkupSnapshot;
@@ -1662,11 +1663,34 @@ export function createDocumentRuntime(
1662
1663
  applyHistory("redo");
1663
1664
  return;
1664
1665
  }
1666
+
1667
+ if (isRuntimeStateOverlayCommand(command)) {
1668
+ applyRuntimeStateOverlayCommand(command);
1669
+ const context = {
1670
+ timestamp: normalizeCommandTimestamp(command.origin?.timestamp) ?? clock(),
1671
+ documentMode: getEffectiveDocumentMode(commandSelection),
1672
+ defaultAuthorId: defaultAuthorId ?? undefined,
1673
+ } as const;
1674
+ const noopTransaction: EditorTransaction = {
1675
+ nextState: state,
1676
+ mapping: createEmptyMapping(),
1677
+ effects: { warningsAdded: [], warningsCleared: [] },
1678
+ historyBoundary: "skip",
1679
+ markDirty: false,
1680
+ };
1681
+ options.onCommandApplied?.(command, noopTransaction, context, {
1682
+ preSelection: state.selection,
1683
+ activeStory,
1684
+ });
1685
+ return;
1686
+ }
1687
+
1665
1688
  try {
1666
1689
  const context = {
1667
1690
  timestamp: normalizeCommandTimestamp(command.origin?.timestamp) ?? clock(),
1668
1691
  documentMode: getEffectiveDocumentMode(commandSelection),
1669
1692
  defaultAuthorId: defaultAuthorId ?? undefined,
1693
+ renderSnapshot: cachedRenderSnapshot,
1670
1694
  } as const;
1671
1695
  const preSelection = commandSelection;
1672
1696
  const preActiveStory = activeStory;
@@ -1685,6 +1709,10 @@ export function createDocumentRuntime(
1685
1709
  if (command.type === "history.undo" || command.type === "history.redo") {
1686
1710
  return;
1687
1711
  }
1712
+ if (isRuntimeStateOverlayCommand(command)) {
1713
+ applyRuntimeStateOverlayCommand(command);
1714
+ return;
1715
+ }
1688
1716
  if (meta?.activeStory && !storyTargetsEqual(meta.activeStory, activeStory)) {
1689
1717
  activeStory = meta.activeStory;
1690
1718
  storySelections.set(
@@ -1695,7 +1723,17 @@ export function createDocumentRuntime(
1695
1723
  const replayState = meta?.preSelection
1696
1724
  ? { ...state, selection: meta.preSelection }
1697
1725
  : state;
1698
- const transaction = executeEditorCommand(replayState, command, context);
1726
+ const replaySnapshot = meta?.preSelection
1727
+ ? {
1728
+ ...cachedRenderSnapshot,
1729
+ selection: toPublicSelectionSnapshot(meta.preSelection, activeStory),
1730
+ }
1731
+ : cachedRenderSnapshot;
1732
+ const replayContext = {
1733
+ ...context,
1734
+ renderSnapshot: replaySnapshot,
1735
+ };
1736
+ const transaction = executeEditorCommand(replayState, command, replayContext);
1699
1737
  commitRemote(transaction);
1700
1738
  } catch (error) {
1701
1739
  emitError(toRuntimeError(error));
@@ -2156,6 +2194,9 @@ export function createDocumentRuntime(
2156
2194
  state.selection.head,
2157
2195
  activeStory,
2158
2196
  options,
2197
+ // P5 — TOC entries print Word's display number (honors page-
2198
+ // number restarts), not the raw 0-based pageIndex+1.
2199
+ (pageIndex: number) => layoutFacet.getDisplayPageNumber(pageIndex),
2159
2200
  );
2160
2201
  if (refreshed.changed) {
2161
2202
  this.dispatch({
@@ -2224,47 +2265,20 @@ export function createDocumentRuntime(
2224
2265
  return result;
2225
2266
  },
2226
2267
  setWorkflowOverlay(overlay) {
2227
- workflowOverlay = structuredClone(overlay);
2228
- cachedRenderSnapshot = refreshRenderSnapshot();
2229
- const snapshot = deriveWorkflowScopeSnapshot()!;
2230
- emit({
2231
- type: "workflow_overlay_changed",
2232
- documentId: state.documentId,
2233
- snapshot,
2268
+ this.dispatch({
2269
+ type: "workflow.set-overlay",
2270
+ overlay,
2271
+ origin: createOrigin("api", clock()),
2234
2272
  });
2235
- if (workflowOverlay.activeWorkItemId !== undefined) {
2236
- emit({
2237
- type: "workflow_active_work_item_changed",
2238
- documentId: state.documentId,
2239
- activeWorkItemId: workflowOverlay.activeWorkItemId ?? null,
2240
- });
2241
- }
2242
- for (const listener of listeners) {
2243
- listener();
2244
- }
2245
2273
  },
2246
2274
  clearWorkflowOverlay() {
2247
- workflowOverlay = null;
2248
- cachedRenderSnapshot = refreshRenderSnapshot();
2249
- emit({
2250
- type: "workflow_active_work_item_changed",
2251
- documentId: state.documentId,
2252
- activeWorkItemId: null,
2253
- });
2254
- emit({
2255
- type: "workflow_overlay_changed",
2256
- documentId: state.documentId,
2257
- snapshot: {
2258
- overlayPresent: false,
2259
- activeWorkItemId: null,
2260
- scopes: [],
2261
- candidates: [],
2262
- blockedReasons: [],
2263
- },
2275
+ this.dispatch({
2276
+ type: "workflow.clear-overlay",
2277
+ origin: createOrigin("api", clock()),
2264
2278
  });
2265
- for (const listener of listeners) {
2266
- listener();
2267
- }
2279
+ },
2280
+ getWorkflowOverlay() {
2281
+ return workflowOverlay;
2268
2282
  },
2269
2283
  getWorkflowScopeSnapshot() {
2270
2284
  return getCachedWorkflowScopeSnapshot();
@@ -2276,77 +2290,46 @@ export function createDocumentRuntime(
2276
2290
  return getCachedWorkflowMarkupSnapshot();
2277
2291
  },
2278
2292
  setWorkflowMetadataDefinitions(definitions) {
2279
- workflowMetadataDefinitions = structuredClone(definitions);
2280
- const snapshot = deriveWorkflowMetadataSnapshot();
2281
- emit({
2282
- type: "workflow_metadata_changed",
2283
- documentId: state.documentId,
2284
- snapshot,
2293
+ this.dispatch({
2294
+ type: "workflow.set-metadata-definitions",
2295
+ definitions,
2296
+ origin: createOrigin("api", clock()),
2285
2297
  });
2286
- for (const listener of listeners) {
2287
- listener();
2288
- }
2289
2298
  },
2290
2299
  clearWorkflowMetadataDefinitions() {
2291
- workflowMetadataDefinitions = [];
2292
- const snapshot = deriveWorkflowMetadataSnapshot();
2293
- emit({
2294
- type: "workflow_metadata_changed",
2295
- documentId: state.documentId,
2296
- snapshot,
2300
+ this.dispatch({
2301
+ type: "workflow.clear-metadata-definitions",
2302
+ origin: createOrigin("api", clock()),
2297
2303
  });
2298
- for (const listener of listeners) {
2299
- listener();
2300
- }
2301
2304
  },
2302
2305
  setWorkflowMetadataEntries(entries) {
2303
- workflowMetadataEntries = structuredClone(entries);
2304
- const snapshot = deriveWorkflowMetadataSnapshot();
2305
- emit({
2306
- type: "workflow_metadata_changed",
2307
- documentId: state.documentId,
2308
- snapshot,
2306
+ this.dispatch({
2307
+ type: "workflow.set-metadata-entries",
2308
+ entries,
2309
+ origin: createOrigin("api", clock()),
2309
2310
  });
2310
- for (const listener of listeners) {
2311
- listener();
2312
- }
2313
2311
  },
2314
2312
  clearWorkflowMetadataEntries() {
2315
- workflowMetadataEntries = [];
2316
- const snapshot = deriveWorkflowMetadataSnapshot();
2317
- emit({
2318
- type: "workflow_metadata_changed",
2319
- documentId: state.documentId,
2320
- snapshot,
2313
+ this.dispatch({
2314
+ type: "workflow.clear-metadata-entries",
2315
+ origin: createOrigin("api", clock()),
2321
2316
  });
2322
- for (const listener of listeners) {
2323
- listener();
2324
- }
2325
2317
  },
2326
2318
  getWorkflowMetadataSnapshot() {
2327
2319
  return deriveWorkflowMetadataSnapshot();
2328
2320
  },
2329
2321
  setHostAnnotationOverlay(overlay) {
2330
- hostAnnotationOverlay = structuredClone(overlay);
2331
- emit({
2332
- type: "host_annotation_overlay_changed",
2333
- documentId: state.documentId,
2334
- snapshot: deriveHostAnnotationSnapshot(),
2322
+ this.dispatch({
2323
+ type: "host-annotation.set-overlay",
2324
+ overlay,
2325
+ origin: createOrigin("api", clock()),
2335
2326
  });
2336
- for (const listener of listeners) {
2337
- listener();
2338
- }
2339
2327
  },
2340
2328
  clearHostAnnotationOverlay() {
2341
- hostAnnotationOverlay = null;
2342
- emit({
2343
- type: "host_annotation_overlay_changed",
2344
- documentId: state.documentId,
2345
- snapshot: deriveHostAnnotationSnapshot(),
2329
+ this.dispatch({
2330
+ type: "host-annotation.clear-overlay",
2331
+ origin: createOrigin("api", clock()),
2346
2332
  });
2347
- for (const listener of listeners) {
2348
- listener();
2349
- }
2350
2333
  },
2351
2334
  getHostAnnotationSnapshot() {
2352
2335
  return deriveHostAnnotationSnapshot();
@@ -2480,9 +2463,14 @@ export function createDocumentRuntime(
2480
2463
  // Font-loader refresh on subParts identity change — this is the
2481
2464
  // lightweight proxy for "a change that could affect which fonts the
2482
2465
  // canvas backend measures against". Typing edits don't rebuild
2483
- // subParts; style + font + numbering imports do.
2466
+ // subParts; style + font + numbering imports do. Hardening: also
2467
+ // invalidate the measurement provider's glyph cache AND the engine's
2468
+ // cached graph so the next pagination run re-measures with the
2469
+ // newly-registered FontFaces (pre-hardening the canvas backend kept
2470
+ // returning pre-refresh widths from its cache).
2484
2471
  if (previous.document.subParts !== state.document.subParts) {
2485
2472
  fontLoader.refresh(collectFontLoaderInput(state.document));
2473
+ layoutEngine.invalidateMeasurementCache();
2486
2474
  }
2487
2475
 
2488
2476
  cachedRenderSnapshot = refreshRenderSnapshot();
@@ -2715,6 +2703,7 @@ export function createDocumentRuntime(
2715
2703
  timestamp,
2716
2704
  documentMode: getEffectiveDocumentMode(selection),
2717
2705
  defaultAuthorId: defaultAuthorId ?? undefined,
2706
+ renderSnapshot: cachedRenderSnapshot,
2718
2707
  } as const;
2719
2708
  const baseState = selection === state.selection
2720
2709
  ? state
@@ -2953,6 +2942,109 @@ export function createDocumentRuntime(
2953
2942
  }
2954
2943
  }
2955
2944
 
2945
+ function applyRuntimeStateOverlayCommand(
2946
+ command: RuntimeStateOverlayCommand,
2947
+ ): void {
2948
+ switch (command.type) {
2949
+ case "workflow.set-overlay": {
2950
+ workflowOverlay = structuredClone(command.overlay);
2951
+ cachedRenderSnapshot = refreshRenderSnapshot();
2952
+ const snapshot = deriveWorkflowScopeSnapshot()!;
2953
+ emit({
2954
+ type: "workflow_overlay_changed",
2955
+ documentId: state.documentId,
2956
+ snapshot,
2957
+ });
2958
+ if (workflowOverlay.activeWorkItemId !== undefined) {
2959
+ emit({
2960
+ type: "workflow_active_work_item_changed",
2961
+ documentId: state.documentId,
2962
+ activeWorkItemId: workflowOverlay.activeWorkItemId ?? null,
2963
+ });
2964
+ }
2965
+ break;
2966
+ }
2967
+ case "workflow.clear-overlay": {
2968
+ workflowOverlay = null;
2969
+ cachedRenderSnapshot = refreshRenderSnapshot();
2970
+ emit({
2971
+ type: "workflow_active_work_item_changed",
2972
+ documentId: state.documentId,
2973
+ activeWorkItemId: null,
2974
+ });
2975
+ emit({
2976
+ type: "workflow_overlay_changed",
2977
+ documentId: state.documentId,
2978
+ snapshot: {
2979
+ overlayPresent: false,
2980
+ activeWorkItemId: null,
2981
+ scopes: [],
2982
+ candidates: [],
2983
+ blockedReasons: [],
2984
+ },
2985
+ });
2986
+ break;
2987
+ }
2988
+ case "workflow.set-metadata-definitions": {
2989
+ workflowMetadataDefinitions = structuredClone(command.definitions);
2990
+ emit({
2991
+ type: "workflow_metadata_changed",
2992
+ documentId: state.documentId,
2993
+ snapshot: deriveWorkflowMetadataSnapshot(),
2994
+ });
2995
+ break;
2996
+ }
2997
+ case "workflow.clear-metadata-definitions": {
2998
+ workflowMetadataDefinitions = [];
2999
+ emit({
3000
+ type: "workflow_metadata_changed",
3001
+ documentId: state.documentId,
3002
+ snapshot: deriveWorkflowMetadataSnapshot(),
3003
+ });
3004
+ break;
3005
+ }
3006
+ case "workflow.set-metadata-entries": {
3007
+ workflowMetadataEntries = structuredClone(command.entries);
3008
+ emit({
3009
+ type: "workflow_metadata_changed",
3010
+ documentId: state.documentId,
3011
+ snapshot: deriveWorkflowMetadataSnapshot(),
3012
+ });
3013
+ break;
3014
+ }
3015
+ case "workflow.clear-metadata-entries": {
3016
+ workflowMetadataEntries = [];
3017
+ emit({
3018
+ type: "workflow_metadata_changed",
3019
+ documentId: state.documentId,
3020
+ snapshot: deriveWorkflowMetadataSnapshot(),
3021
+ });
3022
+ break;
3023
+ }
3024
+ case "host-annotation.set-overlay": {
3025
+ hostAnnotationOverlay = structuredClone(command.overlay);
3026
+ emit({
3027
+ type: "host_annotation_overlay_changed",
3028
+ documentId: state.documentId,
3029
+ snapshot: deriveHostAnnotationSnapshot(),
3030
+ });
3031
+ break;
3032
+ }
3033
+ case "host-annotation.clear-overlay": {
3034
+ hostAnnotationOverlay = null;
3035
+ emit({
3036
+ type: "host_annotation_overlay_changed",
3037
+ documentId: state.documentId,
3038
+ snapshot: deriveHostAnnotationSnapshot(),
3039
+ });
3040
+ break;
3041
+ }
3042
+ }
3043
+ for (const listener of listeners) {
3044
+ listener();
3045
+ }
3046
+ }
3047
+
2956
3048
  function emitInternal(event: DocumentRuntimeEvent): void {
2957
3049
  options.onEvent?.(event);
2958
3050
  for (const listener of eventListeners) {
@@ -3111,6 +3203,39 @@ function normalizeCommandTimestamp(value: unknown): string | undefined {
3111
3203
  return undefined;
3112
3204
  }
3113
3205
 
3206
+ type RuntimeStateOverlayCommand = Extract<
3207
+ EditorCommand,
3208
+ {
3209
+ type:
3210
+ | "workflow.set-overlay"
3211
+ | "workflow.clear-overlay"
3212
+ | "workflow.set-metadata-definitions"
3213
+ | "workflow.clear-metadata-definitions"
3214
+ | "workflow.set-metadata-entries"
3215
+ | "workflow.clear-metadata-entries"
3216
+ | "host-annotation.set-overlay"
3217
+ | "host-annotation.clear-overlay";
3218
+ }
3219
+ >;
3220
+
3221
+ function isRuntimeStateOverlayCommand(
3222
+ command: EditorCommand,
3223
+ ): command is RuntimeStateOverlayCommand {
3224
+ switch (command.type) {
3225
+ case "workflow.set-overlay":
3226
+ case "workflow.clear-overlay":
3227
+ case "workflow.set-metadata-definitions":
3228
+ case "workflow.clear-metadata-definitions":
3229
+ case "workflow.set-metadata-entries":
3230
+ case "workflow.clear-metadata-entries":
3231
+ case "host-annotation.set-overlay":
3232
+ case "host-annotation.clear-overlay":
3233
+ return true;
3234
+ default:
3235
+ return false;
3236
+ }
3237
+ }
3238
+
3114
3239
  function finalizeState(
3115
3240
  state: EditorState,
3116
3241
  markDirty: boolean,
@@ -3684,6 +3809,14 @@ const NON_MUTATION_COMMANDS = new Set([
3684
3809
  "warning.add",
3685
3810
  "warning.clear",
3686
3811
  "comment.open",
3812
+ "workflow.set-overlay",
3813
+ "workflow.clear-overlay",
3814
+ "workflow.set-metadata-definitions",
3815
+ "workflow.clear-metadata-definitions",
3816
+ "workflow.set-metadata-entries",
3817
+ "workflow.clear-metadata-entries",
3818
+ "host-annotation.set-overlay",
3819
+ "host-annotation.clear-overlay",
3687
3820
  ]);
3688
3821
 
3689
3822
  /** Mutation commands that are not yet supported in suggesting mode. */
@@ -4008,6 +4141,7 @@ function refreshDocumentTableOfContents(
4008
4141
  selectionHead: number,
4009
4142
  activeStory: EditorStoryTarget,
4010
4143
  options?: TocRefreshOptions,
4144
+ resolveDisplayPageNumber?: (pageIndex: number) => number | null,
4011
4145
  ): {
4012
4146
  document: CanonicalDocumentEnvelope;
4013
4147
  result: TocRefreshResult;
@@ -4038,7 +4172,7 @@ function refreshDocumentTableOfContents(
4038
4172
  }
4039
4173
  const nextField: FieldNode = {
4040
4174
  ...field,
4041
- children: buildTocInlineNodes(entries),
4175
+ children: buildTocInlineNodes(entries, resolveDisplayPageNumber),
4042
4176
  refreshStatus: "current",
4043
4177
  };
4044
4178
  if (flattenInlineDisplayText(nextField.children) !== flattenInlineDisplayText(field.children)) {
@@ -4221,14 +4355,26 @@ function buildInlineNodesFromDisplayText(text: string): InlineNode[] {
4221
4355
  return children;
4222
4356
  }
4223
4357
 
4358
+ /**
4359
+ * P5 — TOC entry rendering with display-number resolution. When
4360
+ * `resolveDisplayPageNumber` is supplied, TOC entries print the number
4361
+ * Word would print on the page (honors `w:pgNumType/@w:start` restarts
4362
+ * for front-matter roman numerals → body arabic restart). Without the
4363
+ * resolver, falls back to the raw `pageIndex + 1` (pre-P5 behavior).
4364
+ */
4224
4365
  function buildTocInlineNodes(
4225
4366
  entries: ReadonlyArray<{ level: number; text: string; pageIndex: number }>,
4367
+ resolveDisplayPageNumber?: (pageIndex: number) => number | null,
4226
4368
  ): InlineNode[] {
4227
4369
  const children: InlineNode[] = [];
4228
4370
  entries.forEach((entry, index) => {
4229
4371
  children.push({ type: "text", text: entry.text });
4230
4372
  children.push({ type: "tab" });
4231
- children.push({ type: "text", text: String(entry.pageIndex + 1) });
4373
+ const displayed = resolveDisplayPageNumber?.(entry.pageIndex);
4374
+ children.push({
4375
+ type: "text",
4376
+ text: String(displayed ?? entry.pageIndex + 1),
4377
+ });
4232
4378
  if (index < entries.length - 1) {
4233
4379
  children.push({ type: "hard_break" });
4234
4380
  }
@@ -4236,6 +4382,14 @@ function buildTocInlineNodes(
4236
4382
  return children;
4237
4383
  }
4238
4384
 
4385
+ /** Test-only export of `buildTocInlineNodes` (P5 unit tests). */
4386
+ export function __buildTocInlineNodes(
4387
+ entries: ReadonlyArray<{ level: number; text: string; pageIndex: number }>,
4388
+ resolveDisplayPageNumber?: (pageIndex: number) => number | null,
4389
+ ): InlineNode[] {
4390
+ return buildTocInlineNodes(entries, resolveDisplayPageNumber);
4391
+ }
4392
+
4239
4393
  function collectFieldsFromSubParts(
4240
4394
  subParts: SubPartsCatalog | undefined,
4241
4395
  entries: FieldEntrySnapshot[],
@@ -4575,8 +4729,38 @@ function remapProtectionSnapshot(
4575
4729
  * let the loader register system fonts it finds via
4576
4730
  * `document.fonts.check(...)`.
4577
4731
  */
4732
+ // P14.d — memoize the font-family walk by `(content, styles)` reference
4733
+ // identity. Both shapes change identity only on a real edit / import /
4734
+ // style mutation; per-keystroke edits keep the same references because
4735
+ // `finalizeState` shallow-spreads `state.document`. WeakMap two-level
4736
+ // cache so the entries free with the documents that own them.
4737
+ const fontLoaderInputCache = new WeakMap<
4738
+ object,
4739
+ WeakMap<object, { families: readonly string[] }>
4740
+ >();
4741
+
4578
4742
  function collectFontLoaderInput(
4579
4743
  document: CanonicalDocumentEnvelope,
4744
+ ): { families: readonly string[] } {
4745
+ const contentKey = document.content as unknown as object;
4746
+ const stylesKey = (document.styles ?? FONT_LOADER_EMPTY_STYLES_KEY) as unknown as object;
4747
+ let stylesCache = fontLoaderInputCache.get(contentKey);
4748
+ if (stylesCache) {
4749
+ const cached = stylesCache.get(stylesKey);
4750
+ if (cached) return cached;
4751
+ } else {
4752
+ stylesCache = new WeakMap();
4753
+ fontLoaderInputCache.set(contentKey, stylesCache);
4754
+ }
4755
+ const result = collectFontLoaderInputUncached(document);
4756
+ stylesCache.set(stylesKey, result);
4757
+ return result;
4758
+ }
4759
+
4760
+ const FONT_LOADER_EMPTY_STYLES_KEY = Object.freeze({});
4761
+
4762
+ function collectFontLoaderInputUncached(
4763
+ document: CanonicalDocumentEnvelope,
4580
4764
  ): { families: readonly string[] } {
4581
4765
  try {
4582
4766
  const families = new Set<string>();
@@ -4604,6 +4788,9 @@ function collectFontLoaderInput(
4604
4788
  }
4605
4789
  }
4606
4790
 
4791
+ /** Test-only export of the uncached walk so memoization tests can spy on it. */
4792
+ export const __collectFontLoaderInputUncached = collectFontLoaderInputUncached;
4793
+
4607
4794
  /**
4608
4795
  * Asynchronously upgrade the engine's measurement backend to canvas once
4609
4796
  * the platform supports it and fonts have resolved. Errors are swallowed
@@ -0,0 +1,117 @@
1
+ import {
2
+ sendToExternal,
3
+ type SendToExternalBlock,
4
+ type SendToExternalResult,
5
+ } from "../io/export/external-send.ts";
6
+ import type {
7
+ ExternalCustodyResolver,
8
+ } from "../api/external-custody-types.ts";
9
+ import type { CollabSessionBridge } from "./collab-session-bridge.ts";
10
+ import { resignPayload } from "./resign-payload.ts";
11
+ import type { PayloadSigner } from "../io/ooxml/payload-signature.ts";
12
+ import type { TamperGate } from "./tamper-gate.ts";
13
+
14
+ /**
15
+ * Runtime-level composition of the P7 pure pipeline with:
16
+ * - the P8c `CollabSessionBridge` (snapshots of negotiation /
17
+ * presentation / participants)
18
+ * - the P8e `TamperGate` (blocks when `metadataIntegrity === "tampered"`)
19
+ * - the P8a `resignPayload()` hook (every write re-signs)
20
+ *
21
+ * Callers supply the raw workflow-payload XML alongside the collab
22
+ * state. On success the result carries:
23
+ * - the custody receipt (to be emitted inside bw:extensions)
24
+ * - the kept snapshots (to replace the pre-send facet state)
25
+ * - the re-signed payload XML (to persist in the shipped docx)
26
+ *
27
+ * This helper does NOT rewrite `word/document.xml` / `word/comments.xml`
28
+ * or the three companion parts; the caller owns the zip rewrite using
29
+ * `result.stripped.commentIds`. This keeps the runtime layer isolated
30
+ * from OPC packaging.
31
+ */
32
+ export interface RuntimeSendToExternalArgs {
33
+ bridge: CollabSessionBridge;
34
+ tamperGate: TamperGate;
35
+ signer: PayloadSigner;
36
+
37
+ /** The raw `<bw:workflowPayload …>…</bw:workflowPayload>` XML. */
38
+ payloadXml: string;
39
+
40
+ role: "author" | "reviewer" | "observer";
41
+
42
+ originDocumentId: string;
43
+ originPayloadId: string;
44
+ /** sha256:{hex} of canonicalized word/document.xml at send time. */
45
+ originContentHash: string;
46
+
47
+ resolver: ExternalCustodyResolver;
48
+ recipient: string;
49
+ sentBy: string;
50
+ archiveRef: string;
51
+
52
+ /** Optional deterministic overrides for tests. */
53
+ custodyId?: string;
54
+ now?: string;
55
+ }
56
+
57
+ export type RuntimeSendToExternalResult =
58
+ | { ok: false; reason: "collab_role_restricted" }
59
+ | { ok: false; reason: "metadata_tampered" }
60
+ | {
61
+ ok: true;
62
+ custody: SendToExternalResult["custody"];
63
+ kept: SendToExternalResult["kept"];
64
+ stripped: SendToExternalResult["stripped"];
65
+ /** Re-signed `<bw:workflowPayload …>…</bw:workflowPayload>` ready to persist. */
66
+ payloadXml: string;
67
+ };
68
+
69
+ export async function runtimeSendToExternal(
70
+ args: RuntimeSendToExternalArgs,
71
+ ): Promise<RuntimeSendToExternalResult> {
72
+ const guard = args.tamperGate.guard();
73
+ if (!guard.ok) {
74
+ return { ok: false, reason: guard.reason };
75
+ }
76
+
77
+ const presentation = args.bridge.getCommentPresentationSnapshot();
78
+ const negotiation = args.bridge.getCommentNegotiationSnapshot();
79
+ const participants = args.bridge.getParticipantRoster();
80
+
81
+ const pipelineArgs = {
82
+ presentation,
83
+ negotiation,
84
+ participants,
85
+ role: args.role,
86
+ metadataIntegrity:
87
+ args.tamperGate.state === "unsigned" ? "verified" : args.tamperGate.state,
88
+ originDocumentId: args.originDocumentId,
89
+ originPayloadId: args.originPayloadId,
90
+ originContentHash: args.originContentHash,
91
+ resolver: args.resolver,
92
+ recipient: args.recipient,
93
+ sentBy: args.sentBy,
94
+ archiveRef: args.archiveRef,
95
+ ...(args.custodyId !== undefined ? { custodyId: args.custodyId } : {}),
96
+ ...(args.now !== undefined ? { now: args.now } : {}),
97
+ } as const;
98
+
99
+ const pipeline: SendToExternalBlock = await sendToExternal(pipelineArgs);
100
+ if (!pipeline.ok) {
101
+ return { ok: false, reason: pipeline.reason };
102
+ }
103
+
104
+ const { payloadXml } = await resignPayload({
105
+ payloadXml: args.payloadXml,
106
+ signer: args.signer,
107
+ ...(args.now !== undefined ? { now: args.now } : {}),
108
+ });
109
+
110
+ return {
111
+ ok: true,
112
+ custody: pipeline.result.custody,
113
+ kept: pipeline.result.kept,
114
+ stripped: pipeline.result.stripped,
115
+ payloadXml,
116
+ };
117
+ }