@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @ansonlai/docx-redline-js v0.5.
|
|
1
|
+
// @ansonlai/docx-redline-js v0.5.1 — https://github.com/AnsonLai/docx-redline-js
|
|
2
2
|
var __create = Object.create;
|
|
3
3
|
var __defProp = Object.defineProperty;
|
|
4
4
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
@@ -5879,7 +5879,26 @@ function describeInsertionBoundary(spanIndex, pos, fallbackParagraph = null) {
|
|
|
5879
5879
|
};
|
|
5880
5880
|
}
|
|
5881
5881
|
|
|
5882
|
+
// core/revision-cloning.js
|
|
5883
|
+
function refreshRunPropertyChangeIds(root, allocator = null) {
|
|
5884
|
+
if (!root) return root;
|
|
5885
|
+
const xmlDoc = root.nodeType === 9 ? root : root.ownerDocument;
|
|
5886
|
+
const resolvedAllocator = allocator instanceof RevisionIdAllocator ? allocator : getRevisionIdAllocatorForDocument(xmlDoc) || createRevisionIdAllocator(xmlDoc);
|
|
5887
|
+
const candidates = [root, ...Array.from(root.getElementsByTagName?.("*") || [])];
|
|
5888
|
+
for (const node of candidates) {
|
|
5889
|
+
if (!isWordElement(node, "rPrChange")) continue;
|
|
5890
|
+
const nextId = String(resolvedAllocator.next());
|
|
5891
|
+
if (typeof node.setAttributeNS === "function") {
|
|
5892
|
+
node.setAttributeNS(NS_W, "w:id", nextId);
|
|
5893
|
+
} else {
|
|
5894
|
+
node.setAttribute("w:id", nextId);
|
|
5895
|
+
}
|
|
5896
|
+
}
|
|
5897
|
+
return root;
|
|
5898
|
+
}
|
|
5899
|
+
|
|
5882
5900
|
// engine/surgical-run-splitting.js
|
|
5901
|
+
var TRACK_CHANGE_CARRIERS = /* @__PURE__ */ new Set(["ins"]);
|
|
5883
5902
|
function getRunContentPieces(runElement) {
|
|
5884
5903
|
const pieces = [];
|
|
5885
5904
|
let offset = 0;
|
|
@@ -5928,6 +5947,74 @@ function insertRunPiecesBefore(xmlDoc, parent, referenceNode, pieces, rPr) {
|
|
|
5928
5947
|
parent.insertBefore(run, referenceNode);
|
|
5929
5948
|
return run;
|
|
5930
5949
|
}
|
|
5950
|
+
function splitTrackChangeCarrier(xmlDoc, carrierElement, splitOffset, allocator = null) {
|
|
5951
|
+
const carrierName = getLocalName(carrierElement);
|
|
5952
|
+
if (!TRACK_CHANGE_CARRIERS.has(carrierName)) {
|
|
5953
|
+
throw new TypeError("splitTrackChangeCarrier requires a w:ins carrier.");
|
|
5954
|
+
}
|
|
5955
|
+
if (!Number.isInteger(splitOffset) || splitOffset < 0) {
|
|
5956
|
+
throw new RangeError("splitOffset must be a non-negative integer.");
|
|
5957
|
+
}
|
|
5958
|
+
const children = Array.from(carrierElement.childNodes || []);
|
|
5959
|
+
const totalLength = children.reduce((length, child) => {
|
|
5960
|
+
return length + (isWordElement(child, "r") ? getRunTextLength(getRunContentPieces(child)) : 0);
|
|
5961
|
+
}, 0);
|
|
5962
|
+
if (splitOffset > totalLength) {
|
|
5963
|
+
throw new RangeError(`splitOffset ${splitOffset} exceeds carrier text length ${totalLength}.`);
|
|
5964
|
+
}
|
|
5965
|
+
if (splitOffset === 0) {
|
|
5966
|
+
return { leftCarrier: null, rightCarrier: carrierElement.cloneNode(true) };
|
|
5967
|
+
}
|
|
5968
|
+
if (splitOffset === totalLength) {
|
|
5969
|
+
return { leftCarrier: carrierElement.cloneNode(true), rightCarrier: null };
|
|
5970
|
+
}
|
|
5971
|
+
const leftCarrier = carrierElement.cloneNode(false);
|
|
5972
|
+
const rightCarrier = carrierElement.cloneNode(false);
|
|
5973
|
+
let offset = 0;
|
|
5974
|
+
for (const child of children) {
|
|
5975
|
+
if (!isWordElement(child, "r")) {
|
|
5976
|
+
const destination = offset <= splitOffset ? leftCarrier : rightCarrier;
|
|
5977
|
+
destination.appendChild(child.cloneNode(true));
|
|
5978
|
+
continue;
|
|
5979
|
+
}
|
|
5980
|
+
const pieces = getRunContentPieces(child);
|
|
5981
|
+
const runLength = getRunTextLength(pieces);
|
|
5982
|
+
const runEnd = offset + runLength;
|
|
5983
|
+
if (runEnd <= splitOffset) {
|
|
5984
|
+
leftCarrier.appendChild(child.cloneNode(true));
|
|
5985
|
+
} else if (offset >= splitOffset) {
|
|
5986
|
+
rightCarrier.appendChild(child.cloneNode(true));
|
|
5987
|
+
} else {
|
|
5988
|
+
const localOffset = splitOffset - offset;
|
|
5989
|
+
const rPr = Array.from(child.childNodes || []).find((node) => isWordElement(node, "rPr")) || null;
|
|
5990
|
+
const leftPieces = sliceRunPieces(xmlDoc, pieces, 0, localOffset, false);
|
|
5991
|
+
const rightPieces = sliceRunPieces(xmlDoc, pieces, localOffset, runLength, false);
|
|
5992
|
+
leftCarrier.appendChild(createRunFromPieces(xmlDoc, leftPieces, rPr));
|
|
5993
|
+
const rightRun = createRunFromPieces(xmlDoc, rightPieces, rPr);
|
|
5994
|
+
refreshRunPropertyChangeIds(rightRun, resolveAllocator(xmlDoc, allocator));
|
|
5995
|
+
rightCarrier.appendChild(rightRun);
|
|
5996
|
+
}
|
|
5997
|
+
offset = runEnd;
|
|
5998
|
+
}
|
|
5999
|
+
const resolvedAllocator = resolveAllocator(xmlDoc, allocator);
|
|
6000
|
+
const nextId = resolvedAllocator.next();
|
|
6001
|
+
setWordAttribute(rightCarrier, "id", String(nextId));
|
|
6002
|
+
resolvedAllocator._receiptCollector?.recordRevision(nextId, carrierName);
|
|
6003
|
+
return { leftCarrier, rightCarrier };
|
|
6004
|
+
}
|
|
6005
|
+
function resolveAllocator(xmlDoc, allocator) {
|
|
6006
|
+
return allocator instanceof RevisionIdAllocator ? allocator : getRevisionIdAllocatorForDocument(xmlDoc) || createRevisionIdAllocator(xmlDoc);
|
|
6007
|
+
}
|
|
6008
|
+
function setWordAttribute(element, localName2, value) {
|
|
6009
|
+
if (typeof element.setAttributeNS === "function") {
|
|
6010
|
+
element.setAttributeNS(NS_W, `w:${localName2}`, value);
|
|
6011
|
+
} else {
|
|
6012
|
+
element.setAttribute(`w:${localName2}`, value);
|
|
6013
|
+
}
|
|
6014
|
+
}
|
|
6015
|
+
function getLocalName(element) {
|
|
6016
|
+
return String(element?.localName || element?.nodeName || "").replace(/^.*:/, "");
|
|
6017
|
+
}
|
|
5931
6018
|
function cloneRunPiece(xmlDoc, sourceNode, text, asDeletedText) {
|
|
5932
6019
|
if (asDeletedText) {
|
|
5933
6020
|
const delText = createWordElement(xmlDoc, "w:delText");
|
|
@@ -6003,8 +6090,7 @@ function processDelete(xmlDoc, spanIndex, startPos, endPos, author, generateRedl
|
|
|
6003
6090
|
if (!spansByRun.has(span.runElement)) spansByRun.set(span.runElement, []);
|
|
6004
6091
|
spansByRun.get(span.runElement).push(span);
|
|
6005
6092
|
});
|
|
6006
|
-
|
|
6007
|
-
let usedDelMetadata = false;
|
|
6093
|
+
const records = [];
|
|
6008
6094
|
spansByRun.forEach((runSpans, runElement) => {
|
|
6009
6095
|
const parent = runElement.parentNode;
|
|
6010
6096
|
if (!parent) return;
|
|
@@ -6022,24 +6108,84 @@ function processDelete(xmlDoc, spanIndex, startPos, endPos, author, generateRedl
|
|
|
6022
6108
|
deleteEnd = Math.max(deleteEnd, piece.start + spanDeleteEnd);
|
|
6023
6109
|
});
|
|
6024
6110
|
if (!Number.isFinite(deleteStart) || deleteEnd <= deleteStart) return;
|
|
6025
|
-
|
|
6026
|
-
|
|
6027
|
-
|
|
6028
|
-
|
|
6029
|
-
|
|
6030
|
-
|
|
6031
|
-
|
|
6111
|
+
records.push({
|
|
6112
|
+
runElement,
|
|
6113
|
+
parent,
|
|
6114
|
+
rPr: runSpans[0].rPr,
|
|
6115
|
+
beforePieces: sliceRunPieces(xmlDoc, pieces, 0, deleteStart, false),
|
|
6116
|
+
deletedPieces: sliceRunPieces(xmlDoc, pieces, deleteStart, deleteEnd, true),
|
|
6117
|
+
afterPieces: sliceRunPieces(xmlDoc, pieces, deleteEnd, getRunTextLength(pieces), false),
|
|
6118
|
+
globalStart: Math.max(startPos, Math.min(...runSpans.map((span) => span.charStart))),
|
|
6119
|
+
globalEnd: Math.min(endPos, Math.max(...runSpans.map((span) => span.charEnd))),
|
|
6120
|
+
carrierGlobalStart: isWordElement(parent, "ins") ? getCarrierGlobalStart(spanIndex, parent) : null
|
|
6121
|
+
});
|
|
6122
|
+
});
|
|
6123
|
+
const groups = [];
|
|
6124
|
+
for (const record of records) {
|
|
6125
|
+
const previousGroup = groups[groups.length - 1];
|
|
6126
|
+
const previousRecord = previousGroup?.[previousGroup.length - 1];
|
|
6127
|
+
if (previousRecord && previousRecord.parent === record.parent && nextElementSibling(previousRecord.runElement) === record.runElement) {
|
|
6128
|
+
previousGroup.push(record);
|
|
6129
|
+
} else {
|
|
6130
|
+
groups.push([record]);
|
|
6131
|
+
}
|
|
6132
|
+
}
|
|
6133
|
+
let changed = false;
|
|
6134
|
+
let usedDelMetadata = false;
|
|
6135
|
+
for (const group of groups) {
|
|
6136
|
+
const firstRecord = group[0];
|
|
6137
|
+
let delWrapper = null;
|
|
6138
|
+
if (generateRedlines && group.some((record) => record.deletedPieces.length > 0)) {
|
|
6139
|
+
const metadata = revisionMetadata ? usedDelMetadata ? { ...revisionMetadata, id: createRevisionMetadata(author, xmlDoc, "del").id } : revisionMetadata : null;
|
|
6032
6140
|
usedDelMetadata = true;
|
|
6033
|
-
|
|
6034
|
-
|
|
6141
|
+
delWrapper = createTrackChange(xmlDoc, "del", null, author, metadata);
|
|
6142
|
+
}
|
|
6143
|
+
for (const record of group) {
|
|
6144
|
+
const { parent, runElement } = record;
|
|
6145
|
+
insertRunPiecesBefore(xmlDoc, parent, runElement, record.beforePieces, record.rPr);
|
|
6146
|
+
if (delWrapper && record === firstRecord) {
|
|
6147
|
+
parent.insertBefore(delWrapper, runElement);
|
|
6148
|
+
}
|
|
6149
|
+
if (delWrapper && record.deletedPieces.length > 0) {
|
|
6150
|
+
delWrapper.appendChild(createRunFromPieces(xmlDoc, record.deletedPieces, record.rPr));
|
|
6151
|
+
}
|
|
6152
|
+
insertRunPiecesBefore(xmlDoc, parent, runElement, record.afterPieces, record.rPr);
|
|
6153
|
+
parent.removeChild(runElement);
|
|
6154
|
+
changed = true;
|
|
6155
|
+
}
|
|
6156
|
+
const carrier = isWordElement(firstRecord.parent, "ins") ? firstRecord.parent : null;
|
|
6157
|
+
const groupEnd = Math.max(...group.map((record) => record.globalEnd));
|
|
6158
|
+
if (carrier && groupEnd === endPos) {
|
|
6159
|
+
const carrierStart = firstRecord.carrierGlobalStart;
|
|
6160
|
+
const deletedBeforeEnd = group.filter((record) => record.globalStart < endPos).reduce((sum, record) => sum + record.deletedPieces.reduce((n, piece) => n + (piece.textContent || "").length, 0), 0);
|
|
6161
|
+
const currentOffset = Math.max(0, endPos - carrierStart - deletedBeforeEnd);
|
|
6162
|
+
if (!spanIndex.revisionInsertionAnchors) spanIndex.revisionInsertionAnchors = /* @__PURE__ */ new Map();
|
|
6163
|
+
spanIndex.revisionInsertionAnchors.set(endPos, {
|
|
6164
|
+
carrier,
|
|
6165
|
+
splitOffset: currentOffset,
|
|
6166
|
+
rPr: firstRecord.rPr
|
|
6167
|
+
});
|
|
6035
6168
|
}
|
|
6036
|
-
|
|
6037
|
-
parent.removeChild(runElement);
|
|
6038
|
-
changed = true;
|
|
6039
|
-
});
|
|
6169
|
+
}
|
|
6040
6170
|
return changed;
|
|
6041
6171
|
}
|
|
6042
|
-
function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], insertOffset = 0, generateRedlines = true, fallbackParagraph = null, revisionMetadata = null, affinity = null) {
|
|
6172
|
+
function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], insertOffset = 0, generateRedlines = true, fallbackParagraph = null, revisionMetadata = null, affinity = null, existingRevisions = "merge-same-author") {
|
|
6173
|
+
const mutationAnchor = spanIndex.revisionInsertionAnchors?.get(pos) || null;
|
|
6174
|
+
if (mutationAnchor && existingRevisions === "slice-cross-author" && isConnected(mutationAnchor.carrier) && isForeignInsertion(mutationAnchor.carrier, author)) {
|
|
6175
|
+
spanIndex.revisionInsertionAnchors.delete(pos);
|
|
6176
|
+
return spliceInsertionAtCarrierOffset(
|
|
6177
|
+
xmlDoc,
|
|
6178
|
+
mutationAnchor.carrier,
|
|
6179
|
+
mutationAnchor.splitOffset,
|
|
6180
|
+
text,
|
|
6181
|
+
mutationAnchor.rPr,
|
|
6182
|
+
author,
|
|
6183
|
+
formatHints,
|
|
6184
|
+
insertOffset,
|
|
6185
|
+
generateRedlines,
|
|
6186
|
+
revisionMetadata
|
|
6187
|
+
);
|
|
6188
|
+
}
|
|
6043
6189
|
if (!affinity) {
|
|
6044
6190
|
let targetSpan = findContainingSpan(spanIndex, pos);
|
|
6045
6191
|
if (!targetSpan && pos > 0) {
|
|
@@ -6062,6 +6208,20 @@ function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], i
|
|
|
6062
6208
|
insertTextRuns(xmlDoc, fallbackParagraph, null, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
|
|
6063
6209
|
return true;
|
|
6064
6210
|
}
|
|
6211
|
+
if (generateRedlines && existingRevisions === "slice-cross-author" && isForeignInsertion(parent2, author)) {
|
|
6212
|
+
return spliceInsertionAtCarrierOffset(
|
|
6213
|
+
xmlDoc,
|
|
6214
|
+
parent2,
|
|
6215
|
+
getCarrierSplitOffset(spanIndex, parent2, pos),
|
|
6216
|
+
text,
|
|
6217
|
+
targetSpan.rPr,
|
|
6218
|
+
author,
|
|
6219
|
+
formatHints,
|
|
6220
|
+
insertOffset,
|
|
6221
|
+
generateRedlines,
|
|
6222
|
+
revisionMetadata
|
|
6223
|
+
);
|
|
6224
|
+
}
|
|
6065
6225
|
const pieces = getRunContentPieces(targetSpan.runElement);
|
|
6066
6226
|
const targetPiece = pieces.find((piece) => piece.node === targetSpan.textElement);
|
|
6067
6227
|
const localInsertPos = targetPiece ? targetPiece.start + Math.max(0, Math.min(pos - targetSpan.charStart, targetSpan.charEnd - targetSpan.charStart)) : pos <= targetSpan.charStart ? 0 : getRunTextLength(pieces);
|
|
@@ -6224,9 +6384,73 @@ function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], i
|
|
|
6224
6384
|
return true;
|
|
6225
6385
|
}
|
|
6226
6386
|
}
|
|
6387
|
+
if (generateRedlines && existingRevisions === "slice-cross-author" && isForeignInsertion(parent, author)) {
|
|
6388
|
+
return spliceInsertionAtCarrierOffset(
|
|
6389
|
+
xmlDoc,
|
|
6390
|
+
parent,
|
|
6391
|
+
getCarrierSplitOffset(spanIndex, parent, pos),
|
|
6392
|
+
text,
|
|
6393
|
+
baseRPr,
|
|
6394
|
+
author,
|
|
6395
|
+
formatHints,
|
|
6396
|
+
insertOffset,
|
|
6397
|
+
generateRedlines,
|
|
6398
|
+
revisionMetadata
|
|
6399
|
+
);
|
|
6400
|
+
}
|
|
6227
6401
|
insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
|
|
6228
6402
|
return true;
|
|
6229
6403
|
}
|
|
6404
|
+
function spliceInsertionAtCarrierOffset(xmlDoc, carrier, splitOffset, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata) {
|
|
6405
|
+
const parent = carrier.parentNode;
|
|
6406
|
+
if (!parent) return false;
|
|
6407
|
+
const { leftCarrier, rightCarrier } = splitTrackChangeCarrier(xmlDoc, carrier, splitOffset);
|
|
6408
|
+
if (leftCarrier) parent.insertBefore(leftCarrier, carrier);
|
|
6409
|
+
insertTextRuns(
|
|
6410
|
+
xmlDoc,
|
|
6411
|
+
parent,
|
|
6412
|
+
carrier,
|
|
6413
|
+
text,
|
|
6414
|
+
withoutRunPropertyChanges(baseRPr),
|
|
6415
|
+
author,
|
|
6416
|
+
formatHints,
|
|
6417
|
+
insertOffset,
|
|
6418
|
+
generateRedlines,
|
|
6419
|
+
revisionMetadata
|
|
6420
|
+
);
|
|
6421
|
+
if (rightCarrier) parent.insertBefore(rightCarrier, carrier);
|
|
6422
|
+
parent.removeChild(carrier);
|
|
6423
|
+
return true;
|
|
6424
|
+
}
|
|
6425
|
+
function withoutRunPropertyChanges(rPr) {
|
|
6426
|
+
if (!rPr) return null;
|
|
6427
|
+
const clone = rPr.cloneNode(true);
|
|
6428
|
+
const changes = Array.from(clone.getElementsByTagName?.("*") || []).filter((node) => isWordElement(node, "rPrChange"));
|
|
6429
|
+
changes.forEach((node) => node.parentNode?.removeChild(node));
|
|
6430
|
+
return clone;
|
|
6431
|
+
}
|
|
6432
|
+
function getCarrierSplitOffset(spanIndex, carrier, pos) {
|
|
6433
|
+
const carrierStart = getCarrierGlobalStart(spanIndex, carrier);
|
|
6434
|
+
const carrierLength = spanIndex.spans.filter((span) => span.runElement?.parentNode === carrier).reduce((length, span) => length + (span.charEnd - span.charStart), 0);
|
|
6435
|
+
return Math.max(0, Math.min(pos - carrierStart, carrierLength));
|
|
6436
|
+
}
|
|
6437
|
+
function getCarrierGlobalStart(spanIndex, carrier) {
|
|
6438
|
+
const carrierSpans = spanIndex.spans.filter((span) => span.runElement?.parentNode === carrier);
|
|
6439
|
+
return carrierSpans.length > 0 ? Math.min(...carrierSpans.map((span) => span.charStart)) : 0;
|
|
6440
|
+
}
|
|
6441
|
+
function isForeignInsertion(node, author) {
|
|
6442
|
+
if (!isWordElement(node, "ins")) return false;
|
|
6443
|
+
const carrierAuthor = node.getAttribute("w:author") || node.getAttributeNS?.(NS_W, "author") || "";
|
|
6444
|
+
return carrierAuthor.trim().toLowerCase() !== String(author || "").trim().toLowerCase();
|
|
6445
|
+
}
|
|
6446
|
+
function nextElementSibling(node) {
|
|
6447
|
+
let sibling = node?.nextSibling || null;
|
|
6448
|
+
while (sibling && sibling.nodeType !== 1) sibling = sibling.nextSibling;
|
|
6449
|
+
return sibling;
|
|
6450
|
+
}
|
|
6451
|
+
function isConnected(node) {
|
|
6452
|
+
return !!node?.parentNode;
|
|
6453
|
+
}
|
|
6230
6454
|
function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata = null) {
|
|
6231
6455
|
const applicableHints = getApplicableFormatHints(formatHints, insertOffset, insertOffset + text.length);
|
|
6232
6456
|
if (applicableHints.length === 0) {
|
|
@@ -6250,7 +6474,7 @@ function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, fo
|
|
|
6250
6474
|
}
|
|
6251
6475
|
|
|
6252
6476
|
// engine/surgical-mode.js
|
|
6253
|
-
function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos) {
|
|
6477
|
+
function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos, allowInsertionCarrier = false) {
|
|
6254
6478
|
const spans = [];
|
|
6255
6479
|
forEachOverlappingSpan(spanIndex, startPos, endPos, (span) => spans.push(span));
|
|
6256
6480
|
if (spans.length === 0) return { safe: false };
|
|
@@ -6260,7 +6484,7 @@ function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos) {
|
|
|
6260
6484
|
const sameParent = spans.every((s) => s.runElement?.parentNode === parent);
|
|
6261
6485
|
if (!sameParent) return { safe: false, structuralBoundary: true };
|
|
6262
6486
|
const parentLocal = parent.localName || parent.nodeName.replace(/^.*:/, "");
|
|
6263
|
-
if (["hyperlink", "sdt", "
|
|
6487
|
+
if (["hyperlink", "sdt", "del", "moveFrom", "moveTo"].includes(parentLocal) || parentLocal === "ins" && !allowInsertionCarrier) {
|
|
6264
6488
|
return { safe: false, structuralBoundary: true };
|
|
6265
6489
|
}
|
|
6266
6490
|
const structuralTags = /* @__PURE__ */ new Set([
|
|
@@ -6353,7 +6577,12 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
|
|
|
6353
6577
|
const nextText = diffs[i + 1][1];
|
|
6354
6578
|
const textWithoutNewlines = nextText.replace(/\n/g, " ");
|
|
6355
6579
|
if (textWithoutNewlines.trim().length > 0) {
|
|
6356
|
-
const checkResult = checkSafeAdjacencyForPairing(
|
|
6580
|
+
const checkResult = checkSafeAdjacencyForPairing(
|
|
6581
|
+
spanIndex,
|
|
6582
|
+
originalPos,
|
|
6583
|
+
originalPos + text.length,
|
|
6584
|
+
options?.existingRevisions === "slice-cross-author"
|
|
6585
|
+
);
|
|
6357
6586
|
if (checkResult.safe) {
|
|
6358
6587
|
const event = createReplacementRevisionEvent(author, xmlDoc);
|
|
6359
6588
|
delMetadata = { id: event.deletionId, author: event.author, date: event.date };
|
|
@@ -6373,7 +6602,7 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
|
|
|
6373
6602
|
const [, nextText] = diffs[i];
|
|
6374
6603
|
const textWithoutNewlines = nextText.replace(/\n/g, " ");
|
|
6375
6604
|
if (textWithoutNewlines.trim().length > 0) {
|
|
6376
|
-
const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null);
|
|
6605
|
+
const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null, options?.existingRevisions || "merge-same-author");
|
|
6377
6606
|
if (insertResult && typeof insertResult === "object" && insertResult.error) {
|
|
6378
6607
|
return withOoxmlSourceType({
|
|
6379
6608
|
oxml: serializer.serializeToString(xmlDoc),
|
|
@@ -6391,7 +6620,7 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
|
|
|
6391
6620
|
} else if (op === 1) {
|
|
6392
6621
|
const textWithoutNewlines = text.replace(/\n/g, " ");
|
|
6393
6622
|
if (textWithoutNewlines.trim().length > 0) {
|
|
6394
|
-
const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null);
|
|
6623
|
+
const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null, options?.existingRevisions || "merge-same-author");
|
|
6395
6624
|
if (insertResult && typeof insertResult === "object" && insertResult.error) {
|
|
6396
6625
|
return withOoxmlSourceType({
|
|
6397
6626
|
oxml: serializer.serializeToString(xmlDoc),
|
|
@@ -7380,6 +7609,30 @@ function unwrapNode(node) {
|
|
|
7380
7609
|
parent.removeChild(node);
|
|
7381
7610
|
return true;
|
|
7382
7611
|
}
|
|
7612
|
+
function nextElementSibling2(node) {
|
|
7613
|
+
let sibling = node?.nextSibling || null;
|
|
7614
|
+
while (sibling && sibling.nodeType !== 1) sibling = sibling.nextSibling;
|
|
7615
|
+
return sibling;
|
|
7616
|
+
}
|
|
7617
|
+
function revisionMetadataWithoutId(node) {
|
|
7618
|
+
return Array.from(node?.attributes || []).filter((attribute) => String(attribute.localName || attribute.name || "").toLowerCase() !== "id").map((attribute) => `${attribute.namespaceURI || ""}|${attribute.localName || attribute.name}=${attribute.value}`).sort().join("\n");
|
|
7619
|
+
}
|
|
7620
|
+
function coalesceAdjacentCompatibleInsertions(xmlDoc) {
|
|
7621
|
+
let coalesced = 0;
|
|
7622
|
+
const insertions = getWordElementsByLocalName(xmlDoc, "ins");
|
|
7623
|
+
for (const insertion of insertions) {
|
|
7624
|
+
if (!insertion.parentNode || isParagraphMarkRevisionMarker(insertion)) continue;
|
|
7625
|
+
let sibling = nextElementSibling2(insertion);
|
|
7626
|
+
while (isWordElement5(sibling, "ins") && normalizeAuthor(getAttributeByLocalName(sibling, "author")) === normalizeAuthor(getAttributeByLocalName(insertion, "author")) && revisionMetadataWithoutId(sibling) === revisionMetadataWithoutId(insertion)) {
|
|
7627
|
+
const next = nextElementSibling2(sibling);
|
|
7628
|
+
while (sibling.firstChild) insertion.appendChild(sibling.firstChild);
|
|
7629
|
+
sibling.parentNode?.removeChild(sibling);
|
|
7630
|
+
coalesced += 1;
|
|
7631
|
+
sibling = next;
|
|
7632
|
+
}
|
|
7633
|
+
}
|
|
7634
|
+
return coalesced;
|
|
7635
|
+
}
|
|
7383
7636
|
function isTableRowRevisionMarker(node) {
|
|
7384
7637
|
const parent = node?.parentNode;
|
|
7385
7638
|
return isWordElement5(parent, "trPr") && isWordElement5(parent?.parentNode, "tr");
|
|
@@ -7627,6 +7880,7 @@ function rejectTrackedChangesInOoxml(oxml, options = {}) {
|
|
|
7627
7880
|
if (rejectPropertyChangeNode(changeNode, localName2)) rejectedCount += 1;
|
|
7628
7881
|
}
|
|
7629
7882
|
}
|
|
7883
|
+
coalesceAdjacentCompatibleInsertions(xmlDoc);
|
|
7630
7884
|
const serializedOxml = parseResult.isFragmentWrapped ? Array.from(xmlDoc.documentElement.childNodes).map((n) => serializer.serializeToString(n)).join("") : serializer.serializeToString(xmlDoc);
|
|
7631
7885
|
return {
|
|
7632
7886
|
oxml: serializedOxml,
|
|
@@ -7778,7 +8032,7 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
|
|
|
7778
8032
|
const finalize = (result) => {
|
|
7779
8033
|
const withStatus = { ...result };
|
|
7780
8034
|
if (normalizedExistingRevisions && withStatus.hasChanges === false && withStatus.status !== "error") {
|
|
7781
|
-
if (existingRevisionsPolicy === "merge-same-author") {
|
|
8035
|
+
if (existingRevisionsPolicy === "merge-same-author" || existingRevisionsPolicy === "slice-cross-author") {
|
|
7782
8036
|
withStatus.oxml = workingOoxml;
|
|
7783
8037
|
withStatus.hasChanges = true;
|
|
7784
8038
|
withStatus.warnings = [
|
|
@@ -7832,7 +8086,7 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
|
|
|
7832
8086
|
const revisionIdAllocator = options?._revisionIdAllocator instanceof RevisionIdAllocator ? options._revisionIdAllocator : new RevisionIdAllocator();
|
|
7833
8087
|
seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
|
|
7834
8088
|
if (containsTrackedChanges(xmlDoc)) {
|
|
7835
|
-
if (existingRevisionsPolicy === "merge-same-author") {
|
|
8089
|
+
if (existingRevisionsPolicy === "merge-same-author" || existingRevisionsPolicy === "slice-cross-author") {
|
|
7836
8090
|
const authors = getTrackedChangeAuthors(xmlDoc);
|
|
7837
8091
|
const currentAuthor = String(author || "").trim().toLowerCase();
|
|
7838
8092
|
const isSameAuthor = authors.length > 0 && authors.every((a) => a.trim().toLowerCase() === currentAuthor);
|
|
@@ -7887,7 +8141,7 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
|
|
|
7887
8141
|
const paragraphsInDoc = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || "").toLowerCase() === "p" ? [xmlDoc.documentElement] : getDocumentParagraphs(xmlDoc);
|
|
7888
8142
|
const baselineText = paragraphsInDoc.length > 0 ? paragraphsInDoc.map((p) => extractCanonicalParagraphText(p)).join("\n") : "";
|
|
7889
8143
|
originalText = baselineText;
|
|
7890
|
-
} else {
|
|
8144
|
+
} else if (existingRevisionsPolicy === "merge-same-author") {
|
|
7891
8145
|
log("[OxmlEngine] Existing revisions detected from another/unattributed author; refusing per merge-same-author policy");
|
|
7892
8146
|
return finalize({
|
|
7893
8147
|
oxml: inputOoxml,
|
|
@@ -7898,6 +8152,22 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
|
|
|
7898
8152
|
message: `Input OOXML contains tracked changes from another author (${authors.length ? authors.join(", ") : "unattributed"}). Pass existingRevisions: "accept-all-first" or resolve revisions first.`
|
|
7899
8153
|
}
|
|
7900
8154
|
});
|
|
8155
|
+
} else {
|
|
8156
|
+
const hasMoveRevision = ["moveFrom", "moveTo"].some((localName2) => {
|
|
8157
|
+
return getElementsByTagNSOrTag(xmlDoc, NS_W, localName2).length > 0;
|
|
8158
|
+
});
|
|
8159
|
+
if (hasMoveRevision) {
|
|
8160
|
+
return finalize({
|
|
8161
|
+
oxml: inputOoxml,
|
|
8162
|
+
hasChanges: false,
|
|
8163
|
+
status: "error",
|
|
8164
|
+
error: {
|
|
8165
|
+
code: "UNSAFE_REVISION_NESTING",
|
|
8166
|
+
message: "Cross-author slicing does not support pending move revisions."
|
|
8167
|
+
}
|
|
8168
|
+
});
|
|
8169
|
+
}
|
|
8170
|
+
log("[OxmlEngine] Existing revisions retained for cross-author surgical slicing");
|
|
7901
8171
|
}
|
|
7902
8172
|
} else if (existingRevisionsPolicy === "accept-all-first" || existingRevisionsPolicy === "accept-all-first-keep-normalized") {
|
|
7903
8173
|
log("[OxmlEngine] Existing revisions detected; accepting all input revisions before redlining");
|
|
@@ -8096,7 +8366,8 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
|
|
|
8096
8366
|
const isTargetList = isListTargetLoose(cleanModifiedText);
|
|
8097
8367
|
const isStructuredContent = options.structuredContent !== false && structuredAnalysis?.requiresStructuredContent === true;
|
|
8098
8368
|
const tableCellContext = initialTableCellContext;
|
|
8099
|
-
|
|
8369
|
+
const usesSurgicalTextMode = hasTables || existingRevisionsPolicy === "slice-cross-author";
|
|
8370
|
+
log(`[OxmlEngine] Mode: ${usesSurgicalTextMode ? "SURGICAL" : "RECONSTRUCTION"}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
|
|
8100
8371
|
try {
|
|
8101
8372
|
if (isMarkdownTable && !hasTables) {
|
|
8102
8373
|
recordRouteSelection(options, "table", { transformation: "text-to-table" });
|
|
@@ -8107,8 +8378,8 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
|
|
|
8107
8378
|
recordRouteSelection(options, "table", { transformation: "table-reconciliation" });
|
|
8108
8379
|
return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
|
|
8109
8380
|
}
|
|
8110
|
-
if (
|
|
8111
|
-
recordRouteSelection(options, "surgical", { tableScoped:
|
|
8381
|
+
if (usesSurgicalTextMode) {
|
|
8382
|
+
recordRouteSelection(options, "surgical", { tableScoped: hasTables });
|
|
8112
8383
|
const surgicalTarget = tableCellContext.hasTableWrapper && tableCellContext.targetParagraph ? tableCellContext.targetParagraph : null;
|
|
8113
8384
|
if (surgicalTarget) {
|
|
8114
8385
|
log("[OxmlEngine] Table cell edit: scoping surgical mode to target paragraph");
|
|
@@ -9611,11 +9882,15 @@ function validateRedlineOoxml(oxml) {
|
|
|
9611
9882
|
}
|
|
9612
9883
|
for (const revision of revisions) {
|
|
9613
9884
|
const nested = Array.from(revision.getElementsByTagName("*")).filter((el) => el !== revision && ["ins", "del"].includes(localNameOf2(el)));
|
|
9614
|
-
|
|
9885
|
+
const invalidNested = nested.find((candidate) => {
|
|
9886
|
+
if (localNameOf2(revision) !== "ins" || localNameOf2(candidate) !== "del") return true;
|
|
9887
|
+
return candidate.parentNode !== revision;
|
|
9888
|
+
});
|
|
9889
|
+
if (invalidNested) {
|
|
9615
9890
|
addIssue(
|
|
9616
9891
|
"NESTED_REVISION",
|
|
9617
9892
|
"error",
|
|
9618
|
-
`<${revision.nodeName}> (w:id="${wordAttribute2(revision, "id")}") contains nested <${
|
|
9893
|
+
`<${revision.nodeName}> (w:id="${wordAttribute2(revision, "id")}") contains invalid nested <${invalidNested.nodeName}>.`
|
|
9619
9894
|
);
|
|
9620
9895
|
}
|
|
9621
9896
|
}
|
|
@@ -10804,24 +11079,6 @@ function buildCommentsExtendedPartXml(entries) {
|
|
|
10804
11079
|
return `<w15:commentsEx xmlns:w15="${NS_W15}">${body}</w15:commentsEx>`;
|
|
10805
11080
|
}
|
|
10806
11081
|
|
|
10807
|
-
// core/revision-cloning.js
|
|
10808
|
-
function refreshRunPropertyChangeIds(root, allocator = null) {
|
|
10809
|
-
if (!root) return root;
|
|
10810
|
-
const xmlDoc = root.nodeType === 9 ? root : root.ownerDocument;
|
|
10811
|
-
const resolvedAllocator = allocator instanceof RevisionIdAllocator ? allocator : getRevisionIdAllocatorForDocument(xmlDoc) || createRevisionIdAllocator(xmlDoc);
|
|
10812
|
-
const candidates = [root, ...Array.from(root.getElementsByTagName?.("*") || [])];
|
|
10813
|
-
for (const node of candidates) {
|
|
10814
|
-
if (!isWordElement(node, "rPrChange")) continue;
|
|
10815
|
-
const nextId = String(resolvedAllocator.next());
|
|
10816
|
-
if (typeof node.setAttributeNS === "function") {
|
|
10817
|
-
node.setAttributeNS(NS_W, "w:id", nextId);
|
|
10818
|
-
} else {
|
|
10819
|
-
node.setAttribute("w:id", nextId);
|
|
10820
|
-
}
|
|
10821
|
-
}
|
|
10822
|
-
return root;
|
|
10823
|
-
}
|
|
10824
|
-
|
|
10825
11082
|
// services/comment-locator.js
|
|
10826
11083
|
function createParagraphTextIndex(paragraph, options = {}) {
|
|
10827
11084
|
const revisionView = options.revisionView === "current" ? "accepted" : options.revisionView || "accepted";
|