@ansonlai/docx-redline-js 0.4.0 → 0.5.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 +589 -287
- package/ARCHITECTURE.md +215 -9
- package/CHANGELOG.md +319 -0
- package/README.md +604 -360
- package/adapters/config.js +45 -43
- package/bin/docx-redline.js +3 -0
- package/core/list-targeting.js +101 -110
- package/core/paragraph-targeting.js +501 -61
- package/core/paragraph-text.js +209 -0
- package/core/revision-cloning.js +38 -0
- package/core/types.js +64 -10
- package/core/word-xml.js +43 -15
- package/dist/docx-redline-js.esm.js +2849 -466
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +87 -76
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +342 -23
- package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
- package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
- package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
- package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
- package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
- package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
- package/docs/schemas/document-operations.schema.json +109 -0
- package/docs/test-comparison-dashboard.html +4250 -7
- package/engine/formatting-removal.js +11 -2
- package/engine/oxml-engine.js +491 -336
- package/engine/reconstruction-mode.js +15 -14
- package/engine/reconstruction-writer.js +247 -142
- package/engine/route-selection.js +35 -0
- package/engine/rpr-helpers.js +334 -35
- package/engine/run-builders.js +239 -196
- package/engine/surgical-diff-application.js +222 -37
- package/engine/surgical-mode.js +134 -6
- package/engine/surgical-spans.js +52 -1
- package/engine/table-cell-context.js +3 -6
- package/engine/table-mode.js +1 -1
- package/index.d.ts +234 -6
- package/index.js +24 -1
- package/node/cli.js +317 -0
- package/node/docx-document.js +302 -0
- package/node/index.d.ts +31 -0
- package/node/index.js +2 -0
- package/node/zip-archive.js +52 -0
- package/orchestration/list-markdown.js +10 -16
- package/orchestration/list-parsing.js +7 -12
- package/orchestration/list-structural-fallback.js +21 -10
- package/package.json +24 -3
- package/pipeline/content-analysis.js +12 -17
- package/pipeline/ingestion-export.js +3 -31
- package/pipeline/ingestion-paragraph.js +10 -5
- package/pipeline/list-generation.js +150 -55
- package/pipeline/list-markers.js +70 -3
- package/pipeline/serialization.js +4 -2
- package/pipeline/structured-content.js +160 -0
- package/scripts/apply_changes.mjs +27 -0
- package/scripts/benchmark-operation-session.mjs +137 -0
- package/scripts/benchmark-targeting-browser.html +74 -0
- package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
- package/scripts/benchmark-test-runner.mjs +59 -0
- package/scripts/build-test-dashboard.mjs +23 -0
- package/scripts/export-lane1-fixtures.mjs +380 -0
- package/scripts/export-reredline-stress-fixtures.mjs +317 -0
- package/scripts/export-validation-fixtures.mjs +1 -1
- package/scripts/extract_text.mjs +7 -0
- package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
- package/scripts/generate-test-dashboard.mjs +362 -11
- package/scripts/lib/word-coverage-catalogue.mjs +6 -2
- package/scripts/profile-route-selection.mjs +19 -0
- package/scripts/render-agenda-multilevel.mjs +0 -5
- package/scripts/render-multilevel-cases.mjs +0 -1
- package/scripts/run-tests.mjs +107 -35
- package/scripts/word-com-corpus-suite.ps1 +3 -0
- package/scripts/word-com-differential.ps1 +64 -4
- package/scripts/word-com-suite.ps1 +3 -0
- package/services/batch-operation-orchestrator.js +494 -0
- package/services/capture-engine.js +226 -0
- package/services/comment-builders.js +23 -6
- package/services/comment-engine.js +108 -47
- package/services/comment-locator.js +187 -82
- package/services/comment-replies.js +95 -0
- package/services/document-inspection.js +258 -0
- package/services/document-operation-applier.js +372 -0
- package/services/document-operation-contract.js +323 -0
- package/services/document-operation-mutations.js +1733 -0
- package/services/document-operation-session.js +258 -0
- package/services/numbering-service.js +14 -5
- package/services/operation-heuristics.js +173 -0
- package/services/operation-preflight.js +366 -0
- package/services/receipt-collector.js +288 -0
- package/services/revision-comment-management.js +37 -5
- package/services/revision-token.js +290 -0
- package/services/standalone-docx-plumbing.js +123 -8
- package/services/standalone-operation-runner.d.ts +296 -0
- package/services/standalone-operation-runner.js +10 -1455
- package/services/table-reconciliation.js +15 -6
- package/docs/VALIDATION.md +0 -183
- package/docs/WORD-MANUAL-REVIEW.md +0 -138
- package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
- /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
createTextRunWithRPrElement,
|
|
7
7
|
injectFormattingToRPr
|
|
8
8
|
} from './run-builders.js';
|
|
9
|
+
import { createRevisionMetadata } from '../core/types.js';
|
|
9
10
|
import {
|
|
10
11
|
createRunFromPieces,
|
|
11
12
|
getRunContentPieces,
|
|
@@ -17,9 +18,12 @@ import {
|
|
|
17
18
|
findContainingSpan,
|
|
18
19
|
findFirstSpanEndingAt,
|
|
19
20
|
findLastSpanEndingBeforeOrAt,
|
|
20
|
-
forEachOverlappingSpan
|
|
21
|
+
forEachOverlappingSpan,
|
|
22
|
+
describeInsertionBoundary
|
|
21
23
|
} from './surgical-spans.js';
|
|
22
24
|
import { extractFormatFromRPr } from './rpr-helpers.js';
|
|
25
|
+
import { isWordElement } from '../core/word-xml.js';
|
|
26
|
+
import { NS_W } from '../core/types.js';
|
|
23
27
|
|
|
24
28
|
export function reconcileFormattingForTextSpan(xmlDoc, span, start, end, applicableHints, author, generateRedlines) {
|
|
25
29
|
// Plain modified text carries no negative formatting instruction. Preserve
|
|
@@ -67,7 +71,7 @@ export function reconcileFormattingForTextSpan(xmlDoc, span, start, end, applica
|
|
|
67
71
|
return true;
|
|
68
72
|
}
|
|
69
73
|
|
|
70
|
-
export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, generateRedlines) {
|
|
74
|
+
export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, generateRedlines, revisionMetadata = null) {
|
|
71
75
|
const spans = [];
|
|
72
76
|
forEachOverlappingSpan(spanIndex, startPos, endPos, span => {
|
|
73
77
|
spans.push(span);
|
|
@@ -83,6 +87,7 @@ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, gener
|
|
|
83
87
|
});
|
|
84
88
|
|
|
85
89
|
let changed = false;
|
|
90
|
+
let usedDelMetadata = false;
|
|
86
91
|
spansByRun.forEach((runSpans, runElement) => {
|
|
87
92
|
const parent = runElement.parentNode;
|
|
88
93
|
if (!parent) return;
|
|
@@ -114,7 +119,11 @@ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, gener
|
|
|
114
119
|
|
|
115
120
|
if (generateRedlines && deletedPieces.length > 0) {
|
|
116
121
|
const delRun = createRunFromPieces(xmlDoc, deletedPieces, runSpans[0].rPr);
|
|
117
|
-
const
|
|
122
|
+
const metadata = revisionMetadata
|
|
123
|
+
? (usedDelMetadata ? { ...revisionMetadata, id: createRevisionMetadata(author, xmlDoc).id } : revisionMetadata)
|
|
124
|
+
: null;
|
|
125
|
+
usedDelMetadata = true;
|
|
126
|
+
const delWrapper = createTrackChange(xmlDoc, 'del', delRun, author, metadata);
|
|
118
127
|
parent.insertBefore(delWrapper, runElement);
|
|
119
128
|
}
|
|
120
129
|
|
|
@@ -126,63 +135,239 @@ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, gener
|
|
|
126
135
|
return changed;
|
|
127
136
|
}
|
|
128
137
|
|
|
129
|
-
export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], insertOffset = 0, generateRedlines = true, fallbackParagraph = null) {
|
|
130
|
-
|
|
138
|
+
export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], insertOffset = 0, generateRedlines = true, fallbackParagraph = null, revisionMetadata = null, affinity = null) {
|
|
139
|
+
if (!affinity) {
|
|
140
|
+
let targetSpan = findContainingSpan(spanIndex, pos);
|
|
131
141
|
|
|
132
|
-
|
|
133
|
-
|
|
142
|
+
if (!targetSpan && pos > 0) {
|
|
143
|
+
targetSpan = findFirstSpanEndingAt(spanIndex, pos);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!targetSpan && pos > 0) {
|
|
147
|
+
targetSpan = findLastSpanEndingBeforeOrAt(spanIndex, pos);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!targetSpan && spanIndex.spans.length > 0) {
|
|
151
|
+
targetSpan = spanIndex.spans[spanIndex.spans.length - 1];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!targetSpan) {
|
|
155
|
+
if (!fallbackParagraph) return false;
|
|
156
|
+
insertTextRuns(xmlDoc, fallbackParagraph, null, text, null, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const parent = targetSpan.runElement.parentNode;
|
|
161
|
+
if (!parent) {
|
|
162
|
+
if (!fallbackParagraph) return false;
|
|
163
|
+
insertTextRuns(xmlDoc, fallbackParagraph, null, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const pieces = getRunContentPieces(targetSpan.runElement);
|
|
168
|
+
const targetPiece = pieces.find(piece => piece.node === targetSpan.textElement);
|
|
169
|
+
const localInsertPos = targetPiece
|
|
170
|
+
? targetPiece.start + Math.max(0, Math.min(pos - targetSpan.charStart, targetSpan.charEnd - targetSpan.charStart))
|
|
171
|
+
: (pos <= targetSpan.charStart ? 0 : getRunTextLength(pieces));
|
|
172
|
+
|
|
173
|
+
if (localInsertPos > 0 && localInsertPos < getRunTextLength(pieces)) {
|
|
174
|
+
const beforePieces = sliceRunPieces(xmlDoc, pieces, 0, localInsertPos, false);
|
|
175
|
+
const afterPieces = sliceRunPieces(xmlDoc, pieces, localInsertPos, getRunTextLength(pieces), false);
|
|
176
|
+
|
|
177
|
+
insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, beforePieces, targetSpan.rPr);
|
|
178
|
+
insertTextRuns(xmlDoc, parent, targetSpan.runElement, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
|
|
179
|
+
insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, afterPieces, targetSpan.rPr);
|
|
180
|
+
parent.removeChild(targetSpan.runElement);
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const referenceNode = pos <= targetSpan.charStart ? targetSpan.runElement : targetSpan.runElement.nextSibling;
|
|
185
|
+
insertTextRuns(xmlDoc, parent, referenceNode, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
|
|
186
|
+
return true;
|
|
134
187
|
}
|
|
135
188
|
|
|
136
|
-
|
|
137
|
-
|
|
189
|
+
const boundary = describeInsertionBoundary(spanIndex, pos, fallbackParagraph);
|
|
190
|
+
|
|
191
|
+
// Validate hyperlink affinity
|
|
192
|
+
const isLeftInHyperlink = boundary.leftSpan && isWordElement(boundary.leftSpan.runElement?.parentNode, 'hyperlink');
|
|
193
|
+
const isRightInHyperlink = boundary.rightSpan && isWordElement(boundary.rightSpan.runElement?.parentNode, 'hyperlink');
|
|
194
|
+
const isContainingInHyperlink = boundary.containingSpan && isWordElement(boundary.containingSpan.runElement?.parentNode, 'hyperlink');
|
|
195
|
+
|
|
196
|
+
if (affinity.hyperlink === 'outside') {
|
|
197
|
+
if (boundary.isInterior && isContainingInHyperlink) {
|
|
198
|
+
return {
|
|
199
|
+
error: {
|
|
200
|
+
code: 'UNSUPPORTED_INSERTION_AFFINITY',
|
|
201
|
+
message: 'Cannot place insertion outside hyperlink from strictly interior position.'
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
} else if (affinity.hyperlink === 'inside') {
|
|
206
|
+
if (!isLeftInHyperlink && !isRightInHyperlink && !isContainingInHyperlink) {
|
|
207
|
+
return {
|
|
208
|
+
error: {
|
|
209
|
+
code: 'UNSUPPORTED_INSERTION_AFFINITY',
|
|
210
|
+
message: 'Cannot place insertion inside hyperlink when no hyperlink is present at boundary.'
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
138
214
|
}
|
|
139
215
|
|
|
140
|
-
|
|
141
|
-
|
|
216
|
+
// Determine formatting (baseRPr)
|
|
217
|
+
let baseRPr = null;
|
|
218
|
+
if (affinity.formatting === 'none') {
|
|
219
|
+
baseRPr = null;
|
|
220
|
+
} else if (affinity.formatting === 'right') {
|
|
221
|
+
baseRPr = boundary.rightSpan?.rPr || null;
|
|
222
|
+
} else if (affinity.formatting === 'left') {
|
|
223
|
+
baseRPr = boundary.leftSpan?.rPr || null;
|
|
224
|
+
} else {
|
|
225
|
+
baseRPr = (boundary.containingSpan || boundary.leftSpan || boundary.rightSpan)?.rPr || null;
|
|
142
226
|
}
|
|
143
227
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
228
|
+
// Check interior of a run
|
|
229
|
+
if (boundary.isInterior) {
|
|
230
|
+
const targetSpan = boundary.containingSpan;
|
|
231
|
+
const parent = targetSpan.runElement.parentNode || fallbackParagraph;
|
|
232
|
+
if (!parent) return false;
|
|
233
|
+
|
|
234
|
+
const pieces = getRunContentPieces(targetSpan.runElement);
|
|
235
|
+
const targetPiece = pieces.find(piece => piece.node === targetSpan.textElement);
|
|
236
|
+
const localInsertPos = targetPiece
|
|
237
|
+
? targetPiece.start + Math.max(0, Math.min(pos - targetSpan.charStart, targetSpan.charEnd - targetSpan.charStart))
|
|
238
|
+
: (pos <= targetSpan.charStart ? 0 : getRunTextLength(pieces));
|
|
239
|
+
|
|
240
|
+
if (localInsertPos > 0 && localInsertPos < getRunTextLength(pieces)) {
|
|
241
|
+
const beforePieces = sliceRunPieces(xmlDoc, pieces, 0, localInsertPos, false);
|
|
242
|
+
const afterPieces = sliceRunPieces(xmlDoc, pieces, localInsertPos, getRunTextLength(pieces), false);
|
|
243
|
+
|
|
244
|
+
insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, beforePieces, targetSpan.rPr);
|
|
245
|
+
insertTextRuns(xmlDoc, parent, targetSpan.runElement, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
|
|
246
|
+
insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, afterPieces, targetSpan.rPr);
|
|
247
|
+
parent.removeChild(targetSpan.runElement);
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Boundary between runs or at start/end of paragraph
|
|
253
|
+
let parent = null;
|
|
254
|
+
let referenceNode = null;
|
|
255
|
+
|
|
256
|
+
if (affinity.hyperlink === 'outside') {
|
|
257
|
+
if (isRightInHyperlink) {
|
|
258
|
+
const hyperlinkNode = boundary.rightSpan.runElement.parentNode;
|
|
259
|
+
parent = hyperlinkNode.parentNode || fallbackParagraph;
|
|
260
|
+
referenceNode = hyperlinkNode;
|
|
261
|
+
} else if (isLeftInHyperlink) {
|
|
262
|
+
const hyperlinkNode = boundary.leftSpan.runElement.parentNode;
|
|
263
|
+
parent = hyperlinkNode.parentNode || fallbackParagraph;
|
|
264
|
+
referenceNode = hyperlinkNode.nextSibling;
|
|
265
|
+
}
|
|
266
|
+
} else if (affinity.hyperlink === 'inside') {
|
|
267
|
+
if (isRightInHyperlink) {
|
|
268
|
+
parent = boundary.rightSpan.runElement.parentNode;
|
|
269
|
+
referenceNode = boundary.rightSpan.runElement;
|
|
270
|
+
} else if (isLeftInHyperlink) {
|
|
271
|
+
parent = boundary.leftSpan.runElement.parentNode;
|
|
272
|
+
referenceNode = boundary.leftSpan.runElement.nextSibling;
|
|
273
|
+
}
|
|
148
274
|
}
|
|
149
275
|
|
|
150
|
-
const parent = targetSpan.runElement.parentNode;
|
|
151
276
|
if (!parent) {
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
277
|
+
if (boundary.rightSpan) {
|
|
278
|
+
parent = boundary.rightSpan.runElement.parentNode || fallbackParagraph;
|
|
279
|
+
referenceNode = boundary.rightSpan.runElement;
|
|
280
|
+
} else if (boundary.leftSpan) {
|
|
281
|
+
parent = boundary.leftSpan.runElement.parentNode || fallbackParagraph;
|
|
282
|
+
referenceNode = boundary.leftSpan.runElement.nextSibling;
|
|
283
|
+
} else {
|
|
284
|
+
parent = fallbackParagraph;
|
|
285
|
+
referenceNode = null;
|
|
286
|
+
}
|
|
155
287
|
}
|
|
156
288
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
289
|
+
// Check bookmark range affinity
|
|
290
|
+
if (affinity.bookmark && parent) {
|
|
291
|
+
if (affinity.bookmark === 'outside') {
|
|
292
|
+
if (referenceNode && isWordElement(referenceNode.previousSibling, 'bookmarkStart')) {
|
|
293
|
+
referenceNode = referenceNode.previousSibling;
|
|
294
|
+
}
|
|
295
|
+
if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'bookmarkEnd')) {
|
|
296
|
+
referenceNode = boundary.leftSpan.runElement.nextSibling.nextSibling;
|
|
297
|
+
}
|
|
298
|
+
} else if (affinity.bookmark === 'inside') {
|
|
299
|
+
if (referenceNode && isWordElement(referenceNode, 'bookmarkStart')) {
|
|
300
|
+
referenceNode = referenceNode.nextSibling;
|
|
301
|
+
}
|
|
302
|
+
if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'bookmarkEnd')) {
|
|
303
|
+
referenceNode = boundary.leftSpan.runElement.nextSibling;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Check comment range affinity
|
|
309
|
+
if (affinity.comment && parent) {
|
|
310
|
+
if (affinity.comment === 'outside') {
|
|
311
|
+
if (referenceNode && isWordElement(referenceNode.previousSibling, 'commentRangeStart')) {
|
|
312
|
+
referenceNode = referenceNode.previousSibling;
|
|
313
|
+
}
|
|
314
|
+
if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'commentRangeEnd')) {
|
|
315
|
+
let afterComment = boundary.leftSpan.runElement.nextSibling.nextSibling;
|
|
316
|
+
if (afterComment && (isWordElement(afterComment, 'commentReference') || isWordElement(afterComment, 'r'))) {
|
|
317
|
+
const hasCRef = Array.from(afterComment.childNodes || []).some(n => isWordElement(n, 'commentReference'));
|
|
318
|
+
if (hasCRef) afterComment = afterComment.nextSibling;
|
|
319
|
+
}
|
|
320
|
+
referenceNode = afterComment;
|
|
321
|
+
}
|
|
322
|
+
} else if (affinity.comment === 'inside') {
|
|
323
|
+
if (referenceNode && isWordElement(referenceNode, 'commentRangeStart')) {
|
|
324
|
+
referenceNode = referenceNode.nextSibling;
|
|
325
|
+
}
|
|
326
|
+
if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'commentRangeEnd')) {
|
|
327
|
+
referenceNode = boundary.leftSpan.runElement.nextSibling;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
162
331
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
332
|
+
// Check revision affinity (coalesce_same_author)
|
|
333
|
+
if (generateRedlines && affinity.revision === 'coalesce_same_author') {
|
|
334
|
+
let insElem = null;
|
|
335
|
+
let insRef = null;
|
|
336
|
+
|
|
337
|
+
if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.parentNode, 'ins')) {
|
|
338
|
+
const candidate = boundary.leftSpan.runElement.parentNode;
|
|
339
|
+
const candAuthor = candidate.getAttribute('w:author') || candidate.getAttributeNS(NS_W, 'author');
|
|
340
|
+
if (candAuthor === author) {
|
|
341
|
+
insElem = candidate;
|
|
342
|
+
insRef = boundary.leftSpan.runElement.nextSibling;
|
|
343
|
+
}
|
|
344
|
+
} else if (boundary.rightSpan && isWordElement(boundary.rightSpan.runElement.parentNode, 'ins')) {
|
|
345
|
+
const candidate = boundary.rightSpan.runElement.parentNode;
|
|
346
|
+
const candAuthor = candidate.getAttribute('w:author') || candidate.getAttributeNS(NS_W, 'author');
|
|
347
|
+
if (candAuthor === author) {
|
|
348
|
+
insElem = candidate;
|
|
349
|
+
insRef = boundary.rightSpan.runElement;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
166
352
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
353
|
+
if (insElem) {
|
|
354
|
+
const insRun = createTextRun(xmlDoc, text, baseRPr, false);
|
|
355
|
+
insElem.insertBefore(insRun, insRef);
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
172
358
|
}
|
|
173
359
|
|
|
174
|
-
|
|
175
|
-
insertTextRuns(xmlDoc, parent, referenceNode, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines);
|
|
360
|
+
insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
|
|
176
361
|
return true;
|
|
177
362
|
}
|
|
178
363
|
|
|
179
|
-
function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines) {
|
|
364
|
+
function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata = null) {
|
|
180
365
|
const applicableHints = getApplicableFormatHints(formatHints, insertOffset, insertOffset + text.length);
|
|
181
366
|
|
|
182
367
|
if (applicableHints.length === 0) {
|
|
183
368
|
const insRun = createTextRun(xmlDoc, text, baseRPr, false);
|
|
184
369
|
if (generateRedlines) {
|
|
185
|
-
const insWrapper = createTrackChange(xmlDoc, 'ins', insRun, author);
|
|
370
|
+
const insWrapper = createTrackChange(xmlDoc, 'ins', insRun, author, revisionMetadata);
|
|
186
371
|
parent.insertBefore(insWrapper, referenceNode);
|
|
187
372
|
} else {
|
|
188
373
|
parent.insertBefore(insRun, referenceNode);
|
|
@@ -193,7 +378,7 @@ function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, fo
|
|
|
193
378
|
const runs = createFormattedRuns(xmlDoc, text, baseRPr, applicableHints, insertOffset, author, generateRedlines);
|
|
194
379
|
|
|
195
380
|
if (generateRedlines) {
|
|
196
|
-
const insWrapper = createTrackChange(xmlDoc, 'ins', null, author);
|
|
381
|
+
const insWrapper = createTrackChange(xmlDoc, 'ins', null, author, revisionMetadata);
|
|
197
382
|
runs.forEach(run => insWrapper.appendChild(run));
|
|
198
383
|
parent.insertBefore(insWrapper, referenceNode);
|
|
199
384
|
} else {
|
package/engine/surgical-mode.js
CHANGED
|
@@ -15,6 +15,75 @@ import {
|
|
|
15
15
|
reconcileFormattingForTextSpan
|
|
16
16
|
} from './surgical-diff-application.js';
|
|
17
17
|
import { withOoxmlSourceType } from '../core/word-xml.js';
|
|
18
|
+
import { createReplacementRevisionEvent } from '../core/types.js';
|
|
19
|
+
|
|
20
|
+
function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos) {
|
|
21
|
+
const spans = [];
|
|
22
|
+
forEachOverlappingSpan(spanIndex, startPos, endPos, span => spans.push(span));
|
|
23
|
+
if (spans.length === 0) return { safe: false };
|
|
24
|
+
|
|
25
|
+
const firstRun = spans[0].runElement;
|
|
26
|
+
const parent = firstRun?.parentNode;
|
|
27
|
+
if (!parent) return { safe: false };
|
|
28
|
+
|
|
29
|
+
// All overlapping runs must share the exact same parent element
|
|
30
|
+
const sameParent = spans.every(s => s.runElement?.parentNode === parent);
|
|
31
|
+
if (!sameParent) return { safe: false, structuralBoundary: true };
|
|
32
|
+
|
|
33
|
+
// Parent container itself cannot be an existing revision or unsupported container
|
|
34
|
+
const parentLocal = (parent.localName || parent.nodeName.replace(/^.*:/, ''));
|
|
35
|
+
if (['hyperlink', 'sdt', 'ins', 'del', 'moveFrom', 'moveTo'].includes(parentLocal)) {
|
|
36
|
+
return { safe: false, structuralBoundary: true };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const structuralTags = new Set([
|
|
40
|
+
'hyperlink', 'fldSimple', 'sdt',
|
|
41
|
+
'commentRangeStart', 'commentRangeEnd', 'commentReference',
|
|
42
|
+
'bookmarkStart', 'bookmarkEnd',
|
|
43
|
+
'moveFrom', 'moveTo', 'ins', 'del'
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
for (const span of spans) {
|
|
47
|
+
const run = span.runElement;
|
|
48
|
+
for (const child of Array.from(run.childNodes || [])) {
|
|
49
|
+
if (child.nodeType === 1) {
|
|
50
|
+
const tag = child.localName || child.nodeName.replace(/^.*:/, '');
|
|
51
|
+
if (structuralTags.has(tag) || tag === 'fldChar') {
|
|
52
|
+
return { safe: false, structuralBoundary: true };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const lastRun = spans[spans.length - 1].runElement;
|
|
59
|
+
let curr = firstRun;
|
|
60
|
+
while (curr && curr !== lastRun) {
|
|
61
|
+
if (curr !== firstRun) {
|
|
62
|
+
const tag = curr.localName || curr.nodeName.replace(/^.*:/, '');
|
|
63
|
+
if (structuralTags.has(tag)) {
|
|
64
|
+
return { safe: false, structuralBoundary: true };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
curr = curr.nextSibling;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function hasStructuralDescendant(node) {
|
|
71
|
+
if (!node || node.nodeType !== 1) return false;
|
|
72
|
+
const tag = node.localName || node.nodeName.replace(/^.*:/, '');
|
|
73
|
+
if (structuralTags.has(tag) || tag === 'fldChar') return true;
|
|
74
|
+
for (const child of Array.from(node.childNodes || [])) {
|
|
75
|
+
if (child.nodeType === 1 && hasStructuralDescendant(child)) return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Inspect immediate adjacent siblings of the deleted range
|
|
81
|
+
if (hasStructuralDescendant(firstRun.previousSibling) || hasStructuralDescendant(lastRun.nextSibling)) {
|
|
82
|
+
return { safe: false, structuralBoundary: true };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { safe: true };
|
|
86
|
+
}
|
|
18
87
|
|
|
19
88
|
/**
|
|
20
89
|
* Applies surgical mode reconciliation.
|
|
@@ -28,9 +97,10 @@ import { withOoxmlSourceType } from '../core/word-xml.js';
|
|
|
28
97
|
* @param {boolean} [generateRedlines=true] - Track change toggle
|
|
29
98
|
* @param {Element|null} [targetParagraph=null] - Optional scope paragraph
|
|
30
99
|
* @param {{ diffTimeoutSeconds?: number }} [diffOptions={}] - Diff configuration
|
|
31
|
-
* @
|
|
100
|
+
* @param {Object} [options={}] - Additional reconciliation options (e.g. pairReplacements)
|
|
101
|
+
* @returns {{ oxml: string, hasChanges: boolean, warnings?: string[], sourceType?: 'package'|'document'|'fragment' }}
|
|
32
102
|
*/
|
|
33
|
-
export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, targetParagraph = null, diffOptions = {}) {
|
|
103
|
+
export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, targetParagraph = null, diffOptions = {}, options = {}) {
|
|
34
104
|
void originalText;
|
|
35
105
|
|
|
36
106
|
const allParagraphs = targetParagraph
|
|
@@ -40,12 +110,15 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
40
110
|
const { fullText, textSpans } = buildSurgicalTextSpans(allParagraphs);
|
|
41
111
|
const diffs = computeWordDiffs(fullText, modifiedText, diffOptions);
|
|
42
112
|
const spanIndex = buildSpanIndex(textSpans);
|
|
113
|
+
const pairReplacements = options.pairReplacements === true;
|
|
114
|
+
const warnings = [];
|
|
43
115
|
|
|
44
116
|
let originalPos = 0;
|
|
45
117
|
let newPos = 0;
|
|
46
118
|
let hasChanges = false;
|
|
47
119
|
|
|
48
|
-
for (
|
|
120
|
+
for (let i = 0; i < diffs.length; i++) {
|
|
121
|
+
const [op, text] = diffs[i];
|
|
49
122
|
if (op === 0) {
|
|
50
123
|
const len = text.length;
|
|
51
124
|
const startPos = originalPos;
|
|
@@ -67,14 +140,65 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
67
140
|
originalPos += len;
|
|
68
141
|
newPos += len;
|
|
69
142
|
} else if (op === -1) {
|
|
70
|
-
|
|
143
|
+
const hasNextInsert = (i + 1 < diffs.length) && (diffs[i + 1][0] === 1);
|
|
144
|
+
let paired = false;
|
|
145
|
+
let delMetadata = null;
|
|
146
|
+
let insMetadata = null;
|
|
147
|
+
|
|
148
|
+
if (pairReplacements && generateRedlines && hasNextInsert) {
|
|
149
|
+
const nextText = diffs[i + 1][1];
|
|
150
|
+
const textWithoutNewlines = nextText.replace(/\n/g, ' ');
|
|
151
|
+
if (textWithoutNewlines.trim().length > 0) {
|
|
152
|
+
const checkResult = checkSafeAdjacencyForPairing(spanIndex, originalPos, originalPos + text.length);
|
|
153
|
+
if (checkResult.safe) {
|
|
154
|
+
const event = createReplacementRevisionEvent(author, xmlDoc);
|
|
155
|
+
delMetadata = { id: event.deletionId, author: event.author, date: event.date };
|
|
156
|
+
insMetadata = { id: event.insertionId, author: event.author, date: event.date };
|
|
157
|
+
paired = true;
|
|
158
|
+
} else if (checkResult.structuralBoundary) {
|
|
159
|
+
warnings.push('PAIRING_SKIPPED_STRUCTURAL_BOUNDARY');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (processDelete(xmlDoc, spanIndex, originalPos, originalPos + text.length, author, generateRedlines, delMetadata)) {
|
|
71
165
|
hasChanges = true;
|
|
72
166
|
}
|
|
73
167
|
originalPos += text.length;
|
|
168
|
+
|
|
169
|
+
if (paired) {
|
|
170
|
+
i++;
|
|
171
|
+
const [, nextText] = diffs[i];
|
|
172
|
+
const textWithoutNewlines = nextText.replace(/\n/g, ' ');
|
|
173
|
+
if (textWithoutNewlines.trim().length > 0) {
|
|
174
|
+
const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null);
|
|
175
|
+
if (insertResult && typeof insertResult === 'object' && insertResult.error) {
|
|
176
|
+
return withOoxmlSourceType({
|
|
177
|
+
oxml: serializer.serializeToString(xmlDoc),
|
|
178
|
+
hasChanges: false,
|
|
179
|
+
status: 'error',
|
|
180
|
+
error: insertResult.error
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (insertResult === true) {
|
|
184
|
+
hasChanges = true;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
newPos += nextText.length;
|
|
188
|
+
}
|
|
74
189
|
} else if (op === 1) {
|
|
75
190
|
const textWithoutNewlines = text.replace(/\n/g, ' ');
|
|
76
191
|
if (textWithoutNewlines.trim().length > 0) {
|
|
77
|
-
|
|
192
|
+
const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null);
|
|
193
|
+
if (insertResult && typeof insertResult === 'object' && insertResult.error) {
|
|
194
|
+
return withOoxmlSourceType({
|
|
195
|
+
oxml: serializer.serializeToString(xmlDoc),
|
|
196
|
+
hasChanges: false,
|
|
197
|
+
status: 'error',
|
|
198
|
+
error: insertResult.error
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (insertResult === true) {
|
|
78
202
|
hasChanges = true;
|
|
79
203
|
}
|
|
80
204
|
}
|
|
@@ -82,5 +206,9 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
82
206
|
}
|
|
83
207
|
}
|
|
84
208
|
|
|
85
|
-
return withOoxmlSourceType({
|
|
209
|
+
return withOoxmlSourceType({
|
|
210
|
+
oxml: serializer.serializeToString(xmlDoc),
|
|
211
|
+
hasChanges,
|
|
212
|
+
...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
|
|
213
|
+
});
|
|
86
214
|
}
|
package/engine/surgical-spans.js
CHANGED
|
@@ -7,6 +7,7 @@ export function getRunChildText(child) {
|
|
|
7
7
|
if (isWordElement(child, 'br') || isWordElement(child, 'cr')) return '\n';
|
|
8
8
|
if (isWordElement(child, 'tab')) return '\t';
|
|
9
9
|
if (isWordElement(child, 'noBreakHyphen')) return '\u2011';
|
|
10
|
+
if (isWordElement(child, 'softHyphen')) return '\u00ad';
|
|
10
11
|
return '';
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -15,7 +16,8 @@ export function isTextLikeRunChild(child) {
|
|
|
15
16
|
|| isWordElement(child, 'br')
|
|
16
17
|
|| isWordElement(child, 'cr')
|
|
17
18
|
|| isWordElement(child, 'tab')
|
|
18
|
-
|| isWordElement(child, 'noBreakHyphen')
|
|
19
|
+
|| isWordElement(child, 'noBreakHyphen')
|
|
20
|
+
|| isWordElement(child, 'softHyphen');
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export function buildSurgicalTextSpans(paragraphs) {
|
|
@@ -34,6 +36,27 @@ export function buildSurgicalTextSpans(paragraphs) {
|
|
|
34
36
|
fullText += processRunElement(hc, paragraph, container, fullText.length, textSpans).text;
|
|
35
37
|
}
|
|
36
38
|
}
|
|
39
|
+
} else if (isWordElement(child, 'ins')) {
|
|
40
|
+
for (let ic = child.firstChild; ic; ic = ic.nextSibling) {
|
|
41
|
+
if (isWordElement(ic, 'r')) {
|
|
42
|
+
fullText += processRunElement(ic, paragraph, container, fullText.length, textSpans).text;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} else if (isWordElement(child, 'sdt')) {
|
|
46
|
+
const sdtContent = Array.from(child.childNodes || []).find(n => isWordElement(n, 'sdtContent'));
|
|
47
|
+
if (sdtContent) {
|
|
48
|
+
for (let sc = sdtContent.firstChild; sc; sc = sc.nextSibling) {
|
|
49
|
+
if (isWordElement(sc, 'r')) {
|
|
50
|
+
fullText += processRunElement(sc, paragraph, container, fullText.length, textSpans).text;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} else if (isWordElement(child, 'smartTag')) {
|
|
55
|
+
for (let st = child.firstChild; st; st = st.nextSibling) {
|
|
56
|
+
if (isWordElement(st, 'r')) {
|
|
57
|
+
fullText += processRunElement(st, paragraph, container, fullText.length, textSpans).text;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
37
60
|
}
|
|
38
61
|
}
|
|
39
62
|
|
|
@@ -167,3 +190,31 @@ function lowerBound(values, target) {
|
|
|
167
190
|
|
|
168
191
|
return left;
|
|
169
192
|
}
|
|
193
|
+
|
|
194
|
+
export function describeInsertionBoundary(spanIndex, pos, fallbackParagraph = null) {
|
|
195
|
+
if (!spanIndex || !spanIndex.spans || spanIndex.spans.length === 0) {
|
|
196
|
+
return {
|
|
197
|
+
leftSpan: null,
|
|
198
|
+
rightSpan: null,
|
|
199
|
+
containingSpan: null,
|
|
200
|
+
isInterior: false,
|
|
201
|
+
fallbackParagraph
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const containingSpan = findContainingSpan(spanIndex, pos);
|
|
206
|
+
const isInterior = containingSpan !== null && pos > containingSpan.charStart && pos < containingSpan.charEnd;
|
|
207
|
+
|
|
208
|
+
// leftSpan ends at or before pos
|
|
209
|
+
const leftSpan = findFirstSpanEndingAt(spanIndex, pos) || (pos > 0 ? findLastSpanEndingBeforeOrAt(spanIndex, pos) : null);
|
|
210
|
+
// rightSpan starts at or after pos
|
|
211
|
+
const rightSpan = spanIndex.spans.find(s => s.charStart === pos) || (pos === 0 ? spanIndex.spans[0] : null);
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
leftSpan,
|
|
215
|
+
rightSpan,
|
|
216
|
+
containingSpan,
|
|
217
|
+
isInterior,
|
|
218
|
+
fallbackParagraph
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -11,6 +11,7 @@ import { buildParagraphOnlyPackage } from '../services/package-builder.js';
|
|
|
11
11
|
import { getElementsByTagNSOrTag } from '../core/xml-query.js';
|
|
12
12
|
import { NS_W } from '../core/types.js';
|
|
13
13
|
import { isWordElement } from '../core/word-xml.js';
|
|
14
|
+
import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
|
|
14
15
|
|
|
15
16
|
const W14_NS = 'http://schemas.microsoft.com/office/word/2010/wordml';
|
|
16
17
|
|
|
@@ -69,12 +70,8 @@ export function detectTableCellContext(xmlDoc, originalText, options = {}) {
|
|
|
69
70
|
if (originalText && originalText.trim()) {
|
|
70
71
|
const normalizedTarget = originalText.trim();
|
|
71
72
|
if (!targetParagraph) {
|
|
72
|
-
for (const p of paragraphsInCells) {
|
|
73
|
-
const
|
|
74
|
-
let paragraphText = '';
|
|
75
|
-
for (const t of textNodes) {
|
|
76
|
-
paragraphText += t.textContent || '';
|
|
77
|
-
}
|
|
73
|
+
for (const p of paragraphsInCells) {
|
|
74
|
+
const paragraphText = extractCanonicalParagraphText(p);
|
|
78
75
|
|
|
79
76
|
if (paragraphText.trim() === normalizedTarget) {
|
|
80
77
|
targetParagraph = p;
|
package/engine/table-mode.js
CHANGED
|
@@ -165,7 +165,7 @@ export function applyTextToTableTransformation(xmlDoc, modifiedText, serializer,
|
|
|
165
165
|
});
|
|
166
166
|
|
|
167
167
|
const del = createWordElement(workingDoc, 'w:del');
|
|
168
|
-
const metadata = createRevisionMetadata(author, workingDoc);
|
|
168
|
+
const metadata = createRevisionMetadata(author, workingDoc, 'del');
|
|
169
169
|
del.setAttribute('w:id', String(metadata.id));
|
|
170
170
|
del.setAttribute('w:author', metadata.author);
|
|
171
171
|
del.setAttribute('w:date', metadata.date);
|