@beyondwork/docx-react-component 1.0.47 → 1.0.49

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 (80) hide show
  1. package/README.md +16 -11
  2. package/package.json +30 -41
  3. package/src/api/public-types.ts +199 -13
  4. package/src/compare/diff-engine.ts +4 -0
  5. package/src/core/commands/add-scope.ts +257 -0
  6. package/src/core/commands/formatting-commands.ts +2 -0
  7. package/src/core/commands/index.ts +9 -1
  8. package/src/core/commands/text-commands.ts +3 -1
  9. package/src/core/schema/text-schema.ts +95 -1
  10. package/src/core/selection/anchor-conversion.ts +112 -0
  11. package/src/core/selection/review-anchors.ts +108 -3
  12. package/src/core/state/text-transaction.ts +103 -7
  13. package/src/internal/harness-debug-ports.ts +168 -0
  14. package/src/io/chart-preview-resolver.ts +59 -1
  15. package/src/io/docx-session.ts +226 -38
  16. package/src/io/export/serialize-main-document.ts +46 -0
  17. package/src/io/export/serialize-paragraph-formatting.ts +8 -0
  18. package/src/io/export/serialize-run-formatting.ts +10 -1
  19. package/src/io/export/serialize-settings.ts +421 -0
  20. package/src/io/export/serialize-styles.ts +10 -0
  21. package/src/io/normalize/normalize-text.ts +1 -0
  22. package/src/io/ooxml/chart/chart-style-table.ts +543 -0
  23. package/src/io/ooxml/chart/color-palette.ts +101 -0
  24. package/src/io/ooxml/chart/compose-series-color.ts +147 -0
  25. package/src/io/ooxml/chart/parse-axis.ts +277 -0
  26. package/src/io/ooxml/chart/parse-chart-space.ts +885 -0
  27. package/src/io/ooxml/chart/parse-series.ts +635 -0
  28. package/src/io/ooxml/chart/resolve-color.ts +261 -0
  29. package/src/io/ooxml/chart/types.ts +439 -0
  30. package/src/io/ooxml/parse-block-structure.ts +99 -0
  31. package/src/io/ooxml/parse-complex-content.ts +90 -2
  32. package/src/io/ooxml/parse-main-document.ts +156 -1
  33. package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
  34. package/src/io/ooxml/parse-run-formatting.ts +49 -0
  35. package/src/io/ooxml/parse-scope-markers.ts +184 -0
  36. package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
  37. package/src/io/ooxml/parse-settings.ts +97 -1
  38. package/src/io/ooxml/parse-styles.ts +65 -0
  39. package/src/io/ooxml/parse-theme.ts +2 -127
  40. package/src/io/ooxml/property-grab-bag.ts +211 -0
  41. package/src/io/ooxml/xml-attr-helpers.ts +59 -1
  42. package/src/io/ooxml/xml-parser.ts +142 -0
  43. package/src/model/canonical-document.ts +160 -0
  44. package/src/model/scope-markers.ts +144 -0
  45. package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
  46. package/src/runtime/collab/checkpoint-election.ts +75 -0
  47. package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
  48. package/src/runtime/collab/checkpoint-store.ts +115 -0
  49. package/src/runtime/collab/event-types.ts +27 -0
  50. package/src/runtime/collab/index.ts +29 -0
  51. package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
  52. package/src/runtime/collab/runtime-collab-sync.ts +330 -0
  53. package/src/runtime/collab/workflow-shared.ts +247 -0
  54. package/src/runtime/document-locations.ts +1 -9
  55. package/src/runtime/document-outline.ts +1 -9
  56. package/src/runtime/document-runtime.ts +288 -65
  57. package/src/runtime/editor-surface/capabilities.ts +63 -50
  58. package/src/runtime/hyperlink-color-resolver.ts +119 -0
  59. package/src/runtime/layout/layout-engine-version.ts +8 -1
  60. package/src/runtime/prerender/cache-envelope.ts +19 -7
  61. package/src/runtime/prerender/cache-key.ts +25 -14
  62. package/src/runtime/prerender/canonical-document-hash.ts +63 -0
  63. package/src/runtime/prerender/customxml-cache.ts +211 -0
  64. package/src/runtime/prerender/customxml-probe.ts +78 -0
  65. package/src/runtime/prerender/prerender-document.ts +74 -7
  66. package/src/runtime/scope-resolver.ts +148 -0
  67. package/src/runtime/scope-tag-registry.ts +10 -0
  68. package/src/runtime/surface-projection.ts +102 -37
  69. package/src/runtime/theme-color-resolver.ts +188 -0
  70. package/src/runtime/workflow-markup.ts +7 -18
  71. package/src/ui/WordReviewEditor.tsx +48 -2
  72. package/src/ui/editor-runtime-boundary.ts +42 -1
  73. package/src/ui/headless/selection-helpers.ts +10 -23
  74. package/src/ui/runtime-shortcut-dispatch.ts +12 -7
  75. package/src/ui/unsupported-previews-policy.ts +23 -0
  76. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
  77. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  78. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
  79. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
  80. package/src/ui-tailwind/tw-review-workspace.tsx +16 -1
@@ -0,0 +1,257 @@
1
+ import type {
2
+ DocumentRootNode,
3
+ InlineNode,
4
+ ParagraphNode,
5
+ ScopeMarkerStartNode,
6
+ ScopeMarkerEndNode,
7
+ } from "../../model/canonical-document.ts";
8
+ import type { CanonicalDocumentEnvelope } from "../state/editor-state.ts";
9
+
10
+ export interface InsertScopeMarkersResult {
11
+ document: CanonicalDocumentEnvelope;
12
+ scopeId: string;
13
+ }
14
+
15
+ /**
16
+ * Pure helper — returns a new CanonicalDocumentEnvelope with a pair of
17
+ * scope-marker inline nodes inserted at the given position range.
18
+ *
19
+ * Supports the common case: `from` and `to` land in the same top-level
20
+ * paragraph. Cross-paragraph ranges are currently a no-op and return the
21
+ * document unchanged — multi-block scopes ship in a follow-up slice.
22
+ */
23
+ export function insertScopeMarkers(
24
+ document: CanonicalDocumentEnvelope,
25
+ params: {
26
+ scopeId: string;
27
+ from: number;
28
+ to: number;
29
+ },
30
+ ): InsertScopeMarkersResult {
31
+ const { scopeId, from, to } = params;
32
+ const root = document.content as DocumentRootNode;
33
+ if (!root || root.type !== "doc") return { document, scopeId };
34
+
35
+ const normalizedFrom = Math.min(from, to);
36
+ const normalizedTo = Math.max(from, to);
37
+
38
+ let cursor = 0;
39
+ let inserted = false;
40
+ const children = root.children.map((block, blockIndex) => {
41
+ if (inserted) return block;
42
+ if (block.type !== "paragraph") {
43
+ cursor += 1;
44
+ if (blockIndex < root.children.length - 1) cursor += 1;
45
+ return block;
46
+ }
47
+
48
+ const paragraphFrom = cursor;
49
+ const paragraphLength = block.children.reduce(
50
+ (total, child) => total + inlineLength(child as InlineNode),
51
+ 0,
52
+ );
53
+ const paragraphTo = paragraphFrom + paragraphLength;
54
+ cursor = paragraphTo + 1;
55
+
56
+ if (
57
+ normalizedFrom < paragraphFrom ||
58
+ normalizedTo > paragraphTo ||
59
+ normalizedFrom > paragraphTo
60
+ ) {
61
+ return block;
62
+ }
63
+
64
+ inserted = true;
65
+ const startOffset = normalizedFrom - paragraphFrom;
66
+ const endOffset = normalizedTo - paragraphFrom;
67
+ const newChildren = injectMarkersIntoInlineList(
68
+ block.children as InlineNode[],
69
+ scopeId,
70
+ startOffset,
71
+ endOffset,
72
+ );
73
+ return { ...block, children: newChildren } as ParagraphNode;
74
+ });
75
+
76
+ if (!inserted) return { document, scopeId };
77
+
78
+ return {
79
+ document: {
80
+ ...document,
81
+ content: { ...root, children },
82
+ },
83
+ scopeId,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Returns a new document with every scope_marker_* node whose scopeId matches
89
+ * removed. The containing paragraphs' other children are preserved in order.
90
+ */
91
+ export function removeScopeMarkers(
92
+ document: CanonicalDocumentEnvelope,
93
+ scopeId: string,
94
+ ): CanonicalDocumentEnvelope {
95
+ const root = document.content as DocumentRootNode;
96
+ if (!root || root.type !== "doc") return document;
97
+
98
+ let mutated = false;
99
+ const children = root.children.map((block) => {
100
+ if (block.type !== "paragraph") return block;
101
+ const kept = block.children.filter((child) => {
102
+ if (
103
+ (child.type === "scope_marker_start" ||
104
+ child.type === "scope_marker_end") &&
105
+ child.scopeId === scopeId
106
+ ) {
107
+ mutated = true;
108
+ return false;
109
+ }
110
+ return true;
111
+ });
112
+ if (kept.length === block.children.length) return block;
113
+ return { ...block, children: kept } as ParagraphNode;
114
+ });
115
+
116
+ if (!mutated) return document;
117
+
118
+ return {
119
+ ...document,
120
+ content: { ...root, children },
121
+ };
122
+ }
123
+
124
+ function inlineLength(node: InlineNode): number {
125
+ switch (node.type) {
126
+ case "text":
127
+ return Array.from(node.text).length;
128
+ case "hyperlink":
129
+ case "field":
130
+ return node.children.reduce(
131
+ (total, child) => total + inlineLength(child as InlineNode),
132
+ 0,
133
+ );
134
+ case "bookmark_start":
135
+ case "bookmark_end":
136
+ case "scope_marker_start":
137
+ case "scope_marker_end":
138
+ return 0;
139
+ default:
140
+ return 1;
141
+ }
142
+ }
143
+
144
+ function injectMarkersIntoInlineList(
145
+ inlines: InlineNode[],
146
+ scopeId: string,
147
+ startOffset: number,
148
+ endOffset: number,
149
+ ): InlineNode[] {
150
+ const start: ScopeMarkerStartNode = {
151
+ type: "scope_marker_start",
152
+ scopeId,
153
+ };
154
+ const end: ScopeMarkerEndNode = {
155
+ type: "scope_marker_end",
156
+ scopeId,
157
+ };
158
+
159
+ const output: InlineNode[] = [];
160
+ let cursor = 0;
161
+ let startEmitted = false;
162
+ let endEmitted = false;
163
+
164
+ for (const node of inlines) {
165
+ const length = inlineLength(node);
166
+ const nodeStart = cursor;
167
+ const nodeEnd = cursor + length;
168
+
169
+ const startInside = !startEmitted && startOffset >= nodeStart && startOffset <= nodeEnd;
170
+ const endInside = !endEmitted && endOffset >= nodeStart && endOffset <= nodeEnd;
171
+
172
+ if (!startInside && !endInside) {
173
+ output.push(node);
174
+ cursor = nodeEnd;
175
+ continue;
176
+ }
177
+
178
+ // Currently only text nodes support splitting for an internal cut.
179
+ if (node.type !== "text") {
180
+ // For non-text nodes, markers land at the node boundary closest to the
181
+ // target offset — avoids mid-atom splits which would corrupt the node.
182
+ if (startInside && !startEmitted && startOffset <= nodeStart) {
183
+ output.push(start);
184
+ startEmitted = true;
185
+ }
186
+ if (endInside && !endEmitted && endOffset <= nodeStart) {
187
+ output.push(end);
188
+ endEmitted = true;
189
+ }
190
+ output.push(node);
191
+ if (startInside && !startEmitted && startOffset >= nodeEnd) {
192
+ output.push(start);
193
+ startEmitted = true;
194
+ }
195
+ if (endInside && !endEmitted && endOffset >= nodeEnd) {
196
+ output.push(end);
197
+ endEmitted = true;
198
+ }
199
+ cursor = nodeEnd;
200
+ continue;
201
+ }
202
+
203
+ const text = node.text;
204
+ const chars = Array.from(text);
205
+ const marks = node.marks;
206
+ const pieces: {
207
+ cut: number;
208
+ emit: "start" | "end";
209
+ }[] = [];
210
+
211
+ if (startInside) pieces.push({ cut: startOffset - nodeStart, emit: "start" });
212
+ if (endInside) pieces.push({ cut: endOffset - nodeStart, emit: "end" });
213
+ pieces.sort((a, b) => a.cut - b.cut || (a.emit === "start" ? -1 : 1));
214
+
215
+ let priorCut = 0;
216
+ for (const piece of pieces) {
217
+ const segment = chars.slice(priorCut, piece.cut).join("");
218
+ if (segment.length > 0) {
219
+ output.push(
220
+ marks !== undefined
221
+ ? { type: "text", text: segment, marks }
222
+ : { type: "text", text: segment },
223
+ );
224
+ }
225
+ if (piece.emit === "start") {
226
+ output.push(start);
227
+ startEmitted = true;
228
+ } else {
229
+ output.push(end);
230
+ endEmitted = true;
231
+ }
232
+ priorCut = piece.cut;
233
+ }
234
+ const tail = chars.slice(priorCut).join("");
235
+ if (tail.length > 0) {
236
+ output.push(
237
+ marks !== undefined
238
+ ? { type: "text", text: tail, marks }
239
+ : { type: "text", text: tail },
240
+ );
241
+ }
242
+
243
+ cursor = nodeEnd;
244
+ }
245
+
246
+ // Append markers that were at the very end of the paragraph.
247
+ if (!startEmitted) {
248
+ output.push(start);
249
+ startEmitted = true;
250
+ }
251
+ if (!endEmitted) {
252
+ output.push(end);
253
+ endEmitted = true;
254
+ }
255
+
256
+ return output;
257
+ }
@@ -1048,6 +1048,8 @@ function inlineNodeLength(node: InlineNode): number {
1048
1048
  );
1049
1049
  case "bookmark_start":
1050
1050
  case "bookmark_end":
1051
+ case "scope_marker_start":
1052
+ case "scope_marker_end":
1051
1053
  return 0;
1052
1054
  }
1053
1055
  }
@@ -50,6 +50,7 @@ import type {
50
50
  SectionBreakType,
51
51
  SectionLayoutPatch,
52
52
  SectionPageNumberingPatch,
53
+ TextFormattingDirective,
53
54
  WorkflowMetadataDefinition,
54
55
  WorkflowMetadataEntry,
55
56
  WorkflowOverlay,
@@ -136,6 +137,13 @@ export type EditorCommand =
136
137
  | {
137
138
  type: "text.insert";
138
139
  text: string;
140
+ /**
141
+ * I7 — optional directive controlling which character-level marks the inserted
142
+ * text carries. Defaults to `{ mode: "paragraph-default" }` (today's behavior:
143
+ * no inherited run marks). See `src/api/public-types.ts` `TextFormattingDirective`
144
+ * for semantics.
145
+ */
146
+ formatting?: TextFormattingDirective;
139
147
  origin?: CommandOrigin;
140
148
  }
141
149
  | {
@@ -556,7 +564,7 @@ export function executeEditorCommand(
556
564
  : undefined;
557
565
  if (suggestingResult) return suggestingResult;
558
566
  return applyTextCommand(state, context.timestamp, (document, selection) =>
559
- insertText(document, selection, command.text, context),
567
+ insertText(document, selection, command.text, context, command.formatting),
560
568
  );
561
569
  }
562
570
  case "text.delete-backward": {
@@ -1,4 +1,4 @@
1
- import type { InsertTableOptions } from "../../api/public-types";
1
+ import type { InsertTableOptions, TextFormattingDirective } from "../../api/public-types";
2
2
  import type {
3
3
  DocumentRootNode,
4
4
  ParagraphNode,
@@ -126,6 +126,7 @@ export function insertText(
126
126
  selection: SelectionSnapshot,
127
127
  text: string,
128
128
  context: TextCommandContext,
129
+ formatting?: TextFormattingDirective,
129
130
  ): TextTransactionResult {
130
131
  return applyTextTransaction(
131
132
  document,
@@ -138,6 +139,7 @@ export function insertText(
138
139
  text,
139
140
  },
140
141
  ],
142
+ formatting,
141
143
  },
142
144
  context,
143
145
  );
@@ -26,6 +26,7 @@ export type StoryUnit =
26
26
  | ImageUnit
27
27
  | OpaqueInlineUnit
28
28
  | OpaqueBlockUnit
29
+ | ScopeMarkerUnit
29
30
  | ParagraphBreakUnit;
30
31
 
31
32
  export interface TextCharacterUnit {
@@ -69,6 +70,18 @@ export interface ParagraphBreakUnit {
69
70
  nextParagraph: ParagraphProperties;
70
71
  }
71
72
 
73
+ /**
74
+ * Zero-width inline unit that preserves S1 scope-marker nodes through text
75
+ * transactions. Without this unit, scope markers would be silently dropped
76
+ * during `parseTextStory` / `serializeTextStory`, and any `text.insert` /
77
+ * `text.delete-*` dispatch would vaporize the structural scope anchors.
78
+ */
79
+ export interface ScopeMarkerUnit {
80
+ kind: "scope_marker";
81
+ boundary: "start" | "end";
82
+ scopeId: string;
83
+ }
84
+
72
85
  export function parseTextStory(content: unknown): TextStory {
73
86
  const root = normalizeDocumentRoot(content);
74
87
  const firstParagraphNode = root.children.find(isParagraphNode);
@@ -111,10 +124,60 @@ export function parseTextStory(content: unknown): TextStory {
111
124
  return {
112
125
  firstParagraph,
113
126
  units,
114
- size: units.length,
127
+ size: countLogicalPositions(units),
115
128
  };
116
129
  }
117
130
 
131
+ /**
132
+ * Story positions are logical — scope-marker units are preserved in the
133
+ * `units` array for round-trip fidelity but they do NOT consume a position.
134
+ * This matches the surface-projection treatment (markers = 0 width, same as
135
+ * bookmark_start / bookmark_end) so a position 3 set via `selection.set`
136
+ * resolves to the same character in both views.
137
+ */
138
+ export function countLogicalPositions(units: StoryUnit[]): number {
139
+ let size = 0;
140
+ for (const unit of units) {
141
+ if (unit.kind !== "scope_marker") size += 1;
142
+ }
143
+ return size;
144
+ }
145
+
146
+ /**
147
+ * Translate a logical (scope-marker-skipping) position into a unit-array
148
+ * index. Walks units and increments the unit cursor once per non-marker unit;
149
+ * scope markers are passed over transparently. Returns `units.length` when
150
+ * the logical position is at or beyond end-of-story.
151
+ *
152
+ * When `startBias === "after"` (default), the returned unit index is the
153
+ * first position AFTER any scope markers that sit exactly at the logical
154
+ * boundary — useful when slicing units as "...before this cursor". When
155
+ * `startBias === "before"`, markers at the boundary are included in the
156
+ * "after" slice.
157
+ */
158
+ export function logicalPositionToUnitIndex(
159
+ units: StoryUnit[],
160
+ logicalPos: number,
161
+ startBias: "before" | "after" = "after",
162
+ ): number {
163
+ let logicalCursor = 0;
164
+ let unitIndex = 0;
165
+ while (unitIndex < units.length) {
166
+ if (logicalCursor === logicalPos && startBias === "before") {
167
+ return unitIndex;
168
+ }
169
+ const unit = units[unitIndex]!;
170
+ if (unit.kind !== "scope_marker") {
171
+ if (logicalCursor === logicalPos && startBias === "after") {
172
+ return unitIndex;
173
+ }
174
+ logicalCursor += 1;
175
+ }
176
+ unitIndex += 1;
177
+ }
178
+ return unitIndex;
179
+ }
180
+
118
181
  export function serializeTextStory(story: TextStory): DocumentRootNode {
119
182
  const blocks: Array<ParagraphNode | OpaqueBlockNode> = [];
120
183
  let currentParagraph: ParagraphNode | undefined = createParagraph(story.firstParagraph);
@@ -272,6 +335,15 @@ export function serializeTextStory(story: TextStory): DocumentRootNode {
272
335
  warningId: unit.warningId,
273
336
  });
274
337
  break;
338
+ case "scope_marker":
339
+ pushInlineNode({
340
+ type:
341
+ unit.boundary === "start"
342
+ ? "scope_marker_start"
343
+ : "scope_marker_end",
344
+ scopeId: unit.scopeId,
345
+ });
346
+ break;
275
347
  }
276
348
  }
277
349
 
@@ -305,6 +377,8 @@ export function createPlainText(story: TextStory): string {
305
377
  return "\uFFF9";
306
378
  case "opaque_block":
307
379
  return "\uFFFA";
380
+ case "scope_marker":
381
+ return "";
308
382
  }
309
383
  })
310
384
  .join("");
@@ -355,6 +429,12 @@ export function cloneStoryUnit(unit: StoryUnit): StoryUnit {
355
429
  kind: "paragraph_break",
356
430
  nextParagraph: cloneParagraphProperties(unit.nextParagraph),
357
431
  };
432
+ case "scope_marker":
433
+ return {
434
+ kind: "scope_marker",
435
+ boundary: unit.boundary,
436
+ scopeId: unit.scopeId,
437
+ };
358
438
  }
359
439
  }
360
440
 
@@ -442,6 +522,20 @@ function flattenInlineNodes(
442
522
  warningId: node.warningId,
443
523
  });
444
524
  break;
525
+ case "scope_marker_start":
526
+ units.push({
527
+ kind: "scope_marker",
528
+ boundary: "start",
529
+ scopeId: node.scopeId,
530
+ });
531
+ break;
532
+ case "scope_marker_end":
533
+ units.push({
534
+ kind: "scope_marker",
535
+ boundary: "end",
536
+ scopeId: node.scopeId,
537
+ });
538
+ break;
445
539
  }
446
540
  }
447
541
 
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Canonical boundary between the internal `EditorAnchorProjection` shape
3
+ * (`src/core/selection/mapping.ts` — `{ kind: "range", range: DocRange,
4
+ * assoc }`) and the public `EditorAnchorProjection` shape
5
+ * (`src/api/public-types.ts` — `{ kind: "range", from, to, assoc }`).
6
+ *
7
+ * Two shapes stay split intentionally: the internal shape uses `DocRange`
8
+ * so mapping helpers compose ranges independently of assoc semantics;
9
+ * the public shape is flat so host consumers can destructure without
10
+ * reaching through a nested `range` member.
11
+ *
12
+ * Every conversion MUST go through this module. Every creation of a
13
+ * public-shape anchor MUST go through this module. A regression test
14
+ * in `test/api/anchor-boundary-invariants.test.ts` enforces the "no
15
+ * ad-hoc helpers outside this file" rule.
16
+ */
17
+
18
+ import type { EditorAnchorProjection as PublicEditorAnchorProjection } from "../../api/public-types";
19
+ import {
20
+ DEFAULT_BOUNDARY_ASSOC,
21
+ createDetachedAnchor,
22
+ createNodeAnchor,
23
+ createRangeAnchor,
24
+ normalizeRange,
25
+ type DocRange,
26
+ type EditorAnchorProjection as InternalEditorAnchorProjection,
27
+ } from "./mapping.ts";
28
+
29
+ /**
30
+ * Default boundary-associativity used by public-shape range anchors
31
+ * when the caller does not provide one. Matches the inline default the
32
+ * three ad-hoc `createPublicRangeAnchor` helpers all used before this
33
+ * module existed (`{ start: -1, end: 1 }` — the "outward-biased"
34
+ * selection).
35
+ */
36
+ export const DEFAULT_PUBLIC_ASSOC: { start: -1 | 1; end: -1 | 1 } = {
37
+ start: -1,
38
+ end: 1,
39
+ };
40
+
41
+ type PublicRangeAssoc = { start: -1 | 1; end: -1 | 1 };
42
+
43
+ export function createPublicRangeAnchor(
44
+ from: number,
45
+ to: number,
46
+ assoc: PublicRangeAssoc = DEFAULT_PUBLIC_ASSOC,
47
+ ): PublicEditorAnchorProjection {
48
+ const range = normalizeRange({ from, to });
49
+ return {
50
+ kind: "range",
51
+ from: range.from,
52
+ to: range.to,
53
+ assoc,
54
+ };
55
+ }
56
+
57
+ export function createPublicNodeAnchor(
58
+ at: number,
59
+ assoc: -1 | 1 = 1,
60
+ ): PublicEditorAnchorProjection {
61
+ return { kind: "node", at, assoc };
62
+ }
63
+
64
+ export function createPublicDetachedAnchor(
65
+ lastKnownRange: DocRange,
66
+ reason: "deleted" | "invalidatedByStructureChange" | "importAmbiguity",
67
+ ): PublicEditorAnchorProjection {
68
+ return {
69
+ kind: "detached",
70
+ lastKnownRange: normalizeRange(lastKnownRange),
71
+ reason,
72
+ };
73
+ }
74
+
75
+ export function toPublicAnchorProjection(
76
+ anchor: InternalEditorAnchorProjection,
77
+ ): PublicEditorAnchorProjection {
78
+ switch (anchor.kind) {
79
+ case "range":
80
+ return {
81
+ kind: "range",
82
+ from: anchor.range.from,
83
+ to: anchor.range.to,
84
+ assoc: anchor.assoc,
85
+ };
86
+ case "node":
87
+ return { kind: "node", at: anchor.at, assoc: anchor.assoc };
88
+ case "detached":
89
+ return {
90
+ kind: "detached",
91
+ lastKnownRange: anchor.lastKnownRange,
92
+ reason: anchor.reason,
93
+ };
94
+ }
95
+ }
96
+
97
+ export function toInternalAnchorProjection(
98
+ anchor: PublicEditorAnchorProjection,
99
+ ): InternalEditorAnchorProjection {
100
+ switch (anchor.kind) {
101
+ case "range":
102
+ return createRangeAnchor(
103
+ anchor.from,
104
+ anchor.to,
105
+ anchor.assoc ?? DEFAULT_BOUNDARY_ASSOC,
106
+ );
107
+ case "node":
108
+ return createNodeAnchor(anchor.at, anchor.assoc);
109
+ case "detached":
110
+ return createDetachedAnchor(anchor.lastKnownRange, anchor.reason);
111
+ }
112
+ }
@@ -103,20 +103,125 @@ export function rangeStaysWithinSingleParagraph(
103
103
  return true;
104
104
  }
105
105
 
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.
112
+ */
113
+ export const TABLE_ADJACENT_WINDOW = 1;
114
+
115
+ export type CommentAnchorRejectionReason =
116
+ | "invalid_comment_anchor"
117
+ | "comment_anchor_table_adjacent";
118
+
106
119
  export function canCreateDocxCommentAnchor(
107
120
  content: unknown,
108
121
  anchor: ReviewAnchor,
109
122
  ): boolean {
123
+ return commentAnchorRejectionReason(content, anchor) === null;
124
+ }
125
+
126
+ export function commentAnchorRejectionReason(
127
+ content: unknown,
128
+ anchor: ReviewAnchor,
129
+ ): CommentAnchorRejectionReason | null {
110
130
  if (anchor.kind !== "range") {
111
- return false;
131
+ return "invalid_comment_anchor";
112
132
  }
113
133
 
114
134
  const normalized = normalizeRange(anchor.range);
115
135
  if (normalized.from === normalized.to) {
116
- return false;
136
+ return "invalid_comment_anchor";
137
+ }
138
+
139
+ if (!rangeStaysWithinCommentableStory(content, normalized)) {
140
+ return "invalid_comment_anchor";
141
+ }
142
+
143
+ if (rangeLandsMidRunNearTableBoundary(content, normalized)) {
144
+ return "comment_anchor_table_adjacent";
117
145
  }
118
146
 
119
- return rangeStaysWithinCommentableStory(content, normalized);
147
+ return null;
148
+ }
149
+
150
+ /**
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
+ */
155
+ export function snapCommentAnchorAwayFromTable(
156
+ content: unknown,
157
+ anchor: ReviewAnchor,
158
+ ): ReviewAnchor | null {
159
+ if (anchor.kind !== "range") return null;
160
+
161
+ const normalized = normalizeRange(anchor.range);
162
+ if (normalized.from === normalized.to) return null;
163
+
164
+ const reason = commentAnchorRejectionReason(content, anchor);
165
+ if (reason === null) return anchor;
166
+ if (reason !== "comment_anchor_table_adjacent") return null;
167
+
168
+ const surfaceBlocks = readSurfaceBlocks(content);
169
+ if (!surfaceBlocks) return null;
170
+
171
+ const fromOwner = findContainingParagraphForEndpoint(surfaceBlocks, normalized.from, "start");
172
+ const toOwner = findContainingParagraphForEndpoint(surfaceBlocks, normalized.to, "end");
173
+ if (!fromOwner || !toOwner) return null;
174
+
175
+ const snappedFrom = normalized.from > fromOwner.from && normalized.from < fromOwner.to
176
+ ? fromOwner.from
177
+ : normalized.from;
178
+ const snappedTo = normalized.to > toOwner.from && normalized.to < toOwner.to
179
+ ? toOwner.to
180
+ : normalized.to;
181
+
182
+ if (snappedFrom === snappedTo) return null;
183
+
184
+ const snapped = createRangeAnchor(snappedFrom, snappedTo);
185
+ return canCreateDocxCommentAnchor(content, snapped) ? snapped : null;
186
+ }
187
+
188
+ function rangeLandsMidRunNearTableBoundary(
189
+ content: unknown,
190
+ range: DocRange,
191
+ ): boolean {
192
+ const surfaceBlocks = readSurfaceBlocks(content);
193
+ if (!surfaceBlocks) return false;
194
+
195
+ const tableBlocks = surfaceBlocks.filter((block) => block.kind === "table");
196
+ if (tableBlocks.length === 0) return false;
197
+
198
+ const fromOwner = findContainingParagraphForEndpoint(surfaceBlocks, range.from, "start");
199
+ const toOwner = findContainingParagraphForEndpoint(surfaceBlocks, range.to, "end");
200
+ if (!fromOwner || !toOwner) return false;
201
+
202
+ const fromMidRun = range.from > fromOwner.from && range.from < fromOwner.to;
203
+ const toMidRun = range.to > toOwner.from && range.to < toOwner.to;
204
+ if (!fromMidRun && !toMidRun) return false;
205
+
206
+ const fromAdjacent = fromMidRun && paragraphIsTableAdjacent(fromOwner, tableBlocks);
207
+ const toAdjacent = toMidRun && paragraphIsTableAdjacent(toOwner, tableBlocks);
208
+ return fromAdjacent || toAdjacent;
209
+ }
210
+
211
+ function paragraphIsTableAdjacent(
212
+ paragraph: FlattenedSurfaceBlock,
213
+ tableBlocks: readonly FlattenedSurfaceBlock[],
214
+ ): boolean {
215
+ return tableBlocks.some((table) => {
216
+ if (table.tableId !== null && table.tableId === paragraph.tableId) {
217
+ // Skip: a table block at the same cell scope would be a
218
+ // descendant of the paragraph's containing cell, not a sibling.
219
+ return false;
220
+ }
221
+ const leftGap = Math.abs(paragraph.from - table.to);
222
+ const rightGap = Math.abs(paragraph.to - table.from);
223
+ return leftGap <= TABLE_ADJACENT_WINDOW || rightGap <= TABLE_ADJACENT_WINDOW;
224
+ });
120
225
  }
121
226
 
122
227
  export function rangeStaysWithinCommentableStory(