@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.
- package/AGENTS.md +36 -10
- package/README.md +83 -6
- package/adapters/xml-adapter.js +73 -10
- package/core/list-targeting.js +3 -0
- package/core/paragraph-targeting.js +33 -7
- package/core/redline-validation.js +22 -0
- package/core/types.js +122 -27
- package/core/xml-query.js +3 -1
- package/dist/docx-redline-js.esm.js +1124 -545
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +79 -78
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +687 -0
- package/docs/VALIDATION.md +81 -2
- package/docs/WORD-MANUAL-REVIEW.md +138 -0
- package/docs/plans/2026-08-30-reliability-testing-improvements.md +488 -0
- package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +210 -0
- package/docs/plans/{2026-03-01-release-0.1.4-design.md → completed/2026-03-01-release-0.1.4-design.md} +2 -0
- package/docs/plans/{2026-03-01-release-0.1.4.md → completed/2026-03-01-release-0.1.4.md} +5 -3
- package/docs/plans/{2026-05-31-architectural changes.md → completed/2026-05-31-architectural changes.md } +2 -0
- package/docs/plans/completed/2026-08-02-reliability-improvements.md +1155 -0
- package/docs/test-comparison-dashboard.html +95 -0
- package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +22 -0
- package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +24 -0
- package/docs/validation-reports/2026-08-30-phase-3-coverage.md +73 -0
- package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +82 -0
- package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +114 -0
- package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +79 -0
- package/engine/format-extraction.js +1 -1
- package/engine/formatting-removal.js +84 -99
- package/engine/oxml-engine.js +176 -83
- package/engine/reconstruction-mapper.js +276 -79
- package/engine/reconstruction-mode.js +20 -6
- package/engine/reconstruction-writer.js +117 -72
- package/engine/run-builders.js +3 -3
- package/engine/surgical-mode.js +3 -2
- package/engine/table-mode.js +27 -16
- package/index.d.ts +95 -3
- package/index.js +14 -13
- package/orchestration/list-structural-fallback.js +16 -39
- package/package.json +22 -4
- package/pipeline/diff-engine.js +174 -55
- package/pipeline/ingestion-export.js +39 -24
- package/pipeline/ingestion-paragraph.js +7 -5
- package/pipeline/list-generation.js +27 -18
- package/pipeline/patching.js +2 -3
- package/pipeline/pipeline.js +65 -36
- package/pipeline/serialization.js +13 -5
- package/scripts/build-test-dashboard.mjs +43 -0
- package/scripts/check-types.mjs +16 -24
- package/scripts/export-validation-fixtures.mjs +191 -45
- package/scripts/fetch-superdoc-corpus.mjs +61 -0
- package/scripts/generate-test-dashboard.mjs +199 -0
- package/scripts/inspect-visual-evidence.mjs +271 -0
- package/scripts/lib/minimal-zip.mjs +199 -18
- package/scripts/lib/word-coverage-catalogue.mjs +207 -0
- package/scripts/lib/word-coverage-metadata.mjs +93 -0
- package/scripts/lib/zip-reader.mjs +64 -0
- package/scripts/package-superdoc-word-fixtures.ps1 +64 -0
- package/scripts/prepare-corpus-word-visual-review.mjs +84 -0
- package/scripts/prepare-superdoc-word-corpus.mjs +284 -0
- package/scripts/prepare-word-review.mjs +77 -0
- package/scripts/prepare-word-visual-review.mjs +90 -0
- package/scripts/render-agenda-multilevel.mjs +70 -0
- package/scripts/render-case22.mjs +73 -0
- package/scripts/render-case40.ps1 +35 -0
- package/scripts/render-multilevel-bullet-images.py +58 -0
- package/scripts/render-multilevel-bullet-visual.ps1 +32 -0
- package/scripts/render-multilevel-cases.mjs +80 -0
- package/scripts/report-coverage-gaps.mjs +103 -0
- package/scripts/report-word-coverage.mjs +71 -0
- package/scripts/sample-multimodal-visual-check.mjs +221 -0
- package/scripts/test-multilevel-bullet-visual.mjs +187 -0
- package/scripts/word-com-corpus-suite.ps1 +43 -0
- package/scripts/word-com-corpus-visual-suite.ps1 +116 -0
- package/scripts/word-com-differential.ps1 +158 -16
- package/scripts/word-com-suite.ps1 +19 -0
- package/scripts/word-com-visual-suite.ps1 +132 -0
- package/services/comment-engine.js +51 -46
- package/services/comment-locator.js +0 -1
- package/services/comment-package.js +11 -10
- package/services/numbering-service.js +1 -1
- package/services/revision-comment-management.js +31 -10
- package/services/standalone-docx-plumbing.js +45 -34
- package/services/standalone-operation-runner.js +315 -75
- 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 {
|
|
8
|
-
|
|
9
|
-
import {
|
|
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
|
-
|
|
16
|
-
|
|
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
|
|
65
|
-
const paragraphs = getDocumentParagraphs(xmlDoc);
|
|
66
|
-
|
|
67
|
-
let 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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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 =
|
|
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
|
|
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,
|
|
202
|
-
if (child
|
|
203
|
-
return processRunForReconstruction(child, originalFullText, propertyMap, sentinelMap, referenceMap, tokenToCharMap,
|
|
204
|
-
}
|
|
205
|
-
if (child
|
|
206
|
-
return processHyperlinkForReconstruction(child, originalFullText, propertyMap);
|
|
207
|
-
}
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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,
|
|
220
|
-
let fullText = originalFullText;
|
|
221
|
-
const rPr =
|
|
222
|
-
|
|
223
|
-
Array.from(runElement.childNodes).forEach(runChild => {
|
|
224
|
-
if (runChild
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
fullText +=
|
|
239
|
-
propertyMap.push({ start: fullText.length - 1, end: fullText.length, rPr });
|
|
240
|
-
} else if (runChild
|
|
241
|
-
fullText += '\
|
|
242
|
-
propertyMap.push({ start: fullText.length - 1, end: fullText.length, rPr });
|
|
243
|
-
} else if (
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
256
|
-
const id = runChild
|
|
257
|
-
if (id) {
|
|
258
|
-
const type = runChild
|
|
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
|
|
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
|
|
279
|
-
|
|
280
|
-
const rPr =
|
|
281
|
-
const texts =
|
|
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
|
-
* @
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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,
|