@beyondwork/docx-react-component 1.0.41 → 1.0.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/package.json +38 -37
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/editor-state-types.ts +110 -0
  6. package/src/api/external-custody-types.ts +74 -0
  7. package/src/api/participants-types.ts +18 -0
  8. package/src/api/public-types.ts +541 -5
  9. package/src/api/scope-metadata-resolver-types.ts +88 -0
  10. package/src/core/commands/formatting-commands.ts +1 -1
  11. package/src/core/commands/index.ts +601 -9
  12. package/src/core/search/search-text.ts +15 -2
  13. package/src/index.ts +131 -1
  14. package/src/io/docx-session.ts +672 -2
  15. package/src/io/export/escape-xml-attribute.ts +26 -0
  16. package/src/io/export/external-send.ts +188 -0
  17. package/src/io/export/serialize-comments.ts +13 -16
  18. package/src/io/export/serialize-footnotes.ts +17 -24
  19. package/src/io/export/serialize-headers-footers.ts +17 -24
  20. package/src/io/export/serialize-main-document.ts +59 -62
  21. package/src/io/export/serialize-numbering.ts +20 -27
  22. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  23. package/src/io/export/serialize-tables.ts +8 -15
  24. package/src/io/export/table-properties-xml.ts +25 -32
  25. package/src/io/import/external-reimport.ts +40 -0
  26. package/src/io/load-scheduler.ts +230 -0
  27. package/src/io/normalize/normalize-text.ts +83 -0
  28. package/src/io/ooxml/bw-xml.ts +244 -0
  29. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  30. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  31. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  32. package/src/io/ooxml/external-custody-payload.ts +102 -0
  33. package/src/io/ooxml/participants-payload.ts +97 -0
  34. package/src/io/ooxml/payload-signature.ts +112 -0
  35. package/src/io/ooxml/workflow-payload-validator.ts +367 -0
  36. package/src/io/ooxml/workflow-payload.ts +317 -7
  37. package/src/runtime/awareness-identity.ts +173 -0
  38. package/src/runtime/collab/event-types.ts +27 -0
  39. package/src/runtime/collab-session-bridge.ts +157 -0
  40. package/src/runtime/collab-session-facet.ts +193 -0
  41. package/src/runtime/collab-session.ts +273 -0
  42. package/src/runtime/comment-negotiation-sync.ts +91 -0
  43. package/src/runtime/comment-negotiation.ts +158 -0
  44. package/src/runtime/comment-presentation.ts +223 -0
  45. package/src/runtime/document-runtime.ts +639 -124
  46. package/src/runtime/editor-state-channel.ts +544 -0
  47. package/src/runtime/editor-state-integration.ts +217 -0
  48. package/src/runtime/external-send-runtime.ts +117 -0
  49. package/src/runtime/layout/docx-font-loader.ts +11 -30
  50. package/src/runtime/layout/index.ts +2 -0
  51. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  52. package/src/runtime/layout/layout-engine-instance.ts +139 -14
  53. package/src/runtime/layout/page-graph.ts +79 -7
  54. package/src/runtime/layout/paginated-layout-engine.ts +441 -48
  55. package/src/runtime/layout/public-facet.ts +585 -14
  56. package/src/runtime/layout/table-row-split.ts +316 -0
  57. package/src/runtime/markdown-sanitizer.ts +132 -0
  58. package/src/runtime/participants.ts +134 -0
  59. package/src/runtime/perf-counters.ts +28 -0
  60. package/src/runtime/render/render-frame-types.ts +17 -0
  61. package/src/runtime/render/render-kernel.ts +172 -29
  62. package/src/runtime/resign-payload.ts +120 -0
  63. package/src/runtime/surface-projection.ts +10 -5
  64. package/src/runtime/tamper-gate.ts +157 -0
  65. package/src/runtime/workflow-markup.ts +80 -16
  66. package/src/runtime/workflow-rail-segments.ts +244 -5
  67. package/src/ui/WordReviewEditor.tsx +654 -45
  68. package/src/ui/editor-command-bag.ts +14 -0
  69. package/src/ui/editor-runtime-boundary.ts +111 -11
  70. package/src/ui/editor-shell-view.tsx +21 -0
  71. package/src/ui/editor-surface-controller.tsx +5 -0
  72. package/src/ui/headless/selection-helpers.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  74. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  75. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  76. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  77. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  78. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  79. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  80. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  81. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  82. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  83. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  84. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  85. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  86. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  87. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  88. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  89. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  90. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +106 -0
  91. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  92. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  93. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  94. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  95. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  96. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  97. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  98. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  99. package/src/ui-tailwind/editor-surface/pm-schema.ts +167 -17
  100. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  101. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  102. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  103. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +10 -256
  104. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  105. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  106. package/src/ui-tailwind/index.ts +37 -1
  107. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  108. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  109. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  110. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  111. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  112. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  113. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  114. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  115. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  116. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  117. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  118. package/src/ui-tailwind/tw-review-workspace.tsx +455 -118
@@ -0,0 +1,217 @@
1
+ /**
2
+ * editor-state-integration.ts — Schema 1.2 Task D + E helpers.
3
+ *
4
+ * Factored out of document-runtime.ts / docx-session.ts to keep
5
+ * those files focused. Two entry points:
6
+ *
7
+ * - `hydrateEditorStateFromEnvelope`: called right after the runtime
8
+ * is created and the envelope parsed; drives the load-path.
9
+ * - `collectEditorStateForSerialize`: called inside exportDocx before
10
+ * `buildWorkflowPayloadParts`; drives the save-path.
11
+ */
12
+
13
+ import type { EditorStateNamespace } from "../api/editor-state-types.ts";
14
+ import type { EditorStateChannel } from "./editor-state-channel.ts";
15
+ import type {
16
+ EditorStatePayload,
17
+ EditorStatePayloadNamespaceEntry,
18
+ } from "../io/ooxml/workflow-payload.ts";
19
+
20
+ // All namespaces the runtime currently knows about.
21
+ export const ALL_EDITOR_STATE_NAMESPACES: readonly EditorStateNamespace[] = [
22
+ "hostAnnotations",
23
+ "workflowOverlay",
24
+ "workflowMetadata",
25
+ "workItems",
26
+ ] as const;
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Load-path: hydrateEditorStateFromEnvelope
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export interface HydrateEditorStateArgs {
33
+ /** The editorState block parsed from the workflow-payload envelope. */
34
+ editorState: EditorStatePayload;
35
+ channel: EditorStateChannel;
36
+ /**
37
+ * Called for each namespace whose blob has been resolved/loaded.
38
+ * Responsible for applying the blob to the appropriate subsystem store
39
+ * (e.g. calling runtime.setHostAnnotationOverlay).
40
+ */
41
+ applyBlob: (namespace: EditorStateNamespace, data: unknown) => void;
42
+ }
43
+
44
+ /**
45
+ * Drives the load-path for schema 1.2 editor-state entries.
46
+ *
47
+ * - Unknown namespaces → preserved opaquely, `unknown_namespace` event.
48
+ * - Inline entries under in-document policy → applied directly.
49
+ * - Keyed entries → resolver called; result applied (or failure handled).
50
+ * - Policy mismatch → `policy_migrated` event.
51
+ *
52
+ * Returns a promise that resolves when all namespaces have been
53
+ * processed. Rejects only when `onResolveError` is `"block"` for a
54
+ * namespace and the resolver fails — the caller should fail the load.
55
+ */
56
+ export async function hydrateEditorStateFromEnvelope(
57
+ args: HydrateEditorStateArgs,
58
+ ): Promise<void> {
59
+ const { editorState, channel, applyBlob } = args;
60
+
61
+ // Record unknown namespaces first — they are preserved opaquely by
62
+ // the payload layer; we emit the warning event AND hand the raw XML
63
+ // to the channel so the next save round-trips it verbatim.
64
+ for (const unknown of editorState.unknownNamespaces ?? []) {
65
+ channel.recordUnknownNamespace(unknown.name, { rawXml: unknown.rawXml });
66
+ }
67
+
68
+ for (const entry of editorState.entries) {
69
+ await hydrateEntry({ entry, channel, applyBlob });
70
+ }
71
+ }
72
+
73
+ async function hydrateEntry(args: {
74
+ entry: EditorStatePayloadNamespaceEntry;
75
+ channel: EditorStateChannel;
76
+ applyBlob: (namespace: EditorStateNamespace, data: unknown) => void;
77
+ }): Promise<void> {
78
+ const { entry, channel, applyBlob } = args;
79
+ const ns = entry.namespace;
80
+ const policyEntry = channel.getPolicyEntry(ns);
81
+
82
+ // Malformed inline JSON: surface as a load failure; don't apply.
83
+ if (entry.malformedInline) {
84
+ channel.recordLoadFailure({
85
+ namespace: ns,
86
+ error: new Error(`Malformed inline JSON for namespace "${ns}"`),
87
+ fallback: policyEntry.onResolveError === "block" ? "empty" : policyEntry.onResolveError,
88
+ });
89
+ return;
90
+ }
91
+
92
+ // Policy-migration detection: compare payload-written location vs
93
+ // current policy location.
94
+ const payloadLocation: string = entry.storageRef
95
+ ? entry.storageRef.location
96
+ : "in-document";
97
+ if (payloadLocation !== policyEntry.location) {
98
+ channel.recordPolicyMigration({
99
+ namespace: ns,
100
+ from: payloadLocation as import("../api/editor-state-types.ts").EditorStateLocation,
101
+ to: policyEntry.location,
102
+ key: entry.storageRef?.entryKey,
103
+ });
104
+ }
105
+
106
+ // Inline path: apply directly when both payload and policy agree on
107
+ // in-document.
108
+ if (entry.inline !== undefined && policyEntry.location === "in-document") {
109
+ applyBlob(ns, entry.inline);
110
+ channel.recordLoaded(ns, {
111
+ namespace: ns,
112
+ schemaVersion: entry.schemaVersion,
113
+ data: entry.inline,
114
+ });
115
+ return;
116
+ }
117
+
118
+ // Keyed path: set the key from the payload, then call the resolver.
119
+ if (entry.storageRef) {
120
+ channel.setKey(ns, entry.storageRef.entryKey);
121
+ // Under keyed policy the resolver wins over any inline blob.
122
+ const result = await channel.resolve(ns, entry.storageRef.entryKey);
123
+ if (result.blob !== null) {
124
+ applyBlob(ns, result.blob.data);
125
+ if (!result.appliedFallback) {
126
+ channel.recordLoaded(ns, result.blob);
127
+ }
128
+ }
129
+ // result.blob === null → failure already handled by channel (event
130
+ // emitted, fallback mode applied). Nothing more to apply.
131
+ return;
132
+ }
133
+
134
+ // Mismatch: payload is inline but policy is keyed. Apply inline as
135
+ // fallback (no key to resolve against).
136
+ if (entry.inline !== undefined) {
137
+ applyBlob(ns, entry.inline);
138
+ channel.recordLoaded(ns, {
139
+ namespace: ns,
140
+ schemaVersion: entry.schemaVersion,
141
+ data: entry.inline,
142
+ });
143
+ }
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Save-path: collectEditorStateForSerialize
148
+ // ---------------------------------------------------------------------------
149
+
150
+ export interface CollectEditorStateArgs {
151
+ channel: EditorStateChannel;
152
+ /**
153
+ * Returns the current in-memory blob for a namespace, or null if
154
+ * the namespace has no data to persist.
155
+ */
156
+ getNamespaceData: (ns: EditorStateNamespace) => { schemaVersion: string; data: unknown } | null;
157
+ }
158
+
159
+ /**
160
+ * Builds the `EditorStatePayload` for the serializer.
161
+ *
162
+ * 1. Flushes any pending debounced persists so the docx captures
163
+ * the last-known-good state for rowstore namespaces.
164
+ * 2. For each namespace with data: emits inline or storageRef per policy.
165
+ * 3. Returns undefined when no namespaces have data — the serializer
166
+ * then omits `<bw:editorState>` entirely (downgrade to 1.1/1.0).
167
+ */
168
+ export async function collectEditorStateForSerialize(
169
+ args: CollectEditorStateArgs,
170
+ ): Promise<EditorStatePayload | undefined> {
171
+ const { channel, getNamespaceData } = args;
172
+
173
+ // Flush pending debounced persists before serialize resolves.
174
+ await channel.flush();
175
+
176
+ const entries: EditorStatePayloadNamespaceEntry[] = [];
177
+
178
+ for (const ns of ALL_EDITOR_STATE_NAMESPACES) {
179
+ const current = getNamespaceData(ns);
180
+ if (!current) continue;
181
+
182
+ const policyEntry = channel.getPolicyEntry(ns);
183
+
184
+ if (policyEntry.location === "in-document") {
185
+ entries.push({
186
+ namespace: ns,
187
+ schemaVersion: current.schemaVersion,
188
+ inline: current.data,
189
+ });
190
+ } else {
191
+ const key = channel.getKey(ns);
192
+ if (!key) continue; // Keyed policy without a key — can't serialize ref.
193
+ entries.push({
194
+ namespace: ns,
195
+ schemaVersion: current.schemaVersion,
196
+ storageRef: {
197
+ location: policyEntry.location as Exclude<
198
+ import("../api/editor-state-types.ts").EditorStateLocation,
199
+ "in-document"
200
+ >,
201
+ entryKey: key,
202
+ },
203
+ });
204
+ }
205
+ }
206
+
207
+ const unknownEntries = channel.getUnknownEntries();
208
+ const unknownNamespaces = unknownEntries
209
+ .filter((u): u is typeof u & { rawXml: string } => typeof u.rawXml === "string")
210
+ .map((u) => ({ name: u.name, rawXml: u.rawXml }));
211
+
212
+ if (entries.length === 0 && unknownNamespaces.length === 0) return undefined;
213
+ return {
214
+ entries,
215
+ ...(unknownNamespaces.length > 0 ? { unknownNamespaces } : {}),
216
+ };
217
+ }
@@ -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
+ }
@@ -45,35 +45,12 @@ export interface DocxFontLoader {
45
45
  refresh(input: FontLoaderInput): void;
46
46
  }
47
47
 
48
- interface MinimalFontFace {
49
- load(): Promise<MinimalFontFace>;
50
- }
51
-
52
- interface MinimalFontFaceDescriptors {
53
- weight?: string;
54
- style?: string;
55
- }
56
-
57
- interface MinimalFontFaceConstructor {
58
- new (
59
- family: string,
60
- source: ArrayBuffer | ArrayBufferView | string,
61
- descriptors?: MinimalFontFaceDescriptors,
62
- ): MinimalFontFace;
63
- }
64
-
65
- interface MinimalFontFaceSet {
66
- add(face: MinimalFontFace): void;
67
- check(font: string): boolean;
68
- ready: Promise<MinimalFontFaceSet>;
69
- }
70
-
71
48
  export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
72
- const globalDocument = (globalThis as unknown as { document?: { fonts?: unknown } }).document;
73
49
  const supported =
74
- globalDocument !== undefined &&
50
+ typeof document !== "undefined" &&
75
51
  typeof (globalThis as { FontFace?: unknown }).FontFace !== "undefined" &&
76
- Boolean(globalDocument.fonts);
52
+ // Guard against jsdom which exposes FontFace but not document.fonts
53
+ Boolean((document as Document & { fonts?: FontFaceSet }).fonts);
77
54
 
78
55
  let current: FontLoaderInput = initial;
79
56
  let readyPromise: Promise<void>;
@@ -81,7 +58,7 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
81
58
 
82
59
  function run(input: FontLoaderInput): Promise<void> {
83
60
  if (!supported) return Promise.resolve();
84
- const fontSet = globalDocument?.fonts as MinimalFontFaceSet | undefined;
61
+ const fontSet = (document as Document & { fonts?: FontFaceSet }).fonts;
85
62
  if (!fontSet) return Promise.resolve();
86
63
 
87
64
  const pending: Array<Promise<unknown>> = [];
@@ -93,8 +70,10 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
93
70
 
94
71
  for (const [descriptor, data] of variantsOf(variants)) {
95
72
  try {
96
- const FontFaceCtor = (globalThis as { FontFace?: MinimalFontFaceConstructor }).FontFace;
97
- if (!FontFaceCtor) continue;
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
+ };
98
77
  const face = new FontFaceCtor(family, data, descriptor);
99
78
  pending.push(
100
79
  face.load().then((loaded) => {
@@ -109,6 +88,8 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
109
88
  }
110
89
  }
111
90
 
91
+ // Mark declared families as registered if the browser already resolves
92
+ // them (e.g. system fonts like Calibri, Arial).
112
93
  for (const family of input.families) {
113
94
  try {
114
95
  const probe = `12px "${family.replace(/"/g, "'")}", serif`;
@@ -146,7 +127,7 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
146
127
 
147
128
  function* variantsOf(
148
129
  variants: EmbeddedFontBytes,
149
- ): IterableIterator<[MinimalFontFaceDescriptors, ArrayBuffer]> {
130
+ ): IterableIterator<[FontFaceDescriptors, ArrayBuffer]> {
150
131
  if (variants.regular) {
151
132
  yield [{ weight: "400", style: "normal" }, variants.regular];
152
133
  }
@@ -172,6 +172,8 @@ export {
172
172
  type PublicPageNode,
173
173
  type PublicPageRegions,
174
174
  type PublicPageRegion,
175
+ type PublicRegionBlock,
176
+ type PublicRegionKind,
175
177
  type PublicBlockFragment,
176
178
  type PublicLineBox,
177
179
  type PublicNoteAllocation,
@@ -35,6 +35,9 @@ export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
35
35
  getDisplayPageNumber: () => null,
36
36
  getLineBoxes: () => [],
37
37
  getLineBoxesForRegion: () => [],
38
+ getStoryRegionsOnPage: () => [],
39
+ getStoryBlocksForRegion: () => [],
40
+ getDocumentEndnoteBlocks: () => [],
38
41
  getFragmentsForPage: () => [],
39
42
  getPageFormatCatalog: () => PAGE_FORMAT_CATALOG,
40
43
  getActivePageFormat: () => null,
@@ -54,6 +57,7 @@ export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
54
57
  whenMeasurementReady: () => Promise.resolve(),
55
58
  getFirstPageIndexForBlock: () => null,
56
59
  swapMeasurementProvider: () => undefined,
60
+ invalidateMeasurementCache: () => undefined,
57
61
  getTableRenderPlan: () => null,
58
62
  getDirtyFieldFamilies: () => [],
59
63
  getFieldDirtinessReport: () => emptyReport,
@@ -104,7 +104,19 @@ export interface LayoutEngineEvent {
104
104
  | "incremental_relayout"
105
105
  | "page_count_changed"
106
106
  | "page_field_dirtied"
107
- | "measurement_backend_ready";
107
+ | "measurement_backend_ready"
108
+ /**
109
+ * P14.b — coalesced "the engine just finished a build" event. Emitted
110
+ * exactly once per `fullRebuild` / `incrementalRelayout` AFTER the
111
+ * granular events, carrying the union of dirty-field families, page-
112
+ * count delta, and page-range info. Subscribers that only need to
113
+ * react to "something layout-affecting changed" can listen to this
114
+ * single event and skip the multi-event subscription pattern that
115
+ * triggered N React re-renders per applyPatch. The granular events
116
+ * still fire for backward compat with consumers (TwStatusBar fidelity
117
+ * badge, etc.) that care about specific kinds.
118
+ */
119
+ | "layout_committed";
108
120
  revision: number;
109
121
  previousPageCount?: number;
110
122
  currentPageCount?: number;
@@ -113,6 +125,17 @@ export interface LayoutEngineEvent {
113
125
  fidelity?: LayoutMeasurementProvider["fidelity"];
114
126
  /** First dirty page index for incremental_relayout events. */
115
127
  firstDirtyPageIndex?: number;
128
+ /**
129
+ * P14.b — page-count delta for `layout_committed`. Present when the
130
+ * commit produced a different total page count than the prior graph.
131
+ */
132
+ pageCountDelta?: { previous: number; current: number };
133
+ /**
134
+ * P14.b — when `layout_committed` came from a bounded incremental
135
+ * relayout, the page range that was re-paginated. Absent for full
136
+ * rebuilds.
137
+ */
138
+ pageRange?: { fromPageIndex: number; toPageIndex: number };
116
139
  }
117
140
 
118
141
  export interface LayoutEngineInstance {
@@ -149,6 +172,16 @@ export interface LayoutEngineInstance {
149
172
 
150
173
  // ---- measurement plumbing --------------------------------------------
151
174
  swapMeasurementProvider(provider: LayoutMeasurementProvider): void;
175
+ /**
176
+ * Invalidate the active measurement provider's internal caches (canvas
177
+ * glyph / run-width LRU) AND clear the engine's cached page graph so
178
+ * the next query re-paginates with fresh measurements. Host runtime
179
+ * calls this after `docxFontLoader.refresh(...)` registers new
180
+ * FontFace families — without this call the canvas backend's glyph
181
+ * cache keeps returning pre-refresh widths for already-measured
182
+ * glyphs, and the cached page graph keeps its stale page boundaries.
183
+ */
184
+ invalidateMeasurementCache(): void;
152
185
  }
153
186
 
154
187
  // ---------------------------------------------------------------------------
@@ -263,16 +296,24 @@ export function createLayoutEngine(
263
296
  );
264
297
  const pages = pageStack.pages;
265
298
  const stories = resolvePageStories(pages);
266
- const fragmentsByPageIndex = projectSurfaceBlocksToPageFragments(
299
+ const bodyFragmentsByPageIndex = projectSurfaceBlocksToPageFragments(
267
300
  mainSurface,
268
301
  pages,
269
302
  pageStack.splits,
270
303
  );
304
+ // P8.1b — merge per-note fragments (regionKind: "footnote-area") into the
305
+ // main fragments map so buildPageGraph sees them alongside body fragments.
306
+ const fragmentsByPageIndex = new Map(bodyFragmentsByPageIndex);
307
+ for (const [pageIndex, noteFragments] of (pageStack.noteFragmentsByPageIndex ?? new Map())) {
308
+ const existing = fragmentsByPageIndex.get(pageIndex) ?? [];
309
+ fragmentsByPageIndex.set(pageIndex, [...existing, ...noteFragments]);
310
+ }
271
311
  const graph = buildPageGraph({
272
312
  pages,
273
313
  sections,
274
314
  stories,
275
315
  fragmentsByPageIndex,
316
+ noteAllocationsByPageIndex: pageStack.noteAllocationsByPageIndex,
276
317
  });
277
318
 
278
319
  // Field dirtiness diff from previous graph
@@ -284,14 +325,30 @@ export function createLayoutEngine(
284
325
  const formatting = buildResolvedFormattingState(document, mainSurface);
285
326
 
286
327
  const currentPageCount = resolveTotalPageCount(pages);
328
+ let pageCountDelta: { previous: number; current: number } | undefined;
287
329
  if (currentPageCount !== previousPageCount) {
330
+ pageCountDelta = { previous: previousPageCount, current: currentPageCount };
331
+ previousPageCount = currentPageCount;
332
+ }
333
+
334
+ // MUST publish cache before emit: re-entrant getPageGraph() calls from
335
+ // subscribers during emit would otherwise trigger runaway rebuilds.
336
+ cachedKey = {
337
+ content: document.content,
338
+ styles: document.styles,
339
+ subParts: document.subParts,
340
+ };
341
+ cachedGraph = graph;
342
+ cachedFormatting = formatting;
343
+ cachedMapper = createPageFragmentMapper(graph);
344
+
345
+ if (pageCountDelta) {
288
346
  emit({
289
347
  kind: "page_count_changed",
290
348
  revision: graph.revision,
291
- previousPageCount,
292
- currentPageCount,
349
+ previousPageCount: pageCountDelta.previous,
350
+ currentPageCount: pageCountDelta.current,
293
351
  });
294
- previousPageCount = currentPageCount;
295
352
  }
296
353
 
297
354
  if (dirtyFamilies.length > 0) {
@@ -308,14 +365,16 @@ export function createLayoutEngine(
308
365
  ...(reason ? { reason } : {}),
309
366
  });
310
367
 
311
- cachedKey = {
312
- content: document.content,
313
- styles: document.styles,
314
- subParts: document.subParts,
315
- };
316
- cachedGraph = graph;
317
- cachedFormatting = formatting;
318
- cachedMapper = createPageFragmentMapper(graph);
368
+ emit({
369
+ kind: "layout_committed",
370
+ revision: graph.revision,
371
+ ...(reason ? { reason } : {}),
372
+ ...(dirtyFamilies.length > 0
373
+ ? { dirtyFieldFamilies: dirtyFamilies }
374
+ : {}),
375
+ ...(pageCountDelta ? { pageCountDelta } : {}),
376
+ });
377
+
319
378
  return graph;
320
379
  }
321
380
 
@@ -362,16 +421,23 @@ export function createLayoutEngine(
362
421
  const freshStories = resolvePageStories(freshSnapshots);
363
422
  // Project fragments for the fresh tail pages, threading paragraph
364
423
  // line-range splits produced by intra-paragraph pagination.
365
- const freshFragmentsByPageIndex = projectSurfaceBlocksToPageFragments(
424
+ const freshBodyFragmentsByPageIndex = projectSurfaceBlocksToPageFragments(
366
425
  mainSurface,
367
426
  freshSnapshots,
368
427
  freshResult.splits,
369
428
  );
429
+ // P8.1b — merge per-note fragments into the fresh fragments map.
430
+ const freshFragmentsByPageIndex = new Map(freshBodyFragmentsByPageIndex);
431
+ for (const [pageIndex, noteFragments] of (freshResult.noteFragmentsByPageIndex ?? new Map())) {
432
+ const existing = freshFragmentsByPageIndex.get(pageIndex) ?? [];
433
+ freshFragmentsByPageIndex.set(pageIndex, [...existing, ...noteFragments]);
434
+ }
370
435
  const freshGraph = buildPageGraph({
371
436
  pages: freshSnapshots,
372
437
  sections,
373
438
  stories: freshStories,
374
439
  fragmentsByPageIndex: freshFragmentsByPageIndex,
440
+ noteAllocationsByPageIndex: freshResult.noteAllocationsByPageIndex,
375
441
  });
376
442
  const freshNodes = freshGraph.pages;
377
443
 
@@ -388,7 +454,9 @@ export function createLayoutEngine(
388
454
  const currentPageCount = resolveTotalPageCount(
389
455
  deriveDocumentPageSnapshots(splicedGraph),
390
456
  );
457
+ let pageCountDelta: { previous: number; current: number } | undefined;
391
458
  if (currentPageCount !== previousPageCount) {
459
+ pageCountDelta = { previous: previousPageCount, current: currentPageCount };
392
460
  emit({
393
461
  kind: "page_count_changed",
394
462
  revision: splicedGraph.revision,
@@ -413,6 +481,30 @@ export function createLayoutEngine(
413
481
  firstDirtyPageIndex: firstDirty,
414
482
  });
415
483
 
484
+ // P14.b — coalesced commit event for the bounded-incremental path.
485
+ //
486
+ // Page-range semantics: the current `incrementalRelayout` path uses
487
+ // `buildPageStackFromWithSplits` + `spliceGraph`, which always
488
+ // re-paginates from `firstDirty` through the document tail (we
489
+ // discard the prior tail and replace it with the freshly-paginated
490
+ // pages). So `toPageIndex = pages.length - 1` is correct for every
491
+ // commit produced by this path. Future bounded-middle splices
492
+ // (e.g., a middle-style change that doesn't touch the tail) would
493
+ // need to track an explicit upper bound — guard the assumption
494
+ // here so the contract drift becomes a test failure rather than a
495
+ // silent over-iteration in consumers (Chrome overlay, render kernel
496
+ // diff).
497
+ emit({
498
+ kind: "layout_committed",
499
+ revision: splicedGraph.revision,
500
+ reason: pending.reason,
501
+ pageRange: { fromPageIndex: firstDirty, toPageIndex: splicedGraph.pages.length - 1 },
502
+ ...(dirtyFamilies.length > 0
503
+ ? { dirtyFieldFamilies: dirtyFamilies }
504
+ : {}),
505
+ ...(pageCountDelta ? { pageCountDelta } : {}),
506
+ });
507
+
416
508
  cachedKey = {
417
509
  content: document.content,
418
510
  styles: document.styles,
@@ -605,13 +697,46 @@ export function createLayoutEngine(
605
697
  },
606
698
 
607
699
  swapMeasurementProvider(provider) {
700
+ const previousFidelity = measurementProvider.fidelity;
608
701
  measurementProvider = provider;
702
+ // Hardening: a backend swap changes the measurement numerics the
703
+ // cached graph was built against. Empirical → canvas typically
704
+ // reduces line counts (canvas-accurate glyph widths pack more
705
+ // text per line); canvas → canvas-with-font-loading applies the
706
+ // correct FontFace metrics for embedded DOCX fonts. Either way,
707
+ // the cached graph is stale — invalidate so the next
708
+ // `getGraph()` query re-paginates with the new provider. Skip
709
+ // invalidation when fidelity is unchanged (e.g., an empirical
710
+ // → empirical swap, or a canvas fallback that resolved back to
711
+ // the same backend) so we don't churn.
712
+ if (previousFidelity !== provider.fidelity) {
713
+ cachedKey = null;
714
+ cachedGraph = null;
715
+ cachedFormatting = null;
716
+ cachedMapper = null;
717
+ }
609
718
  emit({
610
719
  kind: "measurement_backend_ready",
611
720
  revision: cachedGraph?.revision ?? 0,
612
721
  fidelity: provider.fidelity,
613
722
  });
614
723
  },
724
+ /**
725
+ * Invalidate the current measurement provider's internal glyph /
726
+ * run-width cache. Called by the host runtime after
727
+ * `fontLoader.refresh(...)` so canvas-backed measurements re-read
728
+ * the newly-registered FontFaces instead of returning stale widths
729
+ * from the pre-refresh glyph cache. The graph cache itself is
730
+ * also cleared because a font change can shift line breaks and
731
+ * therefore page boundaries.
732
+ */
733
+ invalidateMeasurementCache() {
734
+ measurementProvider.invalidateCache();
735
+ cachedKey = null;
736
+ cachedGraph = null;
737
+ cachedFormatting = null;
738
+ cachedMapper = null;
739
+ },
615
740
  };
616
741
  }
617
742