@ansonlai/docx-redline-js 0.4.0 → 0.5.1
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 +646 -288
- 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/redline-validation.js +11 -5
- 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 +3145 -505
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +88 -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/2026-09-08-cross-author-revision-slicing.md +505 -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 +508 -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 +407 -50
- package/engine/surgical-mode.js +142 -6
- package/engine/surgical-run-splitting.js +103 -0
- 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 +322 -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 +123 -102
- 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-cross-author-slicing-fixtures.ps1 +256 -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 +513 -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 +345 -0
- package/services/document-operation-mutations.js +1749 -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 +390 -0
- package/services/receipt-collector.js +288 -0
- package/services/revision-comment-management.js +77 -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,20 +6,25 @@ 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,
|
|
12
13
|
getRunTextLength,
|
|
13
14
|
insertRunPiecesBefore,
|
|
14
|
-
sliceRunPieces
|
|
15
|
+
sliceRunPieces,
|
|
16
|
+
splitTrackChangeCarrier
|
|
15
17
|
} from './surgical-run-splitting.js';
|
|
16
18
|
import {
|
|
17
19
|
findContainingSpan,
|
|
18
20
|
findFirstSpanEndingAt,
|
|
19
21
|
findLastSpanEndingBeforeOrAt,
|
|
20
|
-
forEachOverlappingSpan
|
|
22
|
+
forEachOverlappingSpan,
|
|
23
|
+
describeInsertionBoundary
|
|
21
24
|
} from './surgical-spans.js';
|
|
22
25
|
import { extractFormatFromRPr } from './rpr-helpers.js';
|
|
26
|
+
import { isWordElement } from '../core/word-xml.js';
|
|
27
|
+
import { NS_W } from '../core/types.js';
|
|
23
28
|
|
|
24
29
|
export function reconcileFormattingForTextSpan(xmlDoc, span, start, end, applicableHints, author, generateRedlines) {
|
|
25
30
|
// Plain modified text carries no negative formatting instruction. Preserve
|
|
@@ -67,7 +72,7 @@ export function reconcileFormattingForTextSpan(xmlDoc, span, start, end, applica
|
|
|
67
72
|
return true;
|
|
68
73
|
}
|
|
69
74
|
|
|
70
|
-
export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, generateRedlines) {
|
|
75
|
+
export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, generateRedlines, revisionMetadata = null) {
|
|
71
76
|
const spans = [];
|
|
72
77
|
forEachOverlappingSpan(spanIndex, startPos, endPos, span => {
|
|
73
78
|
spans.push(span);
|
|
@@ -82,7 +87,7 @@ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, gener
|
|
|
82
87
|
spansByRun.get(span.runElement).push(span);
|
|
83
88
|
});
|
|
84
89
|
|
|
85
|
-
|
|
90
|
+
const records = [];
|
|
86
91
|
spansByRun.forEach((runSpans, runElement) => {
|
|
87
92
|
const parent = runElement.parentNode;
|
|
88
93
|
if (!parent) return;
|
|
@@ -106,83 +111,435 @@ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, gener
|
|
|
106
111
|
|
|
107
112
|
if (!Number.isFinite(deleteStart) || deleteEnd <= deleteStart) return;
|
|
108
113
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
records.push({
|
|
115
|
+
runElement,
|
|
116
|
+
parent,
|
|
117
|
+
rPr: runSpans[0].rPr,
|
|
118
|
+
beforePieces: sliceRunPieces(xmlDoc, pieces, 0, deleteStart, false),
|
|
119
|
+
deletedPieces: sliceRunPieces(xmlDoc, pieces, deleteStart, deleteEnd, true),
|
|
120
|
+
afterPieces: sliceRunPieces(xmlDoc, pieces, deleteEnd, getRunTextLength(pieces), false),
|
|
121
|
+
globalStart: Math.max(startPos, Math.min(...runSpans.map(span => span.charStart))),
|
|
122
|
+
globalEnd: Math.min(endPos, Math.max(...runSpans.map(span => span.charEnd))),
|
|
123
|
+
carrierGlobalStart: isWordElement(parent, 'ins') ? getCarrierGlobalStart(spanIndex, parent) : null
|
|
124
|
+
});
|
|
125
|
+
});
|
|
112
126
|
|
|
113
|
-
|
|
127
|
+
const groups = [];
|
|
128
|
+
for (const record of records) {
|
|
129
|
+
const previousGroup = groups[groups.length - 1];
|
|
130
|
+
const previousRecord = previousGroup?.[previousGroup.length - 1];
|
|
131
|
+
if (
|
|
132
|
+
previousRecord
|
|
133
|
+
&& previousRecord.parent === record.parent
|
|
134
|
+
&& nextElementSibling(previousRecord.runElement) === record.runElement
|
|
135
|
+
) {
|
|
136
|
+
previousGroup.push(record);
|
|
137
|
+
} else {
|
|
138
|
+
groups.push([record]);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
114
141
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
142
|
+
let changed = false;
|
|
143
|
+
let usedDelMetadata = false;
|
|
144
|
+
for (const group of groups) {
|
|
145
|
+
const firstRecord = group[0];
|
|
146
|
+
let delWrapper = null;
|
|
147
|
+
if (generateRedlines && group.some(record => record.deletedPieces.length > 0)) {
|
|
148
|
+
const metadata = revisionMetadata
|
|
149
|
+
? (usedDelMetadata ? { ...revisionMetadata, id: createRevisionMetadata(author, xmlDoc, 'del').id } : revisionMetadata)
|
|
150
|
+
: null;
|
|
151
|
+
usedDelMetadata = true;
|
|
152
|
+
delWrapper = createTrackChange(xmlDoc, 'del', null, author, metadata);
|
|
119
153
|
}
|
|
120
154
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
155
|
+
for (const record of group) {
|
|
156
|
+
const { parent, runElement } = record;
|
|
157
|
+
insertRunPiecesBefore(xmlDoc, parent, runElement, record.beforePieces, record.rPr);
|
|
158
|
+
if (delWrapper && record === firstRecord) {
|
|
159
|
+
parent.insertBefore(delWrapper, runElement);
|
|
160
|
+
}
|
|
161
|
+
if (delWrapper && record.deletedPieces.length > 0) {
|
|
162
|
+
delWrapper.appendChild(createRunFromPieces(xmlDoc, record.deletedPieces, record.rPr));
|
|
163
|
+
}
|
|
164
|
+
insertRunPiecesBefore(xmlDoc, parent, runElement, record.afterPieces, record.rPr);
|
|
165
|
+
parent.removeChild(runElement);
|
|
166
|
+
changed = true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const carrier = isWordElement(firstRecord.parent, 'ins') ? firstRecord.parent : null;
|
|
170
|
+
const groupEnd = Math.max(...group.map(record => record.globalEnd));
|
|
171
|
+
if (carrier && groupEnd === endPos) {
|
|
172
|
+
const carrierStart = firstRecord.carrierGlobalStart;
|
|
173
|
+
const deletedBeforeEnd = group
|
|
174
|
+
.filter(record => record.globalStart < endPos)
|
|
175
|
+
.reduce((sum, record) => sum + record.deletedPieces.reduce((n, piece) => n + (piece.textContent || '').length, 0), 0);
|
|
176
|
+
const currentOffset = Math.max(0, endPos - carrierStart - deletedBeforeEnd);
|
|
177
|
+
if (!spanIndex.revisionInsertionAnchors) spanIndex.revisionInsertionAnchors = new Map();
|
|
178
|
+
spanIndex.revisionInsertionAnchors.set(endPos, {
|
|
179
|
+
carrier,
|
|
180
|
+
splitOffset: currentOffset,
|
|
181
|
+
rPr: firstRecord.rPr
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
125
185
|
|
|
126
186
|
return changed;
|
|
127
187
|
}
|
|
128
188
|
|
|
129
|
-
export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], insertOffset = 0, generateRedlines = true, fallbackParagraph = null) {
|
|
130
|
-
|
|
189
|
+
export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], insertOffset = 0, generateRedlines = true, fallbackParagraph = null, revisionMetadata = null, affinity = null, existingRevisions = 'merge-same-author') {
|
|
190
|
+
const mutationAnchor = spanIndex.revisionInsertionAnchors?.get(pos) || null;
|
|
191
|
+
if (
|
|
192
|
+
mutationAnchor
|
|
193
|
+
&& existingRevisions === 'slice-cross-author'
|
|
194
|
+
&& isConnected(mutationAnchor.carrier)
|
|
195
|
+
&& isForeignInsertion(mutationAnchor.carrier, author)
|
|
196
|
+
) {
|
|
197
|
+
spanIndex.revisionInsertionAnchors.delete(pos);
|
|
198
|
+
return spliceInsertionAtCarrierOffset(
|
|
199
|
+
xmlDoc,
|
|
200
|
+
mutationAnchor.carrier,
|
|
201
|
+
mutationAnchor.splitOffset,
|
|
202
|
+
text,
|
|
203
|
+
mutationAnchor.rPr,
|
|
204
|
+
author,
|
|
205
|
+
formatHints,
|
|
206
|
+
insertOffset,
|
|
207
|
+
generateRedlines,
|
|
208
|
+
revisionMetadata
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!affinity) {
|
|
213
|
+
let targetSpan = findContainingSpan(spanIndex, pos);
|
|
214
|
+
|
|
215
|
+
if (!targetSpan && pos > 0) {
|
|
216
|
+
targetSpan = findFirstSpanEndingAt(spanIndex, pos);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!targetSpan && pos > 0) {
|
|
220
|
+
targetSpan = findLastSpanEndingBeforeOrAt(spanIndex, pos);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!targetSpan && spanIndex.spans.length > 0) {
|
|
224
|
+
targetSpan = spanIndex.spans[spanIndex.spans.length - 1];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!targetSpan) {
|
|
228
|
+
if (!fallbackParagraph) return false;
|
|
229
|
+
insertTextRuns(xmlDoc, fallbackParagraph, null, text, null, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const parent = targetSpan.runElement.parentNode;
|
|
234
|
+
if (!parent) {
|
|
235
|
+
if (!fallbackParagraph) return false;
|
|
236
|
+
insertTextRuns(xmlDoc, fallbackParagraph, null, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (
|
|
241
|
+
generateRedlines
|
|
242
|
+
&& existingRevisions === 'slice-cross-author'
|
|
243
|
+
&& isForeignInsertion(parent, author)
|
|
244
|
+
) {
|
|
245
|
+
return spliceInsertionAtCarrierOffset(
|
|
246
|
+
xmlDoc,
|
|
247
|
+
parent,
|
|
248
|
+
getCarrierSplitOffset(spanIndex, parent, pos),
|
|
249
|
+
text,
|
|
250
|
+
targetSpan.rPr,
|
|
251
|
+
author,
|
|
252
|
+
formatHints,
|
|
253
|
+
insertOffset,
|
|
254
|
+
generateRedlines,
|
|
255
|
+
revisionMetadata
|
|
256
|
+
);
|
|
257
|
+
}
|
|
131
258
|
|
|
132
|
-
|
|
133
|
-
|
|
259
|
+
const pieces = getRunContentPieces(targetSpan.runElement);
|
|
260
|
+
const targetPiece = pieces.find(piece => piece.node === targetSpan.textElement);
|
|
261
|
+
const localInsertPos = targetPiece
|
|
262
|
+
? targetPiece.start + Math.max(0, Math.min(pos - targetSpan.charStart, targetSpan.charEnd - targetSpan.charStart))
|
|
263
|
+
: (pos <= targetSpan.charStart ? 0 : getRunTextLength(pieces));
|
|
264
|
+
|
|
265
|
+
if (localInsertPos > 0 && localInsertPos < getRunTextLength(pieces)) {
|
|
266
|
+
const beforePieces = sliceRunPieces(xmlDoc, pieces, 0, localInsertPos, false);
|
|
267
|
+
const afterPieces = sliceRunPieces(xmlDoc, pieces, localInsertPos, getRunTextLength(pieces), false);
|
|
268
|
+
|
|
269
|
+
insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, beforePieces, targetSpan.rPr);
|
|
270
|
+
insertTextRuns(xmlDoc, parent, targetSpan.runElement, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
|
|
271
|
+
insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, afterPieces, targetSpan.rPr);
|
|
272
|
+
parent.removeChild(targetSpan.runElement);
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const referenceNode = pos <= targetSpan.charStart ? targetSpan.runElement : targetSpan.runElement.nextSibling;
|
|
277
|
+
insertTextRuns(xmlDoc, parent, referenceNode, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
|
|
278
|
+
return true;
|
|
134
279
|
}
|
|
135
280
|
|
|
136
|
-
|
|
137
|
-
|
|
281
|
+
const boundary = describeInsertionBoundary(spanIndex, pos, fallbackParagraph);
|
|
282
|
+
|
|
283
|
+
// Validate hyperlink affinity
|
|
284
|
+
const isLeftInHyperlink = boundary.leftSpan && isWordElement(boundary.leftSpan.runElement?.parentNode, 'hyperlink');
|
|
285
|
+
const isRightInHyperlink = boundary.rightSpan && isWordElement(boundary.rightSpan.runElement?.parentNode, 'hyperlink');
|
|
286
|
+
const isContainingInHyperlink = boundary.containingSpan && isWordElement(boundary.containingSpan.runElement?.parentNode, 'hyperlink');
|
|
287
|
+
|
|
288
|
+
if (affinity.hyperlink === 'outside') {
|
|
289
|
+
if (boundary.isInterior && isContainingInHyperlink) {
|
|
290
|
+
return {
|
|
291
|
+
error: {
|
|
292
|
+
code: 'UNSUPPORTED_INSERTION_AFFINITY',
|
|
293
|
+
message: 'Cannot place insertion outside hyperlink from strictly interior position.'
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
} else if (affinity.hyperlink === 'inside') {
|
|
298
|
+
if (!isLeftInHyperlink && !isRightInHyperlink && !isContainingInHyperlink) {
|
|
299
|
+
return {
|
|
300
|
+
error: {
|
|
301
|
+
code: 'UNSUPPORTED_INSERTION_AFFINITY',
|
|
302
|
+
message: 'Cannot place insertion inside hyperlink when no hyperlink is present at boundary.'
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
}
|
|
138
306
|
}
|
|
139
307
|
|
|
140
|
-
|
|
141
|
-
|
|
308
|
+
// Determine formatting (baseRPr)
|
|
309
|
+
let baseRPr = null;
|
|
310
|
+
if (affinity.formatting === 'none') {
|
|
311
|
+
baseRPr = null;
|
|
312
|
+
} else if (affinity.formatting === 'right') {
|
|
313
|
+
baseRPr = boundary.rightSpan?.rPr || null;
|
|
314
|
+
} else if (affinity.formatting === 'left') {
|
|
315
|
+
baseRPr = boundary.leftSpan?.rPr || null;
|
|
316
|
+
} else {
|
|
317
|
+
baseRPr = (boundary.containingSpan || boundary.leftSpan || boundary.rightSpan)?.rPr || null;
|
|
142
318
|
}
|
|
143
319
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
320
|
+
// Check interior of a run
|
|
321
|
+
if (boundary.isInterior) {
|
|
322
|
+
const targetSpan = boundary.containingSpan;
|
|
323
|
+
const parent = targetSpan.runElement.parentNode || fallbackParagraph;
|
|
324
|
+
if (!parent) return false;
|
|
325
|
+
|
|
326
|
+
const pieces = getRunContentPieces(targetSpan.runElement);
|
|
327
|
+
const targetPiece = pieces.find(piece => piece.node === targetSpan.textElement);
|
|
328
|
+
const localInsertPos = targetPiece
|
|
329
|
+
? targetPiece.start + Math.max(0, Math.min(pos - targetSpan.charStart, targetSpan.charEnd - targetSpan.charStart))
|
|
330
|
+
: (pos <= targetSpan.charStart ? 0 : getRunTextLength(pieces));
|
|
331
|
+
|
|
332
|
+
if (localInsertPos > 0 && localInsertPos < getRunTextLength(pieces)) {
|
|
333
|
+
const beforePieces = sliceRunPieces(xmlDoc, pieces, 0, localInsertPos, false);
|
|
334
|
+
const afterPieces = sliceRunPieces(xmlDoc, pieces, localInsertPos, getRunTextLength(pieces), false);
|
|
335
|
+
|
|
336
|
+
insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, beforePieces, targetSpan.rPr);
|
|
337
|
+
insertTextRuns(xmlDoc, parent, targetSpan.runElement, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
|
|
338
|
+
insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, afterPieces, targetSpan.rPr);
|
|
339
|
+
parent.removeChild(targetSpan.runElement);
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Boundary between runs or at start/end of paragraph
|
|
345
|
+
let parent = null;
|
|
346
|
+
let referenceNode = null;
|
|
347
|
+
|
|
348
|
+
if (affinity.hyperlink === 'outside') {
|
|
349
|
+
if (isRightInHyperlink) {
|
|
350
|
+
const hyperlinkNode = boundary.rightSpan.runElement.parentNode;
|
|
351
|
+
parent = hyperlinkNode.parentNode || fallbackParagraph;
|
|
352
|
+
referenceNode = hyperlinkNode;
|
|
353
|
+
} else if (isLeftInHyperlink) {
|
|
354
|
+
const hyperlinkNode = boundary.leftSpan.runElement.parentNode;
|
|
355
|
+
parent = hyperlinkNode.parentNode || fallbackParagraph;
|
|
356
|
+
referenceNode = hyperlinkNode.nextSibling;
|
|
357
|
+
}
|
|
358
|
+
} else if (affinity.hyperlink === 'inside') {
|
|
359
|
+
if (isRightInHyperlink) {
|
|
360
|
+
parent = boundary.rightSpan.runElement.parentNode;
|
|
361
|
+
referenceNode = boundary.rightSpan.runElement;
|
|
362
|
+
} else if (isLeftInHyperlink) {
|
|
363
|
+
parent = boundary.leftSpan.runElement.parentNode;
|
|
364
|
+
referenceNode = boundary.leftSpan.runElement.nextSibling;
|
|
365
|
+
}
|
|
148
366
|
}
|
|
149
367
|
|
|
150
|
-
const parent = targetSpan.runElement.parentNode;
|
|
151
368
|
if (!parent) {
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
369
|
+
if (boundary.rightSpan) {
|
|
370
|
+
parent = boundary.rightSpan.runElement.parentNode || fallbackParagraph;
|
|
371
|
+
referenceNode = boundary.rightSpan.runElement;
|
|
372
|
+
} else if (boundary.leftSpan) {
|
|
373
|
+
parent = boundary.leftSpan.runElement.parentNode || fallbackParagraph;
|
|
374
|
+
referenceNode = boundary.leftSpan.runElement.nextSibling;
|
|
375
|
+
} else {
|
|
376
|
+
parent = fallbackParagraph;
|
|
377
|
+
referenceNode = null;
|
|
378
|
+
}
|
|
155
379
|
}
|
|
156
380
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
381
|
+
// Check bookmark range affinity
|
|
382
|
+
if (affinity.bookmark && parent) {
|
|
383
|
+
if (affinity.bookmark === 'outside') {
|
|
384
|
+
if (referenceNode && isWordElement(referenceNode.previousSibling, 'bookmarkStart')) {
|
|
385
|
+
referenceNode = referenceNode.previousSibling;
|
|
386
|
+
}
|
|
387
|
+
if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'bookmarkEnd')) {
|
|
388
|
+
referenceNode = boundary.leftSpan.runElement.nextSibling.nextSibling;
|
|
389
|
+
}
|
|
390
|
+
} else if (affinity.bookmark === 'inside') {
|
|
391
|
+
if (referenceNode && isWordElement(referenceNode, 'bookmarkStart')) {
|
|
392
|
+
referenceNode = referenceNode.nextSibling;
|
|
393
|
+
}
|
|
394
|
+
if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'bookmarkEnd')) {
|
|
395
|
+
referenceNode = boundary.leftSpan.runElement.nextSibling;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
162
399
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
400
|
+
// Check comment range affinity
|
|
401
|
+
if (affinity.comment && parent) {
|
|
402
|
+
if (affinity.comment === 'outside') {
|
|
403
|
+
if (referenceNode && isWordElement(referenceNode.previousSibling, 'commentRangeStart')) {
|
|
404
|
+
referenceNode = referenceNode.previousSibling;
|
|
405
|
+
}
|
|
406
|
+
if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'commentRangeEnd')) {
|
|
407
|
+
let afterComment = boundary.leftSpan.runElement.nextSibling.nextSibling;
|
|
408
|
+
if (afterComment && (isWordElement(afterComment, 'commentReference') || isWordElement(afterComment, 'r'))) {
|
|
409
|
+
const hasCRef = Array.from(afterComment.childNodes || []).some(n => isWordElement(n, 'commentReference'));
|
|
410
|
+
if (hasCRef) afterComment = afterComment.nextSibling;
|
|
411
|
+
}
|
|
412
|
+
referenceNode = afterComment;
|
|
413
|
+
}
|
|
414
|
+
} else if (affinity.comment === 'inside') {
|
|
415
|
+
if (referenceNode && isWordElement(referenceNode, 'commentRangeStart')) {
|
|
416
|
+
referenceNode = referenceNode.nextSibling;
|
|
417
|
+
}
|
|
418
|
+
if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.nextSibling, 'commentRangeEnd')) {
|
|
419
|
+
referenceNode = boundary.leftSpan.runElement.nextSibling;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
166
423
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
424
|
+
// Check revision affinity (coalesce_same_author)
|
|
425
|
+
if (generateRedlines && affinity.revision === 'coalesce_same_author') {
|
|
426
|
+
let insElem = null;
|
|
427
|
+
let insRef = null;
|
|
428
|
+
|
|
429
|
+
if (boundary.leftSpan && isWordElement(boundary.leftSpan.runElement.parentNode, 'ins')) {
|
|
430
|
+
const candidate = boundary.leftSpan.runElement.parentNode;
|
|
431
|
+
const candAuthor = candidate.getAttribute('w:author') || candidate.getAttributeNS(NS_W, 'author');
|
|
432
|
+
if (candAuthor === author) {
|
|
433
|
+
insElem = candidate;
|
|
434
|
+
insRef = boundary.leftSpan.runElement.nextSibling;
|
|
435
|
+
}
|
|
436
|
+
} else if (boundary.rightSpan && isWordElement(boundary.rightSpan.runElement.parentNode, 'ins')) {
|
|
437
|
+
const candidate = boundary.rightSpan.runElement.parentNode;
|
|
438
|
+
const candAuthor = candidate.getAttribute('w:author') || candidate.getAttributeNS(NS_W, 'author');
|
|
439
|
+
if (candAuthor === author) {
|
|
440
|
+
insElem = candidate;
|
|
441
|
+
insRef = boundary.rightSpan.runElement;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (insElem) {
|
|
446
|
+
const insRun = createTextRun(xmlDoc, text, baseRPr, false);
|
|
447
|
+
insElem.insertBefore(insRun, insRef);
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (
|
|
453
|
+
generateRedlines
|
|
454
|
+
&& existingRevisions === 'slice-cross-author'
|
|
455
|
+
&& isForeignInsertion(parent, author)
|
|
456
|
+
) {
|
|
457
|
+
return spliceInsertionAtCarrierOffset(
|
|
458
|
+
xmlDoc,
|
|
459
|
+
parent,
|
|
460
|
+
getCarrierSplitOffset(spanIndex, parent, pos),
|
|
461
|
+
text,
|
|
462
|
+
baseRPr,
|
|
463
|
+
author,
|
|
464
|
+
formatHints,
|
|
465
|
+
insertOffset,
|
|
466
|
+
generateRedlines,
|
|
467
|
+
revisionMetadata
|
|
468
|
+
);
|
|
172
469
|
}
|
|
173
470
|
|
|
174
|
-
|
|
175
|
-
insertTextRuns(xmlDoc, parent, referenceNode, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines);
|
|
471
|
+
insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
|
|
176
472
|
return true;
|
|
177
473
|
}
|
|
178
474
|
|
|
179
|
-
function
|
|
475
|
+
function spliceInsertionAtCarrierOffset(xmlDoc, carrier, splitOffset, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata) {
|
|
476
|
+
const parent = carrier.parentNode;
|
|
477
|
+
if (!parent) return false;
|
|
478
|
+
|
|
479
|
+
const { leftCarrier, rightCarrier } = splitTrackChangeCarrier(xmlDoc, carrier, splitOffset);
|
|
480
|
+
if (leftCarrier) parent.insertBefore(leftCarrier, carrier);
|
|
481
|
+
insertTextRuns(
|
|
482
|
+
xmlDoc,
|
|
483
|
+
parent,
|
|
484
|
+
carrier,
|
|
485
|
+
text,
|
|
486
|
+
withoutRunPropertyChanges(baseRPr),
|
|
487
|
+
author,
|
|
488
|
+
formatHints,
|
|
489
|
+
insertOffset,
|
|
490
|
+
generateRedlines,
|
|
491
|
+
revisionMetadata
|
|
492
|
+
);
|
|
493
|
+
if (rightCarrier) parent.insertBefore(rightCarrier, carrier);
|
|
494
|
+
parent.removeChild(carrier);
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function withoutRunPropertyChanges(rPr) {
|
|
499
|
+
if (!rPr) return null;
|
|
500
|
+
const clone = rPr.cloneNode(true);
|
|
501
|
+
const changes = Array.from(clone.getElementsByTagName?.('*') || [])
|
|
502
|
+
.filter(node => isWordElement(node, 'rPrChange'));
|
|
503
|
+
changes.forEach(node => node.parentNode?.removeChild(node));
|
|
504
|
+
return clone;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function getCarrierSplitOffset(spanIndex, carrier, pos) {
|
|
508
|
+
const carrierStart = getCarrierGlobalStart(spanIndex, carrier);
|
|
509
|
+
const carrierLength = spanIndex.spans
|
|
510
|
+
.filter(span => span.runElement?.parentNode === carrier)
|
|
511
|
+
.reduce((length, span) => length + (span.charEnd - span.charStart), 0);
|
|
512
|
+
return Math.max(0, Math.min(pos - carrierStart, carrierLength));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function getCarrierGlobalStart(spanIndex, carrier) {
|
|
516
|
+
const carrierSpans = spanIndex.spans.filter(span => span.runElement?.parentNode === carrier);
|
|
517
|
+
return carrierSpans.length > 0 ? Math.min(...carrierSpans.map(span => span.charStart)) : 0;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function isForeignInsertion(node, author) {
|
|
521
|
+
if (!isWordElement(node, 'ins')) return false;
|
|
522
|
+
const carrierAuthor = node.getAttribute('w:author') || node.getAttributeNS?.(NS_W, 'author') || '';
|
|
523
|
+
return carrierAuthor.trim().toLowerCase() !== String(author || '').trim().toLowerCase();
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function nextElementSibling(node) {
|
|
527
|
+
let sibling = node?.nextSibling || null;
|
|
528
|
+
while (sibling && sibling.nodeType !== 1) sibling = sibling.nextSibling;
|
|
529
|
+
return sibling;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function isConnected(node) {
|
|
533
|
+
return !!node?.parentNode;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata = null) {
|
|
180
537
|
const applicableHints = getApplicableFormatHints(formatHints, insertOffset, insertOffset + text.length);
|
|
181
538
|
|
|
182
539
|
if (applicableHints.length === 0) {
|
|
183
540
|
const insRun = createTextRun(xmlDoc, text, baseRPr, false);
|
|
184
541
|
if (generateRedlines) {
|
|
185
|
-
const insWrapper = createTrackChange(xmlDoc, 'ins', insRun, author);
|
|
542
|
+
const insWrapper = createTrackChange(xmlDoc, 'ins', insRun, author, revisionMetadata);
|
|
186
543
|
parent.insertBefore(insWrapper, referenceNode);
|
|
187
544
|
} else {
|
|
188
545
|
parent.insertBefore(insRun, referenceNode);
|
|
@@ -193,7 +550,7 @@ function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, fo
|
|
|
193
550
|
const runs = createFormattedRuns(xmlDoc, text, baseRPr, applicableHints, insertOffset, author, generateRedlines);
|
|
194
551
|
|
|
195
552
|
if (generateRedlines) {
|
|
196
|
-
const insWrapper = createTrackChange(xmlDoc, 'ins', null, author);
|
|
553
|
+
const insWrapper = createTrackChange(xmlDoc, 'ins', null, author, revisionMetadata);
|
|
197
554
|
runs.forEach(run => insWrapper.appendChild(run));
|
|
198
555
|
parent.insertBefore(insWrapper, referenceNode);
|
|
199
556
|
} else {
|