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