@ansonlai/docx-redline-js 0.5.3 → 0.5.4
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 +46 -12
- package/ARCHITECTURE.md +38 -3
- package/CHANGELOG.md +6 -0
- package/README.md +37 -10
- package/core/paragraph-revision-safety.js +10 -8
- package/core/redline-validation.js +7 -4
- package/core/revision-cloning.js +21 -0
- package/core/validation-delta.js +23 -0
- package/dist/docx-redline-js.esm.js +164 -14
- package/dist/docx-redline-js.esm.js.map +2 -2
- package/dist/docx-redline-js.esm.min.js +79 -79
- package/dist/docx-redline-js.esm.min.js.map +3 -3
- package/docs/plans/2026-09-08-cross-author-revision-slicing.md +580 -37
- package/docs/schemas/document-operations.schema.json +11 -1
- package/engine/surgical-mode.js +148 -3
- package/engine/surgical-run-splitting.js +19 -7
- package/engine/surgical-spans.js +2 -1
- package/node/cli.js +128 -13
- package/node/docx-document.js +17 -14
- package/package.json +1 -1
- package/pipeline/diff-engine.js +15 -0
- package/services/document-operation-applier.js +49 -4
- package/services/document-operation-contract.js +40 -0
- package/services/document-operation-mutations.js +353 -36
- package/services/operation-preflight.js +1 -1
- package/services/standalone-operation-runner.d.ts +18 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @ansonlai/docx-redline-js v0.5.
|
|
1
|
+
// @ansonlai/docx-redline-js v0.5.4 — 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;
|
|
@@ -2634,6 +2634,10 @@ function computeWordDiffs(originalText, newText, options = {}) {
|
|
|
2634
2634
|
}
|
|
2635
2635
|
return decodeBmpDiffs(charDiffs, wordArray);
|
|
2636
2636
|
}
|
|
2637
|
+
function computeCharacterDiffs(originalText, newText, options = {}) {
|
|
2638
|
+
if (originalText === newText) return [[0, originalText]];
|
|
2639
|
+
return createDiffEngine(options).diff_main(originalText, newText);
|
|
2640
|
+
}
|
|
2637
2641
|
function computeInsertionOnlyDiffs(originalText, newText) {
|
|
2638
2642
|
if (originalText === newText) return [[0, originalText]];
|
|
2639
2643
|
if (!originalText) return [[1, newText]];
|
|
@@ -5713,7 +5717,7 @@ function getParagraphId(paragraph) {
|
|
|
5713
5717
|
|
|
5714
5718
|
// engine/surgical-spans.js
|
|
5715
5719
|
function getRunChildText(child) {
|
|
5716
|
-
if (isWordElement(child, "t")) return child.textContent || "";
|
|
5720
|
+
if (isWordElement(child, "t") || isWordElement(child, "delText")) return child.textContent || "";
|
|
5717
5721
|
if (isWordElement(child, "br") || isWordElement(child, "cr")) return "\n";
|
|
5718
5722
|
if (isWordElement(child, "tab")) return " ";
|
|
5719
5723
|
if (isWordElement(child, "noBreakHyphen")) return "\u2011";
|
|
@@ -5721,7 +5725,7 @@ function getRunChildText(child) {
|
|
|
5721
5725
|
return "";
|
|
5722
5726
|
}
|
|
5723
5727
|
function isTextLikeRunChild(child) {
|
|
5724
|
-
return isWordElement(child, "t") || isWordElement(child, "br") || isWordElement(child, "cr") || isWordElement(child, "tab") || isWordElement(child, "noBreakHyphen") || isWordElement(child, "softHyphen");
|
|
5728
|
+
return isWordElement(child, "t") || isWordElement(child, "delText") || isWordElement(child, "br") || isWordElement(child, "cr") || isWordElement(child, "tab") || isWordElement(child, "noBreakHyphen") || isWordElement(child, "softHyphen");
|
|
5725
5729
|
}
|
|
5726
5730
|
function buildSurgicalTextSpans(paragraphs) {
|
|
5727
5731
|
let fullText = "";
|
|
@@ -5904,12 +5908,13 @@ function refreshRunPropertyChangeIds(root, allocator = null) {
|
|
|
5904
5908
|
} else {
|
|
5905
5909
|
node.setAttribute("w:id", nextId);
|
|
5906
5910
|
}
|
|
5911
|
+
resolvedAllocator._receiptCollector?.recordRevision(Number(nextId), "rPrChange");
|
|
5907
5912
|
}
|
|
5908
5913
|
return root;
|
|
5909
5914
|
}
|
|
5910
5915
|
|
|
5911
5916
|
// engine/surgical-run-splitting.js
|
|
5912
|
-
var TRACK_CHANGE_CARRIERS = /* @__PURE__ */ new Set(["ins"]);
|
|
5917
|
+
var TRACK_CHANGE_CARRIERS = /* @__PURE__ */ new Set(["ins", "del"]);
|
|
5913
5918
|
function getRunContentPieces(runElement) {
|
|
5914
5919
|
const pieces = [];
|
|
5915
5920
|
let offset = 0;
|
|
@@ -5961,7 +5966,7 @@ function insertRunPiecesBefore(xmlDoc, parent, referenceNode, pieces, rPr) {
|
|
|
5961
5966
|
function splitTrackChangeCarrier(xmlDoc, carrierElement, splitOffset, allocator = null) {
|
|
5962
5967
|
const carrierName = getLocalName(carrierElement);
|
|
5963
5968
|
if (!TRACK_CHANGE_CARRIERS.has(carrierName)) {
|
|
5964
|
-
throw new TypeError("splitTrackChangeCarrier requires a w:ins carrier.");
|
|
5969
|
+
throw new TypeError("splitTrackChangeCarrier requires a w:ins or w:del carrier.");
|
|
5965
5970
|
}
|
|
5966
5971
|
if (!Number.isInteger(splitOffset) || splitOffset < 0) {
|
|
5967
5972
|
throw new RangeError("splitOffset must be a non-negative integer.");
|
|
@@ -5998,16 +6003,17 @@ function splitTrackChangeCarrier(xmlDoc, carrierElement, splitOffset, allocator
|
|
|
5998
6003
|
} else {
|
|
5999
6004
|
const localOffset = splitOffset - offset;
|
|
6000
6005
|
const rPr = Array.from(child.childNodes || []).find((node) => isWordElement(node, "rPr")) || null;
|
|
6001
|
-
const
|
|
6002
|
-
const
|
|
6006
|
+
const asDeletedText = carrierName === "del";
|
|
6007
|
+
const leftPieces = sliceRunPieces(xmlDoc, pieces, 0, localOffset, asDeletedText);
|
|
6008
|
+
const rightPieces = sliceRunPieces(xmlDoc, pieces, localOffset, runLength, asDeletedText);
|
|
6003
6009
|
leftCarrier.appendChild(createRunFromPieces(xmlDoc, leftPieces, rPr));
|
|
6004
6010
|
const rightRun = createRunFromPieces(xmlDoc, rightPieces, rPr);
|
|
6005
|
-
refreshRunPropertyChangeIds(rightRun, resolveAllocator(xmlDoc, allocator));
|
|
6006
6011
|
rightCarrier.appendChild(rightRun);
|
|
6007
6012
|
}
|
|
6008
6013
|
offset = runEnd;
|
|
6009
6014
|
}
|
|
6010
6015
|
const resolvedAllocator = resolveAllocator(xmlDoc, allocator);
|
|
6016
|
+
refreshRunPropertyChangeIds(rightCarrier, resolvedAllocator);
|
|
6011
6017
|
const nextId = resolvedAllocator.next();
|
|
6012
6018
|
setWordAttribute(rightCarrier, "id", String(nextId));
|
|
6013
6019
|
resolvedAllocator._receiptCollector?.recordRevision(nextId, carrierName);
|
|
@@ -6027,7 +6033,7 @@ function getLocalName(element) {
|
|
|
6027
6033
|
return String(element?.localName || element?.nodeName || "").replace(/^.*:/, "");
|
|
6028
6034
|
}
|
|
6029
6035
|
function cloneRunPiece(xmlDoc, sourceNode, text, asDeletedText) {
|
|
6030
|
-
if (asDeletedText) {
|
|
6036
|
+
if (asDeletedText && (isWordElement(sourceNode, "delText") || isWordElement(sourceNode, "t"))) {
|
|
6031
6037
|
const delText = createWordElement(xmlDoc, "w:delText");
|
|
6032
6038
|
delText.setAttribute("xml:space", "preserve");
|
|
6033
6039
|
delText.textContent = text;
|
|
@@ -6050,6 +6056,15 @@ function cloneRunPiece(xmlDoc, sourceNode, text, asDeletedText) {
|
|
|
6050
6056
|
if (text === "\u2011" && isWordElement(sourceNode, "noBreakHyphen")) {
|
|
6051
6057
|
return sourceNode.cloneNode(true);
|
|
6052
6058
|
}
|
|
6059
|
+
if (text === "\xAD" && isWordElement(sourceNode, "softHyphen")) {
|
|
6060
|
+
return sourceNode.cloneNode(true);
|
|
6061
|
+
}
|
|
6062
|
+
if (asDeletedText) {
|
|
6063
|
+
const delText = createWordElement(xmlDoc, "w:delText");
|
|
6064
|
+
delText.setAttribute("xml:space", "preserve");
|
|
6065
|
+
delText.textContent = text;
|
|
6066
|
+
return delText;
|
|
6067
|
+
}
|
|
6053
6068
|
const textNode = createWordElement(xmlDoc, "w:t");
|
|
6054
6069
|
textNode.setAttribute("xml:space", "preserve");
|
|
6055
6070
|
textNode.textContent = text;
|
|
@@ -6584,7 +6599,10 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
|
|
|
6584
6599
|
const allParagraphs = targetParagraph ? [targetParagraph] : getDocumentParagraphs(xmlDoc);
|
|
6585
6600
|
const { fullText, textSpans } = buildSurgicalTextSpans(allParagraphs);
|
|
6586
6601
|
const insertionOnlyDiffs = options.existingRevisions === "slice-cross-author" ? computeInsertionOnlyDiffs(fullText, modifiedText) : null;
|
|
6587
|
-
const diffs = insertionOnlyDiffs ||
|
|
6602
|
+
const diffs = insertionOnlyDiffs || refineSpaceEquivalentReplacements(
|
|
6603
|
+
computeWordDiffs(fullText, modifiedText, diffOptions),
|
|
6604
|
+
diffOptions
|
|
6605
|
+
);
|
|
6588
6606
|
const spanIndex = buildSpanIndex(textSpans);
|
|
6589
6607
|
const pairReplacements = options.pairReplacements === true;
|
|
6590
6608
|
const warnings = [];
|
|
@@ -6622,6 +6640,82 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
|
|
|
6622
6640
|
}
|
|
6623
6641
|
if (insertResult === true) hasChanges = true;
|
|
6624
6642
|
}
|
|
6643
|
+
} else if (formatHints.length === 0) {
|
|
6644
|
+
const editOperations = collectTextEditOperations(diffs);
|
|
6645
|
+
for (const operation of editOperations.reverse()) {
|
|
6646
|
+
const liveSpanIndex = buildSpanIndex(buildSurgicalTextSpans(allParagraphs).textSpans);
|
|
6647
|
+
if (operation.type === "insert") {
|
|
6648
|
+
const inserted = processInsert(
|
|
6649
|
+
xmlDoc,
|
|
6650
|
+
liveSpanIndex,
|
|
6651
|
+
operation.start,
|
|
6652
|
+
operation.text.replace(/\n/g, " "),
|
|
6653
|
+
author,
|
|
6654
|
+
formatHints,
|
|
6655
|
+
operation.newPos,
|
|
6656
|
+
generateRedlines,
|
|
6657
|
+
allParagraphs[0] || null,
|
|
6658
|
+
null,
|
|
6659
|
+
options?.insertionAffinity || null,
|
|
6660
|
+
options?.existingRevisions || "merge-same-author"
|
|
6661
|
+
);
|
|
6662
|
+
if (inserted && typeof inserted === "object" && inserted.error) {
|
|
6663
|
+
return withOoxmlSourceType({
|
|
6664
|
+
oxml: serializer.serializeToString(xmlDoc),
|
|
6665
|
+
hasChanges: false,
|
|
6666
|
+
status: "error",
|
|
6667
|
+
error: inserted.error
|
|
6668
|
+
});
|
|
6669
|
+
}
|
|
6670
|
+
if (inserted === true) hasChanges = true;
|
|
6671
|
+
continue;
|
|
6672
|
+
}
|
|
6673
|
+
let delMetadata = null;
|
|
6674
|
+
let insMetadata = null;
|
|
6675
|
+
if (operation.type === "replace" && pairReplacements && generateRedlines && operation.text.replace(/\n/g, " ")) {
|
|
6676
|
+
const checkResult = checkSafeAdjacencyForPairing(
|
|
6677
|
+
liveSpanIndex,
|
|
6678
|
+
operation.start,
|
|
6679
|
+
operation.end,
|
|
6680
|
+
options?.existingRevisions === "slice-cross-author"
|
|
6681
|
+
);
|
|
6682
|
+
if (checkResult.safe) {
|
|
6683
|
+
const event = createReplacementRevisionEvent(author, xmlDoc);
|
|
6684
|
+
delMetadata = { id: event.deletionId, author: event.author, date: event.date };
|
|
6685
|
+
insMetadata = { id: event.insertionId, author: event.author, date: event.date };
|
|
6686
|
+
} else if (checkResult.structuralBoundary) {
|
|
6687
|
+
warnings.push("PAIRING_SKIPPED_STRUCTURAL_BOUNDARY");
|
|
6688
|
+
}
|
|
6689
|
+
}
|
|
6690
|
+
if (processDelete(xmlDoc, liveSpanIndex, operation.start, operation.end, author, generateRedlines, delMetadata)) {
|
|
6691
|
+
hasChanges = true;
|
|
6692
|
+
}
|
|
6693
|
+
if (operation.type === "replace") {
|
|
6694
|
+
const inserted = processInsert(
|
|
6695
|
+
xmlDoc,
|
|
6696
|
+
liveSpanIndex,
|
|
6697
|
+
operation.end,
|
|
6698
|
+
operation.text.replace(/\n/g, " "),
|
|
6699
|
+
author,
|
|
6700
|
+
formatHints,
|
|
6701
|
+
operation.newPos,
|
|
6702
|
+
generateRedlines,
|
|
6703
|
+
allParagraphs[0] || null,
|
|
6704
|
+
insMetadata,
|
|
6705
|
+
options?.insertionAffinity || null,
|
|
6706
|
+
options?.existingRevisions || "merge-same-author"
|
|
6707
|
+
);
|
|
6708
|
+
if (inserted && typeof inserted === "object" && inserted.error) {
|
|
6709
|
+
return withOoxmlSourceType({
|
|
6710
|
+
oxml: serializer.serializeToString(xmlDoc),
|
|
6711
|
+
hasChanges: false,
|
|
6712
|
+
status: "error",
|
|
6713
|
+
error: inserted.error
|
|
6714
|
+
});
|
|
6715
|
+
}
|
|
6716
|
+
if (inserted === true) hasChanges = true;
|
|
6717
|
+
}
|
|
6718
|
+
}
|
|
6625
6719
|
} else {
|
|
6626
6720
|
for (let i = 0; i < diffs.length; i++) {
|
|
6627
6721
|
const [op, text] = diffs[i];
|
|
@@ -6725,7 +6819,9 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
|
|
|
6725
6819
|
message: "Generated OOXML accepted-view text does not match the requested modified text; the mutation was rejected.",
|
|
6726
6820
|
mismatchOffset,
|
|
6727
6821
|
expectedExcerpt: excerptAt(expectedText, mismatchOffset),
|
|
6728
|
-
actualExcerpt: excerptAt(actualText, mismatchOffset)
|
|
6822
|
+
actualExcerpt: excerptAt(actualText, mismatchOffset),
|
|
6823
|
+
expectedCodePoint: codePointAtOffset(expectedText, mismatchOffset),
|
|
6824
|
+
actualCodePoint: codePointAtOffset(actualText, mismatchOffset)
|
|
6729
6825
|
},
|
|
6730
6826
|
...warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {}
|
|
6731
6827
|
});
|
|
@@ -6748,6 +6844,59 @@ function excerptAt(text, offset, radius = 40) {
|
|
|
6748
6844
|
const end = Math.min(text.length, offset + radius);
|
|
6749
6845
|
return text.slice(start, end);
|
|
6750
6846
|
}
|
|
6847
|
+
function codePointAtOffset(text, offset) {
|
|
6848
|
+
if (offset >= text.length) return "END";
|
|
6849
|
+
return `U+${text.codePointAt(offset).toString(16).toUpperCase().padStart(4, "0")}`;
|
|
6850
|
+
}
|
|
6851
|
+
function refineSpaceEquivalentReplacements(diffs, diffOptions) {
|
|
6852
|
+
const refined = [];
|
|
6853
|
+
for (let index = 0; index < diffs.length; index++) {
|
|
6854
|
+
const [op, text] = diffs[index];
|
|
6855
|
+
const next = diffs[index + 1];
|
|
6856
|
+
if (op === -1 && next?.[0] === 1 && text !== next[1] && text.length === next[1].length && text.replace(/\u00a0/g, " ") === next[1].replace(/\u00a0/g, " ")) {
|
|
6857
|
+
refined.push(...computeCharacterDiffs(text, next[1], diffOptions));
|
|
6858
|
+
index++;
|
|
6859
|
+
continue;
|
|
6860
|
+
}
|
|
6861
|
+
refined.push([op, text]);
|
|
6862
|
+
}
|
|
6863
|
+
return refined;
|
|
6864
|
+
}
|
|
6865
|
+
function collectTextEditOperations(diffs) {
|
|
6866
|
+
const operations = [];
|
|
6867
|
+
let originalPos = 0;
|
|
6868
|
+
let newPos = 0;
|
|
6869
|
+
for (let index = 0; index < diffs.length; index++) {
|
|
6870
|
+
const [op, text] = diffs[index];
|
|
6871
|
+
if (op === 0) {
|
|
6872
|
+
originalPos += text.length;
|
|
6873
|
+
newPos += text.length;
|
|
6874
|
+
continue;
|
|
6875
|
+
}
|
|
6876
|
+
if (op === -1) {
|
|
6877
|
+
const next = diffs[index + 1];
|
|
6878
|
+
if (next?.[0] === 1) {
|
|
6879
|
+
operations.push({
|
|
6880
|
+
type: "replace",
|
|
6881
|
+
start: originalPos,
|
|
6882
|
+
end: originalPos + text.length,
|
|
6883
|
+
newPos,
|
|
6884
|
+
text: next[1]
|
|
6885
|
+
});
|
|
6886
|
+
originalPos += text.length;
|
|
6887
|
+
newPos += next[1].length;
|
|
6888
|
+
index++;
|
|
6889
|
+
} else {
|
|
6890
|
+
operations.push({ type: "delete", start: originalPos, end: originalPos + text.length, newPos, text: "" });
|
|
6891
|
+
originalPos += text.length;
|
|
6892
|
+
}
|
|
6893
|
+
continue;
|
|
6894
|
+
}
|
|
6895
|
+
operations.push({ type: "insert", start: originalPos, end: originalPos, newPos, text });
|
|
6896
|
+
newPos += text.length;
|
|
6897
|
+
}
|
|
6898
|
+
return operations;
|
|
6899
|
+
}
|
|
6751
6900
|
function collectInsertionOperations(diffs) {
|
|
6752
6901
|
const operations = [];
|
|
6753
6902
|
let originalPos = 0;
|
|
@@ -10140,11 +10289,12 @@ function parseOoxmlForValidation(oxml) {
|
|
|
10140
10289
|
function validateRedlineOoxml(oxml) {
|
|
10141
10290
|
const issues = [];
|
|
10142
10291
|
const addIssue = (code, severity, message) => issues.push({ code, severity, message });
|
|
10143
|
-
|
|
10144
|
-
|
|
10292
|
+
const isDomNode = oxml && typeof oxml === "object" && (oxml.nodeType === 9 || oxml.nodeType === 1);
|
|
10293
|
+
if (!isDomNode && (typeof oxml !== "string" || oxml.trim() === "")) {
|
|
10294
|
+
addIssue("PARSE_ERROR", "error", "Input is not non-empty OOXML or a parsed XML DOM.");
|
|
10145
10295
|
return { valid: false, issues };
|
|
10146
10296
|
}
|
|
10147
|
-
const { doc, error: error2 } = parseOoxmlForValidation(oxml);
|
|
10297
|
+
const { doc, error: error2 } = isDomNode ? { doc: oxml.nodeType === 9 ? oxml : oxml.ownerDocument } : parseOoxmlForValidation(oxml);
|
|
10148
10298
|
if (!doc) {
|
|
10149
10299
|
addIssue("PARSE_ERROR", "error", `OOXML does not parse as XML: ${error2}`);
|
|
10150
10300
|
return { valid: false, issues };
|