@beyondwork/docx-react-component 1.0.42 → 1.0.45

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 (82) hide show
  1. package/README.md +17 -0
  2. package/package.json +5 -4
  3. package/src/api/editor-state-types.ts +110 -0
  4. package/src/api/public-types.ts +333 -4
  5. package/src/core/commands/formatting-commands.ts +7 -1
  6. package/src/core/commands/index.ts +60 -10
  7. package/src/core/commands/text-commands.ts +59 -0
  8. package/src/core/search/search-text.ts +15 -2
  9. package/src/core/selection/review-anchors.ts +131 -21
  10. package/src/index.ts +29 -1
  11. package/src/io/chart-preview-resolver.ts +281 -0
  12. package/src/io/docx-session.ts +692 -2
  13. package/src/io/export/build-app-properties-xml.ts +1 -1
  14. package/src/io/export/serialize-comments.ts +38 -9
  15. package/src/io/export/twip.ts +1 -1
  16. package/src/io/load-scheduler.ts +230 -0
  17. package/src/io/normalize/normalize-text.ts +116 -0
  18. package/src/io/ooxml/parse-comments.ts +0 -33
  19. package/src/io/ooxml/parse-complex-content.ts +14 -0
  20. package/src/io/ooxml/parse-main-document.ts +4 -0
  21. package/src/io/ooxml/workflow-payload-validator.ts +97 -1
  22. package/src/io/ooxml/workflow-payload.ts +172 -1
  23. package/src/preservation/opaque-region.ts +5 -0
  24. package/src/review/store/comment-remapping.ts +2 -2
  25. package/src/runtime/collab-session.ts +1 -1
  26. package/src/runtime/document-runtime.ts +661 -42
  27. package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
  28. package/src/runtime/edit-dispatch/index.ts +2 -0
  29. package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
  30. package/src/runtime/editor-state-channel.ts +544 -0
  31. package/src/runtime/editor-state-integration.ts +217 -0
  32. package/src/runtime/editor-surface/capabilities.ts +411 -0
  33. package/src/runtime/layout/index.ts +2 -0
  34. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  35. package/src/runtime/layout/layout-engine-instance.ts +63 -2
  36. package/src/runtime/layout/layout-engine-version.ts +41 -0
  37. package/src/runtime/layout/paginated-layout-engine.ts +211 -14
  38. package/src/runtime/layout/public-facet.ts +430 -1
  39. package/src/runtime/perf-counters.ts +28 -0
  40. package/src/runtime/prerender/cache-envelope.ts +29 -0
  41. package/src/runtime/prerender/cache-key.ts +66 -0
  42. package/src/runtime/prerender/font-fingerprint.ts +17 -0
  43. package/src/runtime/prerender/graph-canonicalize.ts +121 -0
  44. package/src/runtime/prerender/indexeddb-cache.ts +184 -0
  45. package/src/runtime/prerender/prerender-document.ts +145 -0
  46. package/src/runtime/render/block-fragment-projection.ts +2 -0
  47. package/src/runtime/render/render-frame-types.ts +17 -0
  48. package/src/runtime/render/render-kernel.ts +172 -29
  49. package/src/runtime/selection/post-edit-validator.ts +77 -0
  50. package/src/runtime/surface-projection.ts +45 -7
  51. package/src/runtime/workflow-markup.ts +71 -16
  52. package/src/ui/WordReviewEditor.tsx +142 -237
  53. package/src/ui/editor-command-bag.ts +14 -0
  54. package/src/ui/editor-runtime-boundary.ts +115 -12
  55. package/src/ui/editor-shell-view.tsx +10 -0
  56. package/src/ui/editor-surface-controller.tsx +5 -0
  57. package/src/ui/headless/selection-helpers.ts +10 -0
  58. package/src/ui/runtime-shortcut-dispatch.ts +28 -68
  59. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  60. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  61. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
  62. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  63. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  64. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
  65. package/src/ui-tailwind/editor-surface/pm-schema.ts +170 -4
  66. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +58 -7
  67. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  68. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  69. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
  70. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +47 -0
  71. package/src/ui-tailwind/index.ts +5 -1
  72. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  73. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  74. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  75. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  76. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  77. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  78. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
  79. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  80. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  81. package/src/ui-tailwind/theme/editor-theme.css +47 -14
  82. package/src/ui-tailwind/tw-review-workspace.tsx +303 -123
@@ -7,7 +7,7 @@
7
7
  * content type.
8
8
  *
9
9
  * Required by:
10
- * docs/plans/close-render-fidelity.md §2 A.5
10
+ * docs/plans/lane-3-layout-engine-ooxml-fidelity.md §2 A.5
11
11
  *
12
12
  * Schema shape (ECMA-376 Part 1, Office Extended properties):
13
13
  * <Properties xmlns="…/extended-properties" xmlns:vt="…/docPropsVTypes">
@@ -267,19 +267,16 @@ export function serializeCommentAnchorsIntoDocumentXml(
267
267
  continue;
268
268
  }
269
269
 
270
- const paragraph = paragraphs.find(
271
- (candidate) =>
272
- anchor.range.from >= candidate.start &&
273
- anchor.range.to <= candidate.end,
274
- );
270
+ const startParagraph = findParagraphForEndpoint(paragraphs, anchor.range.from, "start");
271
+ const endParagraph = findParagraphForEndpoint(paragraphs, anchor.range.to, "end");
275
272
 
276
- if (!paragraph) {
273
+ if (!startParagraph || !endParagraph) {
277
274
  skippedCommentIds.push(thread.commentId);
278
275
  continue;
279
276
  }
280
277
 
281
- const startIndex = paragraph.boundaries.get(anchor.range.from);
282
- const endIndex = paragraph.boundaries.get(anchor.range.to);
278
+ const startIndex = startParagraph.boundaries.get(anchor.range.from);
279
+ const endIndex = endParagraph.boundaries.get(anchor.range.to);
283
280
 
284
281
  if (startIndex === undefined || endIndex === undefined) {
285
282
  skippedCommentIds.push(thread.commentId);
@@ -324,6 +321,25 @@ export function serializeCommentAnchorsIntoDocumentXml(
324
321
  };
325
322
  }
326
323
 
324
+ function findParagraphForEndpoint(
325
+ paragraphs: readonly Pick<
326
+ RevisionParagraphBoundary,
327
+ "paragraphIndex" | "start" | "end" | "boundaries"
328
+ >[],
329
+ offset: number,
330
+ kind: "start" | "end",
331
+ ): Pick<RevisionParagraphBoundary, "paragraphIndex" | "start" | "end" | "boundaries"> | undefined {
332
+ const matches = paragraphs.filter(
333
+ (p) => offset >= p.start && offset <= p.end,
334
+ );
335
+ if (matches.length === 0) return undefined;
336
+ if (matches.length === 1) return matches[0];
337
+ if (kind === "start") {
338
+ return matches.reduce((a, b) => (b.start >= a.start ? b : a));
339
+ }
340
+ return matches.reduce((a, b) => (b.end <= a.end ? b : a));
341
+ }
342
+
327
343
  export function createCommentExportIdMap(
328
344
  threads: readonly CommentThread[],
329
345
  preservedDefinitions: readonly ImportedCommentDefinition[] = [],
@@ -729,6 +745,8 @@ function walkInlineNodeForBoundaries(
729
745
  }
730
746
 
731
747
  switch (localName(node.name)) {
748
+ case "pPr":
749
+ return;
732
750
  case "r": {
733
751
  if (!boundaries.has(getCursor())) {
734
752
  boundaries.set(getCursor(), node.start);
@@ -750,7 +768,18 @@ function walkInlineNodeForBoundaries(
750
768
  return;
751
769
  }
752
770
  case "tab":
753
- case "br": {
771
+ case "br":
772
+ case "footnoteReference":
773
+ case "endnoteReference":
774
+ case "drawing":
775
+ case "pict":
776
+ case "object":
777
+ case "sym":
778
+ case "ptab":
779
+ case "separator":
780
+ case "continuationSeparator":
781
+ case "noBreakHyphen":
782
+ case "softHyphen": {
754
783
  const startCursor = getCursor();
755
784
  boundaries.set(startCursor, node.start);
756
785
  const nextCursor = startCursor + 1;
@@ -18,7 +18,7 @@
18
18
  * requireTwip(value) — mandatory numeric: throws on non-finite input.
19
19
  *
20
20
  * Source:
21
- * docs/plans/close-render-fidelity.md §2 A.2
21
+ * docs/plans/lane-3-layout-engine-ooxml-fidelity.md §2 A.2
22
22
  */
23
23
 
24
24
  /**
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Load scheduler — main-thread time-slicing primitive for the staged
3
+ * document-load pipeline.
4
+ *
5
+ * The loader calls `scheduler.yield()` between parse stages so the browser
6
+ * can paint, service input, and run React commits. `scheduleIdle(task)`
7
+ * queues low-priority work (e.g., sub-part hydration, compatibility report)
8
+ * for post-skeleton execution.
9
+ *
10
+ * Backend cascade (first available wins):
11
+ * 1. `globalThis.scheduler.yield()` — native browser API (Chrome 129+, Edge).
12
+ * 2. `MessageChannel.postMessage` — universal DOM fallback, ~0.1ms per yield.
13
+ * 3. `setTimeout(0)` — last-resort fallback.
14
+ * 4. `sync` — SSR / Node test harness. `yield()` resolves immediately;
15
+ * `scheduleIdle` runs inline.
16
+ *
17
+ * The `sync` backend is selected when `typeof document === "undefined"` so
18
+ * existing Node-side tests drive the staged pipeline with byte-identical
19
+ * behavior to the eager pipeline (no real yielding, no idle deferral).
20
+ */
21
+
22
+ export type LoadSchedulerBackend =
23
+ | "scheduler-api"
24
+ | "message-channel"
25
+ | "timeout"
26
+ | "sync";
27
+
28
+ export interface LoadScheduler {
29
+ readonly backend: LoadSchedulerBackend;
30
+ /** Yield to the browser. Resolves on next scheduled task / microtask. */
31
+ yield(): Promise<void>;
32
+ /** Schedule low-priority work for post-skeleton execution. */
33
+ scheduleIdle(task: () => void): void;
34
+ /** Cancel pending idle tasks. Must be called on unmount / dispose. */
35
+ dispose(): void;
36
+ }
37
+
38
+ export interface CreateLoadSchedulerOptions {
39
+ /** Frame deadline in ms. Default 4ms (keeps browser at 60fps). */
40
+ frameDeadlineMs?: number;
41
+ /**
42
+ * Force a specific backend (test-only). When omitted, the scheduler
43
+ * detects the best available backend at construction time.
44
+ */
45
+ backendOverride?: LoadSchedulerBackend;
46
+ }
47
+
48
+ const DEFAULT_FRAME_DEADLINE_MS = 4;
49
+
50
+ /**
51
+ * Returns true when the elapsed time since `lastYieldAt` exceeds the
52
+ * scheduler's frame deadline. Callers use this inside tight loops to decide
53
+ * when to `await scheduler.yield()`.
54
+ */
55
+ export function shouldYield(
56
+ scheduler: LoadScheduler & { readonly frameDeadlineMs?: number },
57
+ lastYieldAt: number,
58
+ ): boolean {
59
+ const now = typeof performance !== "undefined" ? performance.now() : Date.now();
60
+ const deadline = scheduler.frameDeadlineMs ?? DEFAULT_FRAME_DEADLINE_MS;
61
+ return now - lastYieldAt >= deadline;
62
+ }
63
+
64
+ /**
65
+ * Returns a monotonic timestamp suitable for `shouldYield` comparisons.
66
+ */
67
+ export function nowMs(): number {
68
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
69
+ }
70
+
71
+ interface InternalScheduler extends LoadScheduler {
72
+ readonly frameDeadlineMs: number;
73
+ }
74
+
75
+ export function createLoadScheduler(
76
+ options: CreateLoadSchedulerOptions = {},
77
+ ): LoadScheduler {
78
+ const frameDeadlineMs = options.frameDeadlineMs ?? DEFAULT_FRAME_DEADLINE_MS;
79
+ const backend = options.backendOverride ?? detectBackend();
80
+
81
+ switch (backend) {
82
+ case "scheduler-api":
83
+ return createSchedulerApiBackend(frameDeadlineMs);
84
+ case "message-channel":
85
+ return createMessageChannelBackend(frameDeadlineMs);
86
+ case "timeout":
87
+ return createTimeoutBackend(frameDeadlineMs);
88
+ case "sync":
89
+ return createSyncBackend(frameDeadlineMs);
90
+ }
91
+ }
92
+
93
+ function detectBackend(): LoadSchedulerBackend {
94
+ if (typeof document === "undefined") {
95
+ return "sync";
96
+ }
97
+ const g = globalThis as unknown as {
98
+ scheduler?: { yield?: () => Promise<void> };
99
+ };
100
+ if (typeof g.scheduler?.yield === "function") {
101
+ return "scheduler-api";
102
+ }
103
+ if (typeof MessageChannel !== "undefined") {
104
+ return "message-channel";
105
+ }
106
+ return "timeout";
107
+ }
108
+
109
+ function createSchedulerApiBackend(frameDeadlineMs: number): InternalScheduler {
110
+ const g = globalThis as unknown as {
111
+ scheduler: { yield: () => Promise<void> };
112
+ };
113
+ const pendingIdleHandles = new Set<number>();
114
+ return {
115
+ backend: "scheduler-api",
116
+ frameDeadlineMs,
117
+ yield: () => g.scheduler.yield(),
118
+ scheduleIdle(task) {
119
+ const handle = scheduleIdleCallback(task, pendingIdleHandles);
120
+ pendingIdleHandles.add(handle);
121
+ },
122
+ dispose() {
123
+ disposeIdleHandles(pendingIdleHandles);
124
+ },
125
+ };
126
+ }
127
+
128
+ function createMessageChannelBackend(frameDeadlineMs: number): InternalScheduler {
129
+ const pendingIdleHandles = new Set<number>();
130
+ return {
131
+ backend: "message-channel",
132
+ frameDeadlineMs,
133
+ yield() {
134
+ return new Promise<void>((resolve) => {
135
+ const channel = new MessageChannel();
136
+ channel.port1.onmessage = () => {
137
+ channel.port1.close();
138
+ channel.port2.close();
139
+ resolve();
140
+ };
141
+ channel.port2.postMessage(null);
142
+ });
143
+ },
144
+ scheduleIdle(task) {
145
+ const handle = scheduleIdleCallback(task, pendingIdleHandles);
146
+ pendingIdleHandles.add(handle);
147
+ },
148
+ dispose() {
149
+ disposeIdleHandles(pendingIdleHandles);
150
+ },
151
+ };
152
+ }
153
+
154
+ function createTimeoutBackend(frameDeadlineMs: number): InternalScheduler {
155
+ const pendingIdleHandles = new Set<number>();
156
+ return {
157
+ backend: "timeout",
158
+ frameDeadlineMs,
159
+ yield() {
160
+ return new Promise<void>((resolve) => {
161
+ setTimeout(resolve, 0);
162
+ });
163
+ },
164
+ scheduleIdle(task) {
165
+ const handle = setTimeout(task, 0) as unknown as number;
166
+ pendingIdleHandles.add(handle);
167
+ },
168
+ dispose() {
169
+ for (const handle of pendingIdleHandles) {
170
+ clearTimeout(handle as unknown as ReturnType<typeof setTimeout>);
171
+ }
172
+ pendingIdleHandles.clear();
173
+ },
174
+ };
175
+ }
176
+
177
+ function createSyncBackend(frameDeadlineMs: number): InternalScheduler {
178
+ return {
179
+ backend: "sync",
180
+ frameDeadlineMs,
181
+ yield: () => Promise.resolve(),
182
+ scheduleIdle(task) {
183
+ task();
184
+ },
185
+ dispose() {
186
+ /* no-op */
187
+ },
188
+ };
189
+ }
190
+
191
+ type IdleHandle = number;
192
+
193
+ function scheduleIdleCallback(
194
+ task: () => void,
195
+ store: Set<IdleHandle>,
196
+ ): IdleHandle {
197
+ const g = globalThis as unknown as {
198
+ requestIdleCallback?: (cb: () => void, options?: { timeout: number }) => number;
199
+ cancelIdleCallback?: (handle: number) => void;
200
+ };
201
+ if (typeof g.requestIdleCallback === "function") {
202
+ const handle = g.requestIdleCallback(
203
+ () => {
204
+ store.delete(handle);
205
+ task();
206
+ },
207
+ { timeout: 50 },
208
+ );
209
+ return handle;
210
+ }
211
+ const handle = setTimeout(() => {
212
+ store.delete(handle as unknown as number);
213
+ task();
214
+ }, 0) as unknown as number;
215
+ return handle;
216
+ }
217
+
218
+ function disposeIdleHandles(store: Set<IdleHandle>): void {
219
+ const g = globalThis as unknown as {
220
+ cancelIdleCallback?: (handle: number) => void;
221
+ };
222
+ for (const handle of store) {
223
+ if (typeof g.cancelIdleCallback === "function") {
224
+ g.cancelIdleCallback(handle);
225
+ } else {
226
+ clearTimeout(handle as unknown as ReturnType<typeof setTimeout>);
227
+ }
228
+ }
229
+ store.clear();
230
+ }
@@ -23,6 +23,7 @@ import type {
23
23
  import type {
24
24
  ParsedAltChunkNode,
25
25
  ParsedBlockNode,
26
+ ParsedChartPreviewNode,
26
27
  ParsedCustomXmlNode,
27
28
  ParsedHyperlinkNode,
28
29
  ParsedInlineNode,
@@ -31,11 +32,17 @@ import type {
31
32
  ParsedParagraphNode,
32
33
  ParsedSectionBreakNode,
33
34
  ParsedSdtNode,
35
+ ParsedSmartArtPreviewNode,
34
36
  ParsedTableBlockNode,
35
37
  ParsedTableCellNode,
36
38
  ParsedTableRowNode,
37
39
  } from "../ooxml/parse-main-document.ts";
38
40
  import { classifyFieldInstruction, buildFieldRegistry } from "../ooxml/parse-fields.ts";
41
+ import {
42
+ type LoadScheduler,
43
+ nowMs,
44
+ shouldYield,
45
+ } from "../load-scheduler.ts";
39
46
 
40
47
  export interface NormalizedTextDocument {
41
48
  content: DocumentRootNode;
@@ -115,6 +122,84 @@ export function normalizeParsedTextDocument(
115
122
  };
116
123
  }
117
124
 
125
+ /**
126
+ * Fastload P6: async sibling of `normalizeParsedTextDocument` that yields to
127
+ * the browser every {@link NORMALIZE_YIELD_STRIDE} top-level blocks when
128
+ * {@link shouldYield} fires against the scheduler's frame deadline. Shares
129
+ * the private normalizeBlocks / normalizeParagraph / normalizeInlineChildren
130
+ * helpers with the sync export — only the outermost block walk is duplicated.
131
+ *
132
+ * Byte-equivalent to the sync export on any corpus (fixture parity is asserted
133
+ * in `test/io/normalize-text-async.test.ts` across every F*.docx fixture).
134
+ */
135
+ const NORMALIZE_YIELD_STRIDE = 256;
136
+
137
+ export async function normalizeParsedTextDocumentAsync(
138
+ document: ParsedMainDocument,
139
+ packagePartName = "/word/document.xml",
140
+ scheduler: LoadScheduler,
141
+ options?: { styles?: import("../../model/canonical-document.ts").StylesCatalog },
142
+ ): Promise<NormalizedTextDocument> {
143
+ const state: NormalizationState = {
144
+ nextFragmentIndex: 1,
145
+ nextWarningIndex: 1,
146
+ nextDiagnosticIndex: 1,
147
+ cursor: 0,
148
+ media: {
149
+ items: {},
150
+ },
151
+ preservation: {
152
+ opaqueFragments: {},
153
+ packageParts: {},
154
+ },
155
+ diagnostics: {
156
+ warnings: [],
157
+ errors: [],
158
+ },
159
+ };
160
+
161
+ const children: BlockNode[] = [];
162
+ let previousParagraph = false;
163
+ let lastYieldAt = nowMs();
164
+
165
+ for (let i = 0; i < document.blocks.length; i += 1) {
166
+ const block = document.blocks[i];
167
+ const normalizedBlocks = normalizeBlocks(block, state, packagePartName);
168
+ for (const normalizedBlock of normalizedBlocks) {
169
+ if (previousParagraph && normalizedBlock.type === "paragraph") {
170
+ state.cursor += 1;
171
+ }
172
+ children.push(normalizedBlock);
173
+ previousParagraph = normalizedBlock.type === "paragraph";
174
+ }
175
+ if (
176
+ i > 0 &&
177
+ i % NORMALIZE_YIELD_STRIDE === 0 &&
178
+ shouldYield(scheduler, lastYieldAt)
179
+ ) {
180
+ await scheduler.yield();
181
+ lastYieldAt = nowMs();
182
+ }
183
+ }
184
+
185
+ const content: DocumentRootNode = { type: "doc", children };
186
+
187
+ const styles = options?.styles ?? { paragraphs: {}, characters: {}, tables: {} };
188
+ const fieldRegistry = buildFieldRegistry({ content, styles });
189
+ const hasFields = fieldRegistry.supported.length > 0 || fieldRegistry.preserveOnly.length > 0;
190
+
191
+ return {
192
+ content,
193
+ media: state.media,
194
+ preservation: state.preservation,
195
+ diagnostics: state.diagnostics,
196
+ ...(document.finalSectionProperties !== undefined
197
+ ? { finalSectionProperties: document.finalSectionProperties }
198
+ : {}),
199
+ ...(hasFields ? { fieldRegistry } : {}),
200
+ };
201
+ }
202
+
118
203
  function normalizeBlocks(
119
204
  block: ParsedBlockNode,
120
205
  state: NormalizationState,
@@ -393,6 +478,7 @@ function normalizeInlineChildren(
393
478
  state.cursor += 1;
394
479
  break;
395
480
  case "chart_preview":
481
+ registerComplexPreviewMedia(state, node);
396
482
  normalized.push({
397
483
  type: "chart_preview",
398
484
  ...(node.previewMediaId ? { previewMediaId: node.previewMediaId } : {}),
@@ -401,6 +487,7 @@ function normalizeInlineChildren(
401
487
  state.cursor += 1;
402
488
  break;
403
489
  case "smartart_preview":
490
+ registerComplexPreviewMedia(state, node);
404
491
  normalized.push({
405
492
  type: "smartart_preview",
406
493
  ...(node.previewMediaId ? { previewMediaId: node.previewMediaId } : {}),
@@ -528,6 +615,35 @@ function normalizeImageNode(
528
615
  };
529
616
  }
530
617
 
618
+ /**
619
+ * Register a chart/SmartArt preview bitmap in the media catalog so the
620
+ * surface renderer can resolve `previewMediaId` → `previewSrc` the same
621
+ * way it does for inline images. Chart/SmartArt nodes weren't previously
622
+ * registered because only image nodes walked through `normalizeImageNode`.
623
+ * Needed by docs/plans/lane-5-charts.md Stage 0 (real mc:Fallback bitmaps) and
624
+ * Stage 0B (synthesized previews from the demo harness decorator) —
625
+ * without this, previewMediaId sits in the canonical model but
626
+ * `canonicalDocument.media.items` has no corresponding MediaItem and
627
+ * the editor's mediaPreview resolver skips the bitmap.
628
+ */
629
+ function registerComplexPreviewMedia(
630
+ state: NormalizationState,
631
+ node: ParsedChartPreviewNode | ParsedSmartArtPreviewNode,
632
+ ): void {
633
+ if (!node.previewMediaId) return;
634
+ if (state.media.items[node.previewMediaId]) return; // already registered (e.g. via another reference)
635
+ const packagePartName =
636
+ node.previewPackagePartName ?? `/${node.previewMediaId.slice("media:".length)}`;
637
+ const filename =
638
+ packagePartName.slice(packagePartName.lastIndexOf("/") + 1) || "chart-preview.bin";
639
+ state.media.items[node.previewMediaId] = {
640
+ mediaId: node.previewMediaId,
641
+ contentType: node.previewContentType ?? "application/octet-stream",
642
+ filename,
643
+ packagePartName,
644
+ };
645
+ }
646
+
531
647
  function normalizeHyperlink(node: ParsedHyperlinkNode): {
532
648
  type: "hyperlink";
533
649
  href: string;
@@ -173,39 +173,6 @@ export function parseCommentsFromOoxml(
173
173
  continue;
174
174
  }
175
175
 
176
- if (startParagraphIndex !== endParagraphIndex) {
177
- diagnostics.push({
178
- commentId: rootCommentId,
179
- code: "multi_paragraph_anchor_preserve_only",
180
- message:
181
- "Comment anchor spans multiple paragraphs. Thread is visible but detached; cross-paragraph anchoring is not yet supported for live editing.",
182
- featureClass: "preserve-only",
183
- detachedReason: "multi-paragraph",
184
- actionabilityNote: "The comment thread and body are preserved. Operators see the thread in the sidebar but cannot navigate to an inline highlight.",
185
- });
186
- threads.push(
187
- createImportedCommentThread({
188
- commentId: rootCommentId,
189
- body: rootDefinition.body,
190
- createdBy,
191
- createdAt,
192
- range: detachedRange,
193
- entries,
194
- status: "detached",
195
- resolution,
196
- metadata: {
197
- source: "import",
198
- rootOoxmlCommentId: rootDefinition.commentId,
199
- rootParaId: rootDefinition.paraId,
200
- detachedReason: "multi-paragraph",
201
- actionabilityNote:
202
- "The comment thread and body are preserved. Operators see the thread in the sidebar but cannot navigate to an inline highlight.",
203
- },
204
- }),
205
- );
206
- continue;
207
- }
208
-
209
176
  threads.push(
210
177
  createImportedCommentThread({
211
178
  commentId: rootCommentId,
@@ -21,6 +21,10 @@ export interface ParsedChartContent {
21
21
  type: "chart_preview";
22
22
  /** Media ID of the fallback preview image, if one is present in mc:Fallback. */
23
23
  previewMediaId?: string;
24
+ /** Absolute package path of the preview media (e.g. `/word/media/chartN.png`); required by the media catalog when the preview media needs an entry. */
25
+ previewPackagePartName?: string;
26
+ /** MIME type of the preview media (e.g. `image/png`, `image/svg+xml`). */
27
+ previewContentType?: string;
24
28
  /** Original drawing XML slice for lossless round-trip export. */
25
29
  rawXml: string;
26
30
  }
@@ -29,6 +33,10 @@ export interface ParsedSmartArtContent {
29
33
  type: "smartart_preview";
30
34
  /** Media ID of the fallback preview image, if one is present in mc:Fallback. */
31
35
  previewMediaId?: string;
36
+ /** Absolute package path of the preview media. */
37
+ previewPackagePartName?: string;
38
+ /** MIME type of the preview media. */
39
+ previewContentType?: string;
32
40
  /** Original drawing XML slice for lossless round-trip export. */
33
41
  rawXml: string;
34
42
  }
@@ -116,6 +124,8 @@ function parseAlternateContent(
116
124
 
117
125
  // Extract fallback preview image if present
118
126
  let previewMediaId: string | undefined;
127
+ let previewPackagePartName: string | undefined;
128
+ let previewContentType: string | undefined;
119
129
  if (fallback) {
120
130
  const fallbackBlip = findFirstDescendant(fallback, "blip");
121
131
  if (fallbackBlip) {
@@ -132,6 +142,8 @@ function parseAlternateContent(
132
142
  const mediaPart = mediaParts.get(packagePartName);
133
143
  if (mediaPart) {
134
144
  previewMediaId = `media:${packagePartName.slice(1)}`;
145
+ previewPackagePartName = packagePartName;
146
+ previewContentType = mediaPart.contentType;
135
147
  }
136
148
  }
137
149
  }
@@ -141,6 +153,8 @@ function parseAlternateContent(
141
153
  return {
142
154
  type: contentType,
143
155
  ...(previewMediaId ? { previewMediaId } : {}),
156
+ ...(previewPackagePartName ? { previewPackagePartName } : {}),
157
+ ...(previewContentType ? { previewContentType } : {}),
144
158
  rawXml: fullDrawingXml,
145
159
  };
146
160
  }
@@ -211,12 +211,16 @@ export interface ParsedOpaqueInlineNode {
211
211
  export interface ParsedChartPreviewNode {
212
212
  type: "chart_preview";
213
213
  previewMediaId?: string;
214
+ previewPackagePartName?: string;
215
+ previewContentType?: string;
214
216
  rawXml: string;
215
217
  }
216
218
 
217
219
  export interface ParsedSmartArtPreviewNode {
218
220
  type: "smartart_preview";
219
221
  previewMediaId?: string;
222
+ previewPackagePartName?: string;
223
+ previewContentType?: string;
220
224
  rawXml: string;
221
225
  }
222
226