@beyondwork/docx-react-component 1.0.56 → 1.0.57

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 (107) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +157 -0
  3. package/src/compare/diff-engine.ts +3 -0
  4. package/src/core/commands/formatting-commands.ts +1 -0
  5. package/src/core/commands/index.ts +17 -11
  6. package/src/core/selection/mapping.ts +18 -1
  7. package/src/core/selection/review-anchors.ts +29 -18
  8. package/src/io/chart-preview-resolver.ts +175 -41
  9. package/src/io/docx-session.ts +57 -2
  10. package/src/io/export/serialize-main-document.ts +82 -0
  11. package/src/io/export/serialize-styles.ts +61 -3
  12. package/src/io/export/table-properties-xml.ts +19 -4
  13. package/src/io/normalize/normalize-text.ts +33 -0
  14. package/src/io/ooxml/parse-anchor.ts +182 -0
  15. package/src/io/ooxml/parse-drawing.ts +319 -0
  16. package/src/io/ooxml/parse-fields.ts +115 -2
  17. package/src/io/ooxml/parse-fill.ts +215 -0
  18. package/src/io/ooxml/parse-font-table.ts +190 -0
  19. package/src/io/ooxml/parse-footnotes.ts +52 -1
  20. package/src/io/ooxml/parse-main-document.ts +241 -1
  21. package/src/io/ooxml/parse-numbering.ts +96 -0
  22. package/src/io/ooxml/parse-picture.ts +107 -0
  23. package/src/io/ooxml/parse-settings.ts +34 -0
  24. package/src/io/ooxml/parse-shapes.ts +87 -0
  25. package/src/io/ooxml/parse-solid-fill.ts +11 -0
  26. package/src/io/ooxml/parse-styles.ts +74 -1
  27. package/src/io/ooxml/parse-theme.ts +60 -0
  28. package/src/io/paste/html-clipboard.ts +449 -0
  29. package/src/io/paste/word-clipboard.ts +5 -1
  30. package/src/legal/_document-root.ts +26 -0
  31. package/src/legal/bookmarks.ts +4 -3
  32. package/src/legal/cross-references.ts +3 -2
  33. package/src/legal/defined-terms.ts +2 -1
  34. package/src/legal/signature-blocks.ts +2 -1
  35. package/src/model/canonical-document.ts +415 -3
  36. package/src/runtime/chart/chart-model-store.ts +73 -10
  37. package/src/runtime/document-runtime.ts +693 -41
  38. package/src/runtime/edit-ops/index.ts +129 -0
  39. package/src/runtime/event-refresh-hints.ts +7 -0
  40. package/src/runtime/field-resolver.ts +341 -0
  41. package/src/runtime/footnote-resolver.ts +55 -0
  42. package/src/runtime/hyperlink-color-resolver.ts +13 -10
  43. package/src/runtime/object-grab/index.ts +51 -0
  44. package/src/runtime/paragraph-style-resolver.ts +105 -0
  45. package/src/runtime/resolved-numbering-geometry.ts +12 -0
  46. package/src/runtime/selection/cursor-ops.ts +186 -15
  47. package/src/runtime/selection/index.ts +17 -1
  48. package/src/runtime/structure-ops/index.ts +77 -0
  49. package/src/runtime/styles-cascade.ts +33 -0
  50. package/src/runtime/surface-projection.ts +186 -12
  51. package/src/runtime/theme-color-resolver.ts +189 -44
  52. package/src/runtime/units.ts +46 -0
  53. package/src/runtime/view-state.ts +13 -2
  54. package/src/ui/WordReviewEditor.tsx +168 -10
  55. package/src/ui/editor-runtime-boundary.ts +94 -1
  56. package/src/ui/editor-shell-view.tsx +1 -1
  57. package/src/ui/runtime-shortcut-dispatch.ts +17 -3
  58. package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
  59. package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
  60. package/src/ui-tailwind/chart/render/area.tsx +22 -4
  61. package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
  62. package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
  63. package/src/ui-tailwind/chart/render/combo.tsx +37 -4
  64. package/src/ui-tailwind/chart/render/line.tsx +28 -5
  65. package/src/ui-tailwind/chart/render/pie.tsx +36 -16
  66. package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
  67. package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
  68. package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
  69. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
  70. package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
  71. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
  72. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
  73. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
  74. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
  75. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
  76. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
  77. package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
  78. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -0
  79. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
  80. package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
  81. package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
  82. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
  83. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
  85. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
  86. package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
  87. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
  88. package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
  89. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
  90. package/src/ui-tailwind/editor-surface/pm-schema.ts +188 -11
  91. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +28 -2
  92. package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
  93. package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
  94. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
  95. package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
  96. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
  97. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
  98. package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
  99. package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
  100. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
  101. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
  102. package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
  103. package/src/ui-tailwind/theme/editor-theme.css +1 -0
  104. package/src/ui-tailwind/theme/tokens.css +6 -0
  105. package/src/ui-tailwind/theme/tokens.ts +10 -0
  106. package/src/validation/compatibility-engine.ts +2 -0
  107. package/src/validation/docx-comment-proof.ts +12 -3
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.56",
4
+ "version": "1.0.57",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "packageManager": "pnpm@10.30.3",
7
7
  "type": "module",
@@ -717,11 +717,17 @@ export type SnapshotRefreshChangeKind =
717
717
  | "structure"
718
718
  | "checkpoint";
719
719
 
720
+ export interface TocRefreshTrigger {
721
+ headingContentChanged: boolean;
722
+ headingStructureChanged: boolean;
723
+ }
724
+
720
725
  export interface SnapshotRefreshHints {
721
726
  invalidate: SnapshotRefreshInvalidateTarget[];
722
727
  staleTargets: SnapshotRefreshStaleTarget[];
723
728
  changeKinds: SnapshotRefreshChangeKind[];
724
729
  checkpointType?: "session" | "snapshot" | "export";
730
+ tocRefreshTrigger?: TocRefreshTrigger;
725
731
  }
726
732
 
727
733
  export interface StyleCatalogEntrySnapshot {
@@ -798,6 +804,45 @@ export type SurfaceTextMark =
798
804
  | "smallCaps"
799
805
  | "allCaps";
800
806
 
807
+ /**
808
+ * V2c.4 / V2c.5 — DrawingFrame anchor geometry projected onto image + shape
809
+ * segments. Mirrors the canonical `AnchorGeometry` shape (defined in
810
+ * `src/model/canonical-document.ts`) with the fields chrome consumers need
811
+ * for float-wrap (Lane 6d N9), object-selection chrome (N6), and frame
812
+ * positioning. EMU values are kept verbatim — converters that need px
813
+ * apply 9525 EMU = 1 px at 96 dpi.
814
+ */
815
+ export interface SurfaceDrawingAnchor {
816
+ display: "inline" | "floating";
817
+ wrapMode: "none" | "square" | "tight" | "through" | "topAndBottom";
818
+ extent: { widthEmu: number; heightEmu: number };
819
+ positionH?: { relativeFrom: string; align?: string; offset?: number };
820
+ positionV?: { relativeFrom: string; align?: string; offset?: number };
821
+ distMargins?: { top?: number; bottom?: number; left?: number; right?: number };
822
+ relativeHeight?: number;
823
+ behindDoc?: boolean;
824
+ layoutInCell?: boolean;
825
+ allowOverlap?: boolean;
826
+ simplePos?: boolean;
827
+ /** docPr.id / .name / .descr — used for accessibility chrome and selection labels. */
828
+ docPr?: { id: string; name?: string; descr?: string };
829
+ }
830
+
831
+ /**
832
+ * V2c.4 — Picture-effect data (crop, rotation, flip, preset clip geometry).
833
+ * `srcRect` percentages match OOXML's `a:srcRect` semantics: 0 = no crop,
834
+ * 100 000 = fully cropped from that edge. `rotation` is in 60 000ths of a
835
+ * degree (OOXML `a:xfrm a:rot`).
836
+ */
837
+ export interface SurfacePictureEffects {
838
+ srcRect?: { top: number; bottom: number; left: number; right: number };
839
+ rotation?: number;
840
+ flipH?: boolean;
841
+ flipV?: boolean;
842
+ presetGeom?: string;
843
+ stretch?: boolean;
844
+ }
845
+
801
846
  export type SurfaceInlineSegment =
802
847
  | {
803
848
  segmentId: string;
@@ -836,6 +881,19 @@ export type SurfaceInlineSegment =
836
881
  state: "editable" | "missing";
837
882
  display?: "inline" | "floating";
838
883
  detail?: string;
884
+ /**
885
+ * V2c.4 — DrawingFrame anchor geometry surfaced for Lane 6d float-wrap
886
+ * (P7) and chrome consumers. Present only when the canonical model
887
+ * carries non-trivial anchor metadata; inline pictures with default
888
+ * positioning omit it so the simple-image path stays cheap.
889
+ */
890
+ anchor?: SurfaceDrawingAnchor;
891
+ /**
892
+ * V2c.4 — Picture-effect data (crop, rotation, flip, preset geometry).
893
+ * Absent when none of the underlying `PictureContent` effect fields
894
+ * are set, so consumers can fast-path image rendering when undefined.
895
+ */
896
+ pictureEffects?: SurfacePictureEffects;
839
897
  }
840
898
  | {
841
899
  segmentId: string;
@@ -886,6 +944,44 @@ export type SurfaceInlineSegment =
886
944
  instruction: string;
887
945
  refreshStatus: FieldRefreshStatus;
888
946
  label: string;
947
+ }
948
+ | {
949
+ /**
950
+ * V2c.5 — DrawingFrame shape segment. Replaces the `complex_preview`
951
+ * fallback that used to swallow `wps:wsp` shapes. Carries the
952
+ * geometry preset, fill/line, optional textbox flag + first-paragraph
953
+ * preview, and (for floating shapes) anchor geometry. Lane 6d's N10
954
+ * shape rendering consumes this segment.
955
+ */
956
+ segmentId: string;
957
+ kind: "shape";
958
+ from: number;
959
+ to: number;
960
+ label: string;
961
+ detail: string;
962
+ anchor?: SurfaceDrawingAnchor;
963
+ geometry?: string;
964
+ fill?:
965
+ | { kind: "solid"; color: string; colorType: "srgbClr" | "schemeClr" }
966
+ | { kind: "none" }
967
+ | {
968
+ kind: "gradient";
969
+ stops: Array<{ pos: number; color: string; colorType: "srgbClr" | "schemeClr" }>;
970
+ direction:
971
+ | { kind: "linear"; angle: number; scaled?: boolean }
972
+ | { kind: "path"; path: "circle" | "rect" | "shape" };
973
+ rotWithShape?: boolean;
974
+ }
975
+ | {
976
+ kind: "pattern";
977
+ preset: string;
978
+ fg?: { color: string; colorType: "srgbClr" | "schemeClr" };
979
+ bg?: { color: string; colorType: "srgbClr" | "schemeClr" };
980
+ };
981
+ line?: { color?: string; widthEmu?: number; noLine?: boolean };
982
+ isTextBox?: boolean;
983
+ /** First-paragraph plain-text preview when `isTextBox` is true. */
984
+ txbxText?: string;
889
985
  };
890
986
 
891
987
  export interface SurfaceTableCellSnapshot {
@@ -2563,6 +2659,12 @@ export type WordReviewEditorEvent =
2563
2659
  documentId: string;
2564
2660
  activeStory: EditorStoryTarget;
2565
2661
  }
2662
+ | {
2663
+ type: "toc_auto_refreshed";
2664
+ documentId: string;
2665
+ entryCount: number;
2666
+ trigger: TocRefreshTrigger;
2667
+ }
2566
2668
  | {
2567
2669
  type: "warning_added";
2568
2670
  documentId: string;
@@ -3060,6 +3162,61 @@ export interface WordReviewEditorRef {
3060
3162
  * add merge-intent + richer caret placement as paste parsers come online.
3061
3163
  */
3062
3164
  insertFragment(fragment: CanonicalDocumentFragment, target?: EditorAnchorProjection): void;
3165
+ /**
3166
+ * I2 Tier B Slice 4b — serialize `target` (or the current selection) to a
3167
+ * `CanonicalDocumentFragment` and store it in the editor's internal
3168
+ * clipboard buffer. No document mutation.
3169
+ */
3170
+ copy(target?: EditorAnchorProjection): void;
3171
+ /**
3172
+ * I2 Tier B Slice 4b — `copy(target)` + delete the range.
3173
+ */
3174
+ cut(target?: EditorAnchorProjection): void;
3175
+ /**
3176
+ * I2 Tier B Slice 4b — return the last fragment written by `cut` / `copy`,
3177
+ * or `null` if none has been captured yet. Hosts pair this with
3178
+ * `insertFragment` to implement paste while Slice 4b's async-Clipboard-API
3179
+ * write lands with Slice 5.
3180
+ */
3181
+ getClipboardBuffer(): CanonicalDocumentFragment | null;
3182
+ /**
3183
+ * v5 close-out — return the current clipboard buffer serialized to the
3184
+ * wire formats browsers/Word accept, or `null` when no clipboard op has
3185
+ * been performed. Hosts use this inside their own DOM `copy`/`cut` event
3186
+ * handler to feed `navigator.clipboard.write` (the editor does not
3187
+ * install the DOM handler itself).
3188
+ */
3189
+ getClipboardWireFormats(): { wordml: string; html: string; plainText: string } | null;
3190
+ /**
3191
+ * R.3 ObjectGrabLayer — grab an inline / floating object (image, shape)
3192
+ * by its stable id. Single-select; replaces any previously grabbed
3193
+ * object. Lane 6 P11 chrome reads the grab state to paint handles.
3194
+ */
3195
+ selectObject(objectId: string): void;
3196
+ /**
3197
+ * R.3 — release the grabbed object, if any. Safe to call when nothing
3198
+ * is grabbed.
3199
+ */
3200
+ deselectObject(): void;
3201
+ /**
3202
+ * R.3 — return the grabbed object's id, or `null` when nothing is
3203
+ * grabbed.
3204
+ */
3205
+ getGrabbedObject(): string | null;
3206
+ /**
3207
+ * R.5.a — open an action bracket. Hosts wrap compound edits (paste,
3208
+ * cut+paste, agent suggestion-apply) so snapshot emission + collab
3209
+ * broadcast + undo grouping see them as a single action.
3210
+ *
3211
+ * Phase 1: opt-in. Existing commands do not auto-bracket. Hosts that
3212
+ * want single-undo paste call `startAction("paste")` →
3213
+ * `insertFragment(fragment)` → `endAction()`.
3214
+ */
3215
+ startAction(name: string): void;
3216
+ /** R.5.a — close one level of action bracketing. Unbalanced calls are no-ops. */
3217
+ endAction(): void;
3218
+ /** R.5.a — `true` when the runtime is inside one or more action brackets. */
3219
+ isInAction(): boolean;
3063
3220
  toggleBulletedList(): void;
3064
3221
  toggleNumberedList(): void;
3065
3222
  toggleBold(): void;
@@ -522,6 +522,7 @@ function getInlineLength(node: InlineNode): number {
522
522
  case "shape":
523
523
  case "wordart":
524
524
  case "vml_shape":
525
+ case "drawing_frame":
525
526
  return 1;
526
527
  }
527
528
  }
@@ -581,6 +582,8 @@ function getInlineDisplayText(node: InlineNode): string {
581
582
  return node.text;
582
583
  case "vml_shape":
583
584
  return node.text ?? "[VML Shape]";
585
+ case "drawing_frame":
586
+ return node.content.type === "picture" ? "[Image]" : "[Drawing]";
584
587
  }
585
588
  }
586
589
 
@@ -1040,6 +1040,7 @@ function inlineNodeLength(node: InlineNode): number {
1040
1040
  case "shape":
1041
1041
  case "wordart":
1042
1042
  case "vml_shape":
1043
+ case "drawing_frame":
1043
1044
  return 1;
1044
1045
  case "field":
1045
1046
  return node.children.reduce<number>(
@@ -89,7 +89,8 @@ import {
89
89
  setHeaderFooterLinkAtSectionIndex,
90
90
  } from "./section-layout-commands.ts";
91
91
  import { insertPageBreak, insertTable } from "./text-commands.ts";
92
- import { applyFragmentInsert } from "../../runtime/structure-ops/fragment-insert.ts";
92
+ import { editLayer } from "../../runtime/edit-ops/index.ts";
93
+ import { structureLayer } from "../../runtime/structure-ops/index.ts";
93
94
  import type { TableSelectionDescriptor } from "../../runtime/table-commands.ts";
94
95
 
95
96
  export type ContentChildrenPatch =
@@ -575,8 +576,10 @@ export function executeEditorCommand(
575
576
  ? applySuggestingInsert(state, command.text, context)
576
577
  : undefined;
577
578
  if (suggestingResult) return suggestingResult;
579
+ // R.2 — dispatch via the named EditLayer entry point so the seam is the
580
+ // single site where R.5.a/b hooks will attach.
578
581
  return applyTextCommand(state, context.timestamp, (document, selection) =>
579
- insertText(document, selection, command.text, context, command.formatting),
582
+ editLayer.applyTextInsert(document, selection, command.text, context, command.formatting),
580
583
  );
581
584
  }
582
585
  case "text.delete-backward": {
@@ -585,7 +588,7 @@ export function executeEditorCommand(
585
588
  : undefined;
586
589
  if (suggestingResult) return suggestingResult;
587
590
  return applyTextCommand(state, context.timestamp, (document, selection) =>
588
- deleteSelectionOrBackward(document, selection, context),
591
+ editLayer.applyDeleteBackward(document, selection, context),
589
592
  );
590
593
  }
591
594
  case "text.delete-forward": {
@@ -594,7 +597,7 @@ export function executeEditorCommand(
594
597
  : undefined;
595
598
  if (suggestingResult) return suggestingResult;
596
599
  return applyTextCommand(state, context.timestamp, (document, selection) =>
597
- deleteSelectionOrForward(document, selection, context),
600
+ editLayer.applyDeleteForward(document, selection, context),
598
601
  );
599
602
  }
600
603
  case "text.insert-tab": {
@@ -603,7 +606,7 @@ export function executeEditorCommand(
603
606
  : undefined;
604
607
  if (suggestingResult) return suggestingResult;
605
608
  return applyTextCommand(state, context.timestamp, (document, selection) =>
606
- insertTab(document, selection, context),
609
+ editLayer.applyInsertTab(document, selection, context),
607
610
  );
608
611
  }
609
612
  case "text.outdent-tab": {
@@ -632,7 +635,7 @@ export function executeEditorCommand(
632
635
  : undefined;
633
636
  if (suggestingResult) return suggestingResult;
634
637
  return applyTextCommand(state, context.timestamp, (document, selection) =>
635
- insertHardBreak(document, selection, context),
638
+ editLayer.applyInsertHardBreak(document, selection, context),
636
639
  );
637
640
  }
638
641
  case "paragraph.split":
@@ -641,15 +644,18 @@ export function executeEditorCommand(
641
644
  if (suggestingResult) return suggestingResult;
642
645
  }
643
646
  return applyTextCommand(state, context.timestamp, (document, selection) =>
644
- splitParagraph(document, selection, context),
647
+ editLayer.applySplitParagraph(document, selection, context),
645
648
  );
646
649
  case "fragment.insert": {
647
- // I2 Tier B Slice 1 — route through the structure-ops splicer. No
650
+ // I2 Tier B Slice 1 + R.3 — route through the StructureLayer seam. No
648
651
  // suggesting-mode branch yet; fragment insertion always lands as a direct
649
652
  // edit. Future slices will gate behind track-changes when a fixture needs it.
650
- const result = applyFragmentInsert(state.document, state.selection, command.fragment, {
651
- timestamp: context.timestamp,
652
- });
653
+ const result = structureLayer.applyFragmentInsert(
654
+ state.document,
655
+ state.selection,
656
+ command.fragment,
657
+ { timestamp: context.timestamp },
658
+ );
653
659
  return buildDocumentReplaceTransaction(state, context, result);
654
660
  }
655
661
  case "runtime.set-read-only":
@@ -31,7 +31,24 @@ export interface DetachedAnchor {
31
31
  reason: "deleted" | "invalidatedByStructureChange" | "importAmbiguity";
32
32
  }
33
33
 
34
- export type EditorAnchorProjection = RangeAnchor | NodeAnchor | DetachedAnchor;
34
+ /**
35
+ * Internal representation of an anchor projection — uses `DocRange` so
36
+ * mapping helpers compose ranges independently of assoc semantics. The
37
+ * public-facing shape lives at `src/api/public-types.ts` (same simple name
38
+ * `EditorAnchorProjection`) and is flat (`{ kind: "range", from, to, assoc }`).
39
+ * Conversion between the two goes through `src/core/selection/anchor-conversion.ts`.
40
+ */
41
+ export type InternalEditorAnchorProjection = RangeAnchor | NodeAnchor | DetachedAnchor;
42
+
43
+ /**
44
+ * @deprecated X3 Phase 5 — prefer `InternalEditorAnchorProjection` to avoid
45
+ * name-collision with the public `EditorAnchorProjection` from
46
+ * `src/api/public-types.ts`. Internal call-sites have migrated (via
47
+ * `import type { EditorAnchorProjection as InternalEditorAnchorProjection }`
48
+ * aliasing in `document-runtime.ts`). This alias stays for one release cycle
49
+ * so third-party internal consumers don't break. Remove in v2.1+.
50
+ */
51
+ export type EditorAnchorProjection = InternalEditorAnchorProjection;
35
52
 
36
53
  export interface MappingStep {
37
54
  from: Position;
@@ -104,17 +104,22 @@ export function rangeStaysWithinSingleParagraph(
104
104
  }
105
105
 
106
106
  /**
107
- * I8 "mid-run-near-table" guard. Comment anchors whose endpoints
108
- * land strictly inside a paragraph that sits adjacent to a table
109
- * block are rejected: the serializer's per-paragraph offset walker
110
- * (Lane 3 §O8) produces invalid OOXML for these anchors. Removed
111
- * once O8 ships.
107
+ * Width of the paragraph-to-table proximity window used by
108
+ * `snapCommentAnchorAwayFromTable` to decide when to nudge endpoints to
109
+ * paragraph boundaries. Originally the I8 rejection threshold before Lane 3b
110
+ * §O8 shipped; now used only by the (opt-in) snap helper for hosts that
111
+ * prefer clean anchors.
112
112
  */
113
113
  export const TABLE_ADJACENT_WINDOW = 1;
114
114
 
115
- export type CommentAnchorRejectionReason =
116
- | "invalid_comment_anchor"
117
- | "comment_anchor_table_adjacent";
115
+ /**
116
+ * I8 guard removal (post-O8) — the `comment_anchor_table_adjacent` rejection
117
+ * reason has been retired now that Lane 3b §O8 fixed the serializer's
118
+ * per-paragraph offset walker. The type alias remains as a deprecated union
119
+ * stub so existing host code that narrows on it keeps compiling; new code
120
+ * should simply check against `"invalid_comment_anchor"`.
121
+ */
122
+ export type CommentAnchorRejectionReason = "invalid_comment_anchor";
118
123
 
119
124
  export function canCreateDocxCommentAnchor(
120
125
  content: unknown,
@@ -140,17 +145,18 @@ export function commentAnchorRejectionReason(
140
145
  return "invalid_comment_anchor";
141
146
  }
142
147
 
143
- if (rangeLandsMidRunNearTableBoundary(content, normalized)) {
144
- return "comment_anchor_table_adjacent";
145
- }
146
-
148
+ // I8 branch removed — mid-run-near-table anchors now serialize cleanly
149
+ // via Lane 3b §O8's `walkInlineNodeForBoundaries` fix.
147
150
  return null;
148
151
  }
149
152
 
150
153
  /**
151
- * I8.3 — Snap a rejected mid-run-near-table anchor to paragraph
152
- * boundaries so downstream serialization stays safe. Returns `null`
153
- * if the anchor cannot be rescued (e.g. crosses an opaque block).
154
+ * Snap a mid-run-near-table anchor to paragraph boundaries when a host opts
155
+ * into `snapToSafeBoundary`. Originally a defensive workaround for the pre-O8
156
+ * serializer bug; now a boundary-preference convenience for hosts that dislike
157
+ * mid-run comment anchors on principle. Returns `null` if the anchor cannot
158
+ * be rescued (e.g. crosses an opaque block); returns the input unchanged if
159
+ * it doesn't need snapping.
154
160
  */
155
161
  export function snapCommentAnchorAwayFromTable(
156
162
  content: unknown,
@@ -161,9 +167,14 @@ export function snapCommentAnchorAwayFromTable(
161
167
  const normalized = normalizeRange(anchor.range);
162
168
  if (normalized.from === normalized.to) return null;
163
169
 
164
- const reason = commentAnchorRejectionReason(content, anchor);
165
- if (reason === null) return anchor;
166
- if (reason !== "comment_anchor_table_adjacent") return null;
170
+ // If the anchor is already flagged invalid for other reasons (crosses an
171
+ // opaque block, etc.) we can't rescue it by snapping.
172
+ if (commentAnchorRejectionReason(content, anchor) !== null) return null;
173
+
174
+ // Detect mid-run-near-table via structural check directly (independent of
175
+ // rejection reason — the rejection branch is gone post-O8). If the anchor
176
+ // doesn't need snapping, pass it through unchanged.
177
+ if (!rangeLandsMidRunNearTableBoundary(content, normalized)) return anchor;
167
178
 
168
179
  const surfaceBlocks = readSurfaceBlocks(content);
169
180
  if (!surfaceBlocks) return null;
@@ -74,7 +74,8 @@ interface PendingResolution {
74
74
  readonly heightEmu: number;
75
75
  }
76
76
 
77
- type Pointer = Array<number>;
77
+ type PointerStep = number | { rowIndex: number; cellIndex: number };
78
+ type Pointer = PointerStep[];
78
79
 
79
80
  export async function resolveChartPreviewsForDocument(
80
81
  doc: CanonicalDocument,
@@ -189,30 +190,92 @@ export function scheduleChartPreviewResolution(
189
190
  function collectUnresolvedChartPreviews(doc: CanonicalDocument, pkg: OpcPackage): PendingResolution[] {
190
191
  const out: PendingResolution[] = [];
191
192
  const documentRels = collectDocumentPartRelationships(pkg);
193
+ collectUnresolvedChartPreviewsFromBlocks(
194
+ doc.content.children,
195
+ [],
196
+ documentRels,
197
+ pkg,
198
+ out,
199
+ );
200
+ return out;
201
+ }
192
202
 
193
- const paragraphs = doc.content.children;
194
- for (let i = 0; i < paragraphs.length; i++) {
195
- const block = paragraphs[i]!;
196
- if (block.type !== "paragraph") continue;
197
- const paragraph = block as ParagraphNode;
198
- for (let j = 0; j < paragraph.children.length; j++) {
199
- const child = paragraph.children[j];
200
- if (!child || child.type !== "chart_preview") continue;
201
- const chartNode = child as ChartPreviewNode;
202
- if (chartNode.previewMediaId) continue;
203
- const resolved = resolveChartPart(chartNode, documentRels, pkg);
204
- if (!resolved) continue;
205
- out.push({
206
- pointer: [i, j],
207
- node: chartNode,
208
- chartPartPath: resolved.chartPartPath,
209
- chartXml: resolved.chartXml,
210
- widthEmu: resolved.widthEmu,
211
- heightEmu: resolved.heightEmu,
212
- });
203
+ function collectUnresolvedChartPreviewsFromBlocks(
204
+ blocks: readonly BlockNode[],
205
+ pointerPrefix: Pointer,
206
+ documentRels: Map<string, string>,
207
+ pkg: OpcPackage,
208
+ out: PendingResolution[],
209
+ ): void {
210
+ for (let blockIndex = 0; blockIndex < blocks.length; blockIndex += 1) {
211
+ const block = blocks[blockIndex];
212
+ if (!block) continue;
213
+ switch (block.type) {
214
+ case "paragraph":
215
+ collectUnresolvedChartPreviewsFromParagraph(
216
+ block,
217
+ [...pointerPrefix, blockIndex],
218
+ documentRels,
219
+ pkg,
220
+ out,
221
+ );
222
+ break;
223
+ case "table":
224
+ for (let rowIndex = 0; rowIndex < block.rows.length; rowIndex += 1) {
225
+ const row = block.rows[rowIndex];
226
+ if (!row) continue;
227
+ for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex += 1) {
228
+ const cell = row.cells[cellIndex];
229
+ if (!cell) continue;
230
+ collectUnresolvedChartPreviewsFromBlocks(
231
+ cell.children,
232
+ [...pointerPrefix, blockIndex, { rowIndex, cellIndex }],
233
+ documentRels,
234
+ pkg,
235
+ out,
236
+ );
237
+ }
238
+ }
239
+ break;
240
+ case "sdt":
241
+ case "custom_xml":
242
+ collectUnresolvedChartPreviewsFromBlocks(
243
+ block.children,
244
+ [...pointerPrefix, blockIndex],
245
+ documentRels,
246
+ pkg,
247
+ out,
248
+ );
249
+ break;
250
+ default:
251
+ break;
213
252
  }
214
253
  }
215
- return out;
254
+ }
255
+
256
+ function collectUnresolvedChartPreviewsFromParagraph(
257
+ paragraph: ParagraphNode,
258
+ pointerPrefix: Pointer,
259
+ documentRels: Map<string, string>,
260
+ pkg: OpcPackage,
261
+ out: PendingResolution[],
262
+ ): void {
263
+ for (let childIndex = 0; childIndex < paragraph.children.length; childIndex += 1) {
264
+ const child = paragraph.children[childIndex];
265
+ if (!child || child.type !== "chart_preview") continue;
266
+ const chartNode = child as ChartPreviewNode;
267
+ if (chartNode.previewMediaId) continue;
268
+ const resolved = resolveChartPart(chartNode, documentRels, pkg);
269
+ if (!resolved) continue;
270
+ out.push({
271
+ pointer: [...pointerPrefix, childIndex],
272
+ node: chartNode,
273
+ chartPartPath: resolved.chartPartPath,
274
+ chartXml: resolved.chartXml,
275
+ widthEmu: resolved.widthEmu,
276
+ heightEmu: resolved.heightEmu,
277
+ });
278
+ }
216
279
  }
217
280
 
218
281
  function collectDocumentPartRelationships(pkg: OpcPackage): Map<string, string> {
@@ -323,7 +386,7 @@ function applyResolutions(
323
386
  resolutions: Array<{ entry: PendingResolution; bytes: Uint8Array }>,
324
387
  ): CanonicalDocument {
325
388
  const newMediaItems: Record<string, MediaItem> = { ...doc.media.items };
326
- const updates = new Map<string, { previewMediaId: string }>();
389
+ let newBlocks = doc.content.children;
327
390
 
328
391
  let seq = 0;
329
392
  for (const { entry, bytes } of resolutions) {
@@ -340,31 +403,102 @@ function applyResolutions(
340
403
  widthEmu: entry.widthEmu,
341
404
  heightEmu: entry.heightEmu,
342
405
  };
343
- const pointerKey = entry.pointer.join(",");
344
- updates.set(pointerKey, { previewMediaId: mediaId });
406
+ newBlocks = updateChartPreviewAtPointer(newBlocks, entry.pointer, mediaId);
345
407
  }
346
408
 
347
- if (updates.size === 0) return doc;
348
-
349
- // Clone the content tree along pointer paths only — everything else
350
- // keeps object identity so downstream React memoization stays stable.
351
- const newParagraphs: BlockNode[] = doc.content.children.slice();
352
- for (const [pointerKey, update] of updates) {
353
- const [pi, ci] = pointerKey.split(",").map((s) => parseInt(s, 10)) as [number, number];
354
- const paragraph = newParagraphs[pi];
355
- if (!paragraph || paragraph.type !== "paragraph") continue;
356
- const newChildren: InlineNode[] = (paragraph as ParagraphNode).children.slice();
357
- const existing = newChildren[ci];
358
- if (!existing || existing.type !== "chart_preview") continue;
359
- newChildren[ci] = { ...(existing as ChartPreviewNode), previewMediaId: update.previewMediaId };
360
- newParagraphs[pi] = { ...(paragraph as ParagraphNode), children: newChildren };
361
- }
409
+ if (newBlocks === doc.content.children) return doc;
362
410
 
363
- const newContent: DocumentRootNode = { ...doc.content, children: newParagraphs };
411
+ const newContent: DocumentRootNode = { ...doc.content, children: newBlocks };
364
412
  const newMedia = { items: newMediaItems };
365
413
  return { ...doc, content: newContent, media: newMedia };
366
414
  }
367
415
 
416
+ function updateChartPreviewAtPointer(
417
+ blocks: readonly BlockNode[],
418
+ pointer: Pointer,
419
+ previewMediaId: string,
420
+ ): BlockNode[] {
421
+ if (pointer.length < 2) return blocks as BlockNode[];
422
+ const [head, ...rest] = pointer;
423
+ if (typeof head !== "number") return blocks as BlockNode[];
424
+ const targetBlock = blocks[head];
425
+ if (!targetBlock) return blocks as BlockNode[];
426
+
427
+ switch (targetBlock.type) {
428
+ case "paragraph": {
429
+ if (rest.length !== 1 || typeof rest[0] !== "number") {
430
+ return blocks as BlockNode[];
431
+ }
432
+ const inlineIndex = rest[0];
433
+ const child = targetBlock.children[inlineIndex];
434
+ if (!child || child.type !== "chart_preview") return blocks as BlockNode[];
435
+ const nextChildren: InlineNode[] = targetBlock.children.slice();
436
+ nextChildren[inlineIndex] = {
437
+ ...(child as ChartPreviewNode),
438
+ previewMediaId,
439
+ };
440
+ const nextBlocks = blocks.slice();
441
+ nextBlocks[head] = {
442
+ ...targetBlock,
443
+ children: nextChildren,
444
+ };
445
+ return nextBlocks;
446
+ }
447
+ case "table": {
448
+ const [cellPointer, ...nested] = rest;
449
+ if (
450
+ !cellPointer ||
451
+ typeof cellPointer === "number" ||
452
+ nested.length === 0
453
+ ) {
454
+ return blocks as BlockNode[];
455
+ }
456
+ const row = targetBlock.rows[cellPointer.rowIndex];
457
+ const cell = row?.cells[cellPointer.cellIndex];
458
+ if (!row || !cell) return blocks as BlockNode[];
459
+ const nextChildren = updateChartPreviewAtPointer(
460
+ cell.children,
461
+ nested,
462
+ previewMediaId,
463
+ );
464
+ if (nextChildren === cell.children) return blocks as BlockNode[];
465
+ const nextCells = row.cells.slice();
466
+ nextCells[cellPointer.cellIndex] = {
467
+ ...cell,
468
+ children: nextChildren,
469
+ };
470
+ const nextRows = targetBlock.rows.slice();
471
+ nextRows[cellPointer.rowIndex] = {
472
+ ...row,
473
+ cells: nextCells,
474
+ };
475
+ const nextBlocks = blocks.slice();
476
+ nextBlocks[head] = {
477
+ ...targetBlock,
478
+ rows: nextRows,
479
+ };
480
+ return nextBlocks;
481
+ }
482
+ case "sdt":
483
+ case "custom_xml": {
484
+ const nextChildren = updateChartPreviewAtPointer(
485
+ targetBlock.children,
486
+ rest,
487
+ previewMediaId,
488
+ );
489
+ if (nextChildren === targetBlock.children) return blocks as BlockNode[];
490
+ const nextBlocks = blocks.slice();
491
+ nextBlocks[head] = {
492
+ ...targetBlock,
493
+ children: nextChildren,
494
+ };
495
+ return nextBlocks;
496
+ }
497
+ default:
498
+ return blocks as BlockNode[];
499
+ }
500
+ }
501
+
368
502
  /**
369
503
  * Content-type sniff from the first bytes of the rendered preview.
370
504
  * PNG magic is 0x89 0x50 0x4E 0x47; everything else is assumed to be