@ansonlai/docx-redline-js 0.8.4 → 0.10.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 (72) hide show
  1. package/ARCHITECTURE.md +679 -460
  2. package/CHANGELOG.md +208 -1
  3. package/README.md +152 -16
  4. package/core/current-properties.js +136 -0
  5. package/core/paragraph-text.js +211 -209
  6. package/core/types.js +9 -3
  7. package/core/word-revision-semantics.js +256 -0
  8. package/core/word-xml.js +160 -118
  9. package/dist/docx-redline-js.esm.js +4338 -796
  10. package/dist/docx-redline-js.esm.js.map +4 -4
  11. package/dist/docx-redline-js.esm.min.js +111 -108
  12. package/dist/docx-redline-js.esm.min.js.map +4 -4
  13. package/dist/docx-redline.bundle.cjs +4463 -921
  14. package/dist/docx-redline.bundle.cjs.map +4 -4
  15. package/dist/docx-redline.bundle.js +4463 -921
  16. package/dist/docx-redline.bundle.js.map +4 -4
  17. package/docs/AGENT_FAST_START.md +2 -0
  18. package/docs/AGENT_KNOWLEDGE_BASE.md +49 -7
  19. package/docs/SKILL_AUTHORING.md +169 -150
  20. package/docs/TESTING.md +1146 -1059
  21. package/docs/schemas/document-operations.schema.json +55 -2
  22. package/document/docx-document.js +1022 -1010
  23. package/engine/oxml-engine.js +21 -22
  24. package/engine/paragraph-mark-revisions.js +292 -0
  25. package/engine/reconstruction-mapper.js +312 -311
  26. package/engine/reconstruction-writer.js +67 -10
  27. package/engine/rpr-helpers.js +42 -0
  28. package/engine/run-builders.js +0 -73
  29. package/engine/surgical-diff-application.js +604 -605
  30. package/engine/table-mode.js +63 -63
  31. package/index.d.ts +53 -2
  32. package/index.js +13 -2
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +21 -3
  35. package/orchestration/list-structural-fallback.js +67 -9
  36. package/package.json +1 -1
  37. package/pipeline/diff-engine.js +130 -1
  38. package/pipeline/ingestion-export.js +227 -243
  39. package/pipeline/ingestion-paragraph.js +93 -98
  40. package/pipeline/list-generation.js +415 -419
  41. package/pipeline/markdown-processor.js +5 -2
  42. package/pipeline/pipeline.js +206 -204
  43. package/scripts/export-validation-fixtures.mjs +19 -6
  44. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  45. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  46. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  47. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  48. package/scripts/word-com-differential.ps1 +443 -335
  49. package/scripts/word-com-visual-suite.ps1 +2 -2
  50. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  51. package/services/batch-operation-orchestrator.js +23 -3
  52. package/services/capture-engine.js +228 -226
  53. package/services/comment-replies.js +1 -1
  54. package/services/document-operation-applier.js +214 -0
  55. package/services/document-operation-contract.js +76 -0
  56. package/services/document-operation-mutations.js +688 -51
  57. package/services/error-recovery.js +17 -0
  58. package/services/inspection-comparison.js +82 -0
  59. package/services/list-conversion-diagnostics.js +296 -0
  60. package/services/list-format-change.js +586 -0
  61. package/services/list-format-mutation.js +416 -0
  62. package/services/list-format.js +305 -0
  63. package/services/numbering-allocation.js +642 -0
  64. package/services/numbering-helpers.js +354 -524
  65. package/services/operation-batch-compiler.js +183 -11
  66. package/services/operation-preflight.js +43 -33
  67. package/services/receipt-collector.js +288 -288
  68. package/services/revision-comment-management.js +38 -5
  69. package/services/revision-fidelity-verifier.js +484 -0
  70. package/services/standalone-docx-plumbing.js +627 -631
  71. package/services/standalone-operation-runner.d.ts +182 -5
  72. package/services/table-reconciliation.js +59 -7
@@ -4,49 +4,49 @@
4
4
  * Builds paragraph/property/sentinel mappings and indexed lookups used by reconstruction writing.
5
5
  */
6
6
 
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
- }
27
-
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
- }
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
+ }
27
+
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
+ }
50
50
  const match = ranges[cursor];
51
51
  if (!match) return null;
52
52
  if (match.start <= index && index < match.end) return match;
@@ -74,7 +74,7 @@ function indexSentinelsByStart(sentinelMap) {
74
74
  * @returns {{
75
75
  * paragraphs: Element[],
76
76
  * body: Element|Document,
77
- * paragraphMap: Array<{start:number,end:number,pPr:Element|null,container:Node}>,
77
+ * paragraphMap: Array<{start:number,end:number,pPr:Element|null,paragraph:Element,container:Node}>,
78
78
  * paragraphStarts: Set<number>,
79
79
  * propertyMap: Array<{start:number,end:number,rPr:Element|null,wrapper?:Element}>,
80
80
  * sentinelMap: Array<Object>,
@@ -91,12 +91,12 @@ function indexSentinelsByStart(sentinelMap) {
91
91
  * isParagraphStart: (index:number) => boolean
92
92
  * }}
93
93
  */
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');
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');
100
100
  if (!body && isBodyRoot) body = rootElement;
101
101
 
102
102
  let originalFullText = '';
@@ -104,9 +104,9 @@ export function buildReconstructionMapping(xmlDoc, modifiedText, selectedParagra
104
104
  const paragraphMap = [];
105
105
  const sentinelMap = [];
106
106
  const referenceMap = new Map();
107
- const tokenToCharMap = new Map();
108
- const breakChars = new Set();
109
- const characterState = { nextCharCode: 0xe000 };
107
+ const tokenToCharMap = new Map();
108
+ const breakChars = new Set();
109
+ const characterState = { nextCharCode: 0xe000 };
110
110
  const uniqueContainers = new Set();
111
111
 
112
112
  paragraphs.forEach((paragraph, paragraphIndex) => {
@@ -120,15 +120,15 @@ export function buildReconstructionMapping(xmlDoc, modifiedText, selectedParagra
120
120
  sentinelMap,
121
121
  referenceMap,
122
122
  tokenToCharMap,
123
- characterState,
124
- breakChars
125
- );
123
+ characterState,
124
+ breakChars
125
+ );
126
126
  });
127
127
 
128
128
  originalFullText = appendParagraphBoundary(originalFullText, paragraphIndex, paragraphs.length);
129
129
 
130
130
  const paragraphEnd = originalFullText.length;
131
- const pPr = getFirstElementByTagNSOrTag(paragraph, NS_W, 'pPr');
131
+ const pPr = getFirstElementByTagNSOrTag(paragraph, NS_W, 'pPr');
132
132
  const container = paragraph.parentNode;
133
133
  if (container) uniqueContainers.add(container);
134
134
 
@@ -136,22 +136,23 @@ export function buildReconstructionMapping(xmlDoc, modifiedText, selectedParagra
136
136
  start: paragraphStart,
137
137
  end: paragraphEnd,
138
138
  pPr,
139
+ paragraph,
139
140
  container: container || body
140
141
  });
141
142
  });
142
143
 
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);
150
- tokenToCharMap.forEach((char, tokenString) => {
151
- const escapedToken = tokenString.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
152
- processedModifiedText = processedModifiedText.replace(new RegExp(escapedToken, 'g'), char);
153
- });
154
- processedModifiedText = preserveReferencePlaceholders(originalFullText, processedModifiedText, referenceMap);
144
+ let displayOriginalText = '';
145
+ for (let index = 0; index < originalFullText.length; index++) {
146
+ const char = originalFullText[index];
147
+ displayOriginalText += breakChars.has(char) ? '\n' : char;
148
+ }
149
+ let processedModifiedText = preserveZeroWidthSentinels(displayOriginalText, modifiedText, sentinelMap);
150
+ processedModifiedText = preserveStructuralBreaks(displayOriginalText, originalFullText, processedModifiedText, breakChars);
151
+ tokenToCharMap.forEach((char, tokenString) => {
152
+ const escapedToken = tokenString.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
153
+ processedModifiedText = processedModifiedText.replace(new RegExp(escapedToken, 'g'), char);
154
+ });
155
+ processedModifiedText = preserveReferencePlaceholders(originalFullText, processedModifiedText, referenceMap);
155
156
 
156
157
  const containerFragments = new Map();
157
158
  uniqueContainers.forEach(container => {
@@ -207,208 +208,208 @@ export function buildReconstructionMapping(xmlDoc, modifiedText, selectedParagra
207
208
  getPropertySpanLength,
208
209
  isParagraphStart: index => paragraphStarts.has(index)
209
210
  };
210
- }
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
-
355
- function preserveReferencePlaceholders(originalFullText, modifiedText, referenceMap) {
356
- let result = modifiedText;
357
-
358
- for (const referenceChar of referenceMap.keys()) {
359
- if (result.includes(referenceChar)) continue;
360
-
361
- const originalIndex = originalFullText.indexOf(referenceChar);
362
- if (originalIndex < 0) continue;
363
-
364
- const prefix = originalFullText.slice(0, originalIndex);
365
- const suffix = originalFullText.slice(originalIndex + referenceChar.length);
366
-
367
- if (prefix && result.startsWith(prefix)) {
368
- result = `${result.slice(0, prefix.length)}${referenceChar}${result.slice(prefix.length)}`;
369
- continue;
370
- }
371
-
372
- if (suffix && result.endsWith(suffix)) {
373
- const insertAt = result.length - suffix.length;
374
- result = `${result.slice(0, insertAt)}${referenceChar}${result.slice(insertAt)}`;
375
- }
376
- }
377
-
378
- return result;
379
- }
380
-
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;
211
+ }
212
+
213
+ /**
214
+ * Finds the contiguous paragraph range named by caller-provided original text.
215
+ * Reconstruction replaces whole paragraphs, so returning null is safer than
216
+ * silently rebuilding unrelated paragraphs around a partial match.
217
+ */
218
+ export function findReconstructionParagraphRange(xmlDoc, originalText) {
219
+ const paragraphs = getDocumentParagraphs(xmlDoc);
220
+ if (paragraphs.length === 0) return [];
221
+
222
+ const wanted = normalizeComparisonText(originalText);
223
+ const paragraphTexts = paragraphs.map(extractParagraphVisibleText);
224
+ if (!wanted) {
225
+ const emptyIndex = paragraphTexts.findIndex(text => text === '');
226
+ return emptyIndex >= 0 ? [paragraphs[emptyIndex]] : null;
227
+ }
228
+ if (paragraphs.length === 1 && paragraphTexts[0] === '') {
229
+ return paragraphs;
230
+ }
231
+
232
+ const comparisons = [
233
+ text => text,
234
+ text => text.trim(),
235
+ text => text.replace(/\s+/g, ' ').trim()
236
+ ];
237
+ for (const compare of comparisons) {
238
+ const expected = compare(wanted);
239
+ for (let start = 0; start < paragraphs.length; start++) {
240
+ let combined = '';
241
+ for (let end = start; end < paragraphs.length; end++) {
242
+ combined += (end === start ? '' : '\n') + paragraphTexts[end];
243
+ const candidate = compare(combined);
244
+ if (candidate === expected) return paragraphs.slice(start, end + 1);
245
+ }
246
+ }
247
+ }
248
+
249
+ return null;
250
+ }
251
+
252
+ function normalizeComparisonText(text) {
253
+ return String(text ?? '').replace(/\r\n?/g, '\n').replace(/\u00a0/g, ' ');
254
+ }
255
+
256
+ function extractParagraphVisibleText(paragraph) {
257
+ let text = '';
258
+ const visit = node => {
259
+ for (const child of Array.from(node?.childNodes || [])) {
260
+ if (child.nodeType !== 1) continue;
261
+ if (isWordElement(child, 'pPr') || isWordElement(child, 'del') || isWordElement(child, 'moveFrom')) continue;
262
+ if (isWordElement(child, 't')) text += child.textContent || '';
263
+ else if (isWordElement(child, 'tab')) text += '\t';
264
+ else if (isWordElement(child, 'br') || isWordElement(child, 'cr')) text += '\n';
265
+ else if (isWordElement(child, 'noBreakHyphen')) text += '\u2011';
266
+ else visit(child);
267
+ }
268
+ };
269
+ visit(paragraph);
270
+ return normalizeComparisonText(text);
271
+ }
272
+
273
+ function preserveStructuralBreaks(displayOriginalText, internalOriginalText, modifiedText, breakChars) {
274
+ if (breakChars.size === 0) return modifiedText;
275
+
276
+ const diffs = DMP.diff_main(displayOriginalText, modifiedText);
277
+ let originalOffset = 0;
278
+ let result = '';
279
+
280
+ for (const [op, text] of diffs) {
281
+ if (op === 0) {
282
+ for (let index = 0; index < text.length; index++) {
283
+ const internalChar = internalOriginalText[originalOffset + index];
284
+ result += breakChars.has(internalChar) ? internalChar : text[index];
285
+ }
286
+ originalOffset += text.length;
287
+ } else if (op === -1) {
288
+ originalOffset += text.length;
289
+ } else {
290
+ result += text;
291
+ }
292
+ }
293
+
294
+ return result;
295
+ }
296
+
297
+ function preserveZeroWidthSentinels(displayOriginalText, modifiedText, sentinelMap) {
298
+ const sentinelsByInternalOffset = new Map();
299
+ sentinelMap.forEach(sentinel => {
300
+ if (sentinel.zeroWidth) sentinelsByInternalOffset.set(sentinel.start, sentinel);
301
+ });
302
+ if (sentinelsByInternalOffset.size === 0) return modifiedText;
303
+
304
+ let visibleOriginalText = '';
305
+ let visibleOffset = 0;
306
+ const sentinelsByVisibleBoundary = new Map();
307
+
308
+ for (let internalOffset = 0; internalOffset < displayOriginalText.length; internalOffset++) {
309
+ const sentinel = sentinelsByInternalOffset.get(internalOffset);
310
+ if (sentinel) {
311
+ if (!sentinelsByVisibleBoundary.has(visibleOffset)) sentinelsByVisibleBoundary.set(visibleOffset, []);
312
+ sentinelsByVisibleBoundary.get(visibleOffset).push({
313
+ char: displayOriginalText[internalOffset],
314
+ affinity: sentinel.affinity || 'right',
315
+ emitted: false
316
+ });
317
+ continue;
318
+ }
319
+ visibleOriginalText += displayOriginalText[internalOffset];
320
+ visibleOffset++;
321
+ }
322
+
323
+ const diffs = DMP.diff_main(visibleOriginalText, modifiedText);
324
+ let originalOffset = 0;
325
+ let result = '';
326
+
327
+ const emitSentinels = (boundary, affinity) => {
328
+ const sentinels = sentinelsByVisibleBoundary.get(boundary) || [];
329
+ for (const sentinel of sentinels) {
330
+ if (sentinel.emitted || (affinity && sentinel.affinity !== affinity)) continue;
331
+ result += sentinel.char;
332
+ sentinel.emitted = true;
333
+ }
334
+ };
335
+
336
+ for (const [op, text] of diffs) {
337
+ if (op === 1) {
338
+ // Closing field markers belong before text inserted immediately
339
+ // after a field; opening markers stay after text inserted before it.
340
+ emitSentinels(originalOffset, 'left');
341
+ result += text;
342
+ continue;
343
+ }
344
+
345
+ for (let index = 0; index < text.length; index++) {
346
+ emitSentinels(originalOffset);
347
+ if (op === 0) result += text[index];
348
+ originalOffset++;
349
+ }
350
+ }
351
+
352
+ emitSentinels(originalOffset);
353
+ return result;
354
+ }
355
+
356
+ function preserveReferencePlaceholders(originalFullText, modifiedText, referenceMap) {
357
+ let result = modifiedText;
358
+
359
+ for (const referenceChar of referenceMap.keys()) {
360
+ if (result.includes(referenceChar)) continue;
361
+
362
+ const originalIndex = originalFullText.indexOf(referenceChar);
363
+ if (originalIndex < 0) continue;
364
+
365
+ const prefix = originalFullText.slice(0, originalIndex);
366
+ const suffix = originalFullText.slice(originalIndex + referenceChar.length);
367
+
368
+ if (prefix && result.startsWith(prefix)) {
369
+ result = `${result.slice(0, prefix.length)}${referenceChar}${result.slice(prefix.length)}`;
370
+ continue;
371
+ }
372
+
373
+ if (suffix && result.endsWith(suffix)) {
374
+ const insertAt = result.length - suffix.length;
375
+ result = `${result.slice(0, insertAt)}${referenceChar}${result.slice(insertAt)}`;
376
+ }
377
+ }
378
+
379
+ return result;
380
+ }
381
+
382
+ function processChildNode(child, originalFullText, propertyMap, sentinelMap, referenceMap, tokenToCharMap, characterState, breakChars) {
383
+ if (isWordElement(child, 'r')) {
384
+ return processRunForReconstruction(child, originalFullText, propertyMap, sentinelMap, referenceMap, tokenToCharMap, characterState, breakChars);
385
+ }
386
+ if (isWordElement(child, 'hyperlink') || isWordElement(child, 'fldSimple')) {
387
+ return processHyperlinkForReconstruction(child, originalFullText, propertyMap);
388
+ }
389
+ if (
390
+ isWordElement(child, 'sdt')
391
+ || isWordElement(child, 'oMath')
392
+ || localNameOf(child) === 'oMath'
393
+ || isWordElement(child, 'bookmarkStart')
394
+ || isWordElement(child, 'bookmarkEnd')
395
+ ) {
396
+ sentinelMap.push({ start: originalFullText.length, node: child });
397
+ return originalFullText + '\uFFFC';
398
+ }
399
+ if (isWordElement(child, 'commentRangeStart') || isWordElement(child, 'commentRangeEnd')) {
400
+ sentinelMap.push({ start: originalFullText.length, node: child, isCommentMarker: true });
401
+ return originalFullText;
401
402
  }
402
403
  return originalFullText;
403
404
  }
404
405
 
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 || '';
406
+ function processRunForReconstruction(runElement, originalFullText, propertyMap, sentinelMap, referenceMap, tokenToCharMap, characterState, breakChars) {
407
+ let fullText = originalFullText;
408
+ const rPr = getFirstElementByTagNSOrTag(runElement, NS_W, 'rPr');
409
+
410
+ Array.from(runElement.childNodes).forEach(runChild => {
411
+ if (isWordElement(runChild, 't')) {
412
+ const textContent = runChild.textContent || '';
412
413
  if (textContent.length > 0) {
413
414
  propertyMap.push({
414
415
  start: fullText.length,
@@ -417,51 +418,51 @@ function processRunForReconstruction(runElement, originalFullText, propertyMap,
417
418
  });
418
419
  fullText += textContent;
419
420
  }
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
- });
421
+ } else if (isWordElement(runChild, 'br') || isWordElement(runChild, 'cr')) {
422
+ const char = String.fromCharCode(characterState.nextCharCode++);
423
+ referenceMap.set(char, runChild);
424
+ breakChars.add(char);
425
+ fullText += char;
426
+ propertyMap.push({ start: fullText.length - 1, end: fullText.length, rPr });
427
+ } else if (isWordElement(runChild, 'tab')) {
428
+ fullText += '\t';
429
+ propertyMap.push({ start: fullText.length - 1, end: fullText.length, rPr });
430
+ } else if (isWordElement(runChild, 'noBreakHyphen')) {
431
+ fullText += '\u2011';
432
+ propertyMap.push({ start: fullText.length - 1, end: fullText.length, rPr });
433
+ } else if (['drawing', 'pict', 'object', 'fldChar', 'instrText', 'sym'].some(name => isWordElement(runChild, name))) {
434
+ const textBoxContent = getFirstElementByTagNSOrTag(runChild, NS_W, 'txbxContent');
435
+ const hasTextBox = isWordElement(runChild, 'pict') && !!textBoxContent;
436
+ const isFieldStructure = isWordElement(runChild, 'fldChar') || isWordElement(runChild, 'instrText');
437
+ const fieldCharType = isWordElement(runChild, 'fldChar')
438
+ ? (runChild.getAttributeNS?.(NS_W, 'fldCharType') || runChild.getAttribute('w:fldCharType') || runChild.getAttribute('fldCharType'))
439
+ : null;
440
+
441
+ sentinelMap.push({
442
+ start: fullText.length,
443
+ node: runChild,
444
+ wrapInRun: true,
445
+ rPr,
446
+ zeroWidth: isFieldStructure,
447
+ affinity: fieldCharType === 'end' ? 'left' : 'right',
448
+ isTextBox: hasTextBox,
449
+ originalContainer: hasTextBox ? textBoxContent : undefined
450
+ });
450
451
  fullText += '\uFFFC';
451
452
  propertyMap.push({ start: fullText.length - 1, end: fullText.length, rPr });
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';
453
+ } else if (isWordElement(runChild, 'footnoteReference') || isWordElement(runChild, 'endnoteReference')) {
454
+ const id = wordAttribute(runChild, 'id');
455
+ if (id) {
456
+ const type = isWordElement(runChild, 'footnoteReference') ? 'FN' : 'EN';
456
457
  const tokenString = `{{__${type}_${id}__}}`;
457
- const char = String.fromCharCode(characterState.nextCharCode++);
458
+ const char = String.fromCharCode(characterState.nextCharCode++);
458
459
  referenceMap.set(char, runChild);
459
460
  tokenToCharMap.set(tokenString, char);
460
461
  fullText += char;
461
462
  propertyMap.push({ start: fullText.length - 1, end: fullText.length, rPr });
462
463
  }
463
- } else if (isWordElement(runChild, 'commentReference')) {
464
- sentinelMap.push({ start: fullText.length, node: runChild, isCommentMarker: true });
464
+ } else if (isWordElement(runChild, 'commentReference')) {
465
+ sentinelMap.push({ start: fullText.length, node: runChild, isCommentMarker: true });
465
466
  }
466
467
  });
467
468
 
@@ -469,13 +470,13 @@ function processRunForReconstruction(runElement, originalFullText, propertyMap,
469
470
  }
470
471
 
471
472
  function processHyperlinkForReconstruction(hyperlinkElement, originalFullText, propertyMap) {
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');
473
+ let fullText = originalFullText;
474
+
475
+ Array.from(hyperlinkElement.childNodes).forEach(hyperlinkChild => {
476
+ if (!isWordElement(hyperlinkChild, 'r')) return;
477
+
478
+ const rPr = getFirstElementByTagNSOrTag(hyperlinkChild, NS_W, 'rPr');
479
+ const texts = getElementsByTagNSOrTag(hyperlinkChild, NS_W, 't');
479
480
  texts.forEach(textNode => {
480
481
  const textContent = textNode.textContent || '';
481
482
  if (textContent.length === 0) return;