@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,21 +6,21 @@ import { computeWordDiffs } from '../pipeline/diff-engine.js';
6
6
  import { buildReconstructionMapping, findReconstructionParagraphRange } from './reconstruction-mapper.js';
7
7
  import { applyReconstructionDiffs } from './reconstruction-writer.js';
8
8
  import { withOoxmlSourceType } from '../core/word-xml.js';
9
-
10
- /**
11
- * Applies reconstruction mode reconciliation.
12
- *
13
- * @param {Document} xmlDoc - XML document
14
- * @param {string} originalText - Original text (kept for signature compatibility)
15
- * @param {string} modifiedText - Modified text
16
- * @param {XMLSerializer} serializer - Serializer instance
17
- * @param {string} author - Author name
18
- * @param {Array} formatHints - Format hints
19
- * @param {boolean} [generateRedlines=true] - Track change toggle
9
+
10
+ /**
11
+ * Applies reconstruction mode reconciliation.
12
+ *
13
+ * @param {Document} xmlDoc - XML document
14
+ * @param {string} originalText - Original text (kept for signature compatibility)
15
+ * @param {string} modifiedText - Modified text
16
+ * @param {XMLSerializer} serializer - Serializer instance
17
+ * @param {string} author - Author name
18
+ * @param {Array} formatHints - Format hints
19
+ * @param {boolean} [generateRedlines=true] - Track change toggle
20
20
  * @param {{ diffTimeoutSeconds?: number }} [diffOptions={}] - Diff configuration
21
21
  * @returns {{ oxml: string, hasChanges: boolean }}
22
22
  */
23
- export function applyReconstructionMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, diffOptions = {}) {
23
+ export function applyReconstructionMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, diffOptions = {}, options = {}) {
24
24
  const selectedParagraphs = findReconstructionParagraphRange(xmlDoc, originalText);
25
25
  if (selectedParagraphs === null) {
26
26
  return withOoxmlSourceType({
@@ -38,7 +38,7 @@ export function applyReconstructionMode(xmlDoc, originalText, modifiedText, seri
38
38
  if (mapping.paragraphs.length === 0) {
39
39
  return withOoxmlSourceType({ oxml: serializer.serializeToString(xmlDoc), hasChanges: false });
40
40
  }
41
-
41
+
42
42
  const diffs = computeWordDiffs(mapping.originalFullText, mapping.processedModifiedText, diffOptions);
43
43
 
44
44
  return withOoxmlSourceType(applyReconstructionDiffs(
@@ -48,6 +48,7 @@ export function applyReconstructionMode(xmlDoc, originalText, modifiedText, seri
48
48
  serializer,
49
49
  author,
50
50
  formatHints,
51
- generateRedlines
51
+ generateRedlines,
52
+ options
52
53
  ));
53
54
  }
@@ -1,9 +1,9 @@
1
- /**
2
- * Reconstruction writer.
3
- *
4
- * Applies diff segments to mapped reconstruction context and writes updated DOM content.
5
- */
6
-
1
+ /**
2
+ * Reconstruction writer.
3
+ *
4
+ * Applies diff segments to mapped reconstruction context and writes updated DOM content.
5
+ */
6
+
7
7
  import { getApplicableFormatHints } from '../pipeline/markdown-processor.js';
8
8
  import {
9
9
  createTrackChange,
@@ -12,41 +12,49 @@ import {
12
12
  markParagraphMarkInserted
13
13
  } from './run-builders.js';
14
14
  import { getFirstElementByTagNSOrTag } from '../core/xml-query.js';
15
- import { NS_W } from '../core/types.js';
15
+ import { NS_W, createReplacementRevisionEvent, createRevisionMetadata } from '../core/types.js';
16
16
  import { createWordElement, isWordElement } from '../core/word-xml.js';
17
-
18
- /**
19
- * Applies diffs to reconstruction context and writes updated XML.
20
- *
21
- * @param {Document} xmlDoc - XML document
22
- * @param {Array<[number, string]>} diffs - Diff tuples from diff-match-patch
23
- * @param {ReturnType<import('./reconstruction-mapper.js').buildReconstructionMapping>} context - Reconstruction mapping
24
- * @param {XMLSerializer} serializer - Serializer instance
25
- * @param {string} author - Author name
26
- * @param {Array} formatHints - Format hints
27
- * @param {boolean} [generateRedlines=true] - Track change toggle
28
- * @returns {{ oxml: string, hasChanges: boolean }}
29
- */
30
- export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, author, formatHints, generateRedlines = true) {
31
- const {
17
+
18
+ /**
19
+ * Applies diffs to reconstruction context and writes updated XML.
20
+ *
21
+ * @param {Document} xmlDoc - XML document
22
+ * @param {Array<[number, string]>} diffs - Diff tuples from diff-match-patch
23
+ * @param {ReturnType<import('./reconstruction-mapper.js').buildReconstructionMapping>} context - Reconstruction mapping
24
+ * @param {XMLSerializer} serializer - Serializer instance
25
+ * @param {string} author - Author name
26
+ * @param {Array} formatHints - Format hints
27
+ * @param {boolean} [generateRedlines=true] - Track change toggle
28
+ * @returns {{ oxml: string, hasChanges: boolean }}
29
+ */
30
+ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, author, formatHints, generateRedlines = true, options = {}) {
31
+ const {
32
32
  paragraphs,
33
33
  containerFragments,
34
- sentinelMapByStart,
35
- referenceMap,
36
- replacementContainers,
37
- getParagraphInfo,
38
- getRunProperties,
39
- getPropertySpanLength,
40
- isParagraphStart
34
+ sentinelMapByStart,
35
+ referenceMap,
36
+ replacementContainers,
37
+ getParagraphInfo,
38
+ getRunProperties,
39
+ getPropertySpanLength,
40
+ isParagraphStart
41
41
  } = context;
42
42
 
43
43
  const createNewParagraph = (pPr) => {
44
44
  const newParagraph = createWordElement(xmlDoc, 'w:p');
45
- if (pPr) newParagraph.appendChild(pPr.cloneNode(true));
45
+ if (pPr) {
46
+ const clonedPPr = pPr.cloneNode(true);
47
+ // Safeguard: Never place w:sectPr on both paragraphs during split
48
+ const sectPr = getFirstElementByTagNSOrTag(clonedPPr, NS_W, 'sectPr');
49
+ if (sectPr) {
50
+ clonedPPr.removeChild(sectPr);
51
+ }
52
+ newParagraph.appendChild(clonedPPr);
53
+ }
46
54
  return newParagraph;
47
55
  };
48
-
49
- const startInfo = getParagraphInfo(0);
56
+
57
+ const startInfo = getParagraphInfo(0);
50
58
  let currentParagraph = createNewParagraph(startInfo.pPr);
51
59
  const initialFragment = containerFragments.get(startInfo.container);
52
60
  if (initialFragment) {
@@ -55,61 +63,69 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
55
63
 
56
64
  let currentOriginalIndex = 0;
57
65
  let currentInsertOffset = 0;
58
- // A replacement is represented as one or more deletions followed by an
59
- // insertion. Remember where that deleted range began so replacement text
60
- // inherits the formatting at the start of the range, rather than the last
61
- // deleted character (which may be a superscript ordinal, footnote-style
62
- // run, or another narrow formatting boundary).
66
+ const pairReplacements = options?.pairReplacements === true;
63
67
  let pendingReplacementStart = null;
68
+ let pendingReplacementEvent = null;
64
69
  const emittedCommentMarkers = new WeakSet();
65
70
 
66
- for (const [op, text] of diffs) {
71
+ for (let diffIndex = 0; diffIndex < diffs.length; diffIndex++) {
72
+ const [op, text] = diffs[diffIndex];
67
73
  if (op === 0 || op === -1) {
68
74
  const type = op === 0 ? 'equal' : 'delete';
69
75
  if (op === 0) {
70
76
  pendingReplacementStart = null;
77
+ pendingReplacementEvent = null;
71
78
  } else if (pendingReplacementStart === null) {
72
79
  pendingReplacementStart = currentOriginalIndex;
80
+ let hasInsert = false;
81
+ for (let k = diffIndex + 1; k < diffs.length; k++) {
82
+ if (diffs[k][0] === 1) { hasInsert = true; break; }
83
+ if (diffs[k][0] === 0) break;
84
+ }
85
+ if (pairReplacements && generateRedlines && hasInsert) {
86
+ pendingReplacementEvent = createReplacementRevisionEvent(author, xmlDoc);
87
+ }
73
88
  }
74
89
  let offset = 0;
75
-
76
- while (offset < text.length) {
77
- const chunkStart = currentOriginalIndex + offset;
78
- const properties = getRunProperties(chunkStart);
79
- const chunkLength = getPropertySpanLength(chunkStart, text.length - offset);
80
- const chunk = text.substring(offset, offset + chunkLength);
81
-
90
+
91
+ while (offset < text.length) {
92
+ const chunkStart = currentOriginalIndex + offset;
93
+ const properties = getRunProperties(chunkStart);
94
+ const chunkLength = getPropertySpanLength(chunkStart, text.length - offset);
95
+ const chunk = text.substring(offset, offset + chunkLength);
96
+
82
97
  const appendResult = appendTextToCurrent(
83
98
  xmlDoc,
84
99
  chunk,
85
- type,
86
- properties.rPr,
87
- properties.wrapper,
88
- chunkStart,
89
- currentParagraph,
90
- containerFragments,
91
- sentinelMapByStart,
92
- referenceMap,
93
- replacementContainers,
94
- getParagraphInfo,
95
- createNewParagraph,
96
- author,
100
+ type,
101
+ properties.rPr,
102
+ properties.wrapper,
103
+ chunkStart,
104
+ currentParagraph,
105
+ containerFragments,
106
+ sentinelMapByStart,
107
+ referenceMap,
108
+ replacementContainers,
109
+ getParagraphInfo,
110
+ createNewParagraph,
111
+ author,
97
112
  formatHints,
98
113
  currentInsertOffset,
99
114
  generateRedlines,
100
- emittedCommentMarkers
115
+ emittedCommentMarkers,
116
+ pendingReplacementEvent
101
117
  );
102
118
  currentParagraph = appendResult.currentParagraph;
103
119
 
104
120
  if (op === 0) {
105
121
  currentInsertOffset += chunkLength;
106
- }
107
- offset += chunkLength;
108
- }
109
-
110
- currentOriginalIndex += text.length;
111
- continue;
112
- }
122
+ }
123
+ offset += chunkLength;
124
+ }
125
+
126
+ currentOriginalIndex += text.length;
127
+ continue;
128
+ }
113
129
 
114
130
  if (op === 1) {
115
131
  const propertyIndex = pendingReplacementStart !== null
@@ -118,33 +134,69 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
118
134
  ? currentOriginalIndex - 1
119
135
  : currentOriginalIndex);
120
136
  const properties = getRunProperties(propertyIndex);
121
-
137
+
122
138
  const appendResult = appendTextToCurrent(
123
139
  xmlDoc,
124
140
  text,
125
- 'insert',
126
- properties.rPr,
127
- properties.wrapper,
128
- currentOriginalIndex,
129
- currentParagraph,
130
- containerFragments,
131
- sentinelMapByStart,
132
- referenceMap,
133
- replacementContainers,
134
- getParagraphInfo,
135
- createNewParagraph,
136
- author,
141
+ 'insert',
142
+ properties.rPr,
143
+ properties.wrapper,
144
+ currentOriginalIndex,
145
+ currentParagraph,
146
+ containerFragments,
147
+ sentinelMapByStart,
148
+ referenceMap,
149
+ replacementContainers,
150
+ getParagraphInfo,
151
+ createNewParagraph,
152
+ author,
137
153
  formatHints,
138
154
  currentInsertOffset,
139
155
  generateRedlines,
140
- emittedCommentMarkers
156
+ emittedCommentMarkers,
157
+ pendingReplacementEvent,
158
+ pendingReplacementStart !== null
141
159
  );
142
160
  currentParagraph = appendResult.currentParagraph;
143
161
  currentInsertOffset += text.length;
144
162
  pendingReplacementStart = null;
163
+ pendingReplacementEvent = null;
145
164
  }
146
165
  }
147
-
166
+
167
+ if (generateRedlines) {
168
+ containerFragments.forEach(fragment => {
169
+ Array.from(fragment.childNodes).forEach(node => {
170
+ if (isWordElement(node, 'p')) {
171
+ const hasVisibleText = Array.from(node.getElementsByTagNameNS(NS_W, 't')).length > 0;
172
+ const hasDeletedText = Array.from(node.getElementsByTagNameNS(NS_W, 'delText')).length > 0;
173
+ if (paragraphs.length === 1 && !hasVisibleText && (hasDeletedText || context.originalFullText.trim() !== '')) {
174
+ markParagraphMarkDeleted(xmlDoc, node, author);
175
+ }
176
+ }
177
+ });
178
+ });
179
+ }
180
+
181
+ containerFragments.forEach(fragment => {
182
+ const createdParagraphs = Array.from(fragment.childNodes).filter(node => isWordElement(node, 'p'));
183
+ if (createdParagraphs.length > 1) {
184
+ const firstP = createdParagraphs[0];
185
+ const lastP = createdParagraphs[createdParagraphs.length - 1];
186
+ const firstPPr = getFirstElementByTagNSOrTag(firstP, NS_W, 'pPr');
187
+ const sectPr = firstPPr ? getFirstElementByTagNSOrTag(firstPPr, NS_W, 'sectPr') : null;
188
+ if (sectPr) {
189
+ firstPPr.removeChild(sectPr);
190
+ let lastPPr = getFirstElementByTagNSOrTag(lastP, NS_W, 'pPr');
191
+ if (!lastPPr) {
192
+ lastPPr = createWordElement(xmlDoc, 'w:pPr');
193
+ lastP.insertBefore(lastPPr, lastP.firstChild || null);
194
+ }
195
+ lastPPr.appendChild(sectPr);
196
+ }
197
+ }
198
+ });
199
+
148
200
  const paragraphSet = new Set(paragraphs);
149
201
  const insertionAnchors = new Map();
150
202
  paragraphs.forEach(paragraph => {
@@ -213,29 +265,48 @@ function appendTextToCurrent(
213
265
  formatHints = [],
214
266
  insertOffset = 0,
215
267
  generateRedlines = true,
216
- emittedCommentMarkers = new WeakSet()
268
+ emittedCommentMarkers = new WeakSet(),
269
+ replacementEvent = null,
270
+ deferClosingCommentMarkers = false
217
271
  ) {
218
272
  let localBaseIndex = baseIndex;
219
273
  let localInsertOffset = insertOffset;
220
274
  let localParagraph = currentParagraphRef;
275
+ const deferredCommentMarkers = [];
221
276
 
222
277
  const parts = text.split(/([\n\uFFFC]|[\uE000-\uF8FF])/);
223
278
 
224
279
  parts.forEach(part => {
225
280
  const sentinelsAtOffset = sentinelMapByStart.get(localBaseIndex) || [];
226
281
  const commentMarkers = sentinelsAtOffset.filter(sentinel => sentinel.isCommentMarker && !emittedCommentMarkers.has(sentinel.node));
282
+ const closingCommentIds = deferClosingCommentMarkers
283
+ ? new Set(commentMarkers
284
+ .filter(marker => isWordElement(marker.node, 'commentRangeEnd'))
285
+ .map(marker => marker.node.getAttributeNS?.(NS_W, 'id') || marker.node.getAttribute?.('w:id') || marker.node.getAttribute?.('id')))
286
+ : new Set();
227
287
 
228
288
  commentMarkers.forEach(marker => {
229
289
  emittedCommentMarkers.add(marker.node);
290
+ const markerId = marker.node.getAttributeNS?.(NS_W, 'id')
291
+ || marker.node.getAttribute?.('w:id')
292
+ || marker.node.getAttribute?.('id');
293
+ if (
294
+ deferClosingCommentMarkers
295
+ && closingCommentIds.has(markerId)
296
+ && (isWordElement(marker.node, 'commentRangeEnd') || isWordElement(marker.node, 'commentReference'))
297
+ ) {
298
+ deferredCommentMarkers.push(marker.node);
299
+ return;
300
+ }
230
301
  if (isWordElement(marker.node, 'commentReference')) {
231
302
  const run = createWordElement(xmlDoc, 'w:r');
232
303
  run.appendChild(marker.node.cloneNode(true));
233
304
  localParagraph.appendChild(run);
234
305
  } else {
235
- localParagraph.appendChild(marker.node.cloneNode(true));
236
- }
237
- });
238
-
306
+ localParagraph.appendChild(marker.node.cloneNode(true));
307
+ }
308
+ });
309
+
239
310
  if (part === '\n') {
240
311
  const info = getParagraphInfo(localBaseIndex + 1);
241
312
  const nextParagraph = createNewParagraph(info.pPr);
@@ -252,20 +323,20 @@ function appendTextToCurrent(
252
323
  }
253
324
  localBaseIndex++;
254
325
  if (type !== 'delete') localInsertOffset++;
255
- return;
256
- }
257
-
258
- if (part === '\uFFFC') {
259
- const sentinel = sentinelsAtOffset.find(entry => !entry.isCommentMarker) || sentinelsAtOffset[0];
260
- if (sentinel) {
261
- const clone = sentinel.node.cloneNode(true);
262
- if (sentinel.isTextBox && sentinel.originalContainer) {
326
+ return;
327
+ }
328
+
329
+ if (part === '\uFFFC') {
330
+ const sentinel = sentinelsAtOffset.find(entry => !entry.isCommentMarker) || sentinelsAtOffset[0];
331
+ if (sentinel) {
332
+ const clone = sentinel.node.cloneNode(true);
333
+ if (sentinel.isTextBox && sentinel.originalContainer) {
263
334
  const newContainer = getFirstElementByTagNSOrTag(clone, NS_W, 'txbxContent');
264
- if (newContainer) {
265
- while (newContainer.firstChild) newContainer.removeChild(newContainer.firstChild);
266
- replacementContainers.set(sentinel.originalContainer, newContainer);
267
- }
268
- }
335
+ if (newContainer) {
336
+ while (newContainer.firstChild) newContainer.removeChild(newContainer.firstChild);
337
+ replacementContainers.set(sentinel.originalContainer, newContainer);
338
+ }
339
+ }
269
340
  if (sentinel.wrapInRun) {
270
341
  const run = createWordElement(xmlDoc, 'w:r');
271
342
  if (sentinel.rPr) run.appendChild(sentinel.rPr.cloneNode(true));
@@ -274,14 +345,14 @@ function appendTextToCurrent(
274
345
  } else {
275
346
  localParagraph.appendChild(clone);
276
347
  }
277
- }
278
- localBaseIndex++;
279
- if (type !== 'delete') localInsertOffset++;
280
- return;
281
- }
282
-
283
- if (referenceMap.has(part)) {
284
- if (type !== 'delete') {
348
+ }
349
+ localBaseIndex++;
350
+ if (type !== 'delete') localInsertOffset++;
351
+ return;
352
+ }
353
+
354
+ if (referenceMap.has(part)) {
355
+ if (type !== 'delete') {
285
356
  const refNode = referenceMap.get(part);
286
357
  if (refNode) {
287
358
  const clone = refNode.cloneNode(true);
@@ -289,21 +360,21 @@ function appendTextToCurrent(
289
360
  if (rPr) run.appendChild(rPr.cloneNode(true));
290
361
  run.appendChild(clone);
291
362
  localParagraph.appendChild(run);
292
- }
293
- }
294
- localBaseIndex++;
295
- if (type !== 'delete') localInsertOffset++;
296
- return;
297
- }
298
-
299
- if (part.length === 0) return;
300
-
301
- let parent = localParagraph;
302
- if (wrapper) {
303
- const wrapperClone = wrapper.cloneNode(false);
304
- parent = wrapperClone;
305
- localParagraph.appendChild(wrapperClone);
306
- }
363
+ }
364
+ }
365
+ localBaseIndex++;
366
+ if (type !== 'delete') localInsertOffset++;
367
+ return;
368
+ }
369
+
370
+ if (part.length === 0) return;
371
+
372
+ let parent = localParagraph;
373
+ if (wrapper) {
374
+ const wrapperClone = wrapper.cloneNode(false);
375
+ parent = wrapperClone;
376
+ localParagraph.appendChild(wrapperClone);
377
+ }
307
378
 
308
379
  if (type === 'delete') {
309
380
  const run = createWordElement(xmlDoc, 'w:r');
@@ -312,29 +383,63 @@ function appendTextToCurrent(
312
383
  delText.setAttribute('xml:space', 'preserve');
313
384
  delText.textContent = part;
314
385
  run.appendChild(delText);
315
-
316
- if (generateRedlines) {
317
- const del = createTrackChange(xmlDoc, 'del', run, author);
318
- parent.appendChild(del);
319
- }
320
- } else {
321
- const applicableHints = getApplicableFormatHints(formatHints, localInsertOffset, localInsertOffset + part.length);
322
- const runs = createFormattedRuns(xmlDoc, part, rPr, applicableHints, localInsertOffset, author, generateRedlines);
323
-
324
- if (type === 'insert' && generateRedlines) {
325
- const ins = createTrackChange(xmlDoc, 'ins', null, author);
326
- runs.forEach(run => ins.appendChild(run));
327
- parent.appendChild(ins);
328
- } else {
329
- runs.forEach(run => parent.appendChild(run));
330
- }
331
- }
332
-
333
- if (type !== 'delete') {
334
- localInsertOffset += part.length;
335
- }
386
+
387
+ if (generateRedlines) {
388
+ let metadata = null;
389
+ if (replacementEvent) {
390
+ const id = replacementEvent.usedDeletionId
391
+ ? createRevisionMetadata(author, xmlDoc, 'del').id
392
+ : replacementEvent.deletionId;
393
+ replacementEvent.usedDeletionId = true;
394
+ metadata = {
395
+ id,
396
+ author: replacementEvent.author,
397
+ date: replacementEvent.date
398
+ };
399
+ }
400
+ const del = createTrackChange(xmlDoc, 'del', run, author, metadata);
401
+ parent.appendChild(del);
402
+ }
403
+ } else {
404
+ const applicableHints = getApplicableFormatHints(formatHints, localInsertOffset, localInsertOffset + part.length);
405
+ const runs = createFormattedRuns(xmlDoc, part, rPr, applicableHints, localInsertOffset, author, generateRedlines);
406
+
407
+ if (type === 'insert' && generateRedlines) {
408
+ let metadata = null;
409
+ if (replacementEvent) {
410
+ const id = replacementEvent.usedInsertionId
411
+ ? createRevisionMetadata(author, xmlDoc, 'ins').id
412
+ : replacementEvent.insertionId;
413
+ replacementEvent.usedInsertionId = true;
414
+ metadata = {
415
+ id,
416
+ author: replacementEvent.author,
417
+ date: replacementEvent.date
418
+ };
419
+ }
420
+ const ins = createTrackChange(xmlDoc, 'ins', null, author, metadata);
421
+ runs.forEach(run => ins.appendChild(run));
422
+ parent.appendChild(ins);
423
+ } else {
424
+ runs.forEach(run => parent.appendChild(run));
425
+ }
426
+ }
427
+
428
+ if (type !== 'delete') {
429
+ localInsertOffset += part.length;
430
+ }
336
431
  localBaseIndex += part.length;
337
432
  });
338
433
 
434
+ deferredCommentMarkers.forEach(marker => {
435
+ if (isWordElement(marker, 'commentReference')) {
436
+ const run = createWordElement(xmlDoc, 'w:r');
437
+ run.appendChild(marker.cloneNode(true));
438
+ localParagraph.appendChild(run);
439
+ } else {
440
+ localParagraph.appendChild(marker.cloneNode(true));
441
+ }
442
+ });
443
+
339
444
  return { currentParagraph: localParagraph };
340
445
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Accuracy-oriented capability record for the current reconciliation routes.
3
+ * This is internal instrumentation, not a routing policy API.
4
+ */
5
+ export const RECONCILIATION_CAPABILITY_MATRIX = Object.freeze({
6
+ formatOnly: Object.freeze({ paragraphs: true, formatting: true, tables: 'scoped', hyperlinks: 'preserved', fields: 'preserved', comments: 'preserved' }),
7
+ surgical: Object.freeze({ paragraphs: true, tables: 'cell-scoped', hyperlinks: 'preserved', fields: 'preserved', comments: 'preserved', notes: 'preserved' }),
8
+ reconstruction: Object.freeze({ paragraphs: true, hyperlinks: 'sentinel-preserved', fields: 'sentinel-preserved', comments: 'marker-preserved', notes: 'reference-preserved' }),
9
+ table: Object.freeze({ tables: true, paragraphs: true, formatting: 'cell-dependent', numbering: false }),
10
+ listDirect: Object.freeze({ lists: true, numbering: true, paragraphs: 'single-source expansion', tables: 'embedded-markdown blocks', formatting: 'markdown hints' }),
11
+ listCompatibilityPipeline: Object.freeze({ lists: true, numbering: true, paragraphs: 'multi-source patching', compatibility: true })
12
+ });
13
+
14
+ export function recordRouteSelection(options, route, context = {}) {
15
+ const callback = options?._routeInstrumentation?.onRoute;
16
+ if (typeof callback !== 'function') return;
17
+ callback(Object.freeze({
18
+ route,
19
+ capabilities: RECONCILIATION_CAPABILITY_MATRIX[route] || null,
20
+ ...context
21
+ }));
22
+ }
23
+
24
+ export function createRouteFrequencyCollector() {
25
+ const counts = new Map();
26
+ return {
27
+ onRoute(event) {
28
+ const route = event?.route || 'unknown';
29
+ counts.set(route, (counts.get(route) || 0) + 1);
30
+ },
31
+ snapshot() {
32
+ return Object.fromEntries(Array.from(counts).sort(([a], [b]) => a.localeCompare(b)));
33
+ }
34
+ };
35
+ }