@ansonlai/docx-redline-js 0.8.4 → 0.9.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.
- package/ARCHITECTURE.md +221 -12
- package/CHANGELOG.md +176 -0
- package/README.md +125 -12
- package/core/current-properties.js +136 -0
- package/core/paragraph-text.js +211 -209
- package/core/types.js +9 -3
- package/core/word-revision-semantics.js +256 -0
- package/dist/docx-redline-js.esm.js +4189 -767
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +111 -108
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +4292 -870
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +4292 -870
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_FAST_START.md +2 -0
- package/docs/AGENT_KNOWLEDGE_BASE.md +19 -3
- package/docs/SKILL_AUTHORING.md +165 -150
- package/docs/TESTING.md +1142 -1059
- package/docs/schemas/document-operations.schema.json +54 -1
- package/document/docx-document.js +1022 -1010
- package/engine/oxml-engine.js +736 -749
- package/engine/paragraph-mark-revisions.js +292 -0
- package/engine/reconstruction-mapper.js +312 -311
- package/engine/reconstruction-writer.js +67 -10
- package/engine/rpr-helpers.js +42 -0
- package/engine/run-builders.js +0 -73
- package/engine/surgical-diff-application.js +604 -605
- package/engine/table-mode.js +63 -63
- package/index.d.ts +47 -1
- package/index.js +11 -2
- package/node/cli-help.js +1 -0
- package/node/cli.js +992 -975
- package/orchestration/list-structural-fallback.js +67 -9
- package/package.json +1 -1
- package/pipeline/diff-engine.js +130 -1
- package/pipeline/ingestion-export.js +227 -243
- package/pipeline/ingestion-paragraph.js +93 -98
- package/pipeline/list-generation.js +415 -419
- package/pipeline/pipeline.js +206 -204
- package/scripts/export-validation-fixtures.mjs +19 -6
- package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
- package/scripts/lib/word-coverage-catalogue.mjs +7 -2
- package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
- package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
- package/scripts/word-com-differential.ps1 +443 -335
- package/scripts/word-com-visual-suite.ps1 +2 -2
- package/scripts/word-revision-semantics-matrix.ps1 +108 -0
- package/services/batch-operation-orchestrator.js +23 -3
- package/services/capture-engine.js +228 -226
- package/services/document-operation-applier.js +213 -0
- package/services/document-operation-contract.js +75 -0
- package/services/document-operation-mutations.js +3346 -2721
- package/services/error-recovery.js +17 -0
- package/services/list-conversion-diagnostics.js +296 -0
- package/services/list-format-change.js +586 -0
- package/services/list-format-mutation.js +416 -0
- package/services/list-format.js +305 -0
- package/services/numbering-allocation.js +642 -0
- package/services/numbering-helpers.js +354 -524
- package/services/operation-batch-compiler.js +183 -11
- package/services/operation-preflight.js +1 -0
- package/services/receipt-collector.js +288 -288
- package/services/revision-comment-management.js +38 -5
- package/services/revision-fidelity-verifier.js +498 -0
- package/services/standalone-docx-plumbing.js +627 -631
- package/services/standalone-operation-runner.d.ts +182 -5
- 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;
|