@ansonlai/docx-redline-js 0.5.0 → 0.5.2
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 +76 -15
- package/CHANGELOG.md +24 -1
- package/README.md +43 -11
- package/core/redline-validation.js +11 -5
- package/dist/docx-redline-js.esm.js +363 -55
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +77 -74
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +18 -0
- package/docs/plans/2026-09-08-cross-author-revision-slicing.md +575 -0
- package/docs/schemas/document-operations.schema.json +1 -1
- package/engine/oxml-engine.js +39 -16
- package/engine/surgical-diff-application.js +203 -19
- package/engine/surgical-mode.js +55 -10
- package/engine/surgical-run-splitting.js +103 -0
- package/index.d.ts +1 -1
- package/node/cli.js +12 -7
- package/package.json +123 -123
- package/pipeline/diff-engine.js +22 -0
- package/scripts/generate-cross-author-slicing-fixtures.ps1 +256 -0
- package/services/batch-operation-orchestrator.js +24 -5
- package/services/document-operation-contract.js +22 -0
- package/services/document-operation-mutations.js +19 -3
- package/services/operation-preflight.js +32 -8
- package/services/revision-comment-management.js +40 -0
package/engine/oxml-engine.js
CHANGED
|
@@ -61,7 +61,7 @@ function getCommentIdsInOoxml(node) {
|
|
|
61
61
|
* @param {Object} [options={}] - Options
|
|
62
62
|
* @param {string} [options.author='AI'] - Author for track changes
|
|
63
63
|
* @param {string|null} [options.targetParagraphId=null] - Preferred paragraph identity for table wrappers
|
|
64
|
-
* @param {'merge-same-author'|'reject-input'|'accept-all-first'|'accept-all-first-keep-normalized'} [options.existingRevisions='merge-same-author'] - Policy for source OOXML with tracked changes
|
|
64
|
+
* @param {'merge-same-author'|'slice-cross-author'|'reject-input'|'accept-all-first'|'accept-all-first-keep-normalized'} [options.existingRevisions='merge-same-author'] - Policy for source OOXML with tracked changes
|
|
65
65
|
* @param {boolean} [options.removeFormatting=false] - Remove existing core formatting when text is otherwise unchanged
|
|
66
66
|
* @param {boolean} [options.sanitizeInput=false] - Strip a standalone leading assistant preface line
|
|
67
67
|
* @returns {Promise<{ oxml: string, hasChanges: boolean, sourceType?: 'package'|'document'|'fragment', status?: 'ok'|'no-op'|'error', error?: { code: string, message: string } }>}
|
|
@@ -82,7 +82,7 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
82
82
|
const finalize = result => {
|
|
83
83
|
const withStatus = { ...result };
|
|
84
84
|
if (normalizedExistingRevisions && withStatus.hasChanges === false && withStatus.status !== 'error') {
|
|
85
|
-
if (existingRevisionsPolicy === 'merge-same-author') {
|
|
85
|
+
if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
|
|
86
86
|
withStatus.oxml = workingOoxml;
|
|
87
87
|
withStatus.hasChanges = true;
|
|
88
88
|
withStatus.warnings = [
|
|
@@ -141,7 +141,7 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
141
141
|
seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
|
|
142
142
|
|
|
143
143
|
if (containsTrackedChanges(xmlDoc)) {
|
|
144
|
-
if (existingRevisionsPolicy === 'merge-same-author') {
|
|
144
|
+
if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
|
|
145
145
|
const authors = getTrackedChangeAuthors(xmlDoc);
|
|
146
146
|
const currentAuthor = String(author || '').trim().toLowerCase();
|
|
147
147
|
const isSameAuthor = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === currentAuthor);
|
|
@@ -202,8 +202,8 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
202
202
|
? paragraphsInDoc.map(p => extractCanonicalParagraphText(p)).join('\n')
|
|
203
203
|
: '';
|
|
204
204
|
originalText = baselineText;
|
|
205
|
-
} else {
|
|
206
|
-
log('[OxmlEngine] Existing revisions detected from another/unattributed author; refusing per merge-same-author policy');
|
|
205
|
+
} else if (existingRevisionsPolicy === 'merge-same-author') {
|
|
206
|
+
log('[OxmlEngine] Existing revisions detected from another/unattributed author; refusing per merge-same-author policy');
|
|
207
207
|
return finalize({
|
|
208
208
|
oxml: inputOoxml,
|
|
209
209
|
hasChanges: false,
|
|
@@ -213,8 +213,24 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
213
213
|
message: `Input OOXML contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Pass existingRevisions: "accept-all-first" or resolve revisions first.`
|
|
214
214
|
}
|
|
215
215
|
});
|
|
216
|
-
}
|
|
217
|
-
|
|
216
|
+
} else {
|
|
217
|
+
const hasMoveRevision = ['moveFrom', 'moveTo'].some(localName => {
|
|
218
|
+
return getElementsByTagNSOrTag(xmlDoc, NS_W, localName).length > 0;
|
|
219
|
+
});
|
|
220
|
+
if (hasMoveRevision) {
|
|
221
|
+
return finalize({
|
|
222
|
+
oxml: inputOoxml,
|
|
223
|
+
hasChanges: false,
|
|
224
|
+
status: 'error',
|
|
225
|
+
error: {
|
|
226
|
+
code: 'UNSAFE_REVISION_NESTING',
|
|
227
|
+
message: 'Cross-author slicing does not support pending move revisions.'
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
log('[OxmlEngine] Existing revisions retained for cross-author surgical slicing');
|
|
232
|
+
}
|
|
233
|
+
} else if (existingRevisionsPolicy === 'accept-all-first' || existingRevisionsPolicy === 'accept-all-first-keep-normalized') {
|
|
218
234
|
log('[OxmlEngine] Existing revisions detected; accepting all input revisions before redlining');
|
|
219
235
|
const accepted = acceptTrackedChangesInOoxml(inputOoxml, { allAuthors: true });
|
|
220
236
|
if (accepted.status === 'error') return finalize(accepted);
|
|
@@ -291,7 +307,9 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
291
307
|
}
|
|
292
308
|
const { cleanText: cleanModifiedText, formatHints } = preprocessMarkdown(sanitizedText);
|
|
293
309
|
|
|
294
|
-
const hasTextChanges =
|
|
310
|
+
const hasTextChanges = existingRevisionsPolicy === 'slice-cross-author'
|
|
311
|
+
? cleanModifiedText !== originalText
|
|
312
|
+
: cleanModifiedText.trim() !== originalText.trim();
|
|
295
313
|
const hasFormatHints = formatHints.length > 0;
|
|
296
314
|
|
|
297
315
|
const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
|
|
@@ -453,7 +471,8 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
453
471
|
const isStructuredContent = options.structuredContent !== false && structuredAnalysis?.requiresStructuredContent === true;
|
|
454
472
|
const tableCellContext = initialTableCellContext;
|
|
455
473
|
|
|
456
|
-
|
|
474
|
+
const usesSurgicalTextMode = hasTables || existingRevisionsPolicy === 'slice-cross-author';
|
|
475
|
+
log(`[OxmlEngine] Mode: ${usesSurgicalTextMode ? 'SURGICAL' : 'RECONSTRUCTION'}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
|
|
457
476
|
|
|
458
477
|
try {
|
|
459
478
|
if (isMarkdownTable && !hasTables) {
|
|
@@ -466,8 +485,8 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
466
485
|
recordRouteSelection(options, 'table', { transformation: 'table-reconciliation' });
|
|
467
486
|
return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
|
|
468
487
|
}
|
|
469
|
-
if (
|
|
470
|
-
recordRouteSelection(options, 'surgical', { tableScoped:
|
|
488
|
+
if (usesSurgicalTextMode) {
|
|
489
|
+
recordRouteSelection(options, 'surgical', { tableScoped: hasTables });
|
|
471
490
|
const surgicalTarget = tableCellContext.hasTableWrapper && tableCellContext.targetParagraph
|
|
472
491
|
? tableCellContext.targetParagraph
|
|
473
492
|
: null;
|
|
@@ -475,7 +494,7 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
475
494
|
log('[OxmlEngine] Table cell edit: scoping surgical mode to target paragraph');
|
|
476
495
|
}
|
|
477
496
|
|
|
478
|
-
const result = applySurgicalMode(
|
|
497
|
+
const result = applySurgicalMode(
|
|
479
498
|
xmlDoc,
|
|
480
499
|
originalText,
|
|
481
500
|
cleanModifiedText,
|
|
@@ -485,10 +504,14 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
485
504
|
generateRedlines,
|
|
486
505
|
surgicalTarget,
|
|
487
506
|
{},
|
|
488
|
-
options
|
|
489
|
-
);
|
|
490
|
-
|
|
491
|
-
if (
|
|
507
|
+
options
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
if (result.status === 'error' && result.error?.code === 'PATCH_ROUNDTRIP_MISMATCH') {
|
|
511
|
+
return finalize({ ...result, oxml: inputOoxml, hasChanges: false });
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
|
|
492
515
|
log('[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)');
|
|
493
516
|
return finalize({ oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true });
|
|
494
517
|
}
|
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
getRunContentPieces,
|
|
13
13
|
getRunTextLength,
|
|
14
14
|
insertRunPiecesBefore,
|
|
15
|
-
sliceRunPieces
|
|
15
|
+
sliceRunPieces,
|
|
16
|
+
splitTrackChangeCarrier
|
|
16
17
|
} from './surgical-run-splitting.js';
|
|
17
18
|
import {
|
|
18
19
|
findContainingSpan,
|
|
@@ -86,8 +87,7 @@ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, gener
|
|
|
86
87
|
spansByRun.get(span.runElement).push(span);
|
|
87
88
|
});
|
|
88
89
|
|
|
89
|
-
|
|
90
|
-
let usedDelMetadata = false;
|
|
90
|
+
const records = [];
|
|
91
91
|
spansByRun.forEach((runSpans, runElement) => {
|
|
92
92
|
const parent = runElement.parentNode;
|
|
93
93
|
if (!parent) return;
|
|
@@ -111,31 +111,104 @@ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, gener
|
|
|
111
111
|
|
|
112
112
|
if (!Number.isFinite(deleteStart) || deleteEnd <= deleteStart) return;
|
|
113
113
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
+
});
|
|
117
126
|
|
|
118
|
-
|
|
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
|
+
}
|
|
119
141
|
|
|
120
|
-
|
|
121
|
-
|
|
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)) {
|
|
122
148
|
const metadata = revisionMetadata
|
|
123
|
-
? (usedDelMetadata ? { ...revisionMetadata, id: createRevisionMetadata(author, xmlDoc).id } : revisionMetadata)
|
|
149
|
+
? (usedDelMetadata ? { ...revisionMetadata, id: createRevisionMetadata(author, xmlDoc, 'del').id } : revisionMetadata)
|
|
124
150
|
: null;
|
|
125
151
|
usedDelMetadata = true;
|
|
126
|
-
|
|
127
|
-
parent.insertBefore(delWrapper, runElement);
|
|
152
|
+
delWrapper = createTrackChange(xmlDoc, 'del', null, author, metadata);
|
|
128
153
|
}
|
|
129
154
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
+
}
|
|
134
185
|
|
|
135
186
|
return changed;
|
|
136
187
|
}
|
|
137
188
|
|
|
138
|
-
export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], insertOffset = 0, generateRedlines = true, fallbackParagraph = null, revisionMetadata = null, affinity = null) {
|
|
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
|
+
|
|
139
212
|
if (!affinity) {
|
|
140
213
|
let targetSpan = findContainingSpan(spanIndex, pos);
|
|
141
214
|
|
|
@@ -164,6 +237,31 @@ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints
|
|
|
164
237
|
return true;
|
|
165
238
|
}
|
|
166
239
|
|
|
240
|
+
const generateNestedRevision = !(
|
|
241
|
+
generateRedlines
|
|
242
|
+
&& existingRevisions === 'slice-cross-author'
|
|
243
|
+
&& isSameAuthorInsertion(parent, author)
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
if (
|
|
247
|
+
generateRedlines
|
|
248
|
+
&& existingRevisions === 'slice-cross-author'
|
|
249
|
+
&& isForeignInsertion(parent, author)
|
|
250
|
+
) {
|
|
251
|
+
return spliceInsertionAtCarrierOffset(
|
|
252
|
+
xmlDoc,
|
|
253
|
+
parent,
|
|
254
|
+
getCarrierSplitOffset(spanIndex, parent, pos),
|
|
255
|
+
text,
|
|
256
|
+
targetSpan.rPr,
|
|
257
|
+
author,
|
|
258
|
+
formatHints,
|
|
259
|
+
insertOffset,
|
|
260
|
+
generateRedlines,
|
|
261
|
+
revisionMetadata
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
167
265
|
const pieces = getRunContentPieces(targetSpan.runElement);
|
|
168
266
|
const targetPiece = pieces.find(piece => piece.node === targetSpan.textElement);
|
|
169
267
|
const localInsertPos = targetPiece
|
|
@@ -175,14 +273,14 @@ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints
|
|
|
175
273
|
const afterPieces = sliceRunPieces(xmlDoc, pieces, localInsertPos, getRunTextLength(pieces), false);
|
|
176
274
|
|
|
177
275
|
insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, beforePieces, targetSpan.rPr);
|
|
178
|
-
insertTextRuns(xmlDoc, parent, targetSpan.runElement, text, targetSpan.rPr, author, formatHints, insertOffset,
|
|
276
|
+
insertTextRuns(xmlDoc, parent, targetSpan.runElement, text, targetSpan.rPr, author, formatHints, insertOffset, generateNestedRevision, revisionMetadata);
|
|
179
277
|
insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, afterPieces, targetSpan.rPr);
|
|
180
278
|
parent.removeChild(targetSpan.runElement);
|
|
181
279
|
return true;
|
|
182
280
|
}
|
|
183
281
|
|
|
184
282
|
const referenceNode = pos <= targetSpan.charStart ? targetSpan.runElement : targetSpan.runElement.nextSibling;
|
|
185
|
-
insertTextRuns(xmlDoc, parent, referenceNode, text, targetSpan.rPr, author, formatHints, insertOffset,
|
|
283
|
+
insertTextRuns(xmlDoc, parent, referenceNode, text, targetSpan.rPr, author, formatHints, insertOffset, generateNestedRevision, revisionMetadata);
|
|
186
284
|
return true;
|
|
187
285
|
}
|
|
188
286
|
|
|
@@ -357,10 +455,96 @@ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints
|
|
|
357
455
|
}
|
|
358
456
|
}
|
|
359
457
|
|
|
458
|
+
if (
|
|
459
|
+
generateRedlines
|
|
460
|
+
&& existingRevisions === 'slice-cross-author'
|
|
461
|
+
&& isForeignInsertion(parent, author)
|
|
462
|
+
) {
|
|
463
|
+
return spliceInsertionAtCarrierOffset(
|
|
464
|
+
xmlDoc,
|
|
465
|
+
parent,
|
|
466
|
+
getCarrierSplitOffset(spanIndex, parent, pos),
|
|
467
|
+
text,
|
|
468
|
+
baseRPr,
|
|
469
|
+
author,
|
|
470
|
+
formatHints,
|
|
471
|
+
insertOffset,
|
|
472
|
+
generateRedlines,
|
|
473
|
+
revisionMetadata
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
360
477
|
insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
|
|
361
478
|
return true;
|
|
362
479
|
}
|
|
363
480
|
|
|
481
|
+
function spliceInsertionAtCarrierOffset(xmlDoc, carrier, splitOffset, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata) {
|
|
482
|
+
const parent = carrier.parentNode;
|
|
483
|
+
if (!parent) return false;
|
|
484
|
+
|
|
485
|
+
const { leftCarrier, rightCarrier } = splitTrackChangeCarrier(xmlDoc, carrier, splitOffset);
|
|
486
|
+
if (leftCarrier) parent.insertBefore(leftCarrier, carrier);
|
|
487
|
+
insertTextRuns(
|
|
488
|
+
xmlDoc,
|
|
489
|
+
parent,
|
|
490
|
+
carrier,
|
|
491
|
+
text,
|
|
492
|
+
withoutRunPropertyChanges(baseRPr),
|
|
493
|
+
author,
|
|
494
|
+
formatHints,
|
|
495
|
+
insertOffset,
|
|
496
|
+
generateRedlines,
|
|
497
|
+
revisionMetadata
|
|
498
|
+
);
|
|
499
|
+
if (rightCarrier) parent.insertBefore(rightCarrier, carrier);
|
|
500
|
+
parent.removeChild(carrier);
|
|
501
|
+
return true;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function withoutRunPropertyChanges(rPr) {
|
|
505
|
+
if (!rPr) return null;
|
|
506
|
+
const clone = rPr.cloneNode(true);
|
|
507
|
+
const changes = Array.from(clone.getElementsByTagName?.('*') || [])
|
|
508
|
+
.filter(node => isWordElement(node, 'rPrChange'));
|
|
509
|
+
changes.forEach(node => node.parentNode?.removeChild(node));
|
|
510
|
+
return clone;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function getCarrierSplitOffset(spanIndex, carrier, pos) {
|
|
514
|
+
const carrierStart = getCarrierGlobalStart(spanIndex, carrier);
|
|
515
|
+
const carrierLength = spanIndex.spans
|
|
516
|
+
.filter(span => span.runElement?.parentNode === carrier)
|
|
517
|
+
.reduce((length, span) => length + (span.charEnd - span.charStart), 0);
|
|
518
|
+
return Math.max(0, Math.min(pos - carrierStart, carrierLength));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function getCarrierGlobalStart(spanIndex, carrier) {
|
|
522
|
+
const carrierSpans = spanIndex.spans.filter(span => span.runElement?.parentNode === carrier);
|
|
523
|
+
return carrierSpans.length > 0 ? Math.min(...carrierSpans.map(span => span.charStart)) : 0;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function isForeignInsertion(node, author) {
|
|
527
|
+
if (!isWordElement(node, 'ins')) return false;
|
|
528
|
+
const carrierAuthor = node.getAttribute('w:author') || node.getAttributeNS?.(NS_W, 'author') || '';
|
|
529
|
+
return carrierAuthor.trim().toLowerCase() !== String(author || '').trim().toLowerCase();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function isSameAuthorInsertion(node, author) {
|
|
533
|
+
if (!isWordElement(node, 'ins')) return false;
|
|
534
|
+
const carrierAuthor = node.getAttribute('w:author') || node.getAttributeNS?.(NS_W, 'author') || '';
|
|
535
|
+
return carrierAuthor.trim().toLowerCase() === String(author || '').trim().toLowerCase();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function nextElementSibling(node) {
|
|
539
|
+
let sibling = node?.nextSibling || null;
|
|
540
|
+
while (sibling && sibling.nodeType !== 1) sibling = sibling.nextSibling;
|
|
541
|
+
return sibling;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function isConnected(node) {
|
|
545
|
+
return !!node?.parentNode;
|
|
546
|
+
}
|
|
547
|
+
|
|
364
548
|
function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata = null) {
|
|
365
549
|
const applicableHints = getApplicableFormatHints(formatHints, insertOffset, insertOffset + text.length);
|
|
366
550
|
|
package/engine/surgical-mode.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { getApplicableFormatHints } from '../pipeline/markdown-processor.js';
|
|
9
|
-
import { computeWordDiffs } from '../pipeline/diff-engine.js';
|
|
9
|
+
import { computeInsertionOnlyDiffs, computeWordDiffs } from '../pipeline/diff-engine.js';
|
|
10
10
|
import { getDocumentParagraphs } from './format-extraction.js';
|
|
11
11
|
import { buildSpanIndex, buildSurgicalTextSpans, forEachOverlappingSpan } from './surgical-spans.js';
|
|
12
12
|
import {
|
|
@@ -16,8 +16,9 @@ import {
|
|
|
16
16
|
} from './surgical-diff-application.js';
|
|
17
17
|
import { withOoxmlSourceType } from '../core/word-xml.js';
|
|
18
18
|
import { createReplacementRevisionEvent } from '../core/types.js';
|
|
19
|
+
import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
|
|
19
20
|
|
|
20
|
-
function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos) {
|
|
21
|
+
function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos, allowInsertionCarrier = false) {
|
|
21
22
|
const spans = [];
|
|
22
23
|
forEachOverlappingSpan(spanIndex, startPos, endPos, span => spans.push(span));
|
|
23
24
|
if (spans.length === 0) return { safe: false };
|
|
@@ -32,7 +33,10 @@ function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos) {
|
|
|
32
33
|
|
|
33
34
|
// Parent container itself cannot be an existing revision or unsupported container
|
|
34
35
|
const parentLocal = (parent.localName || parent.nodeName.replace(/^.*:/, ''));
|
|
35
|
-
if (
|
|
36
|
+
if (
|
|
37
|
+
['hyperlink', 'sdt', 'del', 'moveFrom', 'moveTo'].includes(parentLocal)
|
|
38
|
+
|| (parentLocal === 'ins' && !allowInsertionCarrier)
|
|
39
|
+
) {
|
|
36
40
|
return { safe: false, structuralBoundary: true };
|
|
37
41
|
}
|
|
38
42
|
|
|
@@ -108,7 +112,10 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
108
112
|
: getDocumentParagraphs(xmlDoc);
|
|
109
113
|
|
|
110
114
|
const { fullText, textSpans } = buildSurgicalTextSpans(allParagraphs);
|
|
111
|
-
const
|
|
115
|
+
const insertionOnlyDiffs = options.existingRevisions === 'slice-cross-author'
|
|
116
|
+
? computeInsertionOnlyDiffs(fullText, modifiedText)
|
|
117
|
+
: null;
|
|
118
|
+
const diffs = insertionOnlyDiffs || computeWordDiffs(fullText, modifiedText, diffOptions);
|
|
112
119
|
const spanIndex = buildSpanIndex(textSpans);
|
|
113
120
|
const pairReplacements = options.pairReplacements === true;
|
|
114
121
|
const warnings = [];
|
|
@@ -148,8 +155,13 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
148
155
|
if (pairReplacements && generateRedlines && hasNextInsert) {
|
|
149
156
|
const nextText = diffs[i + 1][1];
|
|
150
157
|
const textWithoutNewlines = nextText.replace(/\n/g, ' ');
|
|
151
|
-
if (textWithoutNewlines.
|
|
152
|
-
const checkResult = checkSafeAdjacencyForPairing(
|
|
158
|
+
if (textWithoutNewlines.length > 0) {
|
|
159
|
+
const checkResult = checkSafeAdjacencyForPairing(
|
|
160
|
+
spanIndex,
|
|
161
|
+
originalPos,
|
|
162
|
+
originalPos + text.length,
|
|
163
|
+
options?.existingRevisions === 'slice-cross-author'
|
|
164
|
+
);
|
|
153
165
|
if (checkResult.safe) {
|
|
154
166
|
const event = createReplacementRevisionEvent(author, xmlDoc);
|
|
155
167
|
delMetadata = { id: event.deletionId, author: event.author, date: event.date };
|
|
@@ -170,8 +182,8 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
170
182
|
i++;
|
|
171
183
|
const [, nextText] = diffs[i];
|
|
172
184
|
const textWithoutNewlines = nextText.replace(/\n/g, ' ');
|
|
173
|
-
if (textWithoutNewlines.
|
|
174
|
-
const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null);
|
|
185
|
+
if (textWithoutNewlines.length > 0) {
|
|
186
|
+
const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null, options?.existingRevisions || 'merge-same-author');
|
|
175
187
|
if (insertResult && typeof insertResult === 'object' && insertResult.error) {
|
|
176
188
|
return withOoxmlSourceType({
|
|
177
189
|
oxml: serializer.serializeToString(xmlDoc),
|
|
@@ -188,8 +200,8 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
188
200
|
}
|
|
189
201
|
} else if (op === 1) {
|
|
190
202
|
const textWithoutNewlines = text.replace(/\n/g, ' ');
|
|
191
|
-
if (textWithoutNewlines.
|
|
192
|
-
const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null);
|
|
203
|
+
if (textWithoutNewlines.length > 0) {
|
|
204
|
+
const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null, options?.existingRevisions || 'merge-same-author');
|
|
193
205
|
if (insertResult && typeof insertResult === 'object' && insertResult.error) {
|
|
194
206
|
return withOoxmlSourceType({
|
|
195
207
|
oxml: serializer.serializeToString(xmlDoc),
|
|
@@ -206,9 +218,42 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
206
218
|
}
|
|
207
219
|
}
|
|
208
220
|
|
|
221
|
+
const actualText = allParagraphs.map(paragraph => extractCanonicalParagraphText(paragraph)).join('\n');
|
|
222
|
+
const expectedText = String(modifiedText).replace(/\r\n/g, '\n');
|
|
223
|
+
if (options.existingRevisions === 'slice-cross-author' && actualText !== expectedText) {
|
|
224
|
+
const mismatchOffset = firstMismatchOffset(expectedText, actualText);
|
|
225
|
+
return withOoxmlSourceType({
|
|
226
|
+
oxml: serializer.serializeToString(xmlDoc),
|
|
227
|
+
hasChanges: false,
|
|
228
|
+
status: 'error',
|
|
229
|
+
error: {
|
|
230
|
+
code: 'PATCH_ROUNDTRIP_MISMATCH',
|
|
231
|
+
message: 'Generated OOXML accepted-view text does not match the requested modified text; the mutation was rejected.',
|
|
232
|
+
mismatchOffset,
|
|
233
|
+
expectedExcerpt: excerptAt(expectedText, mismatchOffset),
|
|
234
|
+
actualExcerpt: excerptAt(actualText, mismatchOffset)
|
|
235
|
+
},
|
|
236
|
+
...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
209
240
|
return withOoxmlSourceType({
|
|
210
241
|
oxml: serializer.serializeToString(xmlDoc),
|
|
211
242
|
hasChanges,
|
|
212
243
|
...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
|
|
213
244
|
});
|
|
214
245
|
}
|
|
246
|
+
|
|
247
|
+
function firstMismatchOffset(expected, actual) {
|
|
248
|
+
const limit = Math.min(expected.length, actual.length);
|
|
249
|
+
for (let index = 0; index < limit; index++) {
|
|
250
|
+
if (expected[index] !== actual[index]) return index;
|
|
251
|
+
}
|
|
252
|
+
return limit;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function excerptAt(text, offset, radius = 40) {
|
|
256
|
+
const start = Math.max(0, offset - radius);
|
|
257
|
+
const end = Math.min(text.length, offset + radius);
|
|
258
|
+
return text.slice(start, end);
|
|
259
|
+
}
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { createWordElement, isWordElement } from '../core/word-xml.js';
|
|
2
|
+
import {
|
|
3
|
+
NS_W,
|
|
4
|
+
RevisionIdAllocator,
|
|
5
|
+
createRevisionIdAllocator,
|
|
6
|
+
getRevisionIdAllocatorForDocument
|
|
7
|
+
} from '../core/types.js';
|
|
8
|
+
import { refreshRunPropertyChangeIds } from '../core/revision-cloning.js';
|
|
2
9
|
import { getRunChildText, isTextLikeRunChild } from './surgical-spans.js';
|
|
3
10
|
|
|
11
|
+
const TRACK_CHANGE_CARRIERS = new Set(['ins']);
|
|
12
|
+
|
|
4
13
|
export function getRunContentPieces(runElement) {
|
|
5
14
|
const pieces = [];
|
|
6
15
|
let offset = 0;
|
|
@@ -62,6 +71,100 @@ export function insertRunPiecesBefore(xmlDoc, parent, referenceNode, pieces, rPr
|
|
|
62
71
|
return run;
|
|
63
72
|
}
|
|
64
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Splits a run-level tracked-change carrier at an accepted-view character
|
|
76
|
+
* offset without mutating the source carrier. The original revision ID stays
|
|
77
|
+
* with the leading fragment; an interior trailing fragment receives a fresh,
|
|
78
|
+
* document-scoped ID while all other carrier metadata remains unchanged.
|
|
79
|
+
*
|
|
80
|
+
* @param {Document} xmlDoc
|
|
81
|
+
* @param {Element} carrierElement
|
|
82
|
+
* @param {number} splitOffset
|
|
83
|
+
* @param {RevisionIdAllocator|null} [allocator=null]
|
|
84
|
+
* @returns {{ leftCarrier: Element|null, rightCarrier: Element|null }}
|
|
85
|
+
*/
|
|
86
|
+
export function splitTrackChangeCarrier(xmlDoc, carrierElement, splitOffset, allocator = null) {
|
|
87
|
+
const carrierName = getLocalName(carrierElement);
|
|
88
|
+
if (!TRACK_CHANGE_CARRIERS.has(carrierName)) {
|
|
89
|
+
throw new TypeError('splitTrackChangeCarrier requires a w:ins carrier.');
|
|
90
|
+
}
|
|
91
|
+
if (!Number.isInteger(splitOffset) || splitOffset < 0) {
|
|
92
|
+
throw new RangeError('splitOffset must be a non-negative integer.');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const children = Array.from(carrierElement.childNodes || []);
|
|
96
|
+
const totalLength = children.reduce((length, child) => {
|
|
97
|
+
return length + (isWordElement(child, 'r') ? getRunTextLength(getRunContentPieces(child)) : 0);
|
|
98
|
+
}, 0);
|
|
99
|
+
if (splitOffset > totalLength) {
|
|
100
|
+
throw new RangeError(`splitOffset ${splitOffset} exceeds carrier text length ${totalLength}.`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (splitOffset === 0) {
|
|
104
|
+
return { leftCarrier: null, rightCarrier: carrierElement.cloneNode(true) };
|
|
105
|
+
}
|
|
106
|
+
if (splitOffset === totalLength) {
|
|
107
|
+
return { leftCarrier: carrierElement.cloneNode(true), rightCarrier: null };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const leftCarrier = carrierElement.cloneNode(false);
|
|
111
|
+
const rightCarrier = carrierElement.cloneNode(false);
|
|
112
|
+
let offset = 0;
|
|
113
|
+
|
|
114
|
+
for (const child of children) {
|
|
115
|
+
if (!isWordElement(child, 'r')) {
|
|
116
|
+
const destination = offset <= splitOffset ? leftCarrier : rightCarrier;
|
|
117
|
+
destination.appendChild(child.cloneNode(true));
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const pieces = getRunContentPieces(child);
|
|
122
|
+
const runLength = getRunTextLength(pieces);
|
|
123
|
+
const runEnd = offset + runLength;
|
|
124
|
+
|
|
125
|
+
if (runEnd <= splitOffset) {
|
|
126
|
+
leftCarrier.appendChild(child.cloneNode(true));
|
|
127
|
+
} else if (offset >= splitOffset) {
|
|
128
|
+
rightCarrier.appendChild(child.cloneNode(true));
|
|
129
|
+
} else {
|
|
130
|
+
const localOffset = splitOffset - offset;
|
|
131
|
+
const rPr = Array.from(child.childNodes || []).find(node => isWordElement(node, 'rPr')) || null;
|
|
132
|
+
const leftPieces = sliceRunPieces(xmlDoc, pieces, 0, localOffset, false);
|
|
133
|
+
const rightPieces = sliceRunPieces(xmlDoc, pieces, localOffset, runLength, false);
|
|
134
|
+
leftCarrier.appendChild(createRunFromPieces(xmlDoc, leftPieces, rPr));
|
|
135
|
+
const rightRun = createRunFromPieces(xmlDoc, rightPieces, rPr);
|
|
136
|
+
refreshRunPropertyChangeIds(rightRun, resolveAllocator(xmlDoc, allocator));
|
|
137
|
+
rightCarrier.appendChild(rightRun);
|
|
138
|
+
}
|
|
139
|
+
offset = runEnd;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const resolvedAllocator = resolveAllocator(xmlDoc, allocator);
|
|
143
|
+
const nextId = resolvedAllocator.next();
|
|
144
|
+
setWordAttribute(rightCarrier, 'id', String(nextId));
|
|
145
|
+
resolvedAllocator._receiptCollector?.recordRevision(nextId, carrierName);
|
|
146
|
+
|
|
147
|
+
return { leftCarrier, rightCarrier };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function resolveAllocator(xmlDoc, allocator) {
|
|
151
|
+
return allocator instanceof RevisionIdAllocator
|
|
152
|
+
? allocator
|
|
153
|
+
: (getRevisionIdAllocatorForDocument(xmlDoc) || createRevisionIdAllocator(xmlDoc));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function setWordAttribute(element, localName, value) {
|
|
157
|
+
if (typeof element.setAttributeNS === 'function') {
|
|
158
|
+
element.setAttributeNS(NS_W, `w:${localName}`, value);
|
|
159
|
+
} else {
|
|
160
|
+
element.setAttribute(`w:${localName}`, value);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function getLocalName(element) {
|
|
165
|
+
return String(element?.localName || element?.nodeName || '').replace(/^.*:/, '');
|
|
166
|
+
}
|
|
167
|
+
|
|
65
168
|
function cloneRunPiece(xmlDoc, sourceNode, text, asDeletedText) {
|
|
66
169
|
if (asDeletedText) {
|
|
67
170
|
const delText = createWordElement(xmlDoc, 'w:delText');
|
package/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type OoxmlSourceType = 'package' | 'document' | 'fragment';
|
|
2
2
|
export type RedlineStatus = 'ok' | 'no-op' | 'error';
|
|
3
|
-
export type ExistingRevisionsPolicy = 'merge-same-author' | 'reject-input' | 'accept-all-first' | 'accept-all-first-keep-normalized';
|
|
3
|
+
export type ExistingRevisionsPolicy = 'merge-same-author' | 'slice-cross-author' | 'reject-input' | 'accept-all-first' | 'accept-all-first-keep-normalized';
|
|
4
4
|
export type RevisionView = 'accepted' | 'rejected';
|
|
5
5
|
|
|
6
6
|
export interface RevisionTextSegment {
|