@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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.85",
4
+ "version": "1.0.87",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -921,12 +921,22 @@ export interface UpdateFieldsResult {
921
921
 
922
922
  /** Options for runtime-backed TOC refresh. */
923
923
  export interface TocRefreshOptions {
924
+ /** Specific TOC region to update. Defaults to the first/all supported regions depending on mode. */
925
+ tocId?: string;
926
+ /** Preserve imported cached rows for visual parity, or regenerate from headings. Defaults to regenerate. */
927
+ mode?: "preserveCached" | "regenerate";
924
928
  /** Maximum heading outline level to include (1–9, default 3). */
925
929
  maxLevel?: number;
926
930
  }
927
931
 
928
932
  /** Result of a TOC refresh operation. */
929
933
  export interface TocRefreshResult {
934
+ /** TOC region affected when the document has a first-class TOC region. */
935
+ tocId?: string;
936
+ /** Update mode used by the runtime. */
937
+ mode?: "preserveCached" | "regenerate";
938
+ /** Region freshness after the operation. */
939
+ status?: "current" | "stale" | "missing";
930
940
  /** Number of TOC entries generated from heading structure. */
931
941
  entryCount: number;
932
942
  /** Heading entries that populated the TOC. */
@@ -934,6 +944,8 @@ export interface TocRefreshResult {
934
944
  level: number;
935
945
  text: string;
936
946
  pageIndex: number;
947
+ pageText?: string;
948
+ source?: "cached" | "generated";
937
949
  }>;
938
950
  }
939
951
 
@@ -986,15 +998,30 @@ export interface TocEntrySnapshot {
986
998
  level: number;
987
999
  text: string;
988
1000
  pageIndex?: number;
1001
+ pageText?: string;
1002
+ source?: "cached" | "generated";
989
1003
  anchor?: EditorAnchorProjection;
990
1004
  bookmarkName?: string;
991
1005
  headingId?: string;
992
1006
  }
993
1007
 
1008
+ export interface TocRegionSnapshot {
1009
+ tocId: string;
1010
+ status: "current" | "stale";
1011
+ sourceFieldIndex: number;
1012
+ instruction: string;
1013
+ cachedEntryCount: number;
1014
+ generatedEntryCount: number;
1015
+ }
1016
+
994
1017
  export interface TocSnapshot {
995
1018
  status: "current" | "stale" | "missing";
1019
+ tocId?: string;
996
1020
  sourceFieldIndex?: number;
997
1021
  instruction?: string;
1022
+ source?: "cached" | "generated";
1023
+ regionCount?: number;
1024
+ regions?: TocRegionSnapshot[];
998
1025
  entries: TocEntrySnapshot[];
999
1026
  }
1000
1027
 
@@ -2580,6 +2607,13 @@ export interface AddScopeResult {
2580
2607
  export interface ExportDocxOptions {
2581
2608
  fileName?: string;
2582
2609
  reason?: string;
2610
+ /**
2611
+ * Controls the browser download fallback used by the mounted
2612
+ * `WordReviewEditor` ref when no host/datastore `saveExport` adapter is
2613
+ * present. Defaults to `true`. Set to `false` to receive exported bytes
2614
+ * without triggering an anchor download.
2615
+ */
2616
+ download?: boolean;
2583
2617
  /**
2584
2618
  * @experimental Lane 7c Slice 7c.4 (v2.0.0) — opt-in for Strict-flavor
2585
2619
  * OOXML export (ECMA-376 / ISO 29500-1 Strict, namespaces under
@@ -3595,6 +3629,8 @@ export type WordReviewEditorEvent =
3595
3629
  | {
3596
3630
  type: "toc_auto_refreshed";
3597
3631
  documentId: string;
3632
+ tocId?: string;
3633
+ regionCount?: number;
3598
3634
  entryCount: number;
3599
3635
  trigger: TocRefreshTrigger;
3600
3636
  }
@@ -5802,6 +5838,13 @@ export type TextCommandAckKind =
5802
5838
  | "rejected"
5803
5839
  | "structural-divergence";
5804
5840
 
5841
+ export type TextCommandRefreshClass =
5842
+ | "selection-only"
5843
+ | "local-text-equivalent"
5844
+ | "surface-only"
5845
+ | "full-projection"
5846
+ | "blocked";
5847
+
5805
5848
  export interface ScopeTagTouch {
5806
5849
  /** Tag family: "comment" | "revision" | "field" | "bookmark" | "sdt" | "opaque" | custom string. */
5807
5850
  tagType: string;
@@ -5827,6 +5870,12 @@ export interface ScopeTagTouch {
5827
5870
  */
5828
5871
  export interface TextCommandAck {
5829
5872
  kind: TextCommandAckKind;
5873
+ /**
5874
+ * Narrow post-ack refresh tier for editors that separate the typing hot
5875
+ * path from broader projection work. Older runtimes may omit this; callers
5876
+ * should derive a conservative tier from `kind` when absent.
5877
+ */
5878
+ refreshClass?: TextCommandRefreshClass;
5830
5879
  /** Opaque id echoed back from the predicted op. Undefined for canonical callers. */
5831
5880
  opId?: string;
5832
5881
  /** Revision token of the document AFTER commit. Empty string when `kind === "rejected"`. */
@@ -29,10 +29,7 @@ import type {
29
29
  WordReviewEditorChromePreset,
30
30
  WordReviewEditorChromeVisibility,
31
31
  } from "../../public-types";
32
- import {
33
- resolveChromePresetOptions,
34
- resolveChromeVisibilityForPreset,
35
- } from "./chrome-preset-model";
32
+ import { resolveChromeVisibilityForPreset } from "./chrome-preset-model";
36
33
 
37
34
  /**
38
35
  * Pure breakpoint resolver — inlined at layer 10 so chrome composition does
@@ -285,11 +282,6 @@ export function resolveChromeComposition(
285
282
  input: ChromeCompositionInput,
286
283
  ): ChromeComposition {
287
284
  const mode = deriveMode(input);
288
- const options = resolveChromePresetOptions(
289
- input.chromePreset,
290
- input.chromeOptions,
291
- input.role,
292
- );
293
285
  const visibility = resolveChromeVisibilityForPreset({
294
286
  chromePreset: input.chromePreset,
295
287
  chromeOptions: input.chromeOptions,
@@ -300,8 +292,7 @@ export function resolveChromeComposition(
300
292
  const density: ChromeDensity = input.density ?? "standard";
301
293
  const pinnedRailTabs: ReadonlySet<EditorRailTab> =
302
294
  input.pinnedRailTabs ?? new Set<EditorRailTab>();
303
- const railOpen =
304
- input.railOpen ?? (options.showReviewRail && visibility.reviewRail);
295
+ const railOpen = input.railOpen ?? false;
305
296
  const visibleTabs = resolveVisibleRailTabs(
306
297
  railOpen,
307
298
  diagnosticsSignal,
@@ -277,10 +277,9 @@ export function createChromeFamily(ctx: UiApiContext) {
277
277
  // isolation pattern as U9 overlay-visibility).
278
278
  //
279
279
  // `activeRailTabSet` distinguishes "user explicitly set activeRailTab"
280
- // (including to `null`, meaning "close the rail") from "user hasn't
281
- // expressed an opinion" (composer picks a mode-default). Without this
282
- // flag, getComposition would force `null` for every caller that
283
- // previously relied on the composer's mode-appropriate default.
280
+ // (including to `null`) from "user hasn't expressed an opinion".
281
+ // Without this flag, getComposition would force `null` for every caller
282
+ // that relies on the composer's closed-by-default rail posture.
284
283
  let activeRailTab: EditorRailTab | null = null;
285
284
  let activeRailTabSet = false;
286
285
  const pinnedRailTabs = new Set<EditorRailTab>();
@@ -354,10 +353,9 @@ export function createChromeFamily(ctx: UiApiContext) {
354
353
  // caller can override `activeRailTab` / `pinnedRailTabs` via input;
355
354
  // when omitted AND the UI API's internal state has been set,
356
355
  // internal state fills in. When the caller omits AND internal state
357
- // is untouched (the initial-mount path), the composer's mode-default
358
- // kicks in (e.g. rail open on "advanced" preset picks a default
359
- // active tab per mode). This preserves back-compat for callers that
360
- // never interacted with rail-state methods.
356
+ // is untouched (the initial-mount path), the composer keeps the rail
357
+ // closed. Mounted workspaces pass `railOpen` from their local toggle
358
+ // state when the user opens the rail.
361
359
  const merged: ChromeCompositionInput = {
362
360
  ...input,
363
361
  ...(input.activeRailTab !== undefined
package/src/index.ts CHANGED
@@ -203,6 +203,7 @@ export type {
203
203
  DocumentOutlineHeadingSnapshot,
204
204
  DocumentOutlineSnapshot,
205
205
  TocEntrySnapshot,
206
+ TocRegionSnapshot,
206
207
  TocSnapshot,
207
208
  DocumentSectionSnapshot,
208
209
  SnapshotRefreshInvalidateTarget,
@@ -255,6 +256,10 @@ export type {
255
256
  WorkflowBlockedCommandReason,
256
257
  WorkflowScopeSnapshot,
257
258
  InteractionGuardSnapshot,
259
+ TextCommandAckKind,
260
+ TextCommandRefreshClass,
261
+ TextCommandAck,
262
+ ScopeTagTouch,
258
263
  WorkflowMarkupKind,
259
264
  WorkflowMarkupBase,
260
265
  WorkflowHighlightMarkup,
@@ -1,8 +1,10 @@
1
1
  import type {
2
2
  AltChunkNode,
3
+ BlockNode,
3
4
  BorderSpec,
4
5
  CustomXmlNode,
5
6
  DocumentRootNode,
7
+ FieldNode,
6
8
  FootnoteProperties,
7
9
  InlineNode,
8
10
  LegacyFormFieldNode,
@@ -97,6 +99,8 @@ interface SerializationState {
97
99
  usedBookmarkIds: Set<string>;
98
100
  scopeBookmarkIds: Map<string, string>;
99
101
  nextScopeBookmarkId: number;
102
+ /** Number of paragraph-range TOC fields reconstructed during serialization. */
103
+ tocRegionExportCount: number;
100
104
  }
101
105
 
102
106
  interface InlineSerializationResult {
@@ -216,6 +220,7 @@ export function serializeMainDocument(
216
220
  usedBookmarkIds: collectNumericBookmarkIds(content),
217
221
  scopeBookmarkIds: new Map<string, string>(),
218
222
  nextScopeBookmarkId: 100000,
223
+ tocRegionExportCount: 0,
219
224
  };
220
225
  const suffix = `</w:body>\n</w:document>`;
221
226
  const bodyPieces: string[] = [];
@@ -228,8 +233,43 @@ export function serializeMainDocument(
228
233
  let paragraphIndex = -1;
229
234
  let previousWasParagraph = false;
230
235
 
231
- for (const block of content.children) {
236
+ for (let blockIndex = 0; blockIndex < content.children.length; blockIndex += 1) {
237
+ const block = content.children[blockIndex]!;
232
238
  if (block.type === "paragraph") {
239
+ const tocField = findSerializableTocField(block);
240
+ if (tocField && startsSerializableTocRegion(content.children, blockIndex)) {
241
+ const startIndex = blockIndex;
242
+ const endIndex = findSerializableTocRegionEnd(content.children, startIndex);
243
+ state.tocRegionExportCount += 1;
244
+ for (let regionIndex = startIndex; regionIndex <= endIndex; regionIndex += 1) {
245
+ const paragraph = content.children[regionIndex];
246
+ if (paragraph?.type !== "paragraph") continue;
247
+ if (previousWasParagraph) {
248
+ cursor += 1;
249
+ }
250
+ paragraphIndex += 1;
251
+ const serializedParagraph = serializeParagraphWithTocBoundary(
252
+ paragraph,
253
+ state,
254
+ cursor,
255
+ paragraphIndex,
256
+ tocField,
257
+ regionIndex === startIndex,
258
+ regionIndex === endIndex,
259
+ );
260
+ const bodyRelativeOffset = bodyLength;
261
+ bodyPieces.push(serializedParagraph.xml);
262
+ bodyLength += serializedParagraph.xml.length;
263
+ paragraphBoundaries.push(
264
+ offsetParagraphBoundary(serializedParagraph.boundary, bodyRelativeOffset),
265
+ );
266
+ cursor = serializedParagraph.nextCursor;
267
+ previousWasParagraph = true;
268
+ }
269
+ blockIndex = endIndex;
270
+ continue;
271
+ }
272
+
233
273
  if (previousWasParagraph) {
234
274
  cursor += 1;
235
275
  }
@@ -365,6 +405,7 @@ export function serializeMainDocument(
365
405
  blockKindCounts,
366
406
  documentXmlBytes: documentXml.length,
367
407
  relationshipCount: state.relationships.length,
408
+ tocRegionExportCount: state.tocRegionExportCount,
368
409
  ms: serializePerformanceNow() - started,
369
410
  },
370
411
  });
@@ -412,12 +453,94 @@ function serializeTableCellNode(
412
453
  state: SerializationState,
413
454
  ): string {
414
455
  const propertiesXml = buildCellPropertiesXml(cell);
415
- const blocksXml = cell.children
416
- .map((child) => serializeBlockNode(child, state))
417
- .join("");
456
+ const blocksXml = serializeBlockSequence(cell.children, state);
418
457
  return `<w:tc>${propertiesXml}${blocksXml || "<w:p/>"}</w:tc>`;
419
458
  }
420
459
 
460
+ function serializeBlockSequence(
461
+ blocks: readonly BlockNode[],
462
+ state: SerializationState,
463
+ ): string {
464
+ const pieces: string[] = [];
465
+ for (let index = 0; index < blocks.length; index += 1) {
466
+ const block = blocks[index];
467
+ if (!block) continue;
468
+ if (block?.type === "paragraph") {
469
+ const tocField = findSerializableTocField(block);
470
+ if (tocField && startsSerializableTocRegion(blocks, index)) {
471
+ const endIndex = findSerializableTocRegionEnd(blocks, index);
472
+ state.tocRegionExportCount += 1;
473
+ for (let regionIndex = index; regionIndex <= endIndex; regionIndex += 1) {
474
+ const paragraph = blocks[regionIndex];
475
+ if (paragraph?.type !== "paragraph") continue;
476
+ pieces.push(
477
+ serializeTableCellParagraphWithTocBoundary(
478
+ paragraph,
479
+ state,
480
+ tocField,
481
+ regionIndex === index,
482
+ regionIndex === endIndex,
483
+ ),
484
+ );
485
+ }
486
+ index = endIndex;
487
+ continue;
488
+ }
489
+ }
490
+ pieces.push(serializeBlockNode(block, state));
491
+ }
492
+ return pieces.join("");
493
+ }
494
+
495
+ function findSerializableTocField(paragraph: ParagraphNode): FieldNode | undefined {
496
+ return paragraph.children.find(
497
+ (child): child is FieldNode => child.type === "field" && child.fieldFamily === "TOC",
498
+ );
499
+ }
500
+
501
+ function startsSerializableTocRegion(blocks: readonly BlockNode[], index: number): boolean {
502
+ const current = blocks[index];
503
+ if (current?.type === "paragraph" && isSerializableTocParagraphStyle(current.styleId)) {
504
+ return true;
505
+ }
506
+ const next = blocks[index + 1];
507
+ return next?.type === "paragraph" && isSerializableTocParagraphStyle(next.styleId);
508
+ }
509
+
510
+ function findSerializableTocRegionEnd(blocks: readonly BlockNode[], startIndex: number): number {
511
+ let endIndex = startIndex;
512
+ while (endIndex + 1 < blocks.length) {
513
+ const next = blocks[endIndex + 1];
514
+ if (next?.type !== "paragraph" || !isSerializableTocParagraphStyle(next.styleId)) {
515
+ break;
516
+ }
517
+ endIndex += 1;
518
+ }
519
+ return endIndex;
520
+ }
521
+
522
+ function isSerializableTocParagraphStyle(styleId: string | undefined): boolean {
523
+ return /^TOC\d+$/u.test(styleId ?? "");
524
+ }
525
+
526
+ function stripSerializableTocField(children: readonly InlineNode[]): InlineNode[] {
527
+ return children.filter(
528
+ (child) => !(child.type === "field" && child.fieldFamily === "TOC"),
529
+ );
530
+ }
531
+
532
+ function serializeTocFieldBeginXml(field: FieldNode): string {
533
+ return (
534
+ `<w:r><w:fldChar w:fldCharType="begin"/></w:r>` +
535
+ `<w:r><w:instrText xml:space="preserve"> ${escapeXml(field.instruction)} </w:instrText></w:r>` +
536
+ `<w:r><w:fldChar w:fldCharType="separate"/></w:r>`
537
+ );
538
+ }
539
+
540
+ function serializeTocFieldEndXml(): string {
541
+ return `<w:r><w:fldChar w:fldCharType="end"/></w:r>`;
542
+ }
543
+
421
544
  function serializeBlockNode(
422
545
  block: DocumentRootNode["children"][number],
423
546
  state: SerializationState,
@@ -463,7 +586,7 @@ function serializeSdtNode(
463
586
  state: SerializationState,
464
587
  ): string {
465
588
  const propertiesXml = block.properties.propertiesXml ?? buildSdtPropertiesXml(block);
466
- const childrenXml = block.children.map((child) => serializeBlockNode(child, state)).join("") || "<w:p/>";
589
+ const childrenXml = serializeBlockSequence(block.children, state) || "<w:p/>";
467
590
  return `<w:sdt>${propertiesXml}<w:sdtContent>${childrenXml}</w:sdtContent></w:sdt>`;
468
591
  }
469
592
 
@@ -482,7 +605,7 @@ function serializeCustomXmlNode(
482
605
  attrs.push(`w:element="${escapeXmlAttribute(block.element)}"`);
483
606
  }
484
607
  const attrXml = attrs.length > 0 ? ` ${attrs.join(" ")}` : "";
485
- const childrenXml = block.children.map((child) => serializeBlockNode(child, state)).join("");
608
+ const childrenXml = serializeBlockSequence(block.children, state);
486
609
  return `<w:customXml${attrXml}>${childrenXml || "<w:p/>"}</w:customXml>`;
487
610
  }
488
611
 
@@ -559,6 +682,27 @@ function serializeTableCellParagraph(
559
682
  return xml;
560
683
  }
561
684
 
685
+ function serializeTableCellParagraphWithTocBoundary(
686
+ paragraph: ParagraphNode,
687
+ state: SerializationState,
688
+ tocField: FieldNode,
689
+ isFirst: boolean,
690
+ isLast: boolean,
691
+ ): string {
692
+ let xml = buildParagraphOpenTag(paragraph, state);
693
+ const paragraphPropertiesXml = buildParagraphPropertiesXml(paragraph);
694
+ if (paragraphPropertiesXml.length > 0) {
695
+ xml += paragraphPropertiesXml;
696
+ }
697
+ const children = stripSerializableTocField(paragraph.children);
698
+ const childrenXml = children.map((child) => serializeTableInlineNode(child, state)).join("");
699
+ xml += isFirst ? serializeTocFieldBeginXml(tocField) : "";
700
+ xml += childrenXml || "<w:r><w:t></w:t></w:r>";
701
+ xml += isLast ? serializeTocFieldEndXml() : "";
702
+ xml += "</w:p>";
703
+ return xml;
704
+ }
705
+
562
706
  /**
563
707
  * A.7: build the paragraph opening tag. When the canonical paragraph
564
708
  * carries a preserved `wordExtensionIds`, re-emit `w14:paraId` /
@@ -991,6 +1135,71 @@ function serializeParagraph(
991
1135
  };
992
1136
  }
993
1137
 
1138
+ function serializeParagraphWithTocBoundary(
1139
+ paragraph: ParagraphNode,
1140
+ state: SerializationState,
1141
+ cursor: number,
1142
+ paragraphIndex: number,
1143
+ tocField: FieldNode,
1144
+ isFirst: boolean,
1145
+ isLast: boolean,
1146
+ ): ParagraphSerializationResult {
1147
+ let xml = buildParagraphOpenTag(paragraph, state);
1148
+ const boundaries = new Map<number, number>();
1149
+ const paragraphStart = 0;
1150
+ const paragraphStartTagEnd = xml.length;
1151
+ boundaries.set(cursor, paragraphStartTagEnd);
1152
+
1153
+ let paragraphPropertiesStart: number | undefined;
1154
+ let paragraphPropertiesEnd: number | undefined;
1155
+
1156
+ const paragraphPropertiesXml = buildParagraphPropertiesXml(paragraph);
1157
+ if (paragraphPropertiesXml.length > 0) {
1158
+ paragraphPropertiesStart = xml.length;
1159
+ xml += paragraphPropertiesXml;
1160
+ paragraphPropertiesEnd = xml.length;
1161
+ }
1162
+
1163
+ if (isFirst) {
1164
+ xml += serializeTocFieldBeginXml(tocField);
1165
+ }
1166
+ const strippedChildren = stripSerializableTocField(paragraph.children);
1167
+ const children = serializeParagraphChildren(strippedChildren, state, cursor, xml.length);
1168
+ xml += children.xml;
1169
+ const contentEmpty = children.xml.length === 0;
1170
+ if (contentEmpty) {
1171
+ xml += "<w:r><w:t></w:t></w:r>";
1172
+ }
1173
+ if (isLast) {
1174
+ xml += serializeTocFieldEndXml();
1175
+ }
1176
+ const paragraphEndTagStart = xml.length;
1177
+ xml += "</w:p>";
1178
+
1179
+ if (!children.boundaries.has(children.cursor)) {
1180
+ children.boundaries.set(children.cursor, paragraphEndTagStart);
1181
+ }
1182
+
1183
+ return {
1184
+ xml,
1185
+ nextCursor: children.cursor,
1186
+ boundary: {
1187
+ paragraphIndex,
1188
+ start: cursor,
1189
+ end: children.cursor,
1190
+ boundaries: children.boundaries,
1191
+ paragraphStart,
1192
+ paragraphStartTagEnd,
1193
+ paragraphEndTagStart,
1194
+ paragraphEnd: xml.length,
1195
+ ...(paragraphPropertiesStart !== undefined
1196
+ ? { paragraphPropertiesStart }
1197
+ : {}),
1198
+ ...(paragraphPropertiesEnd !== undefined ? { paragraphPropertiesEnd } : {}),
1199
+ },
1200
+ };
1201
+ }
1202
+
994
1203
  function serializeParagraphChildren(
995
1204
  children: InlineNode[],
996
1205
  state: SerializationState,
@@ -6,6 +6,7 @@ import type { DrawingFrameNode, AnchorGeometry } from "../../model/canonical-doc
6
6
  import { parseAnchorGeometry } from "./parse-anchor.ts";
7
7
  import { parsePicture } from "./parse-picture.ts";
8
8
  import { parseShapeContent, type TxbxBlockParser } from "./parse-shapes.ts";
9
+ import { parseChartSpace } from "./chart/parse-chart-space.ts";
9
10
  import {
10
11
  type XmlElementNode,
11
12
  findFirstChild,
@@ -203,7 +204,14 @@ function resolveContent(
203
204
  return { type: "opaque", rawXml };
204
205
  }
205
206
  if (uri === CHART_GRAPHIC_URI || uri === CHART_GRAPHIC_URI_ALT) {
206
- return { type: "chart_preview", rawXml };
207
+ const chartRelId = extractChartRelId(graphicData);
208
+ const chartXml = chartRelId ? opts.chartPartLookup?.(chartRelId) : undefined;
209
+ const parsedData = chartXml ? parseChartSpace(chartXml) : undefined;
210
+ return {
211
+ type: "chart_preview",
212
+ rawXml,
213
+ ...(parsedData ? { parsedData } : {}),
214
+ };
207
215
  }
208
216
  if (uri === SMARTART_GRAPHIC_URI || uri === SMARTART_GRAPHIC_URI_ALT) {
209
217
  return { type: "smartart_preview", rawXml };
@@ -223,6 +231,12 @@ function resolveContent(
223
231
  return { type: "opaque", rawXml };
224
232
  }
225
233
 
234
+ function extractChartRelId(graphicData: XmlElementNode | undefined): string | null {
235
+ if (!graphicData) return null;
236
+ const chart = findFirstDescendant(graphicData, "chart");
237
+ return chart?.attributes["r:id"] ?? chart?.attributes.id ?? null;
238
+ }
239
+
226
240
  // Phase 6 — XML parser helpers imported from ./_mini-xml.ts (previously
227
241
  // duplicated inline across four files). See that module for B4 throw-on-
228
242
  // unterminated-tag contract and entity-decoding implementation.