@ansonlai/docx-redline-js 0.5.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 +69 -13
- package/CHANGELOG.md +1 -1
- package/README.md +1 -1
- package/core/redline-validation.js +11 -5
- package/dist/docx-redline-js.esm.js +305 -48
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +73 -72
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/plans/2026-09-08-cross-author-revision-slicing.md +505 -0
- package/docs/schemas/document-operations.schema.json +1 -1
- package/engine/oxml-engine.js +27 -10
- package/engine/surgical-diff-application.js +189 -17
- package/engine/surgical-mode.js +13 -5
- 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/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
|
@@ -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,25 @@ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints
|
|
|
164
237
|
return true;
|
|
165
238
|
}
|
|
166
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
|
+
}
|
|
258
|
+
|
|
167
259
|
const pieces = getRunContentPieces(targetSpan.runElement);
|
|
168
260
|
const targetPiece = pieces.find(piece => piece.node === targetSpan.textElement);
|
|
169
261
|
const localInsertPos = targetPiece
|
|
@@ -357,10 +449,90 @@ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints
|
|
|
357
449
|
}
|
|
358
450
|
}
|
|
359
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
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
360
471
|
insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
|
|
361
472
|
return true;
|
|
362
473
|
}
|
|
363
474
|
|
|
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
|
+
|
|
364
536
|
function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata = null) {
|
|
365
537
|
const applicableHints = getApplicableFormatHints(formatHints, insertOffset, insertOffset + text.length);
|
|
366
538
|
|
package/engine/surgical-mode.js
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
import { withOoxmlSourceType } from '../core/word-xml.js';
|
|
18
18
|
import { createReplacementRevisionEvent } from '../core/types.js';
|
|
19
19
|
|
|
20
|
-
function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos) {
|
|
20
|
+
function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos, allowInsertionCarrier = false) {
|
|
21
21
|
const spans = [];
|
|
22
22
|
forEachOverlappingSpan(spanIndex, startPos, endPos, span => spans.push(span));
|
|
23
23
|
if (spans.length === 0) return { safe: false };
|
|
@@ -32,7 +32,10 @@ function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos) {
|
|
|
32
32
|
|
|
33
33
|
// Parent container itself cannot be an existing revision or unsupported container
|
|
34
34
|
const parentLocal = (parent.localName || parent.nodeName.replace(/^.*:/, ''));
|
|
35
|
-
if (
|
|
35
|
+
if (
|
|
36
|
+
['hyperlink', 'sdt', 'del', 'moveFrom', 'moveTo'].includes(parentLocal)
|
|
37
|
+
|| (parentLocal === 'ins' && !allowInsertionCarrier)
|
|
38
|
+
) {
|
|
36
39
|
return { safe: false, structuralBoundary: true };
|
|
37
40
|
}
|
|
38
41
|
|
|
@@ -149,7 +152,12 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
149
152
|
const nextText = diffs[i + 1][1];
|
|
150
153
|
const textWithoutNewlines = nextText.replace(/\n/g, ' ');
|
|
151
154
|
if (textWithoutNewlines.trim().length > 0) {
|
|
152
|
-
const checkResult = checkSafeAdjacencyForPairing(
|
|
155
|
+
const checkResult = checkSafeAdjacencyForPairing(
|
|
156
|
+
spanIndex,
|
|
157
|
+
originalPos,
|
|
158
|
+
originalPos + text.length,
|
|
159
|
+
options?.existingRevisions === 'slice-cross-author'
|
|
160
|
+
);
|
|
153
161
|
if (checkResult.safe) {
|
|
154
162
|
const event = createReplacementRevisionEvent(author, xmlDoc);
|
|
155
163
|
delMetadata = { id: event.deletionId, author: event.author, date: event.date };
|
|
@@ -171,7 +179,7 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
171
179
|
const [, nextText] = diffs[i];
|
|
172
180
|
const textWithoutNewlines = nextText.replace(/\n/g, ' ');
|
|
173
181
|
if (textWithoutNewlines.trim().length > 0) {
|
|
174
|
-
const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null);
|
|
182
|
+
const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null, options?.existingRevisions || 'merge-same-author');
|
|
175
183
|
if (insertResult && typeof insertResult === 'object' && insertResult.error) {
|
|
176
184
|
return withOoxmlSourceType({
|
|
177
185
|
oxml: serializer.serializeToString(xmlDoc),
|
|
@@ -189,7 +197,7 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
189
197
|
} else if (op === 1) {
|
|
190
198
|
const textWithoutNewlines = text.replace(/\n/g, ' ');
|
|
191
199
|
if (textWithoutNewlines.trim().length > 0) {
|
|
192
|
-
const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null);
|
|
200
|
+
const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null, options?.existingRevisions || 'merge-same-author');
|
|
193
201
|
if (insertResult && typeof insertResult === 'object' && insertResult.error) {
|
|
194
202
|
return withOoxmlSourceType({
|
|
195
203
|
oxml: serializer.serializeToString(xmlDoc),
|
|
@@ -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 {
|
package/node/cli.js
CHANGED
|
@@ -4,14 +4,16 @@ import { openDocx } from './docx-document.js';
|
|
|
4
4
|
import { MemoryZip, unzipDocx } from './zip-archive.js';
|
|
5
5
|
import { validateDocxPackage } from '../services/standalone-docx-plumbing.js';
|
|
6
6
|
import { validateRedlineOoxml } from '../core/redline-validation.js';
|
|
7
|
-
import { configureLogger } from '../adapters/logger.js';
|
|
7
|
+
import { configureLogger } from '../adapters/logger.js';
|
|
8
|
+
import { isExistingRevisionsPolicy } from '../services/document-operation-contract.js';
|
|
8
9
|
|
|
9
10
|
const suffixes = { apply: 'redlined', accept: 'accepted', reject: 'rejected', 'delete-comments': 'comments-removed' };
|
|
10
11
|
const CLI_CONTRACT_VERSION = 2;
|
|
11
|
-
const CLI_CAPABILITIES = [
|
|
12
|
-
'atomic-batch-results-on-package-failure',
|
|
13
|
-
'baseline-aware-validation',
|
|
14
|
-
'
|
|
12
|
+
const CLI_CAPABILITIES = [
|
|
13
|
+
'atomic-batch-results-on-package-failure',
|
|
14
|
+
'baseline-aware-validation',
|
|
15
|
+
'cross-author-revision-slicing',
|
|
16
|
+
'document-scoped-list-revision-ids'
|
|
15
17
|
];
|
|
16
18
|
const commandOptions = {
|
|
17
19
|
version: new Set(['help']),
|
|
@@ -214,8 +216,11 @@ export async function executeCli(argv) {
|
|
|
214
216
|
};
|
|
215
217
|
}
|
|
216
218
|
if (!rawInput) return cliError('INPUT_REQUIRED', 'An input .docx path is required.');
|
|
217
|
-
const optionError = validateCommandOptions(command, flags, extraPositionals);
|
|
218
|
-
if (optionError) return optionError;
|
|
219
|
+
const optionError = validateCommandOptions(command, flags, extraPositionals);
|
|
220
|
+
if (optionError) return optionError;
|
|
221
|
+
if (flags.existingRevisions != null && !isExistingRevisionsPolicy(flags.existingRevisions)) {
|
|
222
|
+
return cliError('INVALID_OPERATION', `Unsupported existing-revisions policy: "${String(flags.existingRevisions)}".`);
|
|
223
|
+
}
|
|
219
224
|
let inspectOptions = null;
|
|
220
225
|
if (command === 'inspect' || command === 'extract') {
|
|
221
226
|
try { inspectOptions = inspectionOptions(flags); }
|