@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/dist/index.cjs +20 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +20 -7
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/engine.ts +15 -7
- package/src/mapper.ts +21 -1
- package/src/repro.insertion-anchor.test.ts +91 -0
package/package.json
CHANGED
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.
|
|
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
|
-
|
|
3250
|
-
|
|
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
|
-
|
|
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,
|
|
3276
|
+
anchor_parent.insertBefore(result.first_node, anchor_el);
|
|
3269
3277
|
} else {
|
|
3270
|
-
insertAfter(result.first_node,
|
|
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
|
-
|
|
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
|
+
});
|