@adeu/core 1.19.1 → 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 +19 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +19 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/engine.ts +14 -5
- package/src/mapper.ts +21 -1
- package/src/repro.insertion-anchor.test.ts +91 -0
package/package.json
CHANGED
package/src/engine.ts
CHANGED
|
@@ -3245,8 +3245,17 @@ export class RedlineEngine {
|
|
|
3245
3245
|
const is_inline_first = result.first_node.tagName === "w:ins";
|
|
3246
3246
|
if (is_inline_first) {
|
|
3247
3247
|
if (anchor_run) {
|
|
3248
|
-
|
|
3249
|
-
|
|
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
|
+
}
|
|
3250
3259
|
// get_insertion_anchor(0) resolves to the document's FIRST run: the
|
|
3251
3260
|
// insertion point precedes it, so the new <w:ins> must land before
|
|
3252
3261
|
// the anchor, not after (mirrors the Python engine's insert_before
|
|
@@ -3259,14 +3268,14 @@ export class RedlineEngine {
|
|
|
3259
3268
|
// Python engine).
|
|
3260
3269
|
this._insert_and_split_ins(
|
|
3261
3270
|
anchor_parent,
|
|
3262
|
-
|
|
3271
|
+
anchor_el,
|
|
3263
3272
|
result.first_node,
|
|
3264
3273
|
before_anchor,
|
|
3265
3274
|
);
|
|
3266
3275
|
} else if (before_anchor && anchor_parent) {
|
|
3267
|
-
anchor_parent.insertBefore(result.first_node,
|
|
3276
|
+
anchor_parent.insertBefore(result.first_node, anchor_el);
|
|
3268
3277
|
} else {
|
|
3269
|
-
insertAfter(result.first_node,
|
|
3278
|
+
insertAfter(result.first_node, anchor_el);
|
|
3270
3279
|
}
|
|
3271
3280
|
} else if (anchor_para) {
|
|
3272
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
|
+
});
|