@ansonlai/docx-redline-js 0.2.0 → 0.4.0

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 (87) hide show
  1. package/AGENTS.md +36 -10
  2. package/README.md +83 -6
  3. package/adapters/xml-adapter.js +73 -10
  4. package/core/list-targeting.js +3 -0
  5. package/core/paragraph-targeting.js +33 -7
  6. package/core/redline-validation.js +22 -0
  7. package/core/types.js +122 -27
  8. package/core/xml-query.js +3 -1
  9. package/dist/docx-redline-js.esm.js +1148 -572
  10. package/dist/docx-redline-js.esm.js.map +4 -4
  11. package/dist/docx-redline-js.esm.min.js +79 -78
  12. package/dist/docx-redline-js.esm.min.js.map +4 -4
  13. package/docs/TESTING.md +687 -0
  14. package/docs/VALIDATION.md +81 -2
  15. package/docs/WORD-MANUAL-REVIEW.md +138 -0
  16. package/docs/plans/2026-08-30-reliability-testing-improvements.md +488 -0
  17. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +210 -0
  18. package/docs/plans/{2026-03-01-release-0.1.4-design.md → completed/2026-03-01-release-0.1.4-design.md} +2 -0
  19. package/docs/plans/{2026-03-01-release-0.1.4.md → completed/2026-03-01-release-0.1.4.md} +5 -3
  20. package/docs/plans/{2026-05-31-architectural changes.md → completed/2026-05-31-architectural changes.md } +2 -0
  21. package/docs/plans/completed/2026-08-02-reliability-improvements.md +1155 -0
  22. package/docs/test-comparison-dashboard.html +95 -0
  23. package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +22 -0
  24. package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +24 -0
  25. package/docs/validation-reports/2026-08-30-phase-3-coverage.md +73 -0
  26. package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +82 -0
  27. package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +114 -0
  28. package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +79 -0
  29. package/engine/format-extraction.js +1 -1
  30. package/engine/formatting-removal.js +95 -104
  31. package/engine/oxml-engine.js +176 -83
  32. package/engine/reconstruction-mapper.js +276 -79
  33. package/engine/reconstruction-mode.js +20 -6
  34. package/engine/reconstruction-writer.js +117 -72
  35. package/engine/run-builders.js +17 -13
  36. package/engine/surgical-diff-application.js +7 -21
  37. package/engine/surgical-mode.js +3 -2
  38. package/engine/table-mode.js +27 -16
  39. package/index.d.ts +95 -3
  40. package/index.js +14 -13
  41. package/orchestration/list-structural-fallback.js +16 -39
  42. package/package.json +23 -5
  43. package/pipeline/diff-engine.js +174 -55
  44. package/pipeline/ingestion-export.js +39 -24
  45. package/pipeline/ingestion-paragraph.js +7 -5
  46. package/pipeline/list-generation.js +27 -18
  47. package/pipeline/patching.js +2 -3
  48. package/pipeline/pipeline.js +65 -36
  49. package/pipeline/serialization.js +13 -5
  50. package/scripts/build-test-dashboard.mjs +43 -0
  51. package/scripts/check-types.mjs +16 -24
  52. package/scripts/export-validation-fixtures.mjs +191 -45
  53. package/scripts/fetch-superdoc-corpus.mjs +61 -0
  54. package/scripts/generate-test-dashboard.mjs +199 -0
  55. package/scripts/inspect-visual-evidence.mjs +271 -0
  56. package/scripts/lib/minimal-zip.mjs +199 -18
  57. package/scripts/lib/word-coverage-catalogue.mjs +207 -0
  58. package/scripts/lib/word-coverage-metadata.mjs +93 -0
  59. package/scripts/lib/zip-reader.mjs +64 -0
  60. package/scripts/package-superdoc-word-fixtures.ps1 +64 -0
  61. package/scripts/prepare-corpus-word-visual-review.mjs +84 -0
  62. package/scripts/prepare-superdoc-word-corpus.mjs +284 -0
  63. package/scripts/prepare-word-review.mjs +77 -0
  64. package/scripts/prepare-word-visual-review.mjs +90 -0
  65. package/scripts/render-agenda-multilevel.mjs +70 -0
  66. package/scripts/render-case22.mjs +73 -0
  67. package/scripts/render-case40.ps1 +35 -0
  68. package/scripts/render-multilevel-bullet-images.py +58 -0
  69. package/scripts/render-multilevel-bullet-visual.ps1 +32 -0
  70. package/scripts/render-multilevel-cases.mjs +80 -0
  71. package/scripts/report-coverage-gaps.mjs +103 -0
  72. package/scripts/report-word-coverage.mjs +71 -0
  73. package/scripts/sample-multimodal-visual-check.mjs +221 -0
  74. package/scripts/test-multilevel-bullet-visual.mjs +187 -0
  75. package/scripts/word-com-corpus-suite.ps1 +43 -0
  76. package/scripts/word-com-corpus-visual-suite.ps1 +116 -0
  77. package/scripts/word-com-differential.ps1 +158 -16
  78. package/scripts/word-com-suite.ps1 +19 -0
  79. package/scripts/word-com-visual-suite.ps1 +132 -0
  80. package/services/comment-engine.js +51 -46
  81. package/services/comment-locator.js +0 -1
  82. package/services/comment-package.js +11 -10
  83. package/services/numbering-service.js +1 -1
  84. package/services/revision-comment-management.js +31 -10
  85. package/services/standalone-docx-plumbing.js +45 -34
  86. package/services/standalone-operation-runner.js +315 -75
  87. package/services/table-reconciliation.js +23 -11
@@ -5,9 +5,16 @@
5
5
  * stay focused on UI + prompt orchestration.
6
6
  */
7
7
 
8
- import { createParser, createSerializer } from '../adapters/xml-adapter.js';
9
- import { createRevisionMetadata, seedRevisionIdsFromDocument } from '../core/types.js';
8
+ import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
9
+ import { findReconstructionParagraphRange } from '../engine/reconstruction-mapper.js';
10
+ import {
11
+ RevisionIdAllocator,
12
+ createRevisionMetadata,
13
+ seedRevisionIdsFromDocument,
14
+ setRevisionIdAllocatorForDocument
15
+ } from '../core/types.js';
10
16
  import { createWordElement } from '../core/word-xml.js';
17
+ import { markParagraphMarkInserted } from '../engine/run-builders.js';
11
18
  import {
12
19
  applyRedlineToOxml,
13
20
  reconcileMarkdownTableOoxml,
@@ -16,6 +23,7 @@ import {
16
23
  getParagraphText as getParagraphTextFromOxml,
17
24
  isMarkdownTableText,
18
25
  findContainingWordElement,
26
+ buildTargetReferenceSnapshot,
19
27
  resolveTargetParagraphWithSnapshot as resolveTargetParagraphWithSnapshotShared,
20
28
  buildSingleLineListStructuralFallbackPlan,
21
29
  executeSingleLineListStructuralFallback,
@@ -43,6 +51,15 @@ import {
43
51
 
44
52
  const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
45
53
 
54
+ function prepareRevisionAllocator(xmlDoc, options = {}) {
55
+ const allocator = options?._revisionIdAllocator instanceof RevisionIdAllocator
56
+ ? options._revisionIdAllocator
57
+ : new RevisionIdAllocator();
58
+ seedRevisionIdsFromDocument(xmlDoc, allocator);
59
+ setRevisionIdAllocatorForDocument(xmlDoc, allocator);
60
+ return allocator;
61
+ }
62
+
46
63
  function getParagraphText(paragraph) {
47
64
  return getParagraphTextFromOxml(paragraph);
48
65
  }
@@ -68,68 +85,15 @@ function normalizeBodySectionOrder(xmlDoc) {
68
85
  normalizeBodySectionOrderStandalone(xmlDoc);
69
86
  }
70
87
 
71
- function directFieldCharType(node) {
72
- if (!node || node.nodeType !== 1 || node.namespaceURI !== NS_W || node.localName !== 'r') return '';
73
- const fldChar = Array.from(node.childNodes || []).find(
74
- child => child && child.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'fldChar'
75
- ) || null;
76
- return fldChar?.getAttribute('w:fldCharType') || fldChar?.getAttribute('fldCharType') || '';
77
- }
78
-
79
- function hasInstrText(node) {
80
- if (!node || node.nodeType !== 1) return false;
81
- return Array.from(node.childNodes || []).some(
82
- child => child && child.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'instrText'
83
- );
84
- }
85
-
86
88
  function removeProofErrNodes(paragraph) {
87
89
  for (const node of Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, 'proofErr') || [])) {
88
90
  node.parentNode?.removeChild(node);
89
91
  }
90
92
  }
91
93
 
92
- function unlinkFieldsInParagraph(paragraph) {
93
- const children = Array.from(paragraph?.childNodes || []);
94
- let inField = false;
95
- let seenSeparate = false;
96
-
97
- for (const child of children) {
98
- if (child.nodeType !== 1) continue;
99
-
100
- const fieldType = directFieldCharType(child);
101
- if (fieldType === 'begin') {
102
- inField = true;
103
- seenSeparate = false;
104
- child.parentNode?.removeChild(child);
105
- continue;
106
- }
107
-
108
- if (!inField) continue;
109
-
110
- if (fieldType === 'separate') {
111
- seenSeparate = true;
112
- child.parentNode?.removeChild(child);
113
- continue;
114
- }
115
-
116
- if (fieldType === 'end') {
117
- inField = false;
118
- seenSeparate = false;
119
- child.parentNode?.removeChild(child);
120
- continue;
121
- }
122
-
123
- if (!seenSeparate || hasInstrText(child)) {
124
- child.parentNode?.removeChild(child);
125
- }
126
- }
127
- }
128
-
129
94
  function preprocessRedlineTargetParagraph(targetParagraph) {
130
95
  if (!targetParagraph) return;
131
96
  removeProofErrNodes(targetParagraph);
132
- unlinkFieldsInParagraph(targetParagraph);
133
97
  }
134
98
 
135
99
  function getDirectWordChild(element, localName) {
@@ -201,6 +165,14 @@ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMeta
201
165
  }
202
166
  ensureListProperties(xmlDoc, paragraph, entry.ilvl, entry.numId);
203
167
 
168
+ // A new list item is a whole inserted paragraph, not merely inserted text.
169
+ // Tracking its paragraph mark lets Word (and our accept/reject helpers)
170
+ // remove the list paragraph itself on Reject All instead of leaving an
171
+ // empty bullet or number behind.
172
+ if (generateRedlines) {
173
+ markParagraphMarkInserted(xmlDoc, paragraph, author);
174
+ }
175
+
204
176
  const run = createWordElement(xmlDoc, 'w:r');
205
177
  const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
206
178
  const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
@@ -214,7 +186,7 @@ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMeta
214
186
  textNode.textContent = safeText;
215
187
  run.appendChild(textNode);
216
188
  if (generateRedlines) {
217
- const metadata = revisionMetadata || createRevisionMetadata(author);
189
+ const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc);
218
190
  const ins = createWordElement(xmlDoc, 'w:ins');
219
191
  ins.setAttribute('w:id', String(metadata.id));
220
192
  ins.setAttribute('w:author', metadata.author);
@@ -355,7 +327,7 @@ function applyExplicitRangeListInsertions({
355
327
  numId: entry.numId,
356
328
  text: entry.text
357
329
  },
358
- generateRedlines ? createRevisionMetadata(author) : null,
330
+ generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
359
331
  author,
360
332
  { generateRedlines }
361
333
  );
@@ -485,7 +457,7 @@ function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, aut
485
457
  run.appendChild(textNode);
486
458
 
487
459
  if (generateRedlines) {
488
- const metadata = revisionMetadata || createRevisionMetadata(author);
460
+ const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc);
489
461
  const ins = createWordElement(xmlDoc, 'w:ins');
490
462
  ins.setAttribute('w:id', String(metadata.id));
491
463
  ins.setAttribute('w:author', metadata.author);
@@ -522,7 +494,7 @@ function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionMetadata, au
522
494
  if (pPr) wrappedParagraph.appendChild(pPr.cloneNode(true));
523
495
 
524
496
  const ins = createWordElement(xmlDoc, 'w:ins');
525
- const metadata = revisionMetadata || createRevisionMetadata(author);
497
+ const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc);
526
498
  ins.setAttribute('w:id', String(metadata.id));
527
499
  ins.setAttribute('w:author', metadata.author);
528
500
  ins.setAttribute('w:date', metadata.date);
@@ -818,14 +790,14 @@ async function applyToParagraphByExactText(documentXml, targetText, modifiedText
818
790
  const generateRedlines = options.generateRedlines !== false;
819
791
  const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
820
792
  const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
821
- const parser = createParser();
822
793
  const serializer = createSerializer();
823
- const xmlDoc = parser.parseFromString(documentXml, 'application/xml');
824
- seedRevisionIdsFromDocument(xmlDoc);
794
+ const xmlDoc = parseOoxmlSafe(documentXml, 'application/xml').doc;
795
+ if (!xmlDoc) return { documentXml, hasChanges: false, status: 'error', error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' } };
796
+ const revisionIdAllocator = prepareRevisionAllocator(xmlDoc, options);
825
797
  const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'redline', runtimeContext, { onInfo, onWarn });
826
798
  const targetParagraph = resolved.paragraph;
827
799
  preprocessRedlineTargetParagraph(targetParagraph);
828
- const currentParagraphText = getParagraphText(targetParagraph).trim();
800
+ const currentParagraphText = getParagraphText(targetParagraph);
829
801
  const containingTable = findContainingWordElement(targetParagraph, 'tbl');
830
802
  const rawTableStructuralCandidate = !!containingTable
831
803
  && !targetEndRef
@@ -865,7 +837,9 @@ async function applyToParagraphByExactText(documentXml, targetText, modifiedText
865
837
  onInfo,
866
838
  onWarn
867
839
  })
868
- : null;
840
+ : (typeof targetText === 'string' && /\r?\n/.test(targetText)
841
+ ? findReconstructionParagraphRange(xmlDoc, targetText)
842
+ : null);
869
843
  const hasExplicitRangeScope = Array.isArray(explicitRangeParagraphs) && explicitRangeParagraphs.length > 0;
870
844
  if (!useTableScope && hasExplicitRangeScope) {
871
845
  const insertionEntries = buildExplicitRangeInsertionEntries(explicitRangeParagraphs, effectiveModifiedText);
@@ -928,7 +902,7 @@ async function applyToParagraphByExactText(documentXml, targetText, modifiedText
928
902
  markerType: 'numbered',
929
903
  text: adjacencyInsertionCandidate.text
930
904
  },
931
- generateRedlines ? createRevisionMetadata(author) : null,
905
+ generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
932
906
  author,
933
907
  { generateRedlines }
934
908
  );
@@ -961,7 +935,7 @@ async function applyToParagraphByExactText(documentXml, targetText, modifiedText
961
935
  xmlDoc,
962
936
  targetParagraph,
963
937
  paragraphText,
964
- generateRedlines ? createRevisionMetadata(author) : null,
938
+ generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
965
939
  author,
966
940
  { generateRedlines }
967
941
  );
@@ -992,7 +966,7 @@ async function applyToParagraphByExactText(documentXml, targetText, modifiedText
992
966
  xmlDoc,
993
967
  targetParagraph,
994
968
  { ...entry, numId: insertionOnlyPlan.numId },
995
- generateRedlines ? createRevisionMetadata(author) : null,
969
+ generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
996
970
  author,
997
971
  { generateRedlines }
998
972
  );
@@ -1070,12 +1044,14 @@ async function applyToParagraphByExactText(documentXml, targetText, modifiedText
1070
1044
  author,
1071
1045
  generateRedlines,
1072
1046
  existingRevisions: options.existingRevisions,
1047
+ _revisionIdAllocator: revisionIdAllocator,
1073
1048
  _isolatedTableCell: useTableScope
1074
1049
  })
1075
1050
  : await applyRedlineToOxml(scopedXml, originalTextForApply, effectiveModifiedText, {
1076
1051
  author,
1077
1052
  generateRedlines,
1078
1053
  existingRevisions: options.existingRevisions,
1054
+ _revisionIdAllocator: revisionIdAllocator,
1079
1055
  _isolatedTableCell: useTableScope
1080
1056
  });
1081
1057
  if (!result?.hasChanges) {
@@ -1131,13 +1107,18 @@ async function applyHighlightToParagraphByExactText(documentXml, targetText, tex
1131
1107
  const generateRedlines = options.generateRedlines !== false;
1132
1108
  const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
1133
1109
  const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
1134
- const parser = createParser();
1135
1110
  const serializer = createSerializer();
1136
- const xmlDoc = parser.parseFromString(documentXml, 'application/xml');
1111
+ const xmlDoc = parseOoxmlSafe(documentXml, 'application/xml').doc;
1112
+ if (!xmlDoc) return { documentXml, hasChanges: false, status: 'error', error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' } };
1113
+ const revisionIdAllocator = prepareRevisionAllocator(xmlDoc, options);
1137
1114
  const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'highlight', runtimeContext, { onInfo, onWarn });
1138
1115
  const targetParagraph = resolved.paragraph;
1139
1116
  const paragraphXml = serializer.serializeToString(targetParagraph);
1140
- const highlightedXml = applyHighlightToOoxml(paragraphXml, textToHighlight, color, { generateRedlines, author });
1117
+ const highlightedXml = applyHighlightToOoxml(paragraphXml, textToHighlight, color, {
1118
+ generateRedlines,
1119
+ author,
1120
+ _revisionIdAllocator: revisionIdAllocator
1121
+ });
1141
1122
  if (!highlightedXml || highlightedXml === paragraphXml) return { documentXml, hasChanges: false };
1142
1123
  const { replacementNodes } = extractReplacementNodes(highlightedXml);
1143
1124
  const parent = targetParagraph.parentNode;
@@ -1150,9 +1131,10 @@ async function applyHighlightToParagraphByExactText(documentXml, targetText, tex
1150
1131
  async function applyCommentToParagraphByExactText(documentXml, targetText, textToComment, commentContent, author, targetRef = null, runtimeContext = null, options = {}) {
1151
1132
  const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
1152
1133
  const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
1153
- const parser = createParser();
1154
1134
  const serializer = createSerializer();
1155
- const xmlDoc = parser.parseFromString(documentXml, 'application/xml');
1135
+ const xmlDoc = parseOoxmlSafe(documentXml, 'application/xml').doc;
1136
+ if (!xmlDoc) return { documentXml, hasChanges: false, commentsXml: null, status: 'error', error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' } };
1137
+ prepareRevisionAllocator(xmlDoc, options);
1156
1138
  const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'comment', runtimeContext, { onInfo, onWarn });
1157
1139
  const targetParagraph = resolved.paragraph;
1158
1140
  const paragraphXml = serializer.serializeToString(targetParagraph);
@@ -1166,6 +1148,127 @@ async function applyCommentToParagraphByExactText(documentXml, targetText, textT
1166
1148
  return { documentXml: serializer.serializeToString(xmlDoc), hasChanges: true, commentsXml: commentResult.commentsXml || null, warnings: commentResult.warnings || [] };
1167
1149
  }
1168
1150
 
1151
+ function operationTargetPriority(op) {
1152
+ if (op?.type === 'comment') return 0;
1153
+ return 1;
1154
+ }
1155
+
1156
+ /**
1157
+ * Returns a stable operation order that resolves anchor-based operations before
1158
+ * text edits can mutate their target text. Comments run first; all other
1159
+ * operation types retain their original relative order.
1160
+ *
1161
+ * @param {Object[]} operations - Structured document operations
1162
+ * @returns {Object[]} A reordered copy; input objects and input array are not mutated
1163
+ */
1164
+ export function orderOperationsForStableTargets(operations = []) {
1165
+ return (Array.isArray(operations) ? operations : [])
1166
+ .map((operation, index) => ({ operation, index }))
1167
+ .sort((a, b) => operationTargetPriority(a.operation) - operationTargetPriority(b.operation) || a.index - b.index)
1168
+ .map(entry => entry.operation);
1169
+ }
1170
+
1171
+ function mergeCommentsXml(existingXml, incomingXml) {
1172
+ if (!incomingXml) return existingXml || null;
1173
+ if (!existingXml) return incomingXml;
1174
+
1175
+ const serializer = createSerializer();
1176
+ const existingDoc = parseOoxmlSafe(existingXml, 'application/xml').doc;
1177
+ const incomingDoc = parseOoxmlSafe(incomingXml, 'application/xml').doc;
1178
+ if (!existingDoc || !incomingDoc) return existingXml;
1179
+ const existingRoot = existingDoc.documentElement;
1180
+ const existingIds = new Set(
1181
+ Array.from(existingRoot.getElementsByTagNameNS(NS_W, 'comment'))
1182
+ .map(comment => comment.getAttribute('w:id') || comment.getAttribute('id'))
1183
+ .filter(Boolean)
1184
+ );
1185
+
1186
+ for (const comment of Array.from(incomingDoc.getElementsByTagNameNS(NS_W, 'comment'))) {
1187
+ const id = comment.getAttribute('w:id') || comment.getAttribute('id');
1188
+ if (id && existingIds.has(id)) continue;
1189
+ existingRoot.appendChild(existingDoc.importNode(comment, true));
1190
+ if (id) existingIds.add(id);
1191
+ }
1192
+ return serializer.serializeToString(existingDoc);
1193
+ }
1194
+
1195
+ function cloneBatchRuntimeContext(runtimeContext) {
1196
+ if (!runtimeContext || typeof runtimeContext !== 'object') return {};
1197
+
1198
+ const context = { ...runtimeContext };
1199
+ if (runtimeContext.listFallbackSharedNumIdByKey instanceof Map) {
1200
+ context.listFallbackSharedNumIdByKey = new Map(runtimeContext.listFallbackSharedNumIdByKey);
1201
+ }
1202
+ if (runtimeContext.tableStructuralRedlineKeys instanceof Set) {
1203
+ context.tableStructuralRedlineKeys = new Set(runtimeContext.tableStructuralRedlineKeys);
1204
+ }
1205
+ if (runtimeContext.numberingIdState && typeof runtimeContext.numberingIdState === 'object') {
1206
+ context.numberingIdState = {
1207
+ ...runtimeContext.numberingIdState,
1208
+ usedNumIds: runtimeContext.numberingIdState.usedNumIds instanceof Set
1209
+ ? new Set(runtimeContext.numberingIdState.usedNumIds)
1210
+ : runtimeContext.numberingIdState.usedNumIds,
1211
+ usedAbstractNumIds: runtimeContext.numberingIdState.usedAbstractNumIds instanceof Set
1212
+ ? new Set(runtimeContext.numberingIdState.usedAbstractNumIds)
1213
+ : runtimeContext.numberingIdState.usedAbstractNumIds
1214
+ };
1215
+ }
1216
+ if (runtimeContext.listFallbackSequenceState && typeof runtimeContext.listFallbackSequenceState === 'object') {
1217
+ context.listFallbackSequenceState = {
1218
+ ...runtimeContext.listFallbackSequenceState,
1219
+ explicitByNumberingKey: runtimeContext.listFallbackSequenceState.explicitByNumberingKey instanceof Map
1220
+ ? new Map(runtimeContext.listFallbackSequenceState.explicitByNumberingKey)
1221
+ : runtimeContext.listFallbackSequenceState.explicitByNumberingKey
1222
+ };
1223
+ }
1224
+ return context;
1225
+ }
1226
+
1227
+ function commitBatchRuntimeContext(runtimeContext, context) {
1228
+ if (!runtimeContext || typeof runtimeContext !== 'object') return;
1229
+
1230
+ if (runtimeContext.listFallbackSharedNumIdByKey instanceof Map && context.listFallbackSharedNumIdByKey instanceof Map) {
1231
+ runtimeContext.listFallbackSharedNumIdByKey.clear();
1232
+ for (const entry of context.listFallbackSharedNumIdByKey) runtimeContext.listFallbackSharedNumIdByKey.set(...entry);
1233
+ context.listFallbackSharedNumIdByKey = runtimeContext.listFallbackSharedNumIdByKey;
1234
+ }
1235
+ if (runtimeContext.tableStructuralRedlineKeys instanceof Set && context.tableStructuralRedlineKeys instanceof Set) {
1236
+ runtimeContext.tableStructuralRedlineKeys.clear();
1237
+ for (const value of context.tableStructuralRedlineKeys) runtimeContext.tableStructuralRedlineKeys.add(value);
1238
+ context.tableStructuralRedlineKeys = runtimeContext.tableStructuralRedlineKeys;
1239
+ }
1240
+ if (runtimeContext.numberingIdState && context.numberingIdState) {
1241
+ for (const key of ['usedNumIds', 'usedAbstractNumIds']) {
1242
+ if (runtimeContext.numberingIdState[key] instanceof Set && context.numberingIdState[key] instanceof Set) {
1243
+ runtimeContext.numberingIdState[key].clear();
1244
+ for (const value of context.numberingIdState[key]) runtimeContext.numberingIdState[key].add(value);
1245
+ context.numberingIdState[key] = runtimeContext.numberingIdState[key];
1246
+ }
1247
+ }
1248
+ Object.assign(runtimeContext.numberingIdState, context.numberingIdState);
1249
+ context.numberingIdState = runtimeContext.numberingIdState;
1250
+ }
1251
+ if (runtimeContext.listFallbackSequenceState && context.listFallbackSequenceState) {
1252
+ const originalMap = runtimeContext.listFallbackSequenceState.explicitByNumberingKey;
1253
+ const updatedMap = context.listFallbackSequenceState.explicitByNumberingKey;
1254
+ if (originalMap instanceof Map && updatedMap instanceof Map) {
1255
+ originalMap.clear();
1256
+ for (const entry of updatedMap) originalMap.set(...entry);
1257
+ context.listFallbackSequenceState.explicitByNumberingKey = originalMap;
1258
+ }
1259
+ Object.assign(runtimeContext.listFallbackSequenceState, context.listFallbackSequenceState);
1260
+ context.listFallbackSequenceState = runtimeContext.listFallbackSequenceState;
1261
+ }
1262
+ Object.assign(runtimeContext, context);
1263
+ }
1264
+
1265
+ function normalizeOperationError(error) {
1266
+ return {
1267
+ code: typeof error?.code === 'string' && error.code ? error.code : 'OPERATION_ERROR',
1268
+ message: error?.message || String(error)
1269
+ };
1270
+ }
1271
+
1169
1272
  /**
1170
1273
  * Applies one structured operation (`redline`, `highlight`, or `comment`) to
1171
1274
  * a full `word/document.xml` payload.
@@ -1182,6 +1285,14 @@ async function applyCommentToParagraphByExactText(documentXml, targetText, textT
1182
1285
  * @returns {Promise<{ documentXml: string, hasChanges: boolean, numberingXml?: string|null, commentsXml?: string|null, warnings?: string[] }>}
1183
1286
  */
1184
1287
  export async function applyOperationToDocumentXml(documentXml, op, author, runtimeContext = null, options = {}) {
1288
+ const parsed = parseOoxmlSafe(documentXml, 'application/xml');
1289
+ if (parsed.error || !parsed.doc) {
1290
+ return { documentXml, hasChanges: false, status: 'error', error: parsed.error, warnings: parsed.warnings };
1291
+ }
1292
+ const operationOptions = {
1293
+ ...options,
1294
+ _revisionIdAllocator: prepareRevisionAllocator(parsed.doc, options)
1295
+ };
1185
1296
  if (op?.type === 'highlight') {
1186
1297
  return applyHighlightToParagraphByExactText(
1187
1298
  documentXml,
@@ -1191,7 +1302,7 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
1191
1302
  author,
1192
1303
  op.targetRef,
1193
1304
  runtimeContext,
1194
- options
1305
+ operationOptions
1195
1306
  );
1196
1307
  }
1197
1308
  if (op?.type === 'comment') {
@@ -1203,7 +1314,7 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
1203
1314
  author,
1204
1315
  op.targetRef,
1205
1316
  runtimeContext,
1206
- options
1317
+ operationOptions
1207
1318
  );
1208
1319
  }
1209
1320
  return applyToParagraphByExactText(
@@ -1214,6 +1325,135 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
1214
1325
  op?.targetRef,
1215
1326
  op?.targetEndRef,
1216
1327
  runtimeContext,
1217
- options
1328
+ operationOptions
1218
1329
  );
1219
1330
  }
1331
+
1332
+ /**
1333
+ * Applies a batch of operations using stable target ordering. This prevents a
1334
+ * later comment from missing original text changed by an earlier replacement
1335
+ * in the same batch.
1336
+ *
1337
+ * Results retain each operation's original 1-based index even though execution
1338
+ * is reordered. Numbering payloads are returned as an array so package callers
1339
+ * can merge each one with `ensureNumberingArtifactsInZip`.
1340
+ *
1341
+ * @param {string} documentXml - Full `word/document.xml` payload
1342
+ * @param {Object[]} operations - Structured operations
1343
+ * @param {string} author - Revision/comment author
1344
+ * @param {Object|null} [runtimeContext=null] - Shared turn context
1345
+ * @param {{
1346
+ * atomic?: boolean,
1347
+ * continueOnError?: boolean,
1348
+ * generateRedlines?: boolean,
1349
+ * onInfo?: (message: string) => void,
1350
+ * onWarn?: (message: string) => void
1351
+ * }} [options={}] - Runner options. `atomic` defaults to `true`, returning the
1352
+ * original document and no package artifacts if any operation fails.
1353
+ * `continueOnError` defaults to `true`, so all operations are attempted and
1354
+ * represented in `results`; `false` stops after the first operation error.
1355
+ * @returns {Promise<{
1356
+ * documentXml: string,
1357
+ * hasChanges: boolean,
1358
+ * commentsXml: string|null,
1359
+ * numberingXmlParts: string[],
1360
+ * results: Array<{index:number,type:string,status:string,warnings?:string[],error?:Object}>,
1361
+ * executionOrder: number[],
1362
+ * rolledBack?: boolean
1363
+ * }>}
1364
+ */
1365
+ export async function applyOperationsToDocumentXml(documentXml, operations, author, runtimeContext = null, options = {}) {
1366
+ const parsed = parseOoxmlSafe(documentXml, 'application/xml');
1367
+ if (parsed.error || !parsed.doc) {
1368
+ return {
1369
+ documentXml,
1370
+ hasChanges: false,
1371
+ commentsXml: null,
1372
+ numberingXmlParts: [],
1373
+ results: [],
1374
+ executionOrder: [],
1375
+ status: 'error',
1376
+ error: parsed.error,
1377
+ warnings: parsed.warnings
1378
+ };
1379
+ }
1380
+ const sourceOperations = Array.isArray(operations) ? operations : [];
1381
+ const scheduled = sourceOperations
1382
+ .map((operation, index) => ({ operation, index }))
1383
+ .sort((a, b) => operationTargetPriority(a.operation) - operationTargetPriority(b.operation) || a.index - b.index);
1384
+
1385
+ const atomic = options.atomic !== false;
1386
+ const continueOnError = options.continueOnError !== false;
1387
+ const context = cloneBatchRuntimeContext(runtimeContext);
1388
+ if (!(context.targetRefSnapshot instanceof Map)) {
1389
+ context.targetRefSnapshot = buildTargetReferenceSnapshot(parsed.doc);
1390
+ }
1391
+
1392
+ let currentDocumentXml = documentXml;
1393
+ let commentsXml = null;
1394
+ let hasChanges = false;
1395
+ const numberingXmlParts = [];
1396
+ const results = [];
1397
+ const executionOrder = [];
1398
+ let operationFailed = false;
1399
+
1400
+ for (const entry of scheduled) {
1401
+ const { operation, index } = entry;
1402
+ executionOrder.push(index + 1);
1403
+ try {
1404
+ const result = await applyOperationToDocumentXml(
1405
+ currentDocumentXml,
1406
+ operation,
1407
+ author,
1408
+ context,
1409
+ options
1410
+ );
1411
+ currentDocumentXml = result.documentXml;
1412
+ hasChanges = hasChanges || result.hasChanges === true;
1413
+ commentsXml = mergeCommentsXml(commentsXml, result.commentsXml || null);
1414
+ if (result.numberingXml) numberingXmlParts.push(result.numberingXml);
1415
+ const isError = result.status === 'error' || !!result.error;
1416
+ operationFailed = operationFailed || isError;
1417
+ results.push({
1418
+ index: index + 1,
1419
+ type: operation?.type || 'redline',
1420
+ status: isError ? 'error' : (result.hasChanges ? 'applied' : 'no_change'),
1421
+ ...(Array.isArray(result.warnings) && result.warnings.length > 0 ? { warnings: result.warnings } : {}),
1422
+ ...(result.error ? { error: result.error } : {})
1423
+ });
1424
+ if (isError && !continueOnError) break;
1425
+ } catch (error) {
1426
+ const normalizedError = normalizeOperationError(error);
1427
+ operationFailed = true;
1428
+ results.push({
1429
+ index: index + 1,
1430
+ type: operation?.type || 'redline',
1431
+ status: 'error',
1432
+ warnings: [normalizedError.message],
1433
+ error: normalizedError
1434
+ });
1435
+ if (!continueOnError) break;
1436
+ }
1437
+ }
1438
+
1439
+ results.sort((a, b) => a.index - b.index);
1440
+ const rolledBack = atomic && operationFailed;
1441
+ if (!rolledBack) commitBatchRuntimeContext(runtimeContext, context);
1442
+
1443
+ return {
1444
+ documentXml: rolledBack ? documentXml : currentDocumentXml,
1445
+ hasChanges: rolledBack ? false : hasChanges,
1446
+ commentsXml: rolledBack ? null : commentsXml,
1447
+ numberingXmlParts: rolledBack ? [] : numberingXmlParts,
1448
+ results,
1449
+ executionOrder,
1450
+ ...(rolledBack ? {
1451
+ rolledBack: true,
1452
+ status: 'error',
1453
+ error: {
1454
+ code: 'BATCH_OPERATION_FAILED',
1455
+ message: 'Atomic batch rolled back because one or more operations failed.'
1456
+ }
1457
+ } : {})
1458
+ };
1459
+ }
@@ -7,7 +7,7 @@
7
7
  import { computeWordLevelDiffOps } from '../pipeline/diff-engine.js';
8
8
  import { splitRunsAtDiffBoundaries, applyPatches } from '../pipeline/patching.js';
9
9
  import { serializeToOoxml } from '../pipeline/serialization.js';
10
- import { NS_W, createRevisionMetadata, escapeXml, RunKind } from '../core/types.js';
10
+ import { createRevisionMetadata, escapeXml, RunKind } from '../core/types.js';
11
11
  import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
12
12
 
13
13
  /**
@@ -19,8 +19,8 @@ import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
19
19
  * @returns {string} Complete w:tbl OOXML
20
20
  */
21
21
  export function generateTableOoxml(tableData, options = {}) {
22
- const { generateRedlines = false, author = 'AI' } = options;
23
- const tableInsertMeta = generateRedlines ? createRevisionMetadata(author) : null;
22
+ const { generateRedlines = false, author = 'AI', revisionIdAllocator = null } = options;
23
+ const tableInsertMeta = generateRedlines ? createRevisionMetadata(author, revisionIdAllocator) : null;
24
24
 
25
25
  // Determine number of columns
26
26
  const numCols = tableData.headers?.length || (tableData.rows?.[0]?.length || 1);
@@ -68,7 +68,11 @@ export function generateTableOoxml(tableData, options = {}) {
68
68
  endOffset: cleanText.length
69
69
  }];
70
70
 
71
- const runsOoxml = serializeToOoxml(runModel, null, formatHints, { author, generateRedlines });
71
+ const runsOoxml = serializeToOoxml(runModel, null, formatHints, {
72
+ author,
73
+ generateRedlines,
74
+ revisionIdAllocator
75
+ });
72
76
 
73
77
  // Cell properties
74
78
  const tcPr = '<w:tcPr><w:tcW w:w="0" w:type="auto"/></w:tcPr>';
@@ -164,7 +168,7 @@ export function diffTablesWithVirtualGrid(oldGrid, newTableData) {
164
168
  * @returns {string} Reconciled w:tbl OOXML
165
169
  */
166
170
  export function serializeVirtualGridToOoxml(grid, operations, options) {
167
- const { generateRedlines, author } = options;
171
+ const { generateRedlines, author, revisionIdAllocator = null } = options;
168
172
  const opIndex = buildTableOperationIndex(operations);
169
173
 
170
174
  let rowsXml = '';
@@ -206,7 +210,7 @@ export function serializeVirtualGridToOoxml(grid, operations, options) {
206
210
  let trPr = grid.trPrList[row] || '<w:trPr/>';
207
211
 
208
212
  if (rowDeleteOp && generateRedlines) {
209
- const metadata = createRevisionMetadata(author);
213
+ const metadata = createRevisionMetadata(author, revisionIdAllocator);
210
214
  const delMark = `<w:del w:id="${metadata.id}" w:author="${escapeXml(metadata.author)}" w:date="${metadata.date}"/>`;
211
215
  if (trPr.includes('</w:trPr>')) {
212
216
  trPr = trPr.replace('</w:trPr>', `${delMark}</w:trPr>`);
@@ -222,7 +226,7 @@ export function serializeVirtualGridToOoxml(grid, operations, options) {
222
226
  const insertOps = opIndex.rowInsertOperations;
223
227
  for (const op of insertOps) {
224
228
  let cellsXml = '';
225
- const rowInsertMeta = generateRedlines ? createRevisionMetadata(author) : null;
229
+ const rowInsertMeta = generateRedlines ? createRevisionMetadata(author, revisionIdAllocator) : null;
226
230
 
227
231
  for (const cellText of op.cells) {
228
232
  const { cleanText, formatHints } = preprocessMarkdown(cellText);
@@ -236,7 +240,11 @@ export function serializeVirtualGridToOoxml(grid, operations, options) {
236
240
  endOffset: cleanText.length
237
241
  }];
238
242
 
239
- const runsOoxml = serializeToOoxml(runModel, null, formatHints, { author, generateRedlines });
243
+ const runsOoxml = serializeToOoxml(runModel, null, formatHints, {
244
+ author,
245
+ generateRedlines,
246
+ revisionIdAllocator
247
+ });
240
248
  // serializeToOoxml already returns one or more w:p blocks.
241
249
  cellsXml += `<w:tc><w:tcPr><w:tcW w:w="0" w:type="auto"/></w:tcPr>${runsOoxml}</w:tc>`;
242
250
  }
@@ -293,7 +301,7 @@ function buildTableOperationIndex(operations) {
293
301
  }
294
302
 
295
303
  function reconcileCellContent(cell, newText, options) {
296
- const { generateRedlines, author } = options;
304
+ const { generateRedlines, author, revisionIdAllocator = null } = options;
297
305
  const { cleanText, formatHints } = preprocessMarkdown(newText);
298
306
 
299
307
  // For now, satisfy with single block or join blocks
@@ -310,7 +318,11 @@ function reconcileCellContent(cell, newText, options) {
310
318
  formatHints
311
319
  });
312
320
 
313
- const runsOoxml = serializeToOoxml(patchedModel, baseBlock.pPr, formatHints, { author, generateRedlines });
321
+ const runsOoxml = serializeToOoxml(patchedModel, baseBlock.pPr, formatHints, {
322
+ author,
323
+ generateRedlines,
324
+ revisionIdAllocator
325
+ });
314
326
  return runsOoxml;
315
327
  }
316
328
 
@@ -322,7 +334,7 @@ function serializeCellBlocks(blocks) {
322
334
  }).join('');
323
335
  }
324
336
 
325
- function buildTcXml(cell, content, options) {
337
+ function buildTcXml(cell, content, _options) {
326
338
  let tcPr = cell.tcPrXml;
327
339
 
328
340
  // Ensure gridSpan is preserved