@adeu/core 1.18.4 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adeu/core",
3
- "version": "1.18.4",
3
+ "version": "1.19.0",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/engine.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  ReplyComment,
12
12
  DocumentChange,
13
13
  } from "./models.js";
14
- import { trim_common_context } from "./diff.js";
14
+ import { trim_common_context, generate_edits_from_text } from "./diff.js";
15
15
  import { findChild, findAllDescendants, serializeXml } from "./docx/dom.js";
16
16
  import { split_structural_appendix, paginate } from "./pagination.js";
17
17
  import {
@@ -345,6 +345,90 @@ export class RedlineEngine {
345
345
  }
346
346
  return null;
347
347
  }
348
+
349
+ private _word_diff_sub_edits(
350
+ target_str: string,
351
+ new_str: string,
352
+ base_offset: number,
353
+ parent_comment: string | null = null,
354
+ is_table: boolean = false,
355
+ active_mapper: any = null,
356
+ ): any[] {
357
+ let raw_sub_edits: any[] = [];
358
+ try {
359
+ raw_sub_edits = generate_edits_from_text(target_str, new_str);
360
+ console.log("_word_diff_sub_edits RAW_SUB_EDITS:", JSON.stringify(raw_sub_edits, null, 2));
361
+ } catch (e) {
362
+ console.warn("generate_edits_from_text failed, falling back to wholesale edit", e);
363
+ raw_sub_edits = [];
364
+ }
365
+
366
+ if (!raw_sub_edits || raw_sub_edits.length === 0) {
367
+ const fallback_edit: any = {
368
+ type: "modify",
369
+ target_text: target_str,
370
+ new_text: new_str,
371
+ comment: parent_comment,
372
+ };
373
+ fallback_edit._resolved_start_idx = base_offset;
374
+ fallback_edit._match_start_index = base_offset;
375
+ fallback_edit._active_mapper_ref = active_mapper;
376
+ if (is_table) {
377
+ fallback_edit._is_table_edit = true;
378
+ }
379
+ if (target_str === new_str) {
380
+ fallback_edit._internal_op = "COMMENT_ONLY";
381
+ } else if (!target_str && new_str) {
382
+ fallback_edit._internal_op = "INSERTION";
383
+ } else if (target_str && !new_str) {
384
+ fallback_edit._internal_op = "DELETION";
385
+ } else if (target_str && new_str) {
386
+ fallback_edit._internal_op = "MODIFICATION";
387
+ } else {
388
+ fallback_edit._internal_op = "COMMENT_ONLY";
389
+ }
390
+ return [fallback_edit];
391
+ }
392
+
393
+ const sub_edits: any[] = [];
394
+ let comment_assigned = false;
395
+ for (const raw_edit of raw_sub_edits) {
396
+ const sub_start = base_offset + (raw_edit._match_start_index || 0);
397
+ const should_attach_comment = (parent_comment !== null) && !comment_assigned;
398
+ if (should_attach_comment) {
399
+ comment_assigned = true;
400
+ }
401
+
402
+ const sub_edit: any = {
403
+ type: "modify",
404
+ target_text: raw_edit.target_text,
405
+ new_text: raw_edit.new_text,
406
+ comment: should_attach_comment ? parent_comment : null,
407
+ };
408
+ sub_edit._resolved_start_idx = sub_start;
409
+ sub_edit._match_start_index = sub_start;
410
+ sub_edit._active_mapper_ref = active_mapper;
411
+ if (is_table) {
412
+ sub_edit._is_table_edit = true;
413
+ }
414
+
415
+ const t_val = raw_edit.target_text;
416
+ const n_val = raw_edit.new_text;
417
+ if (!t_val && n_val) {
418
+ sub_edit._internal_op = "INSERTION";
419
+ } else if (t_val && !n_val) {
420
+ sub_edit._internal_op = "DELETION";
421
+ } else if (t_val && n_val) {
422
+ sub_edit._internal_op = "MODIFICATION";
423
+ } else {
424
+ sub_edit._internal_op = "COMMENT_ONLY";
425
+ }
426
+
427
+ sub_edits.push(sub_edit);
428
+ }
429
+
430
+ return sub_edits;
431
+ }
348
432
  /**
349
433
  * Best-effort "did you mean" hint for a failed target. The common loop trap
350
434
  * (observed in the field) is an anchored regex like `^\( x \)$` against a
@@ -1525,6 +1609,18 @@ export class RedlineEngine {
1525
1609
  continue;
1526
1610
  }
1527
1611
  if (!edit.target_text) continue;
1612
+ // Caller-pinned indexes (e.g. generate_edits_from_text output) resolve
1613
+ // by position, not content: ambiguity / not-found checks are meaningless
1614
+ // for them and false-positive whenever the target coincidentally matches
1615
+ // unrelated text (a comment timestamp, an earlier redline). The
1616
+ // string-shape checks above still apply.
1617
+ if (
1618
+ (edit._match_start_index !== undefined &&
1619
+ edit._match_start_index !== null) ||
1620
+ (edit._resolved_start_idx !== undefined &&
1621
+ edit._resolved_start_idx !== null)
1622
+ )
1623
+ continue;
1528
1624
 
1529
1625
  const is_regex = (edit as any).regex || false;
1530
1626
  const match_mode = (edit as any).match_mode || "strict";
@@ -1953,9 +2049,41 @@ export class RedlineEngine {
1953
2049
  let skipped_edits = 0;
1954
2050
 
1955
2051
  if (edits.length > 0) {
2052
+ // Sequential application rebuilds the mapper after every applied edit,
2053
+ // shifting every position at/after that edit. Caller-pinned indexes
2054
+ // (_match_start_index / _resolved_start_idx, e.g. generate_edits_from_text
2055
+ // output) are coordinates in the INITIAL document state, so apply indexed
2056
+ // edits bottom-up first — positions below an applied edit never move (the
2057
+ // same invariant apply_edits' reverse sweep relies on) — then let
2058
+ // text-anchored edits re-resolve against the mutated text as before.
2059
+ // Reports are keyed by `i` so they stay in batch order.
2060
+ const pinned_idx = (e: any): number | null => {
2061
+ if (
2062
+ e._resolved_start_idx !== undefined &&
2063
+ e._resolved_start_idx !== null
2064
+ )
2065
+ return e._resolved_start_idx;
2066
+ if (
2067
+ e._match_start_index !== undefined &&
2068
+ e._match_start_index !== null
2069
+ )
2070
+ return e._match_start_index;
2071
+ return null;
2072
+ };
2073
+ const ordered_edits = edits
2074
+ .map((edit, i) => ({ edit: edit as any, i }))
2075
+ .sort((a, b) => {
2076
+ const ka = pinned_idx(a.edit);
2077
+ const kb = pinned_idx(b.edit);
2078
+ if (ka === null && kb === null) return a.i - b.i;
2079
+ if (ka === null) return 1;
2080
+ if (kb === null) return -1;
2081
+ return kb - ka || a.i - b.i;
2082
+ });
2083
+
1956
2084
  if (dry_run_mode) {
1957
- for (let i = 0; i < edits.length; i++) {
1958
- const edit = edits[i];
2085
+ const reports_by_input: any[] = new Array(edits.length);
2086
+ for (const { edit, i } of ordered_edits) {
1959
2087
  const single_errors = this.validate_edits([edit], i);
1960
2088
  if (single_errors.length > 0) {
1961
2089
  skipped_edits++;
@@ -1967,7 +2095,7 @@ export class RedlineEngine {
1967
2095
  const warning = this._check_punctuation_warning(
1968
2096
  (edit as any).target_text || "",
1969
2097
  );
1970
- edits_reports.push({
2098
+ reports_by_input[i] = {
1971
2099
  status: "failed",
1972
2100
  target_text: (edit as any).target_text || "",
1973
2101
  new_text: (edit as any).new_text || "",
@@ -1975,14 +2103,14 @@ export class RedlineEngine {
1975
2103
  error: single_errors[0],
1976
2104
  critic_markup: null,
1977
2105
  clean_text: null,
1978
- });
2106
+ };
1979
2107
  continue;
1980
2108
  }
1981
2109
  const res = this.apply_edits([edit], page_offsets);
1982
2110
  if ((edit as any)._applied_status) {
1983
2111
  applied_edits++;
1984
2112
  const previews = this._build_edit_context_previews(edit);
1985
- edits_reports.push({
2113
+ reports_by_input[i] = {
1986
2114
  status: "applied",
1987
2115
  target_text: (edit as any).target_text || "",
1988
2116
  new_text: (edit as any).new_text || "",
@@ -1994,7 +2122,7 @@ export class RedlineEngine {
1994
2122
  heading_path: (edit as any)._heading_path || "",
1995
2123
  occurrences_modified: (edit as any)._occurrences_modified || 0,
1996
2124
  match_mode: (edit as any).match_mode || "strict",
1997
- });
2125
+ };
1998
2126
  this.mapper = new DocumentMapper(this.doc);
1999
2127
  this.clean_mapper = null;
2000
2128
  } else {
@@ -2006,7 +2134,7 @@ export class RedlineEngine {
2006
2134
  const warning = this._check_punctuation_warning(
2007
2135
  (edit as any).target_text || "",
2008
2136
  );
2009
- edits_reports.push({
2137
+ reports_by_input[i] = {
2010
2138
  status: "failed",
2011
2139
  target_text: (edit as any).target_text || "",
2012
2140
  new_text: (edit as any).new_text || "",
@@ -2014,17 +2142,17 @@ export class RedlineEngine {
2014
2142
  error: error_msg,
2015
2143
  critic_markup: null,
2016
2144
  clean_text: null,
2017
- });
2145
+ };
2018
2146
  }
2019
2147
  }
2148
+ edits_reports.push(...reports_by_input);
2020
2149
  } else {
2021
2150
  // Simulated dry-run sequentially for wet-run validation parity
2022
2151
  const snapshot = takeSnapshot(this.doc);
2023
2152
  const originalCurrentId = this.current_id;
2024
2153
  try {
2025
2154
  const sequential_errors: string[] = [];
2026
- for (let i = 0; i < edits.length; i++) {
2027
- const edit = edits[i];
2155
+ for (const { edit, i } of ordered_edits) {
2028
2156
  const single_errors = this.validate_edits([edit], i);
2029
2157
  if (single_errors.length > 0) {
2030
2158
  sequential_errors.push(...single_errors);
@@ -2224,7 +2352,8 @@ export class RedlineEngine {
2224
2352
 
2225
2353
  let success = false;
2226
2354
  if (edit.type === "modify") {
2227
- success = this._apply_single_edit_indexed(edit, orig_new, false);
2355
+ const rebuild = edit._split_group_id !== undefined && edit._split_group_id !== null;
2356
+ success = this._apply_single_edit_indexed(edit, orig_new, rebuild);
2228
2357
  } else if (edit.type === "insert_row" || edit.type === "delete_row") {
2229
2358
  success = this._apply_table_edit(edit, false);
2230
2359
  }
@@ -2591,6 +2720,97 @@ export class RedlineEngine {
2591
2720
  continue;
2592
2721
  }
2593
2722
 
2723
+ let overlaps_virtual_pipe = false;
2724
+ if (active_mapper) {
2725
+ overlaps_virtual_pipe = active_mapper.spans.some(
2726
+ (s: any) =>
2727
+ s.text === " | " &&
2728
+ (s.run === null || s.run === undefined) &&
2729
+ s.start < start_idx + match_len &&
2730
+ s.end > start_idx,
2731
+ );
2732
+ }
2733
+
2734
+ if (overlaps_virtual_pipe) {
2735
+ const actual_cells = actual_doc_text.split("|");
2736
+ const new_cells = current_effective_new_text.split("|");
2737
+
2738
+ if (actual_cells.length === new_cells.length && actual_cells.length > 1) {
2739
+ const sub_edits: any[] = [];
2740
+ let search_offset = start_idx;
2741
+
2742
+ // Determine which cell receives the comment
2743
+ let target_comment_idx = 0;
2744
+ for (let idx = 0; idx < actual_cells.length; idx++) {
2745
+ if (actual_cells[idx].trim() !== new_cells[idx].trim()) {
2746
+ target_comment_idx = idx;
2747
+ break;
2748
+ }
2749
+ }
2750
+
2751
+ for (let cell_idx = 0; cell_idx < actual_cells.length; cell_idx++) {
2752
+ const a_cell = actual_cells[cell_idx];
2753
+ const n_cell = new_cells[cell_idx];
2754
+ const a_clean = a_cell.trim();
2755
+ const n_clean = n_cell.trim();
2756
+
2757
+ let actual_start = search_offset;
2758
+ if (a_clean) {
2759
+ actual_start = this.mapper.full_text.indexOf(a_clean, search_offset);
2760
+ if (actual_start === -1 || actual_start > search_offset + 10) {
2761
+ actual_start = search_offset;
2762
+ }
2763
+ }
2764
+
2765
+ const should_attach_comment = (edit.comment !== null && edit.comment !== undefined) && (cell_idx === target_comment_idx);
2766
+
2767
+ if (a_clean !== n_clean || should_attach_comment) {
2768
+ const cell_sub_edits = this._word_diff_sub_edits(
2769
+ a_clean,
2770
+ n_clean,
2771
+ actual_start,
2772
+ should_attach_comment ? edit.comment : null,
2773
+ true,
2774
+ active_mapper,
2775
+ );
2776
+ for (const se of cell_sub_edits) {
2777
+ se._original_target_text = edit.target_text;
2778
+ se._split_group_id = start_idx;
2779
+ sub_edits.push(se);
2780
+ }
2781
+ }
2782
+
2783
+ if (a_clean) {
2784
+ search_offset = actual_start + a_clean.length;
2785
+ }
2786
+
2787
+ const next_pipe = this.mapper.full_text.indexOf(" | ", search_offset);
2788
+ if (next_pipe !== -1 && next_pipe <= search_offset + 10) {
2789
+ search_offset = next_pipe + 3;
2790
+ } else {
2791
+ search_offset += a_cell.length + 1;
2792
+ }
2793
+ }
2794
+
2795
+ for (const sub of sub_edits) {
2796
+ all_sub_edits.push(sub);
2797
+ }
2798
+ continue;
2799
+ } else {
2800
+ throw new BatchValidationError([
2801
+ `Target text spans ${actual_cells.length} table cells, but replacement provides ${new_cells.length}. To modify text without altering table structure (rows or columns), ensure the replacement contains the exact same number of '|' separators (e.g., replace with 'CellC | ' to empty the second cell).`
2802
+ ]);
2803
+ }
2804
+ }
2805
+
2806
+ let has_markdown = false;
2807
+ if (edit.target_text && (edit.target_text.includes("**") || edit.target_text.includes("_"))) {
2808
+ has_markdown = true;
2809
+ }
2810
+ if (current_effective_new_text && (current_effective_new_text.includes("**") || current_effective_new_text.includes("_"))) {
2811
+ has_markdown = true;
2812
+ }
2813
+
2594
2814
  let effective_op = "";
2595
2815
  let final_target = "";
2596
2816
  let final_new = "";
@@ -2598,9 +2818,7 @@ export class RedlineEngine {
2598
2818
 
2599
2819
  if (current_effective_new_text.startsWith(actual_doc_text)) {
2600
2820
  effective_op = "INSERTION";
2601
- final_new = current_effective_new_text.substring(
2602
- actual_doc_text.length,
2603
- );
2821
+ final_new = current_effective_new_text.substring(actual_doc_text.length);
2604
2822
  effective_start_idx = start_idx + match_len;
2605
2823
  } else {
2606
2824
  const [prefix_len, suffix_len] = trim_common_context(
@@ -2613,84 +2831,101 @@ export class RedlineEngine {
2613
2831
  final_target = actual_doc_text.substring(prefix_len, t_end);
2614
2832
  final_new = current_effective_new_text.substring(prefix_len, n_end);
2615
2833
  effective_start_idx = start_idx + prefix_len;
2834
+ }
2616
2835
 
2617
- // Balanced multi-paragraph modification: the matched span crosses one or
2618
- // more paragraph breaks and the replacement preserves the same number of
2619
- // breaks. Apply it as one independent sub-edit per paragraph segment so
2620
- // the structural \n\n breaks are left intact. Each sub-edit shares a
2621
- // _split_group_id (the occurrence's start index) so the batch report
2622
- // counts it as a single applied edit. Unbalanced cases (a genuine
2623
- // paragraph merge or split) fall through to the single-span path and are
2624
- // rejected by validate_edits.
2625
- const target_segs = actual_doc_text.split("\n\n");
2626
- const new_segs = current_effective_new_text.split("\n\n");
2627
- if (
2628
- actual_doc_text.includes("\n\n") &&
2629
- target_segs.length === new_segs.length
2630
- ) {
2631
- const split_sub_edits: any[] = [];
2632
- let seg_offset = start_idx;
2633
- let comment_assigned = false;
2634
- for (let k = 0; k < target_segs.length; k++) {
2635
- const t_seg = target_segs[k];
2636
- const n_seg = new_segs[k];
2637
- if (t_seg !== n_seg) {
2638
- const [seg_prefix, seg_suffix] = trim_common_context(
2639
- t_seg,
2640
- n_seg,
2641
- );
2642
- const seg_target = t_seg.substring(
2643
- seg_prefix,
2644
- t_seg.length - seg_suffix,
2645
- );
2646
- const seg_new = n_seg.substring(
2647
- seg_prefix,
2648
- n_seg.length - seg_suffix,
2649
- );
2650
- const seg_start = seg_offset + seg_prefix;
2651
- let seg_op: string;
2652
- if (!seg_target && seg_new) seg_op = "INSERTION";
2653
- else if (seg_target && !seg_new) seg_op = "DELETION";
2654
- else if (seg_target && seg_new) seg_op = "MODIFICATION";
2655
- else seg_op = "COMMENT_ONLY";
2656
- const seg_comment =
2657
- edit.comment && !comment_assigned ? edit.comment : null;
2658
- if (seg_comment) comment_assigned = true;
2659
- split_sub_edits.push({
2660
- type: "modify",
2661
- target_text: seg_target,
2662
- new_text: seg_new,
2663
- comment: seg_comment,
2664
- _match_start_index: seg_start,
2665
- _internal_op: seg_op,
2666
- _active_mapper_ref: active_mapper,
2667
- _split_group_id: start_idx,
2668
- });
2836
+ if (has_markdown) {
2837
+ if (!final_target && final_new) {
2838
+ effective_op = "INSERTION";
2839
+ } else if (final_target && !final_new) {
2840
+ effective_op = "DELETION";
2841
+ } else if (final_target && final_new) {
2842
+ effective_op = "MODIFICATION";
2843
+ } else {
2844
+ all_sub_edits.push({
2845
+ type: "modify",
2846
+ target_text: final_target,
2847
+ new_text: final_new,
2848
+ comment: edit.comment,
2849
+ _match_start_index: effective_start_idx,
2850
+ _internal_op: "COMMENT_ONLY",
2851
+ _active_mapper_ref: active_mapper,
2852
+ });
2853
+ continue;
2854
+ }
2855
+
2856
+ all_sub_edits.push({
2857
+ type: "modify",
2858
+ target_text: final_target,
2859
+ new_text: final_new,
2860
+ comment: edit.comment,
2861
+ _resolved_start_idx: effective_start_idx,
2862
+ _match_start_index: effective_start_idx,
2863
+ _internal_op: effective_op,
2864
+ _active_mapper_ref: active_mapper,
2865
+ });
2866
+ continue;
2867
+ }
2868
+
2869
+ // Balanced multi-paragraph modification: the matched span crosses one or
2870
+ // more paragraph breaks and the replacement preserves the same number of
2871
+ // breaks. Apply it as one independent sub-edit per paragraph segment so
2872
+ // the structural \n\n breaks are left intact. Each sub-edit shares a
2873
+ // _split_group_id (the occurrence's start index) so the batch report
2874
+ // counts it as a single applied edit. Unbalanced cases (a genuine
2875
+ // paragraph merge or split) fall through to the single-span path and are
2876
+ // rejected by validate_edits.
2877
+ const target_segs = actual_doc_text.split("\n\n");
2878
+ const new_segs = current_effective_new_text.split("\n\n");
2879
+ if (
2880
+ actual_doc_text.includes("\n\n") &&
2881
+ target_segs.length === new_segs.length
2882
+ ) {
2883
+ const split_sub_edits: any[] = [];
2884
+ let seg_offset = start_idx;
2885
+ let comment_assigned = false;
2886
+ for (let k = 0; k < target_segs.length; k++) {
2887
+ const t_seg = target_segs[k];
2888
+ const n_seg = new_segs[k];
2889
+ if (t_seg !== n_seg) {
2890
+ const seg_comment =
2891
+ edit.comment && !comment_assigned ? edit.comment : null;
2892
+ const seg_sub_edits = this._word_diff_sub_edits(
2893
+ t_seg,
2894
+ n_seg,
2895
+ seg_offset,
2896
+ seg_comment,
2897
+ false,
2898
+ active_mapper,
2899
+ );
2900
+ if (seg_sub_edits.some((se) => se.comment !== null && se.comment !== undefined)) {
2901
+ comment_assigned = true;
2902
+ }
2903
+ for (const se of seg_sub_edits) {
2904
+ se._split_group_id = start_idx;
2905
+ split_sub_edits.push(se);
2669
2906
  }
2670
- // Advance past this segment plus its "\n\n" separator span.
2671
- seg_offset += t_seg.length + 2;
2672
- }
2673
- if (split_sub_edits.length > 0) {
2674
- for (const sub of split_sub_edits) all_sub_edits.push(sub);
2675
- continue;
2676
2907
  }
2908
+ // Advance past this segment plus its "\n\n" separator span.
2909
+ seg_offset += t_seg.length + 2;
2910
+ }
2911
+ if (split_sub_edits.length > 0) {
2912
+ for (const sub of split_sub_edits) all_sub_edits.push(sub);
2913
+ continue;
2677
2914
  }
2678
-
2679
- if (!final_target && final_new) effective_op = "INSERTION";
2680
- else if (final_target && !final_new) effective_op = "DELETION";
2681
- else if (final_target && final_new) effective_op = "MODIFICATION";
2682
- else effective_op = "COMMENT_ONLY";
2683
2915
  }
2684
2916
 
2685
- all_sub_edits.push({
2686
- type: "modify",
2687
- target_text: final_target,
2688
- new_text: final_new,
2689
- comment: edit.comment,
2690
- _match_start_index: effective_start_idx,
2691
- _internal_op: effective_op,
2692
- _active_mapper_ref: active_mapper,
2693
- });
2917
+ const sub_edits = this._word_diff_sub_edits(
2918
+ actual_doc_text,
2919
+ current_effective_new_text,
2920
+ start_idx,
2921
+ edit.comment,
2922
+ false,
2923
+ active_mapper,
2924
+ );
2925
+ for (const se of sub_edits) {
2926
+ se._split_group_id = start_idx;
2927
+ all_sub_edits.push(se);
2928
+ }
2694
2929
  }
2695
2930
 
2696
2931
  if (all_sub_edits.length === 0) return null;
@@ -2708,20 +2943,24 @@ export class RedlineEngine {
2708
2943
  */
2709
2944
  private _insert_and_split_ins(
2710
2945
  parent_ins: Element,
2711
- split_after: Element,
2946
+ anchor: Element,
2712
2947
  new_elem: Element,
2948
+ split_before: boolean = false,
2713
2949
  ) {
2714
2950
  const grandparent = parent_ins.parentNode as Element | null;
2715
2951
  if (!grandparent) return;
2716
2952
  // cloneNode(false) copies the attributes (author/id/date) onto both halves.
2953
+ // The split lands after `anchor` by default; with split_before the anchor
2954
+ // itself goes to the right half so new_elem ends up in front of it.
2717
2955
  const left = parent_ins.cloneNode(false) as Element;
2718
2956
  const right = parent_ins.cloneNode(false) as Element;
2719
2957
  let toRight = false;
2720
2958
  for (const kid of Array.from(parent_ins.childNodes)) {
2721
2959
  parent_ins.removeChild(kid);
2960
+ if (split_before && kid === anchor) toRight = true;
2722
2961
  if (!toRight) {
2723
2962
  left.appendChild(kid);
2724
- if (kid === split_after) toRight = true;
2963
+ if (kid === anchor) toRight = true;
2725
2964
  } else {
2726
2965
  right.appendChild(kid);
2727
2966
  }
@@ -2747,6 +2986,22 @@ export class RedlineEngine {
2747
2986
  : edit._match_start_index || 0;
2748
2987
  const length = edit.target_text ? edit.target_text.length : 0;
2749
2988
 
2989
+ // Indexed edits (caller-supplied _match_start_index, e.g. straight from
2990
+ // generate_edits_from_text) bypass _pre_resolve_heuristic_edit — the only
2991
+ // place _internal_op is normally assigned. Without this fallback the
2992
+ // deletion sweep below still runs but no insertion branch does: a
2993
+ // replacement silently degrades to a pure tracked deletion and a pure
2994
+ // insertion fails. Mirrors the Python engine.
2995
+ if (op === undefined || op === null) {
2996
+ if (!edit.target_text && edit.new_text) {
2997
+ op = "INSERTION";
2998
+ } else if (edit.target_text && !edit.new_text) {
2999
+ op = "DELETION";
3000
+ } else {
3001
+ op = "MODIFICATION";
3002
+ }
3003
+ }
3004
+
2750
3005
  if (op === "STYLE_ONLY" || op === "STYLE_AND_TEXT") {
2751
3006
  const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
2752
3007
  start_idx,
@@ -2993,21 +3248,39 @@ export class RedlineEngine {
2993
3248
  if (anchor_run) {
2994
3249
  const anchor_parent = anchor_run._element
2995
3250
  .parentNode as Element | null;
3251
+ // get_insertion_anchor(0) resolves to the document's FIRST run: the
3252
+ // insertion point precedes it, so the new <w:ins> must land before
3253
+ // the anchor, not after (mirrors the Python engine's insert_before
3254
+ // path).
3255
+ const before_anchor = start_idx === 0;
2996
3256
  if (anchor_parent && anchor_parent.tagName === "w:ins") {
2997
3257
  // Inserting inside another author's pending <w:ins>: split it so our
2998
- // new <w:ins> lands as a sibling right after the anchor run, never
3258
+ // new <w:ins> lands as a sibling right next to the anchor run, never
2999
3259
  // <w:ins> nested in <w:ins> (mirrors the MODIFICATION path and the
3000
3260
  // Python engine).
3001
3261
  this._insert_and_split_ins(
3002
3262
  anchor_parent,
3003
3263
  anchor_run._element,
3004
3264
  result.first_node,
3265
+ before_anchor,
3005
3266
  );
3267
+ } else if (before_anchor && anchor_parent) {
3268
+ anchor_parent.insertBefore(result.first_node, anchor_run._element);
3006
3269
  } else {
3007
3270
  insertAfter(result.first_node, anchor_run._element);
3008
3271
  }
3009
3272
  } else if (anchor_para) {
3010
- anchor_para._element.appendChild(result.first_node);
3273
+ // Paragraph-anchored insertion: the anchor resolves to a paragraph
3274
+ // (not a run) for zero-width paragraph-start spans — e.g. index 0 of
3275
+ // the document. The insertion point is the START of the paragraph
3276
+ // content, so land right after pPr, mirroring the Python engine;
3277
+ // appendChild would drop the text at the paragraph's END.
3278
+ const para_el = anchor_para._element;
3279
+ let ref: Node | null = para_el.firstChild;
3280
+ while (ref && (ref as Element).tagName === "w:pPr") {
3281
+ ref = ref.nextSibling;
3282
+ }
3283
+ para_el.insertBefore(result.first_node, ref);
3011
3284
  }
3012
3285
  }
3013
3286