@ansonlai/docx-redline-js 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/AGENTS.md +36 -10
  2. package/README.md +83 -6
  3. package/adapters/xml-adapter.js +73 -10
  4. package/core/list-targeting.js +3 -0
  5. package/core/paragraph-targeting.js +33 -7
  6. package/core/redline-validation.js +22 -0
  7. package/core/types.js +122 -27
  8. package/core/xml-query.js +3 -1
  9. package/dist/docx-redline-js.esm.js +1124 -545
  10. package/dist/docx-redline-js.esm.js.map +4 -4
  11. package/dist/docx-redline-js.esm.min.js +79 -78
  12. package/dist/docx-redline-js.esm.min.js.map +4 -4
  13. package/docs/TESTING.md +687 -0
  14. package/docs/VALIDATION.md +81 -2
  15. package/docs/WORD-MANUAL-REVIEW.md +138 -0
  16. package/docs/plans/2026-08-30-reliability-testing-improvements.md +488 -0
  17. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +210 -0
  18. package/docs/plans/{2026-03-01-release-0.1.4-design.md → completed/2026-03-01-release-0.1.4-design.md} +2 -0
  19. package/docs/plans/{2026-03-01-release-0.1.4.md → completed/2026-03-01-release-0.1.4.md} +5 -3
  20. package/docs/plans/{2026-05-31-architectural changes.md → completed/2026-05-31-architectural changes.md } +2 -0
  21. package/docs/plans/completed/2026-08-02-reliability-improvements.md +1155 -0
  22. package/docs/test-comparison-dashboard.html +95 -0
  23. package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +22 -0
  24. package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +24 -0
  25. package/docs/validation-reports/2026-08-30-phase-3-coverage.md +73 -0
  26. package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +82 -0
  27. package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +114 -0
  28. package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +79 -0
  29. package/engine/format-extraction.js +1 -1
  30. package/engine/formatting-removal.js +84 -99
  31. package/engine/oxml-engine.js +176 -83
  32. package/engine/reconstruction-mapper.js +276 -79
  33. package/engine/reconstruction-mode.js +20 -6
  34. package/engine/reconstruction-writer.js +117 -72
  35. package/engine/run-builders.js +3 -3
  36. package/engine/surgical-mode.js +3 -2
  37. package/engine/table-mode.js +27 -16
  38. package/index.d.ts +95 -3
  39. package/index.js +14 -13
  40. package/orchestration/list-structural-fallback.js +16 -39
  41. package/package.json +22 -4
  42. package/pipeline/diff-engine.js +174 -55
  43. package/pipeline/ingestion-export.js +39 -24
  44. package/pipeline/ingestion-paragraph.js +7 -5
  45. package/pipeline/list-generation.js +27 -18
  46. package/pipeline/patching.js +2 -3
  47. package/pipeline/pipeline.js +65 -36
  48. package/pipeline/serialization.js +13 -5
  49. package/scripts/build-test-dashboard.mjs +43 -0
  50. package/scripts/check-types.mjs +16 -24
  51. package/scripts/export-validation-fixtures.mjs +191 -45
  52. package/scripts/fetch-superdoc-corpus.mjs +61 -0
  53. package/scripts/generate-test-dashboard.mjs +199 -0
  54. package/scripts/inspect-visual-evidence.mjs +271 -0
  55. package/scripts/lib/minimal-zip.mjs +199 -18
  56. package/scripts/lib/word-coverage-catalogue.mjs +207 -0
  57. package/scripts/lib/word-coverage-metadata.mjs +93 -0
  58. package/scripts/lib/zip-reader.mjs +64 -0
  59. package/scripts/package-superdoc-word-fixtures.ps1 +64 -0
  60. package/scripts/prepare-corpus-word-visual-review.mjs +84 -0
  61. package/scripts/prepare-superdoc-word-corpus.mjs +284 -0
  62. package/scripts/prepare-word-review.mjs +77 -0
  63. package/scripts/prepare-word-visual-review.mjs +90 -0
  64. package/scripts/render-agenda-multilevel.mjs +70 -0
  65. package/scripts/render-case22.mjs +73 -0
  66. package/scripts/render-case40.ps1 +35 -0
  67. package/scripts/render-multilevel-bullet-images.py +58 -0
  68. package/scripts/render-multilevel-bullet-visual.ps1 +32 -0
  69. package/scripts/render-multilevel-cases.mjs +80 -0
  70. package/scripts/report-coverage-gaps.mjs +103 -0
  71. package/scripts/report-word-coverage.mjs +71 -0
  72. package/scripts/sample-multimodal-visual-check.mjs +221 -0
  73. package/scripts/test-multilevel-bullet-visual.mjs +187 -0
  74. package/scripts/word-com-corpus-suite.ps1 +43 -0
  75. package/scripts/word-com-corpus-visual-suite.ps1 +116 -0
  76. package/scripts/word-com-differential.ps1 +158 -16
  77. package/scripts/word-com-suite.ps1 +19 -0
  78. package/scripts/word-com-visual-suite.ps1 +132 -0
  79. package/services/comment-engine.js +51 -46
  80. package/services/comment-locator.js +0 -1
  81. package/services/comment-package.js +11 -10
  82. package/services/numbering-service.js +1 -1
  83. package/services/revision-comment-management.js +31 -10
  84. package/services/standalone-docx-plumbing.js +45 -34
  85. package/services/standalone-operation-runner.js +315 -75
  86. package/services/table-reconciliation.js +23 -11
@@ -4,17 +4,49 @@
4
4
  * Builds paragraph/property/sentinel mappings and indexed lookups used by reconstruction writing.
5
5
  */
6
6
 
7
- import { appendParagraphBoundary } from '../core/paragraph-offset-policy.js';
8
- import { getDocumentParagraphs } from './format-extraction.js';
9
- import { getElementsByTag, getFirstElementByTag } from '../core/xml-query.js';
7
+ import { diff_match_patch } from 'diff-match-patch';
8
+
9
+ import { appendParagraphBoundary } from '../core/paragraph-offset-policy.js';
10
+ import { getDocumentParagraphs } from './format-extraction.js';
11
+ import { getElementsByTagNSOrTag, getFirstElementByTagNSOrTag } from '../core/xml-query.js';
12
+ import { NS_W } from '../core/types.js';
13
+ import { isWordElement } from '../core/word-xml.js';
14
+
15
+ const DMP = new diff_match_patch();
16
+
17
+ function localNameOf(node) {
18
+ return String(node?.localName || node?.nodeName || '').replace(/^.*:/, '');
19
+ }
20
+
21
+ function wordAttribute(node, localName) {
22
+ return node?.getAttributeNS?.(NS_W, localName)
23
+ || node?.getAttribute?.(`w:${localName}`)
24
+ || node?.getAttribute?.(localName)
25
+ || '';
26
+ }
10
27
 
11
- function createRangeCursorLookup(ranges) {
12
- let cursor = 0;
13
- return {
14
- at(index) {
15
- while (cursor < ranges.length && ranges[cursor].end <= index) {
16
- cursor++;
17
- }
28
+ function createRangeCursorLookup(ranges) {
29
+ let cursor = 0;
30
+ return {
31
+ at(index) {
32
+ // Reconstruction normally walks source offsets forwards, but a
33
+ // replacement insertion deliberately looks back to the beginning
34
+ // of its deleted range to inherit that run's formatting. If the
35
+ // deletion crossed a run/hyperlink boundary, the cursor has
36
+ // already advanced past that range and must be rewound.
37
+ if (cursor > 0 && (!ranges[cursor] || index < ranges[cursor].start)) {
38
+ let low = 0;
39
+ let high = cursor - 1;
40
+ while (low <= high) {
41
+ const middle = Math.floor((low + high) / 2);
42
+ if (ranges[middle].end <= index) low = middle + 1;
43
+ else high = middle - 1;
44
+ }
45
+ cursor = low;
46
+ }
47
+ while (cursor < ranges.length && ranges[cursor].end <= index) {
48
+ cursor++;
49
+ }
18
50
  const match = ranges[cursor];
19
51
  if (!match) return null;
20
52
  if (match.start <= index && index < match.end) return match;
@@ -59,12 +91,12 @@ function indexSentinelsByStart(sentinelMap) {
59
91
  * isParagraphStart: (index:number) => boolean
60
92
  * }}
61
93
  */
62
- export function buildReconstructionMapping(xmlDoc, modifiedText) {
63
- const rootElement = xmlDoc.documentElement;
64
- const isBodyRoot = rootElement.nodeName === 'w:body' || rootElement.nodeName.endsWith(':package');
65
- const paragraphs = getDocumentParagraphs(xmlDoc);
66
-
67
- let body = getFirstElementByTag(xmlDoc, 'w:body');
94
+ export function buildReconstructionMapping(xmlDoc, modifiedText, selectedParagraphs = null) {
95
+ const rootElement = xmlDoc.documentElement;
96
+ const isBodyRoot = isWordElement(rootElement, 'body') || localNameOf(rootElement) === 'package';
97
+ const paragraphs = selectedParagraphs || getDocumentParagraphs(xmlDoc);
98
+
99
+ let body = getFirstElementByTagNSOrTag(xmlDoc, NS_W, 'body');
68
100
  if (!body && isBodyRoot) body = rootElement;
69
101
 
70
102
  let originalFullText = '';
@@ -72,8 +104,9 @@ export function buildReconstructionMapping(xmlDoc, modifiedText) {
72
104
  const paragraphMap = [];
73
105
  const sentinelMap = [];
74
106
  const referenceMap = new Map();
75
- const tokenToCharMap = new Map();
76
- let nextCharCode = 0xe000;
107
+ const tokenToCharMap = new Map();
108
+ const breakChars = new Set();
109
+ const characterState = { nextCharCode: 0xe000 };
77
110
  const uniqueContainers = new Set();
78
111
 
79
112
  paragraphs.forEach((paragraph, paragraphIndex) => {
@@ -87,17 +120,15 @@ export function buildReconstructionMapping(xmlDoc, modifiedText) {
87
120
  sentinelMap,
88
121
  referenceMap,
89
122
  tokenToCharMap,
90
- nextCharCode
91
- );
92
- if (referenceMap.size > tokenToCharMap.size) {
93
- nextCharCode++;
94
- }
123
+ characterState,
124
+ breakChars
125
+ );
95
126
  });
96
127
 
97
128
  originalFullText = appendParagraphBoundary(originalFullText, paragraphIndex, paragraphs.length);
98
129
 
99
130
  const paragraphEnd = originalFullText.length;
100
- const pPr = getFirstElementByTag(paragraph, 'w:pPr');
131
+ const pPr = getFirstElementByTagNSOrTag(paragraph, NS_W, 'pPr');
101
132
  const container = paragraph.parentNode;
102
133
  if (container) uniqueContainers.add(container);
103
134
 
@@ -109,7 +140,13 @@ export function buildReconstructionMapping(xmlDoc, modifiedText) {
109
140
  });
110
141
  });
111
142
 
112
- let processedModifiedText = modifiedText;
143
+ let displayOriginalText = '';
144
+ for (let index = 0; index < originalFullText.length; index++) {
145
+ const char = originalFullText[index];
146
+ displayOriginalText += breakChars.has(char) ? '\n' : char;
147
+ }
148
+ let processedModifiedText = preserveZeroWidthSentinels(displayOriginalText, modifiedText, sentinelMap);
149
+ processedModifiedText = preserveStructuralBreaks(displayOriginalText, originalFullText, processedModifiedText, breakChars);
113
150
  tokenToCharMap.forEach((char, tokenString) => {
114
151
  const escapedToken = tokenString.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
115
152
  processedModifiedText = processedModifiedText.replace(new RegExp(escapedToken, 'g'), char);
@@ -172,6 +209,149 @@ export function buildReconstructionMapping(xmlDoc, modifiedText) {
172
209
  };
173
210
  }
174
211
 
212
+ /**
213
+ * Finds the contiguous paragraph range named by caller-provided original text.
214
+ * Reconstruction replaces whole paragraphs, so returning null is safer than
215
+ * silently rebuilding unrelated paragraphs around a partial match.
216
+ */
217
+ export function findReconstructionParagraphRange(xmlDoc, originalText) {
218
+ const paragraphs = getDocumentParagraphs(xmlDoc);
219
+ if (paragraphs.length === 0) return [];
220
+
221
+ const wanted = normalizeComparisonText(originalText);
222
+ const paragraphTexts = paragraphs.map(extractParagraphVisibleText);
223
+ if (!wanted) {
224
+ const emptyIndex = paragraphTexts.findIndex(text => text === '');
225
+ return emptyIndex >= 0 ? [paragraphs[emptyIndex]] : null;
226
+ }
227
+ if (paragraphs.length === 1 && paragraphTexts[0] === '') {
228
+ return paragraphs;
229
+ }
230
+
231
+ const comparisons = [
232
+ text => text,
233
+ text => text.trim(),
234
+ text => text.replace(/\s+/g, ' ').trim()
235
+ ];
236
+ for (const compare of comparisons) {
237
+ const expected = compare(wanted);
238
+ for (let start = 0; start < paragraphs.length; start++) {
239
+ let combined = '';
240
+ for (let end = start; end < paragraphs.length; end++) {
241
+ combined += (end === start ? '' : '\n') + paragraphTexts[end];
242
+ const candidate = compare(combined);
243
+ if (candidate === expected) return paragraphs.slice(start, end + 1);
244
+ }
245
+ }
246
+ }
247
+
248
+ return null;
249
+ }
250
+
251
+ function normalizeComparisonText(text) {
252
+ return String(text ?? '').replace(/\r\n?/g, '\n').replace(/\u00a0/g, ' ');
253
+ }
254
+
255
+ function extractParagraphVisibleText(paragraph) {
256
+ let text = '';
257
+ const visit = node => {
258
+ for (const child of Array.from(node?.childNodes || [])) {
259
+ if (child.nodeType !== 1) continue;
260
+ if (isWordElement(child, 'pPr') || isWordElement(child, 'del') || isWordElement(child, 'moveFrom')) continue;
261
+ if (isWordElement(child, 't')) text += child.textContent || '';
262
+ else if (isWordElement(child, 'tab')) text += '\t';
263
+ else if (isWordElement(child, 'br') || isWordElement(child, 'cr')) text += '\n';
264
+ else if (isWordElement(child, 'noBreakHyphen')) text += '\u2011';
265
+ else visit(child);
266
+ }
267
+ };
268
+ visit(paragraph);
269
+ return normalizeComparisonText(text);
270
+ }
271
+
272
+ function preserveStructuralBreaks(displayOriginalText, internalOriginalText, modifiedText, breakChars) {
273
+ if (breakChars.size === 0) return modifiedText;
274
+
275
+ const diffs = DMP.diff_main(displayOriginalText, modifiedText);
276
+ let originalOffset = 0;
277
+ let result = '';
278
+
279
+ for (const [op, text] of diffs) {
280
+ if (op === 0) {
281
+ for (let index = 0; index < text.length; index++) {
282
+ const internalChar = internalOriginalText[originalOffset + index];
283
+ result += breakChars.has(internalChar) ? internalChar : text[index];
284
+ }
285
+ originalOffset += text.length;
286
+ } else if (op === -1) {
287
+ originalOffset += text.length;
288
+ } else {
289
+ result += text;
290
+ }
291
+ }
292
+
293
+ return result;
294
+ }
295
+
296
+ function preserveZeroWidthSentinels(displayOriginalText, modifiedText, sentinelMap) {
297
+ const sentinelsByInternalOffset = new Map();
298
+ sentinelMap.forEach(sentinel => {
299
+ if (sentinel.zeroWidth) sentinelsByInternalOffset.set(sentinel.start, sentinel);
300
+ });
301
+ if (sentinelsByInternalOffset.size === 0) return modifiedText;
302
+
303
+ let visibleOriginalText = '';
304
+ let visibleOffset = 0;
305
+ const sentinelsByVisibleBoundary = new Map();
306
+
307
+ for (let internalOffset = 0; internalOffset < displayOriginalText.length; internalOffset++) {
308
+ const sentinel = sentinelsByInternalOffset.get(internalOffset);
309
+ if (sentinel) {
310
+ if (!sentinelsByVisibleBoundary.has(visibleOffset)) sentinelsByVisibleBoundary.set(visibleOffset, []);
311
+ sentinelsByVisibleBoundary.get(visibleOffset).push({
312
+ char: displayOriginalText[internalOffset],
313
+ affinity: sentinel.affinity || 'right',
314
+ emitted: false
315
+ });
316
+ continue;
317
+ }
318
+ visibleOriginalText += displayOriginalText[internalOffset];
319
+ visibleOffset++;
320
+ }
321
+
322
+ const diffs = DMP.diff_main(visibleOriginalText, modifiedText);
323
+ let originalOffset = 0;
324
+ let result = '';
325
+
326
+ const emitSentinels = (boundary, affinity) => {
327
+ const sentinels = sentinelsByVisibleBoundary.get(boundary) || [];
328
+ for (const sentinel of sentinels) {
329
+ if (sentinel.emitted || (affinity && sentinel.affinity !== affinity)) continue;
330
+ result += sentinel.char;
331
+ sentinel.emitted = true;
332
+ }
333
+ };
334
+
335
+ for (const [op, text] of diffs) {
336
+ if (op === 1) {
337
+ // Closing field markers belong before text inserted immediately
338
+ // after a field; opening markers stay after text inserted before it.
339
+ emitSentinels(originalOffset, 'left');
340
+ result += text;
341
+ continue;
342
+ }
343
+
344
+ for (let index = 0; index < text.length; index++) {
345
+ emitSentinels(originalOffset);
346
+ if (op === 0) result += text[index];
347
+ originalOffset++;
348
+ }
349
+ }
350
+
351
+ emitSentinels(originalOffset);
352
+ return result;
353
+ }
354
+
175
355
  function preserveReferencePlaceholders(originalFullText, modifiedText, referenceMap) {
176
356
  let result = modifiedText;
177
357
 
@@ -198,31 +378,37 @@ function preserveReferencePlaceholders(originalFullText, modifiedText, reference
198
378
  return result;
199
379
  }
200
380
 
201
- function processChildNode(child, originalFullText, propertyMap, sentinelMap, referenceMap, tokenToCharMap, nextCharCode) {
202
- if (child.nodeName === 'w:r') {
203
- return processRunForReconstruction(child, originalFullText, propertyMap, sentinelMap, referenceMap, tokenToCharMap, nextCharCode);
204
- }
205
- if (child.nodeName === 'w:hyperlink') {
206
- return processHyperlinkForReconstruction(child, originalFullText, propertyMap);
207
- }
208
- if (['w:sdt', 'w:oMath', 'm:oMath', 'w:bookmarkStart', 'w:bookmarkEnd'].includes(child.nodeName)) {
209
- sentinelMap.push({ start: originalFullText.length, node: child });
210
- return originalFullText + '\uFFFC';
211
- }
212
- if (['w:commentRangeStart', 'w:commentRangeEnd'].includes(child.nodeName)) {
213
- sentinelMap.push({ start: originalFullText.length, node: child, isCommentMarker: true });
214
- return originalFullText;
381
+ function processChildNode(child, originalFullText, propertyMap, sentinelMap, referenceMap, tokenToCharMap, characterState, breakChars) {
382
+ if (isWordElement(child, 'r')) {
383
+ return processRunForReconstruction(child, originalFullText, propertyMap, sentinelMap, referenceMap, tokenToCharMap, characterState, breakChars);
384
+ }
385
+ if (isWordElement(child, 'hyperlink')) {
386
+ return processHyperlinkForReconstruction(child, originalFullText, propertyMap);
387
+ }
388
+ if (
389
+ isWordElement(child, 'sdt')
390
+ || isWordElement(child, 'oMath')
391
+ || localNameOf(child) === 'oMath'
392
+ || isWordElement(child, 'bookmarkStart')
393
+ || isWordElement(child, 'bookmarkEnd')
394
+ ) {
395
+ sentinelMap.push({ start: originalFullText.length, node: child });
396
+ return originalFullText + '\uFFFC';
397
+ }
398
+ if (isWordElement(child, 'commentRangeStart') || isWordElement(child, 'commentRangeEnd')) {
399
+ sentinelMap.push({ start: originalFullText.length, node: child, isCommentMarker: true });
400
+ return originalFullText;
215
401
  }
216
402
  return originalFullText;
217
403
  }
218
404
 
219
- function processRunForReconstruction(runElement, originalFullText, propertyMap, sentinelMap, referenceMap, tokenToCharMap, nextCharCode) {
220
- let fullText = originalFullText;
221
- const rPr = getFirstElementByTag(runElement, 'w:rPr');
222
-
223
- Array.from(runElement.childNodes).forEach(runChild => {
224
- if (runChild.nodeName === 'w:t') {
225
- const textContent = runChild.textContent || '';
405
+ function processRunForReconstruction(runElement, originalFullText, propertyMap, sentinelMap, referenceMap, tokenToCharMap, characterState, breakChars) {
406
+ let fullText = originalFullText;
407
+ const rPr = getFirstElementByTagNSOrTag(runElement, NS_W, 'rPr');
408
+
409
+ Array.from(runElement.childNodes).forEach(runChild => {
410
+ if (isWordElement(runChild, 't')) {
411
+ const textContent = runChild.textContent || '';
226
412
  if (textContent.length > 0) {
227
413
  propertyMap.push({
228
414
  start: fullText.length,
@@ -231,40 +417,51 @@ function processRunForReconstruction(runElement, originalFullText, propertyMap,
231
417
  });
232
418
  fullText += textContent;
233
419
  }
234
- } else if (runChild.nodeName === 'w:br' || runChild.nodeName === 'w:cr') {
235
- fullText += '\n';
236
- propertyMap.push({ start: fullText.length - 1, end: fullText.length, rPr });
237
- } else if (runChild.nodeName === 'w:tab') {
238
- fullText += '\t';
239
- propertyMap.push({ start: fullText.length - 1, end: fullText.length, rPr });
240
- } else if (runChild.nodeName === 'w:noBreakHyphen') {
241
- fullText += '\u2011';
242
- propertyMap.push({ start: fullText.length - 1, end: fullText.length, rPr });
243
- } else if (['w:drawing', 'w:pict', 'w:object', 'w:fldChar', 'w:instrText', 'w:sym'].includes(runChild.nodeName)) {
244
- const textBoxContent = getFirstElementByTag(runChild, 'w:txbxContent');
245
- const hasTextBox = runChild.nodeName === 'w:pict' && !!textBoxContent;
246
-
247
- sentinelMap.push({
248
- start: fullText.length,
249
- node: runChild,
250
- isTextBox: hasTextBox,
251
- originalContainer: hasTextBox ? textBoxContent : undefined
252
- });
420
+ } else if (isWordElement(runChild, 'br') || isWordElement(runChild, 'cr')) {
421
+ const char = String.fromCharCode(characterState.nextCharCode++);
422
+ referenceMap.set(char, runChild);
423
+ breakChars.add(char);
424
+ fullText += char;
425
+ propertyMap.push({ start: fullText.length - 1, end: fullText.length, rPr });
426
+ } else if (isWordElement(runChild, 'tab')) {
427
+ fullText += '\t';
428
+ propertyMap.push({ start: fullText.length - 1, end: fullText.length, rPr });
429
+ } else if (isWordElement(runChild, 'noBreakHyphen')) {
430
+ fullText += '\u2011';
431
+ propertyMap.push({ start: fullText.length - 1, end: fullText.length, rPr });
432
+ } else if (['drawing', 'pict', 'object', 'fldChar', 'instrText', 'sym'].some(name => isWordElement(runChild, name))) {
433
+ const textBoxContent = getFirstElementByTagNSOrTag(runChild, NS_W, 'txbxContent');
434
+ const hasTextBox = isWordElement(runChild, 'pict') && !!textBoxContent;
435
+ const isFieldStructure = isWordElement(runChild, 'fldChar') || isWordElement(runChild, 'instrText');
436
+ const fieldCharType = isWordElement(runChild, 'fldChar')
437
+ ? (runChild.getAttributeNS?.(NS_W, 'fldCharType') || runChild.getAttribute('w:fldCharType') || runChild.getAttribute('fldCharType'))
438
+ : null;
439
+
440
+ sentinelMap.push({
441
+ start: fullText.length,
442
+ node: runChild,
443
+ wrapInRun: true,
444
+ rPr,
445
+ zeroWidth: isFieldStructure,
446
+ affinity: fieldCharType === 'end' ? 'left' : 'right',
447
+ isTextBox: hasTextBox,
448
+ originalContainer: hasTextBox ? textBoxContent : undefined
449
+ });
253
450
  fullText += '\uFFFC';
254
451
  propertyMap.push({ start: fullText.length - 1, end: fullText.length, rPr });
255
- } else if (runChild.nodeName === 'w:footnoteReference' || runChild.nodeName === 'w:endnoteReference') {
256
- const id = runChild.getAttribute('w:id');
257
- if (id) {
258
- const type = runChild.nodeName === 'w:footnoteReference' ? 'FN' : 'EN';
452
+ } else if (isWordElement(runChild, 'footnoteReference') || isWordElement(runChild, 'endnoteReference')) {
453
+ const id = wordAttribute(runChild, 'id');
454
+ if (id) {
455
+ const type = isWordElement(runChild, 'footnoteReference') ? 'FN' : 'EN';
259
456
  const tokenString = `{{__${type}_${id}__}}`;
260
- const char = String.fromCharCode(nextCharCode);
457
+ const char = String.fromCharCode(characterState.nextCharCode++);
261
458
  referenceMap.set(char, runChild);
262
459
  tokenToCharMap.set(tokenString, char);
263
460
  fullText += char;
264
461
  propertyMap.push({ start: fullText.length - 1, end: fullText.length, rPr });
265
462
  }
266
- } else if (runChild.nodeName === 'w:commentReference') {
267
- sentinelMap.push({ start: fullText.length, node: runChild, isCommentMarker: true });
463
+ } else if (isWordElement(runChild, 'commentReference')) {
464
+ sentinelMap.push({ start: fullText.length, node: runChild, isCommentMarker: true });
268
465
  }
269
466
  });
270
467
 
@@ -272,13 +469,13 @@ function processRunForReconstruction(runElement, originalFullText, propertyMap,
272
469
  }
273
470
 
274
471
  function processHyperlinkForReconstruction(hyperlinkElement, originalFullText, propertyMap) {
275
- let fullText = originalFullText;
276
-
277
- Array.from(hyperlinkElement.childNodes).forEach(hyperlinkChild => {
278
- if (hyperlinkChild.nodeName !== 'w:r') return;
279
-
280
- const rPr = getFirstElementByTag(hyperlinkChild, 'w:rPr');
281
- const texts = getElementsByTag(hyperlinkChild, 'w:t');
472
+ let fullText = originalFullText;
473
+
474
+ Array.from(hyperlinkElement.childNodes).forEach(hyperlinkChild => {
475
+ if (!isWordElement(hyperlinkChild, 'r')) return;
476
+
477
+ const rPr = getFirstElementByTagNSOrTag(hyperlinkChild, NS_W, 'rPr');
478
+ const texts = getElementsByTagNSOrTag(hyperlinkChild, NS_W, 't');
282
479
  texts.forEach(textNode => {
283
480
  const textContent = textNode.textContent || '';
284
481
  if (textContent.length === 0) return;
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { computeWordDiffs } from '../pipeline/diff-engine.js';
6
- import { buildReconstructionMapping } from './reconstruction-mapper.js';
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
9
 
@@ -17,15 +17,29 @@ import { withOoxmlSourceType } from '../core/word-xml.js';
17
17
  * @param {string} author - Author name
18
18
  * @param {Array} formatHints - Format hints
19
19
  * @param {boolean} [generateRedlines=true] - Track change toggle
20
- * @returns {{ oxml: string, hasChanges: boolean }}
21
- */
22
- export function applyReconstructionMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true) {
23
- const mapping = buildReconstructionMapping(xmlDoc, modifiedText);
20
+ * @param {{ diffTimeoutSeconds?: number }} [diffOptions={}] - Diff configuration
21
+ * @returns {{ oxml: string, hasChanges: boolean }}
22
+ */
23
+ export function applyReconstructionMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, diffOptions = {}) {
24
+ const selectedParagraphs = findReconstructionParagraphRange(xmlDoc, originalText);
25
+ if (selectedParagraphs === null) {
26
+ return withOoxmlSourceType({
27
+ oxml: serializer.serializeToString(xmlDoc),
28
+ hasChanges: false,
29
+ status: 'error',
30
+ error: {
31
+ code: 'PARTIAL_TARGET',
32
+ message: 'Original text did not identify a complete contiguous paragraph range for reconstruction.'
33
+ }
34
+ });
35
+ }
36
+
37
+ const mapping = buildReconstructionMapping(xmlDoc, modifiedText, selectedParagraphs);
24
38
  if (mapping.paragraphs.length === 0) {
25
39
  return withOoxmlSourceType({ oxml: serializer.serializeToString(xmlDoc), hasChanges: false });
26
40
  }
27
41
 
28
- const diffs = computeWordDiffs(mapping.originalFullText, mapping.processedModifiedText);
42
+ const diffs = computeWordDiffs(mapping.originalFullText, mapping.processedModifiedText, diffOptions);
29
43
 
30
44
  return withOoxmlSourceType(applyReconstructionDiffs(
31
45
  xmlDoc,