@beyondwork/docx-react-component 1.0.73 → 1.0.75

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 (59) hide show
  1. package/package.json +1 -1
  2. package/src/api/anchor-conversion.ts +2 -2
  3. package/src/api/public-types.ts +40 -6
  4. package/src/api/v3/_runtime-handle.ts +15 -0
  5. package/src/api/v3/runtime/workflow.ts +130 -1
  6. package/src/api/v3/ui/_types.ts +21 -0
  7. package/src/api/v3/ui/overlays.ts +276 -2
  8. package/src/api/v3/ui/scope.ts +113 -1
  9. package/src/compare/diff-engine.ts +1 -2
  10. package/src/core/commands/index.ts +14 -15
  11. package/src/core/selection/anchor-conversion.ts +2 -2
  12. package/src/core/selection/mapping.ts +10 -8
  13. package/src/core/selection/review-anchors.ts +3 -3
  14. package/src/io/export/export-session.ts +53 -0
  15. package/src/io/export/serialize-comments.ts +4 -4
  16. package/src/io/export/serialize-runtime-revisions.ts +10 -10
  17. package/src/io/export/split-review-boundaries.ts +4 -4
  18. package/src/io/export/split-story-blocks-for-runtime-revisions.ts +2 -2
  19. package/src/io/ooxml/parse-comments.ts +2 -2
  20. package/src/io/ooxml/parse-headers-footers.ts +7 -13
  21. package/src/io/ooxml/parse-main-document.ts +7 -31
  22. package/src/io/ooxml/table-opaque-preservation.ts +171 -0
  23. package/src/model/anchor.ts +9 -1
  24. package/src/model/canonical-document.ts +76 -3
  25. package/src/preservation/store.ts +24 -0
  26. package/src/review/store/comment-anchors.ts +1 -1
  27. package/src/review/store/comment-remapping.ts +1 -1
  28. package/src/review/store/revision-actions.ts +4 -4
  29. package/src/review/store/revision-types.ts +1 -1
  30. package/src/review/store/scope-tag-diff.ts +1 -1
  31. package/src/runtime/collab/map-local-selection-on-remote-replay.ts +7 -7
  32. package/src/runtime/document-runtime.ts +233 -38
  33. package/src/runtime/formatting/formatting-context.ts +1 -1
  34. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  35. package/src/runtime/layout/layout-engine-version.ts +9 -1
  36. package/src/runtime/layout/public-facet.ts +27 -0
  37. package/src/runtime/scopes/evidence.ts +1 -1
  38. package/src/runtime/scopes/review-bundle.ts +1 -1
  39. package/src/runtime/scopes/scope-range.ts +1 -1
  40. package/src/runtime/selection/post-edit-validator.ts +4 -4
  41. package/src/runtime/surface-projection.ts +48 -4
  42. package/src/runtime/workflow/scope-writer.ts +212 -10
  43. package/src/session/import/review-import.ts +12 -12
  44. package/src/session/import/workflow-scope-import.ts +9 -8
  45. package/src/shell/session-bootstrap.ts +4 -0
  46. package/src/ui-tailwind/editor-surface/pm-schema.ts +22 -2
  47. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +5 -2
  48. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +22 -3
  49. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +7 -3
  50. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +5 -1
  51. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +5 -1
  52. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +7 -5
  53. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +99 -43
  54. package/src/ui-tailwind/review-workspace/use-page-markers.ts +48 -7
  55. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +0 -13
  56. package/src/ui-tailwind/tw-review-workspace.tsx +13 -41
  57. package/src/validation/compatibility-engine.ts +1 -1
  58. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +0 -114
  59. package/src/ui-tailwind/review-workspace/tw-review-workspace-page-toolbar.tsx +0 -240
@@ -33,9 +33,92 @@
33
33
  */
34
34
 
35
35
  import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
36
- import type { ScopeBundle } from "../../public-types.ts";
36
+ import type {
37
+ ScopeBundle,
38
+ ScopeCardModel,
39
+ ScopeRailSnapshot,
40
+ SemanticScope,
41
+ SemanticScopeKind,
42
+ } from "../../public-types.ts";
37
43
  import type { UiApiContext } from "./_context.ts";
38
44
 
45
+ export interface UiScopeListFilter {
46
+ readonly kind?: SemanticScopeKind;
47
+ readonly limit?: number;
48
+ }
49
+
50
+ export interface UiScopeRailOptions {
51
+ /**
52
+ * Narrow the snapshot to a single page index. Omit (default) to
53
+ * span every page the runtime's page graph knows about.
54
+ */
55
+ readonly pageIndex?: number;
56
+ }
57
+
58
+ export const cardMetadata: ApiV3FnMetadata = {
59
+ name: "ui.scope.card",
60
+ status: "live-with-adapter",
61
+ sourceLayer: "presentation",
62
+ liveEvidence: {
63
+ runnerTest: "test/api/v3/ui/scope-card-rail.test.ts",
64
+ commit: "refactor-10-ki-008-ui-scope-card-rail",
65
+ },
66
+ uxIntent: { uiVisible: false },
67
+ agentMetadata: {
68
+ readOrMutate: "read",
69
+ boundedScope: "scope",
70
+ auditCategory: "ui-scope-card-read",
71
+ },
72
+ // Same classification as other `ui.scope.*` reads — canonical scope
73
+ // projection (not class-C local preference). KI-008 close.
74
+ stateClass: "A-canonical",
75
+ persistsTo: "canonical",
76
+ rwdReference:
77
+ "§UI API § ui.scope.card. Wraps handle.compileScopeCardById(scopeId) which projects runtime.workflow.getAllScopeCardModels() filtered to the matching scope. Returns plain-value ScopeCardModel — no React handlers, no DOM refs. Hosts wire event handlers in their own chrome tree.",
78
+ };
79
+
80
+ export const railMetadata: ApiV3FnMetadata = {
81
+ name: "ui.scope.rail",
82
+ status: "live-with-adapter",
83
+ sourceLayer: "presentation",
84
+ liveEvidence: {
85
+ runnerTest: "test/api/v3/ui/scope-card-rail.test.ts",
86
+ commit: "refactor-10-ki-008-ui-scope-card-rail",
87
+ },
88
+ uxIntent: { uiVisible: false },
89
+ agentMetadata: {
90
+ readOrMutate: "read",
91
+ boundedScope: "document",
92
+ auditCategory: "ui-scope-rail-read",
93
+ },
94
+ stateClass: "A-canonical",
95
+ persistsTo: "canonical",
96
+ rwdReference:
97
+ "§UI API § ui.scope.rail. Wraps handle.compileScopeRailSnapshot({pageIndex?}) which projects runtime.workflow.getRailSegments(pageIndex) / getAllRailSegments() into a ScopeRailSnapshot envelope. Plain-value data; no React handlers.",
98
+ };
99
+
100
+ export const listMetadata: ApiV3FnMetadata = {
101
+ name: "ui.scope.list",
102
+ status: "live-with-adapter",
103
+ sourceLayer: "presentation",
104
+ liveEvidence: {
105
+ runnerTest: "test/api/v3/ui/scope-list.test.ts",
106
+ commit: "refactor-10-ki-008-ui-scope-list",
107
+ },
108
+ uxIntent: { uiVisible: false },
109
+ agentMetadata: {
110
+ readOrMutate: "read",
111
+ boundedScope: "document",
112
+ auditCategory: "ui-scope-list",
113
+ },
114
+ // Same classification as `ui.scope.getBundle` — reads canonical
115
+ // scope state (not class-C local preference). KI-008 closure.
116
+ stateClass: "A-canonical",
117
+ persistsTo: "canonical",
118
+ rwdReference:
119
+ "§UI API § ui.scope.list. Wraps handle.compileScopeList(filter?) to enumerate compiled SemanticScope records through the L10 seam without importing src/runtime/scopes/**. Filter matches ai.listScopes({kind?, limit?}) for identity-set symmetry.",
120
+ };
121
+
39
122
  export const getBundleMetadata: ApiV3FnMetadata = {
40
123
  name: "ui.scope.getBundle",
41
124
  status: "live-with-adapter",
@@ -67,5 +150,34 @@ export function createScopeFamily(ctx: UiApiContext) {
67
150
  const nowUtc = new Date().toISOString();
68
151
  return compile.call(ctx.handle, scopeId, nowUtc);
69
152
  },
153
+ list(filter?: UiScopeListFilter): readonly SemanticScope[] {
154
+ // KI-008 — enumeration through the handle's batch primitive
155
+ // (L10 purity blocks importing src/runtime/scopes/**). Returns
156
+ // a fresh array so callers that sort / splice don't leak into
157
+ // internal compiler state.
158
+ const enumerate = ctx.handle.compileScopeList;
159
+ if (typeof enumerate !== "function") return Object.freeze([]);
160
+ return enumerate.call(ctx.handle, filter);
161
+ },
162
+ card(scopeId: string): ScopeCardModel | null {
163
+ // KI-008 close — single-scope presentation projection. Returns
164
+ // a plain-value ScopeCardModel (posture, label, issue, suggestion
165
+ // groups, review actions, agent-pending flag). Host chrome wires
166
+ // event handlers around it.
167
+ const compile = ctx.handle.compileScopeCardById;
168
+ if (typeof compile !== "function") return null;
169
+ return compile.call(ctx.handle, scopeId);
170
+ },
171
+ rail(options?: UiScopeRailOptions): ScopeRailSnapshot {
172
+ // KI-008 close — rail snapshot. When `options.pageIndex` is
173
+ // supplied, narrows to that page; otherwise spans every page
174
+ // in the runtime's page graph. Envelope shape is stable either
175
+ // way; callers check `snapshot.pageIndex` to detect narrowing.
176
+ const compile = ctx.handle.compileScopeRailSnapshot;
177
+ if (typeof compile !== "function") {
178
+ return { segments: Object.freeze([]) };
179
+ }
180
+ return compile.call(ctx.handle, options);
181
+ },
70
182
  };
71
183
  }
@@ -410,8 +410,7 @@ function createParagraphRevisionRecords(
410
410
  changeId: revision.changeId,
411
411
  kind: revision.kind,
412
412
  anchor: {
413
- kind: "range",
414
- range: { from: position, to: position },
413
+ kind: "range", from: position, to: position,
415
414
  assoc: { start: -1, end: 1 },
416
415
  },
417
416
  authorId,
@@ -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;
@@ -17,6 +17,7 @@ import { resolveHighlightColor } from "./highlight-colors.ts";
17
17
  import type { ParseDrawingOpts } from "./parse-drawing.ts";
18
18
  import { parseFFDataFromFldChar } from "./parse-ffdata.ts";
19
19
  import { classifyFieldInstruction } from "./parse-fields.ts";
20
+ import { isSafeTableFieldInstruction } from "./table-opaque-preservation.ts";
20
21
  import { parseXmlWithOffsets as parseXml } from "./xml-parser.ts";
21
22
  import { localName, readStringAttr } from "./xml-attr-helpers.ts";
22
23
  import {
@@ -1018,8 +1019,12 @@ function containsRiskyElement(element: XmlElementNode): boolean {
1018
1019
  const instruction =
1019
1020
  readStringAttr(child, "w:instr") ??
1020
1021
  extractTextContent(child);
1021
- const classification = classifyFieldInstruction(instruction);
1022
- if (!isSafeSecondaryStoryFieldFamily(classification.family)) {
1022
+ // Coord-01 §11 unification (2026-04-24): the field-safety check
1023
+ // is delegated to the shared `isSafeTableFieldInstruction` helper
1024
+ // so body-direct + secondary-story tables apply the same
1025
+ // allowlist. Legacy form fields (FORMTEXT/FORMCHECKBOX/
1026
+ // FORMDROPDOWN) now pass here just like in main-story tables.
1027
+ if (!isSafeTableFieldInstruction(instruction)) {
1023
1028
  return true;
1024
1029
  }
1025
1030
  continue;
@@ -1041,17 +1046,6 @@ function containsRiskyElement(element: XmlElementNode): boolean {
1041
1046
  return false;
1042
1047
  }
1043
1048
 
1044
- function isSafeSecondaryStoryFieldFamily(family: string): boolean {
1045
- return (
1046
- family === "REF" ||
1047
- family === "PAGEREF" ||
1048
- family === "NOTEREF" ||
1049
- family === "TOC" ||
1050
- family === "PAGE" ||
1051
- family === "NUMPAGES"
1052
- );
1053
- }
1054
-
1055
1049
  function parseSimpleTableElement(
1056
1050
  tblElement: XmlElementNode,
1057
1051
  sourceXml: string,
@@ -41,6 +41,7 @@ import { parseShapeXml, parseVmlXml } from "./parse-shapes.ts";
41
41
  import { parseObject } from "./parse-object.ts";
42
42
  import { parseDrawingFrame } from "./parse-drawing.ts";
43
43
  import { readFrameProperties } from "./parse-paragraph-formatting.ts";
44
+ import { tableRequiresOpaquePreservation } from "./table-opaque-preservation.ts";
44
45
  import { classifyFieldInstruction } from "./parse-fields.ts";
45
46
  import { parseFFDataFromFldChar } from "./parse-ffdata.ts";
46
47
  import { resolveHighlightColor } from "./highlight-colors.ts";
@@ -2020,37 +2021,12 @@ function readCellCnfStyle(node: XmlElementNode): string | undefined {
2020
2021
  * Tables matching this check stay opaque until the respective features
2021
2022
  * are implemented in the table editing path.
2022
2023
  */
2023
- function tableRequiresOpaquePreservation(rawXml: string): boolean {
2024
- // Safe table-local content now includes hyperlinks, bookmarks, comments,
2025
- // nested tables, floating images, VML preview atoms, and bounded field
2026
- // families already owned by the current field slice. Risky table-local
2027
- // semantics still fail closed to preserve-only.
2028
- if (/<w:(ins|del|rPrChange|pPrChange|tblPrChange|trPrChange|tcPrChange|sectPrChange|cellIns|cellDel|cellMerge|smartTag)\b/.test(rawXml)) {
2029
- return true;
2030
- }
2031
-
2032
- const simpleInstructions = [...rawXml.matchAll(/\bw:instr="([^"]*)"/g)].map((match) => match[1] ?? "");
2033
- const complexInstructions = extractComplexFieldInstructions(rawXml);
2034
- for (const instruction of [...simpleInstructions, ...complexInstructions]) {
2035
- const classification = classifyFieldInstruction(instruction);
2036
- if (!isSafeMainStoryTableFieldFamily(classification.family)) {
2037
- return true;
2038
- }
2039
- }
2040
-
2041
- return false;
2042
- }
2043
-
2044
- function isSafeMainStoryTableFieldFamily(family: string): boolean {
2045
- return (
2046
- family === "REF" ||
2047
- family === "PAGEREF" ||
2048
- family === "NOTEREF" ||
2049
- family === "TOC" ||
2050
- family === "PAGE" ||
2051
- family === "NUMPAGES"
2052
- );
2053
- }
2024
+ // `tableRequiresOpaquePreservation` now lives in
2025
+ // `src/io/ooxml/table-opaque-preservation.ts` as a shared helper used by
2026
+ // `parse-main-document.ts`, `parse-headers-footers.ts`, and
2027
+ // `parse-footnotes.ts`. Unified 2026-04-24 per coord-01 §11 to stop the
2028
+ // three parsers from drifting out of alignment. The top-of-file import
2029
+ // `tableRequiresOpaquePreservation` points at that module.
2054
2030
 
2055
2031
  function extractComplexFieldInstructions(rawXml: string): string[] {
2056
2032
  const tokenPattern =