@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/dist/index.cjs +270 -76
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +270 -76
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/engine.ts +363 -91
- package/src/repro.indexed-edit-insertion-loss.test.ts +137 -0
- package/src/repro.report.test.ts +1 -0
- package/src/repro_surgical_word_diff.test.ts +26 -0
package/package.json
CHANGED
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
|
-
|
|
1958
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
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
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
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
|
-
|
|
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 ===
|
|
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
|
|
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
|
-
|
|
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
|
|