@beyondwork/docx-react-component 1.0.36 → 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 (107) hide show
  1. package/README.md +103 -13
  2. package/package.json +1 -1
  3. package/src/api/package-version.ts +13 -0
  4. package/src/api/public-types.ts +402 -1
  5. package/src/core/commands/index.ts +18 -1
  6. package/src/core/commands/section-layout-commands.ts +58 -0
  7. package/src/core/commands/table-grid.ts +431 -0
  8. package/src/core/commands/table-structure-commands.ts +815 -55
  9. package/src/core/selection/mapping.ts +6 -0
  10. package/src/io/docx-session.ts +24 -9
  11. package/src/io/export/build-app-properties-xml.ts +88 -0
  12. package/src/io/export/serialize-comments.ts +6 -1
  13. package/src/io/export/serialize-footnotes.ts +10 -9
  14. package/src/io/export/serialize-headers-footers.ts +11 -10
  15. package/src/io/export/serialize-main-document.ts +328 -50
  16. package/src/io/export/serialize-numbering.ts +114 -24
  17. package/src/io/export/serialize-tables.ts +87 -11
  18. package/src/io/export/table-properties-xml.ts +174 -20
  19. package/src/io/export/twip.ts +66 -0
  20. package/src/io/normalize/normalize-text.ts +20 -0
  21. package/src/io/ooxml/parse-footnotes.ts +62 -1
  22. package/src/io/ooxml/parse-headers-footers.ts +62 -1
  23. package/src/io/ooxml/parse-main-document.ts +158 -1
  24. package/src/io/ooxml/parse-tables.ts +249 -0
  25. package/src/legal/bookmarks.ts +78 -0
  26. package/src/model/canonical-document.ts +45 -0
  27. package/src/review/store/scope-tag-diff.ts +130 -0
  28. package/src/runtime/document-layout.ts +4 -2
  29. package/src/runtime/document-navigation.ts +2 -306
  30. package/src/runtime/document-runtime.ts +287 -11
  31. package/src/runtime/layout/default-page-format.ts +96 -0
  32. package/src/runtime/layout/docx-font-loader.ts +143 -0
  33. package/src/runtime/layout/index.ts +233 -0
  34. package/src/runtime/layout/inert-layout-facet.ts +59 -0
  35. package/src/runtime/layout/layout-engine-instance.ts +628 -0
  36. package/src/runtime/layout/layout-invalidation.ts +257 -0
  37. package/src/runtime/layout/layout-measurement-provider.ts +175 -0
  38. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  39. package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
  40. package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
  41. package/src/runtime/layout/page-format-catalog.ts +233 -0
  42. package/src/runtime/layout/page-fragment-mapper.ts +179 -0
  43. package/src/runtime/layout/page-graph.ts +452 -0
  44. package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
  45. package/src/runtime/layout/page-story-resolver.ts +195 -0
  46. package/src/runtime/layout/paginated-layout-engine.ts +921 -0
  47. package/src/runtime/layout/project-block-fragments.ts +91 -0
  48. package/src/runtime/layout/public-facet.ts +1398 -0
  49. package/src/runtime/layout/resolved-formatting-document.ts +317 -0
  50. package/src/runtime/layout/resolved-formatting-state.ts +430 -0
  51. package/src/runtime/layout/table-render-plan.ts +229 -0
  52. package/src/runtime/render/block-fragment-projection.ts +35 -0
  53. package/src/runtime/render/decoration-resolver.ts +189 -0
  54. package/src/runtime/render/index.ts +57 -0
  55. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  56. package/src/runtime/render/render-frame-types.ts +317 -0
  57. package/src/runtime/render/render-kernel.ts +755 -0
  58. package/src/runtime/scope-tag-registry.ts +95 -0
  59. package/src/runtime/surface-projection.ts +1 -0
  60. package/src/runtime/text-ack-range.ts +49 -0
  61. package/src/runtime/view-state.ts +67 -0
  62. package/src/runtime/workflow-markup.ts +1 -5
  63. package/src/runtime/workflow-rail-segments.ts +280 -0
  64. package/src/ui/WordReviewEditor.tsx +99 -15
  65. package/src/ui/editor-runtime-boundary.ts +10 -1
  66. package/src/ui/editor-shell-view.tsx +6 -0
  67. package/src/ui/editor-surface-controller.tsx +3 -0
  68. package/src/ui/headless/chrome-registry.ts +501 -0
  69. package/src/ui/headless/scoped-chrome-policy.ts +183 -0
  70. package/src/ui/headless/selection-tool-context.ts +2 -0
  71. package/src/ui/headless/selection-tool-resolver.ts +36 -17
  72. package/src/ui/headless/selection-tool-types.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
  74. package/src/ui-tailwind/chrome/role-action-sets.ts +74 -0
  75. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  76. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +163 -0
  77. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +57 -92
  78. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  79. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  80. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +274 -138
  81. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +90 -0
  82. package/src/ui-tailwind/chrome-overlay/index.ts +22 -0
  83. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +86 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  85. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +95 -0
  86. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +337 -0
  87. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +100 -0
  88. package/src/ui-tailwind/editor-surface/perf-probe.ts +27 -1
  89. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +20 -2
  90. package/src/ui-tailwind/editor-surface/pm-decorations.ts +93 -23
  91. package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
  92. package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
  93. package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
  94. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +176 -6
  95. package/src/ui-tailwind/index.ts +33 -0
  96. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  97. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  98. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  99. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  100. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  101. package/src/ui-tailwind/theme/editor-theme.css +505 -144
  102. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +559 -0
  103. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  104. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  105. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
  106. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +304 -166
  107. package/src/ui-tailwind/tw-review-workspace.tsx +163 -2
@@ -47,12 +47,14 @@ import type {
47
47
  RuntimeContextAnalyticsQuery,
48
48
  RuntimeContextAnalyticsSnapshot,
49
49
  RuntimeRenderSnapshot,
50
+ ScopeTagTouch,
50
51
  SelectionSnapshot,
51
52
  SnapshotRefreshHints,
52
53
  SuggestionsSnapshot,
53
54
  SurfaceBlockSnapshot,
54
55
  SurfaceInlineSegment,
55
56
  StoryTextStreamSnapshot,
57
+ TextCommandAck,
56
58
  TocSnapshot,
57
59
  StyleCatalogSnapshot,
58
60
  TocRefreshOptions,
@@ -121,6 +123,17 @@ import {
121
123
  createDocumentNavigationSnapshot,
122
124
  findPageForOffset,
123
125
  } from "./document-navigation.ts";
126
+ import {
127
+ createDocxFontLoader,
128
+ createLayoutEngine,
129
+ createLayoutFacet,
130
+ createMeasurementProvider,
131
+ type DocxFontLoader,
132
+ type LayoutEngineInstance,
133
+ type LayoutMeasurementProvider,
134
+ type WordReviewEditorLayoutFacet,
135
+ } from "./layout/index.ts";
136
+ import { createRenderKernel, type RenderKernel } from "./render/index.ts";
124
137
  import {
125
138
  createDocumentOutlineSnapshot,
126
139
  createDocumentSectionSnapshots,
@@ -150,6 +163,7 @@ import {
150
163
  resolveActiveSection,
151
164
  } from "./document-layout.ts";
152
165
  import { normalizeHeaderFooterTarget } from "./story-context.ts";
166
+ import { computeAdjustedRange as computeAdjustedRangeImpl } from "./text-ack-range.ts";
153
167
  import {
154
168
  getStoryBlocks,
155
169
  replaceStoryBlocks,
@@ -213,7 +227,7 @@ export interface DocumentRuntime {
213
227
  getCanonicalDocument(): CanonicalDocumentEnvelope;
214
228
  getSourcePackage(): EditorSessionState["sourcePackage"] | undefined;
215
229
  replaceText(text: string, target?: EditorAnchorProjection): void;
216
- applyActiveStoryTextCommand(command: ActiveStoryTextCommand): void;
230
+ applyActiveStoryTextCommand(command: ActiveStoryTextCommand): TextCommandAck;
217
231
  dispatch(command: EditorCommand): void;
218
232
  emitBlockedCommand(command: string, reasons: WorkflowBlockedCommandReason[]): void;
219
233
  undo(): void;
@@ -243,6 +257,12 @@ export interface DocumentRuntime {
243
257
  setZoom(level: ZoomLevel): void;
244
258
  getPageLayoutSnapshot(): PageLayoutSnapshot | null;
245
259
  getDocumentNavigationSnapshot(): DocumentNavigationSnapshot;
260
+ /**
261
+ * Runtime-owned layout facet. Provides graph-aware queries, fragment
262
+ * resolution, formatting inspection, and layout events. Prefer this over
263
+ * the opaque snapshot methods for new integration code.
264
+ */
265
+ readonly layout: WordReviewEditorLayoutFacet;
246
266
  getCurrentLocation(): DocumentLocationSnapshot | null;
247
267
  getLocationForSelection(selection: SelectionSnapshot): DocumentLocationSnapshot | null;
248
268
  getLocationForAnchor(
@@ -385,6 +405,57 @@ export function createDocumentRuntime(
385
405
  fatalError: options.fatalError as never,
386
406
  });
387
407
  storySelections.set(storyTargetKey(MAIN_STORY_TARGET), state.selection);
408
+
409
+ // Runtime-owned paginated layout engine (Phase 1+ of the layout facet work).
410
+ // The engine caches graph + resolved-formatting + fragment mapper keyed on
411
+ // (content, styles, subParts). It is the single internal source of truth
412
+ // for page composition, story resolution, and layout invalidation.
413
+ //
414
+ // R0 measurement wiring: the engine starts with the sync empirical backend
415
+ // so the runtime is available immediately, then we kick off
416
+ // `createMeasurementProvider({ preference: "auto", fontLoader })` which
417
+ // upgrades to the canvas backend once fonts resolve. `swapMeasurementProvider`
418
+ // emits `measurement_backend_ready` so chrome consumers can re-read metrics.
419
+ const layoutEngine: LayoutEngineInstance = createLayoutEngine();
420
+ const fontLoader: DocxFontLoader = createDocxFontLoader(
421
+ collectFontLoaderInput(state.document),
422
+ );
423
+ void upgradeMeasurementProvider(layoutEngine, fontLoader);
424
+ // `renderKernelRef` is a forward reference so the facet can reach the
425
+ // kernel after it is created below (kernel creation needs the facet).
426
+ let renderKernelRef: RenderKernel | null = null;
427
+ const layoutFacet: WordReviewEditorLayoutFacet = createLayoutFacet({
428
+ engine: layoutEngine,
429
+ getQueryInput: () => ({
430
+ document: state.document,
431
+ viewState: {
432
+ activeStory,
433
+ workspaceMode: viewState.workspaceMode,
434
+ zoomLevel: viewState.zoomLevel,
435
+ },
436
+ }),
437
+ renderKernel: () => renderKernelRef,
438
+ getWorkflowRailInput: () => {
439
+ if (!workflowOverlay) return null;
440
+ const activeWorkItemId = workflowOverlay.activeWorkItemId ?? null;
441
+ const activeWorkItem =
442
+ activeWorkItemId !== null
443
+ ? workflowOverlay.workItems?.find(
444
+ (item) => item.workItemId === activeWorkItemId,
445
+ )
446
+ : undefined;
447
+ return {
448
+ scopes: workflowOverlay.scopes,
449
+ candidates: workflowOverlay.candidates,
450
+ activeWorkItemScopeIds: activeWorkItem?.scopeIds ?? [],
451
+ activeStory,
452
+ };
453
+ },
454
+ });
455
+ renderKernelRef = createRenderKernel({
456
+ facet: layoutFacet,
457
+ getActiveStory: () => activeStory,
458
+ });
388
459
  let cachedSurface:
389
460
  | {
390
461
  revisionToken: string;
@@ -1604,9 +1675,21 @@ export function createDocumentRuntime(
1604
1675
  },
1605
1676
  applyActiveStoryTextCommand(command) {
1606
1677
  try {
1607
- applyTextCommandInActiveStory(command);
1678
+ return applyTextCommandInActiveStory(command);
1608
1679
  } catch (error) {
1609
- emitError(toRuntimeError(error));
1680
+ const runtimeError = toRuntimeError(error);
1681
+ emitError(runtimeError);
1682
+ return {
1683
+ kind: "rejected",
1684
+ opId: (command.origin as { opId?: string } | undefined)?.opId,
1685
+ newRevisionToken: "",
1686
+ blockedReasons: [
1687
+ {
1688
+ code: runtimeError.code ?? "runtime_error",
1689
+ message: runtimeError.message,
1690
+ },
1691
+ ],
1692
+ };
1610
1693
  }
1611
1694
  },
1612
1695
  addComment(params) {
@@ -1813,6 +1896,7 @@ export function createDocumentRuntime(
1813
1896
  getDocumentNavigationSnapshot() {
1814
1897
  return getCachedDocumentNavigationSnapshot(state, activeStory);
1815
1898
  },
1899
+ layout: layoutFacet,
1816
1900
  getCurrentLocation() {
1817
1901
  const navigation = getCachedDocumentNavigationSnapshot(state, activeStory);
1818
1902
  return createCurrentLocation({
@@ -2272,6 +2356,33 @@ export function createDocumentRuntime(
2272
2356
  protectionSnapshot = remapProtectionSnapshot(protectionSnapshot, transaction.mapping);
2273
2357
  state = finalizeState(transaction.nextState, transaction.markDirty, clock());
2274
2358
  storySelections.set(storyTargetKey(activeStory), state.selection);
2359
+
2360
+ // Signal a bounded content-edit invalidation to the layout engine so the
2361
+ // next layout query can splice rather than rebuild the full graph. The
2362
+ // engine analyzes the reason against its cached graph and falls back to
2363
+ // a full rebuild when the edit crosses section boundaries or reaches a
2364
+ // page the engine cannot safely resume from.
2365
+ if (transaction.markDirty && transaction.mapping.steps.length > 0) {
2366
+ let minFrom = Infinity;
2367
+ let maxTo = -Infinity;
2368
+ for (const step of transaction.mapping.steps) {
2369
+ if (step.from < minFrom) minFrom = step.from;
2370
+ const end = step.from + step.insertSize;
2371
+ if (end > maxTo) maxTo = end;
2372
+ }
2373
+ if (minFrom < maxTo) {
2374
+ layoutEngine.invalidate({ kind: "content-edit", from: minFrom, to: maxTo });
2375
+ }
2376
+ }
2377
+
2378
+ // Font-loader refresh on subParts identity change — this is the
2379
+ // lightweight proxy for "a change that could affect which fonts the
2380
+ // canvas backend measures against". Typing edits don't rebuild
2381
+ // subParts; style + font + numbering imports do.
2382
+ if (previous.document.subParts !== state.document.subParts) {
2383
+ fontLoader.refresh(collectFontLoaderInput(state.document));
2384
+ }
2385
+
2275
2386
  cachedRenderSnapshot = refreshRenderSnapshot();
2276
2387
  notify(previous, state, transaction);
2277
2388
  }
@@ -2455,24 +2566,31 @@ export function createDocumentRuntime(
2455
2566
  selection?: EditorState["selection"];
2456
2567
  blockedCommandName?: string;
2457
2568
  } = {},
2458
- ): void {
2569
+ ): TextCommandAck {
2570
+ const opId = (command.origin as { opId?: string } | undefined)?.opId;
2459
2571
  const selection = options.selection ?? state.selection;
2460
2572
  if (
2461
2573
  activeStory.kind !== "main" &&
2462
2574
  getEffectiveDocumentMode(selection) === "suggesting" &&
2463
2575
  command.type === "paragraph.split"
2464
2576
  ) {
2577
+ const message = `"${command.type}" is not supported in suggesting mode for this story.`;
2465
2578
  emit({
2466
2579
  type: "command_blocked",
2467
2580
  documentId: state.documentId,
2468
2581
  command: options.blockedCommandName ?? command.type,
2469
2582
  reasons: [{
2470
2583
  code: "suggesting_unsupported",
2471
- message: `"${command.type}" is not supported in suggesting mode for this story.`,
2584
+ message,
2472
2585
  storyTarget: activeStory,
2473
2586
  }],
2474
2587
  });
2475
- return;
2588
+ return {
2589
+ kind: "rejected",
2590
+ opId,
2591
+ newRevisionToken: "",
2592
+ blockedReasons: [{ code: "suggesting_unsupported", message }],
2593
+ };
2476
2594
  }
2477
2595
  const blockedReasons = evaluateWorkflowBlockedReasons(selection, command.type);
2478
2596
  if (blockedReasons.length > 0) {
@@ -2482,7 +2600,12 @@ export function createDocumentRuntime(
2482
2600
  command: options.blockedCommandName ?? command.type,
2483
2601
  reasons: blockedReasons,
2484
2602
  });
2485
- return;
2603
+ return {
2604
+ kind: "rejected",
2605
+ opId,
2606
+ newRevisionToken: "",
2607
+ blockedReasons: blockedReasons.map((r) => ({ code: r.code, message: r.message })),
2608
+ };
2486
2609
  }
2487
2610
 
2488
2611
  const timestamp = command.origin?.timestamp ?? clock();
@@ -2499,8 +2622,15 @@ export function createDocumentRuntime(
2499
2622
  };
2500
2623
 
2501
2624
  if (activeStory.kind === "main") {
2502
- commit(executeEditorCommand(baseState, command, context));
2503
- return;
2625
+ const mainTransaction = executeEditorCommand(baseState, command, context);
2626
+ commit(mainTransaction);
2627
+ return classifyAck({
2628
+ command,
2629
+ opId,
2630
+ priorState: baseState,
2631
+ transaction: mainTransaction,
2632
+ newRevisionToken: state.revisionToken,
2633
+ });
2504
2634
  }
2505
2635
 
2506
2636
  const localState = createEditorState({
@@ -2531,7 +2661,11 @@ export function createDocumentRuntime(
2531
2661
  historyBoundary: "skip",
2532
2662
  markDirty: false,
2533
2663
  });
2534
- return;
2664
+ return {
2665
+ kind: "equivalent",
2666
+ opId,
2667
+ newRevisionToken: state.revisionToken,
2668
+ };
2535
2669
  }
2536
2670
 
2537
2671
  const nextDocument = replaceStoryBlocks(
@@ -2561,12 +2695,87 @@ export function createDocumentRuntime(
2561
2695
  context,
2562
2696
  );
2563
2697
 
2564
- commit({
2698
+ const mergedTransaction: EditorTransaction = {
2565
2699
  ...fullTransaction,
2566
2700
  effects: mergeTransactionEffects(fullTransaction.effects, localTransaction.effects),
2701
+ };
2702
+ commit(mergedTransaction);
2703
+ return classifyAck({
2704
+ command,
2705
+ opId,
2706
+ priorState: baseState,
2707
+ transaction: mergedTransaction,
2708
+ newRevisionToken: state.revisionToken,
2567
2709
  });
2568
2710
  }
2569
2711
 
2712
+ function classifyAck(params: {
2713
+ command: ActiveStoryTextCommand;
2714
+ opId: string | undefined;
2715
+ priorState: EditorState;
2716
+ transaction: EditorTransaction;
2717
+ newRevisionToken: string;
2718
+ }): TextCommandAck {
2719
+ const { opId, priorState, transaction, newRevisionToken } = params;
2720
+ const meta = transaction.mapping.metadata ?? {};
2721
+ const touches: readonly ScopeTagTouch[] =
2722
+ (meta.scopeTagTouches as readonly ScopeTagTouch[] | undefined) ?? [];
2723
+
2724
+ if (meta.invalidatesStructures) {
2725
+ return {
2726
+ kind: "structural-divergence",
2727
+ opId,
2728
+ newRevisionToken,
2729
+ scopeTagTouches: touches,
2730
+ };
2731
+ }
2732
+
2733
+ // A real touch means the runtime actually changed a tag anchor — not
2734
+ // merely "a text edit happened and might conceivably have touched one".
2735
+ // The coarse `affectsComments` / `affectsRevisions` flags today are set
2736
+ // unconditionally by the text-transaction pipeline, so we cannot trust
2737
+ // them to distinguish equivalent from adjusted. `scopeTagTouches` is the
2738
+ // fine-grained truth that the predicted lane needs.
2739
+ const touchedForAdjusted = touches.some(
2740
+ (t) =>
2741
+ t.behavior === "extended" ||
2742
+ t.behavior === "trimmed" ||
2743
+ t.behavior === "split" ||
2744
+ t.behavior === "detached",
2745
+ );
2746
+
2747
+ if (touchedForAdjusted) {
2748
+ const adjustedRange = computeAdjustedRange(priorState, transaction);
2749
+ return {
2750
+ kind: "adjusted",
2751
+ opId,
2752
+ newRevisionToken,
2753
+ adjustedRange,
2754
+ scopeTagTouches: touches,
2755
+ };
2756
+ }
2757
+
2758
+ return {
2759
+ kind: "equivalent",
2760
+ opId,
2761
+ newRevisionToken,
2762
+ scopeTagTouches: touches,
2763
+ };
2764
+ }
2765
+
2766
+ function computeAdjustedRange(
2767
+ prior: EditorState,
2768
+ transaction: EditorTransaction,
2769
+ ): { fromRuntime: number; toRuntime: number } {
2770
+ return computeAdjustedRangeImpl(
2771
+ { from: prior.selection.anchor, to: prior.selection.head },
2772
+ transaction.mapping.steps.map((step) => ({
2773
+ from: step.from,
2774
+ insertSize: step.insertSize,
2775
+ })),
2776
+ );
2777
+ }
2778
+
2570
2779
  function mergeTransactionEffects(
2571
2780
  base: EditorTransaction["effects"],
2572
2781
  local: EditorTransaction["effects"],
@@ -4228,3 +4437,70 @@ function remapProtectionSnapshot(
4228
4437
  preservedRangeCount: nextRanges.filter((range) => !range.enforced).length,
4229
4438
  };
4230
4439
  }
4440
+
4441
+ // ---------------------------------------------------------------------------
4442
+ // Measurement provider wiring (R0)
4443
+ // ---------------------------------------------------------------------------
4444
+
4445
+ /**
4446
+ * Build the initial input the `DocxFontLoader` needs: a list of font
4447
+ * families the document actively uses, plus any embedded font payloads the
4448
+ * import pipeline may have extracted.
4449
+ *
4450
+ * Walks the document content tree once per call. Embedded font extraction
4451
+ * is not yet wired into the canonical model; we pass an empty map today and
4452
+ * let the loader register system fonts it finds via
4453
+ * `document.fonts.check(...)`.
4454
+ */
4455
+ function collectFontLoaderInput(
4456
+ document: CanonicalDocumentEnvelope,
4457
+ ): { families: readonly string[] } {
4458
+ try {
4459
+ const families = new Set<string>();
4460
+ const visit = (node: unknown): void => {
4461
+ if (!node || typeof node !== "object") return;
4462
+ const record = node as Record<string, unknown>;
4463
+ const rpr = record["runProperties"] as
4464
+ | Record<string, unknown>
4465
+ | undefined;
4466
+ if (rpr && typeof rpr["fontFamily"] === "string") {
4467
+ families.add(rpr["fontFamily"] as string);
4468
+ }
4469
+ for (const value of Object.values(record)) {
4470
+ if (Array.isArray(value)) value.forEach(visit);
4471
+ else if (value && typeof value === "object") visit(value);
4472
+ }
4473
+ };
4474
+ visit(document.content);
4475
+ if (document.styles) {
4476
+ visit(document.styles);
4477
+ }
4478
+ return { families: Array.from(families) };
4479
+ } catch {
4480
+ return { families: [] };
4481
+ }
4482
+ }
4483
+
4484
+ /**
4485
+ * Asynchronously upgrade the engine's measurement backend to canvas once
4486
+ * the platform supports it and fonts have resolved. Errors are swallowed
4487
+ * so a failure in the upgrade path can never break the empirical baseline.
4488
+ */
4489
+ async function upgradeMeasurementProvider(
4490
+ engine: LayoutEngineInstance,
4491
+ fontLoader: DocxFontLoader,
4492
+ ): Promise<void> {
4493
+ try {
4494
+ const provider: LayoutMeasurementProvider = await createMeasurementProvider({
4495
+ preference: "auto",
4496
+ fontLoader,
4497
+ });
4498
+ // If the host is running in SSR or a jsdom test shell, the factory will
4499
+ // fall back to the empirical backend. In that case swapping is a no-op
4500
+ // but still emits `measurement_backend_ready` with `empirical` which is
4501
+ // informational; chrome consumers use the event to refresh metrics.
4502
+ engine.swapMeasurementProvider(provider);
4503
+ } catch {
4504
+ // fall through — the empirical backend remains in place
4505
+ }
4506
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Locale-aware default page format.
3
+ *
4
+ * Word historically defaults to US Letter for `en-US` hosts and A4 everywhere
5
+ * else. This module is the single place that decides which format a
6
+ * newly-created document uses when no section carries an explicit `w:pgSz`.
7
+ *
8
+ * It never overrides an existing section's page size on import — importers
9
+ * always preserve what the source document specified. The default is
10
+ * consulted in two places:
11
+ *
12
+ * 1. `serialize-main-document.ts` when the canonical model carries a
13
+ * section with no `pageSize` (e.g. programmatic document construction).
14
+ * 2. `DocumentRuntime` when rendering a brand-new blank document.
15
+ */
16
+
17
+ import {
18
+ getPageFormatById,
19
+ type PageFormatDefinition,
20
+ } from "./page-format-catalog.ts";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Locale resolution
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /**
27
+ * Which BCP-47 language tags should fall back to Letter.
28
+ *
29
+ * Anything else defaults to A4. This intentionally uses a small whitelist
30
+ * rather than a `startsWith("en")` check — `en-GB`, `en-AU`, `en-IN` all
31
+ * expect ISO paper sizes, not US Letter.
32
+ */
33
+ const LETTER_LOCALES = new Set<string>([
34
+ "en-us",
35
+ "en-ca",
36
+ "fr-ca",
37
+ "es-mx",
38
+ "es-cl",
39
+ "fil-ph",
40
+ ]);
41
+
42
+ export interface ResolveDefaultPageFormatOptions {
43
+ /** Explicit BCP-47 locale (e.g. "en-US", "de-DE"). */
44
+ locale?: string;
45
+ }
46
+
47
+ /**
48
+ * Return the default `PageFormatDefinition` for a given locale.
49
+ *
50
+ * When `locale` is omitted the function consults `Intl.DateTimeFormat` to
51
+ * infer the current locale. When `Intl` is unavailable (unusual in modern
52
+ * runtimes) it falls back to A4 as the safer international default.
53
+ */
54
+ export function resolveDefaultPageFormat(
55
+ options: ResolveDefaultPageFormatOptions = {},
56
+ ): PageFormatDefinition {
57
+ const raw = options.locale ?? tryResolveHostLocale();
58
+ if (!raw) {
59
+ return getPageFormatById("a4");
60
+ }
61
+ const normalized = raw.toLowerCase();
62
+ if (LETTER_LOCALES.has(normalized)) {
63
+ return getPageFormatById("letter");
64
+ }
65
+ // Match just the language-region prefix (e.g. "en-us-1234" → "en-us")
66
+ const region = normalized.split("-").slice(0, 2).join("-");
67
+ if (LETTER_LOCALES.has(region)) {
68
+ return getPageFormatById("letter");
69
+ }
70
+ return getPageFormatById("a4");
71
+ }
72
+
73
+ function tryResolveHostLocale(): string | undefined {
74
+ try {
75
+ if (typeof Intl === "undefined" || typeof Intl.DateTimeFormat !== "function") {
76
+ return undefined;
77
+ }
78
+ return new Intl.DateTimeFormat().resolvedOptions().locale;
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Return the default `w:pgSz` payload (width/height in twips) for a given
86
+ * locale. Convenience wrapper used by the export pipeline.
87
+ */
88
+ export function resolveDefaultPageSizeTwips(
89
+ options: ResolveDefaultPageFormatOptions = {},
90
+ ): { widthTwips: number; heightTwips: number } {
91
+ const format = resolveDefaultPageFormat(options);
92
+ return {
93
+ widthTwips: format.portraitWidthTwips,
94
+ heightTwips: format.portraitHeightTwips,
95
+ };
96
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * DocxFontLoader — best-effort registration of the document's declared font
3
+ * families with the browser's FontFace registry.
4
+ *
5
+ * Scope:
6
+ * - For each font family the document uses, resolve whether the browser
7
+ * can already render it.
8
+ * - If the package ships embedded font binary data, register each face via
9
+ * `new FontFace(family, data).load()` then `document.fonts.add(face)`.
10
+ * - Wait on `document.fonts.ready` to know when layout-affecting fonts are
11
+ * available, so the Canvas backend is measuring against real metrics.
12
+ *
13
+ * Non-goals:
14
+ * - This loader does not attempt to fetch fonts from external CDNs.
15
+ * - It does not attempt style-matching with Panose; that belongs in a
16
+ * font-substitution pass if we ever need it.
17
+ * - SSR and gRPC never run this loader.
18
+ */
19
+
20
+ export interface FontLoaderInput {
21
+ /** Font family names the document actively uses. */
22
+ families: readonly string[];
23
+ /**
24
+ * Optional embedded font payloads keyed by family name (uppercase-insensitive).
25
+ * Each entry holds binary data for regular / bold / italic / bold-italic
26
+ * variants. Callers may omit any variant; the loader will register only
27
+ * what is provided.
28
+ */
29
+ embeddedFontBytes?: Map<string, EmbeddedFontBytes>;
30
+ }
31
+
32
+ export interface EmbeddedFontBytes {
33
+ regular?: ArrayBuffer;
34
+ bold?: ArrayBuffer;
35
+ italic?: ArrayBuffer;
36
+ boldItalic?: ArrayBuffer;
37
+ }
38
+
39
+ export interface DocxFontLoader {
40
+ whenReady(): Promise<void>;
41
+ isSupported(): boolean;
42
+ /** Which families are currently registered or detected as available. */
43
+ getRegisteredFamilies(): readonly string[];
44
+ /** Force re-resolution of the ready promise (e.g. after adding more fonts). */
45
+ refresh(input: FontLoaderInput): void;
46
+ }
47
+
48
+ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
49
+ const supported =
50
+ typeof document !== "undefined" &&
51
+ typeof (globalThis as { FontFace?: unknown }).FontFace !== "undefined" &&
52
+ // Guard against jsdom which exposes FontFace but not document.fonts
53
+ Boolean((document as Document & { fonts?: FontFaceSet }).fonts);
54
+
55
+ let current: FontLoaderInput = initial;
56
+ let readyPromise: Promise<void>;
57
+ const registered = new Set<string>();
58
+
59
+ function run(input: FontLoaderInput): Promise<void> {
60
+ if (!supported) return Promise.resolve();
61
+ const fontSet = (document as Document & { fonts?: FontFaceSet }).fonts;
62
+ if (!fontSet) return Promise.resolve();
63
+
64
+ const pending: Array<Promise<unknown>> = [];
65
+
66
+ if (input.embeddedFontBytes) {
67
+ for (const [familyRaw, variants] of input.embeddedFontBytes) {
68
+ const family = familyRaw.trim();
69
+ if (!family) continue;
70
+
71
+ for (const [descriptor, data] of variantsOf(variants)) {
72
+ try {
73
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
74
+ const FontFaceCtor = (globalThis as any).FontFace as {
75
+ new (family: string, source: BufferSource, descriptors?: FontFaceDescriptors): FontFace;
76
+ };
77
+ const face = new FontFaceCtor(family, data, descriptor);
78
+ pending.push(
79
+ face.load().then((loaded) => {
80
+ fontSet.add(loaded);
81
+ registered.add(family);
82
+ }),
83
+ );
84
+ } catch {
85
+ // Single-face failures should not fail the whole batch.
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ // Mark declared families as registered if the browser already resolves
92
+ // them (e.g. system fonts like Calibri, Arial).
93
+ for (const family of input.families) {
94
+ try {
95
+ const probe = `12px "${family.replace(/"/g, "'")}", serif`;
96
+ if (fontSet.check(probe)) {
97
+ registered.add(family);
98
+ }
99
+ } catch {
100
+ // ignore
101
+ }
102
+ }
103
+
104
+ return Promise.all(pending)
105
+ .then(() => fontSet.ready)
106
+ .then(() => undefined);
107
+ }
108
+
109
+ readyPromise = run(current);
110
+
111
+ return {
112
+ whenReady() {
113
+ return readyPromise;
114
+ },
115
+ isSupported() {
116
+ return supported;
117
+ },
118
+ getRegisteredFamilies() {
119
+ return Array.from(registered);
120
+ },
121
+ refresh(input: FontLoaderInput) {
122
+ current = input;
123
+ readyPromise = run(current);
124
+ },
125
+ };
126
+ }
127
+
128
+ function* variantsOf(
129
+ variants: EmbeddedFontBytes,
130
+ ): IterableIterator<[FontFaceDescriptors, ArrayBuffer]> {
131
+ if (variants.regular) {
132
+ yield [{ weight: "400", style: "normal" }, variants.regular];
133
+ }
134
+ if (variants.bold) {
135
+ yield [{ weight: "700", style: "normal" }, variants.bold];
136
+ }
137
+ if (variants.italic) {
138
+ yield [{ weight: "400", style: "italic" }, variants.italic];
139
+ }
140
+ if (variants.boldItalic) {
141
+ yield [{ weight: "700", style: "italic" }, variants.boldItalic];
142
+ }
143
+ }