@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.
@@ -1,4 +1,4 @@
1
- // @ansonlai/docx-redline-js v0.5.3 — https://github.com/AnsonLai/docx-redline-js
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 leftPieces = sliceRunPieces(xmlDoc, pieces, 0, localOffset, false);
6002
- const rightPieces = sliceRunPieces(xmlDoc, pieces, localOffset, runLength, false);
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 || computeWordDiffs(fullText, modifiedText, diffOptions);
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
- if (typeof oxml !== "string" || oxml.trim() === "") {
10144
- addIssue("PARSE_ERROR", "error", "Input is not a non-empty OOXML string.");
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 };