@ansonlai/docx-redline-js 0.4.0 → 0.5.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 (100) hide show
  1. package/AGENTS.md +589 -287
  2. package/ARCHITECTURE.md +215 -9
  3. package/CHANGELOG.md +319 -0
  4. package/README.md +604 -360
  5. package/adapters/config.js +45 -43
  6. package/bin/docx-redline.js +3 -0
  7. package/core/list-targeting.js +101 -110
  8. package/core/paragraph-targeting.js +501 -61
  9. package/core/paragraph-text.js +209 -0
  10. package/core/revision-cloning.js +38 -0
  11. package/core/types.js +64 -10
  12. package/core/word-xml.js +43 -15
  13. package/dist/docx-redline-js.esm.js +2849 -466
  14. package/dist/docx-redline-js.esm.js.map +4 -4
  15. package/dist/docx-redline-js.esm.min.js +87 -76
  16. package/dist/docx-redline-js.esm.min.js.map +4 -4
  17. package/docs/TESTING.md +342 -23
  18. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  19. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  20. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  21. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  22. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  23. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  24. package/docs/schemas/document-operations.schema.json +109 -0
  25. package/docs/test-comparison-dashboard.html +4250 -7
  26. package/engine/formatting-removal.js +11 -2
  27. package/engine/oxml-engine.js +491 -336
  28. package/engine/reconstruction-mode.js +15 -14
  29. package/engine/reconstruction-writer.js +247 -142
  30. package/engine/route-selection.js +35 -0
  31. package/engine/rpr-helpers.js +334 -35
  32. package/engine/run-builders.js +239 -196
  33. package/engine/surgical-diff-application.js +222 -37
  34. package/engine/surgical-mode.js +134 -6
  35. package/engine/surgical-spans.js +52 -1
  36. package/engine/table-cell-context.js +3 -6
  37. package/engine/table-mode.js +1 -1
  38. package/index.d.ts +234 -6
  39. package/index.js +24 -1
  40. package/node/cli.js +317 -0
  41. package/node/docx-document.js +302 -0
  42. package/node/index.d.ts +31 -0
  43. package/node/index.js +2 -0
  44. package/node/zip-archive.js +52 -0
  45. package/orchestration/list-markdown.js +10 -16
  46. package/orchestration/list-parsing.js +7 -12
  47. package/orchestration/list-structural-fallback.js +21 -10
  48. package/package.json +24 -3
  49. package/pipeline/content-analysis.js +12 -17
  50. package/pipeline/ingestion-export.js +3 -31
  51. package/pipeline/ingestion-paragraph.js +10 -5
  52. package/pipeline/list-generation.js +150 -55
  53. package/pipeline/list-markers.js +70 -3
  54. package/pipeline/serialization.js +4 -2
  55. package/pipeline/structured-content.js +160 -0
  56. package/scripts/apply_changes.mjs +27 -0
  57. package/scripts/benchmark-operation-session.mjs +137 -0
  58. package/scripts/benchmark-targeting-browser.html +74 -0
  59. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  60. package/scripts/benchmark-test-runner.mjs +59 -0
  61. package/scripts/build-test-dashboard.mjs +23 -0
  62. package/scripts/export-lane1-fixtures.mjs +380 -0
  63. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  64. package/scripts/export-validation-fixtures.mjs +1 -1
  65. package/scripts/extract_text.mjs +7 -0
  66. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  67. package/scripts/generate-test-dashboard.mjs +362 -11
  68. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  69. package/scripts/profile-route-selection.mjs +19 -0
  70. package/scripts/render-agenda-multilevel.mjs +0 -5
  71. package/scripts/render-multilevel-cases.mjs +0 -1
  72. package/scripts/run-tests.mjs +107 -35
  73. package/scripts/word-com-corpus-suite.ps1 +3 -0
  74. package/scripts/word-com-differential.ps1 +64 -4
  75. package/scripts/word-com-suite.ps1 +3 -0
  76. package/services/batch-operation-orchestrator.js +494 -0
  77. package/services/capture-engine.js +226 -0
  78. package/services/comment-builders.js +23 -6
  79. package/services/comment-engine.js +108 -47
  80. package/services/comment-locator.js +187 -82
  81. package/services/comment-replies.js +95 -0
  82. package/services/document-inspection.js +258 -0
  83. package/services/document-operation-applier.js +372 -0
  84. package/services/document-operation-contract.js +323 -0
  85. package/services/document-operation-mutations.js +1733 -0
  86. package/services/document-operation-session.js +258 -0
  87. package/services/numbering-service.js +14 -5
  88. package/services/operation-heuristics.js +173 -0
  89. package/services/operation-preflight.js +366 -0
  90. package/services/receipt-collector.js +288 -0
  91. package/services/revision-comment-management.js +37 -5
  92. package/services/revision-token.js +290 -0
  93. package/services/standalone-docx-plumbing.js +123 -8
  94. package/services/standalone-operation-runner.d.ts +296 -0
  95. package/services/standalone-operation-runner.js +10 -1455
  96. package/services/table-reconciliation.js +15 -6
  97. package/docs/VALIDATION.md +0 -183
  98. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  99. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  100. /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
@@ -6,6 +6,7 @@ import {
6
6
  createTextRunWithRPrElement,
7
7
  injectFormattingToRPr
8
8
  } from './run-builders.js';
9
+ import { createRevisionMetadata } from '../core/types.js';
9
10
  import {
10
11
  createRunFromPieces,
11
12
  getRunContentPieces,
@@ -17,9 +18,12 @@ import {
17
18
  findContainingSpan,
18
19
  findFirstSpanEndingAt,
19
20
  findLastSpanEndingBeforeOrAt,
20
- forEachOverlappingSpan
21
+ forEachOverlappingSpan,
22
+ describeInsertionBoundary
21
23
  } from './surgical-spans.js';
22
24
  import { extractFormatFromRPr } from './rpr-helpers.js';
25
+ import { isWordElement } from '../core/word-xml.js';
26
+ import { NS_W } from '../core/types.js';
23
27
 
24
28
  export function reconcileFormattingForTextSpan(xmlDoc, span, start, end, applicableHints, author, generateRedlines) {
25
29
  // Plain modified text carries no negative formatting instruction. Preserve
@@ -67,7 +71,7 @@ export function reconcileFormattingForTextSpan(xmlDoc, span, start, end, applica
67
71
  return true;
68
72
  }
69
73
 
70
- export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, generateRedlines) {
74
+ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, generateRedlines, revisionMetadata = null) {
71
75
  const spans = [];
72
76
  forEachOverlappingSpan(spanIndex, startPos, endPos, span => {
73
77
  spans.push(span);
@@ -83,6 +87,7 @@ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, gener
83
87
  });
84
88
 
85
89
  let changed = false;
90
+ let usedDelMetadata = false;
86
91
  spansByRun.forEach((runSpans, runElement) => {
87
92
  const parent = runElement.parentNode;
88
93
  if (!parent) return;
@@ -114,7 +119,11 @@ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, gener
114
119
 
115
120
  if (generateRedlines && deletedPieces.length > 0) {
116
121
  const delRun = createRunFromPieces(xmlDoc, deletedPieces, runSpans[0].rPr);
117
- const delWrapper = createTrackChange(xmlDoc, 'del', delRun, author);
122
+ const metadata = revisionMetadata
123
+ ? (usedDelMetadata ? { ...revisionMetadata, id: createRevisionMetadata(author, xmlDoc).id } : revisionMetadata)
124
+ : null;
125
+ usedDelMetadata = true;
126
+ const delWrapper = createTrackChange(xmlDoc, 'del', delRun, author, metadata);
118
127
  parent.insertBefore(delWrapper, runElement);
119
128
  }
120
129
 
@@ -126,63 +135,239 @@ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, gener
126
135
  return changed;
127
136
  }
128
137
 
129
- export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], insertOffset = 0, generateRedlines = true, fallbackParagraph = null) {
130
- let targetSpan = findContainingSpan(spanIndex, pos);
138
+ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], insertOffset = 0, generateRedlines = true, fallbackParagraph = null, revisionMetadata = null, affinity = null) {
139
+ if (!affinity) {
140
+ let targetSpan = findContainingSpan(spanIndex, pos);
131
141
 
132
- if (!targetSpan && pos > 0) {
133
- targetSpan = findFirstSpanEndingAt(spanIndex, pos);
142
+ if (!targetSpan && pos > 0) {
143
+ targetSpan = findFirstSpanEndingAt(spanIndex, pos);
144
+ }
145
+
146
+ if (!targetSpan && pos > 0) {
147
+ targetSpan = findLastSpanEndingBeforeOrAt(spanIndex, pos);
148
+ }
149
+
150
+ if (!targetSpan && spanIndex.spans.length > 0) {
151
+ targetSpan = spanIndex.spans[spanIndex.spans.length - 1];
152
+ }
153
+
154
+ if (!targetSpan) {
155
+ if (!fallbackParagraph) return false;
156
+ insertTextRuns(xmlDoc, fallbackParagraph, null, text, null, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
157
+ return true;
158
+ }
159
+
160
+ const parent = targetSpan.runElement.parentNode;
161
+ if (!parent) {
162
+ if (!fallbackParagraph) return false;
163
+ insertTextRuns(xmlDoc, fallbackParagraph, null, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
164
+ return true;
165
+ }
166
+
167
+ const pieces = getRunContentPieces(targetSpan.runElement);
168
+ const targetPiece = pieces.find(piece => piece.node === targetSpan.textElement);
169
+ const localInsertPos = targetPiece
170
+ ? targetPiece.start + Math.max(0, Math.min(pos - targetSpan.charStart, targetSpan.charEnd - targetSpan.charStart))
171
+ : (pos <= targetSpan.charStart ? 0 : getRunTextLength(pieces));
172
+
173
+ if (localInsertPos > 0 && localInsertPos < getRunTextLength(pieces)) {
174
+ const beforePieces = sliceRunPieces(xmlDoc, pieces, 0, localInsertPos, false);
175
+ const afterPieces = sliceRunPieces(xmlDoc, pieces, localInsertPos, getRunTextLength(pieces), false);
176
+
177
+ insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, beforePieces, targetSpan.rPr);
178
+ insertTextRuns(xmlDoc, parent, targetSpan.runElement, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
179
+ insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, afterPieces, targetSpan.rPr);
180
+ parent.removeChild(targetSpan.runElement);
181
+ return true;
182
+ }
183
+
184
+ const referenceNode = pos <= targetSpan.charStart ? targetSpan.runElement : targetSpan.runElement.nextSibling;
185
+ insertTextRuns(xmlDoc, parent, referenceNode, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
186
+ return true;
134
187
  }
135
188
 
136
- if (!targetSpan && pos > 0) {
137
- targetSpan = findLastSpanEndingBeforeOrAt(spanIndex, pos);
189
+ const boundary = describeInsertionBoundary(spanIndex, pos, fallbackParagraph);
190
+
191
+ // Validate hyperlink affinity
192
+ const isLeftInHyperlink = boundary.leftSpan && isWordElement(boundary.leftSpan.runElement?.parentNode, 'hyperlink');
193
+ const isRightInHyperlink = boundary.rightSpan && isWordElement(boundary.rightSpan.runElement?.parentNode, 'hyperlink');
194
+ const isContainingInHyperlink = boundary.containingSpan && isWordElement(boundary.containingSpan.runElement?.parentNode, 'hyperlink');
195
+
196
+ if (affinity.hyperlink === 'outside') {
197
+ if (boundary.isInterior && isContainingInHyperlink) {
198
+ return {
199
+ error: {
200
+ code: 'UNSUPPORTED_INSERTION_AFFINITY',
201
+ message: 'Cannot place insertion outside hyperlink from strictly interior position.'
202
+ }
203
+ };
204
+ }
205
+ } else if (affinity.hyperlink === 'inside') {
206
+ if (!isLeftInHyperlink && !isRightInHyperlink && !isContainingInHyperlink) {
207
+ return {
208
+ error: {
209
+ code: 'UNSUPPORTED_INSERTION_AFFINITY',
210
+ message: 'Cannot place insertion inside hyperlink when no hyperlink is present at boundary.'
211
+ }
212
+ };
213
+ }
138
214
  }
139
215
 
140
- if (!targetSpan && spanIndex.spans.length > 0) {
141
- targetSpan = spanIndex.spans[spanIndex.spans.length - 1];
216
+ // Determine formatting (baseRPr)
217
+ let baseRPr = null;
218
+ if (affinity.formatting === 'none') {
219
+ baseRPr = null;
220
+ } else if (affinity.formatting === 'right') {
221
+ baseRPr = boundary.rightSpan?.rPr || null;
222
+ } else if (affinity.formatting === 'left') {
223
+ baseRPr = boundary.leftSpan?.rPr || null;
224
+ } else {
225
+ baseRPr = (boundary.containingSpan || boundary.leftSpan || boundary.rightSpan)?.rPr || null;
142
226
  }
143
227
 
144
- if (!targetSpan) {
145
- if (!fallbackParagraph) return false;
146
- insertTextRuns(xmlDoc, fallbackParagraph, null, text, null, author, formatHints, insertOffset, generateRedlines);
147
- return true;
228
+ // Check interior of a run
229
+ if (boundary.isInterior) {
230
+ const targetSpan = boundary.containingSpan;
231
+ const parent = targetSpan.runElement.parentNode || fallbackParagraph;
232
+ if (!parent) return false;
233
+
234
+ const pieces = getRunContentPieces(targetSpan.runElement);
235
+ const targetPiece = pieces.find(piece => piece.node === targetSpan.textElement);
236
+ const localInsertPos = targetPiece
237
+ ? targetPiece.start + Math.max(0, Math.min(pos - targetSpan.charStart, targetSpan.charEnd - targetSpan.charStart))
238
+ : (pos <= targetSpan.charStart ? 0 : getRunTextLength(pieces));
239
+
240
+ if (localInsertPos > 0 && localInsertPos < getRunTextLength(pieces)) {
241
+ const beforePieces = sliceRunPieces(xmlDoc, pieces, 0, localInsertPos, false);
242
+ const afterPieces = sliceRunPieces(xmlDoc, pieces, localInsertPos, getRunTextLength(pieces), false);
243
+
244
+ insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, beforePieces, targetSpan.rPr);
245
+ insertTextRuns(xmlDoc, parent, targetSpan.runElement, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
246
+ insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, afterPieces, targetSpan.rPr);
247
+ parent.removeChild(targetSpan.runElement);
248
+ return true;
249
+ }
250
+ }
251
+
252
+ // Boundary between runs or at start/end of paragraph
253
+ let parent = null;
254
+ let referenceNode = null;
255
+
256
+ if (affinity.hyperlink === 'outside') {
257
+ if (isRightInHyperlink) {
258
+ const hyperlinkNode = boundary.rightSpan.runElement.parentNode;
259
+ parent = hyperlinkNode.parentNode || fallbackParagraph;
260
+ referenceNode = hyperlinkNode;
261
+ } else if (isLeftInHyperlink) {
262
+ const hyperlinkNode = boundary.leftSpan.runElement.parentNode;
263
+ parent = hyperlinkNode.parentNode || fallbackParagraph;
264
+ referenceNode = hyperlinkNode.nextSibling;
265
+ }
266
+ } else if (affinity.hyperlink === 'inside') {
267
+ if (isRightInHyperlink) {
268
+ parent = boundary.rightSpan.runElement.parentNode;
269
+ referenceNode = boundary.rightSpan.runElement;
270
+ } else if (isLeftInHyperlink) {
271
+ parent = boundary.leftSpan.runElement.parentNode;
272
+ referenceNode = boundary.leftSpan.runElement.nextSibling;
273
+ }
148
274
  }
149
275
 
150
- const parent = targetSpan.runElement.parentNode;
151
276
  if (!parent) {
152
- if (!fallbackParagraph) return false;
153
- insertTextRuns(xmlDoc, fallbackParagraph, null, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines);
154
- return true;
277
+ if (boundary.rightSpan) {
278
+ parent = boundary.rightSpan.runElement.parentNode || fallbackParagraph;
279
+ referenceNode = boundary.rightSpan.runElement;
280
+ } else if (boundary.leftSpan) {
281
+ parent = boundary.leftSpan.runElement.parentNode || fallbackParagraph;
282
+ referenceNode = boundary.leftSpan.runElement.nextSibling;
283
+ } else {
284
+ parent = fallbackParagraph;
285
+ referenceNode = null;
286
+ }
155
287
  }
156
288
 
157
- const pieces = getRunContentPieces(targetSpan.runElement);
158
- const targetPiece = pieces.find(piece => piece.node === targetSpan.textElement);
159
- const localInsertPos = targetPiece
160
- ? targetPiece.start + Math.max(0, Math.min(pos - targetSpan.charStart, targetSpan.charEnd - targetSpan.charStart))
161
- : (pos <= targetSpan.charStart ? 0 : getRunTextLength(pieces));
289
+ // Check bookmark range affinity
290
+ if (affinity.bookmark && parent) {
291
+ if (affinity.bookmark === 'outside') {
292
+ if (referenceNode && isWordElement(referenceNode.previousSibling, 'bookmarkStart')) {
293
+ referenceNode = referenceNode.previousSibling;
294
+ }
295
+ if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'bookmarkEnd')) {
296
+ referenceNode = boundary.leftSpan.runElement.nextSibling.nextSibling;
297
+ }
298
+ } else if (affinity.bookmark === 'inside') {
299
+ if (referenceNode && isWordElement(referenceNode, 'bookmarkStart')) {
300
+ referenceNode = referenceNode.nextSibling;
301
+ }
302
+ if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'bookmarkEnd')) {
303
+ referenceNode = boundary.leftSpan.runElement.nextSibling;
304
+ }
305
+ }
306
+ }
307
+
308
+ // Check comment range affinity
309
+ if (affinity.comment && parent) {
310
+ if (affinity.comment === 'outside') {
311
+ if (referenceNode && isWordElement(referenceNode.previousSibling, 'commentRangeStart')) {
312
+ referenceNode = referenceNode.previousSibling;
313
+ }
314
+ if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'commentRangeEnd')) {
315
+ let afterComment = boundary.leftSpan.runElement.nextSibling.nextSibling;
316
+ if (afterComment && (isWordElement(afterComment, 'commentReference') || isWordElement(afterComment, 'r'))) {
317
+ const hasCRef = Array.from(afterComment.childNodes || []).some(n => isWordElement(n, 'commentReference'));
318
+ if (hasCRef) afterComment = afterComment.nextSibling;
319
+ }
320
+ referenceNode = afterComment;
321
+ }
322
+ } else if (affinity.comment === 'inside') {
323
+ if (referenceNode && isWordElement(referenceNode, 'commentRangeStart')) {
324
+ referenceNode = referenceNode.nextSibling;
325
+ }
326
+ if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'commentRangeEnd')) {
327
+ referenceNode = boundary.leftSpan.runElement.nextSibling;
328
+ }
329
+ }
330
+ }
162
331
 
163
- if (localInsertPos > 0 && localInsertPos < getRunTextLength(pieces)) {
164
- const beforePieces = sliceRunPieces(xmlDoc, pieces, 0, localInsertPos, false);
165
- const afterPieces = sliceRunPieces(xmlDoc, pieces, localInsertPos, getRunTextLength(pieces), false);
332
+ // Check revision affinity (coalesce_same_author)
333
+ if (generateRedlines && affinity.revision === 'coalesce_same_author') {
334
+ let insElem = null;
335
+ let insRef = null;
336
+
337
+ if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.parentNode, 'ins')) {
338
+ const candidate = boundary.leftSpan.runElement.parentNode;
339
+ const candAuthor = candidate.getAttribute('w:author') || candidate.getAttributeNS(NS_W, 'author');
340
+ if (candAuthor === author) {
341
+ insElem = candidate;
342
+ insRef = boundary.leftSpan.runElement.nextSibling;
343
+ }
344
+ } else if (boundary.rightSpan && isWordElement(boundary.rightSpan.runElement.parentNode, 'ins')) {
345
+ const candidate = boundary.rightSpan.runElement.parentNode;
346
+ const candAuthor = candidate.getAttribute('w:author') || candidate.getAttributeNS(NS_W, 'author');
347
+ if (candAuthor === author) {
348
+ insElem = candidate;
349
+ insRef = boundary.rightSpan.runElement;
350
+ }
351
+ }
166
352
 
167
- insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, beforePieces, targetSpan.rPr);
168
- insertTextRuns(xmlDoc, parent, targetSpan.runElement, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines);
169
- insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, afterPieces, targetSpan.rPr);
170
- parent.removeChild(targetSpan.runElement);
171
- return true;
353
+ if (insElem) {
354
+ const insRun = createTextRun(xmlDoc, text, baseRPr, false);
355
+ insElem.insertBefore(insRun, insRef);
356
+ return true;
357
+ }
172
358
  }
173
359
 
174
- const referenceNode = pos <= targetSpan.charStart ? targetSpan.runElement : targetSpan.runElement.nextSibling;
175
- insertTextRuns(xmlDoc, parent, referenceNode, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines);
360
+ insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
176
361
  return true;
177
362
  }
178
363
 
179
- function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines) {
364
+ function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata = null) {
180
365
  const applicableHints = getApplicableFormatHints(formatHints, insertOffset, insertOffset + text.length);
181
366
 
182
367
  if (applicableHints.length === 0) {
183
368
  const insRun = createTextRun(xmlDoc, text, baseRPr, false);
184
369
  if (generateRedlines) {
185
- const insWrapper = createTrackChange(xmlDoc, 'ins', insRun, author);
370
+ const insWrapper = createTrackChange(xmlDoc, 'ins', insRun, author, revisionMetadata);
186
371
  parent.insertBefore(insWrapper, referenceNode);
187
372
  } else {
188
373
  parent.insertBefore(insRun, referenceNode);
@@ -193,7 +378,7 @@ function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, fo
193
378
  const runs = createFormattedRuns(xmlDoc, text, baseRPr, applicableHints, insertOffset, author, generateRedlines);
194
379
 
195
380
  if (generateRedlines) {
196
- const insWrapper = createTrackChange(xmlDoc, 'ins', null, author);
381
+ const insWrapper = createTrackChange(xmlDoc, 'ins', null, author, revisionMetadata);
197
382
  runs.forEach(run => insWrapper.appendChild(run));
198
383
  parent.insertBefore(insWrapper, referenceNode);
199
384
  } else {
@@ -15,6 +15,75 @@ import {
15
15
  reconcileFormattingForTextSpan
16
16
  } from './surgical-diff-application.js';
17
17
  import { withOoxmlSourceType } from '../core/word-xml.js';
18
+ import { createReplacementRevisionEvent } from '../core/types.js';
19
+
20
+ function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos) {
21
+ const spans = [];
22
+ forEachOverlappingSpan(spanIndex, startPos, endPos, span => spans.push(span));
23
+ if (spans.length === 0) return { safe: false };
24
+
25
+ const firstRun = spans[0].runElement;
26
+ const parent = firstRun?.parentNode;
27
+ if (!parent) return { safe: false };
28
+
29
+ // All overlapping runs must share the exact same parent element
30
+ const sameParent = spans.every(s => s.runElement?.parentNode === parent);
31
+ if (!sameParent) return { safe: false, structuralBoundary: true };
32
+
33
+ // Parent container itself cannot be an existing revision or unsupported container
34
+ const parentLocal = (parent.localName || parent.nodeName.replace(/^.*:/, ''));
35
+ if (['hyperlink', 'sdt', 'ins', 'del', 'moveFrom', 'moveTo'].includes(parentLocal)) {
36
+ return { safe: false, structuralBoundary: true };
37
+ }
38
+
39
+ const structuralTags = new Set([
40
+ 'hyperlink', 'fldSimple', 'sdt',
41
+ 'commentRangeStart', 'commentRangeEnd', 'commentReference',
42
+ 'bookmarkStart', 'bookmarkEnd',
43
+ 'moveFrom', 'moveTo', 'ins', 'del'
44
+ ]);
45
+
46
+ for (const span of spans) {
47
+ const run = span.runElement;
48
+ for (const child of Array.from(run.childNodes || [])) {
49
+ if (child.nodeType === 1) {
50
+ const tag = child.localName || child.nodeName.replace(/^.*:/, '');
51
+ if (structuralTags.has(tag) || tag === 'fldChar') {
52
+ return { safe: false, structuralBoundary: true };
53
+ }
54
+ }
55
+ }
56
+ }
57
+
58
+ const lastRun = spans[spans.length - 1].runElement;
59
+ let curr = firstRun;
60
+ while (curr && curr !== lastRun) {
61
+ if (curr !== firstRun) {
62
+ const tag = curr.localName || curr.nodeName.replace(/^.*:/, '');
63
+ if (structuralTags.has(tag)) {
64
+ return { safe: false, structuralBoundary: true };
65
+ }
66
+ }
67
+ curr = curr.nextSibling;
68
+ }
69
+
70
+ function hasStructuralDescendant(node) {
71
+ if (!node || node.nodeType !== 1) return false;
72
+ const tag = node.localName || node.nodeName.replace(/^.*:/, '');
73
+ if (structuralTags.has(tag) || tag === 'fldChar') return true;
74
+ for (const child of Array.from(node.childNodes || [])) {
75
+ if (child.nodeType === 1 && hasStructuralDescendant(child)) return true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ // Inspect immediate adjacent siblings of the deleted range
81
+ if (hasStructuralDescendant(firstRun.previousSibling) || hasStructuralDescendant(lastRun.nextSibling)) {
82
+ return { safe: false, structuralBoundary: true };
83
+ }
84
+
85
+ return { safe: true };
86
+ }
18
87
 
19
88
  /**
20
89
  * Applies surgical mode reconciliation.
@@ -28,9 +97,10 @@ import { withOoxmlSourceType } from '../core/word-xml.js';
28
97
  * @param {boolean} [generateRedlines=true] - Track change toggle
29
98
  * @param {Element|null} [targetParagraph=null] - Optional scope paragraph
30
99
  * @param {{ diffTimeoutSeconds?: number }} [diffOptions={}] - Diff configuration
31
- * @returns {{ oxml: string, hasChanges: boolean, sourceType?: 'package'|'document'|'fragment' }}
100
+ * @param {Object} [options={}] - Additional reconciliation options (e.g. pairReplacements)
101
+ * @returns {{ oxml: string, hasChanges: boolean, warnings?: string[], sourceType?: 'package'|'document'|'fragment' }}
32
102
  */
33
- export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, targetParagraph = null, diffOptions = {}) {
103
+ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, targetParagraph = null, diffOptions = {}, options = {}) {
34
104
  void originalText;
35
105
 
36
106
  const allParagraphs = targetParagraph
@@ -40,12 +110,15 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
40
110
  const { fullText, textSpans } = buildSurgicalTextSpans(allParagraphs);
41
111
  const diffs = computeWordDiffs(fullText, modifiedText, diffOptions);
42
112
  const spanIndex = buildSpanIndex(textSpans);
113
+ const pairReplacements = options.pairReplacements === true;
114
+ const warnings = [];
43
115
 
44
116
  let originalPos = 0;
45
117
  let newPos = 0;
46
118
  let hasChanges = false;
47
119
 
48
- for (const [op, text] of diffs) {
120
+ for (let i = 0; i < diffs.length; i++) {
121
+ const [op, text] = diffs[i];
49
122
  if (op === 0) {
50
123
  const len = text.length;
51
124
  const startPos = originalPos;
@@ -67,14 +140,65 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
67
140
  originalPos += len;
68
141
  newPos += len;
69
142
  } else if (op === -1) {
70
- if (processDelete(xmlDoc, spanIndex, originalPos, originalPos + text.length, author, generateRedlines)) {
143
+ const hasNextInsert = (i + 1 < diffs.length) && (diffs[i + 1][0] === 1);
144
+ let paired = false;
145
+ let delMetadata = null;
146
+ let insMetadata = null;
147
+
148
+ if (pairReplacements && generateRedlines && hasNextInsert) {
149
+ const nextText = diffs[i + 1][1];
150
+ const textWithoutNewlines = nextText.replace(/\n/g, ' ');
151
+ if (textWithoutNewlines.trim().length > 0) {
152
+ const checkResult = checkSafeAdjacencyForPairing(spanIndex, originalPos, originalPos + text.length);
153
+ if (checkResult.safe) {
154
+ const event = createReplacementRevisionEvent(author, xmlDoc);
155
+ delMetadata = { id: event.deletionId, author: event.author, date: event.date };
156
+ insMetadata = { id: event.insertionId, author: event.author, date: event.date };
157
+ paired = true;
158
+ } else if (checkResult.structuralBoundary) {
159
+ warnings.push('PAIRING_SKIPPED_STRUCTURAL_BOUNDARY');
160
+ }
161
+ }
162
+ }
163
+
164
+ if (processDelete(xmlDoc, spanIndex, originalPos, originalPos + text.length, author, generateRedlines, delMetadata)) {
71
165
  hasChanges = true;
72
166
  }
73
167
  originalPos += text.length;
168
+
169
+ if (paired) {
170
+ i++;
171
+ const [, nextText] = diffs[i];
172
+ const textWithoutNewlines = nextText.replace(/\n/g, ' ');
173
+ if (textWithoutNewlines.trim().length > 0) {
174
+ const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null);
175
+ if (insertResult && typeof insertResult === 'object' && insertResult.error) {
176
+ return withOoxmlSourceType({
177
+ oxml: serializer.serializeToString(xmlDoc),
178
+ hasChanges: false,
179
+ status: 'error',
180
+ error: insertResult.error
181
+ });
182
+ }
183
+ if (insertResult === true) {
184
+ hasChanges = true;
185
+ }
186
+ }
187
+ newPos += nextText.length;
188
+ }
74
189
  } else if (op === 1) {
75
190
  const textWithoutNewlines = text.replace(/\n/g, ' ');
76
191
  if (textWithoutNewlines.trim().length > 0) {
77
- if (processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null)) {
192
+ const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null);
193
+ if (insertResult && typeof insertResult === 'object' && insertResult.error) {
194
+ return withOoxmlSourceType({
195
+ oxml: serializer.serializeToString(xmlDoc),
196
+ hasChanges: false,
197
+ status: 'error',
198
+ error: insertResult.error
199
+ });
200
+ }
201
+ if (insertResult === true) {
78
202
  hasChanges = true;
79
203
  }
80
204
  }
@@ -82,5 +206,9 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
82
206
  }
83
207
  }
84
208
 
85
- return withOoxmlSourceType({ oxml: serializer.serializeToString(xmlDoc), hasChanges });
209
+ return withOoxmlSourceType({
210
+ oxml: serializer.serializeToString(xmlDoc),
211
+ hasChanges,
212
+ ...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
213
+ });
86
214
  }
@@ -7,6 +7,7 @@ export function getRunChildText(child) {
7
7
  if (isWordElement(child, 'br') || isWordElement(child, 'cr')) return '\n';
8
8
  if (isWordElement(child, 'tab')) return '\t';
9
9
  if (isWordElement(child, 'noBreakHyphen')) return '\u2011';
10
+ if (isWordElement(child, 'softHyphen')) return '\u00ad';
10
11
  return '';
11
12
  }
12
13
 
@@ -15,7 +16,8 @@ export function isTextLikeRunChild(child) {
15
16
  || isWordElement(child, 'br')
16
17
  || isWordElement(child, 'cr')
17
18
  || isWordElement(child, 'tab')
18
- || isWordElement(child, 'noBreakHyphen');
19
+ || isWordElement(child, 'noBreakHyphen')
20
+ || isWordElement(child, 'softHyphen');
19
21
  }
20
22
 
21
23
  export function buildSurgicalTextSpans(paragraphs) {
@@ -34,6 +36,27 @@ export function buildSurgicalTextSpans(paragraphs) {
34
36
  fullText += processRunElement(hc, paragraph, container, fullText.length, textSpans).text;
35
37
  }
36
38
  }
39
+ } else if (isWordElement(child, 'ins')) {
40
+ for (let ic = child.firstChild; ic; ic = ic.nextSibling) {
41
+ if (isWordElement(ic, 'r')) {
42
+ fullText += processRunElement(ic, paragraph, container, fullText.length, textSpans).text;
43
+ }
44
+ }
45
+ } else if (isWordElement(child, 'sdt')) {
46
+ const sdtContent = Array.from(child.childNodes || []).find(n => isWordElement(n, 'sdtContent'));
47
+ if (sdtContent) {
48
+ for (let sc = sdtContent.firstChild; sc; sc = sc.nextSibling) {
49
+ if (isWordElement(sc, 'r')) {
50
+ fullText += processRunElement(sc, paragraph, container, fullText.length, textSpans).text;
51
+ }
52
+ }
53
+ }
54
+ } else if (isWordElement(child, 'smartTag')) {
55
+ for (let st = child.firstChild; st; st = st.nextSibling) {
56
+ if (isWordElement(st, 'r')) {
57
+ fullText += processRunElement(st, paragraph, container, fullText.length, textSpans).text;
58
+ }
59
+ }
37
60
  }
38
61
  }
39
62
 
@@ -167,3 +190,31 @@ function lowerBound(values, target) {
167
190
 
168
191
  return left;
169
192
  }
193
+
194
+ export function describeInsertionBoundary(spanIndex, pos, fallbackParagraph = null) {
195
+ if (!spanIndex || !spanIndex.spans || spanIndex.spans.length === 0) {
196
+ return {
197
+ leftSpan: null,
198
+ rightSpan: null,
199
+ containingSpan: null,
200
+ isInterior: false,
201
+ fallbackParagraph
202
+ };
203
+ }
204
+
205
+ const containingSpan = findContainingSpan(spanIndex, pos);
206
+ const isInterior = containingSpan !== null && pos > containingSpan.charStart && pos < containingSpan.charEnd;
207
+
208
+ // leftSpan ends at or before pos
209
+ const leftSpan = findFirstSpanEndingAt(spanIndex, pos) || (pos > 0 ? findLastSpanEndingBeforeOrAt(spanIndex, pos) : null);
210
+ // rightSpan starts at or after pos
211
+ const rightSpan = spanIndex.spans.find(s => s.charStart === pos) || (pos === 0 ? spanIndex.spans[0] : null);
212
+
213
+ return {
214
+ leftSpan,
215
+ rightSpan,
216
+ containingSpan,
217
+ isInterior,
218
+ fallbackParagraph
219
+ };
220
+ }
@@ -11,6 +11,7 @@ import { buildParagraphOnlyPackage } from '../services/package-builder.js';
11
11
  import { getElementsByTagNSOrTag } from '../core/xml-query.js';
12
12
  import { NS_W } from '../core/types.js';
13
13
  import { isWordElement } from '../core/word-xml.js';
14
+ import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
14
15
 
15
16
  const W14_NS = 'http://schemas.microsoft.com/office/word/2010/wordml';
16
17
 
@@ -69,12 +70,8 @@ export function detectTableCellContext(xmlDoc, originalText, options = {}) {
69
70
  if (originalText && originalText.trim()) {
70
71
  const normalizedTarget = originalText.trim();
71
72
  if (!targetParagraph) {
72
- for (const p of paragraphsInCells) {
73
- const textNodes = getElementsByTagNSOrTag(p, NS_W, 't');
74
- let paragraphText = '';
75
- for (const t of textNodes) {
76
- paragraphText += t.textContent || '';
77
- }
73
+ for (const p of paragraphsInCells) {
74
+ const paragraphText = extractCanonicalParagraphText(p);
78
75
 
79
76
  if (paragraphText.trim() === normalizedTarget) {
80
77
  targetParagraph = p;
@@ -165,7 +165,7 @@ export function applyTextToTableTransformation(xmlDoc, modifiedText, serializer,
165
165
  });
166
166
 
167
167
  const del = createWordElement(workingDoc, 'w:del');
168
- const metadata = createRevisionMetadata(author, workingDoc);
168
+ const metadata = createRevisionMetadata(author, workingDoc, 'del');
169
169
  del.setAttribute('w:id', String(metadata.id));
170
170
  del.setAttribute('w:author', metadata.author);
171
171
  del.setAttribute('w:date', metadata.date);