@ansonlai/docx-redline-js 0.5.3 → 0.6.0
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 +82 -667
- package/ARCHITECTURE.md +51 -4
- package/CHANGELOG.md +11 -0
- package/README.md +176 -39
- package/core/paragraph-revision-safety.js +10 -8
- package/core/paragraph-targeting.js +14 -2
- 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 +275 -45
- package/dist/docx-redline-js.esm.js.map +3 -3
- package/dist/docx-redline-js.esm.min.js +82 -82
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/AGENT_FAST_START.md +59 -0
- package/docs/AGENT_KNOWLEDGE_BASE.md +868 -0
- package/docs/TESTING.md +20 -1
- package/docs/schemas/document-operations.schema.json +16 -2
- package/docs/validation-reports/2026-09-12-agent-protocol-rollout.md +82 -0
- package/engine/oxml-engine.js +80 -13
- package/engine/run-builders.js +5 -15
- package/engine/surgical-mode.js +148 -3
- package/engine/surgical-run-splitting.js +19 -7
- package/engine/surgical-spans.js +2 -1
- package/index.d.ts +17 -1
- package/node/cli.js +235 -36
- package/node/docx-document.js +137 -83
- package/node/index.d.ts +6 -2
- package/package.json +10 -3
- package/pipeline/diff-engine.js +15 -0
- package/scripts/generate-cross-author-slicing-fixtures.ps1 +25 -25
- package/services/batch-operation-orchestrator.js +215 -120
- package/services/document-inspection.js +5 -3
- package/services/document-operation-applier.js +99 -36
- package/services/document-operation-contract.js +50 -6
- package/services/document-operation-mutations.js +404 -41
- package/services/document-operation-session.js +4 -0
- package/services/error-recovery.js +174 -0
- package/services/operation-batch-compiler.js +394 -0
- package/services/operation-preflight.js +91 -72
- package/services/standalone-operation-runner.d.ts +35 -1
- package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +0 -1669
- package/docs/plans/2026-09-08-cross-author-revision-slicing.md +0 -856
- package/docs/plans/completed/2026-03-01-release-0.1.4-design.md +0 -33
- package/docs/plans/completed/2026-03-01-release-0.1.4.md +0 -110
- package/docs/plans/completed/2026-05-31-architectural changes.md +0 -593
- package/docs/plans/completed/2026-08-02-reliability-improvements.md +0 -1155
- package/docs/plans/completed/2026-08-30-reliability-testing-improvements.md +0 -488
- package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +0 -669
- package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +0 -427
- package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +0 -519
- package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +0 -69
- package/docs/plans/completed/structural-revision-capability-matrix.md +0 -115
- package/docs/test-comparison-dashboard.html +0 -4338
- package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +0 -22
- package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +0 -24
- package/docs/validation-reports/2026-08-30-phase-3-coverage.md +0 -73
- package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +0 -82
- package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +0 -114
- package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +0 -79
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @ansonlai/docx-redline-js v0.
|
|
1
|
+
// @ansonlai/docx-redline-js v0.6.0 — 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]];
|
|
@@ -4886,6 +4890,47 @@ function extractFormattingFromOoxml(xmlDoc) {
|
|
|
4886
4890
|
return { existingFormatHints, textSpans, paragraphs };
|
|
4887
4891
|
}
|
|
4888
4892
|
|
|
4893
|
+
// core/revision-cloning.js
|
|
4894
|
+
var REVISION_HISTORY_ELEMENTS = /* @__PURE__ */ new Set([
|
|
4895
|
+
"ins",
|
|
4896
|
+
"del",
|
|
4897
|
+
"moveFrom",
|
|
4898
|
+
"moveTo",
|
|
4899
|
+
"pPrChange",
|
|
4900
|
+
"rPrChange",
|
|
4901
|
+
"tblPrChange",
|
|
4902
|
+
"trPrChange",
|
|
4903
|
+
"tcPrChange",
|
|
4904
|
+
"sectPrChange"
|
|
4905
|
+
]);
|
|
4906
|
+
function clonePropertiesWithoutRevisionHistory(root) {
|
|
4907
|
+
if (!root) return null;
|
|
4908
|
+
const clone = root.cloneNode(true);
|
|
4909
|
+
const candidates = [clone, ...Array.from(clone.getElementsByTagName?.("*") || [])];
|
|
4910
|
+
for (const node of candidates.reverse()) {
|
|
4911
|
+
if (!REVISION_HISTORY_ELEMENTS.has(String(node.localName || node.nodeName || "").replace(/^.*:/, ""))) continue;
|
|
4912
|
+
node.parentNode?.removeChild(node);
|
|
4913
|
+
}
|
|
4914
|
+
return clone;
|
|
4915
|
+
}
|
|
4916
|
+
function refreshRunPropertyChangeIds(root, allocator = null) {
|
|
4917
|
+
if (!root) return root;
|
|
4918
|
+
const xmlDoc = root.nodeType === 9 ? root : root.ownerDocument;
|
|
4919
|
+
const resolvedAllocator = allocator instanceof RevisionIdAllocator ? allocator : getRevisionIdAllocatorForDocument(xmlDoc) || createRevisionIdAllocator(xmlDoc);
|
|
4920
|
+
const candidates = [root, ...Array.from(root.getElementsByTagName?.("*") || [])];
|
|
4921
|
+
for (const node of candidates) {
|
|
4922
|
+
if (!isWordElement(node, "rPrChange")) continue;
|
|
4923
|
+
const nextId = String(resolvedAllocator.next());
|
|
4924
|
+
if (typeof node.setAttributeNS === "function") {
|
|
4925
|
+
node.setAttributeNS(NS_W, "w:id", nextId);
|
|
4926
|
+
} else {
|
|
4927
|
+
node.setAttribute("w:id", nextId);
|
|
4928
|
+
}
|
|
4929
|
+
resolvedAllocator._receiptCollector?.recordRevision(Number(nextId), "rPrChange");
|
|
4930
|
+
}
|
|
4931
|
+
return root;
|
|
4932
|
+
}
|
|
4933
|
+
|
|
4889
4934
|
// engine/run-builders.js
|
|
4890
4935
|
function createTrackChange(xmlDoc, type, run, author, revisionMetadata = null) {
|
|
4891
4936
|
const wrapper = createWordElement(xmlDoc, type === "ins" ? "w:ins" : "w:del");
|
|
@@ -5074,13 +5119,8 @@ function snapshotAndAttachRPrChange(xmlDoc, rPr, author, dateStr, sourceNode) {
|
|
|
5074
5119
|
rPrChange.setAttribute("w:id", String(metadata.id));
|
|
5075
5120
|
rPrChange.setAttribute("w:author", metadata.author);
|
|
5076
5121
|
rPrChange.setAttribute("w:date", dateStr || metadata.date);
|
|
5077
|
-
const previousRPr = createWordElement(xmlDoc, "w:rPr");
|
|
5078
5122
|
const source = sourceNode || rPr;
|
|
5079
|
-
|
|
5080
|
-
if (child.nodeName !== "w:rPrChange") {
|
|
5081
|
-
previousRPr.appendChild(child.cloneNode(true));
|
|
5082
|
-
}
|
|
5083
|
-
});
|
|
5123
|
+
const previousRPr = clonePropertiesWithoutRevisionHistory(source) || createWordElement(xmlDoc, "w:rPr");
|
|
5084
5124
|
rPrChange.appendChild(previousRPr);
|
|
5085
5125
|
const existing = getFirstElementByTag(rPr, "w:rPrChange");
|
|
5086
5126
|
if (existing) {
|
|
@@ -5713,7 +5753,7 @@ function getParagraphId(paragraph) {
|
|
|
5713
5753
|
|
|
5714
5754
|
// engine/surgical-spans.js
|
|
5715
5755
|
function getRunChildText(child) {
|
|
5716
|
-
if (isWordElement(child, "t")) return child.textContent || "";
|
|
5756
|
+
if (isWordElement(child, "t") || isWordElement(child, "delText")) return child.textContent || "";
|
|
5717
5757
|
if (isWordElement(child, "br") || isWordElement(child, "cr")) return "\n";
|
|
5718
5758
|
if (isWordElement(child, "tab")) return " ";
|
|
5719
5759
|
if (isWordElement(child, "noBreakHyphen")) return "\u2011";
|
|
@@ -5721,7 +5761,7 @@ function getRunChildText(child) {
|
|
|
5721
5761
|
return "";
|
|
5722
5762
|
}
|
|
5723
5763
|
function isTextLikeRunChild(child) {
|
|
5724
|
-
return isWordElement(child, "t") || isWordElement(child, "br") || isWordElement(child, "cr") || isWordElement(child, "tab") || isWordElement(child, "noBreakHyphen") || isWordElement(child, "softHyphen");
|
|
5764
|
+
return isWordElement(child, "t") || isWordElement(child, "delText") || isWordElement(child, "br") || isWordElement(child, "cr") || isWordElement(child, "tab") || isWordElement(child, "noBreakHyphen") || isWordElement(child, "softHyphen");
|
|
5725
5765
|
}
|
|
5726
5766
|
function buildSurgicalTextSpans(paragraphs) {
|
|
5727
5767
|
let fullText = "";
|
|
@@ -5890,26 +5930,8 @@ function describeInsertionBoundary(spanIndex, pos, fallbackParagraph = null) {
|
|
|
5890
5930
|
};
|
|
5891
5931
|
}
|
|
5892
5932
|
|
|
5893
|
-
// core/revision-cloning.js
|
|
5894
|
-
function refreshRunPropertyChangeIds(root, allocator = null) {
|
|
5895
|
-
if (!root) return root;
|
|
5896
|
-
const xmlDoc = root.nodeType === 9 ? root : root.ownerDocument;
|
|
5897
|
-
const resolvedAllocator = allocator instanceof RevisionIdAllocator ? allocator : getRevisionIdAllocatorForDocument(xmlDoc) || createRevisionIdAllocator(xmlDoc);
|
|
5898
|
-
const candidates = [root, ...Array.from(root.getElementsByTagName?.("*") || [])];
|
|
5899
|
-
for (const node of candidates) {
|
|
5900
|
-
if (!isWordElement(node, "rPrChange")) continue;
|
|
5901
|
-
const nextId = String(resolvedAllocator.next());
|
|
5902
|
-
if (typeof node.setAttributeNS === "function") {
|
|
5903
|
-
node.setAttributeNS(NS_W, "w:id", nextId);
|
|
5904
|
-
} else {
|
|
5905
|
-
node.setAttribute("w:id", nextId);
|
|
5906
|
-
}
|
|
5907
|
-
}
|
|
5908
|
-
return root;
|
|
5909
|
-
}
|
|
5910
|
-
|
|
5911
5933
|
// engine/surgical-run-splitting.js
|
|
5912
|
-
var TRACK_CHANGE_CARRIERS = /* @__PURE__ */ new Set(["ins"]);
|
|
5934
|
+
var TRACK_CHANGE_CARRIERS = /* @__PURE__ */ new Set(["ins", "del"]);
|
|
5913
5935
|
function getRunContentPieces(runElement) {
|
|
5914
5936
|
const pieces = [];
|
|
5915
5937
|
let offset = 0;
|
|
@@ -5961,7 +5983,7 @@ function insertRunPiecesBefore(xmlDoc, parent, referenceNode, pieces, rPr) {
|
|
|
5961
5983
|
function splitTrackChangeCarrier(xmlDoc, carrierElement, splitOffset, allocator = null) {
|
|
5962
5984
|
const carrierName = getLocalName(carrierElement);
|
|
5963
5985
|
if (!TRACK_CHANGE_CARRIERS.has(carrierName)) {
|
|
5964
|
-
throw new TypeError("splitTrackChangeCarrier requires a w:ins carrier.");
|
|
5986
|
+
throw new TypeError("splitTrackChangeCarrier requires a w:ins or w:del carrier.");
|
|
5965
5987
|
}
|
|
5966
5988
|
if (!Number.isInteger(splitOffset) || splitOffset < 0) {
|
|
5967
5989
|
throw new RangeError("splitOffset must be a non-negative integer.");
|
|
@@ -5998,16 +6020,17 @@ function splitTrackChangeCarrier(xmlDoc, carrierElement, splitOffset, allocator
|
|
|
5998
6020
|
} else {
|
|
5999
6021
|
const localOffset = splitOffset - offset;
|
|
6000
6022
|
const rPr = Array.from(child.childNodes || []).find((node) => isWordElement(node, "rPr")) || null;
|
|
6001
|
-
const
|
|
6002
|
-
const
|
|
6023
|
+
const asDeletedText = carrierName === "del";
|
|
6024
|
+
const leftPieces = sliceRunPieces(xmlDoc, pieces, 0, localOffset, asDeletedText);
|
|
6025
|
+
const rightPieces = sliceRunPieces(xmlDoc, pieces, localOffset, runLength, asDeletedText);
|
|
6003
6026
|
leftCarrier.appendChild(createRunFromPieces(xmlDoc, leftPieces, rPr));
|
|
6004
6027
|
const rightRun = createRunFromPieces(xmlDoc, rightPieces, rPr);
|
|
6005
|
-
refreshRunPropertyChangeIds(rightRun, resolveAllocator(xmlDoc, allocator));
|
|
6006
6028
|
rightCarrier.appendChild(rightRun);
|
|
6007
6029
|
}
|
|
6008
6030
|
offset = runEnd;
|
|
6009
6031
|
}
|
|
6010
6032
|
const resolvedAllocator = resolveAllocator(xmlDoc, allocator);
|
|
6033
|
+
refreshRunPropertyChangeIds(rightCarrier, resolvedAllocator);
|
|
6011
6034
|
const nextId = resolvedAllocator.next();
|
|
6012
6035
|
setWordAttribute(rightCarrier, "id", String(nextId));
|
|
6013
6036
|
resolvedAllocator._receiptCollector?.recordRevision(nextId, carrierName);
|
|
@@ -6027,7 +6050,7 @@ function getLocalName(element) {
|
|
|
6027
6050
|
return String(element?.localName || element?.nodeName || "").replace(/^.*:/, "");
|
|
6028
6051
|
}
|
|
6029
6052
|
function cloneRunPiece(xmlDoc, sourceNode, text, asDeletedText) {
|
|
6030
|
-
if (asDeletedText) {
|
|
6053
|
+
if (asDeletedText && (isWordElement(sourceNode, "delText") || isWordElement(sourceNode, "t"))) {
|
|
6031
6054
|
const delText = createWordElement(xmlDoc, "w:delText");
|
|
6032
6055
|
delText.setAttribute("xml:space", "preserve");
|
|
6033
6056
|
delText.textContent = text;
|
|
@@ -6050,6 +6073,15 @@ function cloneRunPiece(xmlDoc, sourceNode, text, asDeletedText) {
|
|
|
6050
6073
|
if (text === "\u2011" && isWordElement(sourceNode, "noBreakHyphen")) {
|
|
6051
6074
|
return sourceNode.cloneNode(true);
|
|
6052
6075
|
}
|
|
6076
|
+
if (text === "\xAD" && isWordElement(sourceNode, "softHyphen")) {
|
|
6077
|
+
return sourceNode.cloneNode(true);
|
|
6078
|
+
}
|
|
6079
|
+
if (asDeletedText) {
|
|
6080
|
+
const delText = createWordElement(xmlDoc, "w:delText");
|
|
6081
|
+
delText.setAttribute("xml:space", "preserve");
|
|
6082
|
+
delText.textContent = text;
|
|
6083
|
+
return delText;
|
|
6084
|
+
}
|
|
6053
6085
|
const textNode = createWordElement(xmlDoc, "w:t");
|
|
6054
6086
|
textNode.setAttribute("xml:space", "preserve");
|
|
6055
6087
|
textNode.textContent = text;
|
|
@@ -6584,7 +6616,10 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
|
|
|
6584
6616
|
const allParagraphs = targetParagraph ? [targetParagraph] : getDocumentParagraphs(xmlDoc);
|
|
6585
6617
|
const { fullText, textSpans } = buildSurgicalTextSpans(allParagraphs);
|
|
6586
6618
|
const insertionOnlyDiffs = options.existingRevisions === "slice-cross-author" ? computeInsertionOnlyDiffs(fullText, modifiedText) : null;
|
|
6587
|
-
const diffs = insertionOnlyDiffs ||
|
|
6619
|
+
const diffs = insertionOnlyDiffs || refineSpaceEquivalentReplacements(
|
|
6620
|
+
computeWordDiffs(fullText, modifiedText, diffOptions),
|
|
6621
|
+
diffOptions
|
|
6622
|
+
);
|
|
6588
6623
|
const spanIndex = buildSpanIndex(textSpans);
|
|
6589
6624
|
const pairReplacements = options.pairReplacements === true;
|
|
6590
6625
|
const warnings = [];
|
|
@@ -6622,6 +6657,82 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
|
|
|
6622
6657
|
}
|
|
6623
6658
|
if (insertResult === true) hasChanges = true;
|
|
6624
6659
|
}
|
|
6660
|
+
} else if (formatHints.length === 0) {
|
|
6661
|
+
const editOperations = collectTextEditOperations(diffs);
|
|
6662
|
+
for (const operation of editOperations.reverse()) {
|
|
6663
|
+
const liveSpanIndex = buildSpanIndex(buildSurgicalTextSpans(allParagraphs).textSpans);
|
|
6664
|
+
if (operation.type === "insert") {
|
|
6665
|
+
const inserted = processInsert(
|
|
6666
|
+
xmlDoc,
|
|
6667
|
+
liveSpanIndex,
|
|
6668
|
+
operation.start,
|
|
6669
|
+
operation.text.replace(/\n/g, " "),
|
|
6670
|
+
author,
|
|
6671
|
+
formatHints,
|
|
6672
|
+
operation.newPos,
|
|
6673
|
+
generateRedlines,
|
|
6674
|
+
allParagraphs[0] || null,
|
|
6675
|
+
null,
|
|
6676
|
+
options?.insertionAffinity || null,
|
|
6677
|
+
options?.existingRevisions || "merge-same-author"
|
|
6678
|
+
);
|
|
6679
|
+
if (inserted && typeof inserted === "object" && inserted.error) {
|
|
6680
|
+
return withOoxmlSourceType({
|
|
6681
|
+
oxml: serializer.serializeToString(xmlDoc),
|
|
6682
|
+
hasChanges: false,
|
|
6683
|
+
status: "error",
|
|
6684
|
+
error: inserted.error
|
|
6685
|
+
});
|
|
6686
|
+
}
|
|
6687
|
+
if (inserted === true) hasChanges = true;
|
|
6688
|
+
continue;
|
|
6689
|
+
}
|
|
6690
|
+
let delMetadata = null;
|
|
6691
|
+
let insMetadata = null;
|
|
6692
|
+
if (operation.type === "replace" && pairReplacements && generateRedlines && operation.text.replace(/\n/g, " ")) {
|
|
6693
|
+
const checkResult = checkSafeAdjacencyForPairing(
|
|
6694
|
+
liveSpanIndex,
|
|
6695
|
+
operation.start,
|
|
6696
|
+
operation.end,
|
|
6697
|
+
options?.existingRevisions === "slice-cross-author"
|
|
6698
|
+
);
|
|
6699
|
+
if (checkResult.safe) {
|
|
6700
|
+
const event = createReplacementRevisionEvent(author, xmlDoc);
|
|
6701
|
+
delMetadata = { id: event.deletionId, author: event.author, date: event.date };
|
|
6702
|
+
insMetadata = { id: event.insertionId, author: event.author, date: event.date };
|
|
6703
|
+
} else if (checkResult.structuralBoundary) {
|
|
6704
|
+
warnings.push("PAIRING_SKIPPED_STRUCTURAL_BOUNDARY");
|
|
6705
|
+
}
|
|
6706
|
+
}
|
|
6707
|
+
if (processDelete(xmlDoc, liveSpanIndex, operation.start, operation.end, author, generateRedlines, delMetadata)) {
|
|
6708
|
+
hasChanges = true;
|
|
6709
|
+
}
|
|
6710
|
+
if (operation.type === "replace") {
|
|
6711
|
+
const inserted = processInsert(
|
|
6712
|
+
xmlDoc,
|
|
6713
|
+
liveSpanIndex,
|
|
6714
|
+
operation.end,
|
|
6715
|
+
operation.text.replace(/\n/g, " "),
|
|
6716
|
+
author,
|
|
6717
|
+
formatHints,
|
|
6718
|
+
operation.newPos,
|
|
6719
|
+
generateRedlines,
|
|
6720
|
+
allParagraphs[0] || null,
|
|
6721
|
+
insMetadata,
|
|
6722
|
+
options?.insertionAffinity || null,
|
|
6723
|
+
options?.existingRevisions || "merge-same-author"
|
|
6724
|
+
);
|
|
6725
|
+
if (inserted && typeof inserted === "object" && inserted.error) {
|
|
6726
|
+
return withOoxmlSourceType({
|
|
6727
|
+
oxml: serializer.serializeToString(xmlDoc),
|
|
6728
|
+
hasChanges: false,
|
|
6729
|
+
status: "error",
|
|
6730
|
+
error: inserted.error
|
|
6731
|
+
});
|
|
6732
|
+
}
|
|
6733
|
+
if (inserted === true) hasChanges = true;
|
|
6734
|
+
}
|
|
6735
|
+
}
|
|
6625
6736
|
} else {
|
|
6626
6737
|
for (let i = 0; i < diffs.length; i++) {
|
|
6627
6738
|
const [op, text] = diffs[i];
|
|
@@ -6725,7 +6836,9 @@ function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, autho
|
|
|
6725
6836
|
message: "Generated OOXML accepted-view text does not match the requested modified text; the mutation was rejected.",
|
|
6726
6837
|
mismatchOffset,
|
|
6727
6838
|
expectedExcerpt: excerptAt(expectedText, mismatchOffset),
|
|
6728
|
-
actualExcerpt: excerptAt(actualText, mismatchOffset)
|
|
6839
|
+
actualExcerpt: excerptAt(actualText, mismatchOffset),
|
|
6840
|
+
expectedCodePoint: codePointAtOffset(expectedText, mismatchOffset),
|
|
6841
|
+
actualCodePoint: codePointAtOffset(actualText, mismatchOffset)
|
|
6729
6842
|
},
|
|
6730
6843
|
...warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {}
|
|
6731
6844
|
});
|
|
@@ -6748,6 +6861,59 @@ function excerptAt(text, offset, radius = 40) {
|
|
|
6748
6861
|
const end = Math.min(text.length, offset + radius);
|
|
6749
6862
|
return text.slice(start, end);
|
|
6750
6863
|
}
|
|
6864
|
+
function codePointAtOffset(text, offset) {
|
|
6865
|
+
if (offset >= text.length) return "END";
|
|
6866
|
+
return `U+${text.codePointAt(offset).toString(16).toUpperCase().padStart(4, "0")}`;
|
|
6867
|
+
}
|
|
6868
|
+
function refineSpaceEquivalentReplacements(diffs, diffOptions) {
|
|
6869
|
+
const refined = [];
|
|
6870
|
+
for (let index = 0; index < diffs.length; index++) {
|
|
6871
|
+
const [op, text] = diffs[index];
|
|
6872
|
+
const next = diffs[index + 1];
|
|
6873
|
+
if (op === -1 && next?.[0] === 1 && text !== next[1] && text.length === next[1].length && text.replace(/\u00a0/g, " ") === next[1].replace(/\u00a0/g, " ")) {
|
|
6874
|
+
refined.push(...computeCharacterDiffs(text, next[1], diffOptions));
|
|
6875
|
+
index++;
|
|
6876
|
+
continue;
|
|
6877
|
+
}
|
|
6878
|
+
refined.push([op, text]);
|
|
6879
|
+
}
|
|
6880
|
+
return refined;
|
|
6881
|
+
}
|
|
6882
|
+
function collectTextEditOperations(diffs) {
|
|
6883
|
+
const operations = [];
|
|
6884
|
+
let originalPos = 0;
|
|
6885
|
+
let newPos = 0;
|
|
6886
|
+
for (let index = 0; index < diffs.length; index++) {
|
|
6887
|
+
const [op, text] = diffs[index];
|
|
6888
|
+
if (op === 0) {
|
|
6889
|
+
originalPos += text.length;
|
|
6890
|
+
newPos += text.length;
|
|
6891
|
+
continue;
|
|
6892
|
+
}
|
|
6893
|
+
if (op === -1) {
|
|
6894
|
+
const next = diffs[index + 1];
|
|
6895
|
+
if (next?.[0] === 1) {
|
|
6896
|
+
operations.push({
|
|
6897
|
+
type: "replace",
|
|
6898
|
+
start: originalPos,
|
|
6899
|
+
end: originalPos + text.length,
|
|
6900
|
+
newPos,
|
|
6901
|
+
text: next[1]
|
|
6902
|
+
});
|
|
6903
|
+
originalPos += text.length;
|
|
6904
|
+
newPos += next[1].length;
|
|
6905
|
+
index++;
|
|
6906
|
+
} else {
|
|
6907
|
+
operations.push({ type: "delete", start: originalPos, end: originalPos + text.length, newPos, text: "" });
|
|
6908
|
+
originalPos += text.length;
|
|
6909
|
+
}
|
|
6910
|
+
continue;
|
|
6911
|
+
}
|
|
6912
|
+
operations.push({ type: "insert", start: originalPos, end: originalPos, newPos, text });
|
|
6913
|
+
newPos += text.length;
|
|
6914
|
+
}
|
|
6915
|
+
return operations;
|
|
6916
|
+
}
|
|
6751
6917
|
function collectInsertionOperations(diffs) {
|
|
6752
6918
|
const operations = [];
|
|
6753
6919
|
let originalPos = 0;
|
|
@@ -8266,6 +8432,51 @@ function getCommentIdsInOoxml(node) {
|
|
|
8266
8432
|
}
|
|
8267
8433
|
return [...ids].sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
|
|
8268
8434
|
}
|
|
8435
|
+
function directWordChild(node, localName2) {
|
|
8436
|
+
return Array.from(node?.childNodes || []).find((child) => child?.nodeType === 1 && child.namespaceURI === NS_W && child.localName === localName2) || null;
|
|
8437
|
+
}
|
|
8438
|
+
function insertedParagraphMarkMetadata(paragraph, author) {
|
|
8439
|
+
const marker = directWordChild(directWordChild(directWordChild(paragraph, "pPr"), "rPr"), "ins");
|
|
8440
|
+
if (!marker) return null;
|
|
8441
|
+
const markerAuthor = marker.getAttribute("w:author") || marker.getAttributeNS(NS_W, "author") || "";
|
|
8442
|
+
if (markerAuthor.trim().toLowerCase() !== String(author || "").trim().toLowerCase()) return null;
|
|
8443
|
+
return {
|
|
8444
|
+
id: marker.getAttribute("w:id") || marker.getAttributeNS(NS_W, "id") || "",
|
|
8445
|
+
author: markerAuthor,
|
|
8446
|
+
date: marker.getAttribute("w:date") || marker.getAttributeNS(NS_W, "date") || ""
|
|
8447
|
+
};
|
|
8448
|
+
}
|
|
8449
|
+
function emptyParagraphBaseline(paragraph, serializer) {
|
|
8450
|
+
const clone = paragraph.cloneNode(false);
|
|
8451
|
+
const pPr = directWordChild(paragraph, "pPr");
|
|
8452
|
+
if (pPr) clone.appendChild(clonePropertiesWithoutRevisionHistory(pPr));
|
|
8453
|
+
return serializer.serializeToString(clone);
|
|
8454
|
+
}
|
|
8455
|
+
function restoreInsertedParagraphMark(oxml, metadata) {
|
|
8456
|
+
if (!metadata || typeof oxml !== "string" || !oxml.trim()) return oxml;
|
|
8457
|
+
const parsed = parseOoxmlSafe(oxml, "text/xml");
|
|
8458
|
+
if (!parsed.doc || parsed.error) return oxml;
|
|
8459
|
+
const paragraph = parsed.doc.documentElement?.localName === "p" ? parsed.doc.documentElement : getDocumentParagraphs(parsed.doc)[0];
|
|
8460
|
+
if (!paragraph) return oxml;
|
|
8461
|
+
let pPr = directWordChild(paragraph, "pPr");
|
|
8462
|
+
if (!pPr) {
|
|
8463
|
+
pPr = createWordElement(parsed.doc, "w:pPr");
|
|
8464
|
+
paragraph.insertBefore(pPr, paragraph.firstChild);
|
|
8465
|
+
}
|
|
8466
|
+
let rPr = directWordChild(pPr, "rPr");
|
|
8467
|
+
if (!rPr) {
|
|
8468
|
+
rPr = createWordElement(parsed.doc, "w:rPr");
|
|
8469
|
+
pPr.appendChild(rPr);
|
|
8470
|
+
}
|
|
8471
|
+
if (!directWordChild(rPr, "ins")) {
|
|
8472
|
+
const marker = createWordElement(parsed.doc, "w:ins");
|
|
8473
|
+
if (metadata.id) marker.setAttribute("w:id", metadata.id);
|
|
8474
|
+
marker.setAttribute("w:author", metadata.author);
|
|
8475
|
+
if (metadata.date) marker.setAttribute("w:date", metadata.date);
|
|
8476
|
+
rPr.appendChild(marker);
|
|
8477
|
+
}
|
|
8478
|
+
return serializeXml(parsed.doc);
|
|
8479
|
+
}
|
|
8269
8480
|
async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}) {
|
|
8270
8481
|
const inputOoxml = oxml;
|
|
8271
8482
|
let workingOoxml = oxml;
|
|
@@ -8277,6 +8488,7 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
|
|
|
8277
8488
|
let parseWarnings = [];
|
|
8278
8489
|
const operationWarnings = [];
|
|
8279
8490
|
let normalizedExistingRevisions = false;
|
|
8491
|
+
let preservedInsertedParagraphMark = null;
|
|
8280
8492
|
const existingRevisionsPolicy = options.existingRevisions || "merge-same-author";
|
|
8281
8493
|
const keepNormalizedNoOp = existingRevisionsPolicy === "accept-all-first-keep-normalized";
|
|
8282
8494
|
const finalize = (result) => {
|
|
@@ -8307,6 +8519,9 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
|
|
|
8307
8519
|
if (!withStatus.status) {
|
|
8308
8520
|
withStatus.status = withStatus.hasChanges ? "ok" : "no-op";
|
|
8309
8521
|
}
|
|
8522
|
+
if (preservedInsertedParagraphMark && withStatus.hasChanges && typeof withStatus.oxml === "string") {
|
|
8523
|
+
withStatus.oxml = restoreInsertedParagraphMark(withStatus.oxml, preservedInsertedParagraphMark);
|
|
8524
|
+
}
|
|
8310
8525
|
return withOoxmlSourceType(withStatus);
|
|
8311
8526
|
};
|
|
8312
8527
|
const finalizeUnchanged = () => {
|
|
@@ -8372,9 +8587,11 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
|
|
|
8372
8587
|
});
|
|
8373
8588
|
}
|
|
8374
8589
|
log("[OxmlEngine] Existing revisions from same author detected; rejecting previous changes to merge against baseline");
|
|
8590
|
+
const soleParagraph = inputParagraphs.length === 1 ? inputParagraphs[0] : null;
|
|
8591
|
+
preservedInsertedParagraphMark = insertedParagraphMarkMetadata(soleParagraph, author);
|
|
8375
8592
|
const rejected = rejectTrackedChangesInOoxml(inputOoxml, { author });
|
|
8376
8593
|
if (rejected.status === "error") return finalize(rejected);
|
|
8377
|
-
workingOoxml = rejected.oxml;
|
|
8594
|
+
workingOoxml = preservedInsertedParagraphMark && !String(rejected.oxml || "").trim() ? emptyParagraphBaseline(soleParagraph, serializer) : rejected.oxml;
|
|
8378
8595
|
normalizedExistingRevisions = true;
|
|
8379
8596
|
const rejectedParsed = parseOoxmlSafe(workingOoxml, "text/xml");
|
|
8380
8597
|
parseWarnings.push(...rejectedParsed.warnings);
|
|
@@ -8416,7 +8633,9 @@ async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}
|
|
|
8416
8633
|
status: "error",
|
|
8417
8634
|
error: {
|
|
8418
8635
|
code: "EXISTING_REVISIONS",
|
|
8419
|
-
message: `Input OOXML contains tracked changes from another author (${authors.length ? authors.join(", ") : "unattributed"}).
|
|
8636
|
+
message: `Input OOXML contains tracked changes from another author (${authors.length ? authors.join(", ") : "unattributed"}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
|
|
8637
|
+
revisionAuthors: authors,
|
|
8638
|
+
currentPolicy: existingRevisionsPolicy
|
|
8420
8639
|
}
|
|
8421
8640
|
});
|
|
8422
8641
|
} else {
|
|
@@ -9024,9 +9243,12 @@ function resolveTargetParagraph(xmlDoc, options = {}) {
|
|
|
9024
9243
|
);
|
|
9025
9244
|
}
|
|
9026
9245
|
if (descriptor.fingerprint && descriptor.fingerprint !== actualFingerprint) {
|
|
9246
|
+
const alternateView = revisionView === "rejected" ? "accepted" : "rejected";
|
|
9247
|
+
const alternateFingerprint = createParagraphFingerprint(byId, { revisionView: alternateView });
|
|
9248
|
+
const viewHint = descriptor.fingerprint === alternateFingerprint ? ` The supplied fingerprint matches the ${alternateView} view; set target.revisionView to "${alternateView}" or use a fingerprint extracted from the ${revisionView} view.` : "";
|
|
9027
9249
|
throw createTargetError(
|
|
9028
9250
|
"TARGET_FINGERPRINT_MISMATCH",
|
|
9029
|
-
`Target paragraphId "${descriptor.paragraphId}" no longer matches its source fingerprint
|
|
9251
|
+
`Target paragraphId "${descriptor.paragraphId}" no longer matches its source fingerprint.${viewHint}`,
|
|
9030
9252
|
cachedEntry ? [serializeTargetCandidate(cachedEntry)] : null
|
|
9031
9253
|
);
|
|
9032
9254
|
}
|
|
@@ -9038,9 +9260,14 @@ function resolveTargetParagraph(xmlDoc, options = {}) {
|
|
|
9038
9260
|
);
|
|
9039
9261
|
}
|
|
9040
9262
|
if (cleanTargetText && actualText !== normalizeWhitespaceForTargeting(cleanTargetText)) {
|
|
9263
|
+
const alternateView = revisionView === "rejected" ? "accepted" : "rejected";
|
|
9264
|
+
const alternateText = normalizeWhitespaceForTargeting(
|
|
9265
|
+
extractCanonicalParagraphText(byId, { revisionView: alternateView })
|
|
9266
|
+
);
|
|
9267
|
+
const viewHint = alternateText === normalizeWhitespaceForTargeting(cleanTargetText) ? ` The supplied text matches the ${alternateView} view; set target.revisionView to "${alternateView}".` : "";
|
|
9041
9268
|
throw createTargetError(
|
|
9042
9269
|
"TARGET_TEXT_MISMATCH",
|
|
9043
|
-
`Target paragraphId "${descriptor.paragraphId}" no longer matches the supplied text
|
|
9270
|
+
`Target paragraphId "${descriptor.paragraphId}" no longer matches the supplied text.${viewHint}`,
|
|
9044
9271
|
cachedEntry ? [serializeTargetCandidate(cachedEntry)] : null
|
|
9045
9272
|
);
|
|
9046
9273
|
}
|
|
@@ -10140,11 +10367,12 @@ function parseOoxmlForValidation(oxml) {
|
|
|
10140
10367
|
function validateRedlineOoxml(oxml) {
|
|
10141
10368
|
const issues = [];
|
|
10142
10369
|
const addIssue = (code, severity, message) => issues.push({ code, severity, message });
|
|
10143
|
-
|
|
10144
|
-
|
|
10370
|
+
const isDomNode = oxml && typeof oxml === "object" && (oxml.nodeType === 9 || oxml.nodeType === 1);
|
|
10371
|
+
if (!isDomNode && (typeof oxml !== "string" || oxml.trim() === "")) {
|
|
10372
|
+
addIssue("PARSE_ERROR", "error", "Input is not non-empty OOXML or a parsed XML DOM.");
|
|
10145
10373
|
return { valid: false, issues };
|
|
10146
10374
|
}
|
|
10147
|
-
const { doc, error: error2 } = parseOoxmlForValidation(oxml);
|
|
10375
|
+
const { doc, error: error2 } = isDomNode ? { doc: oxml.nodeType === 9 ? oxml : oxml.ownerDocument } : parseOoxmlForValidation(oxml);
|
|
10148
10376
|
if (!doc) {
|
|
10149
10377
|
addIssue("PARSE_ERROR", "error", `OOXML does not parse as XML: ${error2}`);
|
|
10150
10378
|
return { valid: false, issues };
|
|
@@ -11253,9 +11481,10 @@ function inspectDocumentParts(parts, options = {}) {
|
|
|
11253
11481
|
const resolveNumbering = createNumberingResolver(numberingPart.doc);
|
|
11254
11482
|
let nearestHeading = null;
|
|
11255
11483
|
const paragraphNodes = getDocumentParagraphNodes(documentPart.doc);
|
|
11256
|
-
const
|
|
11484
|
+
const revisionView = options.revisionView === "rejected" ? "rejected" : "accepted";
|
|
11485
|
+
const commentAnchors = collectDocumentCommentAnchors(paragraphNodes, revisionView);
|
|
11257
11486
|
let paragraphs = paragraphNodes.map((paragraph, zeroIndex) => {
|
|
11258
|
-
const text = extractCanonicalParagraphText(paragraph, { revisionView
|
|
11487
|
+
const text = extractCanonicalParagraphText(paragraph, { revisionView });
|
|
11259
11488
|
const level = headingLevel(paragraph);
|
|
11260
11489
|
if (level) nearestHeading = { level, text };
|
|
11261
11490
|
const ids = [...new Set([...descendants(paragraph, "commentRangeStart"), ...descendants(paragraph, "commentReference")].map((node) => attr2(node, "id")).filter(Boolean))];
|
|
@@ -11272,7 +11501,8 @@ function inspectDocumentParts(parts, options = {}) {
|
|
|
11272
11501
|
index,
|
|
11273
11502
|
ref: `P${index}`,
|
|
11274
11503
|
paragraphId: getParagraphId2(paragraph),
|
|
11275
|
-
fingerprint: createParagraphFingerprint(paragraph),
|
|
11504
|
+
fingerprint: createParagraphFingerprint(paragraph, { text, index, revisionView }),
|
|
11505
|
+
revisionView,
|
|
11276
11506
|
text,
|
|
11277
11507
|
exactText: text,
|
|
11278
11508
|
excerpt: text.slice(0, options.excerptLength || 120),
|