@adeu/core 1.19.0 → 1.20.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adeu/core",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/engine.ts CHANGED
@@ -357,9 +357,8 @@ export class RedlineEngine {
357
357
  let raw_sub_edits: any[] = [];
358
358
  try {
359
359
  raw_sub_edits = generate_edits_from_text(target_str, new_str);
360
- console.log("_word_diff_sub_edits RAW_SUB_EDITS:", JSON.stringify(raw_sub_edits, null, 2));
361
360
  } catch (e) {
362
- console.warn("generate_edits_from_text failed, falling back to wholesale edit", e);
361
+ console.error("generate_edits_from_text failed, falling back to wholesale edit", e);
363
362
  raw_sub_edits = [];
364
363
  }
365
364
 
@@ -3246,8 +3245,17 @@ export class RedlineEngine {
3246
3245
  const is_inline_first = result.first_node.tagName === "w:ins";
3247
3246
  if (is_inline_first) {
3248
3247
  if (anchor_run) {
3249
- const anchor_parent = anchor_run._element
3250
- .parentNode as Element | null;
3248
+ let anchor_el: Element = anchor_run._element;
3249
+ let anchor_parent = anchor_el.parentNode as Element | null;
3250
+ // A tracked-deleted anchor (run inside <w:del>) cannot host the
3251
+ // new <w:ins> as a child — an insertion nested inside a deletion
3252
+ // is invalid revision XML. Lift the anchor to the <w:del> wrapper
3253
+ // so the insert lands beside the whole block (mirrors the Python
3254
+ // engine).
3255
+ if (anchor_parent && anchor_parent.tagName === "w:del") {
3256
+ anchor_el = anchor_parent;
3257
+ anchor_parent = anchor_el.parentNode as Element | null;
3258
+ }
3251
3259
  // get_insertion_anchor(0) resolves to the document's FIRST run: the
3252
3260
  // insertion point precedes it, so the new <w:ins> must land before
3253
3261
  // the anchor, not after (mirrors the Python engine's insert_before
@@ -3260,14 +3268,14 @@ export class RedlineEngine {
3260
3268
  // Python engine).
3261
3269
  this._insert_and_split_ins(
3262
3270
  anchor_parent,
3263
- anchor_run._element,
3271
+ anchor_el,
3264
3272
  result.first_node,
3265
3273
  before_anchor,
3266
3274
  );
3267
3275
  } else if (before_anchor && anchor_parent) {
3268
- anchor_parent.insertBefore(result.first_node, anchor_run._element);
3276
+ anchor_parent.insertBefore(result.first_node, anchor_el);
3269
3277
  } else {
3270
- insertAfter(result.first_node, anchor_run._element);
3278
+ insertAfter(result.first_node, anchor_el);
3271
3279
  }
3272
3280
  } else if (anchor_para) {
3273
3281
  // Paragraph-anchored insertion: the anchor resolves to a paragraph
package/src/mapper.ts CHANGED
@@ -989,7 +989,27 @@ export class DocumentMapper {
989
989
  if (preceding[i].run) return [preceding[i].run, preceding[i].paragraph];
990
990
  }
991
991
  for (let i = preceding.length - 1; i >= 0; i--) {
992
- if (preceding[i].paragraph) return [null, preceding[i].paragraph];
992
+ const para = preceding[i].paragraph;
993
+ if (para) {
994
+ // Every span ending exactly here is virtual (CriticMarkup
995
+ // wrappers, {>>...<<} meta blocks, prefixes). If real text
996
+ // precedes this index in the SAME paragraph, anchor after its
997
+ // last run: falling back to the bare paragraph would drop the
998
+ // insertion at paragraph start, ahead of the very redlines and
999
+ // comment ranges that fence off the true position (mirrors the
1000
+ // Python mapper).
1001
+ for (let j = this.spans.length - 1; j >= 0; j--) {
1002
+ const prev = this.spans[j];
1003
+ if (
1004
+ prev.end <= index &&
1005
+ prev.run !== null &&
1006
+ prev.paragraph === para
1007
+ ) {
1008
+ return [prev.run, prev.paragraph];
1009
+ }
1010
+ }
1011
+ return [null, para];
1012
+ }
993
1013
  }
994
1014
  }
995
1015
 
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { DocumentObject } from "./docx/bridge.js";
6
+ import { RedlineEngine } from "./engine.js";
7
+ import { extractTextFromBuffer } from "./ingest.js";
8
+ import { createTestDocument, addParagraph } from "./test-utils.js";
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+
13
+ // Reproduction for the insertion-anchor bug (2026-07, golden.docx):
14
+ // ModifyText("document" -> "finalized document") is diff-minimized to a
15
+ // zero-width INSERTION of "finalized " immediately before "document". The
16
+ // target paragraph already carries tracked changes ({--initial --}
17
+ // {++golden ++}) and three comment ranges, so in the raw projection the
18
+ // insertion index sits right after a virtual {>>...<<} meta block.
19
+ // get_insertion_anchor only looked for a run-backed span ENDING exactly at
20
+ // that index; every span ending there is virtual, so it fell back to
21
+ // (null, paragraph) and the engine dropped the new <w:ins> at the START of
22
+ // the paragraph — "finalized This is the golden document" after accept-all.
23
+ // The fix anchors after the nearest preceding REAL run in the same
24
+ // paragraph (mirrors the Python engine/mapper fix).
25
+ describe("Zero-width insertion anchoring with preceding redlines/comments", () => {
26
+ it("lands the insertion before its target, not at paragraph start (golden.docx)", async () => {
27
+ const fixturePath = resolve(
28
+ __dirname,
29
+ "../../../../shared/fixtures/golden.docx",
30
+ );
31
+ const buf = readFileSync(fixturePath);
32
+ const doc = await DocumentObject.load(buf);
33
+ const engine = new RedlineEngine(doc, "T");
34
+
35
+ const stats = engine.process_batch([
36
+ { type: "modify", target_text: "document", new_text: "finalized document" },
37
+ ]);
38
+ expect(stats.edits_applied).toBe(1);
39
+
40
+ const out = await doc.save();
41
+ const clean = await extractTextFromBuffer(out, true);
42
+ expect(clean).toContain("golden finalized document");
43
+ expect(clean.startsWith("finalized This is")).toBe(false);
44
+
45
+ const red = await extractTextFromBuffer(out);
46
+ expect(red.startsWith("{++finalized")).toBe(false);
47
+ });
48
+
49
+ it("does not nest <w:ins> inside <w:del> when the anchor run is tracked-deleted", async () => {
50
+ const doc = await createTestDocument();
51
+ addParagraph(doc, "This is the initial document");
52
+
53
+ // Pass 1: track-delete "initial " so the raw projection becomes
54
+ // "This is the {--initial --}{>>[Chg:1 delete] T<<}document".
55
+ const engine1 = new RedlineEngine(doc, "T");
56
+ engine1.process_batch([
57
+ { type: "modify", target_text: "initial ", new_text: "" },
58
+ ]);
59
+ const deleted = await doc.save();
60
+
61
+ // Pass 2: zero-width insertion right before "document".
62
+ const doc2 = await DocumentObject.load(deleted);
63
+ const engine2 = new RedlineEngine(doc2, "T2");
64
+ engine2.process_batch([
65
+ { type: "modify", target_text: "document", new_text: "finalized document" },
66
+ ]);
67
+
68
+ const dels = Array.from(doc2.element.getElementsByTagName("w:del"));
69
+ for (const del of dels) {
70
+ expect(del.getElementsByTagName("w:ins").length).toBe(0);
71
+ }
72
+
73
+ const out = await doc2.save();
74
+ const clean = await extractTextFromBuffer(out, true);
75
+ expect(clean).toContain("This is the finalized document");
76
+ });
77
+
78
+ it("keeps genuine paragraph-start insertions at the paragraph start", async () => {
79
+ const doc = await createTestDocument();
80
+ addParagraph(doc, "Alpha beta gamma");
81
+ const engine = new RedlineEngine(doc, "T");
82
+
83
+ engine.process_batch([
84
+ { type: "modify", target_text: "Alpha", new_text: "Intro Alpha" },
85
+ ]);
86
+
87
+ const out = await doc.save();
88
+ const clean = await extractTextFromBuffer(out, true);
89
+ expect(clean).toContain("Intro Alpha beta gamma");
90
+ });
91
+ });