@beyondwork/docx-react-component 1.0.43 → 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 (48) hide show
  1. package/README.md +17 -0
  2. package/package.json +44 -32
  3. package/src/api/public-types.ts +139 -3
  4. package/src/core/commands/formatting-commands.ts +7 -1
  5. package/src/core/commands/index.ts +27 -2
  6. package/src/core/commands/text-commands.ts +59 -0
  7. package/src/core/selection/review-anchors.ts +131 -21
  8. package/src/index.ts +16 -1
  9. package/src/io/chart-preview-resolver.ts +281 -0
  10. package/src/io/docx-session.ts +21 -1
  11. package/src/io/export/build-app-properties-xml.ts +1 -1
  12. package/src/io/export/serialize-comments.ts +38 -9
  13. package/src/io/export/twip.ts +1 -1
  14. package/src/io/normalize/normalize-text.ts +33 -0
  15. package/src/io/ooxml/parse-comments.ts +0 -33
  16. package/src/io/ooxml/parse-complex-content.ts +14 -0
  17. package/src/io/ooxml/parse-main-document.ts +4 -0
  18. package/src/preservation/opaque-region.ts +5 -0
  19. package/src/review/store/comment-remapping.ts +2 -2
  20. package/src/runtime/document-runtime.ts +316 -25
  21. package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
  22. package/src/runtime/edit-dispatch/index.ts +2 -0
  23. package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
  24. package/src/runtime/editor-surface/capabilities.ts +411 -0
  25. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  26. package/src/runtime/layout/layout-engine-instance.ts +46 -0
  27. package/src/runtime/layout/layout-engine-version.ts +41 -0
  28. package/src/runtime/layout/public-facet.ts +30 -0
  29. package/src/runtime/prerender/cache-envelope.ts +29 -0
  30. package/src/runtime/prerender/cache-key.ts +66 -0
  31. package/src/runtime/prerender/font-fingerprint.ts +17 -0
  32. package/src/runtime/prerender/graph-canonicalize.ts +121 -0
  33. package/src/runtime/prerender/indexeddb-cache.ts +184 -0
  34. package/src/runtime/prerender/prerender-document.ts +145 -0
  35. package/src/runtime/render/block-fragment-projection.ts +2 -0
  36. package/src/runtime/selection/post-edit-validator.ts +77 -0
  37. package/src/runtime/surface-projection.ts +35 -2
  38. package/src/ui/WordReviewEditor.tsx +75 -192
  39. package/src/ui/editor-runtime-boundary.ts +5 -1
  40. package/src/ui/runtime-shortcut-dispatch.ts +28 -68
  41. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
  42. package/src/ui-tailwind/editor-surface/pm-schema.ts +18 -0
  43. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +23 -0
  44. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  45. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +38 -0
  46. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
  47. package/src/ui-tailwind/theme/editor-theme.css +47 -14
  48. package/src/ui-tailwind/tw-review-workspace.tsx +131 -29
@@ -116,12 +116,61 @@ export function canCreateDocxCommentAnchor(
116
116
  return false;
117
117
  }
118
118
 
119
- return rangeStaysWithinSingleParagraph(content, normalized);
119
+ return rangeStaysWithinCommentableStory(content, normalized);
120
+ }
121
+
122
+ export function rangeStaysWithinCommentableStory(
123
+ content: unknown,
124
+ range: DocRange,
125
+ ): boolean {
126
+ const normalized = normalizeRange(range);
127
+ if (normalized.from === normalized.to) {
128
+ return false;
129
+ }
130
+
131
+ const surfaceBlocks = readSurfaceBlocks(content);
132
+ if (surfaceBlocks) {
133
+ const fromOwner = findContainingParagraphForEndpoint(surfaceBlocks, normalized.from, "start");
134
+ const toOwner = findContainingParagraphForEndpoint(surfaceBlocks, normalized.to, "end");
135
+ if (!fromOwner || !toOwner) {
136
+ return false;
137
+ }
138
+ if (fromOwner.tableId !== toOwner.tableId) {
139
+ return false;
140
+ }
141
+ if (fromOwner.tableCellId !== toOwner.tableCellId) {
142
+ return false;
143
+ }
144
+ return !rangeCrossesOpaqueOrTableBoundary(
145
+ surfaceBlocks,
146
+ normalized,
147
+ fromOwner,
148
+ );
149
+ }
150
+
151
+ const story = parseTextStory(content);
152
+ const upperBound = Math.min(normalized.to, story.units.length);
153
+ for (let index = Math.max(0, normalized.from); index < upperBound; index += 1) {
154
+ const unit = story.units[index];
155
+ if (!unit) continue;
156
+ if (unit.kind === "opaque_block") {
157
+ return false;
158
+ }
159
+ }
160
+ return true;
161
+ }
162
+
163
+ interface FlattenedSurfaceBlock {
164
+ kind: string;
165
+ from: number;
166
+ to: number;
167
+ tableId: string | null;
168
+ tableCellId: string | null;
120
169
  }
121
170
 
122
171
  function readSurfaceBlocks(
123
172
  content: unknown,
124
- ): Array<{ kind: string; from: number; to: number }> | undefined {
173
+ ): FlattenedSurfaceBlock[] | undefined {
125
174
  if (!content || typeof content !== "object" || !("blocks" in content)) {
126
175
  return undefined;
127
176
  }
@@ -131,17 +180,19 @@ function readSurfaceBlocks(
131
180
  return undefined;
132
181
  }
133
182
 
134
- const normalized = flattenSurfaceBlocks(blocks);
183
+ const normalized = flattenSurfaceBlocks(blocks, null, null);
135
184
 
136
185
  return normalized.length > 0 ? normalized : undefined;
137
186
  }
138
187
 
139
188
  function flattenSurfaceBlocks(
140
189
  blocks: unknown[],
141
- ): Array<{ kind: string; from: number; to: number }> {
142
- const flattened: Array<{ kind: string; from: number; to: number }> = [];
190
+ tableId: string | null,
191
+ tableCellId: string | null,
192
+ ): FlattenedSurfaceBlock[] {
193
+ const flattened: FlattenedSurfaceBlock[] = [];
143
194
 
144
- for (const block of blocks) {
195
+ for (const [blockIndex, block] of blocks.entries()) {
145
196
  if (
146
197
  !block ||
147
198
  typeof block !== "object" ||
@@ -152,32 +203,91 @@ function flattenSurfaceBlocks(
152
203
  continue;
153
204
  }
154
205
 
155
- flattened.push({
156
- kind: (block as { kind: string }).kind,
157
- from: (block as { from: number }).from,
158
- to: (block as { to: number }).to,
159
- });
206
+ const kind = (block as { kind: string }).kind;
207
+ const from = (block as { from: number }).from;
208
+ const to = (block as { to: number }).to;
160
209
 
161
- if (
162
- (block as { kind: string }).kind === "table" &&
163
- Array.isArray((block as { rows?: unknown }).rows)
164
- ) {
165
- for (const row of (block as { rows: Array<{ cells?: unknown[] }> }).rows) {
166
- for (const cell of row.cells ?? []) {
210
+ flattened.push({ kind, from, to, tableId, tableCellId });
211
+
212
+ if (kind === "table" && Array.isArray((block as { rows?: unknown }).rows)) {
213
+ const nextTableId =
214
+ (block as { blockId?: string }).blockId ?? `table-${from}-${to}-${blockIndex}`;
215
+ const rows = (block as { rows: Array<{ cells?: unknown[] }> }).rows;
216
+ for (const [rowIdx, row] of rows.entries()) {
217
+ for (const [cellIdx, cell] of (row.cells ?? []).entries()) {
167
218
  if (cell && typeof cell === "object" && Array.isArray((cell as { content?: unknown[] }).content)) {
168
- flattened.push(...flattenSurfaceBlocks((cell as { content: unknown[] }).content));
219
+ const cellFingerprint = `${nextTableId}-r${rowIdx}-c${cellIdx}`;
220
+ flattened.push(
221
+ ...flattenSurfaceBlocks(
222
+ (cell as { content: unknown[] }).content,
223
+ nextTableId,
224
+ cellFingerprint,
225
+ ),
226
+ );
169
227
  }
170
228
  }
171
229
  }
172
230
  }
173
231
 
174
232
  if (
175
- (block as { kind: string }).kind === "sdt_block" &&
176
- Array.isArray((block as { children?: unknown[] }).children)
233
+ kind === "sdt_block" &&
234
+ Array.isArray((block as { children?: unknown }).children)
177
235
  ) {
178
- flattened.push(...flattenSurfaceBlocks((block as { children: unknown[] }).children));
236
+ flattened.push(
237
+ ...flattenSurfaceBlocks(
238
+ (block as { children: unknown[] }).children,
239
+ tableId,
240
+ tableCellId,
241
+ ),
242
+ );
179
243
  }
180
244
  }
181
245
 
182
246
  return flattened;
183
247
  }
248
+
249
+ function findContainingParagraphForEndpoint(
250
+ blocks: readonly FlattenedSurfaceBlock[],
251
+ offset: number,
252
+ kind: "start" | "end",
253
+ ): FlattenedSurfaceBlock | null {
254
+ const matches = blocks.filter(
255
+ (block) =>
256
+ block.kind === "paragraph" && offset >= block.from && offset <= block.to,
257
+ );
258
+ if (matches.length === 0) return null;
259
+ if (matches.length === 1) return matches[0]!;
260
+ // When an offset sits exactly on a paragraph boundary it matches the
261
+ // trailing paragraph as well as the leading one. For a start endpoint we
262
+ // prefer the later paragraph (the range extends forward from it); for an
263
+ // end endpoint we prefer the earlier paragraph (the range ends there).
264
+ if (kind === "start") {
265
+ return matches.reduce((a, b) => (b.from >= a.from ? b : a));
266
+ }
267
+ return matches.reduce((a, b) => (b.to <= a.to ? b : a));
268
+ }
269
+
270
+ function rangeCrossesOpaqueOrTableBoundary(
271
+ blocks: readonly FlattenedSurfaceBlock[],
272
+ range: DocRange,
273
+ origin: FlattenedSurfaceBlock,
274
+ ): boolean {
275
+ for (const block of blocks) {
276
+ const overlapFrom = Math.max(block.from, range.from);
277
+ const overlapTo = Math.min(block.to, range.to);
278
+ if (overlapTo <= overlapFrom) continue;
279
+
280
+ if (block.kind === "opaque_block") {
281
+ return true;
282
+ }
283
+
284
+ if (
285
+ block.kind === "paragraph" &&
286
+ (block.tableId !== origin.tableId ||
287
+ block.tableCellId !== origin.tableCellId)
288
+ ) {
289
+ return true;
290
+ }
291
+ }
292
+ return false;
293
+ }
package/src/index.ts CHANGED
@@ -13,7 +13,7 @@ export { ISSUE_METADATA_ID, REVIEW_ACTION_METADATA_ID } from "./api/public-types
13
13
  // P17 — metadata persistence error class.
14
14
  export { MetadataResolverMissingError } from "./api/public-types.ts";
15
15
 
16
- // Collab substrate (P1 – P8f + P14). See docs/plans/collab-master-plan.md
16
+ // Collab substrate (P1 – P8f + P14). See docs/plans/lane-4-collab-clm-vallor.md
17
17
  // for the shipped-slice table. Surfaces are stable for host integration;
18
18
  // the chrome preset + markdown renderer land in P9 / P10.
19
19
  export { createCollabSession } from "./runtime/collab-session.ts";
@@ -121,6 +121,7 @@ export type {
121
121
  LoadSourcePolicy,
122
122
  EditorSessionState,
123
123
  EditorHostAdapter,
124
+ ChartPreviewResolveParams,
124
125
  WordReviewEditorProps,
125
126
  WordReviewEditorChromePreset,
126
127
  WordReviewEditorChromeOptions,
@@ -185,6 +186,8 @@ export type {
185
186
  SnapshotRefreshChangeKind,
186
187
  SnapshotRefreshHints,
187
188
  AddCommentParams,
189
+ AddCommentResult,
190
+ AddCommentReplyResult,
188
191
  ExportDocxOptions,
189
192
  ExportResult,
190
193
  AutosaveConfig,
@@ -281,3 +284,15 @@ export type {
281
284
  EditorStatePartLoadFailure,
282
285
  EditorStatePartPersistFailure,
283
286
  } from "./api/public-types.ts";
287
+
288
+ // L7 Phase 2.5 — prerender cache public API. Platforms / ingest workers
289
+ // call `prerenderDocument(buffer)` on template upload to populate the
290
+ // cache; end-user opens read the cache and skip the layout pass. See
291
+ // docs/plans/lane-2-render-performance.md §Phase 2.5.
292
+ export { prerenderDocument } from "./runtime/prerender/prerender-document.ts";
293
+ export type {
294
+ PrerenderOptions,
295
+ PrerenderResult,
296
+ PrerenderCounters,
297
+ } from "./runtime/prerender/prerender-document.ts";
298
+ export type { CacheEnvelope } from "./runtime/prerender/cache-envelope.ts";
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Stage 0B.1 — chart preview resolver.
3
+ *
4
+ * Given a freshly imported `CanonicalDocument` and the source
5
+ * `OpcPackage`, iterate every `chart_preview` inline node whose
6
+ * `previewMediaId` is unset, extract the chart part XML + theme, and
7
+ * ask the host adapter's `renderChartPreview` callback for preview
8
+ * bytes. Successful returns become synthesized `MediaItem` entries on
9
+ * the document; unresolved charts keep their existing shape so
10
+ * downstream rendering falls back to the Stage 0 typed badge.
11
+ *
12
+ * Pure Node-side. No React imports. Not reachable from the edit path
13
+ * (runs at import time only) so §Performance Invariants in CLAUDE.md
14
+ * about synchronous layout reads do not apply.
15
+ *
16
+ * See docs/plans/lane-5-charts.md §2 Task 2.
17
+ */
18
+
19
+ import type {
20
+ BlockNode,
21
+ CanonicalDocument,
22
+ ChartPreviewNode,
23
+ DocumentRootNode,
24
+ InlineNode,
25
+ MediaItem,
26
+ ParagraphNode,
27
+ } from "../model/canonical-document.ts";
28
+ import type {
29
+ ChartPreviewResolveParams,
30
+ EditorHostAdapter,
31
+ } from "../api/public-types.ts";
32
+ import type { OpcPackage } from "./opc/package-reader.ts";
33
+ import { normalizePartPath, resolveRelationshipTarget } from "./ooxml/part-manifest.ts";
34
+
35
+ interface ResolveContext {
36
+ readonly package: OpcPackage;
37
+ readonly adapter: EditorHostAdapter;
38
+ readonly themeXml: string | undefined;
39
+ /** Monotonic counter so we can generate unique media ids across one run. */
40
+ seq: number;
41
+ }
42
+
43
+ interface PendingResolution {
44
+ /** Path from the document root to the chart_preview node, used to surgically replace the node on resolution. */
45
+ readonly pointer: Pointer;
46
+ readonly node: ChartPreviewNode;
47
+ readonly chartPartPath: string;
48
+ readonly chartXml: string;
49
+ readonly widthEmu: number;
50
+ readonly heightEmu: number;
51
+ }
52
+
53
+ type Pointer = Array<number>;
54
+
55
+ export async function resolveChartPreviewsForDocument(
56
+ doc: CanonicalDocument,
57
+ pkg: OpcPackage,
58
+ adapter: EditorHostAdapter | undefined,
59
+ ): Promise<CanonicalDocument> {
60
+ if (!adapter?.renderChartPreview) return doc;
61
+
62
+ const pending = collectUnresolvedChartPreviews(doc, pkg);
63
+ if (pending.length === 0) return doc;
64
+
65
+ const themeXml = extractPartTextFromPackage(pkg, "/word/theme/theme1.xml");
66
+ const renderer = adapter.renderChartPreview;
67
+ const ctx: ResolveContext = { package: pkg, adapter, themeXml, seq: 0 };
68
+
69
+ const resolutions = await Promise.all(
70
+ pending.map(async (entry) => {
71
+ const params: ChartPreviewResolveParams = {
72
+ chartXml: entry.chartXml,
73
+ chartPartPath: entry.chartPartPath,
74
+ themeXml,
75
+ widthEmu: entry.widthEmu,
76
+ heightEmu: entry.heightEmu,
77
+ };
78
+ try {
79
+ const bytes = await renderer(params);
80
+ if (!bytes || bytes.length === 0) return null;
81
+ return { entry, bytes };
82
+ } catch (err) {
83
+ try {
84
+ adapter.logEvent?.({
85
+ type: "chart_preview_render_failed",
86
+ documentId: doc.docId,
87
+ detail: {
88
+ chartPartPath: entry.chartPartPath,
89
+ message: err instanceof Error ? err.message : String(err),
90
+ },
91
+ });
92
+ } catch {
93
+ // Host's logEvent can throw too; swallow silently — one chart failing
94
+ // must never surface as an unhandled rejection during import.
95
+ }
96
+ return null;
97
+ }
98
+ }),
99
+ );
100
+
101
+ const successful = resolutions.filter(
102
+ (r): r is { entry: PendingResolution; bytes: Uint8Array } => r !== null,
103
+ );
104
+ if (successful.length === 0) return doc;
105
+
106
+ void ctx; // resolveContext is reserved for future use (caching, progress events)
107
+ return applyResolutions(doc, successful);
108
+ }
109
+
110
+ /**
111
+ * Walk the whole content tree and yield one PendingResolution per
112
+ * chart_preview node that (a) has no previewMediaId yet and (b) can
113
+ * resolve its chart part from the package. The pointer path lets
114
+ * `applyResolutions` rewrite those exact nodes without touching the
115
+ * surrounding structure.
116
+ */
117
+ function collectUnresolvedChartPreviews(doc: CanonicalDocument, pkg: OpcPackage): PendingResolution[] {
118
+ const out: PendingResolution[] = [];
119
+ const documentRels = collectDocumentPartRelationships(pkg);
120
+
121
+ const paragraphs = doc.content.children;
122
+ for (let i = 0; i < paragraphs.length; i++) {
123
+ const block = paragraphs[i]!;
124
+ if (block.type !== "paragraph") continue;
125
+ const paragraph = block as ParagraphNode;
126
+ for (let j = 0; j < paragraph.children.length; j++) {
127
+ const child = paragraph.children[j];
128
+ if (!child || child.type !== "chart_preview") continue;
129
+ const chartNode = child as ChartPreviewNode;
130
+ if (chartNode.previewMediaId) continue;
131
+ const resolved = resolveChartPart(chartNode, documentRels, pkg);
132
+ if (!resolved) continue;
133
+ out.push({
134
+ pointer: [i, j],
135
+ node: chartNode,
136
+ chartPartPath: resolved.chartPartPath,
137
+ chartXml: resolved.chartXml,
138
+ widthEmu: resolved.widthEmu,
139
+ heightEmu: resolved.heightEmu,
140
+ });
141
+ }
142
+ }
143
+ return out;
144
+ }
145
+
146
+ function collectDocumentPartRelationships(pkg: OpcPackage): Map<string, string> {
147
+ // Only walk /word/document.xml for Stage 0B.1; headers/footers/endnotes
148
+ // carry charts rarely and will be a later iteration.
149
+ const docPart = pkg.parts.get("/word/document.xml");
150
+ if (!docPart?.relationships) return new Map();
151
+ const map = new Map<string, string>();
152
+ for (const rel of docPart.relationships) {
153
+ if (rel.targetMode !== "internal") continue;
154
+ if (!rel.type.endsWith("/chart")) continue;
155
+ const target = resolveRelationshipTarget("/word/document.xml", rel);
156
+ map.set(rel.id, normalizePartPath(target));
157
+ }
158
+ return map;
159
+ }
160
+
161
+ interface ResolvedChartLocation {
162
+ chartPartPath: string;
163
+ chartXml: string;
164
+ widthEmu: number;
165
+ heightEmu: number;
166
+ }
167
+
168
+ function resolveChartPart(
169
+ node: ChartPreviewNode,
170
+ documentRels: Map<string, string>,
171
+ pkg: OpcPackage,
172
+ ): ResolvedChartLocation | null {
173
+ const relId = extractChartRelId(node.rawXml);
174
+ if (!relId) return null;
175
+ const chartPartPath = documentRels.get(relId);
176
+ if (!chartPartPath) return null;
177
+ const chartXml = extractPartTextFromPackage(pkg, chartPartPath);
178
+ if (!chartXml) return null;
179
+ const extent = extractDrawingExtent(node.rawXml);
180
+ return {
181
+ chartPartPath,
182
+ chartXml,
183
+ widthEmu: extent.widthEmu,
184
+ heightEmu: extent.heightEmu,
185
+ };
186
+ }
187
+
188
+ /** Find `<c:chart ... r:id="rIdN"/>` and return the relationship id. */
189
+ function extractChartRelId(rawXml: string): string | null {
190
+ const match = /<c:chart\b[^>]*?r:id="([^"]+)"/.exec(rawXml);
191
+ return match ? match[1]! : null;
192
+ }
193
+
194
+ /** Read `<wp:extent cx="..." cy="..."/>` out of the drawing XML. */
195
+ function extractDrawingExtent(rawXml: string): { widthEmu: number; heightEmu: number } {
196
+ const match = /<wp:extent\b([^/>]*)\/>/.exec(rawXml);
197
+ if (!match) return { widthEmu: 5486400, heightEmu: 3200400 };
198
+ const attrs = match[1] ?? "";
199
+ const cx = /\bcx="(\d+)"/.exec(attrs)?.[1];
200
+ const cy = /\bcy="(\d+)"/.exec(attrs)?.[1];
201
+ return {
202
+ widthEmu: cx ? parseInt(cx, 10) : 5486400,
203
+ heightEmu: cy ? parseInt(cy, 10) : 3200400,
204
+ };
205
+ }
206
+
207
+ function extractPartTextFromPackage(pkg: OpcPackage, path: string): string | undefined {
208
+ const part = pkg.parts.get(path);
209
+ if (!part?.bytes) return undefined;
210
+ try {
211
+ return new TextDecoder("utf-8").decode(part.bytes);
212
+ } catch {
213
+ return undefined;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Produce a new CanonicalDocument with the resolved chart_preview
219
+ * nodes carrying previewMediaId + corresponding MediaCatalog entries.
220
+ * Never mutates the input doc.
221
+ */
222
+ function applyResolutions(
223
+ doc: CanonicalDocument,
224
+ resolutions: Array<{ entry: PendingResolution; bytes: Uint8Array }>,
225
+ ): CanonicalDocument {
226
+ const newMediaItems: Record<string, MediaItem> = { ...doc.media.items };
227
+ const updates = new Map<string, { previewMediaId: string }>();
228
+
229
+ let seq = 0;
230
+ for (const { entry, bytes } of resolutions) {
231
+ seq += 1;
232
+ const contentType = inferPreviewContentType(bytes);
233
+ const extension = contentType === "image/png" ? "png" : "svg";
234
+ const packagePartName = `/word/media/chart-preview-synth-${seq}.${extension}`;
235
+ const mediaId = `media:word/media/chart-preview-synth-${seq}.${extension}`;
236
+ newMediaItems[mediaId] = {
237
+ mediaId,
238
+ contentType,
239
+ filename: packagePartName.slice(packagePartName.lastIndexOf("/") + 1),
240
+ packagePartName,
241
+ widthEmu: entry.widthEmu,
242
+ heightEmu: entry.heightEmu,
243
+ };
244
+ const pointerKey = entry.pointer.join(",");
245
+ updates.set(pointerKey, { previewMediaId: mediaId });
246
+ }
247
+
248
+ if (updates.size === 0) return doc;
249
+
250
+ // Clone the content tree along pointer paths only — everything else
251
+ // keeps object identity so downstream React memoization stays stable.
252
+ const newParagraphs: BlockNode[] = doc.content.children.slice();
253
+ for (const [pointerKey, update] of updates) {
254
+ const [pi, ci] = pointerKey.split(",").map((s) => parseInt(s, 10)) as [number, number];
255
+ const paragraph = newParagraphs[pi];
256
+ if (!paragraph || paragraph.type !== "paragraph") continue;
257
+ const newChildren: InlineNode[] = (paragraph as ParagraphNode).children.slice();
258
+ const existing = newChildren[ci];
259
+ if (!existing || existing.type !== "chart_preview") continue;
260
+ newChildren[ci] = { ...(existing as ChartPreviewNode), previewMediaId: update.previewMediaId };
261
+ newParagraphs[pi] = { ...(paragraph as ParagraphNode), children: newChildren };
262
+ }
263
+
264
+ const newContent: DocumentRootNode = { ...doc.content, children: newParagraphs };
265
+ const newMedia = { items: newMediaItems };
266
+ return { ...doc, content: newContent, media: newMedia };
267
+ }
268
+
269
+ /**
270
+ * Content-type sniff from the first bytes of the rendered preview.
271
+ * PNG magic is 0x89 0x50 0x4E 0x47; everything else is assumed to be
272
+ * SVG text. Hosts that need a different content type should extend
273
+ * this in a follow-up.
274
+ */
275
+ function inferPreviewContentType(bytes: Uint8Array): string {
276
+ if (bytes.length >= 4 &&
277
+ bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
278
+ return "image/png";
279
+ }
280
+ return "image/svg+xml";
281
+ }
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  CompatibilityReport as PublicCompatibilityReport,
3
3
  EditorError,
4
+ EditorHostAdapter,
4
5
  EditorSessionState,
5
6
  EditorWarning as PublicEditorWarning,
6
7
  EditorAnchorProjection as PublicEditorAnchorProjection,
@@ -43,6 +44,7 @@ import {
43
44
  normalizeParsedTextDocument,
44
45
  normalizeParsedTextDocumentAsync,
45
46
  } from "./normalize/normalize-text.ts";
47
+ import { resolveChartPreviewsForDocument } from "./chart-preview-resolver.ts";
46
48
  import { type LoadScheduler } from "./load-scheduler.ts";
47
49
  import {
48
50
  CONTENT_TYPES_PATH,
@@ -228,6 +230,15 @@ interface LoadDocxEditorSessionOptions {
228
230
  * change.
229
231
  */
230
232
  onLoadStage?: (stage: import("../api/public-types.ts").LoadStage, durationMs: number) => void;
233
+ /**
234
+ * Stage 0B.1: host-adapter surface. The sync loader accepts the option for
235
+ * API symmetry with {@link LoadDocxEditorSessionAsyncOptions} but does not
236
+ * invoke `renderChartPreview` — chart-preview synthesis is asynchronous, so
237
+ * hosts that want preview bitmaps must call {@link loadDocxEditorSessionAsync}.
238
+ * Other adapter methods (load/saveSession/logEvent) are not consumed by the
239
+ * loader itself and are carried through unchanged.
240
+ */
241
+ hostAdapter?: EditorHostAdapter;
231
242
  }
232
243
 
233
244
  export interface LoadedDocxEditorSession {
@@ -1322,7 +1333,7 @@ export async function loadDocxEditorSessionAsync(
1322
1333
  workflowMetadata: embeddedWorkflowMetadata,
1323
1334
  timestamp,
1324
1335
  });
1325
- const document = createImportedCanonicalDocument({
1336
+ const importedDocument = createImportedCanonicalDocument({
1326
1337
  documentId: options.documentId,
1327
1338
  timestamp,
1328
1339
  numbering: parsedNumbering,
@@ -1377,6 +1388,15 @@ export async function loadDocxEditorSessionAsync(
1377
1388
  ]),
1378
1389
  },
1379
1390
  });
1391
+ // Stage 0B.1: if the host implements `renderChartPreview`, resolve
1392
+ // chart_preview nodes inline so the first snapshot already carries the
1393
+ // synthesized `previewMediaId`. Fallback-safe: returning null or throwing
1394
+ // is per-chart — the typed badge renders as if the adapter weren't set.
1395
+ const document = (await resolveChartPreviewsForDocument(
1396
+ importedDocument,
1397
+ sourcePackage,
1398
+ options.hostAdapter,
1399
+ )) as CanonicalDocumentEnvelope;
1380
1400
  const compatibility = buildCompatibilityReport({
1381
1401
  document,
1382
1402
  generatedAt: timestamp,
@@ -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
  /**