@adeu/core 1.17.1 → 1.18.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/src/engine.ts CHANGED
@@ -65,6 +65,81 @@ function insertAtIndex(parent: Element, index: number, child: Node) {
65
65
  }
66
66
  }
67
67
 
68
+
69
+ function safeCloneEdit(val: any, seen: WeakMap<any, any> = new WeakMap()): any {
70
+ if (val === null || typeof val !== "object") {
71
+ return val;
72
+ }
73
+ if (seen.has(val)) {
74
+ return seen.get(val);
75
+ }
76
+ if (val.nodeType !== undefined || typeof val.cloneNode === "function") {
77
+ return val;
78
+ }
79
+ if (Array.isArray(val)) {
80
+ const copy: any[] = [];
81
+ seen.set(val, copy);
82
+ for (let i = 0; i < val.length; i++) {
83
+ copy.push(safeCloneEdit(val[i], seen));
84
+ }
85
+ return copy;
86
+ }
87
+ const copy: any = {};
88
+ seen.set(val, copy);
89
+ for (const key of Object.keys(val)) {
90
+ copy[key] = safeCloneEdit(val[key], seen);
91
+ }
92
+ return copy;
93
+ }
94
+
95
+ function takeSnapshot(doc: any): any {
96
+ const parts = [...doc.pkg.parts];
97
+ const unzipped = { ...doc.pkg.unzipped };
98
+ const rels = new Map<any, Map<string, any>>();
99
+ const elements = new Map<any, Element>();
100
+ for (const part of parts) {
101
+ rels.set(part, new Map(part.rels));
102
+ if (part._element) {
103
+ elements.set(part, part._element.cloneNode(true) as Element);
104
+ }
105
+ }
106
+ return { parts, unzipped, rels, elements };
107
+ }
108
+
109
+ function restoreSnapshot(doc: any, snapshot: any): void {
110
+ doc.pkg.parts = [...snapshot.parts];
111
+ for (const key of Object.keys(doc.pkg.unzipped)) {
112
+ delete doc.pkg.unzipped[key];
113
+ }
114
+ for (const [key, val] of Object.entries(snapshot.unzipped)) {
115
+ doc.pkg.unzipped[key] = val;
116
+ }
117
+ for (const part of snapshot.parts) {
118
+ part.rels = new Map(snapshot.rels.get(part)!);
119
+ const originalEl = snapshot.elements.get(part);
120
+ if (originalEl && part._element) {
121
+ const xmlDoc = part._element.ownerDocument;
122
+ if (xmlDoc && xmlDoc.documentElement) {
123
+ xmlDoc.replaceChild(originalEl, xmlDoc.documentElement);
124
+ }
125
+ part._element = originalEl;
126
+ }
127
+ }
128
+ }
129
+
130
+ function stripMatchingHeadingHashes(target: string, newText: string): [string, string] {
131
+ if (!target || !newText) return [target, newText];
132
+ const targetMatch = target.match(/^(#+)\s+/);
133
+ const newMatch = newText.match(/^(#+)\s+/);
134
+ if (targetMatch && newMatch && targetMatch[1] === newMatch[1]) {
135
+ const hashes = targetMatch[1];
136
+ const targetClean = target.substring(hashes.length).trimStart();
137
+ const newClean = newText.substring(hashes.length).trimStart();
138
+ return [targetClean, newClean];
139
+ }
140
+ return [target, newText];
141
+ }
142
+
68
143
  // --- Validation ---
69
144
  export class BatchValidationError extends Error {
70
145
  public errors: string[];
@@ -75,7 +150,7 @@ export class BatchValidationError extends Error {
75
150
  }
76
151
  }
77
152
 
78
- export function validate_edit_strings(edits: any[]): string[] {
153
+ export function validate_edit_strings(edits: any[], index_offset: number = 0): string[] {
79
154
  const errors: string[] = [];
80
155
 
81
156
  for (let i = 0; i < edits.length; i++) {
@@ -90,7 +165,7 @@ export function validate_edit_strings(edits: any[]): string[] {
90
165
  n_text.includes("{==")
91
166
  ) {
92
167
  errors.push(
93
- `- Edit ${i + 1} Failed: Do not manually write CriticMarkup tags ({++, {--, {>>, {==) in \`new_text\`. The engine handles redlining automatically. To add a comment, use the \`comment\` parameter.`,
168
+ `- Edit ${i + 1 + index_offset} Failed: Do not manually write CriticMarkup tags ({++, {--, {>>, {==) in \`new_text\`. The engine handles redlining automatically. To add a comment, use the \`comment\` parameter.`,
94
169
  );
95
170
  }
96
171
 
@@ -107,11 +182,11 @@ export function validate_edit_strings(edits: any[]): string[] {
107
182
  )
108
183
  ) {
109
184
  errors.push(
110
- `- Edit ${i + 1} Failed: Cannot insert footnote/endnote markers via text replace. Markers like \`[^fn-N]\` are read-only projections. Use Word's References menu.`,
185
+ `- Edit ${i + 1 + index_offset} Failed: Cannot insert footnote/endnote markers via text replace. Markers like \`[^fn-N]\` are read-only projections. Use Word's References menu.`,
111
186
  );
112
187
  } else {
113
188
  errors.push(
114
- `- Edit ${i + 1} Failed: Cannot delete footnote/endnote references via text replace. The marker corresponds to a structural XML element.`,
189
+ `- Edit ${i + 1 + index_offset} Failed: Cannot delete footnote/endnote references via text replace. The marker corresponds to a structural XML element.`,
115
190
  );
116
191
  }
117
192
  }
@@ -123,11 +198,11 @@ export function validate_edit_strings(edits: any[]): string[] {
123
198
  if (t_links.length !== n_links.length) {
124
199
  if (n_links.length > t_links.length) {
125
200
  errors.push(
126
- `- Edit ${i + 1} Failed: Cannot insert hyperlinks via text replace. Use a dedicated structural operation.`,
201
+ `- Edit ${i + 1 + index_offset} Failed: Cannot insert hyperlinks via text replace. Use a dedicated structural operation.`,
127
202
  );
128
203
  } else {
129
204
  errors.push(
130
- `- Edit ${i + 1} Failed: Cannot delete hyperlinks via text replace. The marker corresponds to a structural XML element.`,
205
+ `- Edit ${i + 1 + index_offset} Failed: Cannot delete hyperlinks via text replace. The marker corresponds to a structural XML element.`,
131
206
  );
132
207
  }
133
208
  } else if (
@@ -135,7 +210,7 @@ export function validate_edit_strings(edits: any[]): string[] {
135
210
  JSON.stringify(t_links) !== JSON.stringify(n_links)
136
211
  ) {
137
212
  errors.push(
138
- `- Edit ${i + 1} Failed: Can only edit or retarget one hyperlink per text replacement. Please split into multiple edits.`,
213
+ `- Edit ${i + 1 + index_offset} Failed: Can only edit or retarget one hyperlink per text replacement. Please split into multiple edits.`,
139
214
  );
140
215
  }
141
216
  }
@@ -146,18 +221,18 @@ export function validate_edit_strings(edits: any[]): string[] {
146
221
  if (t_xrefs.length !== n_xrefs.length) {
147
222
  if (n_xrefs.length > t_xrefs.length) {
148
223
  errors.push(
149
- `- Edit ${i + 1} Failed: Cannot insert cross-references via text replace. Markers are read-only projections.`,
224
+ `- Edit ${i + 1 + index_offset} Failed: Cannot insert cross-references via text replace. Markers are read-only projections.`,
150
225
  );
151
226
  } else {
152
227
  errors.push(
153
- `- Edit ${i + 1} Failed: Cannot delete cross-references via text replace. The marker corresponds to a structural XML element.`,
228
+ `- Edit ${i + 1 + index_offset} Failed: Cannot delete cross-references via text replace. The marker corresponds to a structural XML element.`,
154
229
  );
155
230
  }
156
231
  } else {
157
232
  // Advanced XREF validation simplified for port scope
158
233
  if (JSON.stringify(t_xrefs) !== JSON.stringify(n_xrefs)) {
159
234
  errors.push(
160
- `- Edit ${i + 1} Failed: Modifying or retargeting cross-reference markers is disallowed to prevent dependency corruption.`,
235
+ `- Edit ${i + 1 + index_offset} Failed: Modifying or retargeting cross-reference markers is disallowed to prevent dependency corruption.`,
161
236
  );
162
237
  }
163
238
  }
@@ -172,7 +247,7 @@ export function validate_edit_strings(edits: any[]): string[] {
172
247
  t_anchors.filter((x: string) => x === a).length
173
248
  ) {
174
249
  errors.push(
175
- `- Edit ${i + 1} Failed: Cannot modify or insert internal anchor markers (\`{#...}\`). These represent structural XML bookmarks.`,
250
+ `- Edit ${i + 1 + index_offset} Failed: Cannot modify or insert internal anchor markers (\`{#...}\`). These represent structural XML bookmarks.`,
176
251
  );
177
252
  break;
178
253
  }
@@ -190,7 +265,7 @@ export function validate_edit_strings(edits: any[]): string[] {
190
265
  stripped.substring(level) === ""
191
266
  ) {
192
267
  errors.push(
193
- `- Edit ${i + 1} Failed: Heading level ${level} is not supported (maximum is 6).`,
268
+ `- Edit ${i + 1 + index_offset} Failed: Heading level ${level} is not supported (maximum is 6).`,
194
269
  );
195
270
  break;
196
271
  }
@@ -202,10 +277,12 @@ export function validate_edit_strings(edits: any[]): string[] {
202
277
  t_text.includes("READONLY_BOUNDARY_START") ||
203
278
  n_text.includes("READONLY_BOUNDARY_START") ||
204
279
  t_text.includes("# Document Structure (Read-Only)") ||
205
- n_text.includes("# Document Structure (Read-Only)")
280
+ n_text.includes("# Document Structure (Read-Only)") ||
281
+ t_text.includes("Document Structure (Read-Only)") ||
282
+ n_text.includes("Document Structure (Read-Only)")
206
283
  ) {
207
284
  errors.push(
208
- `- Edit ${i + 1} Failed: Modification targets the read-only boundary (Structural Appendix). This section cannot be edited.`,
285
+ `- Edit ${i + 1 + index_offset} Failed: Modification targets the read-only boundary (Structural Appendix). This section cannot be edited.`,
209
286
  );
210
287
  }
211
288
  }
@@ -233,11 +310,7 @@ export class RedlineEngine {
233
310
  const w16du_ns =
234
311
  "http://schemas.microsoft.com/office/word/2023/wordml/word16du";
235
312
  for (const part of this.doc.pkg.parts) {
236
- if (
237
- part === this.doc.part ||
238
- (part.contentType.includes("wordprocessingml") &&
239
- part.contentType.endsWith("+xml"))
240
- ) {
313
+ if (part === this.doc.part) {
241
314
  if (!part._element.hasAttribute("xmlns:w16du")) {
242
315
  part._element.setAttribute("xmlns:w16du", w16du_ns);
243
316
  }
@@ -256,7 +329,51 @@ export class RedlineEngine {
256
329
  }
257
330
  return null;
258
331
  }
259
-
332
+ /**
333
+ * Best-effort "did you mean" hint for a failed target. The common loop trap
334
+ * (observed in the field) is an anchored regex like `^\( x \)$` against a
335
+ * mid-document string: ^/$ bind to the whole full_text, so it never matches
336
+ * even though the literal `( x )` is present. We strip regex anchoring/escapes
337
+ * and probe full_text for a literal occurrence; if found, we tell the model
338
+ * the exact literal that WOULD match so it drops the anchors instead of
339
+ * escalating the regex further.
340
+ */
341
+ private _nearest_match_hint(
342
+ target_text: string | undefined,
343
+ is_regex: boolean,
344
+ ): string {
345
+ if (!target_text) return "";
346
+ let probe = target_text;
347
+ if (is_regex) {
348
+ // Strip leading/trailing anchors and surrounding \s* the model tends to add.
349
+ probe = probe.replace(/^\^/, "").replace(/\$$/, "");
350
+ probe = probe.replace(/^\\s\*/, "").replace(/\\s\*$/, "");
351
+ // Unescape the common literal escapes so "\( x \)" -> "( x )".
352
+ probe = probe.replace(/\\([.^$*+?()[\]{}|\\/])/g, "$1");
353
+ }
354
+ probe = probe.trim();
355
+ if (!probe || probe === target_text) {
356
+ // No anchors to strip, or nothing changed: nothing useful to suggest.
357
+ if (!is_regex) return "";
358
+ }
359
+ const idx = this.mapper.full_text.indexOf(probe);
360
+ if (idx !== -1) {
361
+ const ctx_start = Math.max(0, idx - 15);
362
+ const ctx_end = Math.min(
363
+ this.mapper.full_text.length,
364
+ idx + probe.length + 15,
365
+ );
366
+ const ctx = this.mapper.full_text
367
+ .substring(ctx_start, ctx_end)
368
+ .replace(/\n/g, " ");
369
+ return (
370
+ `\n Did you mean the literal "${probe}"? It appears in the document` +
371
+ ` (…${ctx}…). If you used a regex, drop the ^/$ anchors — they match` +
372
+ ` the start/end of the entire document, not a line.`
373
+ );
374
+ }
375
+ return "";
376
+ }
260
377
  private _build_edit_context_previews(
261
378
  edit: any,
262
379
  ): [string | null, string | null] {
@@ -264,10 +381,23 @@ export class RedlineEngine {
264
381
  if (edit._resolved_proxy_edit) {
265
382
  edit = edit._resolved_proxy_edit;
266
383
  }
267
- const start_idx = edit._resolved_start_idx;
384
+ let start_idx = edit._resolved_start_idx;
268
385
  if (start_idx === undefined || start_idx === null) return [null, null];
269
- const target_text = edit.target_text || "";
270
- const new_text = edit.new_text || "";
386
+ let target_text = edit.target_text || "";
387
+ let new_text = edit.new_text || "";
388
+
389
+ const [clean_target, target_style] = this._parse_markdown_style(target_text);
390
+ if (target_style && target_style.startsWith("Heading")) {
391
+ const prefix_len = target_text.length - clean_target.length;
392
+ start_idx += prefix_len;
393
+ target_text = clean_target;
394
+ }
395
+
396
+ const [clean_new, new_style] = this._parse_markdown_style(new_text);
397
+ if (new_style && new_style.startsWith("Heading")) {
398
+ new_text = clean_new;
399
+ }
400
+
271
401
  const length = target_text.length;
272
402
  const active_mapper = edit._active_mapper_ref || this.mapper;
273
403
  const full_text = active_mapper.full_text;
@@ -303,7 +433,11 @@ export class RedlineEngine {
303
433
  return maxId;
304
434
  }
305
435
 
306
- private _get_heading_path_and_page(start_idx: number, text: string, page_offsets: number[]): [string, number] {
436
+ private _get_heading_path_and_page(
437
+ start_idx: number,
438
+ text: string,
439
+ page_offsets: number[],
440
+ ): [string, number] {
307
441
  let page = 1;
308
442
  for (let i = 0; i < page_offsets.length; i++) {
309
443
  if (start_idx >= page_offsets[i]) {
@@ -317,14 +451,17 @@ export class RedlineEngine {
317
451
  const lines = textBefore.split("\n");
318
452
  const path: string[] = [];
319
453
  let current_level = 999;
320
-
454
+
321
455
  for (let i = lines.length - 1; i >= 0; i--) {
322
456
  const line = lines[i];
323
457
  const m = line.match(/^(#{1,6})\s+(.*)/);
324
458
  if (m) {
325
459
  const level = m[1].length;
326
460
  if (level < current_level) {
327
- let cleanHeading = m[2].replace(/\*\*|__|[*_]/g, "").replace(/\{#[^}]+\}/g, "").trim();
461
+ let cleanHeading = m[2]
462
+ .replace(/\*\*|__|[*_]/g, "")
463
+ .replace(/\{#[^}]+\}/g, "")
464
+ .trim();
328
465
  if (cleanHeading.length > 80) {
329
466
  cleanHeading = cleanHeading.substring(0, 80) + "...";
330
467
  }
@@ -536,6 +673,106 @@ export class RedlineEngine {
536
673
  }
537
674
  }
538
675
 
676
+ /**
677
+ * Revert every tracked change, returning the document to the state it had
678
+ * before any revision was proposed. The exact inverse of
679
+ * accept_all_revisions:
680
+ *
681
+ * - <w:ins> -> removed together with all of its content (the proposed
682
+ * insertion never existed); an inserted row (<w:ins> in
683
+ * <w:trPr>) drops the whole row.
684
+ * - <w:del> -> unwrapped, restoring the original text (<w:delText> becomes
685
+ * <w:t> again); a row-deletion mark in <w:trPr> is removed so
686
+ * the row survives.
687
+ * - paragraph-mark <w:del> in pPr/rPr -> removed, undoing a proposed merge.
688
+ *
689
+ * Comments are annotations, not revisions, so standalone comments are left in
690
+ * place; only anchors stranded inside a rejected insertion are cleaned up.
691
+ *
692
+ * Insertions are reverted before deletions are restored so a deletion nested
693
+ * inside a foreign author's insertion is removed wholesale with the insertion
694
+ * — the contingent text disappears rather than being promoted to committed
695
+ * body text.
696
+ *
697
+ * Known limitation: tracked paragraph STRUCTURE changes (a split recorded as a
698
+ * pilcrow <w:ins>, or a merge recorded as a pilcrow <w:del>) are reverted only
699
+ * to the extent of dropping/keeping the mark; the original paragraph boundary
700
+ * is not reconstructed, because the merge protocol coalesces paragraphs
701
+ * destructively at edit time. Reverting run-level insertions/deletions (the
702
+ * common case) is exact. This limitation is shared with the Python engine.
703
+ */
704
+ public reject_all_revisions() {
705
+ const parts_to_process: Element[] = [this.doc.element];
706
+
707
+ for (const part of this.doc.pkg.parts) {
708
+ if (part === this.doc.part) continue;
709
+ if (
710
+ part.contentType.includes("wordprocessingml") &&
711
+ part.contentType.endsWith("+xml")
712
+ ) {
713
+ parts_to_process.push(part._element);
714
+ }
715
+ }
716
+
717
+ for (const root_element of parts_to_process) {
718
+ // 1. Reject insertions: drop the <w:ins> and everything inside it.
719
+ // Document order means an outer <w:ins> is handled before a nested
720
+ // one; removing the outer detaches the inner (guarded below).
721
+ const insNodes = findAllDescendants(root_element, "w:ins");
722
+ for (const ins of insNodes) {
723
+ const parent = ins.parentNode as Element | null;
724
+ if (!parent) continue;
725
+ this._clean_wrapping_comments(ins);
726
+ this._delete_comments_in_element(ins);
727
+ if (parent.tagName === "w:trPr") {
728
+ const row = parent.parentNode as Element | null;
729
+ if (row && row.parentNode) {
730
+ row.parentNode.removeChild(row);
731
+ }
732
+ } else {
733
+ parent.removeChild(ins);
734
+ }
735
+ }
736
+
737
+ // 2. Reject paragraph-mark deletions: keep the paragraph break.
738
+ const pNodes = findAllDescendants(root_element, "w:p");
739
+ for (const p of pNodes) {
740
+ const pPr = findChild(p, "w:pPr");
741
+ if (pPr) {
742
+ const rPr = findChild(pPr, "w:rPr");
743
+ const delMark = rPr ? findChild(rPr, "w:del") : null;
744
+ if (rPr && delMark) {
745
+ rPr.removeChild(delMark);
746
+ }
747
+ }
748
+ }
749
+
750
+ // 3. Reject deletions: restore the original text.
751
+ const delNodes = findAllDescendants(root_element, "w:del");
752
+ for (const d of delNodes) {
753
+ const parent = d.parentNode as Element | null;
754
+ if (!parent) continue;
755
+ this._clean_wrapping_comments(d);
756
+ if (parent.tagName === "w:trPr") {
757
+ parent.removeChild(d);
758
+ continue;
759
+ }
760
+ const delTexts = Array.from(d.getElementsByTagName("w:delText"));
761
+ for (const dt of delTexts) {
762
+ const t = d.ownerDocument!.createElement("w:t");
763
+ t.textContent = dt.textContent;
764
+ if (dt.hasAttribute("xml:space"))
765
+ t.setAttribute("xml:space", "preserve");
766
+ dt.parentNode?.replaceChild(t, dt);
767
+ }
768
+ while (d.firstChild) {
769
+ parent.insertBefore(d.firstChild, d);
770
+ }
771
+ parent.removeChild(d);
772
+ }
773
+ }
774
+ }
775
+
539
776
  private _getNextId(): string {
540
777
  this.current_id++;
541
778
  return this.current_id.toString();
@@ -1220,33 +1457,39 @@ export class RedlineEngine {
1220
1457
  }
1221
1458
  }
1222
1459
 
1223
- public validate_edits(edits: any[]): string[] {
1460
+ public validate_edits(edits: any[], index_offset: number = 0): string[] {
1224
1461
  const errors: string[] = [];
1225
1462
  if (!this.mapper.full_text) this.mapper["_build_map"]();
1226
1463
 
1227
- errors.push(...validate_edit_strings(edits));
1464
+ errors.push(...validate_edit_strings(edits, index_offset));
1228
1465
 
1229
1466
  for (let i = 0; i < edits.length; i++) {
1230
1467
  const edit = edits[i];
1231
1468
  if (typeof edit !== "object" || edit === null) {
1232
1469
  errors.push(
1233
- `- Edit ${i + 1} Failed: Invalid change format. Expected a JSON object, but received a primitive ${typeof edit}. Do not pass raw strings.`
1470
+ `- Edit ${i + 1 + index_offset} Failed: Invalid change format. Expected a JSON object, but received a primitive ${typeof edit}. Do not pass raw strings.`,
1234
1471
  );
1235
1472
  continue;
1236
1473
  }
1237
1474
  if (!edit.target_text) continue;
1238
-
1475
+
1239
1476
  const is_regex = (edit as any).regex || false;
1240
1477
  const match_mode = (edit as any).match_mode || "strict";
1241
1478
 
1242
- let matches = this.mapper.find_all_match_indices(edit.target_text, is_regex);
1479
+ let matches = this.mapper.find_all_match_indices(
1480
+ edit.target_text,
1481
+ is_regex,
1482
+ );
1243
1483
  let activeText = this.mapper.full_text;
1244
1484
  let target_mapper = this.mapper;
1245
1485
 
1246
1486
  if (matches.length === 0) {
1247
1487
  if (!this.clean_mapper)
1248
1488
  this.clean_mapper = new DocumentMapper(this.doc, true);
1249
- matches = this.clean_mapper.find_all_match_indices(edit.target_text, is_regex);
1489
+ matches = this.clean_mapper.find_all_match_indices(
1490
+ edit.target_text,
1491
+ is_regex,
1492
+ );
1250
1493
  if (matches.length > 0) {
1251
1494
  activeText = this.clean_mapper.full_text;
1252
1495
  target_mapper = this.clean_mapper;
@@ -1278,7 +1521,10 @@ export class RedlineEngine {
1278
1521
  if (!this.original_mapper) {
1279
1522
  this.original_mapper = new DocumentMapper(this.doc, false, true);
1280
1523
  }
1281
- const orig_matches = this.original_mapper.find_all_match_indices(edit.target_text, is_regex);
1524
+ const orig_matches = this.original_mapper.find_all_match_indices(
1525
+ edit.target_text,
1526
+ is_regex,
1527
+ );
1282
1528
  if (orig_matches.length > 0) {
1283
1529
  is_deleted_text = true;
1284
1530
  for (const [start, length] of orig_matches) {
@@ -1289,7 +1535,10 @@ export class RedlineEngine {
1289
1535
  if (s.run !== null) {
1290
1536
  let parent = s.run._element as Node | null;
1291
1537
  while (parent) {
1292
- if (parent.nodeType === 1 && (parent as Element).tagName === "w:del") {
1538
+ if (
1539
+ parent.nodeType === 1 &&
1540
+ (parent as Element).tagName === "w:del"
1541
+ ) {
1293
1542
  const auth = (parent as Element).getAttribute("w:author");
1294
1543
  if (auth) {
1295
1544
  deleted_authors.add(auth);
@@ -1306,30 +1555,36 @@ export class RedlineEngine {
1306
1555
 
1307
1556
  if (matches.length === 0) {
1308
1557
  if (is_deleted_text) {
1309
- const author_phrase = deleted_authors.size > 0
1310
- ? `by ${Array.from(deleted_authors).sort().join(", ")}`
1311
- : "by an existing revision";
1558
+ const author_phrase =
1559
+ deleted_authors.size > 0
1560
+ ? `by ${Array.from(deleted_authors).sort().join(", ")}`
1561
+ : "by an existing revision";
1312
1562
  errors.push(
1313
- `- Edit ${i + 1} Failed: Target text matches text inside a tracked deletion ${author_phrase}. Reject/accept that change first or target the active replacement text instead.`,
1563
+ `- Edit ${i + 1 + index_offset} Failed: Target text matches text inside a tracked deletion ${author_phrase}. Reject/accept that change first or target the active replacement text instead.`,
1314
1564
  );
1315
1565
  } else {
1566
+ const hint = this._nearest_match_hint(edit.target_text, is_regex);
1316
1567
  errors.push(
1317
- `- Edit ${i + 1} Failed: Target text not found in document:\n "${edit.target_text}"`,
1568
+ `- Edit ${i + 1 + index_offset} Failed: Target text not found in document:\n "${edit.target_text}"${hint}`,
1318
1569
  );
1319
1570
  }
1320
1571
  } else if (matches.length > 1 && match_mode === "strict") {
1321
- const positions: [number, number][] = matches.map(([start, length]) => [
1322
- start,
1323
- start + length,
1324
- ]);
1325
- errors.push(
1326
- format_ambiguity_error(
1327
- i + 1,
1328
- edit.target_text,
1329
- activeText,
1330
- positions,
1331
- ),
1332
- );
1572
+ if (edit.target_text.includes("|")) {
1573
+ matches = matches.slice(0, 1);
1574
+ } else {
1575
+ const positions: [number, number][] = matches.map(([start, length]) => [
1576
+ start,
1577
+ start + length,
1578
+ ]);
1579
+ errors.push(
1580
+ format_ambiguity_error(
1581
+ i + 1 + index_offset,
1582
+ edit.target_text,
1583
+ activeText,
1584
+ positions,
1585
+ ),
1586
+ );
1587
+ }
1333
1588
  }
1334
1589
 
1335
1590
  // BUG-23-4: when the effective (context-trimmed) target spans a
@@ -1346,38 +1601,63 @@ export class RedlineEngine {
1346
1601
  (edit.new_text || "").length - sfx,
1347
1602
  );
1348
1603
  if (final_target.includes("\n\n")) {
1349
- if (final_new.includes("\n\n")) {
1350
- const parts = matched.split("\n\n");
1351
- if (
1352
- parts.length >= 2 &&
1353
- parts[0].trim() !== "" &&
1354
- parts[parts.length - 1].trim() !== ""
1355
- ) {
1356
- errors.push(
1357
- `- Edit ${i + 1} Failed: target_text spans a paragraph boundary with body text on both sides. The paragraph break is a structural element, not literal text, so it cannot be replaced as a single span without corrupting the document. Split this into one edit per paragraph.`,
1358
- );
1359
- }
1360
- } else {
1361
- const parts = final_target.split("\n\n");
1362
- if (
1363
- parts.length >= 2 &&
1364
- parts[0].trim() !== "" &&
1365
- parts[parts.length - 1].trim() !== ""
1366
- ) {
1367
- errors.push(
1368
- `- Edit ${i + 1} Failed: target_text spans a paragraph boundary with body text on both sides. The paragraph break is a structural element, not literal text, so it cannot be replaced as a single span without corrupting the document. Split this into one edit per paragraph.`,
1369
- );
1604
+ // A *balanced* multi-paragraph modification (target and replacement
1605
+ // carry the same number of paragraph breaks) is safe: it is split
1606
+ // into one sub-edit per paragraph segment and applied, leaving the
1607
+ // structural \n\n breaks untouched. Only reject when the paragraph
1608
+ // structure would actually change (a merge or split), which cannot be
1609
+ // expressed as a per-paragraph text replacement. See
1610
+ // _pre_resolve_heuristic_edit.
1611
+ const balanced =
1612
+ matched.split("\n\n").length ===
1613
+ (edit.new_text || "").split("\n\n").length;
1614
+ if (!balanced) {
1615
+ if (final_new.includes("\n\n")) {
1616
+ const parts = matched.split("\n\n");
1617
+ if (
1618
+ parts.length >= 2 &&
1619
+ parts[0].trim() !== "" &&
1620
+ parts[parts.length - 1].trim() !== ""
1621
+ ) {
1622
+ errors.push(
1623
+ `- Edit ${i + 1 + index_offset} Failed: target_text spans a paragraph boundary with body text on both sides. The paragraph break is a structural element, not literal text, so it cannot be replaced as a single span without corrupting the document. Split this into one edit per paragraph.`,
1624
+ );
1625
+ }
1626
+ } else {
1627
+ const parts = final_target.split("\n\n");
1628
+ if (
1629
+ parts.length >= 2 &&
1630
+ parts[0].trim() !== "" &&
1631
+ parts[parts.length - 1].trim() !== ""
1632
+ ) {
1633
+ errors.push(
1634
+ `- Edit ${i + 1 + index_offset} Failed: target_text spans a paragraph boundary with body text on both sides. The paragraph break is a structural element, not literal text, so it cannot be replaced as a single span without corrupting the document. Split this into one edit per paragraph.`,
1635
+ );
1636
+ }
1370
1637
  }
1371
1638
  }
1372
1639
  }
1373
1640
  }
1374
1641
 
1375
1642
  for (const [start, length] of matches) {
1376
- const spans = this.mapper.spans.filter(
1643
+ // Filter spans from the SAME mapper the match indices came from
1644
+ // (target_mapper may be the clean mapper); using this.mapper.spans here
1645
+ // would read a different coordinate space and miss the foreign <w:ins>
1646
+ // overlap for clean-mapper-resolved targets — silently letting a
1647
+ // partial straddle through. (Python filters target_mapper.spans too.)
1648
+ const spans = target_mapper.spans.filter(
1377
1649
  (s) => s.end > start && s.start < start + length,
1378
1650
  );
1379
- const nestedAuthors = new Set<string>();
1651
+ const insAuthors = new Set<string>();
1652
+ const commentAuthors = new Set<string>();
1653
+ // Does any real (run-backed) text in the target lie OUTSIDE a foreign
1654
+ // insertion? If so the target only partially overlaps the insertion and
1655
+ // replacing it as one span would straddle the <w:ins> boundary — that
1656
+ // case must still be refused.
1657
+ let hasNonForeignRealText = false;
1380
1658
  for (const s of spans) {
1659
+ if (s.run === null) continue;
1660
+ let isForeignIns = false;
1381
1661
  if (s.ins_id) {
1382
1662
  const insNodes = findAllDescendants(
1383
1663
  this.doc.element,
@@ -1385,21 +1665,48 @@ export class RedlineEngine {
1385
1665
  ).filter((n) => n.getAttribute("w:id") === s.ins_id);
1386
1666
  if (insNodes.length > 0) {
1387
1667
  const auth = insNodes[0].getAttribute("w:author");
1388
- if (auth && auth !== this.author) nestedAuthors.add(auth);
1668
+ if (auth && auth !== this.author) {
1669
+ insAuthors.add(auth);
1670
+ isForeignIns = true;
1671
+ }
1389
1672
  }
1390
1673
  }
1674
+ if (!isForeignIns) hasNonForeignRealText = true;
1675
+ }
1676
+ for (const s of spans) {
1391
1677
  if (s.comment_ids) {
1392
1678
  for (const cid of s.comment_ids) {
1393
1679
  const c_data = this.mapper.comments_map[cid];
1394
1680
  if (c_data && c_data.author && c_data.author !== this.author) {
1395
- nestedAuthors.add(c_data.author);
1681
+ commentAuthors.add(c_data.author);
1396
1682
  }
1397
1683
  }
1398
1684
  }
1399
1685
  }
1400
- if (nestedAuthors.size > 0) {
1686
+ if (insAuthors.size > 0 || commentAuthors.size > 0) {
1687
+ // A single (strict/first) modification whose target lies ENTIRELY
1688
+ // inside foreign-authored insertion(s), with no foreign comment
1689
+ // overlap, is allowed: track_delete_run splits the enclosing <w:ins>
1690
+ // and nests the change, producing valid tracked-change XML. Refuse the
1691
+ // remaining cases — match_mode "all" fan-outs, partial overlaps that
1692
+ // straddle the insertion boundary, and edits touching another author's
1693
+ // comment range.
1694
+ const fullyWithinForeignIns =
1695
+ insAuthors.size > 0 &&
1696
+ !hasNonForeignRealText &&
1697
+ commentAuthors.size === 0;
1698
+ if (
1699
+ (match_mode === "strict" || match_mode === "first") &&
1700
+ fullyWithinForeignIns
1701
+ ) {
1702
+ continue;
1703
+ }
1704
+ const nestedAuthors = new Set<string>([
1705
+ ...insAuthors,
1706
+ ...commentAuthors,
1707
+ ]);
1401
1708
  errors.push(
1402
- `- Edit ${i + 1} Failed: Modification targets an active insertion from another author (${Array.from(nestedAuthors).join(", ")}). Accept that change first or scope your edit outside of it.`,
1709
+ `- Edit ${i + 1 + index_offset} Failed: Modification targets an active insertion from another author (${Array.from(nestedAuthors).join(", ")}). Accept that change first or scope your edit outside of it.`,
1403
1710
  );
1404
1711
  }
1405
1712
  }
@@ -1481,22 +1788,13 @@ export class RedlineEngine {
1481
1788
  }
1482
1789
 
1483
1790
  if (dry_run) {
1484
- const baselines = new Map<any, Element>();
1485
- for (const part of this.doc.pkg.parts) {
1486
- if (part._element) {
1487
- baselines.set(part, part._element.cloneNode(true) as Element);
1488
- }
1489
- }
1791
+ const snapshot = takeSnapshot(this.doc);
1792
+ const originalCurrentId = this.current_id;
1490
1793
  try {
1491
1794
  return this._process_batch_internal(changes, true);
1492
1795
  } finally {
1493
- for (const [part, originalEl] of baselines.entries()) {
1494
- const doc = part._element.ownerDocument;
1495
- if (doc && doc.documentElement) {
1496
- doc.replaceChild(originalEl, doc.documentElement);
1497
- }
1498
- part._element = originalEl;
1499
- }
1796
+ restoreSnapshot(this.doc, snapshot);
1797
+ this.current_id = originalCurrentId;
1500
1798
  this.mapper = new DocumentMapper(this.doc);
1501
1799
  this.comments_manager = new CommentsManager(this.doc);
1502
1800
  this.clean_mapper = null;
@@ -1510,23 +1808,52 @@ export class RedlineEngine {
1510
1808
  changes: DocumentChange[],
1511
1809
  dry_run_mode: boolean = false,
1512
1810
  ): any {
1811
+ // Pre-process edits: strip identical leading heading hashes from target_text and new_text
1812
+ for (const c of changes) {
1813
+ if (c && typeof c === "object" && (c as any).type === "modify" && (c as any).target_text && (c as any).new_text) {
1814
+ const [strippedTarget, strippedNew] = stripMatchingHeadingHashes((c as any).target_text, (c as any).new_text);
1815
+ (c as any).target_text = strippedTarget;
1816
+ (c as any).new_text = strippedNew;
1817
+ }
1818
+ }
1819
+
1513
1820
  this.skipped_details = [];
1514
- const actions = changes.filter((c) =>
1515
- c !== null && typeof c === "object" && ["accept", "reject", "reply"].includes(c.type),
1821
+
1822
+ const actions = changes.filter(
1823
+ (c) =>
1824
+ c !== null &&
1825
+ typeof c === "object" &&
1826
+ ["accept", "reject", "reply"].includes(c.type),
1516
1827
  );
1517
1828
  const edits = changes.filter(
1518
- (c) => c === null || typeof c !== "object" || !["accept", "reject", "reply"].includes(c.type),
1829
+ (c) =>
1830
+ c === null ||
1831
+ typeof c !== "object" ||
1832
+ !["accept", "reject", "reply"].includes(c.type),
1519
1833
  );
1520
1834
 
1521
- // BUG-7: Unified single-pass validation in wet-run / standard mode
1835
+ // NOTE: a previous "edits_for_merge" pre-pass here silently UNWRAPPED a
1836
+ // foreign author's <w:ins> when a strict/first edit only partially straddled
1837
+ // its boundary — turning that author's tracked-inserted text into untracked
1838
+ // committed body text before the edit applied, destroying their provenance.
1839
+ // That is the same provenance-laundering failure mode the canonical engine
1840
+ // refuses, so it has been removed: a partial straddle now surfaces the
1841
+ // standard validation error ("Modification targets an active insertion from
1842
+ // another author …") via validate_edits, matching the Python engine. An edit
1843
+ // fully CONTAINED inside a foreign <w:ins> stays allowed and is handled by
1844
+ // nesting the <w:del> inside that <w:ins> (see _apply_single_edit_indexed /
1845
+ // _insert_and_split_ins).
1846
+
1847
+ // BUG-7: Unified single-pass validation in wet-run / standard mode.
1522
1848
  if (!dry_run_mode) {
1523
- const all_errors: string[] = [];
1524
- if (actions.length > 0) {
1525
- all_errors.push(...this.validate_review_actions(actions));
1526
- }
1527
- if (edits.length > 0) {
1528
- all_errors.push(...this.validate_edits(edits));
1529
- }
1849
+ const action_errors =
1850
+ actions.length > 0 ? this.validate_review_actions(actions) : [];
1851
+ const validate_edits_now = edits.length > 0 && action_errors.length > 0;
1852
+ const edit_errors = validate_edits_now ? this.validate_edits(edits) : [];
1853
+ const all_errors = [
1854
+ ...action_errors,
1855
+ ...edit_errors,
1856
+ ];
1530
1857
  if (all_errors.length > 0) {
1531
1858
  throw new BatchValidationError(all_errors);
1532
1859
  }
@@ -1564,8 +1891,9 @@ export class RedlineEngine {
1564
1891
 
1565
1892
  if (edits.length > 0) {
1566
1893
  if (dry_run_mode) {
1567
- for (const edit of edits) {
1568
- const single_errors = this.validate_edits([edit]);
1894
+ for (let i = 0; i < edits.length; i++) {
1895
+ const edit = edits[i];
1896
+ const single_errors = this.validate_edits([edit], i);
1569
1897
  const warning = this._check_punctuation_warning(
1570
1898
  (edit as any).target_text || "",
1571
1899
  );
@@ -1599,6 +1927,8 @@ export class RedlineEngine {
1599
1927
  occurrences_modified: (edit as any)._occurrences_modified || 0,
1600
1928
  match_mode: (edit as any).match_mode || "strict",
1601
1929
  });
1930
+ this.mapper = new DocumentMapper(this.doc);
1931
+ this.clean_mapper = null;
1602
1932
  } else {
1603
1933
  skipped_edits++;
1604
1934
  const error_msg =
@@ -1617,16 +1947,40 @@ export class RedlineEngine {
1617
1947
  }
1618
1948
  }
1619
1949
  } else {
1620
- const errors = this.validate_edits(edits);
1621
- if (errors.length > 0) {
1622
- throw new BatchValidationError(errors);
1950
+ // Simulated dry-run sequentially for wet-run validation parity
1951
+ const snapshot = takeSnapshot(this.doc);
1952
+ const originalCurrentId = this.current_id;
1953
+ try {
1954
+ const sequential_errors: string[] = [];
1955
+ for (let i = 0; i < edits.length; i++) {
1956
+ const edit = edits[i];
1957
+ const single_errors = this.validate_edits([edit], i);
1958
+ if (single_errors.length > 0) {
1959
+ sequential_errors.push(...single_errors);
1960
+ } else {
1961
+ this.apply_edits([edit], page_offsets);
1962
+ this.mapper = new DocumentMapper(this.doc);
1963
+ this.clean_mapper = null;
1964
+ }
1965
+ }
1966
+ if (sequential_errors.length > 0) {
1967
+ throw new BatchValidationError(sequential_errors);
1968
+ }
1969
+ } catch (err) {
1970
+ restoreSnapshot(this.doc, snapshot);
1971
+ this.current_id = originalCurrentId;
1972
+ this.mapper = new DocumentMapper(this.doc);
1973
+ this.comments_manager = new CommentsManager(this.doc);
1974
+ this.clean_mapper = null;
1975
+ throw err;
1623
1976
  }
1624
- const cloned_edits = edits.map((e) => JSON.parse(JSON.stringify(e)));
1625
- const res = this.apply_edits(cloned_edits, page_offsets);
1626
- applied_edits = cloned_edits.filter((e) => (e as any)._applied_status).length;
1627
- skipped_edits = cloned_edits.length - applied_edits;
1628
1977
 
1629
- for (const edit of cloned_edits) {
1978
+ applied_edits = edits.filter(
1979
+ (e) => (e as any)._applied_status,
1980
+ ).length;
1981
+ skipped_edits = edits.length - applied_edits;
1982
+
1983
+ for (const edit of edits) {
1630
1984
  const success = (edit as any)._applied_status || false;
1631
1985
  const error_msg = (edit as any)._error_msg || null;
1632
1986
  const warning = this._check_punctuation_warning(
@@ -1655,7 +2009,6 @@ export class RedlineEngine {
1655
2009
  }
1656
2010
  }
1657
2011
  }
1658
-
1659
2012
  return {
1660
2013
  actions_applied: applied_actions,
1661
2014
  actions_skipped: skipped_actions,
@@ -1668,7 +2021,10 @@ export class RedlineEngine {
1668
2021
  };
1669
2022
  }
1670
2023
 
1671
- public apply_edits(edits: any[], page_offsets: number[] = []): [number, number] {
2024
+ public apply_edits(
2025
+ edits: any[],
2026
+ page_offsets: number[] = [],
2027
+ ): [number, number] {
1672
2028
  let applied = 0;
1673
2029
  let skipped = 0;
1674
2030
 
@@ -1765,6 +2121,10 @@ export class RedlineEngine {
1765
2121
  (b[0]._resolved_start_idx || 0) - (a[0]._resolved_start_idx || 0),
1766
2122
  );
1767
2123
  const occupied_ranges: [number, number][] = [];
2124
+ // Sub-edits split from one balanced multi-paragraph modification share a
2125
+ // _split_group_id; count the group as a single applied edit (and a single
2126
+ // occurrence), even though it touches several paragraphs.
2127
+ const counted_split_groups = new Set<number>();
1768
2128
 
1769
2129
  for (const [edit, orig_new] of resolved_edits) {
1770
2130
  const start = edit._resolved_start_idx || 0;
@@ -1797,21 +2157,45 @@ export class RedlineEngine {
1797
2157
  }
1798
2158
 
1799
2159
  if (success) {
1800
- applied++;
2160
+ // A balanced multi-paragraph split fans one logical edit into several
2161
+ // paragraph sub-edits sharing a _split_group_id; count it once. Edits
2162
+ // with no group id (the common case) always count.
2163
+ const group_id = edit._split_group_id;
2164
+ const first_in_group =
2165
+ group_id === undefined ||
2166
+ group_id === null ||
2167
+ !counted_split_groups.has(group_id);
2168
+ if (first_in_group && group_id !== undefined && group_id !== null) {
2169
+ counted_split_groups.add(group_id);
2170
+ }
2171
+ if (first_in_group) applied++;
1801
2172
  occupied_ranges.push([start, end]);
1802
2173
  edit._applied_status = true;
1803
2174
  const parent = edit._parent_edit_ref;
1804
2175
  if (parent) {
1805
2176
  parent._applied_status = true;
1806
- parent._occurrences_modified = (parent._occurrences_modified || 0) + 1;
1807
- const [path, page] = this._get_heading_path_and_page(start, this.mapper.full_text, page_offsets);
2177
+ if (first_in_group) {
2178
+ parent._occurrences_modified =
2179
+ (parent._occurrences_modified || 0) + 1;
2180
+ }
2181
+ const [path, page] = this._get_heading_path_and_page(
2182
+ start,
2183
+ this.mapper.full_text,
2184
+ page_offsets,
2185
+ );
1808
2186
  const pages: number[] = parent._pages || [];
1809
2187
  if (!pages.includes(page)) pages.unshift(page);
1810
2188
  parent._pages = pages;
1811
2189
  parent._heading_path = path;
1812
2190
  } else {
1813
- edit._occurrences_modified = (edit._occurrences_modified || 0) + 1;
1814
- const [path, page] = this._get_heading_path_and_page(start, this.mapper.full_text, page_offsets);
2191
+ if (first_in_group) {
2192
+ edit._occurrences_modified = (edit._occurrences_modified || 0) + 1;
2193
+ }
2194
+ const [path, page] = this._get_heading_path_and_page(
2195
+ start,
2196
+ this.mapper.full_text,
2197
+ page_offsets,
2198
+ );
1815
2199
  const pages: number[] = edit._pages || [];
1816
2200
  if (!pages.includes(page)) pages.unshift(page);
1817
2201
  edit._pages = pages;
@@ -1993,13 +2377,19 @@ export class RedlineEngine {
1993
2377
  const is_regex = edit.regex || false;
1994
2378
  const match_mode = edit.match_mode || "strict";
1995
2379
 
1996
- let matches = this.mapper.find_all_match_indices(edit.target_text, is_regex);
2380
+ let matches = this.mapper.find_all_match_indices(
2381
+ edit.target_text,
2382
+ is_regex,
2383
+ );
1997
2384
  let use_clean_map = false;
1998
2385
 
1999
2386
  if (matches.length === 0) {
2000
2387
  if (!this.clean_mapper)
2001
2388
  this.clean_mapper = new DocumentMapper(this.doc, true);
2002
- matches = this.clean_mapper.find_all_match_indices(edit.target_text, is_regex);
2389
+ matches = this.clean_mapper.find_all_match_indices(
2390
+ edit.target_text,
2391
+ is_regex,
2392
+ );
2003
2393
  if (matches.length > 0) use_clean_map = true;
2004
2394
  else return null;
2005
2395
  }
@@ -2009,7 +2399,8 @@ export class RedlineEngine {
2009
2399
  let live_matches: [number, number][] = [];
2010
2400
  for (const [s, match_len] of matches) {
2011
2401
  const realSpans = active_mapper.spans.filter(
2012
- (span) => span.run !== null && span.end > s && span.start < s + match_len
2402
+ (span) =>
2403
+ span.run !== null && span.end > s && span.start < s + match_len,
2013
2404
  );
2014
2405
  if (realSpans.length === 0 || realSpans.some((span) => !span.del_id)) {
2015
2406
  live_matches.push([s, match_len]);
@@ -2031,23 +2422,72 @@ export class RedlineEngine {
2031
2422
  );
2032
2423
  let current_effective_new_text = edit.new_text || "";
2033
2424
 
2425
+ // Cell anchors ({#cell:<paraId>}) are pure position markers with no real
2426
+ // content — they let the model address an empty (or any) table cell that
2427
+ // has no run to diff against. Treat such a target as a clean INSERTION at
2428
+ // the anchor's paragraph: never delete the marker, never run trim_common_context
2429
+ // (which refuses to split inside {#...} markup and yields a no-op MODIFICATION).
2430
+ // Strip any echoed anchor from new_text so the model can send either
2431
+ // "June 22, 2026" or "June 22, 2026{#cell:...}" and get the same result.
2432
+ if (/^\{#cell:[^}]+\}$/.test(actual_doc_text.trim())) {
2433
+ let ins_text = current_effective_new_text;
2434
+ // Drop a leading/trailing copy of the same anchor token if echoed.
2435
+ ins_text = ins_text.split(actual_doc_text.trim()).join("");
2436
+ if (ins_text) {
2437
+ all_sub_edits.push({
2438
+ type: "modify",
2439
+ target_text: "",
2440
+ new_text: ins_text,
2441
+ comment: edit.comment,
2442
+ // Insert at the anchor token's start so the new run lands inside
2443
+ // the cell paragraph that get_insertion_anchor resolves there.
2444
+ _match_start_index: start_idx,
2445
+ _internal_op: "INSERTION",
2446
+ _active_mapper_ref: active_mapper,
2447
+ });
2448
+ } else if (edit.comment) {
2449
+ // Anchor target with empty effective new_text but a comment: attach
2450
+ // the comment to the cell paragraph.
2451
+ all_sub_edits.push({
2452
+ type: "modify",
2453
+ target_text: "",
2454
+ new_text: "",
2455
+ comment: edit.comment,
2456
+ _match_start_index: start_idx,
2457
+ _internal_op: "COMMENT_ONLY",
2458
+ _active_mapper_ref: active_mapper,
2459
+ });
2460
+ }
2461
+ continue;
2462
+ }
2463
+
2034
2464
  if (is_regex && current_effective_new_text) {
2035
2465
  try {
2036
- current_effective_new_text = actual_doc_text.replace(new RegExp(edit.target_text), current_effective_new_text);
2466
+ current_effective_new_text = actual_doc_text.replace(
2467
+ new RegExp(edit.target_text),
2468
+ current_effective_new_text,
2469
+ );
2037
2470
  } catch (e) {}
2038
2471
  }
2039
2472
 
2040
- const [edit_target_clean, edit_target_style] = this._parse_markdown_style(edit.target_text);
2041
- const [edit_new_clean, edit_new_style] = this._parse_markdown_style(current_effective_new_text);
2473
+ const [edit_target_clean, edit_target_style] = this._parse_markdown_style(
2474
+ edit.target_text,
2475
+ );
2476
+ const [edit_new_clean, edit_new_style] = this._parse_markdown_style(
2477
+ current_effective_new_text,
2478
+ );
2042
2479
 
2043
2480
  if (edit_target_style !== edit_new_style) {
2044
2481
  const [actual_clean] = this._parse_markdown_style(actual_doc_text);
2045
2482
  const final_target = actual_clean;
2046
2483
  const final_new = edit_new_clean;
2047
- const style_op = final_target === final_new ? "STYLE_ONLY" : "STYLE_AND_TEXT";
2484
+ const style_op =
2485
+ final_target === final_new ? "STYLE_ONLY" : "STYLE_AND_TEXT";
2048
2486
  const prefix_offset = actual_doc_text.indexOf(actual_clean);
2049
- const effective_start_idx = start_idx + (prefix_offset !== -1 ? prefix_offset : 0);
2050
- const resolved_style = edit_new_style !== null ? edit_new_style : "Normal";
2487
+ const effective_start_idx =
2488
+ start_idx + (prefix_offset !== -1 ? prefix_offset : 0);
2489
+ const resolved_style =
2490
+ edit_new_style !== null ? edit_new_style : "Normal";
2051
2491
 
2052
2492
  all_sub_edits.push({
2053
2493
  type: "modify",
@@ -2085,7 +2525,9 @@ export class RedlineEngine {
2085
2525
 
2086
2526
  if (current_effective_new_text.startsWith(actual_doc_text)) {
2087
2527
  effective_op = "INSERTION";
2088
- final_new = current_effective_new_text.substring(actual_doc_text.length);
2528
+ final_new = current_effective_new_text.substring(
2529
+ actual_doc_text.length,
2530
+ );
2089
2531
  effective_start_idx = start_idx + match_len;
2090
2532
  } else {
2091
2533
  const [prefix_len, suffix_len] = trim_common_context(
@@ -2099,6 +2541,65 @@ export class RedlineEngine {
2099
2541
  final_new = current_effective_new_text.substring(prefix_len, n_end);
2100
2542
  effective_start_idx = start_idx + prefix_len;
2101
2543
 
2544
+ // Balanced multi-paragraph modification: the matched span crosses one or
2545
+ // more paragraph breaks and the replacement preserves the same number of
2546
+ // breaks. Apply it as one independent sub-edit per paragraph segment so
2547
+ // the structural \n\n breaks are left intact. Each sub-edit shares a
2548
+ // _split_group_id (the occurrence's start index) so the batch report
2549
+ // counts it as a single applied edit. Unbalanced cases (a genuine
2550
+ // paragraph merge or split) fall through to the single-span path and are
2551
+ // rejected by validate_edits.
2552
+ const target_segs = actual_doc_text.split("\n\n");
2553
+ const new_segs = current_effective_new_text.split("\n\n");
2554
+ if (
2555
+ actual_doc_text.includes("\n\n") &&
2556
+ target_segs.length === new_segs.length
2557
+ ) {
2558
+ const split_sub_edits: any[] = [];
2559
+ let seg_offset = start_idx;
2560
+ let comment_assigned = false;
2561
+ for (let k = 0; k < target_segs.length; k++) {
2562
+ const t_seg = target_segs[k];
2563
+ const n_seg = new_segs[k];
2564
+ if (t_seg !== n_seg) {
2565
+ const [seg_prefix, seg_suffix] = trim_common_context(t_seg, n_seg);
2566
+ const seg_target = t_seg.substring(
2567
+ seg_prefix,
2568
+ t_seg.length - seg_suffix,
2569
+ );
2570
+ const seg_new = n_seg.substring(
2571
+ seg_prefix,
2572
+ n_seg.length - seg_suffix,
2573
+ );
2574
+ const seg_start = seg_offset + seg_prefix;
2575
+ let seg_op: string;
2576
+ if (!seg_target && seg_new) seg_op = "INSERTION";
2577
+ else if (seg_target && !seg_new) seg_op = "DELETION";
2578
+ else if (seg_target && seg_new) seg_op = "MODIFICATION";
2579
+ else seg_op = "COMMENT_ONLY";
2580
+ const seg_comment =
2581
+ edit.comment && !comment_assigned ? edit.comment : null;
2582
+ if (seg_comment) comment_assigned = true;
2583
+ split_sub_edits.push({
2584
+ type: "modify",
2585
+ target_text: seg_target,
2586
+ new_text: seg_new,
2587
+ comment: seg_comment,
2588
+ _match_start_index: seg_start,
2589
+ _internal_op: seg_op,
2590
+ _active_mapper_ref: active_mapper,
2591
+ _split_group_id: start_idx,
2592
+ });
2593
+ }
2594
+ // Advance past this segment plus its "\n\n" separator span.
2595
+ seg_offset += t_seg.length + 2;
2596
+ }
2597
+ if (split_sub_edits.length > 0) {
2598
+ for (const sub of split_sub_edits) all_sub_edits.push(sub);
2599
+ continue;
2600
+ }
2601
+ }
2602
+
2102
2603
  if (!final_target && final_new) effective_op = "INSERTION";
2103
2604
  else if (final_target && !final_new) effective_op = "DELETION";
2104
2605
  else if (final_target && final_new) effective_op = "MODIFICATION";
@@ -2121,6 +2622,41 @@ export class RedlineEngine {
2121
2622
  return all_sub_edits[0];
2122
2623
  }
2123
2624
 
2625
+ /**
2626
+ * Split a <w:ins> so that everything up to and INCLUDING split_after stays in
2627
+ * a left <w:ins>, new_elem is placed between, and the remainder moves to a
2628
+ * right <w:ins> — all at the grandparent level. Used when revising another
2629
+ * author's pending insertion: the <w:del> stays nested in their <w:ins> while
2630
+ * our replacement <w:ins> lands as a sibling, so we never nest <w:ins> in
2631
+ * <w:ins>.
2632
+ */
2633
+ private _insert_and_split_ins(
2634
+ parent_ins: Element,
2635
+ split_after: Element,
2636
+ new_elem: Element,
2637
+ ) {
2638
+ const grandparent = parent_ins.parentNode as Element | null;
2639
+ if (!grandparent) return;
2640
+ // cloneNode(false) copies the attributes (author/id/date) onto both halves.
2641
+ const left = parent_ins.cloneNode(false) as Element;
2642
+ const right = parent_ins.cloneNode(false) as Element;
2643
+ let toRight = false;
2644
+ for (const kid of Array.from(parent_ins.childNodes)) {
2645
+ parent_ins.removeChild(kid);
2646
+ if (!toRight) {
2647
+ left.appendChild(kid);
2648
+ if (kid === split_after) toRight = true;
2649
+ } else {
2650
+ right.appendChild(kid);
2651
+ }
2652
+ }
2653
+ if (left.childNodes.length > 0) grandparent.insertBefore(left, parent_ins);
2654
+ grandparent.insertBefore(new_elem, parent_ins);
2655
+ if (right.childNodes.length > 0)
2656
+ grandparent.insertBefore(right, parent_ins);
2657
+ grandparent.removeChild(parent_ins);
2658
+ }
2659
+
2124
2660
  private _apply_single_edit_indexed(
2125
2661
  edit: any,
2126
2662
  orig_new: string | null,
@@ -2172,7 +2708,10 @@ export class RedlineEngine {
2172
2708
  while (end_p && end_p.tagName !== "w:p")
2173
2709
  end_p = end_p.parentNode as Element;
2174
2710
  if (start_p && end_p) {
2175
- const ascend_to_paragraph_child = (el: Element, p: Element): Element => {
2711
+ const ascend_to_paragraph_child = (
2712
+ el: Element,
2713
+ p: Element,
2714
+ ): Element => {
2176
2715
  let cur: Element = el;
2177
2716
  while (cur.parentNode && cur.parentNode !== p) {
2178
2717
  cur = cur.parentNode as Element;
@@ -2182,7 +2721,12 @@ export class RedlineEngine {
2182
2721
  const first_anchor = ascend_to_paragraph_child(first_el, start_p);
2183
2722
  const last_anchor = ascend_to_paragraph_child(last_el, end_p);
2184
2723
  if (start_p === end_p) {
2185
- this._attach_comment(start_p, first_anchor, last_anchor, edit.comment);
2724
+ this._attach_comment(
2725
+ start_p,
2726
+ first_anchor,
2727
+ last_anchor,
2728
+ edit.comment,
2729
+ );
2186
2730
  } else {
2187
2731
  this._attach_comment_spanning(
2188
2732
  start_p,
@@ -2371,7 +2915,20 @@ export class RedlineEngine {
2371
2915
  const is_inline_first = result.first_node.tagName === "w:ins";
2372
2916
  if (is_inline_first) {
2373
2917
  if (anchor_run) {
2374
- insertAfter(result.first_node, anchor_run._element);
2918
+ const anchor_parent = anchor_run._element.parentNode as Element | null;
2919
+ if (anchor_parent && anchor_parent.tagName === "w:ins") {
2920
+ // Inserting inside another author's pending <w:ins>: split it so our
2921
+ // new <w:ins> lands as a sibling right after the anchor run, never
2922
+ // <w:ins> nested in <w:ins> (mirrors the MODIFICATION path and the
2923
+ // Python engine).
2924
+ this._insert_and_split_ins(
2925
+ anchor_parent,
2926
+ anchor_run._element,
2927
+ result.first_node,
2928
+ );
2929
+ } else {
2930
+ insertAfter(result.first_node, anchor_run._element);
2931
+ }
2375
2932
  } else if (anchor_para) {
2376
2933
  anchor_para._element.appendChild(result.first_node);
2377
2934
  }
@@ -2513,8 +3070,17 @@ export class RedlineEngine {
2513
3070
  if (result.first_node) {
2514
3071
  const is_inline_first = result.first_node.tagName === "w:ins";
2515
3072
  if (is_inline_first) {
2516
- // Inline: place the first <w:ins> immediately after last_del.
2517
- insertAfter(result.first_node, last_del);
3073
+ const del_parent = last_del!.parentNode as Element | null;
3074
+ if (del_parent && del_parent.tagName === "w:ins") {
3075
+ // Revising another author's pending insertion: keep the <w:del>
3076
+ // nested in their <w:ins> and splice our new <w:ins> in right after
3077
+ // it by splitting their <w:ins>, so we never nest <w:ins> in
3078
+ // <w:ins>.
3079
+ this._insert_and_split_ins(del_parent, last_del!, result.first_node);
3080
+ } else {
3081
+ // Inline: place the first <w:ins> immediately after last_del.
3082
+ insertAfter(result.first_node, last_del!);
3083
+ }
2518
3084
  ins_elem = result.first_node;
2519
3085
  } else {
2520
3086
  // Block-mode first paragraph was already inserted after the anchor