@beyondwork/docx-react-component 1.0.85 → 1.0.87

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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +49 -0
  3. package/src/api/v3/ui/chrome-composition.ts +2 -11
  4. package/src/api/v3/ui/chrome.ts +6 -8
  5. package/src/index.ts +5 -0
  6. package/src/io/export/serialize-main-document.ts +215 -6
  7. package/src/io/ooxml/parse-drawing.ts +15 -1
  8. package/src/io/ooxml/parse-fields.ts +410 -12
  9. package/src/model/canonical-document.ts +177 -2
  10. package/src/model/layout/page-layout-snapshot.ts +2 -0
  11. package/src/model/layout/runtime-page-graph-types.ts +6 -0
  12. package/src/preservation/store.ts +4 -5
  13. package/src/runtime/document-outline.ts +80 -0
  14. package/src/runtime/document-runtime.ts +338 -13
  15. package/src/runtime/formatting/field/page-number-format.ts +49 -0
  16. package/src/runtime/formatting/field/resolver.ts +61 -40
  17. package/src/runtime/layout/layout-engine-instance.ts +18 -1
  18. package/src/runtime/layout/layout-engine-version.ts +19 -1
  19. package/src/runtime/layout/measurement-backend-canvas.ts +21 -9
  20. package/src/runtime/layout/measurement-backend-empirical.ts +18 -4
  21. package/src/runtime/layout/page-graph.ts +13 -2
  22. package/src/runtime/layout/paginated-layout-engine.ts +440 -117
  23. package/src/runtime/layout/project-block-fragments.ts +87 -4
  24. package/src/runtime/layout/resolve-page-fields.ts +8 -5
  25. package/src/runtime/layout/table-row-split.ts +97 -23
  26. package/src/runtime/surface-projection.ts +227 -27
  27. package/src/shell/session-bootstrap.ts +6 -1
  28. package/src/ui/WordReviewEditor.tsx +112 -33
  29. package/src/ui/editor-command-bag.ts +4 -0
  30. package/src/ui/editor-shell-view.tsx +1 -0
  31. package/src/ui/editor-surface-controller.tsx +1 -0
  32. package/src/ui/headless/revision-decoration-model.ts +11 -13
  33. package/src/ui-tailwind/chrome/editor-action-registry.ts +7 -26
  34. package/src/ui-tailwind/chrome/responsive-chrome.ts +2 -2
  35. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +27 -0
  36. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +6 -2
  37. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +57 -6
  38. package/src/ui-tailwind/editor-surface/page-slice-util.ts +17 -0
  39. package/src/ui-tailwind/editor-surface/perf-probe.ts +5 -0
  40. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +34 -0
  41. package/src/ui-tailwind/editor-surface/pm-decorations.ts +146 -20
  42. package/src/ui-tailwind/editor-surface/pm-position-map.ts +8 -2
  43. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +41 -44
  44. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +1 -1
  45. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +8 -3
  46. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -0
  47. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +75 -31
  48. package/src/ui-tailwind/review/tw-workflow-tab.tsx +159 -8
  49. package/src/ui-tailwind/review-workspace/types.ts +4 -0
  50. package/src/ui-tailwind/review-workspace/use-review-rail-state.ts +1 -1
  51. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +8 -10
  52. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +21 -17
  53. package/src/ui-tailwind/tw-review-workspace.tsx +27 -2
@@ -25,8 +25,10 @@ import type {
25
25
  SurfaceBlockSnapshot,
26
26
  } from "../../api/public-types";
27
27
  import type { RuntimeBlockFragment } from "./page-graph.ts";
28
+ import type { RuntimeLineBox } from "../../model/layout/page-graph-types.ts";
28
29
  import type {
29
30
  BlockSplits,
31
+ FragmentMeasurement,
30
32
  ParagraphLineSlice,
31
33
  TableRowSlice,
32
34
  } from "./paginated-layout-engine.ts";
@@ -38,6 +40,7 @@ export function projectSurfaceBlocksToPageFragments(
38
40
  pages: readonly DocumentPageSnapshot[],
39
41
  splits?: BlockSplits,
40
42
  columnByBlockIdByPageIndex?: ReadonlyMap<number, ReadonlyMap<string, number>>,
43
+ fragmentMeasurementsByPageIndex?: ReadonlyMap<number, ReadonlyMap<string, FragmentMeasurement>>,
41
44
  ): Map<number, FragmentWithoutPageId[]> {
42
45
  const byPage = new Map<number, FragmentWithoutPageId[]>();
43
46
  const perPageCounter = new Map<number, number>();
@@ -63,6 +66,13 @@ export function projectSurfaceBlocksToPageFragments(
63
66
  // Refactor/04 Slice 4 — per-block column placement lookup.
64
67
  const columnIndexFor = (pageIndex: number, blockId: string): number | undefined =>
65
68
  columnByBlockIdByPageIndex?.get(pageIndex)?.get(blockId);
69
+ const measuredHeightFor = (
70
+ pageIndex: number,
71
+ blockId: string,
72
+ fallback: number,
73
+ ): number =>
74
+ fragmentMeasurementsByPageIndex?.get(pageIndex)?.get(blockId)?.heightTwips ??
75
+ fallback;
66
76
 
67
77
  for (const block of surface.blocks) {
68
78
  // R3: table split across pages — emit one fragment per row slice.
@@ -78,7 +88,12 @@ export function projectSurfaceBlocksToPageFragments(
78
88
  pushFragment(pageIndex, {
79
89
  ...fragment,
80
90
  orderInRegion: nextOrder(pageIndex),
81
- ...(columnIndex !== undefined ? { columnIndex } : {}),
91
+ heightTwips: fragment.heightTwips,
92
+ ...(fragment.columnIndex !== undefined
93
+ ? { columnIndex: fragment.columnIndex }
94
+ : columnIndex !== undefined
95
+ ? { columnIndex }
96
+ : {}),
82
97
  });
83
98
  },
84
99
  );
@@ -118,7 +133,11 @@ export function projectSurfaceBlocksToPageFragments(
118
133
  regionKind: "body",
119
134
  from: block.from,
120
135
  to: block.to,
121
- heightTwips: estimateBlockHeightFromSpan(block),
136
+ heightTwips: measuredHeightFor(
137
+ pageIndex,
138
+ block.blockId,
139
+ estimateBlockHeightFromSpan(block),
140
+ ),
122
141
  ...deriveStyleMetadata(block),
123
142
  kind: "whole",
124
143
  ...(columnIndex !== undefined ? { columnIndex } : {}),
@@ -130,6 +149,69 @@ export function projectSurfaceBlocksToPageFragments(
130
149
  return byPage;
131
150
  }
132
151
 
152
+ export function projectLineBoxesForPageFragments(
153
+ pages: readonly DocumentPageSnapshot[],
154
+ fragmentsByPageIndex: ReadonlyMap<
155
+ number,
156
+ ReadonlyArray<Omit<RuntimeBlockFragment, "pageId">>
157
+ >,
158
+ fragmentMeasurementsByPageIndex?: ReadonlyMap<number, ReadonlyMap<string, FragmentMeasurement>>,
159
+ ): Map<number, RuntimeLineBox[]> {
160
+ const byPage = new Map<number, RuntimeLineBox[]>();
161
+ for (const page of pages) {
162
+ const fragments = [...(fragmentsByPageIndex.get(page.pageIndex) ?? [])]
163
+ .filter((fragment) => fragment.regionKind === "body")
164
+ .sort((a, b) => a.orderInRegion - b.orderInRegion);
165
+ const lines: RuntimeLineBox[] = [];
166
+ let cursorTwips = 0;
167
+ for (const fragment of fragments) {
168
+ const measurement = fragmentMeasurementsByPageIndex
169
+ ?.get(page.pageIndex)
170
+ ?.get(fragment.blockId);
171
+ const explicitLines =
172
+ fragment.kind === "paragraph-slice" && fragment.paragraphLineRange
173
+ ? fragment.paragraphLineRange.to - fragment.paragraphLineRange.from
174
+ : measurement?.lineCount;
175
+ const lineCount = Math.max(
176
+ 0,
177
+ explicitLines ?? (fragment.heightTwips > 0 ? 1 : 0),
178
+ );
179
+ if (lineCount === 0) {
180
+ cursorTwips += Math.max(0, fragment.heightTwips);
181
+ continue;
182
+ }
183
+ const lineHeight =
184
+ fragment.kind === "paragraph-slice" && fragment.paragraphLineRange
185
+ ? Math.max(1, Math.floor(fragment.heightTwips / lineCount))
186
+ : measurement?.lineHeightTwips ??
187
+ Math.max(1, Math.floor(fragment.heightTwips / lineCount));
188
+ const widthTwips =
189
+ measurement?.widthTwips ??
190
+ Math.max(
191
+ 0,
192
+ page.layout.pageWidth -
193
+ page.layout.marginLeft -
194
+ page.layout.marginRight -
195
+ page.layout.gutter,
196
+ );
197
+ for (let lineIndex = 0; lineIndex < lineCount; lineIndex += 1) {
198
+ lines.push({
199
+ fragmentId: fragment.fragmentId,
200
+ lineIndex,
201
+ baselineTwips: cursorTwips + lineIndex * lineHeight,
202
+ heightTwips: lineHeight,
203
+ widthTwips,
204
+ });
205
+ }
206
+ cursorTwips += Math.max(0, fragment.heightTwips);
207
+ }
208
+ if (lines.length > 0) {
209
+ byPage.set(page.pageIndex, lines);
210
+ }
211
+ }
212
+ return byPage;
213
+ }
214
+
133
215
  /**
134
216
  * Emit one fragment per slice for a paragraph that pagination split across
135
217
  * pages. The source `block` offset range stays intact on every slice; the
@@ -149,7 +231,7 @@ function emitSlicedParagraph(
149
231
  regionKind: "body",
150
232
  from: block.from,
151
233
  to: block.to,
152
- heightTwips: estimateSliceHeightFromLines(slice.lineRange),
234
+ heightTwips: slice.heightTwips ?? estimateSliceHeightFromLines(slice.lineRange),
153
235
  ...deriveStyleMetadata(block),
154
236
  kind: "paragraph-slice",
155
237
  paragraphLineRange: slice.lineRange,
@@ -190,10 +272,11 @@ function emitSlicedTable(
190
272
  regionKind: "body",
191
273
  from: block.from,
192
274
  to: block.to,
193
- heightTwips: estimateSliceHeightFromRows(slice.rowRange),
275
+ heightTwips: slice.heightTwips ?? estimateSliceHeightFromRows(slice.rowRange),
194
276
  ...deriveStyleMetadata(block),
195
277
  kind: "table-slice",
196
278
  tableRowRange: slice.rowRange,
279
+ ...(slice.columnIndex !== undefined ? { columnIndex: slice.columnIndex } : {}),
197
280
  };
198
281
  emit(slice.pageIndex, fragment);
199
282
  }
@@ -14,7 +14,10 @@
14
14
  */
15
15
 
16
16
  import type { SupportedFieldFamily } from "../../model/canonical-document.ts";
17
- import { formatPageNumber } from "../formatting/field/page-number-format.ts";
17
+ import {
18
+ formatPageNumber,
19
+ formatPageNumberWithChapter,
20
+ } from "../formatting/field/page-number-format.ts";
18
21
  import type { PublicPageNode } from "./public-facet.ts";
19
22
  import type { RuntimePageGraph, RuntimePageNode } from "./page-graph.ts";
20
23
 
@@ -46,9 +49,9 @@ export function resolvePageFieldDisplayText(
46
49
  ): string {
47
50
  switch (family) {
48
51
  case "PAGE":
49
- return formatPageNumber(
52
+ return formatPageNumberWithChapter(
50
53
  context.page.stories.displayPageNumber,
51
- context.page.layout?.pageNumbering?.format,
54
+ context.page.layout?.pageNumbering,
52
55
  );
53
56
  case "NUMPAGES":
54
57
  // Blank fillers (evenPage/oddPage section breaks) don't count toward
@@ -78,9 +81,9 @@ export function resolvePublicPageFieldDisplayText(
78
81
  ): string {
79
82
  switch (family) {
80
83
  case "PAGE":
81
- return formatPageNumber(
84
+ return formatPageNumberWithChapter(
82
85
  context.page.displayPageNumber,
83
- context.page.layout.pageNumbering?.format,
86
+ context.page.layout.pageNumbering,
84
87
  );
85
88
  case "NUMPAGES":
86
89
  return formatPageNumber(
@@ -37,6 +37,12 @@
37
37
 
38
38
  import type { LayoutMeasurementProvider } from "./layout-measurement-provider.ts";
39
39
  import type { SurfaceBlockSnapshot } from "../../api/public-types";
40
+ import {
41
+ buildRunFormattingMap,
42
+ calculateParagraphHeight,
43
+ resolveBlockFormatting,
44
+ type LayoutThemeFonts,
45
+ } from "./resolved-formatting-state.ts";
40
46
 
41
47
  // Re-export the resolveCellWidth helper from paginated-layout-engine so the
42
48
  // math stays single-sourced. The engine module exposes it via the
@@ -50,6 +56,9 @@ export interface MeasureTableRowHeightsInput {
50
56
  block: Extract<SurfaceBlockSnapshot, { kind: "table" }>;
51
57
  columnWidth: number;
52
58
  measurementProvider?: LayoutMeasurementProvider;
59
+ defaultTabInterval?: number;
60
+ themeFonts?: LayoutThemeFonts;
61
+ contentMode?: "legacy-min" | "resolved";
53
62
  }
54
63
 
55
64
  /**
@@ -65,7 +74,14 @@ export interface MeasureTableRowHeightsInput {
65
74
  export function measureTableRowHeights(
66
75
  input: MeasureTableRowHeightsInput,
67
76
  ): number[] {
68
- const { block, columnWidth, measurementProvider } = input;
77
+ const {
78
+ block,
79
+ columnWidth,
80
+ measurementProvider,
81
+ defaultTabInterval = 720,
82
+ themeFonts,
83
+ contentMode = "legacy-min",
84
+ } = input;
69
85
  const heights: number[] = [];
70
86
 
71
87
  const totalGridTwips = block.gridColumns.reduce((sum, w) => sum + w, 0);
@@ -96,11 +112,15 @@ export function measureTableRowHeights(
96
112
  let cellContentHeight = 0;
97
113
  for (const child of cell.content) {
98
114
  if (child.kind === "paragraph") {
99
- cellContentHeight += measureParagraphStandaloneHeight(
100
- child,
101
- cellWidth,
102
- measurementProvider,
103
- );
115
+ cellContentHeight += contentMode === "resolved"
116
+ ? measureParagraphStandaloneHeight(
117
+ child,
118
+ cellWidth,
119
+ measurementProvider,
120
+ defaultTabInterval,
121
+ themeFonts,
122
+ )
123
+ : MIN_ROW_HEIGHT_TWIPS;
104
124
  } else {
105
125
  cellContentHeight += MIN_ROW_HEIGHT_TWIPS;
106
126
  }
@@ -126,26 +146,80 @@ export function measureTableRowHeights(
126
146
  }
127
147
 
128
148
  /**
129
- * Lightweight paragraph height estimate used when walking table cell
130
- * content. Matches the engine's internal measureTableHeight math
131
- * (MIN_ROW_HEIGHT_TWIPS per paragraph) rather than delegating back to
132
- * `measureBlockHeight`, which would require threading the
133
- * per-invocation cache through. Since cell content already re-runs
134
- * through `measureBlockHeight` on the main pagination path, this
135
- * helper is only used by `measureTableRowHeights` for the split-math
136
- * preflight and does not affect canonical pagination.
149
+ * Paragraph height used when walking table cell content. This mirrors the
150
+ * paginator's paragraph measurement path closely enough for row-boundary
151
+ * decisions without importing `measureBlockHeight` back from the paginator.
137
152
  */
138
153
  function measureParagraphStandaloneHeight(
139
- _block: SurfaceBlockSnapshot,
140
- _columnWidth: number,
141
- _provider: LayoutMeasurementProvider | undefined,
154
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
155
+ columnWidth: number,
156
+ provider: LayoutMeasurementProvider | undefined,
157
+ defaultTabInterval: number,
158
+ themeFonts: LayoutThemeFonts | undefined,
142
159
  ): number {
143
- // P6.b scope: the engine's existing table measurement treats every
144
- // cell paragraph as MIN_ROW_HEIGHT_TWIPS (see `measureTableHeight`
145
- // inner loop). P6.b preserves that exact constant so the split
146
- // math agrees with pagination. P6.c can upgrade this to call
147
- // `measureBlockHeight` via the cache once the cache is threaded in.
148
- return MIN_ROW_HEIGHT_TWIPS;
160
+ const formatting = resolveBlockFormatting(block, defaultTabInterval, themeFonts);
161
+ if (!formatting) return MIN_ROW_HEIGHT_TWIPS;
162
+ if (provider) {
163
+ const measured = provider.measureLineFragments({
164
+ block,
165
+ formatting,
166
+ runs: buildRunFormattingMap(block),
167
+ columnWidth,
168
+ });
169
+ const contentHeight = measured.lineHeights.reduce(
170
+ (total, lineHeight) => total + lineHeight,
171
+ 0,
172
+ );
173
+ return Math.max(
174
+ MIN_ROW_HEIGHT_TWIPS,
175
+ contentHeight + formatting.spacingBefore + formatting.spacingAfter,
176
+ );
177
+ }
178
+ const firstLineWidth = Math.max(
179
+ 1,
180
+ columnWidth -
181
+ formatting.indentLeft -
182
+ formatting.indentRight -
183
+ Math.max(0, formatting.firstLineIndent),
184
+ );
185
+ const subsequentLineWidth = Math.max(
186
+ 1,
187
+ columnWidth - formatting.indentLeft - formatting.indentRight,
188
+ );
189
+ const firstCapacity = Math.max(
190
+ 1,
191
+ Math.floor(firstLineWidth / formatting.averageCharWidthTwips),
192
+ );
193
+ const subsequentCapacity = Math.max(
194
+ 1,
195
+ Math.floor(subsequentLineWidth / formatting.averageCharWidthTwips),
196
+ );
197
+ let lineCount = 1;
198
+ let current = 0;
199
+ let capacity = firstCapacity;
200
+ for (const segment of block.segments) {
201
+ if (segment.kind === "hard_break") {
202
+ lineCount += 1;
203
+ current = 0;
204
+ capacity = subsequentCapacity;
205
+ continue;
206
+ }
207
+ const advance =
208
+ segment.kind === "text"
209
+ ? Array.from(segment.text).length
210
+ : segment.kind === "tab"
211
+ ? 4
212
+ : segment.kind === "opaque_inline" && segment.presentation === "quiet-marker"
213
+ ? 0
214
+ : 1;
215
+ current += advance;
216
+ while (current > capacity) {
217
+ lineCount += 1;
218
+ current -= capacity;
219
+ capacity = subsequentCapacity;
220
+ }
221
+ }
222
+ return calculateParagraphHeight(formatting, lineCount);
149
223
  }
150
224
 
151
225
  export interface FindTableRowSplitInput {
@@ -29,6 +29,7 @@ import type {
29
29
  SdtNode,
30
30
  ShapeNode,
31
31
  SmartArtPreviewNode,
32
+ LegacyFormFieldNode,
32
33
  BorderSpec,
33
34
  TableBorders,
34
35
  TableCellBorders,
@@ -1186,7 +1187,11 @@ function createParagraphBlock(
1186
1187
  let cursor = start;
1187
1188
  const children = Array.isArray(paragraph.children) ? paragraph.children : [];
1188
1189
 
1189
- for (const child of children) {
1190
+ for (let childIndex = 0; childIndex < children.length; childIndex += 1) {
1191
+ const child = children[childIndex];
1192
+ if (isEmptyTocFieldWithCachedResult(child, children, childIndex)) {
1193
+ continue;
1194
+ }
1190
1195
  const result = appendInlineSegments(
1191
1196
  accumulator,
1192
1197
  child,
@@ -1210,6 +1215,54 @@ function createParagraphBlock(
1210
1215
  };
1211
1216
  }
1212
1217
 
1218
+ function isEmptyTocFieldWithCachedResult(
1219
+ child: InlineNode,
1220
+ siblings: readonly InlineNode[],
1221
+ childIndex: number,
1222
+ ): boolean {
1223
+ if (child.type !== "field") return false;
1224
+ if (child.fieldFamily !== "TOC") return false;
1225
+ if (child.children.length > 0) return false;
1226
+
1227
+ for (let index = childIndex + 1; index < siblings.length; index += 1) {
1228
+ if (isVisibleTocResultInline(siblings[index])) {
1229
+ return true;
1230
+ }
1231
+ }
1232
+ return false;
1233
+ }
1234
+
1235
+ function isVisibleTocResultInline(node: InlineNode): boolean {
1236
+ switch (node.type) {
1237
+ case "text":
1238
+ return node.text.length > 0;
1239
+ case "hyperlink":
1240
+ return node.children.some(isVisibleTocResultInline);
1241
+ case "tab":
1242
+ case "hard_break":
1243
+ case "symbol":
1244
+ case "image":
1245
+ case "opaque_inline":
1246
+ case "footnote_ref":
1247
+ case "chart_preview":
1248
+ case "smartart_preview":
1249
+ case "shape":
1250
+ case "wordart":
1251
+ case "vml_shape":
1252
+ case "drawing_frame":
1253
+ case "ole_embed":
1254
+ return true;
1255
+ case "bookmark_start":
1256
+ case "bookmark_end":
1257
+ case "scope_marker_start":
1258
+ case "scope_marker_end":
1259
+ case "field":
1260
+ return false;
1261
+ default:
1262
+ return false;
1263
+ }
1264
+ }
1265
+
1213
1266
  // `advanceNumberingCounterOnly`, `resolveEffectiveParagraphNumbering`,
1214
1267
  // `collectParagraphStyleChain`, `resolveStyleLinkedNumberingLevel`, and
1215
1268
  // `buildDirectRunFormattingFromMarks` were removed from surface-projection
@@ -1374,22 +1427,7 @@ function appendInlineSegments(
1374
1427
  return { nextCursor: start + 1, lockedFragmentIds: [node.fragmentId] };
1375
1428
  }
1376
1429
  case "chart_preview": {
1377
- let parsedChartId: string | undefined;
1378
- if (node.parsedData) {
1379
- parsedChartId = stableChartId(node.rawXml);
1380
- // Always call `set` (even when the entry exists) so the active
1381
- // `chartModelStore` build pass records the id in its seen-set —
1382
- // the pass uses the seen-set to evict stale entries from earlier
1383
- // builds. The entry object is a tiny reference so the set cost
1384
- // is negligible.
1385
- const { widthPx, heightPx } = extractChartDimensions(node.rawXml);
1386
- chartModelStore.set(parsedChartId, {
1387
- model: node.parsedData,
1388
- widthPx,
1389
- heightPx,
1390
- theme: undefined,
1391
- });
1392
- }
1430
+ const parsedChartId = registerParsedChartPreview(node);
1393
1431
  return appendComplexPreviewSegment(paragraph, node, start, "Embedded chart", createChartDetail(node), {
1394
1432
  previewMediaId: node.previewMediaId,
1395
1433
  parsedChartId,
@@ -1467,14 +1505,37 @@ function appendInlineSegments(
1467
1505
  });
1468
1506
  return { nextCursor: start + 1, lockedFragmentIds: [] };
1469
1507
  }
1470
- const rawXml = "rawXml" in c ? c.rawXml : "";
1471
- const label =
1472
- c.type === "chart_preview"
1473
- ? "Embedded chart"
1474
- : c.type === "smartart_preview"
1475
- ? "SmartArt diagram"
1476
- : "Drawing frame";
1477
- return appendComplexPreviewSegment(paragraph, { rawXml } as DrawingFrameNode["content"] & { rawXml: string }, start, label, `DrawingFrame ${c.type} (${node.anchor.wrapMode}).`);
1508
+ if (c.type === "chart_preview") {
1509
+ const parsedChartId = registerParsedChartPreview(c);
1510
+ return appendComplexPreviewSegment(
1511
+ paragraph,
1512
+ c,
1513
+ start,
1514
+ "Embedded chart",
1515
+ `DrawingFrame chart_preview (${node.anchor.wrapMode}).`,
1516
+ {
1517
+ previewMediaId: c.previewMediaId,
1518
+ parsedChartId,
1519
+ },
1520
+ );
1521
+ }
1522
+ if (c.type === "smartart_preview") {
1523
+ return appendComplexPreviewSegment(
1524
+ paragraph,
1525
+ c,
1526
+ start,
1527
+ "SmartArt diagram",
1528
+ `DrawingFrame smartart_preview (${node.anchor.wrapMode}).`,
1529
+ { previewMediaId: c.previewMediaId },
1530
+ );
1531
+ }
1532
+ return appendComplexPreviewSegment(
1533
+ paragraph,
1534
+ c,
1535
+ start,
1536
+ "Drawing frame",
1537
+ `DrawingFrame ${c.type} (${node.anchor.wrapMode}).`,
1538
+ );
1478
1539
  }
1479
1540
  case "symbol":
1480
1541
  paragraph.segments.push({
@@ -1530,6 +1591,51 @@ function appendInlineSegments(
1530
1591
  });
1531
1592
  return { nextCursor: start + 1, lockedFragmentIds: [] };
1532
1593
  case "field": {
1594
+ const legacyFormDisplay = legacyFormFieldDisplay(node.legacyFormField);
1595
+ if (legacyFormDisplay && !fieldHasVisibleChildren(node)) {
1596
+ if (legacyFormDisplay.presentation === "checkbox") {
1597
+ paragraph.segments.push({
1598
+ segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
1599
+ kind: "opaque_inline",
1600
+ from: start,
1601
+ to: start + 1,
1602
+ fragmentId: `legacy-form:${legacyFormDisplay.kind}:${start}`,
1603
+ warningId: `warning:legacy-form:${start}`,
1604
+ label: legacyFormDisplay.label,
1605
+ detail: legacyFormDisplay.detail,
1606
+ presentation: "checkbox",
1607
+ displayText: legacyFormDisplay.text,
1608
+ state: "locked-preserve-only",
1609
+ });
1610
+ return { nextCursor: start + 1, lockedFragmentIds: [] };
1611
+ }
1612
+
1613
+ if (legacyFormDisplay.text.length > 0) {
1614
+ paragraph.segments.push({
1615
+ segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
1616
+ kind: "text",
1617
+ from: start,
1618
+ to: start + legacyFormDisplay.text.length,
1619
+ text: legacyFormDisplay.text,
1620
+ });
1621
+ return { nextCursor: start + legacyFormDisplay.text.length, lockedFragmentIds: [] };
1622
+ }
1623
+
1624
+ paragraph.segments.push({
1625
+ segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
1626
+ kind: "opaque_inline",
1627
+ from: start,
1628
+ to: start + 1,
1629
+ fragmentId: `legacy-form:${legacyFormDisplay.kind}:${start}`,
1630
+ warningId: `warning:legacy-form:${start}`,
1631
+ label: legacyFormDisplay.label,
1632
+ detail: legacyFormDisplay.detail,
1633
+ presentation: "quiet-marker",
1634
+ state: "locked-preserve-only",
1635
+ });
1636
+ return { nextCursor: start + 1, lockedFragmentIds: [] };
1637
+ }
1638
+
1533
1639
  const isSupportedField =
1534
1640
  node.fieldFamily === "REF" ||
1535
1641
  node.fieldFamily === "PAGEREF" ||
@@ -1565,9 +1671,12 @@ function appendInlineSegments(
1565
1671
  return { nextCursor: start + 1, lockedFragmentIds: [] };
1566
1672
  }
1567
1673
  if (node.children && node.children.length > 0) {
1568
- // For REF \h, pass the bookmark as a hyperlink href so child text gets hyperlink styling
1674
+ // For REF/PAGEREF/NOTEREF \h, pass the bookmark as a hyperlink
1675
+ // href so cached result text gets hyperlink styling.
1569
1676
  const refHyperlinkHref =
1570
- node.fieldFamily === "REF" &&
1677
+ (node.fieldFamily === "REF" ||
1678
+ node.fieldFamily === "PAGEREF" ||
1679
+ node.fieldFamily === "NOTEREF") &&
1571
1680
  node.switches?.hyperlink === true &&
1572
1681
  node.fieldTarget
1573
1682
  ? `#${node.fieldTarget}`
@@ -1643,6 +1752,97 @@ function appendInlineSegments(
1643
1752
  }
1644
1753
  }
1645
1754
 
1755
+ function registerParsedChartPreview(node: ChartPreviewNode): string | undefined {
1756
+ if (!node.parsedData) return undefined;
1757
+ const parsedChartId = stableChartId(node.rawXml);
1758
+ // Always call `set` (even when the entry exists) so the active
1759
+ // `chartModelStore` build pass records the id in its seen-set; the
1760
+ // pass uses that seen-set to evict stale entries from earlier builds.
1761
+ const { widthPx, heightPx } = extractChartDimensions(node.rawXml);
1762
+ chartModelStore.set(parsedChartId, {
1763
+ model: node.parsedData,
1764
+ widthPx,
1765
+ heightPx,
1766
+ theme: undefined,
1767
+ });
1768
+ return parsedChartId;
1769
+ }
1770
+
1771
+ function fieldHasVisibleChildren(node: Extract<InlineNode, { type: "field" }>): boolean {
1772
+ return (node.children ?? []).some((child) => inlineNodeHasVisibleText(child));
1773
+ }
1774
+
1775
+ function inlineNodeHasVisibleText(node: InlineNode): boolean {
1776
+ switch (node.type) {
1777
+ case "text":
1778
+ return node.text.trim().length > 0;
1779
+ case "tab":
1780
+ case "hard_break":
1781
+ case "image":
1782
+ case "footnote_ref":
1783
+ case "symbol":
1784
+ case "chart_preview":
1785
+ case "smartart_preview":
1786
+ case "shape":
1787
+ case "wordart":
1788
+ case "vml_shape":
1789
+ case "drawing_frame":
1790
+ case "ole_embed":
1791
+ return true;
1792
+ case "hyperlink":
1793
+ case "field":
1794
+ return node.children.some((child) => inlineNodeHasVisibleText(child));
1795
+ default:
1796
+ return false;
1797
+ }
1798
+ }
1799
+
1800
+ function legacyFormFieldDisplay(
1801
+ legacyFormField: LegacyFormFieldNode | undefined,
1802
+ ): {
1803
+ kind: LegacyFormFieldNode["kind"];
1804
+ text: string;
1805
+ label: string;
1806
+ detail: string;
1807
+ presentation?: "checkbox";
1808
+ } | undefined {
1809
+ if (!legacyFormField) return undefined;
1810
+ switch (legacyFormField.kind) {
1811
+ case "textInput": {
1812
+ const value = legacyFormField.textInput?.default;
1813
+ return {
1814
+ kind: legacyFormField.kind,
1815
+ text: value && value.length > 0 ? value : "",
1816
+ label: "Legacy text form field",
1817
+ detail: "Word legacy text form field preserved from ffData.",
1818
+ };
1819
+ }
1820
+ case "checkBox": {
1821
+ const checked =
1822
+ legacyFormField.checkBox?.checked ??
1823
+ legacyFormField.checkBox?.default ??
1824
+ false;
1825
+ return {
1826
+ kind: legacyFormField.kind,
1827
+ text: checked ? "☒" : "☐",
1828
+ label: "Legacy checkbox form field",
1829
+ detail: "Word legacy checkbox form field preserved from ffData.",
1830
+ presentation: "checkbox",
1831
+ };
1832
+ }
1833
+ case "ddList": {
1834
+ const entries = legacyFormField.ddList?.listEntry ?? [];
1835
+ const index = legacyFormField.ddList?.default ?? 0;
1836
+ return {
1837
+ kind: legacyFormField.kind,
1838
+ text: entries[index] ?? entries[0] ?? "",
1839
+ label: "Legacy dropdown form field",
1840
+ detail: "Word legacy dropdown form field preserved from ffData.",
1841
+ };
1842
+ }
1843
+ }
1844
+ }
1845
+
1646
1846
  /**
1647
1847
  * V2c.4 — Map a canonical `AnchorGeometry` onto the surface anchor type.
1648
1848
  * Returns `undefined` when the anchor carries only the trivial inline
@@ -1486,7 +1486,12 @@ export async function persistAndExport(input: {
1486
1486
  const saveExport = input.hostAdapter?.saveExport ?? input.datastore?.saveExport;
1487
1487
  const saveExportSource = input.hostAdapter?.saveExport ? "host" : "datastore";
1488
1488
  if (!saveExport) {
1489
- result = downloadExportResult(result);
1489
+ result =
1490
+ input.options?.download === false
1491
+ ? withExportDelivery(result, {
1492
+ mode: "exported-bytes-only",
1493
+ })
1494
+ : downloadExportResult(result);
1490
1495
  emitEditorEvent({
1491
1496
  hostAdapter: input.hostAdapter,
1492
1497
  datastore: input.datastore,