@beyondwork/docx-react-component 1.0.58 → 1.0.59

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 (134) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -1
  3. package/src/api/awareness-identity-types.ts +4 -2
  4. package/src/api/comment-negotiation-types.ts +4 -1
  5. package/src/api/external-custody-types.ts +16 -0
  6. package/src/api/internal/build-ref-projections.ts +108 -0
  7. package/src/api/package-version.ts +1 -1
  8. package/src/api/participants-types.ts +11 -1
  9. package/src/api/public-types.ts +978 -10
  10. package/src/api/scope-metadata-resolver-types.ts +6 -0
  11. package/src/compare/diff-engine.ts +3 -0
  12. package/src/core/commands/formatting-commands.ts +1 -0
  13. package/src/core/commands/index.ts +225 -16
  14. package/src/core/commands/legacy-form-field-commands.ts +181 -0
  15. package/src/core/commands/table-structure-commands.ts +149 -31
  16. package/src/core/selection/mapping.ts +20 -0
  17. package/src/core/state/editor-state.ts +2 -1
  18. package/src/index.ts +28 -0
  19. package/src/io/docx-session.ts +22 -3
  20. package/src/io/export/export-session.ts +11 -7
  21. package/src/io/export/ooxml-namespaces.ts +47 -0
  22. package/src/io/export/reattach-preserved-parts.ts +4 -16
  23. package/src/io/export/serialize-comments.ts +3 -131
  24. package/src/io/export/serialize-ffdata.ts +89 -0
  25. package/src/io/export/serialize-headers-footers.ts +5 -0
  26. package/src/io/export/serialize-main-document.ts +224 -34
  27. package/src/io/export/serialize-numbering.ts +22 -2
  28. package/src/io/export/serialize-revisions.ts +99 -0
  29. package/src/io/export/serialize-tables.ts +9 -0
  30. package/src/io/export/split-review-boundaries.ts +1 -0
  31. package/src/io/export/table-properties-xml.ts +14 -0
  32. package/src/io/load-scheduler.ts +70 -28
  33. package/src/io/normalize/normalize-text.ts +13 -0
  34. package/src/io/ooxml/_mini-xml.ts +198 -0
  35. package/src/io/ooxml/canonicalize-payload.ts +1 -4
  36. package/src/io/ooxml/chart/chart-style-table.ts +4 -3
  37. package/src/io/ooxml/chart/parse-chart-space.ts +2 -4
  38. package/src/io/ooxml/chart/parse-series.ts +2 -1
  39. package/src/io/ooxml/chart/resolve-color.ts +2 -2
  40. package/src/io/ooxml/chart/types.ts +6 -434
  41. package/src/io/ooxml/comment-presentation-payload.ts +6 -5
  42. package/src/io/ooxml/highlight-colors.ts +8 -5
  43. package/src/io/ooxml/parse-anchor.ts +68 -53
  44. package/src/io/ooxml/parse-comments.ts +14 -142
  45. package/src/io/ooxml/parse-complex-content.ts +3 -106
  46. package/src/io/ooxml/parse-drawing.ts +100 -195
  47. package/src/io/ooxml/parse-ffdata.ts +93 -0
  48. package/src/io/ooxml/parse-fields.ts +7 -146
  49. package/src/io/ooxml/parse-fill.ts +88 -8
  50. package/src/io/ooxml/parse-font-table.ts +5 -105
  51. package/src/io/ooxml/parse-footnotes.ts +28 -152
  52. package/src/io/ooxml/parse-headers-footers.ts +106 -212
  53. package/src/io/ooxml/parse-inline-media.ts +3 -200
  54. package/src/io/ooxml/parse-main-document.ts +180 -217
  55. package/src/io/ooxml/parse-numbering.ts +154 -335
  56. package/src/io/ooxml/parse-object.ts +147 -0
  57. package/src/io/ooxml/parse-ole-relationship.ts +82 -0
  58. package/src/io/ooxml/parse-paragraph-formatting.ts +7 -10
  59. package/src/io/ooxml/parse-picture-sdt.ts +85 -0
  60. package/src/io/ooxml/parse-picture.ts +72 -42
  61. package/src/io/ooxml/parse-revisions.ts +285 -51
  62. package/src/io/ooxml/parse-settings.ts +6 -99
  63. package/src/io/ooxml/parse-shapes.ts +25 -140
  64. package/src/io/ooxml/parse-styles.ts +3 -218
  65. package/src/io/ooxml/parse-tables.ts +76 -256
  66. package/src/io/ooxml/parse-theme.ts +1 -4
  67. package/src/io/ooxml/property-grab-bag.ts +5 -47
  68. package/src/io/ooxml/xml-element-serialize.ts +32 -0
  69. package/src/io/ooxml/xml-parser.ts +183 -0
  70. package/src/legal/bookmarks.ts +1 -1
  71. package/src/legal/cross-references.ts +1 -1
  72. package/src/legal/defined-terms.ts +1 -1
  73. package/src/legal/{_document-root.ts → document-root.ts} +8 -0
  74. package/src/legal/signature-blocks.ts +1 -1
  75. package/src/model/canonical-document.ts +159 -6
  76. package/src/model/chart-types.ts +439 -0
  77. package/src/model/snapshot.ts +3 -1
  78. package/src/review/store/comment-remapping.ts +24 -11
  79. package/src/review/store/revision-actions.ts +482 -2
  80. package/src/review/store/revision-store.ts +15 -0
  81. package/src/review/store/revision-types.ts +76 -0
  82. package/src/runtime/collab/remote-cursor-awareness.ts +24 -0
  83. package/src/runtime/collab/runtime-collab-sync.ts +33 -0
  84. package/src/runtime/diagnostics/build-diagnostic.ts +151 -0
  85. package/src/runtime/diagnostics/code-metadata-table.ts +221 -0
  86. package/src/runtime/document-runtime.ts +476 -34
  87. package/src/runtime/document-search.ts +115 -0
  88. package/src/runtime/edit-ops/index.ts +18 -2
  89. package/src/runtime/footnote-resolver.ts +130 -0
  90. package/src/runtime/layout/layout-engine-instance.ts +31 -4
  91. package/src/runtime/layout/layout-engine-version.ts +37 -1
  92. package/src/runtime/layout/page-graph.ts +14 -1
  93. package/src/runtime/layout/resolved-formatting-state.ts +21 -0
  94. package/src/runtime/numbering-prefix.ts +17 -0
  95. package/src/runtime/query-scopes.ts +5 -8
  96. package/src/runtime/resolved-numbering-geometry.ts +37 -6
  97. package/src/runtime/revision-runtime.ts +27 -1
  98. package/src/runtime/selection/post-edit-validator.ts +60 -6
  99. package/src/runtime/structure-ops/index.ts +20 -4
  100. package/src/runtime/surface-projection.ts +290 -21
  101. package/src/runtime/table-schema.ts +6 -0
  102. package/src/runtime/theme-color-resolver.ts +2 -2
  103. package/src/runtime/units.ts +9 -0
  104. package/src/runtime/workflow-rail-segments.ts +4 -0
  105. package/src/ui/WordReviewEditor.tsx +187 -43
  106. package/src/ui/editor-runtime-boundary.ts +10 -0
  107. package/src/ui/editor-shell-view.tsx +4 -1
  108. package/src/ui/headless/chrome-registry.ts +53 -0
  109. package/src/ui/headless/selection-tool-resolver.ts +11 -1
  110. package/src/ui-tailwind/chrome/chrome-preset-model.ts +13 -0
  111. package/src/ui-tailwind/chrome/tw-command-palette-mount.tsx +96 -0
  112. package/src/ui-tailwind/chrome/tw-context-menu.tsx +2 -1
  113. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +5 -4
  114. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +6 -2
  115. package/src/ui-tailwind/chrome/use-container-breakpoint.ts +111 -0
  116. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +0 -9
  117. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +1 -0
  118. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +6 -7
  119. package/src/ui-tailwind/editor-surface/pm-schema.ts +87 -25
  120. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +9 -0
  121. package/src/ui-tailwind/editor-surface/shape-renderer.ts +76 -14
  122. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +18 -1
  123. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +2 -0
  124. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +18 -2
  125. package/src/ui-tailwind/index.ts +9 -0
  126. package/src/ui-tailwind/page-chrome-model.ts +77 -5
  127. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +56 -1
  128. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +2 -0
  129. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +116 -113
  130. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +2 -2
  131. package/src/ui-tailwind/theme/tokens.ts +14 -0
  132. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +5 -0
  133. package/src/ui-tailwind/tw-review-workspace.tsx +29 -87
  134. package/src/validation/diagnostics.ts +1 -0
@@ -75,6 +75,8 @@ const SUPPORTED_CONTAINER_TYPES = new Set(["ins", "del"]);
75
75
  const PRESERVE_ONLY_CONTAINER_TYPES = new Set(["moveFrom", "moveTo"]);
76
76
  const PROPERTY_CHANGE_REVISION_TYPES = new Set(["rPrChange", "pPrChange"]);
77
77
  const STRUCTURAL_TABLE_REVISION_TYPES = new Set(["cellIns", "cellDel", "cellMerge"]);
78
+ const MOVE_RANGE_START_TYPES = new Set(["moveFromRangeStart", "moveToRangeStart"]);
79
+ const MOVE_RANGE_END_TYPES = new Set(["moveFromRangeEnd", "moveToRangeEnd"]);
78
80
 
79
81
  export function parseRevisionsFromDocumentXml(
80
82
  documentXml: string,
@@ -92,65 +94,118 @@ export function parseRevisionsFromDocumentXml(
92
94
  nextGeneratedIndex: 1,
93
95
  };
94
96
 
95
- let paragraphIndex = -1;
96
- let cursor = 0;
97
- let previousWasParagraph = false;
97
+ const walkState = {
98
+ paragraphIndex: -1,
99
+ cursor: 0,
100
+ previousWasParagraph: false,
101
+ tableOrdinal: -1,
102
+ };
103
+
104
+ walkBodyChildren(bodyElement.children, walkState, state);
105
+
106
+ linkMoveRevisionPairs(state);
107
+
108
+ return {
109
+ revisions: state.revisions,
110
+ preservedMarkup: state.preservedMarkup.sort((left, right) => left.xmlStart - right.xmlStart),
111
+ diagnostics: state.diagnostics,
112
+ boundaries,
113
+ };
114
+ }
98
115
 
99
- for (const child of bodyElement.children) {
116
+ interface BodyWalkState {
117
+ paragraphIndex: number;
118
+ cursor: number;
119
+ previousWasParagraph: boolean;
120
+ tableOrdinal: number;
121
+ }
122
+
123
+ function walkBodyChildren(
124
+ children: XmlNode[],
125
+ walkState: BodyWalkState,
126
+ state: ParseState,
127
+ ): void {
128
+ for (const child of children) {
100
129
  if (child.type !== "element") {
101
130
  continue;
102
131
  }
103
132
 
104
133
  const childType = localName(child.name);
105
134
 
106
- if (childType !== "p") {
107
- if (childType === "tbl") {
108
- parseTblPropertyRevisions(child, cursor, state);
109
- const nested = parseNestedParagraphContainers(
110
- child.children,
111
- paragraphIndex,
112
- cursor,
113
- previousWasParagraph,
114
- state,
115
- );
116
- paragraphIndex = nested.paragraphIndex;
117
- cursor = nested.cursor;
118
- previousWasParagraph = nested.previousWasParagraph;
119
- } else if (childType === "sectPr") {
120
- parseSectPrRevisions(child, cursor, state);
121
- cursor += 1;
122
- previousWasParagraph = false;
123
- } else {
124
- cursor += 1;
125
- previousWasParagraph = false;
135
+ if (childType === "p") {
136
+ if (walkState.previousWasParagraph) {
137
+ walkState.cursor += 1;
126
138
  }
139
+ walkState.paragraphIndex += 1;
140
+ const paragraphBoundary = state.boundaries[walkState.paragraphIndex];
141
+ if (!paragraphBoundary) {
142
+ continue;
143
+ }
144
+ parseParagraphMarkRevisions(child, paragraphBoundary, state);
145
+ walkParagraphContent(
146
+ child.children,
147
+ walkState.paragraphIndex,
148
+ state,
149
+ () => walkState.cursor,
150
+ (next) => {
151
+ walkState.cursor = next;
152
+ },
153
+ );
154
+ walkState.previousWasParagraph = true;
127
155
  continue;
128
156
  }
129
157
 
130
- if (previousWasParagraph) {
131
- cursor += 1;
158
+ if (childType === "tbl") {
159
+ walkState.tableOrdinal += 1;
160
+ parseTblPropertyRevisions(child, walkState.cursor, walkState.tableOrdinal, state);
161
+ const nested = parseNestedParagraphContainers(
162
+ child.children,
163
+ walkState.paragraphIndex,
164
+ walkState.cursor,
165
+ walkState.previousWasParagraph,
166
+ state,
167
+ [walkState.tableOrdinal],
168
+ );
169
+ walkState.paragraphIndex = nested.paragraphIndex;
170
+ walkState.cursor = nested.cursor;
171
+ walkState.previousWasParagraph = nested.previousWasParagraph;
172
+ continue;
132
173
  }
133
- paragraphIndex += 1;
134
- const paragraphBoundary = boundaries[paragraphIndex];
135
- if (!paragraphBoundary) {
174
+
175
+ // Phase S: body-level SDT / customXml wrappers contribute their children
176
+ // to body scope. Tables inside them get flat body-level tableOrdinals
177
+ // (not nested paths) because the canonical-document projection preserves
178
+ // the SDT wrapper and `resolveCanonicalTableRow` descends into it to
179
+ // match ordinals.
180
+ if (
181
+ childType === "sdt" ||
182
+ childType === "sdtContent" ||
183
+ childType === "customXml" ||
184
+ childType === "smartTag"
185
+ ) {
186
+ walkBodyChildren(child.children, walkState, state);
136
187
  continue;
137
188
  }
138
189
 
139
- parseParagraphMarkRevisions(child, paragraphBoundary, state);
140
- walkParagraphContent(child.children, paragraphIndex, state, () => cursor, (next) => {
141
- cursor = next;
142
- });
143
- previousWasParagraph = true;
144
- }
190
+ if (childType === "sectPr") {
191
+ parseSectPrRevisions(child, walkState.cursor, state);
192
+ walkState.cursor += 1;
193
+ walkState.previousWasParagraph = false;
194
+ continue;
195
+ }
145
196
 
146
- linkMoveRevisionPairs(state);
197
+ if (
198
+ MOVE_RANGE_START_TYPES.has(childType) ||
199
+ MOVE_RANGE_END_TYPES.has(childType)
200
+ ) {
201
+ parseMoveRangeMarker(child, childType, walkState.cursor, state);
202
+ walkState.previousWasParagraph = false;
203
+ continue;
204
+ }
147
205
 
148
- return {
149
- revisions: state.revisions,
150
- preservedMarkup: state.preservedMarkup.sort((left, right) => left.xmlStart - right.xmlStart),
151
- diagnostics: state.diagnostics,
152
- boundaries,
153
- };
206
+ walkState.cursor += 1;
207
+ walkState.previousWasParagraph = false;
208
+ }
154
209
  }
155
210
 
156
211
  function parseNestedParagraphContainers(
@@ -159,6 +214,8 @@ function parseNestedParagraphContainers(
159
214
  cursor: number,
160
215
  previousWasParagraph: boolean,
161
216
  state: ParseState,
217
+ parentPath?: readonly number[],
218
+ nestedTableCounter?: { value: number },
162
219
  ): {
163
220
  paragraphIndex: number;
164
221
  cursor: number;
@@ -167,6 +224,12 @@ function parseNestedParagraphContainers(
167
224
  let nextParagraphIndex = paragraphIndex;
168
225
  let nextCursor = cursor;
169
226
  let nextPreviousWasParagraph = previousWasParagraph;
227
+ // SHARED counter for nested <w:tbl> elements across the entire DFS walk of a
228
+ // single outer table. Created when first entering with parentPath (body
229
+ // loop or a new nested table); passed through recursive tr/tc/sdtContent
230
+ // calls so two sibling cells each containing a nested table get ordinals
231
+ // 0, 1 (matching `findNestedTableByOrdinal`'s DFS counter).
232
+ const counter = nestedTableCounter ?? { value: -1 };
170
233
 
171
234
  for (const node of nodes) {
172
235
  if (node.type !== "element") {
@@ -196,12 +259,30 @@ function parseNestedParagraphContainers(
196
259
  type === "customXml" ||
197
260
  type === "smartTag"
198
261
  ) {
262
+ let childParentPath = parentPath;
263
+ let childCounter = counter;
264
+
265
+ // For nested tables: call parseTblPropertyRevisions so that row/cell
266
+ // structural revisions inside the nested table get an ordinal path
267
+ // (tableOrdinalPath) instead of a body-level flat ordinal. (Phase Q)
268
+ if (type === "tbl" && parentPath !== undefined) {
269
+ counter.value += 1;
270
+ const myOrdinal = counter.value;
271
+ // parentPath (not nestedPath) because parseTableRowAndCellRevisions
272
+ // appends tableOrdinal itself to build the full tableOrdinalPath.
273
+ parseTblPropertyRevisions(node as XmlElementNode, nextCursor, myOrdinal, state, parentPath);
274
+ childParentPath = [...parentPath, myOrdinal];
275
+ // A new counter for THIS nested table's own direct-child tables.
276
+ childCounter = { value: -1 };
277
+ }
199
278
  const nested = parseNestedParagraphContainers(
200
279
  node.children,
201
280
  nextParagraphIndex,
202
281
  nextCursor,
203
282
  nextPreviousWasParagraph,
204
283
  state,
284
+ childParentPath,
285
+ childCounter,
205
286
  );
206
287
  nextParagraphIndex = nested.paragraphIndex;
207
288
  nextCursor = nested.cursor;
@@ -398,11 +479,7 @@ function walkContentNode(
398
479
  const length = measureStoryLength(node);
399
480
  const end = start + length;
400
481
  const hasNestedRevision = containsNestedRevision(node);
401
- const metadata = readRevisionMetadata(
402
- node,
403
- state,
404
- type === "moveFrom" || type === "moveTo" ? "move" : type,
405
- );
482
+ const metadata = readRevisionMetadata(node, state, type);
406
483
 
407
484
  if (hasNestedRevision) {
408
485
  const nestedKind = type === "ins" ? "insertion" : type === "del" ? "deletion" : "move";
@@ -634,7 +711,9 @@ function parseRunFormattingRevisions(
634
711
  function parseTblPropertyRevisions(
635
712
  table: XmlElementNode,
636
713
  position: number,
714
+ tableOrdinal: number,
637
715
  state: ParseState,
716
+ parentPath?: readonly number[],
638
717
  ): void {
639
718
  const tblPr = findChildElement(table, "tblPr");
640
719
  if (tblPr) {
@@ -679,18 +758,87 @@ function parseTblPropertyRevisions(
679
758
  }
680
759
  }
681
760
 
682
- parseTableRowAndCellRevisions(table, position, state);
761
+ parseTableRowAndCellRevisions(table, position, tableOrdinal, state, parentPath);
762
+ }
763
+
764
+ function parseMoveRangeMarker(
765
+ node: XmlElementNode,
766
+ markerType: string,
767
+ position: number,
768
+ state: ParseState,
769
+ ): void {
770
+ const direction: "from" | "to" = markerType.startsWith("moveFrom")
771
+ ? "from"
772
+ : "to";
773
+ const isStart =
774
+ markerType === "moveFromRangeStart" || markerType === "moveToRangeStart";
775
+
776
+ if (!isStart) {
777
+ // End markers serialize as-is; record only preservedMarkup so the
778
+ // serializer round-trips them losslessly. No revision record — the
779
+ // start marker owns the pairing.
780
+ state.preservedMarkup.push({
781
+ revisionId: `revision:${markerType}-${sanitizeRevisionToken(
782
+ node.attributes["w:id"] ?? node.attributes.id ?? `unknown-${state.nextGeneratedIndex++}`,
783
+ )}`,
784
+ rawXml: state.documentXml.slice(node.start, node.end),
785
+ xmlStart: node.start,
786
+ xmlEnd: node.end,
787
+ originalRevisionType: markerType,
788
+ });
789
+ return;
790
+ }
791
+
792
+ const metadata = readRevisionMetadata(node, state, markerType);
793
+ const moveData: MoveData = {
794
+ moveId: metadata.ooxmlRevisionId ?? `generated-${metadata.revisionId}`,
795
+ direction,
796
+ };
797
+
798
+ state.revisions.push(
799
+ createRevisionRecord({
800
+ revisionId: metadata.revisionId,
801
+ kind: "move",
802
+ anchor: createRevisionRangeAnchor(position, position),
803
+ authorId: metadata.authorId,
804
+ createdAt: metadata.createdAt,
805
+ metadata: {
806
+ source: "import",
807
+ originalRevisionType: markerType,
808
+ ooxmlRevisionId: metadata.ooxmlRevisionId,
809
+ preserveOnlyReason: "Imported preserve-only revision.",
810
+ moveData,
811
+ },
812
+ }),
813
+ );
814
+ state.preservedMarkup.push({
815
+ revisionId: metadata.revisionId,
816
+ rawXml: state.documentXml.slice(node.start, node.end),
817
+ xmlStart: node.start,
818
+ xmlEnd: node.end,
819
+ originalRevisionType: markerType,
820
+ });
821
+ state.diagnostics.push({
822
+ revisionId: metadata.revisionId,
823
+ code: "preserve_only_move_revision",
824
+ message: `Move range marker (${markerType}) parsed as preserve-only; pairing resolved in post-parse step.`,
825
+ featureClass: "preserve-only",
826
+ });
683
827
  }
684
828
 
685
829
  function parseTableRowAndCellRevisions(
686
830
  table: XmlElementNode,
687
831
  position: number,
832
+ tableOrdinal: number,
688
833
  state: ParseState,
834
+ parentPath?: readonly number[],
689
835
  ): void {
836
+ let rowIndex = -1;
690
837
  for (const child of table.children) {
691
838
  if (child.type !== "element" || localName(child.name) !== "tr") {
692
839
  continue;
693
840
  }
841
+ rowIndex += 1;
694
842
 
695
843
  const trPr = findChildElement(child, "trPr");
696
844
  if (trPr) {
@@ -728,12 +876,56 @@ function parseTableRowAndCellRevisions(
728
876
  beforeContainerXml: beforeXml,
729
877
  });
730
878
  }
879
+
880
+ // X4.a.3 — bare <w:ins>/<w:del> inside <w:trPr> mark the entire
881
+ // row as inserted/deleted. Distinct from cellIns/cellDel (which
882
+ // live inside tcPr) and from trPrChange (property diff).
883
+ for (const trChild of trPr.children) {
884
+ if (trChild.type !== "element") {
885
+ continue;
886
+ }
887
+ const trChildName = localName(trChild.name);
888
+ if (trChildName !== "ins" && trChildName !== "del") {
889
+ continue;
890
+ }
891
+ const markerType = trChildName === "ins" ? "row-ins" : "row-del";
892
+ const metadata = readRevisionMetadata(trChild, state, markerType);
893
+ state.revisions.push(
894
+ createRevisionRecord({
895
+ revisionId: metadata.revisionId,
896
+ kind: trChildName === "ins" ? "insertion" : "deletion",
897
+ anchor: createRevisionRangeAnchor(position, position),
898
+ authorId: metadata.authorId,
899
+ createdAt: metadata.createdAt,
900
+ metadata: {
901
+ source: "import",
902
+ originalRevisionType: markerType,
903
+ ooxmlRevisionId: metadata.ooxmlRevisionId,
904
+ semanticKind: "structural-change",
905
+ tableRevisionCoordinates: {
906
+ tableOrdinal,
907
+ rowIndex,
908
+ ...(parentPath ? { tableOrdinalPath: [...parentPath, tableOrdinal] } : {}),
909
+ },
910
+ },
911
+ }),
912
+ );
913
+ state.preservedMarkup.push({
914
+ revisionId: metadata.revisionId,
915
+ rawXml: state.documentXml.slice(trChild.start, trChild.end),
916
+ xmlStart: trChild.start,
917
+ xmlEnd: trChild.end,
918
+ originalRevisionType: markerType,
919
+ });
920
+ }
731
921
  }
732
922
 
923
+ let cellIndex = -1;
733
924
  for (const tcNode of child.children) {
734
925
  if (tcNode.type !== "element" || localName(tcNode.name) !== "tc") {
735
926
  continue;
736
927
  }
928
+ cellIndex += 1;
737
929
 
738
930
  const tcPr = findChildElement(tcNode, "tcPr");
739
931
  if (!tcPr) {
@@ -783,6 +975,44 @@ function parseTableRowAndCellRevisions(
783
975
  const tcChildName = localName(tcChild.name);
784
976
  if (STRUCTURAL_TABLE_REVISION_TYPES.has(tcChildName)) {
785
977
  const metadata = readRevisionMetadata(tcChild, state, tcChildName);
978
+ // Phase U: capture w:vMerge direction for cellMerge accept/reject.
979
+ const cellMergeDirection =
980
+ tcChildName === "cellMerge"
981
+ ? ((tcChild.attributes["w:vMerge"] ?? tcChild.attributes.vMerge ?? "rest") === "cont"
982
+ ? "cont"
983
+ : "rest")
984
+ : undefined;
985
+ const isActionable =
986
+ tcChildName === "cellIns" ||
987
+ tcChildName === "cellDel" ||
988
+ tcChildName === "cellMerge";
989
+ state.revisions.push(
990
+ createRevisionRecord({
991
+ revisionId: metadata.revisionId,
992
+ kind: "formatting",
993
+ anchor: createRevisionRangeAnchor(position, position),
994
+ authorId: metadata.authorId,
995
+ createdAt: metadata.createdAt,
996
+ metadata: {
997
+ source: "import",
998
+ originalRevisionType: tcChildName,
999
+ ooxmlRevisionId: metadata.ooxmlRevisionId,
1000
+ semanticKind: "structural-change",
1001
+ tableRevisionCoordinates: {
1002
+ tableOrdinal,
1003
+ rowIndex,
1004
+ cellIndex,
1005
+ ...(parentPath ? { tableOrdinalPath: [...parentPath, tableOrdinal] } : {}),
1006
+ },
1007
+ ...(cellMergeDirection ? { cellMergeData: { direction: cellMergeDirection } } : {}),
1008
+ ...(isActionable
1009
+ ? {}
1010
+ : {
1011
+ preserveOnlyReason: `Structural table revision (${tcChildName}) preserved for round-trip.`,
1012
+ }),
1013
+ },
1014
+ }),
1015
+ );
786
1016
  state.preservedMarkup.push({
787
1017
  revisionId: metadata.revisionId,
788
1018
  rawXml: state.documentXml.slice(tcChild.start, tcChild.end),
@@ -792,9 +1022,13 @@ function parseTableRowAndCellRevisions(
792
1022
  });
793
1023
  state.diagnostics.push({
794
1024
  revisionId: metadata.revisionId,
795
- code: "table_structural_revision_preserve_only",
796
- message: `Structural table revision (${tcChildName}) remains preserve-only; topology-altering changes cannot be safely applied.`,
797
- featureClass: "preserve-only",
1025
+ code: isActionable
1026
+ ? "table_property_revision_parsed"
1027
+ : "table_structural_revision_preserve_only",
1028
+ message: isActionable
1029
+ ? `Structural table revision (${tcChildName}) parsed as actionable structural-change.`
1030
+ : `Structural table revision (${tcChildName}) remains preserve-only; topology-altering changes cannot be safely applied.`,
1031
+ featureClass: isActionable ? "supported" : "preserve-only",
798
1032
  });
799
1033
  }
800
1034
  }
@@ -5,14 +5,16 @@ import type {
5
5
  DocumentSettings,
6
6
  ThemeColorSlot,
7
7
  } from "../../model/canonical-document.ts";
8
+ import { parseXml } from "./xml-parser.ts";
9
+ import { localName } from "./xml-attr-helpers.ts";
8
10
 
9
- const CLRSCHEME_MAPPING_SLOTS = new Set<string>([
11
+ const CLRSCHEME_MAPPING_SLOTS = new Set<ClrSchemeMappingSlot>([
10
12
  "bg1", "bg2", "t1", "t2",
11
13
  "accent1", "accent2", "accent3", "accent4", "accent5", "accent6",
12
14
  "hlink", "followedHyperlink",
13
15
  ]);
14
16
 
15
- const THEME_COLOR_SLOTS = new Set<string>([
17
+ const THEME_COLOR_SLOTS = new Set<ThemeColorSlot>([
16
18
  "dk1", "lt1", "dk2", "lt2",
17
19
  "accent1", "accent2", "accent3", "accent4", "accent5", "accent6",
18
20
  "hlink", "folHlink",
@@ -161,8 +163,8 @@ function parseClrSchemeMapping(
161
163
  for (const [attr, value] of Object.entries(el.attributes)) {
162
164
  if (!value) continue;
163
165
  const local = localName(attr);
164
- if (!CLRSCHEME_MAPPING_SLOTS.has(local)) continue;
165
- if (!THEME_COLOR_SLOTS.has(value)) continue;
166
+ if (!CLRSCHEME_MAPPING_SLOTS.has(local as ClrSchemeMappingSlot)) continue;
167
+ if (!THEME_COLOR_SLOTS.has(value as ThemeColorSlot)) continue;
166
168
  mapping[local as ClrSchemeMappingSlot] = value as ThemeColorSlot;
167
169
  }
168
170
  return Object.keys(mapping).length > 0 ? mapping : undefined;
@@ -178,10 +180,6 @@ function findChildElementOptional(
178
180
  );
179
181
  }
180
182
 
181
- function localName(name: string): string {
182
- const idx = name.indexOf(":");
183
- return idx >= 0 ? name.slice(idx + 1) : name;
184
- }
185
183
 
186
184
  function readOnOffValue(
187
185
  element: XmlElementNode,
@@ -221,94 +219,3 @@ function readZoomLevel(
221
219
  return { zoomLevel: parsed };
222
220
  }
223
221
 
224
- function parseXml(xml: string): XmlElementNode {
225
- const root: XmlElementNode = {
226
- type: "element",
227
- name: "__root__",
228
- attributes: {},
229
- children: [],
230
- };
231
- const stack: XmlElementNode[] = [root];
232
- let cursor = 0;
233
-
234
- while (cursor < xml.length) {
235
- if (xml.startsWith("<!--", cursor)) {
236
- const end = xml.indexOf("-->", cursor);
237
- cursor = end >= 0 ? end + 3 : xml.length;
238
- continue;
239
- }
240
-
241
- if (xml.startsWith("<?", cursor)) {
242
- const end = xml.indexOf("?>", cursor);
243
- cursor = end >= 0 ? end + 2 : xml.length;
244
- continue;
245
- }
246
-
247
- const nextLt = xml.indexOf("<", cursor);
248
- if (nextLt < 0) {
249
- pushText(xml.slice(cursor));
250
- break;
251
- }
252
-
253
- if (nextLt > cursor) {
254
- pushText(xml.slice(cursor, nextLt));
255
- }
256
-
257
- if (xml.startsWith("</", nextLt)) {
258
- const end = xml.indexOf(">", nextLt);
259
- if (end < 0) break;
260
- if (stack.length > 1) {
261
- stack.pop();
262
- }
263
- cursor = end + 1;
264
- continue;
265
- }
266
-
267
- const end = xml.indexOf(">", nextLt);
268
- if (end < 0) {
269
- break;
270
- }
271
-
272
- const rawTag = xml.slice(nextLt + 1, end).trim();
273
- const selfClosing = rawTag.endsWith("/");
274
- const tagBody = selfClosing ? rawTag.slice(0, -1).trim() : rawTag;
275
- const spaceIndex = tagBody.search(/\s/u);
276
- const name =
277
- spaceIndex >= 0 ? tagBody.slice(0, spaceIndex) : tagBody;
278
- const attrs = spaceIndex >= 0 ? tagBody.slice(spaceIndex + 1) : "";
279
- const element: XmlElementNode = {
280
- type: "element",
281
- name,
282
- attributes: parseAttributes(attrs),
283
- children: [],
284
- };
285
- stack[stack.length - 1]?.children.push(element);
286
- if (!selfClosing) {
287
- stack.push(element);
288
- }
289
- cursor = end + 1;
290
- }
291
-
292
- return root;
293
-
294
- function pushText(raw: string): void {
295
- const normalized = raw.replace(/\r\n?/gu, "\n");
296
- if (!normalized.trim()) {
297
- return;
298
- }
299
- stack[stack.length - 1]?.children.push({
300
- type: "text",
301
- text: normalized,
302
- });
303
- }
304
- }
305
-
306
- function parseAttributes(raw: string): Record<string, string> {
307
- const attributes: Record<string, string> = {};
308
- const pattern = /([^\s=]+)\s*=\s*("([^"]*)"|'([^']*)')/gu;
309
- for (const match of raw.matchAll(pattern)) {
310
- const [, name, , dq, sq] = match;
311
- attributes[name] = dq ?? sq ?? "";
312
- }
313
- return attributes;
314
- }