@adeu/core 1.9.0 → 1.10.1

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
@@ -247,6 +247,47 @@ export class RedlineEngine {
247
247
  this.comments_manager = new CommentsManager(this.doc);
248
248
  }
249
249
 
250
+ private _check_punctuation_warning(target_text: string): string | null {
251
+ if (!target_text) return null;
252
+ if (target_text.includes("_") || target_text.includes("-")) {
253
+ return `Warning: target_text '${target_text}' contains tokenization-splitting punctuation ('_' or '-'). This can trigger mid-word splits in the diff engine. Consider using a longer plain-prose anchor.`;
254
+ }
255
+ return null;
256
+ }
257
+
258
+ private _build_edit_context_previews(
259
+ edit: any,
260
+ ): [string | null, string | null] {
261
+ if (edit.type !== "modify") return [null, null];
262
+ if (edit._resolved_proxy_edit) {
263
+ edit = edit._resolved_proxy_edit;
264
+ }
265
+ const start_idx = edit._resolved_start_idx;
266
+ if (start_idx === undefined || start_idx === null) return [null, null];
267
+ const target_text = edit.target_text || "";
268
+ const new_text = edit.new_text || "";
269
+ const length = target_text.length;
270
+ const active_mapper = edit._active_mapper_ref || this.mapper;
271
+ const full_text = active_mapper.full_text;
272
+ if (!full_text) return [null, null];
273
+
274
+ const before_start = Math.max(0, start_idx - 30);
275
+ const context_before = full_text.substring(before_start, start_idx);
276
+ const context_after = full_text.substring(
277
+ start_idx + length,
278
+ start_idx + length + 30,
279
+ );
280
+
281
+ const critic_markup = `${context_before}{--${target_text}--}{++${new_text}++}${context_after}`;
282
+
283
+ let clean_text = critic_markup;
284
+ clean_text = clean_text.replace(/\{>>.*?<<\}/gs, "");
285
+ clean_text = clean_text.replace(/\{--.*?--\}/gs, "");
286
+ clean_text = clean_text.replace(/\{\+\+(.*?)\+\+\}/gs, "$1");
287
+
288
+ return [critic_markup, clean_text];
289
+ }
290
+
250
291
  private _scan_existing_ids(): number {
251
292
  let maxId = 0;
252
293
  for (const tag of ["w:ins", "w:del"]) {
@@ -301,7 +342,7 @@ export class RedlineEngine {
301
342
  for (const tag of ["w:t", "w:tab", "w:br"]) {
302
343
  for (const child of findAllDescendants(p, tag)) {
303
344
  if (tag === "w:t" && !child.textContent) continue;
304
-
345
+
305
346
  let is_deleted = false;
306
347
  let curr = child.parentNode as Element | null;
307
348
  while (curr && curr !== p) {
@@ -351,35 +392,113 @@ export class RedlineEngine {
351
392
  }
352
393
  }
353
394
 
354
- // Final pass: remove any free-standing comments
355
- const comment_ids = new Set<string>();
356
- for (const tag of [
357
- "w:commentRangeStart",
358
- "w:commentRangeEnd",
359
- "w:commentReference",
360
- ]) {
361
- for (const node of findAllDescendants(this.doc.element, tag)) {
362
- const cid = node.getAttribute("w:id");
363
- if (cid) comment_ids.add(cid);
395
+ // Final pass: completely eject all comments, anchors, and parts
396
+ for (const root_element of parts_to_process) {
397
+ for (const tag of ["w:commentRangeStart", "w:commentRangeEnd"]) {
398
+ for (const el of findAllDescendants(root_element, tag)) {
399
+ el.parentNode?.removeChild(el);
400
+ }
401
+ }
402
+
403
+ const refs = findAllDescendants(root_element, "w:commentReference");
404
+ for (const ref of refs) {
405
+ const parent = ref.parentNode as Element | null;
406
+ if (parent) {
407
+ if (parent.tagName === "w:r" || parent.tagName.endsWith(":r")) {
408
+ const nonRprChildren = Array.from(parent.childNodes).filter(
409
+ (c) =>
410
+ c.nodeType === 1 &&
411
+ (c as Element).tagName !== "w:rPr" &&
412
+ (c as Element).tagName !== "rPr",
413
+ );
414
+ if (nonRprChildren.length <= 1) {
415
+ parent.parentNode?.removeChild(parent);
416
+ } else {
417
+ parent.removeChild(ref);
418
+ }
419
+ } else {
420
+ parent.removeChild(ref);
421
+ }
422
+ }
364
423
  }
365
424
  }
366
425
 
367
- const comments_part = this.doc.pkg.parts.find(
368
- (p) =>
369
- p.contentType ===
370
- "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
371
- );
372
- if (comments_part) {
373
- for (const c of findAllDescendants(comments_part._element, "w:comment")) {
374
- const cid = c.getAttribute("w:id");
375
- if (cid) comment_ids.add(cid);
426
+ const pkg = this.doc.pkg;
427
+ const comment_partnames = new Set<string>();
428
+ for (const part of pkg.parts) {
429
+ if (part.partname.toLowerCase().includes("comments")) {
430
+ comment_partnames.add(part.partname);
431
+ const withSlash = part.partname.startsWith("/")
432
+ ? part.partname
433
+ : "/" + part.partname;
434
+ const withoutSlash = part.partname.startsWith("/")
435
+ ? part.partname.substring(1)
436
+ : part.partname;
437
+ comment_partnames.add(withSlash);
438
+ comment_partnames.add(withoutSlash);
376
439
  }
377
440
  }
378
441
 
379
- for (const cid of comment_ids) {
380
- this.comments_manager.deleteComment(cid);
442
+ if (comment_partnames.size > 0) {
443
+ // Sever relationships referencing comments
444
+ for (const part of pkg.parts) {
445
+ if (part.partname.endsWith(".rels")) {
446
+ const rels = findAllDescendants(part._element, "Relationship");
447
+ const toRemove: Element[] = [];
448
+ for (const rel of rels) {
449
+ const target = rel.getAttribute("Target") || "";
450
+ if (target.toLowerCase().includes("comments")) {
451
+ toRemove.push(rel);
452
+
453
+ const sourcePath = part.partname
454
+ .replace("/_rels/", "/")
455
+ .replace(".rels", "");
456
+ const sourcePart = pkg.getPartByPath(sourcePath);
457
+ if (sourcePart) {
458
+ const relId = rel.getAttribute("Id");
459
+ if (relId) sourcePart.rels.delete(relId);
460
+ }
461
+ }
462
+ }
463
+ for (const relEl of toRemove) {
464
+ relEl.parentNode?.removeChild(relEl);
465
+ }
466
+ }
467
+ }
468
+
469
+ // Remove overrides from [Content_Types].xml
470
+ const ctPart = pkg.getPartByPath("[Content_Types].xml");
471
+ if (ctPart) {
472
+ const overrides = findAllDescendants(ctPart._element, "Override");
473
+ const toRemove: Element[] = [];
474
+ for (const override of overrides) {
475
+ const partName = override.getAttribute("PartName") || "";
476
+ if (
477
+ comment_partnames.has(partName) ||
478
+ partName.toLowerCase().includes("comments")
479
+ ) {
480
+ toRemove.push(override);
481
+ }
482
+ }
483
+ for (const overrideEl of toRemove) {
484
+ overrideEl.parentNode?.removeChild(overrideEl);
485
+ }
486
+ }
487
+
488
+ // Remove comment parts from pkg.parts
489
+ pkg.parts = pkg.parts.filter(
490
+ (p) => !p.partname.toLowerCase().includes("comments"),
491
+ );
492
+
493
+ // Remove comment files from pkg.unzipped
494
+ for (const key of Object.keys(pkg.unzipped)) {
495
+ if (key.toLowerCase().includes("comments")) {
496
+ delete pkg.unzipped[key];
497
+ }
498
+ }
381
499
  }
382
500
  }
501
+
383
502
  private _getNextId(): string {
384
503
  this.current_id++;
385
504
  return this.current_id.toString();
@@ -505,7 +624,9 @@ export class RedlineEngine {
505
624
  end_p.appendChild(range_end);
506
625
  end_p.appendChild(ref_run);
507
626
  }
508
- } /**
627
+ }
628
+
629
+ /**
509
630
  * Inserts `text` as one or more tracked paragraphs anchored relative to
510
631
  * either an existing run or a paragraph. Returns:
511
632
  * { first_node, last_p, last_ins, used_block_mode }
@@ -734,8 +855,19 @@ export class RedlineEngine {
734
855
  const anchor_rPr = findChild(anchor_run._element, "w:rPr");
735
856
  if (anchor_rPr) {
736
857
  const clone = anchor_rPr.cloneNode(true) as Element;
737
- // Strip vanish / strike to avoid invisible inserts.
738
- for (const tag of ["w:vanish", "w:strike", "w:dstrike"]) {
858
+ // Strip vanish / strike to avoid invisible inserts, and emphasis
859
+ // (bold/italic) so inserted replacement text does not silently
860
+ // inherit the anchor run's character formatting (BUG-23-2). Explicit
861
+ // markdown emphasis is re-applied per-segment via _apply_run_props.
862
+ for (const tag of [
863
+ "w:vanish",
864
+ "w:strike",
865
+ "w:dstrike",
866
+ "w:i",
867
+ "w:iCs",
868
+ "w:b",
869
+ "w:bCs",
870
+ ]) {
739
871
  const found = findChild(clone, tag);
740
872
  if (found) clone.removeChild(found);
741
873
  }
@@ -750,6 +882,7 @@ export class RedlineEngine {
750
882
  }
751
883
  return ins;
752
884
  }
885
+
753
886
  private _parse_markdown_style(text: string): [string, string | null] {
754
887
  const stripped_text = text.trimStart();
755
888
 
@@ -855,6 +988,7 @@ export class RedlineEngine {
855
988
  }
856
989
  }
857
990
  }
991
+
858
992
  /**
859
993
  * Replaces (or creates) a paragraph's <w:pPr> with a single <w:pStyle> entry
860
994
  * pointing at `style_name`. Strips any existing pPr to avoid layering a new
@@ -884,6 +1018,7 @@ export class RedlineEngine {
884
1018
  // pPr is the first child of <w:p> per OOXML schema.
885
1019
  p_element.insertBefore(pPr, p_element.firstChild);
886
1020
  }
1021
+
887
1022
  private _anchor_reply_comment(parent_id: string, new_id: string) {
888
1023
  const docEl = this.doc.part._element.ownerDocument!;
889
1024
 
@@ -938,6 +1073,7 @@ export class RedlineEngine {
938
1073
 
939
1074
  insertAfter(ref_run, new_end);
940
1075
  }
1076
+
941
1077
  private _clean_wrapping_comments(element: Element) {
942
1078
  let first_node: Element = element;
943
1079
  while (true) {
@@ -1046,6 +1182,7 @@ export class RedlineEngine {
1046
1182
  }
1047
1183
  }
1048
1184
  }
1185
+
1049
1186
  public validate_edits(edits: any[]): string[] {
1050
1187
  const errors: string[] = [];
1051
1188
  if (!this.mapper.full_text) this.mapper["_build_map"]();
@@ -1066,6 +1203,24 @@ export class RedlineEngine {
1066
1203
  if (matches.length > 0) activeText = this.clean_mapper.full_text;
1067
1204
  }
1068
1205
 
1206
+ // BUG-23-5: a copy of the target that lives entirely inside a tracked
1207
+ // deletion (<w:del>) is not a live, editable occurrence and must not
1208
+ // count toward ambiguity. Drop matches whose overlapping real text is
1209
+ // exclusively deleted. Only applies to the raw mapper (the clean mapper
1210
+ // already omits deleted text).
1211
+ if (activeText === this.mapper.full_text && matches.length > 1) {
1212
+ const liveMatches = matches.filter(([start, length]) => {
1213
+ const realSpans = this.mapper.spans.filter(
1214
+ (s) => s.run !== null && s.end > start && s.start < start + length,
1215
+ );
1216
+ if (realSpans.length === 0) return true; // virtual-only; keep
1217
+ // Keep only if at least one overlapping real span is live (not
1218
+ // part of a tracked deletion).
1219
+ return realSpans.some((s) => !s.del_id);
1220
+ });
1221
+ if (liveMatches.length > 0) matches = liveMatches;
1222
+ }
1223
+
1069
1224
  if (matches.length === 0) {
1070
1225
  errors.push(
1071
1226
  `- Edit ${i + 1} Failed: Target text not found in document:\n "${edit.target_text}"`,
@@ -1085,6 +1240,46 @@ export class RedlineEngine {
1085
1240
  );
1086
1241
  }
1087
1242
 
1243
+ // BUG-23-4: when the effective (context-trimmed) target spans a
1244
+ // paragraph boundary with real body text on BOTH sides, we must reject
1245
+ // the modification to prevent silent corruption of the paragraph structure.
1246
+ if (matches.length === 1) {
1247
+ const [m_start, m_len] = matches[0];
1248
+ const matched = activeText.substring(m_start, m_start + m_len);
1249
+ const [pfx, sfx] = trim_common_context(matched, edit.new_text || "");
1250
+ const t_end = matched.length - sfx;
1251
+ const final_target = matched.substring(pfx, t_end);
1252
+ const final_new = (edit.new_text || "").substring(
1253
+ pfx,
1254
+ (edit.new_text || "").length - sfx,
1255
+ );
1256
+ if (final_target.includes("\n\n")) {
1257
+ if (final_new.includes("\n\n")) {
1258
+ const parts = matched.split("\n\n");
1259
+ if (
1260
+ parts.length >= 2 &&
1261
+ parts[0].trim() !== "" &&
1262
+ parts[parts.length - 1].trim() !== ""
1263
+ ) {
1264
+ errors.push(
1265
+ `- 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.`,
1266
+ );
1267
+ }
1268
+ } else {
1269
+ const parts = final_target.split("\n\n");
1270
+ if (
1271
+ parts.length >= 2 &&
1272
+ parts[0].trim() !== "" &&
1273
+ parts[parts.length - 1].trim() !== ""
1274
+ ) {
1275
+ errors.push(
1276
+ `- 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.`,
1277
+ );
1278
+ }
1279
+ }
1280
+ }
1281
+ }
1282
+
1088
1283
  for (const [start, length] of matches) {
1089
1284
  const spans = this.mapper.spans.filter(
1090
1285
  (s) => s.end > start && s.start < start + length,
@@ -1111,6 +1306,7 @@ export class RedlineEngine {
1111
1306
  }
1112
1307
  return errors;
1113
1308
  }
1309
+
1114
1310
  public validate_review_actions(actions: any[]): string[] {
1115
1311
  const errors: string[] = [];
1116
1312
  for (let i = 0; i < actions.length; i++) {
@@ -1151,7 +1347,41 @@ export class RedlineEngine {
1151
1347
  }
1152
1348
  return errors;
1153
1349
  }
1154
- public process_batch(changes: DocumentChange[]): any {
1350
+
1351
+ public process_batch(
1352
+ changes: DocumentChange[],
1353
+ dry_run: boolean = false,
1354
+ ): any {
1355
+ if (dry_run) {
1356
+ const baselines = new Map<any, Element>();
1357
+ for (const part of this.doc.pkg.parts) {
1358
+ if (part._element) {
1359
+ baselines.set(part, part._element.cloneNode(true) as Element);
1360
+ }
1361
+ }
1362
+ try {
1363
+ return this._process_batch_internal(changes, true);
1364
+ } finally {
1365
+ for (const [part, originalEl] of baselines.entries()) {
1366
+ const doc = part._element.ownerDocument;
1367
+ if (doc && doc.documentElement) {
1368
+ doc.replaceChild(originalEl, doc.documentElement);
1369
+ }
1370
+ part._element = originalEl;
1371
+ }
1372
+ this.mapper = new DocumentMapper(this.doc);
1373
+ this.comments_manager = new CommentsManager(this.doc);
1374
+ this.clean_mapper = null;
1375
+ }
1376
+ } else {
1377
+ return this._process_batch_internal(changes, false);
1378
+ }
1379
+ }
1380
+
1381
+ private _process_batch_internal(
1382
+ changes: DocumentChange[],
1383
+ dry_run_mode: boolean = false,
1384
+ ): any {
1155
1385
  this.skipped_details = [];
1156
1386
  const actions = changes.filter((c) =>
1157
1387
  ["accept", "reject", "reply"].includes(c.type),
@@ -1160,37 +1390,131 @@ export class RedlineEngine {
1160
1390
  (c) => !["accept", "reject", "reply"].includes(c.type),
1161
1391
  );
1162
1392
 
1163
- const all_errors: string[] = [];
1164
-
1165
- if (actions.length > 0) {
1166
- all_errors.push(...this.validate_review_actions(actions));
1167
- }
1168
- if (edits.length > 0) {
1169
- all_errors.push(...this.validate_edits(edits));
1170
- }
1171
-
1172
- if (all_errors.length > 0) {
1173
- throw new BatchValidationError(all_errors);
1393
+ // BUG-7: Unified single-pass validation in wet-run / standard mode
1394
+ if (!dry_run_mode) {
1395
+ const all_errors: string[] = [];
1396
+ if (actions.length > 0) {
1397
+ all_errors.push(...this.validate_review_actions(actions));
1398
+ }
1399
+ if (edits.length > 0) {
1400
+ all_errors.push(...this.validate_edits(edits));
1401
+ }
1402
+ if (all_errors.length > 0) {
1403
+ throw new BatchValidationError(all_errors);
1404
+ }
1405
+ } else {
1406
+ if (actions.length > 0) {
1407
+ const action_errors = this.validate_review_actions(actions);
1408
+ if (action_errors.length > 0) {
1409
+ throw new BatchValidationError(action_errors);
1410
+ }
1411
+ }
1174
1412
  }
1175
1413
 
1176
- let applied_actions = 0,
1177
- skipped_actions = 0;
1414
+ let applied_actions = 0;
1415
+ let skipped_actions = 0;
1178
1416
  if (actions.length > 0) {
1179
1417
  const res = this.apply_review_actions(actions);
1180
1418
  applied_actions = res[0];
1181
1419
  skipped_actions = res[1];
1420
+ if (skipped_actions > 0) {
1421
+ throw new BatchValidationError(this.skipped_details);
1422
+ }
1182
1423
  if (applied_actions > 0) {
1183
1424
  this.mapper["_build_map"]();
1184
1425
  if (this.clean_mapper) this.clean_mapper["_build_map"]();
1185
1426
  }
1186
1427
  }
1187
1428
 
1188
- let applied_edits = 0,
1189
- skipped_edits = 0;
1429
+ const edits_reports: any[] = [];
1430
+ let applied_edits = 0;
1431
+ let skipped_edits = 0;
1432
+
1190
1433
  if (edits.length > 0) {
1191
- const res = this.apply_edits(edits as any[]);
1192
- applied_edits = res[0];
1193
- skipped_edits = res[1];
1434
+ if (dry_run_mode) {
1435
+ for (const edit of edits) {
1436
+ const single_errors = this.validate_edits([edit]);
1437
+ const warning = this._check_punctuation_warning(
1438
+ (edit as any).target_text || "",
1439
+ );
1440
+ if (single_errors.length > 0) {
1441
+ skipped_edits++;
1442
+ edits_reports.push({
1443
+ status: "failed",
1444
+ target_text: (edit as any).target_text || "",
1445
+ new_text: (edit as any).new_text || "",
1446
+ warning: warning,
1447
+ error: single_errors[0],
1448
+ critic_markup: null,
1449
+ clean_text: null,
1450
+ });
1451
+ continue;
1452
+ }
1453
+ const res = this.apply_edits([edit]);
1454
+ const applied = res[0];
1455
+ if (applied > 0) {
1456
+ applied_edits++;
1457
+ const previews = this._build_edit_context_previews(edit);
1458
+ edits_reports.push({
1459
+ status: "applied",
1460
+ target_text: (edit as any).target_text || "",
1461
+ new_text: (edit as any).new_text || "",
1462
+ warning: warning,
1463
+ error: null,
1464
+ critic_markup: previews[0],
1465
+ clean_text: previews[1],
1466
+ });
1467
+ } else {
1468
+ skipped_edits++;
1469
+ const error_msg =
1470
+ this.skipped_details.length > 0
1471
+ ? this.skipped_details[this.skipped_details.length - 1]
1472
+ : "Failed to apply edit";
1473
+ edits_reports.push({
1474
+ status: "failed",
1475
+ target_text: (edit as any).target_text || "",
1476
+ new_text: (edit as any).new_text || "",
1477
+ warning: warning,
1478
+ error: error_msg,
1479
+ critic_markup: null,
1480
+ clean_text: null,
1481
+ });
1482
+ }
1483
+ }
1484
+ } else {
1485
+ const errors = this.validate_edits(edits);
1486
+ if (errors.length > 0) {
1487
+ throw new BatchValidationError(errors);
1488
+ }
1489
+ const cloned_edits = edits.map((e) => JSON.parse(JSON.stringify(e)));
1490
+ const res = this.apply_edits(cloned_edits);
1491
+ applied_edits = res[0];
1492
+ skipped_edits = res[1];
1493
+
1494
+ for (const edit of cloned_edits) {
1495
+ const success = (edit as any)._applied_status || false;
1496
+ const error_msg = (edit as any)._error_msg || null;
1497
+ const warning = this._check_punctuation_warning(
1498
+ (edit as any).target_text || "",
1499
+ );
1500
+ let critic_markup = null;
1501
+ let clean_text = null;
1502
+ if (success) {
1503
+ const previews = this._build_edit_context_previews(edit);
1504
+ critic_markup = previews[0];
1505
+ clean_text = previews[1];
1506
+ }
1507
+ edits_reports.push({
1508
+ status: success ? "applied" : "failed",
1509
+ target_text: (edit as any).target_text || "",
1510
+ new_text: (edit as any).new_text || "",
1511
+ warning: warning,
1512
+ error: error_msg,
1513
+ critic_markup: critic_markup,
1514
+ clean_text: clean_text,
1515
+ });
1516
+ }
1517
+ }
1194
1518
  }
1195
1519
 
1196
1520
  return {
@@ -1199,6 +1523,9 @@ export class RedlineEngine {
1199
1523
  edits_applied: applied_edits,
1200
1524
  edits_skipped: skipped_edits,
1201
1525
  skipped_details: this.skipped_details,
1526
+ edits: edits_reports,
1527
+ engine: "node",
1528
+ version: "1.10.0",
1202
1529
  };
1203
1530
  }
1204
1531
 
@@ -1207,47 +1534,90 @@ export class RedlineEngine {
1207
1534
  let skipped = 0;
1208
1535
  const resolved_edits: [any, string | null][] = [];
1209
1536
 
1537
+ for (const edit of edits) {
1538
+ edit._applied_status = false;
1539
+ edit._error_msg = null;
1540
+ }
1541
+
1210
1542
  for (const edit of edits) {
1211
1543
  if (
1544
+ edit._resolved_start_idx !== undefined &&
1545
+ edit._resolved_start_idx !== null
1546
+ ) {
1547
+ resolved_edits.push([edit, edit.new_text || null]);
1548
+ } else if (
1212
1549
  edit._match_start_index !== undefined &&
1213
1550
  edit._match_start_index !== null
1214
1551
  ) {
1552
+ edit._resolved_start_idx = edit._match_start_index;
1215
1553
  resolved_edits.push([edit, edit.new_text || null]);
1216
1554
  } else if (edit.type === "insert_row" || edit.type === "delete_row") {
1217
- const [idx] = this.mapper.find_match_index(edit.target_text);
1218
- if (idx !== -1) {
1219
- edit._match_start_index = idx;
1555
+ let matches = this.mapper.find_all_match_indices(edit.target_text);
1556
+ if (matches.length === 0) {
1557
+ if (!this.clean_mapper) {
1558
+ this.clean_mapper = new DocumentMapper(this.doc, true);
1559
+ }
1560
+ matches = this.clean_mapper.find_all_match_indices(edit.target_text);
1561
+ }
1562
+
1563
+ if (matches.length > 0) {
1564
+ edit._resolved_start_idx = matches[0][0];
1220
1565
  resolved_edits.push([edit, null]);
1221
1566
  } else {
1222
1567
  skipped++;
1223
- this.skipped_details.push(
1224
- `- Failed to locate row target: '${(edit.target_text || "").substring(0, 40)}...'`,
1225
- );
1568
+ edit._applied_status = false;
1569
+ const target_snippet = (edit.target_text || "")
1570
+ .trim()
1571
+ .substring(0, 40);
1572
+ const msg = `- Failed to locate row target: '${target_snippet}...'`;
1573
+ this.skipped_details.push(msg);
1574
+ edit._error_msg = msg;
1226
1575
  }
1227
1576
  } else {
1228
1577
  const resolved = this._pre_resolve_heuristic_edit(edit);
1229
1578
  if (resolved) {
1230
1579
  if (Array.isArray(resolved)) {
1231
- for (const r of resolved) resolved_edits.push([r, r.new_text]);
1580
+ for (const r of resolved) {
1581
+ r._resolved_start_idx = r._match_start_index;
1582
+ r._parent_edit_ref = edit;
1583
+ if (
1584
+ edit._resolved_start_idx === undefined ||
1585
+ edit._resolved_start_idx === null
1586
+ ) {
1587
+ edit._resolved_start_idx = r._resolved_start_idx;
1588
+ }
1589
+ if (!edit._resolved_proxy_edit) {
1590
+ edit._resolved_proxy_edit = r;
1591
+ }
1592
+ resolved_edits.push([r, r.new_text]);
1593
+ }
1232
1594
  } else {
1595
+ resolved._resolved_start_idx = resolved._match_start_index;
1596
+ resolved._parent_edit_ref = edit;
1597
+ edit._resolved_start_idx = resolved._resolved_start_idx;
1598
+ edit._resolved_proxy_edit = resolved;
1233
1599
  resolved_edits.push([resolved, (resolved as any).new_text]);
1234
1600
  }
1235
1601
  } else {
1236
1602
  skipped++;
1237
- this.skipped_details.push(
1238
- `- Failed to apply edit targeting: '${(edit.target_text || "insertion").substring(0, 40)}...'`,
1239
- );
1603
+ edit._applied_status = false;
1604
+ const display_text = edit.target_text || "insertion";
1605
+ const target_snippet = display_text.trim().substring(0, 40);
1606
+ const msg = `- Failed to apply edit targeting: '${target_snippet}...'`;
1607
+ this.skipped_details.push(msg);
1608
+ edit._error_msg = msg;
1240
1609
  }
1241
1610
  }
1242
1611
  }
1243
1612
 
1244
1613
  resolved_edits.sort(
1245
- (a, b) => (b[0]._match_start_index || 0) - (a[0]._match_start_index || 0),
1614
+ (a, b) =>
1615
+ (b[0]._resolved_start_idx || 0) - (a[0]._resolved_start_idx || 0),
1246
1616
  );
1247
1617
  const occupied_ranges: [number, number][] = [];
1248
1618
 
1249
1619
  for (const [edit, orig_new] of resolved_edits) {
1250
- const start = edit._match_start_index || 0;
1620
+ const start = edit._resolved_start_idx || 0;
1251
1621
  const end = start + (edit.target_text ? edit.target_text.length : 0);
1252
1622
 
1253
1623
  const overlaps = occupied_ranges.some(
@@ -1255,9 +1625,17 @@ export class RedlineEngine {
1255
1625
  );
1256
1626
  if (overlaps) {
1257
1627
  skipped++;
1258
- this.skipped_details.push(
1259
- `- Skipped overlapping edit targeting: '${(edit.target_text || "insertion").substring(0, 40)}...'`,
1260
- );
1628
+ const display_text = edit.target_text || "insertion";
1629
+ const target_snippet = display_text.trim().substring(0, 40);
1630
+ const msg = `- Skipped overlapping edit targeting: '${target_snippet}...'`;
1631
+ this.skipped_details.push(msg);
1632
+ edit._applied_status = false;
1633
+ edit._error_msg = msg;
1634
+ const parent = edit._parent_edit_ref;
1635
+ if (parent) {
1636
+ parent._applied_status = false;
1637
+ parent._error_msg = msg;
1638
+ }
1261
1639
  continue;
1262
1640
  }
1263
1641
 
@@ -1271,11 +1649,26 @@ export class RedlineEngine {
1271
1649
  if (success) {
1272
1650
  applied++;
1273
1651
  occupied_ranges.push([start, end]);
1652
+ edit._applied_status = true;
1653
+ const parent = edit._parent_edit_ref;
1654
+ if (parent) {
1655
+ parent._applied_status = true;
1656
+ }
1274
1657
  } else {
1275
1658
  skipped++;
1276
- this.skipped_details.push(
1277
- `- Failed to apply edit targeting: '${(edit.target_text || "insertion").substring(0, 40)}...'`,
1278
- );
1659
+ const display_text = edit.target_text || "insertion";
1660
+ const target_snippet = display_text.trim().substring(0, 40);
1661
+ const msg = `- Failed to apply edit targeting: '${target_snippet}...'`;
1662
+ this.skipped_details.push(msg);
1663
+ edit._applied_status = false;
1664
+ edit._error_msg = msg;
1665
+ const parent = edit._parent_edit_ref;
1666
+ if (parent) {
1667
+ if (!parent._applied_status) {
1668
+ parent._applied_status = false;
1669
+ parent._error_msg = msg;
1670
+ }
1671
+ }
1279
1672
  }
1280
1673
  }
1281
1674
 
@@ -1378,7 +1771,11 @@ export class RedlineEngine {
1378
1771
  }
1379
1772
 
1380
1773
  private _apply_table_edit(edit: any, rebuild_map: boolean): boolean {
1381
- const start_idx = edit._match_start_index || 0;
1774
+ const start_idx =
1775
+ edit._resolved_start_idx !== undefined &&
1776
+ edit._resolved_start_idx !== null
1777
+ ? edit._resolved_start_idx
1778
+ : edit._match_start_index || 0;
1382
1779
  const [anchor_run, anchor_para] = this.mapper.get_insertion_anchor(
1383
1780
  start_idx,
1384
1781
  rebuild_map,
@@ -1427,10 +1824,33 @@ export class RedlineEngine {
1427
1824
  return false;
1428
1825
  }
1429
1826
 
1827
+ /**
1828
+ * Returns the first match of `target_text` in the raw mapper that is NOT
1829
+ * entirely contained within a tracked deletion (<w:del>). Tracked-deleted
1830
+ * copies are not live, editable text, so an edit must resolve to a live
1831
+ * occurrence even when a dead copy appears earlier in the document
1832
+ * (BUG-23-5). Falls back to the plain first match when no live copy is
1833
+ * found (e.g. fuzzy/normalized matches the span filter cannot align).
1834
+ */
1835
+ private _first_live_match(target_text: string): [number, number] {
1836
+ const all = this.mapper.find_all_match_indices(target_text);
1837
+ if (all.length <= 1) {
1838
+ return this.mapper.find_match_index(target_text);
1839
+ }
1840
+ for (const [start, length] of all) {
1841
+ const realSpans = this.mapper.spans.filter(
1842
+ (s) => s.run !== null && s.end > start && s.start < start + length,
1843
+ );
1844
+ if (realSpans.length === 0) return [start, length];
1845
+ if (realSpans.some((s) => !s.del_id)) return [start, length];
1846
+ }
1847
+ return this.mapper.find_match_index(target_text);
1848
+ }
1849
+
1430
1850
  private _pre_resolve_heuristic_edit(edit: any): any {
1431
1851
  if (!edit.target_text) return null;
1432
1852
 
1433
- let [start_idx, match_len] = this.mapper.find_match_index(edit.target_text);
1853
+ let [start_idx, match_len] = this._first_live_match(edit.target_text);
1434
1854
  let use_clean_map = false;
1435
1855
 
1436
1856
  if (start_idx === -1) {
@@ -1510,7 +1930,11 @@ export class RedlineEngine {
1510
1930
  ): boolean {
1511
1931
  let op = edit._internal_op;
1512
1932
  const active_mapper = edit._active_mapper_ref || this.mapper;
1513
- const start_idx = edit._match_start_index || 0;
1933
+ const start_idx =
1934
+ edit._resolved_start_idx !== undefined &&
1935
+ edit._resolved_start_idx !== null
1936
+ ? edit._resolved_start_idx
1937
+ : edit._match_start_index || 0;
1514
1938
  const length = edit.target_text ? edit.target_text.length : 0;
1515
1939
 
1516
1940
  const del_id = ["DELETION", "MODIFICATION"].includes(op)
@@ -1576,6 +2000,90 @@ export class RedlineEngine {
1576
2000
  );
1577
2001
  if (!anchor_run && !anchor_para) return false;
1578
2002
 
2003
+ // BUG-23-3: a prefix insertion whose new_text ends in a paragraph break
2004
+ // (e.g. "Summary\n\n" inserted before "Conclusion") must become a NEW
2005
+ // paragraph placed BEFORE the anchor paragraph, not inline text merged
2006
+ // into a neighbouring paragraph. _track_insert_multiline drops the
2007
+ // trailing break and inlines the remainder, which both loses the
2008
+ // paragraph boundary and mis-orders the content. Handle this case here.
2009
+ const _bug233_new = edit.new_text || "";
2010
+ const _bug233_trailing_break = /\n\s*$/.test(_bug233_new);
2011
+ let _bug233_target_para: Element | null = null;
2012
+ {
2013
+ const startingSpans = active_mapper.spans.filter(
2014
+ (s: TextSpan) => s.paragraph !== null && s.start === start_idx,
2015
+ );
2016
+ if (startingSpans.length > 0 && startingSpans[0].paragraph) {
2017
+ _bug233_target_para = startingSpans[0].paragraph._element;
2018
+ }
2019
+ }
2020
+ if (
2021
+ _bug233_trailing_break &&
2022
+ _bug233_target_para &&
2023
+ _bug233_target_para.parentNode
2024
+ ) {
2025
+ const body = _bug233_target_para.parentNode as Element;
2026
+ const xmlDoc = this.doc.part._element.ownerDocument!;
2027
+ const lines = _bug233_new
2028
+ .split(/[\r\n]+/)
2029
+ .filter((l: string) => l !== "");
2030
+ let firstNew: Element | null = null;
2031
+ let lastNew: Element | null = null;
2032
+ let lastIns: Element | null = null;
2033
+ for (const raw_line of lines) {
2034
+ const [clean_text, style_name] = this._parse_markdown_style(raw_line);
2035
+ const new_p = xmlDoc.createElement("w:p");
2036
+ if (style_name) {
2037
+ this._set_paragraph_style(new_p, style_name);
2038
+ } else {
2039
+ const existing_pPr = findChild(_bug233_target_para, "w:pPr");
2040
+ if (existing_pPr) new_p.appendChild(existing_pPr.cloneNode(true));
2041
+ }
2042
+ let pPr = findChild(new_p, "w:pPr");
2043
+ if (!pPr) {
2044
+ pPr = xmlDoc.createElement("w:pPr");
2045
+ new_p.insertBefore(pPr, new_p.firstChild);
2046
+ }
2047
+ let rPr = findChild(pPr, "w:rPr");
2048
+ if (!rPr) {
2049
+ rPr = xmlDoc.createElement("w:rPr");
2050
+ pPr.appendChild(rPr);
2051
+ }
2052
+ rPr.appendChild(this._create_track_change_tag("w:ins", "", ins_id!));
2053
+ const content_ins = this._build_tracked_ins_for_line(
2054
+ clean_text,
2055
+ anchor_run,
2056
+ ins_id!,
2057
+ xmlDoc,
2058
+ );
2059
+ if (content_ins) new_p.appendChild(content_ins);
2060
+ body.insertBefore(new_p, _bug233_target_para);
2061
+ if (!firstNew) firstNew = new_p;
2062
+ lastNew = new_p;
2063
+ lastIns = content_ins;
2064
+ }
2065
+ if (firstNew) {
2066
+ if (edit.comment && lastNew && lastIns) {
2067
+ const ascend = (el: Element, p: Element): Element => {
2068
+ let cur: Element = el;
2069
+ while (cur.parentNode && cur.parentNode !== p)
2070
+ cur = cur.parentNode as Element;
2071
+ return cur;
2072
+ };
2073
+ const startIns =
2074
+ findAllDescendants(firstNew, "w:ins")[0] || firstNew;
2075
+ this._attach_comment_spanning(
2076
+ firstNew,
2077
+ ascend(startIns, firstNew),
2078
+ lastNew,
2079
+ ascend(lastIns, lastNew),
2080
+ edit.comment,
2081
+ );
2082
+ }
2083
+ return true;
2084
+ }
2085
+ }
2086
+
1579
2087
  const result = this._track_insert_multiline(
1580
2088
  edit.new_text || "",
1581
2089
  anchor_run,
@@ -1620,7 +2128,9 @@ export class RedlineEngine {
1620
2128
  if (start_p) {
1621
2129
  let first_anchor_target = result.first_node;
1622
2130
  if (result.first_node.tagName === "w:p") {
1623
- first_anchor_target = findAllDescendants(result.first_node, "w:ins")[0] || result.first_node;
2131
+ first_anchor_target =
2132
+ findAllDescendants(result.first_node, "w:ins")[0] ||
2133
+ result.first_node;
1624
2134
  }
1625
2135
  const start_anchor = ascend_to_paragraph_child(
1626
2136
  first_anchor_target,
@@ -1637,22 +2147,27 @@ export class RedlineEngine {
1637
2147
  end_anchor,
1638
2148
  edit.comment,
1639
2149
  );
1640
- }
1641
- } else {
1642
- // Inline only: anchor around first_node in its host paragraph.
1643
- let host_p: Element | null = result.first_node;
1644
- while (host_p && host_p.tagName !== "w:p")
2150
+ }
2151
+ } else {
2152
+ // Inline only: anchor around first_node in its host paragraph.
2153
+ let host_p: Element | null = result.first_node;
2154
+ while (host_p && host_p.tagName !== "w:p")
1645
2155
  host_p = host_p.parentNode as Element;
1646
- if (host_p) {
2156
+ if (host_p) {
1647
2157
  let first_anchor_target = result.first_node;
1648
2158
  if (result.first_node.tagName === "w:p") {
1649
- first_anchor_target = findAllDescendants(result.first_node, "w:ins")[0] || result.first_node;
2159
+ first_anchor_target =
2160
+ findAllDescendants(result.first_node, "w:ins")[0] ||
2161
+ result.first_node;
1650
2162
  }
1651
- const anchor = ascend_to_paragraph_child(first_anchor_target, host_p);
2163
+ const anchor = ascend_to_paragraph_child(
2164
+ first_anchor_target,
2165
+ host_p,
2166
+ );
1652
2167
  this._attach_comment(host_p, anchor, anchor, edit.comment);
1653
- }
1654
2168
  }
1655
2169
  }
2170
+ }
1656
2171
  return true;
1657
2172
  }
1658
2173
 
@@ -1662,7 +2177,10 @@ export class RedlineEngine {
1662
2177
  length,
1663
2178
  rebuild_map,
1664
2179
  );
1665
- const virtual_spans = active_mapper.get_virtual_spans_in_range(start_idx, length);
2180
+ const virtual_spans = active_mapper.get_virtual_spans_in_range(
2181
+ start_idx,
2182
+ length,
2183
+ );
1666
2184
 
1667
2185
  if (target_runs.length === 0 && virtual_spans.length === 0) return false;
1668
2186
 
@@ -1738,18 +2256,26 @@ export class RedlineEngine {
1738
2256
 
1739
2257
  // PHASE 2: OOXML Paragraph Merge Protocol
1740
2258
  if (op === "DELETION" || op === "MODIFICATION") {
1741
- if (op === "MODIFICATION" && target_runs.length === 0 && virtual_spans.length > 0 && edit.new_text) {
2259
+ if (
2260
+ op === "MODIFICATION" &&
2261
+ target_runs.length === 0 &&
2262
+ virtual_spans.length > 0 &&
2263
+ edit.new_text
2264
+ ) {
1742
2265
  const first_span = virtual_spans[0];
1743
2266
  if (first_span.paragraph) {
1744
2267
  const p1_el = first_span.paragraph._element;
1745
2268
  const last_runs = findAllDescendants(p1_el, "w:r");
1746
- const anchor = last_runs.length > 0 ? new Run(last_runs[last_runs.length - 1], first_span.paragraph) : null;
1747
-
2269
+ const anchor =
2270
+ last_runs.length > 0
2271
+ ? new Run(last_runs[last_runs.length - 1], first_span.paragraph)
2272
+ : null;
2273
+
1748
2274
  const result = this._track_insert_multiline(
1749
2275
  edit.new_text,
1750
2276
  anchor,
1751
2277
  first_span.paragraph,
1752
- ins_id!
2278
+ ins_id!,
1753
2279
  );
1754
2280
  if (result.first_node) {
1755
2281
  p1_el.appendChild(result.first_node);
@@ -1769,7 +2295,10 @@ export class RedlineEngine {
1769
2295
  let pPr = findChild(p1_element, "w:pPr");
1770
2296
  if (!pPr) {
1771
2297
  pPr = p1_element.ownerDocument!.createElement("w:pPr") as Element;
1772
- p1_element.insertBefore(pPr, p1_element.firstChild as Node | null);
2298
+ p1_element.insertBefore(
2299
+ pPr,
2300
+ p1_element.firstChild as Node | null,
2301
+ );
1773
2302
  }
1774
2303
  let rPr = findChild(pPr!, "w:rPr");
1775
2304
  if (!rPr) {
@@ -1781,7 +2310,10 @@ export class RedlineEngine {
1781
2310
 
1782
2311
  const children = Array.from(p2_element.childNodes);
1783
2312
  for (const child of children) {
1784
- if (child.nodeType === 1 && (child as Element).tagName === "w:pPr") {
2313
+ if (
2314
+ child.nodeType === 1 &&
2315
+ (child as Element).tagName === "w:pPr"
2316
+ ) {
1785
2317
  continue;
1786
2318
  }
1787
2319
  p1_element.appendChild(child);