@beyondwork/docx-react-component 1.0.58 → 1.0.60

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 (135) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -1
  3. package/src/api/awareness-identity-types.ts +4 -2
  4. package/src/api/comment-negotiation-types.ts +4 -1
  5. package/src/api/external-custody-types.ts +16 -0
  6. package/src/api/internal/build-ref-projections.ts +108 -0
  7. package/src/api/package-version.ts +1 -1
  8. package/src/api/participants-types.ts +11 -1
  9. package/src/api/public-types.ts +980 -10
  10. package/src/api/scope-metadata-resolver-types.ts +6 -0
  11. package/src/compare/diff-engine.ts +3 -0
  12. package/src/core/commands/formatting-commands.ts +1 -0
  13. package/src/core/commands/index.ts +225 -16
  14. package/src/core/commands/legacy-form-field-commands.ts +181 -0
  15. package/src/core/commands/table-structure-commands.ts +149 -31
  16. package/src/core/selection/mapping.ts +20 -0
  17. package/src/core/state/editor-state.ts +4 -1
  18. package/src/index.ts +28 -0
  19. package/src/io/docx-session.ts +22 -3
  20. package/src/io/export/export-session.ts +11 -7
  21. package/src/io/export/ooxml-namespaces.ts +47 -0
  22. package/src/io/export/reattach-preserved-parts.ts +4 -16
  23. package/src/io/export/serialize-comments.ts +3 -131
  24. package/src/io/export/serialize-ffdata.ts +89 -0
  25. package/src/io/export/serialize-headers-footers.ts +5 -0
  26. package/src/io/export/serialize-main-document.ts +224 -34
  27. package/src/io/export/serialize-numbering.ts +22 -2
  28. package/src/io/export/serialize-revisions.ts +99 -0
  29. package/src/io/export/serialize-tables.ts +9 -0
  30. package/src/io/export/split-review-boundaries.ts +1 -0
  31. package/src/io/export/table-properties-xml.ts +14 -0
  32. package/src/io/load-scheduler.ts +70 -28
  33. package/src/io/normalize/normalize-text.ts +13 -0
  34. package/src/io/ooxml/_mini-xml.ts +198 -0
  35. package/src/io/ooxml/canonicalize-payload.ts +1 -4
  36. package/src/io/ooxml/chart/chart-style-table.ts +4 -3
  37. package/src/io/ooxml/chart/parse-chart-space.ts +2 -4
  38. package/src/io/ooxml/chart/parse-series.ts +2 -1
  39. package/src/io/ooxml/chart/resolve-color.ts +2 -2
  40. package/src/io/ooxml/chart/types.ts +6 -434
  41. package/src/io/ooxml/comment-presentation-payload.ts +6 -5
  42. package/src/io/ooxml/highlight-colors.ts +8 -5
  43. package/src/io/ooxml/parse-anchor.ts +68 -53
  44. package/src/io/ooxml/parse-comments.ts +14 -142
  45. package/src/io/ooxml/parse-complex-content.ts +3 -106
  46. package/src/io/ooxml/parse-drawing.ts +100 -195
  47. package/src/io/ooxml/parse-ffdata.ts +93 -0
  48. package/src/io/ooxml/parse-fields.ts +7 -146
  49. package/src/io/ooxml/parse-fill.ts +88 -8
  50. package/src/io/ooxml/parse-font-table.ts +5 -105
  51. package/src/io/ooxml/parse-footnotes.ts +28 -152
  52. package/src/io/ooxml/parse-headers-footers.ts +106 -212
  53. package/src/io/ooxml/parse-inline-media.ts +3 -200
  54. package/src/io/ooxml/parse-main-document.ts +180 -217
  55. package/src/io/ooxml/parse-numbering.ts +154 -335
  56. package/src/io/ooxml/parse-object.ts +147 -0
  57. package/src/io/ooxml/parse-ole-relationship.ts +82 -0
  58. package/src/io/ooxml/parse-paragraph-formatting.ts +7 -10
  59. package/src/io/ooxml/parse-picture-sdt.ts +85 -0
  60. package/src/io/ooxml/parse-picture.ts +72 -42
  61. package/src/io/ooxml/parse-revisions.ts +285 -51
  62. package/src/io/ooxml/parse-settings.ts +6 -99
  63. package/src/io/ooxml/parse-shapes.ts +25 -140
  64. package/src/io/ooxml/parse-styles.ts +3 -218
  65. package/src/io/ooxml/parse-tables.ts +76 -256
  66. package/src/io/ooxml/parse-theme.ts +1 -4
  67. package/src/io/ooxml/property-grab-bag.ts +5 -47
  68. package/src/io/ooxml/workflow-payload.ts +6 -1
  69. package/src/io/ooxml/xml-element-serialize.ts +32 -0
  70. package/src/io/ooxml/xml-parser.ts +183 -0
  71. package/src/legal/bookmarks.ts +1 -1
  72. package/src/legal/cross-references.ts +1 -1
  73. package/src/legal/defined-terms.ts +1 -1
  74. package/src/legal/{_document-root.ts → document-root.ts} +8 -0
  75. package/src/legal/signature-blocks.ts +1 -1
  76. package/src/model/canonical-document.ts +159 -6
  77. package/src/model/chart-types.ts +439 -0
  78. package/src/model/snapshot.ts +5 -1
  79. package/src/review/store/comment-remapping.ts +24 -11
  80. package/src/review/store/revision-actions.ts +482 -2
  81. package/src/review/store/revision-store.ts +15 -0
  82. package/src/review/store/revision-types.ts +76 -0
  83. package/src/runtime/collab/remote-cursor-awareness.ts +24 -0
  84. package/src/runtime/collab/runtime-collab-sync.ts +33 -0
  85. package/src/runtime/diagnostics/build-diagnostic.ts +153 -0
  86. package/src/runtime/diagnostics/code-metadata-table.ts +230 -0
  87. package/src/runtime/document-runtime.ts +821 -54
  88. package/src/runtime/document-search.ts +115 -0
  89. package/src/runtime/edit-ops/index.ts +18 -2
  90. package/src/runtime/footnote-resolver.ts +130 -0
  91. package/src/runtime/layout/layout-engine-instance.ts +31 -4
  92. package/src/runtime/layout/layout-engine-version.ts +37 -1
  93. package/src/runtime/layout/page-graph.ts +14 -1
  94. package/src/runtime/layout/resolved-formatting-state.ts +21 -0
  95. package/src/runtime/numbering-prefix.ts +17 -0
  96. package/src/runtime/query-scopes.ts +108 -10
  97. package/src/runtime/resolved-numbering-geometry.ts +37 -6
  98. package/src/runtime/revision-runtime.ts +27 -1
  99. package/src/runtime/selection/post-edit-validator.ts +60 -6
  100. package/src/runtime/structure-ops/index.ts +20 -4
  101. package/src/runtime/surface-projection.ts +290 -21
  102. package/src/runtime/table-schema.ts +6 -0
  103. package/src/runtime/theme-color-resolver.ts +2 -2
  104. package/src/runtime/units.ts +9 -0
  105. package/src/runtime/workflow-rail-segments.ts +4 -0
  106. package/src/ui/WordReviewEditor.tsx +187 -43
  107. package/src/ui/editor-runtime-boundary.ts +10 -0
  108. package/src/ui/editor-shell-view.tsx +4 -1
  109. package/src/ui/headless/chrome-registry.ts +53 -0
  110. package/src/ui/headless/selection-tool-resolver.ts +11 -1
  111. package/src/ui-tailwind/chrome/chrome-preset-model.ts +13 -0
  112. package/src/ui-tailwind/chrome/tw-command-palette-mount.tsx +96 -0
  113. package/src/ui-tailwind/chrome/tw-context-menu.tsx +2 -1
  114. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +5 -4
  115. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +6 -2
  116. package/src/ui-tailwind/chrome/use-container-breakpoint.ts +111 -0
  117. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +0 -9
  118. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +1 -0
  119. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +6 -7
  120. package/src/ui-tailwind/editor-surface/pm-schema.ts +87 -25
  121. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +9 -0
  122. package/src/ui-tailwind/editor-surface/shape-renderer.ts +76 -14
  123. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +18 -1
  124. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +2 -0
  125. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +18 -2
  126. package/src/ui-tailwind/index.ts +9 -0
  127. package/src/ui-tailwind/page-chrome-model.ts +77 -5
  128. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +56 -1
  129. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +2 -0
  130. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +116 -113
  131. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +2 -2
  132. package/src/ui-tailwind/theme/tokens.ts +14 -0
  133. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +5 -0
  134. package/src/ui-tailwind/tw-review-workspace.tsx +29 -87
  135. package/src/validation/diagnostics.ts +1 -0
@@ -97,13 +97,27 @@ function serializeMarkup(
97
97
 
98
98
  switch (revision.kind) {
99
99
  case "move":
100
+ return serializeMoveMarkup(markup, revision.status);
100
101
  case "formatting":
102
+ if (
103
+ markup.originalRevisionType === "cellIns" ||
104
+ markup.originalRevisionType === "cellDel" ||
105
+ markup.originalRevisionType === "cellMerge"
106
+ ) {
107
+ return serializeStructuralTableMarkup(markup.rawXml, revision.status);
108
+ }
101
109
  return markup.rawXml;
102
110
  case "property-change":
103
111
  return serializePropertyChangeMarkup(markup.rawXml, revision.status);
104
112
  case "insertion":
113
+ if (markup.originalRevisionType === "row-ins") {
114
+ return serializeStructuralRowMarkup(markup.rawXml, revision.status);
115
+ }
105
116
  return serializeInsertionMarkup(markup.rawXml, revision.status);
106
117
  case "deletion":
118
+ if (markup.originalRevisionType === "row-del") {
119
+ return serializeStructuralRowMarkup(markup.rawXml, revision.status);
120
+ }
107
121
  return serializeDeletionMarkup(markup.rawXml, revision.status);
108
122
  }
109
123
  }
@@ -346,6 +360,91 @@ function serializePropertyChangeMarkup(
346
360
  }
347
361
  }
348
362
 
363
+ function serializeMoveMarkup(
364
+ markup: PreservedRevisionMarkup,
365
+ status: RevisionRecord["status"],
366
+ ): string {
367
+ if (status === "active" || status === "detached") {
368
+ return markup.rawXml;
369
+ }
370
+
371
+ const direction: "from" | "to" | undefined =
372
+ markup.originalRevisionType === "moveFrom"
373
+ ? "from"
374
+ : markup.originalRevisionType === "moveTo"
375
+ ? "to"
376
+ : undefined;
377
+
378
+ if (!direction) {
379
+ return markup.rawXml;
380
+ }
381
+
382
+ // Accept = commit the move: source removed, destination unwrapped.
383
+ // Reject = cancel the move: source unwrapped, destination removed.
384
+ const shouldDropWrapperAndContent =
385
+ (status === "accepted" && direction === "from") ||
386
+ (status === "rejected" && direction === "to");
387
+
388
+ if (shouldDropWrapperAndContent) {
389
+ return "";
390
+ }
391
+
392
+ return unwrapMoveWrapper(markup.rawXml, direction);
393
+ }
394
+
395
+ function unwrapMoveWrapper(rawXml: string, direction: "from" | "to"): string {
396
+ const tag = direction === "from" ? "moveFrom" : "moveTo";
397
+ const selfClosingPattern = new RegExp(`^\\s*<w:${tag}\\b[^>]*/>\\s*$`);
398
+ if (selfClosingPattern.test(rawXml)) {
399
+ return "";
400
+ }
401
+ const openPattern = new RegExp(`^\\s*<w:${tag}\\b[^>]*>`);
402
+ const closePattern = new RegExp(`</w:${tag}>\\s*$`);
403
+ return rawXml.replace(openPattern, "").replace(closePattern, "");
404
+ }
405
+
406
+ function serializeStructuralRowMarkup(
407
+ rawXml: string,
408
+ status: RevisionRecord["status"],
409
+ ): string {
410
+ // accept(row-ins) and reject(row-del): drop the marker from trPr while
411
+ // leaving the enclosing row intact. The canonical-document row is
412
+ // unchanged in these cases.
413
+ // accept(row-del) and reject(row-ins): the canonical model layer already
414
+ // removed the entire row via `removeTableRowPure`, so the <w:tr> itself
415
+ // won't exist in the serialized output — this helper only runs on the
416
+ // surviving-row branch.
417
+ switch (status) {
418
+ case "accepted":
419
+ case "rejected":
420
+ return "";
421
+ case "active":
422
+ case "detached":
423
+ return rawXml;
424
+ }
425
+ }
426
+
427
+ function serializeStructuralTableMarkup(
428
+ rawXml: string,
429
+ status: RevisionRecord["status"],
430
+ ): string {
431
+ // Lane 7b/L: cellIns on rejected status means the canonical-model layer
432
+ // already removed the enclosing <w:tc>, so the marker never reaches the
433
+ // exported table. When it does fire, the marker should be dropped just
434
+ // like on accept (the alternative — leaving the marker in place — would
435
+ // round-trip back as a still-pending revision, contradicting the store
436
+ // status). Accept = commit the cell (drop marker). Reject = cell removed
437
+ // (marker also drops).
438
+ switch (status) {
439
+ case "accepted":
440
+ case "rejected":
441
+ return "";
442
+ case "active":
443
+ case "detached":
444
+ return rawXml;
445
+ }
446
+ }
447
+
349
448
  function serializeInsertionMarkup(
350
449
  rawXml: string,
351
450
  status: RevisionRecord["status"],
@@ -229,6 +229,15 @@ function serializeCellShading(shading: ParsedCellShading): string {
229
229
  if (shading.val) attrs.push(`w:val="${shading.val}"`);
230
230
  if (shading.color) attrs.push(`w:color="${shading.color}"`);
231
231
  if (shading.fill) attrs.push(`w:fill="${shading.fill}"`);
232
+ // SOW gap G3 — round-trip theme-shading attributes so a doc imported with
233
+ // `<w:shd w:themeFill="accent5" w:themeFillTint="33"/>` serializes back to
234
+ // the same form instead of losing the theme reference after edit.
235
+ if (shading.themeFill) attrs.push(`w:themeFill="${shading.themeFill}"`);
236
+ if (shading.themeFillTint) attrs.push(`w:themeFillTint="${shading.themeFillTint}"`);
237
+ if (shading.themeFillShade) attrs.push(`w:themeFillShade="${shading.themeFillShade}"`);
238
+ if (shading.themeColor) attrs.push(`w:themeColor="${shading.themeColor}"`);
239
+ if (shading.themeColorTint) attrs.push(`w:themeColorTint="${shading.themeColorTint}"`);
240
+ if (shading.themeColorShade) attrs.push(`w:themeColorShade="${shading.themeColorShade}"`);
232
241
  const attrsStr = attrs.length > 0 ? ` ${attrs.join(" ")}` : "";
233
242
  return `<w:shd${attrsStr}/>`;
234
243
  }
@@ -348,6 +348,7 @@ function measureInlineNodeForReviewBoundaries(node: InlineNode): number {
348
348
  case "shape":
349
349
  case "wordart":
350
350
  case "vml_shape":
351
+ case "ole_embed":
351
352
  case "symbol":
352
353
  return 1;
353
354
  default:
@@ -45,6 +45,13 @@ interface CellShadingLike {
45
45
  val?: string;
46
46
  color?: string;
47
47
  fill?: string;
48
+ /** SOW gap G3 — round-trip theme-shading references. */
49
+ themeFill?: string;
50
+ themeFillTint?: string;
51
+ themeFillShade?: string;
52
+ themeColor?: string;
53
+ themeColorTint?: string;
54
+ themeColorShade?: string;
48
55
  }
49
56
 
50
57
  interface TableIndentLike {
@@ -476,5 +483,12 @@ function serializeCellShading(shading: CellShadingLike): string {
476
483
  if (shading.val) attrs.push(`w:val="${escapeXmlAttribute(shading.val)}"`);
477
484
  if (shading.color) attrs.push(`w:color="${escapeXmlAttribute(shading.color)}"`);
478
485
  if (shading.fill) attrs.push(`w:fill="${escapeXmlAttribute(shading.fill)}"`);
486
+ // SOW gap G3 — theme-shading round-trip.
487
+ if (shading.themeFill) attrs.push(`w:themeFill="${escapeXmlAttribute(shading.themeFill)}"`);
488
+ if (shading.themeFillTint) attrs.push(`w:themeFillTint="${escapeXmlAttribute(shading.themeFillTint)}"`);
489
+ if (shading.themeFillShade) attrs.push(`w:themeFillShade="${escapeXmlAttribute(shading.themeFillShade)}"`);
490
+ if (shading.themeColor) attrs.push(`w:themeColor="${escapeXmlAttribute(shading.themeColor)}"`);
491
+ if (shading.themeColorTint) attrs.push(`w:themeColorTint="${escapeXmlAttribute(shading.themeColorTint)}"`);
492
+ if (shading.themeColorShade) attrs.push(`w:themeColorShade="${escapeXmlAttribute(shading.themeColorShade)}"`);
479
493
  return attrs.length > 0 ? `<w:shd ${attrs.join(" ")}/>` : "";
480
494
  }
@@ -25,12 +25,31 @@ export type LoadSchedulerBackend =
25
25
  | "timeout"
26
26
  | "sync";
27
27
 
28
+ export interface ScheduleIdleOptions {
29
+ /**
30
+ * Abort the pending idle callback before it runs. If the signal is already
31
+ * aborted at call time, the task is not scheduled. Post-run aborts are
32
+ * no-ops. The idle handle is cancelled and the abort listener removed in
33
+ * both the run and abort paths.
34
+ *
35
+ * Typical consumer: `RuntimeCollabSyncHandle.getRemoteActivitySignal()`
36
+ * (Lane 4 R3). Idle prep that races with remote commit work passes the
37
+ * signal here so remote arrivals cancel stale prep before the document
38
+ * moves. See `docs/wiki/collaboration.md` §"Remote-activity abort
39
+ * signal (R3)" for the end-to-end narrative.
40
+ *
41
+ * Stability: advanced-supported (v2.0.0, shipped 2026-04-20 in
42
+ * commit c2aeaf8d).
43
+ */
44
+ signal?: AbortSignal;
45
+ }
46
+
28
47
  export interface LoadScheduler {
29
48
  readonly backend: LoadSchedulerBackend;
30
49
  /** Yield to the browser. Resolves on next scheduled task / microtask. */
31
50
  yield(): Promise<void>;
32
51
  /** Schedule low-priority work for post-skeleton execution. */
33
- scheduleIdle(task: () => void): void;
52
+ scheduleIdle(task: () => void, options?: ScheduleIdleOptions): void;
34
53
  /** Cancel pending idle tasks. Must be called on unmount / dispose. */
35
54
  dispose(): void;
36
55
  }
@@ -115,9 +134,8 @@ function createSchedulerApiBackend(frameDeadlineMs: number): InternalScheduler {
115
134
  backend: "scheduler-api",
116
135
  frameDeadlineMs,
117
136
  yield: () => g.scheduler.yield(),
118
- scheduleIdle(task) {
119
- const handle = scheduleIdleCallback(task, pendingIdleHandles);
120
- pendingIdleHandles.add(handle);
137
+ scheduleIdle(task, options) {
138
+ scheduleIdleCallback(task, pendingIdleHandles, options);
121
139
  },
122
140
  dispose() {
123
141
  disposeIdleHandles(pendingIdleHandles);
@@ -141,9 +159,8 @@ function createMessageChannelBackend(frameDeadlineMs: number): InternalScheduler
141
159
  channel.port2.postMessage(null);
142
160
  });
143
161
  },
144
- scheduleIdle(task) {
145
- const handle = scheduleIdleCallback(task, pendingIdleHandles);
146
- pendingIdleHandles.add(handle);
162
+ scheduleIdle(task, options) {
163
+ scheduleIdleCallback(task, pendingIdleHandles, options);
147
164
  },
148
165
  dispose() {
149
166
  disposeIdleHandles(pendingIdleHandles);
@@ -161,15 +178,11 @@ function createTimeoutBackend(frameDeadlineMs: number): InternalScheduler {
161
178
  setTimeout(resolve, 0);
162
179
  });
163
180
  },
164
- scheduleIdle(task) {
165
- const handle = setTimeout(task, 0) as unknown as number;
166
- pendingIdleHandles.add(handle);
181
+ scheduleIdle(task, options) {
182
+ scheduleIdleCallback(task, pendingIdleHandles, options);
167
183
  },
168
184
  dispose() {
169
- for (const handle of pendingIdleHandles) {
170
- clearTimeout(handle as unknown as ReturnType<typeof setTimeout>);
171
- }
172
- pendingIdleHandles.clear();
185
+ disposeIdleHandles(pendingIdleHandles);
173
186
  },
174
187
  };
175
188
  }
@@ -179,7 +192,8 @@ function createSyncBackend(frameDeadlineMs: number): InternalScheduler {
179
192
  backend: "sync",
180
193
  frameDeadlineMs,
181
194
  yield: () => Promise.resolve(),
182
- scheduleIdle(task) {
195
+ scheduleIdle(task, options) {
196
+ if (options?.signal?.aborted) return;
183
197
  task();
184
198
  },
185
199
  dispose() {
@@ -193,26 +207,54 @@ type IdleHandle = number;
193
207
  function scheduleIdleCallback(
194
208
  task: () => void,
195
209
  store: Set<IdleHandle>,
196
- ): IdleHandle {
210
+ options?: ScheduleIdleOptions,
211
+ ): void {
212
+ const signal = options?.signal;
213
+ if (signal?.aborted) return;
214
+
197
215
  const g = globalThis as unknown as {
198
216
  requestIdleCallback?: (cb: () => void, options?: { timeout: number }) => number;
199
217
  cancelIdleCallback?: (handle: number) => void;
200
218
  };
219
+
220
+ // Forward-declared handle ref so onAbort can cancel before it's assigned.
221
+ let handle: IdleHandle = -1;
222
+ let onAbort: (() => void) | null = null;
223
+
224
+ const cleanupAbortListener = () => {
225
+ if (signal && onAbort) {
226
+ signal.removeEventListener("abort", onAbort);
227
+ onAbort = null;
228
+ }
229
+ };
230
+
231
+ const run = () => {
232
+ store.delete(handle);
233
+ cleanupAbortListener();
234
+ task();
235
+ };
236
+
201
237
  if (typeof g.requestIdleCallback === "function") {
202
- const handle = g.requestIdleCallback(
203
- () => {
238
+ handle = g.requestIdleCallback(run, { timeout: 50 });
239
+ } else {
240
+ handle = setTimeout(run, 0) as unknown as number;
241
+ }
242
+ store.add(handle);
243
+
244
+ if (signal) {
245
+ onAbort = () => {
246
+ if (store.has(handle)) {
247
+ if (typeof g.cancelIdleCallback === "function" && typeof g.requestIdleCallback === "function") {
248
+ g.cancelIdleCallback(handle);
249
+ } else {
250
+ clearTimeout(handle as unknown as ReturnType<typeof setTimeout>);
251
+ }
204
252
  store.delete(handle);
205
- task();
206
- },
207
- { timeout: 50 },
208
- );
209
- return handle;
253
+ }
254
+ onAbort = null;
255
+ };
256
+ signal.addEventListener("abort", onAbort, { once: true });
210
257
  }
211
- const handle = setTimeout(() => {
212
- store.delete(handle as unknown as number);
213
- task();
214
- }, 0) as unknown as number;
215
- return handle;
216
258
  }
217
259
 
218
260
  function disposeIdleHandles(store: Set<IdleHandle>): void {
@@ -531,6 +531,18 @@ function normalizeInlineChildren(
531
531
  });
532
532
  state.cursor += 1;
533
533
  break;
534
+ case "ole_embed":
535
+ normalized.push({
536
+ type: "ole_embed",
537
+ id: node.id,
538
+ ...(node.progId ? { progId: node.progId } : {}),
539
+ embedType: node.embedType,
540
+ relationshipId: node.relationshipId,
541
+ metadata: { ...node.metadata },
542
+ rawXml: node.rawXml,
543
+ });
544
+ state.cursor += 1;
545
+ break;
534
546
  case "bookmark_start":
535
547
  normalized.push({
536
548
  type: "bookmark_start",
@@ -569,6 +581,7 @@ function normalizeInlineChildren(
569
581
  ...(classification.target ? { fieldTarget: classification.target } : {}),
570
582
  ...(classification.switches ? { switches: classification.switches } : {}),
571
583
  refreshStatus: classification.supported ? "stale" : "preserve-only",
584
+ ...(node.legacyFormField ? { legacyFormField: node.legacyFormField } : {}),
572
585
  });
573
586
  state.cursor += renderedLength > 0 ? renderedLength : 1;
574
587
  break;
@@ -0,0 +1,198 @@
1
+ /**
2
+ * _mini-xml.ts — Shared minimal XML parser for CO4 DrawingML modules.
3
+ *
4
+ * Consolidates the four identical copies previously duplicated across
5
+ * parse-anchor.ts, parse-drawing.ts, parse-picture.ts, and parse-shapes.ts.
6
+ *
7
+ * Scope: OOXML attribute-preserving walker used for decoding DrawingML
8
+ * fragments. Self-contained (no fast-xml-parser dependency), low allocation,
9
+ * preserves attribute order.
10
+ *
11
+ * Throws on unterminated tags (CO4 B4 fix). Callers in parseDrawingFrame +
12
+ * parseShapeContent are already try/catch-wrapped at parse-main-document.ts.
13
+ */
14
+
15
+ export interface XmlElementNode {
16
+ type: "element";
17
+ name: string;
18
+ attributes: Record<string, string>;
19
+ children: XmlNode[];
20
+ }
21
+
22
+ export interface XmlTextNode {
23
+ type: "text";
24
+ text: string;
25
+ }
26
+
27
+ export type XmlNode = XmlElementNode | XmlTextNode;
28
+
29
+ export function findFirstChild(
30
+ node: XmlElementNode,
31
+ local: string,
32
+ ): XmlElementNode | undefined {
33
+ for (const child of node.children) {
34
+ if (child.type === "element" && localName(child.name) === local) return child;
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ export function findFirstDescendant(
40
+ node: XmlElementNode,
41
+ local: string,
42
+ ): XmlElementNode | undefined {
43
+ for (const child of node.children) {
44
+ if (child.type !== "element") continue;
45
+ if (localName(child.name) === local) return child;
46
+ const found = findFirstDescendant(child, local);
47
+ if (found) return found;
48
+ }
49
+ return undefined;
50
+ }
51
+
52
+ export function findDescendants(
53
+ node: XmlElementNode,
54
+ local: string,
55
+ ): XmlElementNode[] {
56
+ const out: XmlElementNode[] = [];
57
+ for (const child of node.children) {
58
+ if (child.type !== "element") continue;
59
+ if (localName(child.name) === local) out.push(child);
60
+ out.push(...findDescendants(child, local));
61
+ }
62
+ return out;
63
+ }
64
+
65
+ export function localName(name: string): string {
66
+ const i = name.indexOf(":");
67
+ return i >= 0 ? name.slice(i + 1) : name;
68
+ }
69
+
70
+ export function extractText(node: XmlElementNode): string {
71
+ return node.children
72
+ .map((c) => (c.type === "text" ? c.text : extractText(c as XmlElementNode)))
73
+ .join("");
74
+ }
75
+
76
+ export function parseXml(xml: string): XmlElementNode {
77
+ const root: XmlElementNode = {
78
+ type: "element",
79
+ name: "__root__",
80
+ attributes: {},
81
+ children: [],
82
+ };
83
+ const stack: XmlElementNode[] = [root];
84
+ let cursor = 0;
85
+
86
+ while (cursor < xml.length) {
87
+ if (xml.startsWith("<!--", cursor)) {
88
+ const end = xml.indexOf("-->", cursor);
89
+ cursor = end >= 0 ? end + 3 : xml.length;
90
+ continue;
91
+ }
92
+ if (xml.startsWith("<?", cursor)) {
93
+ const end = xml.indexOf("?>", cursor);
94
+ cursor = end >= 0 ? end + 2 : xml.length;
95
+ continue;
96
+ }
97
+ if (xml[cursor] !== "<") {
98
+ const nextTag = xml.indexOf("<", cursor);
99
+ const end = nextTag >= 0 ? nextTag : xml.length;
100
+ const text = decodeEntities(xml.slice(cursor, end));
101
+ if (text) stack[stack.length - 1]?.children.push({ type: "text", text });
102
+ cursor = end;
103
+ continue;
104
+ }
105
+ if (xml[cursor + 1] === "/") {
106
+ const end = xml.indexOf(">", cursor);
107
+ stack.pop();
108
+ cursor = end + 1;
109
+ continue;
110
+ }
111
+ const tagEnd = findTagEnd(xml, cursor);
112
+ const tagBody = xml.slice(cursor + 1, tagEnd);
113
+ const selfClosing = /\/\s*$/.test(tagBody);
114
+ const { name, attributes } = parseTag(tagBody.replace(/\/\s*$/, "").trim());
115
+ const el: XmlElementNode = { type: "element", name, attributes, children: [] };
116
+ stack[stack.length - 1]?.children.push(el);
117
+ if (!selfClosing) stack.push(el);
118
+ cursor = tagEnd + 1;
119
+ }
120
+
121
+ return root;
122
+ }
123
+
124
+ function findTagEnd(xml: string, start: number): number {
125
+ let cursor = start + 1;
126
+ let quote: string | null = null;
127
+ while (cursor < xml.length) {
128
+ const ch = xml[cursor];
129
+ if (quote) {
130
+ if (ch === quote) quote = null;
131
+ } else if (ch === `"` || ch === `'`) {
132
+ quote = ch;
133
+ } else if (ch === ">") {
134
+ return cursor;
135
+ }
136
+ cursor++;
137
+ }
138
+ // B4 — throw on unterminated tag; callers are try/catch-wrapped at the
139
+ // parse-main-document.ts case "drawing" dispatch so the legacy chain +
140
+ // opaque_inline fallback take over.
141
+ throw new Error(`Malformed XML: unterminated tag starting at offset ${start}`);
142
+ }
143
+
144
+ function parseTag(body: string): { name: string; attributes: Record<string, string> } {
145
+ let i = 0;
146
+ while (i < body.length && /\s/.test(body[i] ?? "")) i++;
147
+ const nameStart = i;
148
+ while (i < body.length && !/\s/.test(body[i] ?? "")) i++;
149
+ const name = body.slice(nameStart, i);
150
+ const attributes: Record<string, string> = {};
151
+ while (i < body.length) {
152
+ while (i < body.length && /\s/.test(body[i] ?? "")) i++;
153
+ if (i >= body.length) break;
154
+ const kStart = i;
155
+ while (i < body.length && !/[\s=]/.test(body[i] ?? "")) i++;
156
+ const key = body.slice(kStart, i);
157
+ while (i < body.length && /\s/.test(body[i] ?? "")) i++;
158
+ if (body[i] !== "=") { attributes[key] = ""; continue; }
159
+ i++;
160
+ while (i < body.length && /\s/.test(body[i] ?? "")) i++;
161
+ const q = body[i];
162
+ if (q !== `"` && q !== `'`) throw new Error(`Bad attr ${key}`);
163
+ i++;
164
+ const vStart = i;
165
+ while (i < body.length && body[i] !== q) i++;
166
+ attributes[key] = decodeEntities(body.slice(vStart, i));
167
+ i++;
168
+ }
169
+ return { name, attributes };
170
+ }
171
+
172
+ function decodeEntities(s: string): string {
173
+ return s.replace(/&(#x[0-9a-fA-F]+|#\d+|amp|lt|gt|quot|apos);/g, (_, e) => {
174
+ if (e === "amp") return "&";
175
+ if (e === "lt") return "<";
176
+ if (e === "gt") return ">";
177
+ if (e === "quot") return `"`;
178
+ if (e === "apos") return "'";
179
+ if (e.startsWith("#x")) return String.fromCodePoint(parseInt(e.slice(2), 16));
180
+ if (e.startsWith("#")) return String.fromCodePoint(parseInt(e.slice(1), 10));
181
+ return `&${e};`;
182
+ });
183
+ }
184
+
185
+ function encodeEntities(s: string): string {
186
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
187
+ }
188
+
189
+ export function serializeXmlNode(node: XmlElementNode): string {
190
+ const attrs = Object.entries(node.attributes)
191
+ .map(([k, v]) => ` ${k}="${encodeEntities(v)}"`)
192
+ .join("");
193
+ if (node.children.length === 0) return `<${node.name}${attrs}/>`;
194
+ const inner = node.children
195
+ .map((c) => (c.type === "text" ? encodeEntities(c.text) : serializeXmlNode(c)))
196
+ .join("");
197
+ return `<${node.name}${attrs}>${inner}</${node.name}>`;
198
+ }
@@ -21,6 +21,7 @@
21
21
  * reader use this same routine) and self-referential (the signature
22
22
  * element is excluded from its own input).
23
23
  */
24
+ import { localName } from "./xml-attr-helpers.ts";
24
25
  export function canonicalizePayload(xml: string): Uint8Array {
25
26
  const tree = parseXml(xml);
26
27
  const filtered = stripSignature(tree);
@@ -273,10 +274,6 @@ function preservesSpace(): boolean {
273
274
 
274
275
  // ----- Helpers --------------------------------------------------------------
275
276
 
276
- function localName(qname: string): string {
277
- const colon = qname.indexOf(":");
278
- return colon < 0 ? qname : qname.slice(colon + 1);
279
- }
280
277
 
281
278
  function normalizeWhitespace(value: string): string {
282
279
  return value.replace(/\s+/g, " ").trim();
@@ -27,6 +27,7 @@
27
27
  */
28
28
 
29
29
  import type { FillSpec, StrokeSpec, TextProperties } from "./types.ts";
30
+ import { EMU_PER_PX } from "../../../runtime/units.ts";
30
31
 
31
32
  /**
32
33
  * How each series gets a color.
@@ -100,7 +101,7 @@ const GRIDLINE_STROKE_SUBTLE: StrokeSpec = {
100
101
  { kind: "lumMod", value: 15000 },
101
102
  { kind: "lumOff", value: 85000 },
102
103
  ] },
103
- widthEmu: 9525, // 0.75 pt
104
+ widthEmu: EMU_PER_PX, // 0.75 pt
104
105
  dash: "solid",
105
106
  };
106
107
 
@@ -110,7 +111,7 @@ const SERIES_OUTLINE_NONE: StrokeSpec = {
110
111
 
111
112
  const SERIES_OUTLINE_LIGHT: StrokeSpec = {
112
113
  color: { kind: "scheme", value: "lt1" },
113
- widthEmu: 9525,
114
+ widthEmu: EMU_PER_PX,
114
115
  dash: "solid",
115
116
  };
116
117
 
@@ -305,7 +306,7 @@ const GRIDLINE_STROKE_LIGHT: StrokeSpec = {
305
306
  color: { kind: "scheme", value: "lt1", mods: [
306
307
  { kind: "lumMod", value: 80000 },
307
308
  ] },
308
- widthEmu: 9525,
309
+ widthEmu: EMU_PER_PX,
309
310
  dash: "solid",
310
311
  };
311
312
 
@@ -30,6 +30,7 @@ import {
30
30
  } from "../xml-attr-helpers.ts";
31
31
  import type { XmlElementNode } from "../xml-element.ts";
32
32
  import { parseXml } from "../xml-parser.ts";
33
+ import { ROTATION_UNITS_PER_DEGREE } from "../../../runtime/units.ts";
33
34
 
34
35
  import { parseAxisNode } from "./parse-axis.ts";
35
36
  import {
@@ -530,9 +531,6 @@ function readGroupBoolean(groupNode: XmlElementNode, local: string): boolean {
530
531
  // Pie / Doughnut
531
532
  // ---------------------------------------------------------------------------
532
533
 
533
- /** OOXML's angle unit is 1/60000 of a degree (sixtyThousandths). */
534
- const OOXML_ANGLE_UNIT = 60000;
535
-
536
534
  function parsePieGroup(
537
535
  groupNode: XmlElementNode,
538
536
  common: ChartCommon,
@@ -553,7 +551,7 @@ function parsePieGroup(
553
551
  const firstSliceAngleRaw = readIntVal(findChildOptional(groupNode, "firstSliceAngle"));
554
552
  const firstSliceAngle =
555
553
  firstSliceAngleRaw !== undefined
556
- ? (((firstSliceAngleRaw / OOXML_ANGLE_UNIT) % 360) + 360) % 360
554
+ ? (((firstSliceAngleRaw / ROTATION_UNITS_PER_DEGREE) % 360) + 360) % 360
557
555
  : 0;
558
556
 
559
557
  // Doughnut hole size is a percent (0–99 typical). Missing on pieChart.
@@ -22,6 +22,7 @@ import {
22
22
  } from "../xml-attr-helpers.ts";
23
23
  import type { XmlElementNode } from "../xml-element.ts";
24
24
  import { parseXml } from "../xml-parser.ts";
25
+ import { GRADIENT_STOP_UNITS } from "../../../runtime/units.ts";
25
26
 
26
27
  import type {
27
28
  BubbleSeries,
@@ -513,7 +514,7 @@ function parseGradientFill(gradNode: XmlElementNode): FillSpec | undefined {
513
514
  const color = parseColorRef(child);
514
515
  if (!color) continue;
515
516
  const pos = Number.isFinite(posPpm)
516
- ? Math.max(0, Math.min(1, posPpm / 100_000))
517
+ ? Math.max(0, Math.min(1, posPpm / GRADIENT_STOP_UNITS))
517
518
  : stops.length / 2;
518
519
  stops.push({ pos, color });
519
520
  }
@@ -19,9 +19,9 @@
19
19
 
20
20
  import type { ColorMod, ColorRef } from "./types.ts";
21
21
  import type { ResolvedTheme } from "../../../model/canonical-document.ts";
22
+ import { GRADIENT_STOP_UNITS } from "../../../runtime/units.ts";
22
23
 
23
24
  const FALLBACK_COLOR = "#808080";
24
- const OOXML_UNIT = 100_000; // modifier val is in parts per 100,000
25
25
 
26
26
  /**
27
27
  * Scheme-alias map. Word often emits aliases that refer to the primary
@@ -100,7 +100,7 @@ function applyMods(rgb: Rgb, mods: readonly ColorMod[] | undefined): Rgb {
100
100
  }
101
101
 
102
102
  function applyMod(rgb: Rgb, mod: ColorMod): Rgb {
103
- const frac = mod.value / OOXML_UNIT;
103
+ const frac = mod.value / GRADIENT_STOP_UNITS;
104
104
  switch (mod.kind) {
105
105
  case "lumMod": {
106
106
  // Scale luminance.