@ansonlai/docx-redline-js 0.1.4 → 0.2.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 +53 -4
- package/ARCHITECTURE.md +75 -11
- package/README.md +62 -3
- package/core/redline-validation.js +156 -0
- package/core/types.js +35 -8
- package/core/word-xml.js +90 -0
- package/dist/docx-redline-js.esm.js +3195 -2592
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +71 -67
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/VALIDATION.md +104 -0
- package/docs/plans/2026-03-01-release-0.1.4-design.md +31 -0
- package/docs/plans/2026-03-01-release-0.1.4.md +108 -0
- package/docs/plans/2026-05-31-architectural changes.md +591 -0
- package/engine/format-application.js +13 -14
- package/engine/format-span-application.js +7 -6
- package/engine/formatting-removal.js +15 -12
- package/engine/oxml-engine.js +146 -55
- package/engine/reconstruction-mapper.js +35 -8
- package/engine/reconstruction-mode.js +14 -13
- package/engine/reconstruction-writer.js +97 -78
- package/engine/rpr-helpers.js +34 -32
- package/engine/run-builders.js +150 -39
- package/engine/surgical-diff-application.js +216 -0
- package/engine/surgical-mode.js +84 -519
- package/engine/surgical-run-splitting.js +96 -0
- package/engine/surgical-spans.js +169 -0
- package/engine/table-cell-context.js +15 -13
- package/engine/table-mode.js +39 -35
- package/index.d.ts +172 -0
- package/index.js +50 -47
- package/package.json +10 -2
- package/pipeline/ingestion-export.js +1 -0
- package/pipeline/ingestion-paragraph.js +37 -12
- package/pipeline/ingestion-table.js +11 -8
- package/scripts/build.mjs +40 -0
- package/scripts/check-types.mjs +29 -0
- package/scripts/export-validation-fixtures.mjs +125 -0
- package/scripts/lib/minimal-zip.mjs +155 -0
- package/scripts/run-tests.mjs +43 -0
- package/scripts/validate-fixtures-xsd.sh +37 -0
- package/scripts/word-com-differential.ps1 +133 -0
- package/scripts/word-com-smoke.ps1 +48 -0
- package/services/comment-locator.js +10 -9
- package/services/revision-comment-management.js +115 -1
- package/services/standalone-operation-runner.js +119 -69
- package/services/table-reconciliation.js +7 -8
package/engine/surgical-mode.js
CHANGED
|
@@ -1,520 +1,85 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Surgical reconciliation mode.
|
|
3
|
-
*
|
|
4
|
-
* This mode performs in-place run-level edits and preserves existing structure,
|
|
5
|
-
* making it safe for tables and other complex OOXML containers.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { getApplicableFormatHints } from '../pipeline/markdown-processor.js';
|
|
9
|
-
import { computeWordDiffs } from '../pipeline/diff-engine.js';
|
|
10
|
-
import { appendParagraphBoundary } from '../core/paragraph-offset-policy.js';
|
|
11
|
-
import { NS_W, getNextRevisionId } from '../core/types.js';
|
|
12
|
-
import { createSerializer } from '../adapters/xml-adapter.js';
|
|
1
|
+
/**
|
|
2
|
+
* Surgical reconciliation mode.
|
|
3
|
+
*
|
|
4
|
+
* This mode performs in-place run-level edits and preserves existing structure,
|
|
5
|
+
* making it safe for tables and other complex OOXML containers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getApplicableFormatHints } from '../pipeline/markdown-processor.js';
|
|
9
|
+
import { computeWordDiffs } from '../pipeline/diff-engine.js';
|
|
13
10
|
import { getDocumentParagraphs } from './format-extraction.js';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* @param {
|
|
29
|
-
* @param {
|
|
30
|
-
* @param {
|
|
31
|
-
* @param {
|
|
32
|
-
* @param {
|
|
33
|
-
* @
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Applies surgical mode reconciliation.
|
|
91
|
-
*
|
|
92
|
-
* @param {Document} xmlDoc - XML document
|
|
93
|
-
* @param {string} originalText - Original text
|
|
94
|
-
* @param {string} modifiedText - Modified text
|
|
95
|
-
* @param {XMLSerializer} serializer - Serializer instance
|
|
96
|
-
* @param {string} author - Author name
|
|
97
|
-
* @param {Array} formatHints - Format hints
|
|
98
|
-
* @param {boolean} [generateRedlines=true] - Track change toggle
|
|
99
|
-
* @param {Element|null} [targetParagraph=null] - Optional scope paragraph
|
|
100
|
-
* @returns {{ oxml: string, hasChanges: boolean }}
|
|
101
|
-
*/
|
|
102
|
-
export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, targetParagraph = null) {
|
|
103
|
-
let fullText = '';
|
|
104
|
-
const textSpans = [];
|
|
105
|
-
|
|
106
|
-
const allParagraphs = targetParagraph
|
|
107
|
-
? [targetParagraph]
|
|
108
|
-
: getDocumentParagraphs(xmlDoc);
|
|
109
|
-
|
|
110
|
-
allParagraphs.forEach((p, pIndex) => {
|
|
111
|
-
const container = p.parentNode;
|
|
112
|
-
|
|
113
|
-
for (let child = p.firstChild; child; child = child.nextSibling) {
|
|
114
|
-
if (child.nodeName === 'w:r') {
|
|
115
|
-
const runResult = processRunElement(child, p, container, fullText.length, textSpans);
|
|
116
|
-
fullText += runResult.text;
|
|
117
|
-
} else if (child.nodeName === 'w:hyperlink') {
|
|
118
|
-
for (let hc = child.firstChild; hc; hc = hc.nextSibling) {
|
|
119
|
-
if (hc.nodeName === 'w:r') {
|
|
120
|
-
const runResult = processRunElement(hc, p, container, fullText.length, textSpans);
|
|
121
|
-
fullText += runResult.text;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
fullText = appendParagraphBoundary(fullText, pIndex, allParagraphs.length);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
const diffs = computeWordDiffs(fullText, modifiedText);
|
|
131
|
-
const spanIndex = buildSpanIndex(textSpans);
|
|
132
|
-
|
|
133
|
-
let originalPos = 0;
|
|
134
|
-
let newPos = 0;
|
|
135
|
-
const processedSpans = new Set();
|
|
136
|
-
|
|
137
|
-
for (const [op, text] of diffs) {
|
|
138
|
-
if (op === 0) {
|
|
139
|
-
const len = text.length;
|
|
140
|
-
const startPos = originalPos;
|
|
141
|
-
const endPos = originalPos + len;
|
|
142
|
-
|
|
143
|
-
forEachOverlappingSpan(spanIndex, startPos, endPos, span => {
|
|
144
|
-
const overlapStartOriginal = Math.max(span.charStart, startPos);
|
|
145
|
-
const overlapEndOriginal = Math.min(span.charEnd, endPos);
|
|
146
|
-
const segmentLen = overlapEndOriginal - overlapStartOriginal;
|
|
147
|
-
const relativeOffset = overlapStartOriginal - startPos;
|
|
148
|
-
const overlapStartNew = newPos + relativeOffset;
|
|
149
|
-
const overlapEndNew = overlapStartNew + segmentLen;
|
|
150
|
-
const applicableHints = getApplicableFormatHints(formatHints, overlapStartNew, overlapEndNew);
|
|
151
|
-
reconcileFormattingForTextSpan(xmlDoc, span, overlapStartOriginal, overlapEndOriginal, applicableHints, author, generateRedlines);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
originalPos += len;
|
|
155
|
-
newPos += len;
|
|
156
|
-
} else if (op === -1) {
|
|
157
|
-
processDelete(xmlDoc, spanIndex, originalPos, originalPos + text.length, processedSpans, author, generateRedlines);
|
|
158
|
-
originalPos += text.length;
|
|
159
|
-
} else if (op === 1) {
|
|
160
|
-
const textWithoutNewlines = text.replace(/\n/g, ' ');
|
|
161
|
-
if (textWithoutNewlines.trim().length > 0) {
|
|
162
|
-
processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, processedSpans, author, formatHints, newPos, generateRedlines);
|
|
163
|
-
}
|
|
164
|
-
newPos += text.length;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
return { oxml: serializer.serializeToString(xmlDoc), hasChanges: true };
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Reconciles formatting for a text span segment.
|
|
173
|
-
*
|
|
174
|
-
* @param {Document} xmlDoc - XML document
|
|
175
|
-
* @param {Object} span - Text span
|
|
176
|
-
* @param {number} start - Absolute start
|
|
177
|
-
* @param {number} end - Absolute end
|
|
178
|
-
* @param {Array} applicableHints - Applicable format hints
|
|
179
|
-
* @param {string} author - Author name
|
|
180
|
-
* @param {boolean} generateRedlines - Track change toggle
|
|
181
|
-
*/
|
|
182
|
-
function reconcileFormattingForTextSpan(xmlDoc, span, start, end, applicableHints, author, generateRedlines) {
|
|
183
|
-
const desiredFormat = {};
|
|
184
|
-
if (applicableHints.length > 0) {
|
|
185
|
-
applicableHints.forEach(h => Object.assign(desiredFormat, h.format));
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const rPr = span.rPr;
|
|
189
|
-
const hasElement = (tagName) => {
|
|
190
|
-
if (!rPr) return false;
|
|
191
|
-
for (let node = rPr.firstChild; node; node = node.nextSibling) {
|
|
192
|
-
if (node.nodeName === tagName) {
|
|
193
|
-
return true;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
return false;
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
const existingFormat = {
|
|
200
|
-
bold: hasElement('w:b'),
|
|
201
|
-
italic: hasElement('w:i'),
|
|
202
|
-
underline: hasElement('w:u'),
|
|
203
|
-
strikethrough: hasElement('w:strike')
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
const formatsToCheck = ['bold', 'italic', 'underline', 'strikethrough'];
|
|
207
|
-
const changesNeeded = formatsToCheck.some(f => !!desiredFormat[f] !== existingFormat[f]);
|
|
208
|
-
|
|
209
|
-
if (!changesNeeded) return;
|
|
210
|
-
|
|
211
|
-
const parent = span.runElement.parentNode;
|
|
212
|
-
if (!parent) return;
|
|
213
|
-
|
|
214
|
-
const fullText = span.textElement.textContent || '';
|
|
215
|
-
const runStart = span.charStart;
|
|
216
|
-
|
|
217
|
-
const localStart = start - runStart;
|
|
218
|
-
const localEnd = end - runStart;
|
|
219
|
-
|
|
220
|
-
const beforeText = fullText.substring(0, localStart);
|
|
221
|
-
const affectedText = fullText.substring(localStart, localEnd);
|
|
222
|
-
const afterText = fullText.substring(localEnd);
|
|
223
|
-
|
|
224
|
-
if (beforeText.length > 0) {
|
|
225
|
-
const beforeRun = createTextRun(xmlDoc, beforeText, rPr, false);
|
|
226
|
-
parent.insertBefore(beforeRun, span.runElement);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const newRPr = injectFormattingToRPr(xmlDoc, rPr, desiredFormat, author, generateRedlines);
|
|
230
|
-
const newRun = createTextRunWithRPrElement(xmlDoc, affectedText, newRPr, false);
|
|
231
|
-
parent.insertBefore(newRun, span.runElement);
|
|
232
|
-
|
|
233
|
-
if (afterText.length > 0) {
|
|
234
|
-
const afterRun = createTextRun(xmlDoc, afterText, rPr, false);
|
|
235
|
-
parent.insertBefore(afterRun, span.runElement);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
parent.removeChild(span.runElement);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Processes a run and appends text span metadata.
|
|
243
|
-
*
|
|
244
|
-
* @param {Element} r - Run element
|
|
245
|
-
* @param {Element} p - Paragraph element
|
|
246
|
-
* @param {Element} container - Parent container
|
|
247
|
-
* @param {number} currentOffset - Absolute offset
|
|
248
|
-
* @param {Array} textSpans - Span collection
|
|
249
|
-
* @returns {{ nextOffset: number, text: string }}
|
|
250
|
-
*/
|
|
251
|
-
function processRunElement(r, p, container, currentOffset, textSpans) {
|
|
252
|
-
const rPr = getFirstElementByTag(r, 'w:rPr');
|
|
253
|
-
let localOffset = currentOffset;
|
|
254
|
-
const textParts = [];
|
|
255
|
-
|
|
256
|
-
for (let rc = r.firstChild; rc; rc = rc.nextSibling) {
|
|
257
|
-
if (rc.nodeName === 'w:t') {
|
|
258
|
-
const text = rc.textContent || '';
|
|
259
|
-
if (text.length > 0) {
|
|
260
|
-
textSpans.push({
|
|
261
|
-
charStart: localOffset,
|
|
262
|
-
charEnd: localOffset + text.length,
|
|
263
|
-
textElement: rc,
|
|
264
|
-
runElement: r,
|
|
265
|
-
paragraph: p,
|
|
266
|
-
container,
|
|
267
|
-
rPr
|
|
268
|
-
});
|
|
269
|
-
localOffset += text.length;
|
|
270
|
-
textParts.push(text);
|
|
271
|
-
}
|
|
272
|
-
} else if (rc.nodeName === 'w:br' || rc.nodeName === 'w:cr') {
|
|
273
|
-
textSpans.push({
|
|
274
|
-
charStart: localOffset,
|
|
275
|
-
charEnd: localOffset + 1,
|
|
276
|
-
textElement: rc,
|
|
277
|
-
runElement: r,
|
|
278
|
-
paragraph: p,
|
|
279
|
-
container,
|
|
280
|
-
rPr
|
|
281
|
-
});
|
|
282
|
-
localOffset += 1;
|
|
283
|
-
textParts.push('\n');
|
|
284
|
-
} else if (rc.nodeName === 'w:tab') {
|
|
285
|
-
textSpans.push({
|
|
286
|
-
charStart: localOffset,
|
|
287
|
-
charEnd: localOffset + 1,
|
|
288
|
-
textElement: rc,
|
|
289
|
-
runElement: r,
|
|
290
|
-
paragraph: p,
|
|
291
|
-
container,
|
|
292
|
-
rPr
|
|
293
|
-
});
|
|
294
|
-
localOffset += 1;
|
|
295
|
-
textParts.push('\t');
|
|
296
|
-
} else if (rc.nodeName === 'w:noBreakHyphen') {
|
|
297
|
-
textSpans.push({
|
|
298
|
-
charStart: localOffset,
|
|
299
|
-
charEnd: localOffset + 1,
|
|
300
|
-
textElement: rc,
|
|
301
|
-
runElement: r,
|
|
302
|
-
paragraph: p,
|
|
303
|
-
container,
|
|
304
|
-
rPr
|
|
305
|
-
});
|
|
306
|
-
localOffset += 1;
|
|
307
|
-
textParts.push('\u2011');
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
return { nextOffset: localOffset, text: textParts.join('') };
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* Applies deletion over affected spans.
|
|
315
|
-
*
|
|
316
|
-
* @param {Document} xmlDoc - XML document
|
|
317
|
-
* @param {{ spans: Array, starts: number[], ends: number[] }} spanIndex - Indexed spans
|
|
318
|
-
* @param {number} startPos - Delete start
|
|
319
|
-
* @param {number} endPos - Delete end
|
|
320
|
-
* @param {Set<Node>} processedSpans - Processed marker set
|
|
321
|
-
* @param {string} author - Author name
|
|
322
|
-
* @param {boolean} generateRedlines - Track change toggle
|
|
323
|
-
*/
|
|
324
|
-
function processDelete(xmlDoc, spanIndex, startPos, endPos, processedSpans, author, generateRedlines) {
|
|
325
|
-
forEachOverlappingSpan(spanIndex, startPos, endPos, span => {
|
|
326
|
-
if (processedSpans.has(span.textElement)) return;
|
|
327
|
-
|
|
328
|
-
const deleteStart = Math.max(0, startPos - span.charStart);
|
|
329
|
-
const deleteEnd = Math.min(span.charEnd - span.charStart, endPos - span.charStart);
|
|
330
|
-
|
|
331
|
-
const originalText = span.textElement.textContent || '';
|
|
332
|
-
const beforeText = originalText.substring(0, deleteStart);
|
|
333
|
-
const deletedText = originalText.substring(deleteStart, deleteEnd);
|
|
334
|
-
const afterText = originalText.substring(deleteEnd);
|
|
335
|
-
|
|
336
|
-
if (deletedText.length === 0) return;
|
|
337
|
-
|
|
338
|
-
const parent = span.runElement.parentNode;
|
|
339
|
-
if (!parent) return;
|
|
340
|
-
|
|
341
|
-
if (beforeText.length === 0 && afterText.length === 0) {
|
|
342
|
-
const delRun = createTextRun(xmlDoc, deletedText, span.rPr, true);
|
|
343
|
-
if (generateRedlines) {
|
|
344
|
-
const delWrapper = createTrackChange(xmlDoc, 'del', delRun, author);
|
|
345
|
-
parent.insertBefore(delWrapper, span.runElement);
|
|
346
|
-
}
|
|
347
|
-
parent.removeChild(span.runElement);
|
|
348
|
-
} else {
|
|
349
|
-
const oldRun = span.runElement;
|
|
350
|
-
|
|
351
|
-
if (beforeText.length > 0) {
|
|
352
|
-
const beforeRun = createTextRun(xmlDoc, beforeText, span.rPr, false);
|
|
353
|
-
parent.insertBefore(beforeRun, oldRun);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
const delRun = createTextRun(xmlDoc, deletedText, span.rPr, true);
|
|
357
|
-
if (generateRedlines) {
|
|
358
|
-
const delWrapper = createTrackChange(xmlDoc, 'del', delRun, author);
|
|
359
|
-
parent.insertBefore(delWrapper, oldRun);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
if (afterText.length > 0) {
|
|
363
|
-
const afterRun = createTextRun(xmlDoc, afterText, span.rPr, false);
|
|
364
|
-
parent.insertBefore(afterRun, oldRun);
|
|
365
|
-
span.runElement = afterRun;
|
|
366
|
-
span.textElement = getFirstElementByTag(afterRun, 'w:t') || getFirstElementByTag(afterRun, 't');
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
parent.removeChild(oldRun);
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
processedSpans.add(span.textElement);
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/**
|
|
377
|
-
* Inserts new text at an absolute position.
|
|
378
|
-
*
|
|
379
|
-
* @param {Document} xmlDoc - XML document
|
|
380
|
-
* @param {{ spans: Array, starts: number[], ends: number[] }} spanIndex - Indexed spans
|
|
381
|
-
* @param {number} pos - Insertion position
|
|
382
|
-
* @param {string} text - Text to insert
|
|
383
|
-
* @param {Set<Node>} processedSpans - Processed marker set (unused, kept for signature)
|
|
384
|
-
* @param {string} author - Author name
|
|
385
|
-
* @param {Array} [formatHints=[]] - Format hints
|
|
386
|
-
* @param {number} [insertOffset=0] - Offset in modified text
|
|
387
|
-
* @param {boolean} [generateRedlines=true] - Track change toggle
|
|
388
|
-
*/
|
|
389
|
-
function processInsert(xmlDoc, spanIndex, pos, text, processedSpans, author, formatHints = [], insertOffset = 0, generateRedlines = true) {
|
|
390
|
-
void processedSpans;
|
|
391
|
-
|
|
392
|
-
let targetSpan = findContainingSpan(spanIndex, pos);
|
|
393
|
-
|
|
394
|
-
if (!targetSpan && pos > 0) {
|
|
395
|
-
targetSpan = findFirstSpanEndingAt(spanIndex, pos);
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
if (!targetSpan && pos > 0) {
|
|
399
|
-
targetSpan = findLastSpanEndingBeforeOrAt(spanIndex, pos);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
if (!targetSpan && spanIndex.spans.length > 0) {
|
|
403
|
-
targetSpan = spanIndex.spans[spanIndex.spans.length - 1];
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
if (targetSpan) {
|
|
407
|
-
const applicableHints = getApplicableFormatHints(formatHints, insertOffset, insertOffset + text.length);
|
|
408
|
-
const baseRPr = targetSpan.rPr;
|
|
409
|
-
const parent = targetSpan.runElement.parentNode;
|
|
410
|
-
|
|
411
|
-
if (parent) {
|
|
412
|
-
const referenceNode = (pos === targetSpan.charStart) ? targetSpan.runElement : targetSpan.runElement.nextSibling;
|
|
413
|
-
|
|
414
|
-
if (applicableHints.length === 0) {
|
|
415
|
-
const insRun = createTextRun(xmlDoc, text, baseRPr, false);
|
|
416
|
-
if (generateRedlines) {
|
|
417
|
-
const insWrapper = createTrackChange(xmlDoc, 'ins', insRun, author);
|
|
418
|
-
parent.insertBefore(insWrapper, referenceNode);
|
|
419
|
-
} else {
|
|
420
|
-
parent.insertBefore(insRun, referenceNode);
|
|
421
|
-
}
|
|
422
|
-
} else {
|
|
423
|
-
const runs = createFormattedRuns(xmlDoc, text, baseRPr, applicableHints, insertOffset, author, generateRedlines);
|
|
424
|
-
|
|
425
|
-
if (generateRedlines) {
|
|
426
|
-
const insWrapper = createTrackChange(xmlDoc, 'ins', null, author);
|
|
427
|
-
runs.forEach(run => insWrapper.appendChild(run));
|
|
428
|
-
parent.insertBefore(insWrapper, referenceNode);
|
|
429
|
-
} else {
|
|
430
|
-
runs.forEach(run => parent.insertBefore(run, referenceNode));
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
function buildSpanIndex(textSpans) {
|
|
438
|
-
const spans = textSpans
|
|
439
|
-
.slice()
|
|
440
|
-
.sort((a, b) => a.charStart - b.charStart || a.charEnd - b.charEnd);
|
|
441
|
-
|
|
442
|
-
const starts = spans.map(span => span.charStart);
|
|
443
|
-
const ends = spans.map(span => span.charEnd);
|
|
444
|
-
|
|
445
|
-
return { spans, starts, ends };
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
function upperBound(values, target) {
|
|
449
|
-
let left = 0;
|
|
450
|
-
let right = values.length;
|
|
451
|
-
|
|
452
|
-
while (left < right) {
|
|
453
|
-
const middle = (left + right) >> 1;
|
|
454
|
-
if (values[middle] <= target) {
|
|
455
|
-
left = middle + 1;
|
|
456
|
-
} else {
|
|
457
|
-
right = middle;
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
return left;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
function lowerBound(values, target) {
|
|
465
|
-
let left = 0;
|
|
466
|
-
let right = values.length;
|
|
467
|
-
|
|
468
|
-
while (left < right) {
|
|
469
|
-
const middle = (left + right) >> 1;
|
|
470
|
-
if (values[middle] < target) {
|
|
471
|
-
left = middle + 1;
|
|
472
|
-
} else {
|
|
473
|
-
right = middle;
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
return left;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
function forEachOverlappingSpan(spanIndex, startPos, endPos, callback) {
|
|
481
|
-
if (endPos <= startPos || spanIndex.spans.length === 0) {
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
let index = upperBound(spanIndex.ends, startPos);
|
|
486
|
-
while (index < spanIndex.spans.length) {
|
|
487
|
-
const span = spanIndex.spans[index];
|
|
488
|
-
if (span.charStart >= endPos) {
|
|
489
|
-
break;
|
|
490
|
-
}
|
|
491
|
-
callback(span);
|
|
492
|
-
index++;
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
function findContainingSpan(spanIndex, pos) {
|
|
497
|
-
if (spanIndex.spans.length === 0) return null;
|
|
498
|
-
|
|
499
|
-
const index = upperBound(spanIndex.starts, pos) - 1;
|
|
500
|
-
if (index < 0) return null;
|
|
501
|
-
|
|
502
|
-
const span = spanIndex.spans[index];
|
|
503
|
-
return pos >= span.charStart && pos < span.charEnd ? span : null;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
function findFirstSpanEndingAt(spanIndex, pos) {
|
|
507
|
-
const index = lowerBound(spanIndex.ends, pos);
|
|
508
|
-
if (index < spanIndex.spans.length && spanIndex.ends[index] === pos) {
|
|
509
|
-
return spanIndex.spans[index];
|
|
510
|
-
}
|
|
511
|
-
return null;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
function findLastSpanEndingBeforeOrAt(spanIndex, pos) {
|
|
515
|
-
const index = upperBound(spanIndex.ends, pos) - 1;
|
|
516
|
-
if (index >= 0) {
|
|
517
|
-
return spanIndex.spans[index];
|
|
518
|
-
}
|
|
519
|
-
return null;
|
|
520
|
-
}
|
|
11
|
+
import { buildSpanIndex, buildSurgicalTextSpans, forEachOverlappingSpan } from './surgical-spans.js';
|
|
12
|
+
import {
|
|
13
|
+
processDelete,
|
|
14
|
+
processInsert,
|
|
15
|
+
reconcileFormattingForTextSpan
|
|
16
|
+
} from './surgical-diff-application.js';
|
|
17
|
+
import { withOoxmlSourceType } from '../core/word-xml.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Applies surgical mode reconciliation.
|
|
21
|
+
*
|
|
22
|
+
* @param {Document} xmlDoc - XML document
|
|
23
|
+
* @param {string} originalText - Original text
|
|
24
|
+
* @param {string} modifiedText - Modified text
|
|
25
|
+
* @param {XMLSerializer} serializer - Serializer instance
|
|
26
|
+
* @param {string} author - Author name
|
|
27
|
+
* @param {Array} formatHints - Format hints
|
|
28
|
+
* @param {boolean} [generateRedlines=true] - Track change toggle
|
|
29
|
+
* @param {Element|null} [targetParagraph=null] - Optional scope paragraph
|
|
30
|
+
* @returns {{ oxml: string, hasChanges: boolean, sourceType?: 'package'|'document'|'fragment' }}
|
|
31
|
+
*/
|
|
32
|
+
export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, targetParagraph = null) {
|
|
33
|
+
void originalText;
|
|
34
|
+
|
|
35
|
+
const allParagraphs = targetParagraph
|
|
36
|
+
? [targetParagraph]
|
|
37
|
+
: getDocumentParagraphs(xmlDoc);
|
|
38
|
+
|
|
39
|
+
const { fullText, textSpans } = buildSurgicalTextSpans(allParagraphs);
|
|
40
|
+
const diffs = computeWordDiffs(fullText, modifiedText);
|
|
41
|
+
const spanIndex = buildSpanIndex(textSpans);
|
|
42
|
+
|
|
43
|
+
let originalPos = 0;
|
|
44
|
+
let newPos = 0;
|
|
45
|
+
let hasChanges = false;
|
|
46
|
+
|
|
47
|
+
for (const [op, text] of diffs) {
|
|
48
|
+
if (op === 0) {
|
|
49
|
+
const len = text.length;
|
|
50
|
+
const startPos = originalPos;
|
|
51
|
+
const endPos = originalPos + len;
|
|
52
|
+
|
|
53
|
+
forEachOverlappingSpan(spanIndex, startPos, endPos, span => {
|
|
54
|
+
const overlapStartOriginal = Math.max(span.charStart, startPos);
|
|
55
|
+
const overlapEndOriginal = Math.min(span.charEnd, endPos);
|
|
56
|
+
const segmentLen = overlapEndOriginal - overlapStartOriginal;
|
|
57
|
+
const relativeOffset = overlapStartOriginal - startPos;
|
|
58
|
+
const overlapStartNew = newPos + relativeOffset;
|
|
59
|
+
const overlapEndNew = overlapStartNew + segmentLen;
|
|
60
|
+
const applicableHints = getApplicableFormatHints(formatHints, overlapStartNew, overlapEndNew);
|
|
61
|
+
if (reconcileFormattingForTextSpan(xmlDoc, span, overlapStartOriginal, overlapEndOriginal, applicableHints, author, generateRedlines)) {
|
|
62
|
+
hasChanges = true;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
originalPos += len;
|
|
67
|
+
newPos += len;
|
|
68
|
+
} else if (op === -1) {
|
|
69
|
+
if (processDelete(xmlDoc, spanIndex, originalPos, originalPos + text.length, author, generateRedlines)) {
|
|
70
|
+
hasChanges = true;
|
|
71
|
+
}
|
|
72
|
+
originalPos += text.length;
|
|
73
|
+
} else if (op === 1) {
|
|
74
|
+
const textWithoutNewlines = text.replace(/\n/g, ' ');
|
|
75
|
+
if (textWithoutNewlines.trim().length > 0) {
|
|
76
|
+
if (processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null)) {
|
|
77
|
+
hasChanges = true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
newPos += text.length;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return withOoxmlSourceType({ oxml: serializer.serializeToString(xmlDoc), hasChanges });
|
|
85
|
+
}
|