@ansonlai/docx-redline-js 0.4.0 → 0.5.1

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 (104) hide show
  1. package/AGENTS.md +646 -288
  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/redline-validation.js +11 -5
  11. package/core/revision-cloning.js +38 -0
  12. package/core/types.js +64 -10
  13. package/core/word-xml.js +43 -15
  14. package/dist/docx-redline-js.esm.js +3145 -505
  15. package/dist/docx-redline-js.esm.js.map +4 -4
  16. package/dist/docx-redline-js.esm.min.js +88 -76
  17. package/dist/docx-redline-js.esm.min.js.map +4 -4
  18. package/docs/TESTING.md +342 -23
  19. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  20. package/docs/plans/2026-09-08-cross-author-revision-slicing.md +505 -0
  21. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  22. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  23. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  24. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  25. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  26. package/docs/schemas/document-operations.schema.json +109 -0
  27. package/docs/test-comparison-dashboard.html +4250 -7
  28. package/engine/formatting-removal.js +11 -2
  29. package/engine/oxml-engine.js +508 -336
  30. package/engine/reconstruction-mode.js +15 -14
  31. package/engine/reconstruction-writer.js +247 -142
  32. package/engine/route-selection.js +35 -0
  33. package/engine/rpr-helpers.js +334 -35
  34. package/engine/run-builders.js +239 -196
  35. package/engine/surgical-diff-application.js +407 -50
  36. package/engine/surgical-mode.js +142 -6
  37. package/engine/surgical-run-splitting.js +103 -0
  38. package/engine/surgical-spans.js +52 -1
  39. package/engine/table-cell-context.js +3 -6
  40. package/engine/table-mode.js +1 -1
  41. package/index.d.ts +234 -6
  42. package/index.js +24 -1
  43. package/node/cli.js +322 -0
  44. package/node/docx-document.js +302 -0
  45. package/node/index.d.ts +31 -0
  46. package/node/index.js +2 -0
  47. package/node/zip-archive.js +52 -0
  48. package/orchestration/list-markdown.js +10 -16
  49. package/orchestration/list-parsing.js +7 -12
  50. package/orchestration/list-structural-fallback.js +21 -10
  51. package/package.json +123 -102
  52. package/pipeline/content-analysis.js +12 -17
  53. package/pipeline/ingestion-export.js +3 -31
  54. package/pipeline/ingestion-paragraph.js +10 -5
  55. package/pipeline/list-generation.js +150 -55
  56. package/pipeline/list-markers.js +70 -3
  57. package/pipeline/serialization.js +4 -2
  58. package/pipeline/structured-content.js +160 -0
  59. package/scripts/apply_changes.mjs +27 -0
  60. package/scripts/benchmark-operation-session.mjs +137 -0
  61. package/scripts/benchmark-targeting-browser.html +74 -0
  62. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  63. package/scripts/benchmark-test-runner.mjs +59 -0
  64. package/scripts/build-test-dashboard.mjs +23 -0
  65. package/scripts/export-lane1-fixtures.mjs +380 -0
  66. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  67. package/scripts/export-validation-fixtures.mjs +1 -1
  68. package/scripts/extract_text.mjs +7 -0
  69. package/scripts/generate-cross-author-slicing-fixtures.ps1 +256 -0
  70. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  71. package/scripts/generate-test-dashboard.mjs +362 -11
  72. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  73. package/scripts/profile-route-selection.mjs +19 -0
  74. package/scripts/render-agenda-multilevel.mjs +0 -5
  75. package/scripts/render-multilevel-cases.mjs +0 -1
  76. package/scripts/run-tests.mjs +107 -35
  77. package/scripts/word-com-corpus-suite.ps1 +3 -0
  78. package/scripts/word-com-differential.ps1 +64 -4
  79. package/scripts/word-com-suite.ps1 +3 -0
  80. package/services/batch-operation-orchestrator.js +513 -0
  81. package/services/capture-engine.js +226 -0
  82. package/services/comment-builders.js +23 -6
  83. package/services/comment-engine.js +108 -47
  84. package/services/comment-locator.js +187 -82
  85. package/services/comment-replies.js +95 -0
  86. package/services/document-inspection.js +258 -0
  87. package/services/document-operation-applier.js +372 -0
  88. package/services/document-operation-contract.js +345 -0
  89. package/services/document-operation-mutations.js +1749 -0
  90. package/services/document-operation-session.js +258 -0
  91. package/services/numbering-service.js +14 -5
  92. package/services/operation-heuristics.js +173 -0
  93. package/services/operation-preflight.js +390 -0
  94. package/services/receipt-collector.js +288 -0
  95. package/services/revision-comment-management.js +77 -5
  96. package/services/revision-token.js +290 -0
  97. package/services/standalone-docx-plumbing.js +123 -8
  98. package/services/standalone-operation-runner.d.ts +296 -0
  99. package/services/standalone-operation-runner.js +10 -1455
  100. package/services/table-reconciliation.js +15 -6
  101. package/docs/VALIDATION.md +0 -183
  102. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  103. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  104. /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
@@ -6,20 +6,25 @@ 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,
12
13
  getRunTextLength,
13
14
  insertRunPiecesBefore,
14
- sliceRunPieces
15
+ sliceRunPieces,
16
+ splitTrackChangeCarrier
15
17
  } from './surgical-run-splitting.js';
16
18
  import {
17
19
  findContainingSpan,
18
20
  findFirstSpanEndingAt,
19
21
  findLastSpanEndingBeforeOrAt,
20
- forEachOverlappingSpan
22
+ forEachOverlappingSpan,
23
+ describeInsertionBoundary
21
24
  } from './surgical-spans.js';
22
25
  import { extractFormatFromRPr } from './rpr-helpers.js';
26
+ import { isWordElement } from '../core/word-xml.js';
27
+ import { NS_W } from '../core/types.js';
23
28
 
24
29
  export function reconcileFormattingForTextSpan(xmlDoc, span, start, end, applicableHints, author, generateRedlines) {
25
30
  // Plain modified text carries no negative formatting instruction. Preserve
@@ -67,7 +72,7 @@ export function reconcileFormattingForTextSpan(xmlDoc, span, start, end, applica
67
72
  return true;
68
73
  }
69
74
 
70
- export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, generateRedlines) {
75
+ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, generateRedlines, revisionMetadata = null) {
71
76
  const spans = [];
72
77
  forEachOverlappingSpan(spanIndex, startPos, endPos, span => {
73
78
  spans.push(span);
@@ -82,7 +87,7 @@ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, gener
82
87
  spansByRun.get(span.runElement).push(span);
83
88
  });
84
89
 
85
- let changed = false;
90
+ const records = [];
86
91
  spansByRun.forEach((runSpans, runElement) => {
87
92
  const parent = runElement.parentNode;
88
93
  if (!parent) return;
@@ -106,83 +111,435 @@ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, gener
106
111
 
107
112
  if (!Number.isFinite(deleteStart) || deleteEnd <= deleteStart) return;
108
113
 
109
- const beforePieces = sliceRunPieces(xmlDoc, pieces, 0, deleteStart, false);
110
- const deletedPieces = sliceRunPieces(xmlDoc, pieces, deleteStart, deleteEnd, true);
111
- const afterPieces = sliceRunPieces(xmlDoc, pieces, deleteEnd, getRunTextLength(pieces), false);
114
+ records.push({
115
+ runElement,
116
+ parent,
117
+ rPr: runSpans[0].rPr,
118
+ beforePieces: sliceRunPieces(xmlDoc, pieces, 0, deleteStart, false),
119
+ deletedPieces: sliceRunPieces(xmlDoc, pieces, deleteStart, deleteEnd, true),
120
+ afterPieces: sliceRunPieces(xmlDoc, pieces, deleteEnd, getRunTextLength(pieces), false),
121
+ globalStart: Math.max(startPos, Math.min(...runSpans.map(span => span.charStart))),
122
+ globalEnd: Math.min(endPos, Math.max(...runSpans.map(span => span.charEnd))),
123
+ carrierGlobalStart: isWordElement(parent, 'ins') ? getCarrierGlobalStart(spanIndex, parent) : null
124
+ });
125
+ });
112
126
 
113
- insertRunPiecesBefore(xmlDoc, parent, runElement, beforePieces, runSpans[0].rPr);
127
+ const groups = [];
128
+ for (const record of records) {
129
+ const previousGroup = groups[groups.length - 1];
130
+ const previousRecord = previousGroup?.[previousGroup.length - 1];
131
+ if (
132
+ previousRecord
133
+ && previousRecord.parent === record.parent
134
+ && nextElementSibling(previousRecord.runElement) === record.runElement
135
+ ) {
136
+ previousGroup.push(record);
137
+ } else {
138
+ groups.push([record]);
139
+ }
140
+ }
114
141
 
115
- if (generateRedlines && deletedPieces.length > 0) {
116
- const delRun = createRunFromPieces(xmlDoc, deletedPieces, runSpans[0].rPr);
117
- const delWrapper = createTrackChange(xmlDoc, 'del', delRun, author);
118
- parent.insertBefore(delWrapper, runElement);
142
+ let changed = false;
143
+ let usedDelMetadata = false;
144
+ for (const group of groups) {
145
+ const firstRecord = group[0];
146
+ let delWrapper = null;
147
+ if (generateRedlines && group.some(record => record.deletedPieces.length > 0)) {
148
+ const metadata = revisionMetadata
149
+ ? (usedDelMetadata ? { ...revisionMetadata, id: createRevisionMetadata(author, xmlDoc, 'del').id } : revisionMetadata)
150
+ : null;
151
+ usedDelMetadata = true;
152
+ delWrapper = createTrackChange(xmlDoc, 'del', null, author, metadata);
119
153
  }
120
154
 
121
- insertRunPiecesBefore(xmlDoc, parent, runElement, afterPieces, runSpans[0].rPr);
122
- parent.removeChild(runElement);
123
- changed = true;
124
- });
155
+ for (const record of group) {
156
+ const { parent, runElement } = record;
157
+ insertRunPiecesBefore(xmlDoc, parent, runElement, record.beforePieces, record.rPr);
158
+ if (delWrapper && record === firstRecord) {
159
+ parent.insertBefore(delWrapper, runElement);
160
+ }
161
+ if (delWrapper && record.deletedPieces.length > 0) {
162
+ delWrapper.appendChild(createRunFromPieces(xmlDoc, record.deletedPieces, record.rPr));
163
+ }
164
+ insertRunPiecesBefore(xmlDoc, parent, runElement, record.afterPieces, record.rPr);
165
+ parent.removeChild(runElement);
166
+ changed = true;
167
+ }
168
+
169
+ const carrier = isWordElement(firstRecord.parent, 'ins') ? firstRecord.parent : null;
170
+ const groupEnd = Math.max(...group.map(record => record.globalEnd));
171
+ if (carrier && groupEnd === endPos) {
172
+ const carrierStart = firstRecord.carrierGlobalStart;
173
+ const deletedBeforeEnd = group
174
+ .filter(record => record.globalStart < endPos)
175
+ .reduce((sum, record) => sum + record.deletedPieces.reduce((n, piece) => n + (piece.textContent || '').length, 0), 0);
176
+ const currentOffset = Math.max(0, endPos - carrierStart - deletedBeforeEnd);
177
+ if (!spanIndex.revisionInsertionAnchors) spanIndex.revisionInsertionAnchors = new Map();
178
+ spanIndex.revisionInsertionAnchors.set(endPos, {
179
+ carrier,
180
+ splitOffset: currentOffset,
181
+ rPr: firstRecord.rPr
182
+ });
183
+ }
184
+ }
125
185
 
126
186
  return changed;
127
187
  }
128
188
 
129
- export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], insertOffset = 0, generateRedlines = true, fallbackParagraph = null) {
130
- let targetSpan = findContainingSpan(spanIndex, pos);
189
+ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], insertOffset = 0, generateRedlines = true, fallbackParagraph = null, revisionMetadata = null, affinity = null, existingRevisions = 'merge-same-author') {
190
+ const mutationAnchor = spanIndex.revisionInsertionAnchors?.get(pos) || null;
191
+ if (
192
+ mutationAnchor
193
+ && existingRevisions === 'slice-cross-author'
194
+ && isConnected(mutationAnchor.carrier)
195
+ && isForeignInsertion(mutationAnchor.carrier, author)
196
+ ) {
197
+ spanIndex.revisionInsertionAnchors.delete(pos);
198
+ return spliceInsertionAtCarrierOffset(
199
+ xmlDoc,
200
+ mutationAnchor.carrier,
201
+ mutationAnchor.splitOffset,
202
+ text,
203
+ mutationAnchor.rPr,
204
+ author,
205
+ formatHints,
206
+ insertOffset,
207
+ generateRedlines,
208
+ revisionMetadata
209
+ );
210
+ }
211
+
212
+ if (!affinity) {
213
+ let targetSpan = findContainingSpan(spanIndex, pos);
214
+
215
+ if (!targetSpan && pos > 0) {
216
+ targetSpan = findFirstSpanEndingAt(spanIndex, pos);
217
+ }
218
+
219
+ if (!targetSpan && pos > 0) {
220
+ targetSpan = findLastSpanEndingBeforeOrAt(spanIndex, pos);
221
+ }
222
+
223
+ if (!targetSpan && spanIndex.spans.length > 0) {
224
+ targetSpan = spanIndex.spans[spanIndex.spans.length - 1];
225
+ }
226
+
227
+ if (!targetSpan) {
228
+ if (!fallbackParagraph) return false;
229
+ insertTextRuns(xmlDoc, fallbackParagraph, null, text, null, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
230
+ return true;
231
+ }
232
+
233
+ const parent = targetSpan.runElement.parentNode;
234
+ if (!parent) {
235
+ if (!fallbackParagraph) return false;
236
+ insertTextRuns(xmlDoc, fallbackParagraph, null, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
237
+ return true;
238
+ }
239
+
240
+ if (
241
+ generateRedlines
242
+ && existingRevisions === 'slice-cross-author'
243
+ && isForeignInsertion(parent, author)
244
+ ) {
245
+ return spliceInsertionAtCarrierOffset(
246
+ xmlDoc,
247
+ parent,
248
+ getCarrierSplitOffset(spanIndex, parent, pos),
249
+ text,
250
+ targetSpan.rPr,
251
+ author,
252
+ formatHints,
253
+ insertOffset,
254
+ generateRedlines,
255
+ revisionMetadata
256
+ );
257
+ }
131
258
 
132
- if (!targetSpan && pos > 0) {
133
- targetSpan = findFirstSpanEndingAt(spanIndex, pos);
259
+ const pieces = getRunContentPieces(targetSpan.runElement);
260
+ const targetPiece = pieces.find(piece => piece.node === targetSpan.textElement);
261
+ const localInsertPos = targetPiece
262
+ ? targetPiece.start + Math.max(0, Math.min(pos - targetSpan.charStart, targetSpan.charEnd - targetSpan.charStart))
263
+ : (pos <= targetSpan.charStart ? 0 : getRunTextLength(pieces));
264
+
265
+ if (localInsertPos > 0 && localInsertPos < getRunTextLength(pieces)) {
266
+ const beforePieces = sliceRunPieces(xmlDoc, pieces, 0, localInsertPos, false);
267
+ const afterPieces = sliceRunPieces(xmlDoc, pieces, localInsertPos, getRunTextLength(pieces), false);
268
+
269
+ insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, beforePieces, targetSpan.rPr);
270
+ insertTextRuns(xmlDoc, parent, targetSpan.runElement, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
271
+ insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, afterPieces, targetSpan.rPr);
272
+ parent.removeChild(targetSpan.runElement);
273
+ return true;
274
+ }
275
+
276
+ const referenceNode = pos <= targetSpan.charStart ? targetSpan.runElement : targetSpan.runElement.nextSibling;
277
+ insertTextRuns(xmlDoc, parent, referenceNode, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
278
+ return true;
134
279
  }
135
280
 
136
- if (!targetSpan && pos > 0) {
137
- targetSpan = findLastSpanEndingBeforeOrAt(spanIndex, pos);
281
+ const boundary = describeInsertionBoundary(spanIndex, pos, fallbackParagraph);
282
+
283
+ // Validate hyperlink affinity
284
+ const isLeftInHyperlink = boundary.leftSpan && isWordElement(boundary.leftSpan.runElement?.parentNode, 'hyperlink');
285
+ const isRightInHyperlink = boundary.rightSpan && isWordElement(boundary.rightSpan.runElement?.parentNode, 'hyperlink');
286
+ const isContainingInHyperlink = boundary.containingSpan && isWordElement(boundary.containingSpan.runElement?.parentNode, 'hyperlink');
287
+
288
+ if (affinity.hyperlink === 'outside') {
289
+ if (boundary.isInterior && isContainingInHyperlink) {
290
+ return {
291
+ error: {
292
+ code: 'UNSUPPORTED_INSERTION_AFFINITY',
293
+ message: 'Cannot place insertion outside hyperlink from strictly interior position.'
294
+ }
295
+ };
296
+ }
297
+ } else if (affinity.hyperlink === 'inside') {
298
+ if (!isLeftInHyperlink && !isRightInHyperlink && !isContainingInHyperlink) {
299
+ return {
300
+ error: {
301
+ code: 'UNSUPPORTED_INSERTION_AFFINITY',
302
+ message: 'Cannot place insertion inside hyperlink when no hyperlink is present at boundary.'
303
+ }
304
+ };
305
+ }
138
306
  }
139
307
 
140
- if (!targetSpan && spanIndex.spans.length > 0) {
141
- targetSpan = spanIndex.spans[spanIndex.spans.length - 1];
308
+ // Determine formatting (baseRPr)
309
+ let baseRPr = null;
310
+ if (affinity.formatting === 'none') {
311
+ baseRPr = null;
312
+ } else if (affinity.formatting === 'right') {
313
+ baseRPr = boundary.rightSpan?.rPr || null;
314
+ } else if (affinity.formatting === 'left') {
315
+ baseRPr = boundary.leftSpan?.rPr || null;
316
+ } else {
317
+ baseRPr = (boundary.containingSpan || boundary.leftSpan || boundary.rightSpan)?.rPr || null;
142
318
  }
143
319
 
144
- if (!targetSpan) {
145
- if (!fallbackParagraph) return false;
146
- insertTextRuns(xmlDoc, fallbackParagraph, null, text, null, author, formatHints, insertOffset, generateRedlines);
147
- return true;
320
+ // Check interior of a run
321
+ if (boundary.isInterior) {
322
+ const targetSpan = boundary.containingSpan;
323
+ const parent = targetSpan.runElement.parentNode || fallbackParagraph;
324
+ if (!parent) return false;
325
+
326
+ const pieces = getRunContentPieces(targetSpan.runElement);
327
+ const targetPiece = pieces.find(piece => piece.node === targetSpan.textElement);
328
+ const localInsertPos = targetPiece
329
+ ? targetPiece.start + Math.max(0, Math.min(pos - targetSpan.charStart, targetSpan.charEnd - targetSpan.charStart))
330
+ : (pos <= targetSpan.charStart ? 0 : getRunTextLength(pieces));
331
+
332
+ if (localInsertPos > 0 && localInsertPos < getRunTextLength(pieces)) {
333
+ const beforePieces = sliceRunPieces(xmlDoc, pieces, 0, localInsertPos, false);
334
+ const afterPieces = sliceRunPieces(xmlDoc, pieces, localInsertPos, getRunTextLength(pieces), false);
335
+
336
+ insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, beforePieces, targetSpan.rPr);
337
+ insertTextRuns(xmlDoc, parent, targetSpan.runElement, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
338
+ insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, afterPieces, targetSpan.rPr);
339
+ parent.removeChild(targetSpan.runElement);
340
+ return true;
341
+ }
342
+ }
343
+
344
+ // Boundary between runs or at start/end of paragraph
345
+ let parent = null;
346
+ let referenceNode = null;
347
+
348
+ if (affinity.hyperlink === 'outside') {
349
+ if (isRightInHyperlink) {
350
+ const hyperlinkNode = boundary.rightSpan.runElement.parentNode;
351
+ parent = hyperlinkNode.parentNode || fallbackParagraph;
352
+ referenceNode = hyperlinkNode;
353
+ } else if (isLeftInHyperlink) {
354
+ const hyperlinkNode = boundary.leftSpan.runElement.parentNode;
355
+ parent = hyperlinkNode.parentNode || fallbackParagraph;
356
+ referenceNode = hyperlinkNode.nextSibling;
357
+ }
358
+ } else if (affinity.hyperlink === 'inside') {
359
+ if (isRightInHyperlink) {
360
+ parent = boundary.rightSpan.runElement.parentNode;
361
+ referenceNode = boundary.rightSpan.runElement;
362
+ } else if (isLeftInHyperlink) {
363
+ parent = boundary.leftSpan.runElement.parentNode;
364
+ referenceNode = boundary.leftSpan.runElement.nextSibling;
365
+ }
148
366
  }
149
367
 
150
- const parent = targetSpan.runElement.parentNode;
151
368
  if (!parent) {
152
- if (!fallbackParagraph) return false;
153
- insertTextRuns(xmlDoc, fallbackParagraph, null, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines);
154
- return true;
369
+ if (boundary.rightSpan) {
370
+ parent = boundary.rightSpan.runElement.parentNode || fallbackParagraph;
371
+ referenceNode = boundary.rightSpan.runElement;
372
+ } else if (boundary.leftSpan) {
373
+ parent = boundary.leftSpan.runElement.parentNode || fallbackParagraph;
374
+ referenceNode = boundary.leftSpan.runElement.nextSibling;
375
+ } else {
376
+ parent = fallbackParagraph;
377
+ referenceNode = null;
378
+ }
155
379
  }
156
380
 
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));
381
+ // Check bookmark range affinity
382
+ if (affinity.bookmark && parent) {
383
+ if (affinity.bookmark === 'outside') {
384
+ if (referenceNode && isWordElement(referenceNode.previousSibling, 'bookmarkStart')) {
385
+ referenceNode = referenceNode.previousSibling;
386
+ }
387
+ if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'bookmarkEnd')) {
388
+ referenceNode = boundary.leftSpan.runElement.nextSibling.nextSibling;
389
+ }
390
+ } else if (affinity.bookmark === 'inside') {
391
+ if (referenceNode && isWordElement(referenceNode, 'bookmarkStart')) {
392
+ referenceNode = referenceNode.nextSibling;
393
+ }
394
+ if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'bookmarkEnd')) {
395
+ referenceNode = boundary.leftSpan.runElement.nextSibling;
396
+ }
397
+ }
398
+ }
162
399
 
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);
400
+ // Check comment range affinity
401
+ if (affinity.comment && parent) {
402
+ if (affinity.comment === 'outside') {
403
+ if (referenceNode && isWordElement(referenceNode.previousSibling, 'commentRangeStart')) {
404
+ referenceNode = referenceNode.previousSibling;
405
+ }
406
+ if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'commentRangeEnd')) {
407
+ let afterComment = boundary.leftSpan.runElement.nextSibling.nextSibling;
408
+ if (afterComment && (isWordElement(afterComment, 'commentReference') || isWordElement(afterComment, 'r'))) {
409
+ const hasCRef = Array.from(afterComment.childNodes || []).some(n => isWordElement(n, 'commentReference'));
410
+ if (hasCRef) afterComment = afterComment.nextSibling;
411
+ }
412
+ referenceNode = afterComment;
413
+ }
414
+ } else if (affinity.comment === 'inside') {
415
+ if (referenceNode && isWordElement(referenceNode, 'commentRangeStart')) {
416
+ referenceNode = referenceNode.nextSibling;
417
+ }
418
+ if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'commentRangeEnd')) {
419
+ referenceNode = boundary.leftSpan.runElement.nextSibling;
420
+ }
421
+ }
422
+ }
166
423
 
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;
424
+ // Check revision affinity (coalesce_same_author)
425
+ if (generateRedlines && affinity.revision === 'coalesce_same_author') {
426
+ let insElem = null;
427
+ let insRef = null;
428
+
429
+ if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.parentNode, 'ins')) {
430
+ const candidate = boundary.leftSpan.runElement.parentNode;
431
+ const candAuthor = candidate.getAttribute('w:author') || candidate.getAttributeNS(NS_W, 'author');
432
+ if (candAuthor === author) {
433
+ insElem = candidate;
434
+ insRef = boundary.leftSpan.runElement.nextSibling;
435
+ }
436
+ } else if (boundary.rightSpan && isWordElement(boundary.rightSpan.runElement.parentNode, 'ins')) {
437
+ const candidate = boundary.rightSpan.runElement.parentNode;
438
+ const candAuthor = candidate.getAttribute('w:author') || candidate.getAttributeNS(NS_W, 'author');
439
+ if (candAuthor === author) {
440
+ insElem = candidate;
441
+ insRef = boundary.rightSpan.runElement;
442
+ }
443
+ }
444
+
445
+ if (insElem) {
446
+ const insRun = createTextRun(xmlDoc, text, baseRPr, false);
447
+ insElem.insertBefore(insRun, insRef);
448
+ return true;
449
+ }
450
+ }
451
+
452
+ if (
453
+ generateRedlines
454
+ && existingRevisions === 'slice-cross-author'
455
+ && isForeignInsertion(parent, author)
456
+ ) {
457
+ return spliceInsertionAtCarrierOffset(
458
+ xmlDoc,
459
+ parent,
460
+ getCarrierSplitOffset(spanIndex, parent, pos),
461
+ text,
462
+ baseRPr,
463
+ author,
464
+ formatHints,
465
+ insertOffset,
466
+ generateRedlines,
467
+ revisionMetadata
468
+ );
172
469
  }
173
470
 
174
- const referenceNode = pos <= targetSpan.charStart ? targetSpan.runElement : targetSpan.runElement.nextSibling;
175
- insertTextRuns(xmlDoc, parent, referenceNode, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines);
471
+ insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
176
472
  return true;
177
473
  }
178
474
 
179
- function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines) {
475
+ function spliceInsertionAtCarrierOffset(xmlDoc, carrier, splitOffset, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata) {
476
+ const parent = carrier.parentNode;
477
+ if (!parent) return false;
478
+
479
+ const { leftCarrier, rightCarrier } = splitTrackChangeCarrier(xmlDoc, carrier, splitOffset);
480
+ if (leftCarrier) parent.insertBefore(leftCarrier, carrier);
481
+ insertTextRuns(
482
+ xmlDoc,
483
+ parent,
484
+ carrier,
485
+ text,
486
+ withoutRunPropertyChanges(baseRPr),
487
+ author,
488
+ formatHints,
489
+ insertOffset,
490
+ generateRedlines,
491
+ revisionMetadata
492
+ );
493
+ if (rightCarrier) parent.insertBefore(rightCarrier, carrier);
494
+ parent.removeChild(carrier);
495
+ return true;
496
+ }
497
+
498
+ function withoutRunPropertyChanges(rPr) {
499
+ if (!rPr) return null;
500
+ const clone = rPr.cloneNode(true);
501
+ const changes = Array.from(clone.getElementsByTagName?.('*') || [])
502
+ .filter(node => isWordElement(node, 'rPrChange'));
503
+ changes.forEach(node => node.parentNode?.removeChild(node));
504
+ return clone;
505
+ }
506
+
507
+ function getCarrierSplitOffset(spanIndex, carrier, pos) {
508
+ const carrierStart = getCarrierGlobalStart(spanIndex, carrier);
509
+ const carrierLength = spanIndex.spans
510
+ .filter(span => span.runElement?.parentNode === carrier)
511
+ .reduce((length, span) => length + (span.charEnd - span.charStart), 0);
512
+ return Math.max(0, Math.min(pos - carrierStart, carrierLength));
513
+ }
514
+
515
+ function getCarrierGlobalStart(spanIndex, carrier) {
516
+ const carrierSpans = spanIndex.spans.filter(span => span.runElement?.parentNode === carrier);
517
+ return carrierSpans.length > 0 ? Math.min(...carrierSpans.map(span => span.charStart)) : 0;
518
+ }
519
+
520
+ function isForeignInsertion(node, author) {
521
+ if (!isWordElement(node, 'ins')) return false;
522
+ const carrierAuthor = node.getAttribute('w:author') || node.getAttributeNS?.(NS_W, 'author') || '';
523
+ return carrierAuthor.trim().toLowerCase() !== String(author || '').trim().toLowerCase();
524
+ }
525
+
526
+ function nextElementSibling(node) {
527
+ let sibling = node?.nextSibling || null;
528
+ while (sibling && sibling.nodeType !== 1) sibling = sibling.nextSibling;
529
+ return sibling;
530
+ }
531
+
532
+ function isConnected(node) {
533
+ return !!node?.parentNode;
534
+ }
535
+
536
+ function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata = null) {
180
537
  const applicableHints = getApplicableFormatHints(formatHints, insertOffset, insertOffset + text.length);
181
538
 
182
539
  if (applicableHints.length === 0) {
183
540
  const insRun = createTextRun(xmlDoc, text, baseRPr, false);
184
541
  if (generateRedlines) {
185
- const insWrapper = createTrackChange(xmlDoc, 'ins', insRun, author);
542
+ const insWrapper = createTrackChange(xmlDoc, 'ins', insRun, author, revisionMetadata);
186
543
  parent.insertBefore(insWrapper, referenceNode);
187
544
  } else {
188
545
  parent.insertBefore(insRun, referenceNode);
@@ -193,7 +550,7 @@ function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, fo
193
550
  const runs = createFormattedRuns(xmlDoc, text, baseRPr, applicableHints, insertOffset, author, generateRedlines);
194
551
 
195
552
  if (generateRedlines) {
196
- const insWrapper = createTrackChange(xmlDoc, 'ins', null, author);
553
+ const insWrapper = createTrackChange(xmlDoc, 'ins', null, author, revisionMetadata);
197
554
  runs.forEach(run => insWrapper.appendChild(run));
198
555
  parent.insertBefore(insWrapper, referenceNode);
199
556
  } else {