@beyondwork/docx-react-component 1.0.73 → 1.0.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/src/api/anchor-conversion.ts +2 -2
  3. package/src/api/public-types.ts +33 -6
  4. package/src/api/v3/_runtime-handle.ts +15 -0
  5. package/src/api/v3/ui/_types.ts +21 -0
  6. package/src/api/v3/ui/overlays.ts +276 -2
  7. package/src/api/v3/ui/scope.ts +113 -1
  8. package/src/compare/diff-engine.ts +1 -2
  9. package/src/core/commands/index.ts +14 -15
  10. package/src/core/selection/anchor-conversion.ts +2 -2
  11. package/src/core/selection/mapping.ts +10 -8
  12. package/src/core/selection/review-anchors.ts +3 -3
  13. package/src/io/export/export-session.ts +53 -0
  14. package/src/io/export/serialize-comments.ts +4 -4
  15. package/src/io/export/serialize-runtime-revisions.ts +10 -10
  16. package/src/io/export/split-review-boundaries.ts +4 -4
  17. package/src/io/export/split-story-blocks-for-runtime-revisions.ts +2 -2
  18. package/src/io/ooxml/parse-comments.ts +2 -2
  19. package/src/model/anchor.ts +9 -1
  20. package/src/model/canonical-document.ts +76 -3
  21. package/src/preservation/store.ts +24 -0
  22. package/src/review/store/comment-anchors.ts +1 -1
  23. package/src/review/store/comment-remapping.ts +1 -1
  24. package/src/review/store/revision-actions.ts +4 -4
  25. package/src/review/store/revision-types.ts +1 -1
  26. package/src/review/store/scope-tag-diff.ts +1 -1
  27. package/src/runtime/collab/map-local-selection-on-remote-replay.ts +7 -7
  28. package/src/runtime/document-runtime.ts +205 -37
  29. package/src/runtime/formatting/formatting-context.ts +1 -1
  30. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  31. package/src/runtime/layout/layout-engine-version.ts +9 -1
  32. package/src/runtime/layout/public-facet.ts +27 -0
  33. package/src/runtime/scopes/evidence.ts +1 -1
  34. package/src/runtime/scopes/review-bundle.ts +1 -1
  35. package/src/runtime/scopes/scope-range.ts +1 -1
  36. package/src/runtime/selection/post-edit-validator.ts +4 -4
  37. package/src/runtime/surface-projection.ts +39 -4
  38. package/src/session/import/review-import.ts +12 -12
  39. package/src/session/import/workflow-scope-import.ts +9 -8
  40. package/src/shell/session-bootstrap.ts +4 -0
  41. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +5 -2
  42. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +99 -43
  43. package/src/ui-tailwind/review-workspace/use-page-markers.ts +48 -7
  44. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +8 -8
  45. package/src/ui-tailwind/tw-review-workspace.tsx +13 -35
  46. package/src/validation/compatibility-engine.ts +1 -1
  47. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +0 -114
  48. package/src/ui-tailwind/review-workspace/tw-review-workspace-page-toolbar.tsx +0 -240
@@ -1525,9 +1525,9 @@ export function remapSelection(
1525
1525
 
1526
1526
  if (activeRange.kind === "range") {
1527
1527
  return {
1528
- anchor: activeRange.range.from,
1529
- head: activeRange.range.to,
1530
- isCollapsed: activeRange.range.from === activeRange.range.to,
1528
+ anchor: activeRange.from,
1529
+ head: activeRange.to,
1530
+ isCollapsed: activeRange.from === activeRange.to,
1531
1531
  activeRange,
1532
1532
  };
1533
1533
  }
@@ -1664,8 +1664,8 @@ function normalizeSelection(selection: SelectionSnapshot): SelectionSnapshot {
1664
1664
  );
1665
1665
 
1666
1666
  if (activeRange.kind === "range") {
1667
- const rangeFrom = activeRange.range.from;
1668
- const rangeTo = activeRange.range.to;
1667
+ const rangeFrom = activeRange.from;
1668
+ const rangeTo = activeRange.to;
1669
1669
  const collapsed = rangeFrom === rangeTo;
1670
1670
  const anchorWithinRange = selection.anchor >= rangeFrom && selection.anchor <= rangeTo;
1671
1671
  const headWithinRange = selection.head >= rangeFrom && selection.head <= rangeTo;
@@ -2256,7 +2256,8 @@ function createAuthoredRevision(
2256
2256
  kind,
2257
2257
  anchor: {
2258
2258
  kind: "range",
2259
- range: { from, to },
2259
+ from,
2260
+ to,
2260
2261
  assoc: { start: 1, end: -1 },
2261
2262
  },
2262
2263
  authorId,
@@ -2396,8 +2397,7 @@ function applySuggestingInsert(
2396
2397
  const updatedRevision: CanonicalRevisionRecord = {
2397
2398
  ...existingInsertion,
2398
2399
  anchor: {
2399
- kind: "range",
2400
- range: { from: existingInsertion.anchor.range.from, to: insertedTo },
2400
+ kind: "range", from: existingInsertion.anchor.from, to: insertedTo,
2401
2401
  assoc: { start: 1, end: -1 },
2402
2402
  },
2403
2403
  };
@@ -2635,19 +2635,18 @@ function applySuggestingDelete(
2635
2635
  if (existingDeletion) {
2636
2636
  // Extend the existing deletion revision to cover the new range
2637
2637
  const extendedFrom = Math.min(
2638
- existingDeletion.anchor.kind === "range" ? existingDeletion.anchor.range.from : deleteFrom,
2638
+ existingDeletion.anchor.kind === "range" ? existingDeletion.anchor.from : deleteFrom,
2639
2639
  deleteFrom,
2640
2640
  );
2641
2641
  const extendedTo = Math.max(
2642
- existingDeletion.anchor.kind === "range" ? existingDeletion.anchor.range.to : deleteTo,
2642
+ existingDeletion.anchor.kind === "range" ? existingDeletion.anchor.to : deleteTo,
2643
2643
  deleteTo,
2644
2644
  );
2645
2645
 
2646
2646
  const updatedRevision: CanonicalRevisionRecord = {
2647
2647
  ...existingDeletion,
2648
2648
  anchor: {
2649
- kind: "range",
2650
- range: { from: extendedFrom, to: extendedTo },
2649
+ kind: "range", from: extendedFrom, to: extendedTo,
2651
2650
  assoc: { start: 1, end: -1 },
2652
2651
  },
2653
2652
  };
@@ -2933,7 +2932,7 @@ function findAdjacentInsertionRevision(
2933
2932
  revision.status === "open" &&
2934
2933
  revision.metadata?.source === "runtime" &&
2935
2934
  revision.anchor.kind === "range" &&
2936
- revision.anchor.range.to === cursorPos &&
2935
+ revision.anchor.to === cursorPos &&
2937
2936
  revision.authorId === authorId
2938
2937
  ) {
2939
2938
  return revision;
@@ -2954,8 +2953,8 @@ function findOverlappingAuthoredDeletion(
2954
2953
  revision.metadata?.source === "runtime" &&
2955
2954
  revision.anchor.kind === "range"
2956
2955
  ) {
2957
- const revFrom = revision.anchor.range.from;
2958
- const revTo = revision.anchor.range.to;
2956
+ const revFrom = revision.anchor.from;
2957
+ const revTo = revision.anchor.to;
2959
2958
  // Adjacent or overlapping: the deletion is directly next to or overlapping the range
2960
2959
  if (
2961
2960
  (from >= revFrom && from <= revTo) ||
@@ -79,8 +79,8 @@ export function toPublicAnchorProjection(
79
79
  case "range":
80
80
  return {
81
81
  kind: "range",
82
- from: anchor.range.from,
83
- to: anchor.range.to,
82
+ from: anchor.from,
83
+ to: anchor.to,
84
84
  assoc: anchor.assoc,
85
85
  };
86
86
  case "node":
@@ -66,9 +66,11 @@ export function createRangeAnchor(
66
66
  to = from,
67
67
  assoc: BoundaryAssoc = DEFAULT_BOUNDARY_ASSOC,
68
68
  ): RangeAnchor {
69
+ const normalized = normalizeRange({ from, to });
69
70
  return {
70
71
  kind: "range",
71
- range: normalizeRange({ from, to }),
72
+ from: normalized.from,
73
+ to: normalized.to,
72
74
  assoc,
73
75
  };
74
76
  }
@@ -89,7 +91,7 @@ export function createEmptyMapping(): TransactionMapping {
89
91
 
90
92
  export function getEffectiveRange(anchor: EditorAnchorProjection): DocRange {
91
93
  if (anchor.kind === "range") {
92
- return anchor.range;
94
+ return { from: anchor.from, to: anchor.to };
93
95
  }
94
96
  if (anchor.kind === "node") {
95
97
  return { from: anchor.at, to: anchor.at };
@@ -140,13 +142,13 @@ export function mapAnchor(
140
142
  return createNodeAnchor(mapped.position, anchor.assoc);
141
143
  }
142
144
 
143
- const from = mapPosition(anchor.range.from, anchor.assoc.start, mapping);
144
- const to = mapPosition(anchor.range.to, anchor.assoc.end, mapping);
145
+ const from = mapPosition(anchor.from, anchor.assoc.start, mapping);
146
+ const to = mapPosition(anchor.to, anchor.assoc.end, mapping);
145
147
  const mappedRange = normalizeRange({ from: from.position, to: to.position });
146
148
 
147
149
  if (from.deleted && to.deleted && mappedRange.from === mappedRange.to) {
148
150
  return createDetachedAnchor(
149
- anchor.range,
151
+ { from: anchor.from, to: anchor.to },
150
152
  mapping.metadata?.invalidatesStructures
151
153
  ? "invalidatedByStructureChange"
152
154
  : "deleted",
@@ -187,8 +189,8 @@ export function areAnchorsEqual(
187
189
 
188
190
  if (left.kind === "range" && right.kind === "range") {
189
191
  return (
190
- left.range.from === right.range.from &&
191
- left.range.to === right.range.to &&
192
+ left.from === right.from &&
193
+ left.to === right.to &&
192
194
  left.assoc.start === right.assoc.start &&
193
195
  left.assoc.end === right.assoc.end
194
196
  );
@@ -213,7 +215,7 @@ export function anchorUnaffectedByMapping(
213
215
  ): boolean {
214
216
  if (anchor.kind === "detached") return true;
215
217
  if (mapping.steps.length === 0) return true;
216
- const end = anchor.kind === "range" ? anchor.range.to : anchor.at;
218
+ const end = anchor.kind === "range" ? anchor.to : anchor.at;
217
219
  return mapping.steps.every((s) => s.from > end);
218
220
  }
219
221
 
@@ -40,7 +40,7 @@ export function mapReviewAnchor(
40
40
 
41
41
  export function getAnchorRange(anchor: ReviewAnchor): DocRange {
42
42
  if (anchor.kind === "range") {
43
- return normalizeRange(anchor.range);
43
+ return normalizeRange({ from: anchor.from, to: anchor.to });
44
44
  }
45
45
 
46
46
  if (anchor.kind === "node") {
@@ -136,7 +136,7 @@ export function commentAnchorRejectionReason(
136
136
  return "invalid_comment_anchor";
137
137
  }
138
138
 
139
- const normalized = normalizeRange(anchor.range);
139
+ const normalized = normalizeRange({ from: anchor.from, to: anchor.to });
140
140
  if (normalized.from === normalized.to) {
141
141
  return "invalid_comment_anchor";
142
142
  }
@@ -164,7 +164,7 @@ export function snapCommentAnchorAwayFromTable(
164
164
  ): ReviewAnchor | null {
165
165
  if (anchor.kind !== "range") return null;
166
166
 
167
- const normalized = normalizeRange(anchor.range);
167
+ const normalized = normalizeRange({ from: anchor.from, to: anchor.to });
168
168
  if (normalized.from === normalized.to) return null;
169
169
 
170
170
  // If the anchor is already flagged invalid for other reasons (crosses an
@@ -119,6 +119,7 @@ export class ExportSession {
119
119
  this.packageRelationships,
120
120
  this.ownedPaths,
121
121
  );
122
+ ensurePicNamespaceOnStoryParts(this.workingParts);
122
123
  const parts = pruneReservedManifestParts(this.workingParts);
123
124
  const manifest = buildManifest(parts, this.packageRelationships, this.sourcePackage.manifest.contentTypes);
124
125
  return {
@@ -140,6 +141,58 @@ export function createExportSession(
140
141
  return new ExportSession(sourcePackage, ownedOutputPaths);
141
142
  }
142
143
 
144
+ /**
145
+ * LibreOffice strict-parser safety net. Word tolerates `pic:` prefix
146
+ * usage (from `<pic:pic>` / `<pic:spPr>` inside drawings) without
147
+ * declaring `xmlns:pic` at the part root, because it inherits scope
148
+ * from `<wp:inline xmlns:pic=…>` wrappers or from `mc:AlternateContent`
149
+ * fallbacks. LibreOffice refuses the file ("Namespace prefix pic on
150
+ * spPr is not defined"). Our own serializers already inject
151
+ * `xmlns:pic` on freshly serialized parts — but parts that pass
152
+ * through unmodified from the source package keep the original
153
+ * declaration (which may be missing it). This sweep runs at toPackage
154
+ * time over the main story + every header / footer part, and injects
155
+ * `xmlns:pic` on the root element when the part uses the `pic:` prefix
156
+ * but doesn't declare it. Byte-stable for parts already correct.
157
+ *
158
+ * See commit a3e05a8e (serializer-side fix) + this (pass-through fix).
159
+ */
160
+ const PIC_NAMESPACE_URI = "http://schemas.openxmlformats.org/drawingml/2006/picture";
161
+ const PIC_NAMESPACE_DECL = ` xmlns:pic="${PIC_NAMESPACE_URI}"`;
162
+
163
+ function ensurePicNamespaceOnStoryParts(
164
+ parts: Map<string, OpcPackagePart>,
165
+ ): void {
166
+ for (const [path, part] of parts.entries()) {
167
+ if (!isPicSensitiveStoryPath(path)) continue;
168
+ const patched = ensurePicNamespaceOnRoot(part.bytes);
169
+ if (patched !== part.bytes) {
170
+ parts.set(path, { ...part, bytes: patched, crc32: 0 });
171
+ }
172
+ }
173
+ }
174
+
175
+ function isPicSensitiveStoryPath(path: string): boolean {
176
+ // Normalize for leading slash variation.
177
+ const normalized = path.startsWith("/") ? path.slice(1) : path;
178
+ if (normalized === "word/document.xml") return true;
179
+ if (/^word\/(?:header|footer|footnotes|endnotes)[^/]*\.xml$/.test(normalized)) return true;
180
+ return false;
181
+ }
182
+
183
+ function ensurePicNamespaceOnRoot(bytes: Uint8Array): Uint8Array {
184
+ const xml = new TextDecoder().decode(bytes);
185
+ // Match the first element (skipping the XML prolog + whitespace).
186
+ const rootMatch = xml.match(/^([\s\S]*?)<([A-Za-z][\w.:-]*)([^>]*)>/);
187
+ if (!rootMatch) return bytes;
188
+ const [fullMatch, prefix, tagName, rootAttrs] = rootMatch;
189
+ if (rootAttrs.includes("xmlns:pic=")) return bytes; // already declared
190
+ if (!/\bpic:[A-Za-z]/.test(xml)) return bytes; // doesn't use pic: prefix
191
+ const patchedRoot = `<${tagName}${rootAttrs}${PIC_NAMESPACE_DECL}>`;
192
+ const patched = `${prefix}${patchedRoot}${xml.slice(fullMatch.length)}`;
193
+ return new TextEncoder().encode(patched);
194
+ }
195
+
143
196
  function clonePartsForExport(
144
197
  parts: Map<string, OpcPackagePart>,
145
198
  ownedPaths: ReadonlySet<string>,
@@ -269,16 +269,16 @@ export function serializeCommentAnchorsIntoDocumentXml(
269
269
  continue;
270
270
  }
271
271
 
272
- const startParagraph = findParagraphForEndpoint(paragraphs, anchor.range.from, "start");
273
- const endParagraph = findParagraphForEndpoint(paragraphs, anchor.range.to, "end");
272
+ const startParagraph = findParagraphForEndpoint(paragraphs, anchor.from, "start");
273
+ const endParagraph = findParagraphForEndpoint(paragraphs, anchor.to, "end");
274
274
 
275
275
  if (!startParagraph || !endParagraph) {
276
276
  skippedCommentIds.push(thread.commentId);
277
277
  continue;
278
278
  }
279
279
 
280
- const startIndex = startParagraph.boundaries.get(anchor.range.from);
281
- const endIndex = endParagraph.boundaries.get(anchor.range.to);
280
+ const startIndex = startParagraph.boundaries.get(anchor.from);
281
+ const endIndex = endParagraph.boundaries.get(anchor.to);
282
282
 
283
283
  if (startIndex === undefined || endIndex === undefined) {
284
284
  skippedCommentIds.push(thread.commentId);
@@ -135,8 +135,8 @@ function createParagraphPropertyChangeReplacement(
135
135
  }
136
136
  const paragraphBoundary = findParagraphBoundaryForRange(
137
137
  boundaries,
138
- revision.anchor.range.from,
139
- revision.anchor.range.to,
138
+ revision.anchor.from,
139
+ revision.anchor.to,
140
140
  );
141
141
  if (!paragraphBoundary) {
142
142
  return undefined;
@@ -168,14 +168,14 @@ function createRunPropertyChangeReplacement(
168
168
  }
169
169
  const paragraphBoundary = findParagraphBoundaryForRange(
170
170
  boundaries,
171
- revision.anchor.range.from,
172
- revision.anchor.range.to,
171
+ revision.anchor.from,
172
+ revision.anchor.to,
173
173
  );
174
174
  if (!paragraphBoundary) {
175
175
  return undefined;
176
176
  }
177
- const startIndex = paragraphBoundary.boundaries.get(revision.anchor.range.from);
178
- const endIndex = paragraphBoundary.boundaries.get(revision.anchor.range.to);
177
+ const startIndex = paragraphBoundary.boundaries.get(revision.anchor.from);
178
+ const endIndex = paragraphBoundary.boundaries.get(revision.anchor.to);
179
179
  if (startIndex === undefined || endIndex === undefined || endIndex < startIndex) {
180
180
  return undefined;
181
181
  }
@@ -245,13 +245,13 @@ function createRangeRevisionReplacement(
245
245
  if (anchor.kind !== "range") {
246
246
  return undefined;
247
247
  }
248
- const paragraphBoundary = findParagraphBoundaryForRange(boundaries, anchor.range.from, anchor.range.to);
248
+ const paragraphBoundary = findParagraphBoundaryForRange(boundaries, anchor.from, anchor.to);
249
249
  if (!paragraphBoundary) {
250
250
  return undefined;
251
251
  }
252
252
 
253
- const startIndex = paragraphBoundary.boundaries.get(anchor.range.from);
254
- const endIndex = paragraphBoundary.boundaries.get(anchor.range.to);
253
+ const startIndex = paragraphBoundary.boundaries.get(anchor.from);
254
+ const endIndex = paragraphBoundary.boundaries.get(anchor.to);
255
255
  if (startIndex === undefined || endIndex === undefined || endIndex < startIndex) {
256
256
  return undefined;
257
257
  }
@@ -340,7 +340,7 @@ function findParagraphBoundaryForAnchor(
340
340
  boundaries: readonly RevisionParagraphBoundary[],
341
341
  revision: RevisionRecord,
342
342
  ): RevisionParagraphBoundary | undefined {
343
- const anchor = revision.anchor.kind === "range" ? revision.anchor.range.from : undefined;
343
+ const anchor = revision.anchor.kind === "range" ? revision.anchor.from : undefined;
344
344
  if (anchor === undefined) {
345
345
  return undefined;
346
346
  }
@@ -35,8 +35,8 @@ function collectSplitPositions(
35
35
  continue;
36
36
  }
37
37
 
38
- positions.add(thread.anchor.range.from);
39
- positions.add(thread.anchor.range.to);
38
+ positions.add(thread.anchor.from);
39
+ positions.add(thread.anchor.to);
40
40
  }
41
41
 
42
42
  for (const revision of revisions) {
@@ -51,8 +51,8 @@ function collectSplitPositions(
51
51
  continue;
52
52
  }
53
53
 
54
- positions.add(revision.anchor.range.from);
55
- positions.add(revision.anchor.range.to);
54
+ positions.add(revision.anchor.from);
55
+ positions.add(revision.anchor.to);
56
56
  }
57
57
 
58
58
  return positions;
@@ -200,8 +200,8 @@ function collectSplitPoints(
200
200
  ): number[] {
201
201
  const points = new Set<number>();
202
202
  for (const revision of revisions) {
203
- const from = revision.anchor.range.from;
204
- const to = revision.anchor.range.to;
203
+ const from = revision.anchor.from;
204
+ const to = revision.anchor.to;
205
205
  if (from > start && from < end) {
206
206
  points.add(from);
207
207
  }
@@ -652,9 +652,9 @@ function extractNodeText(node: XmlNode): string {
652
652
 
653
653
  function compareThreadsByAnchor(left: CommentThread, right: CommentThread): number {
654
654
  const leftStart =
655
- left.anchor.kind === "range" ? left.anchor.range.from : Number.MAX_SAFE_INTEGER;
655
+ left.anchor.kind === "range" ? left.anchor.from : Number.MAX_SAFE_INTEGER;
656
656
  const rightStart =
657
- right.anchor.kind === "range" ? right.anchor.range.from : Number.MAX_SAFE_INTEGER;
657
+ right.anchor.kind === "range" ? right.anchor.from : Number.MAX_SAFE_INTEGER;
658
658
 
659
659
  if (leftStart !== rightStart) {
660
660
  return leftStart - rightStart;
@@ -25,9 +25,17 @@ export interface BoundaryAssoc {
25
25
  end: Assoc;
26
26
  }
27
27
 
28
+ /**
29
+ * Range anchor — flat shape `{ kind: "range", from, to, assoc }` after the
30
+ * 2026-04-23 flat-wins collapse. Prior to the collapse this carried a nested
31
+ * `{ range: DocRange, assoc }` shape. See `canonical-document.ts` CanonicalAnchor
32
+ * for the matching canonical shape + `repairCanonicalDocumentEnvelope` for the
33
+ * persisted-snapshot legacy-to-flat migration.
34
+ */
28
35
  export interface RangeAnchor {
29
36
  kind: "range";
30
- range: DocRange;
37
+ from: Position;
38
+ to: Position;
31
39
  assoc: BoundaryAssoc;
32
40
  }
33
41
 
@@ -2013,7 +2013,8 @@ export interface BoundaryAssoc {
2013
2013
  export type CanonicalAnchor =
2014
2014
  | {
2015
2015
  kind: "range";
2016
- range: DocRange;
2016
+ from: number;
2017
+ to: number;
2017
2018
  assoc: BoundaryAssoc;
2018
2019
  }
2019
2020
  | {
@@ -2329,7 +2330,7 @@ export function repairCanonicalDocumentEnvelope(
2329
2330
  review:
2330
2331
  record.review === undefined
2331
2332
  ? base.review
2332
- : (record.review as CanonicalDocument["review"]),
2333
+ : (migrateLegacyReviewAnchors(record.review) as CanonicalDocument["review"]),
2333
2334
  preservation:
2334
2335
  record.preservation === undefined
2335
2336
  ? base.preservation
@@ -2355,6 +2356,67 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
2355
2356
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
2356
2357
  }
2357
2358
 
2359
+ /**
2360
+ * Schema-compat shim for the 2026-04-23 flat-wins anchor collapse. Legacy
2361
+ * persisted snapshots carry range anchors as `{kind: "range", range: {from, to},
2362
+ * assoc}`. Rewrite those to the flat shape `{kind: "range", from, to, assoc}`
2363
+ * on rehydration so old snapshots continue to load.
2364
+ */
2365
+ export function migrateLegacyReviewAnchors(review: unknown): unknown {
2366
+ if (!isPlainRecord(review)) {
2367
+ return review;
2368
+ }
2369
+ const migrated: Record<string, unknown> = { ...review };
2370
+ if (isPlainRecord(review.comments)) {
2371
+ migrated.comments = Object.fromEntries(
2372
+ Object.entries(review.comments).map(([id, record]) => [
2373
+ id,
2374
+ migrateReviewRecordAnchor(record),
2375
+ ]),
2376
+ );
2377
+ }
2378
+ if (isPlainRecord(review.revisions)) {
2379
+ migrated.revisions = Object.fromEntries(
2380
+ Object.entries(review.revisions).map(([id, record]) => [
2381
+ id,
2382
+ migrateReviewRecordAnchor(record),
2383
+ ]),
2384
+ );
2385
+ }
2386
+ return migrated;
2387
+ }
2388
+
2389
+ function migrateReviewRecordAnchor(record: unknown): unknown {
2390
+ if (!isPlainRecord(record)) {
2391
+ return record;
2392
+ }
2393
+ const anchor = record.anchor;
2394
+ if (!isPlainRecord(anchor) || anchor.kind !== "range") {
2395
+ return record;
2396
+ }
2397
+ if (typeof anchor.from === "number" && typeof anchor.to === "number") {
2398
+ return record;
2399
+ }
2400
+ const nested = anchor.range;
2401
+ if (
2402
+ isPlainRecord(nested) &&
2403
+ typeof nested.from === "number" &&
2404
+ typeof nested.to === "number"
2405
+ ) {
2406
+ const { range: _range, ...rest } = anchor;
2407
+ void _range;
2408
+ return {
2409
+ ...record,
2410
+ anchor: {
2411
+ ...rest,
2412
+ from: nested.from,
2413
+ to: nested.to,
2414
+ },
2415
+ };
2416
+ }
2417
+ return record;
2418
+ }
2419
+
2358
2420
  export function serializeCanonicalDocument(document: CanonicalDocument): string {
2359
2421
  assertCanonicalDocument(document);
2360
2422
  return stableStringify(document);
@@ -3507,7 +3569,18 @@ function validateAnchor(
3507
3569
  }
3508
3570
 
3509
3571
  if (kind === "range") {
3510
- validateRange(record.range, `${path}.range`, issues);
3572
+ if (typeof record.from !== "number") {
3573
+ issues.push({
3574
+ path: `${path}.from`,
3575
+ message: "range anchor from must be a number.",
3576
+ });
3577
+ }
3578
+ if (typeof record.to !== "number") {
3579
+ issues.push({
3580
+ path: `${path}.to`,
3581
+ message: "range anchor to must be a number.",
3582
+ });
3583
+ }
3511
3584
  validateBoundaryAssoc(record.assoc, `${path}.assoc`, issues);
3512
3585
  } else if (kind === "node") {
3513
3586
  if (typeof record.at !== "number") {
@@ -170,6 +170,15 @@ export function describeStructuredWrapperBlock(
170
170
  };
171
171
  }
172
172
  if (block.properties.sdtType === "docPartObj") {
173
+ // coord-02 §11 P0 — a Template content control that wraps a
174
+ // drawing_frame (CCEP header logos, cover hero photos) must stay
175
+ // recursable so surface-projection emits the inner image segment.
176
+ // Collapsing to opaque_block here silently drops every image
177
+ // inside a docPartObj SDT. Only collapse when the content is
178
+ // genuinely wrapper-heavy with no user-visible media.
179
+ if (sdtContainsDrawingFrame(block)) {
180
+ return null;
181
+ }
173
182
  return {
174
183
  featureKey: "content-controls",
175
184
  label: "Template content control",
@@ -220,6 +229,21 @@ function createDetail(fragment: OpaqueFragmentRecord): string {
220
229
  : "Preserved whole-unit to keep unsupported OOXML intact.";
221
230
  }
222
231
 
232
+ function sdtContainsDrawingFrame(block: Extract<BlockNode, { type: "sdt" }>): boolean {
233
+ const visit = (node: unknown): boolean => {
234
+ if (!node || typeof node !== "object") return false;
235
+ const n = node as { type?: string; children?: readonly unknown[] };
236
+ if (n.type === "drawing_frame") return true;
237
+ if (Array.isArray(n.children)) {
238
+ for (const child of n.children) {
239
+ if (visit(child)) return true;
240
+ }
241
+ }
242
+ return false;
243
+ };
244
+ return block.children.some(visit);
245
+ }
246
+
223
247
  function isTocContentControl(block: Extract<BlockNode, { type: "sdt" }>): boolean {
224
248
  const searchText = [
225
249
  block.properties.alias,
@@ -47,7 +47,7 @@ export function summarizeCommentAnchor(anchor: CommentAnchor): CommentAnchorSumm
47
47
  return {
48
48
  anchor,
49
49
  state: "active",
50
- range: normalizeRange(anchor.range),
50
+ range: normalizeRange({ from: anchor.from, to: anchor.to }),
51
51
  };
52
52
  }
53
53
 
@@ -98,7 +98,7 @@ function normalizeCommentAnchor(
98
98
 
99
99
  if (
100
100
  mappedAnchor.kind === "range" &&
101
- !rangeStaysWithinCommentableStory(nextContent, mappedAnchor.range)
101
+ !rangeStaysWithinCommentableStory(nextContent, { from: mappedAnchor.from, to: mappedAnchor.to })
102
102
  ) {
103
103
  return detachReviewAnchor(previousRange, "invalidatedByStructureChange");
104
104
  }
@@ -153,8 +153,8 @@ export function applyRevisionAction(
153
153
 
154
154
  const story = parseTextStory(options.document.content);
155
155
  const range = normalizeRange(
156
- revision.anchor.range.from,
157
- revision.anchor.range.to,
156
+ revision.anchor.from,
157
+ revision.anchor.to,
158
158
  );
159
159
 
160
160
  if (range.to > story.size) {
@@ -811,8 +811,8 @@ function resolveParagraphMarkDeletionRange(
811
811
 
812
812
  const paragraphs = mapParagraphRanges(story);
813
813
  const anchorPosition = normalizeRange(
814
- revision.anchor.range.from,
815
- revision.anchor.range.to,
814
+ revision.anchor.from,
815
+ revision.anchor.to,
816
816
  ).from;
817
817
  const paragraph = paragraphs.find(
818
818
  (candidate) =>
@@ -77,7 +77,7 @@ export function summarizeRevisionAnchor(anchor: RevisionAnchor): RevisionAnchorS
77
77
  return {
78
78
  anchor,
79
79
  state: "active",
80
- range: normalizeRange(anchor.range),
80
+ range: normalizeRange({ from: anchor.from, to: anchor.to }),
81
81
  };
82
82
  }
83
83
 
@@ -96,7 +96,7 @@ export function collectScopeTagTouches(
96
96
 
97
97
  function anchorRange(anchor: CanonicalAnchor): { from: number; to: number } {
98
98
  if (anchor.kind === "range") {
99
- return { from: anchor.range.from, to: anchor.range.to };
99
+ return { from: anchor.from, to: anchor.to };
100
100
  }
101
101
  if (anchor.kind === "node") {
102
102
  return { from: anchor.at, to: anchor.at };
@@ -88,10 +88,10 @@ export function mapLocalSelectionOnRemoteReplay(
88
88
 
89
89
  if (active.kind === "range") {
90
90
  const directionForward = selection.anchor <= selection.head;
91
- const isCollapsed = active.range.from === active.range.to;
91
+ const isCollapsed = active.from === active.to;
92
92
 
93
93
  if (isCollapsed) {
94
- const collapsedAt = mapPosition(active.range.from, 1, mapping).position;
94
+ const collapsedAt = mapPosition(active.from, 1, mapping).position;
95
95
  return {
96
96
  anchor: collapsedAt,
97
97
  head: collapsedAt,
@@ -107,7 +107,7 @@ export function mapLocalSelectionOnRemoteReplay(
107
107
  const mapped = mapAnchor(active, mapping);
108
108
 
109
109
  if (mapped.kind === "range") {
110
- const { from, to } = mapped.range;
110
+ const { from, to } = mapped;
111
111
  return {
112
112
  anchor: directionForward ? from : to,
113
113
  head: directionForward ? to : from,
@@ -122,7 +122,7 @@ export function mapLocalSelectionOnRemoteReplay(
122
122
  // positions that land inside a deleted span to `step.from +
123
123
  // insertSize`, which is the correct logical "where the selection
124
124
  // used to start" position.
125
- const collapsedAt = mapPosition(active.range.from, 1, mapping).position;
125
+ const collapsedAt = mapPosition(active.from, 1, mapping).position;
126
126
  return {
127
127
  anchor: collapsedAt,
128
128
  head: collapsedAt,
@@ -147,9 +147,9 @@ export function mapLocalSelectionOnRemoteReplay(
147
147
  }
148
148
  if (mapped.kind === "range") {
149
149
  return {
150
- anchor: mapped.range.from,
151
- head: mapped.range.to,
152
- isCollapsed: mapped.range.from === mapped.range.to,
150
+ anchor: mapped.from,
151
+ head: mapped.to,
152
+ isCollapsed: mapped.from === mapped.to,
153
153
  activeRange: mapped,
154
154
  };
155
155
  }