@adeu/core 1.27.0 → 1.29.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/dist/index.cjs CHANGED
@@ -1226,6 +1226,18 @@ function get_paragraph_prefix(paragraph, style_cache, default_pstyle) {
1226
1226
  if (custom_level !== null) return "#".repeat(custom_level) + " ";
1227
1227
  }
1228
1228
  if (!style_name || style_name === "Normal") {
1229
+ let is_inside_tc = false;
1230
+ let curr = paragraph._element;
1231
+ while (curr) {
1232
+ if (curr.tagName === "w:tc") {
1233
+ is_inside_tc = true;
1234
+ break;
1235
+ }
1236
+ curr = curr.parentNode;
1237
+ }
1238
+ if (is_inside_tc) {
1239
+ return "";
1240
+ }
1229
1241
  const text = paragraph.text.trim();
1230
1242
  if (text && text.length < 100 && text === text.toUpperCase()) {
1231
1243
  let is_bold = false;
@@ -1297,6 +1309,50 @@ function split_boundary_whitespace(text) {
1297
1309
  text.substring(lead_len + core.length)
1298
1310
  ];
1299
1311
  }
1312
+ function compute_change_pair_map(states_list) {
1313
+ const groups = [];
1314
+ let current = [];
1315
+ const seen_ids = /* @__PURE__ */ new Set();
1316
+ for (const [ins_map, del_map] of states_list) {
1317
+ const ins_entries = Object.entries(ins_map);
1318
+ const del_entries = Object.entries(del_map);
1319
+ if (ins_entries.length === 0 && del_entries.length === 0) {
1320
+ if (current.length > 0) {
1321
+ groups.push(current);
1322
+ current = [];
1323
+ }
1324
+ continue;
1325
+ }
1326
+ const state_new = [];
1327
+ for (const [uid, meta] of ins_entries) {
1328
+ if (!seen_ids.has(uid)) {
1329
+ state_new.push([uid, meta && meta.author || "Unknown"]);
1330
+ }
1331
+ }
1332
+ for (const [uid, meta] of del_entries) {
1333
+ if (!seen_ids.has(uid)) {
1334
+ state_new.push([uid, meta && meta.author || "Unknown"]);
1335
+ }
1336
+ }
1337
+ for (const [uid, author] of state_new) {
1338
+ seen_ids.add(uid);
1339
+ if (current.length > 0 && current[current.length - 1][1] !== author) {
1340
+ groups.push(current);
1341
+ current = [];
1342
+ }
1343
+ current.push([uid, author]);
1344
+ }
1345
+ }
1346
+ if (current.length > 0) groups.push(current);
1347
+ const pair_map = {};
1348
+ for (const group of groups) {
1349
+ if (group.length < 2) continue;
1350
+ for (const [uid] of group) {
1351
+ pair_map[uid] = group.filter(([u]) => u !== uid).map(([u]) => `Chg:${u}`).join(", ");
1352
+ }
1353
+ }
1354
+ return pair_map;
1355
+ }
1300
1356
  function apply_formatting_to_segments(text, prefix, suffix) {
1301
1357
  if (!prefix && !suffix) return text;
1302
1358
  if (!text) return "";
@@ -1699,11 +1755,34 @@ ${header}`;
1699
1755
  const cell_start = current;
1700
1756
  current = this._map_blocks(cell, current);
1701
1757
  if (!this.clean_view && !this.original_view) {
1702
- const firstP = cell._element.getElementsByTagName("w:p")[0];
1703
- const paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
1758
+ let firstP = cell._element.getElementsByTagName("w:p")[0];
1759
+ let paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
1760
+ const is_empty = current === cell_start;
1761
+ if (!paraId && is_empty) {
1762
+ if (!firstP) {
1763
+ const xmlDoc = cell._element.ownerDocument;
1764
+ firstP = xmlDoc.createElement("w:p");
1765
+ cell._element.appendChild(firstP);
1766
+ }
1767
+ const allPs = Array.from(cell._element.ownerDocument.getElementsByTagName("w:p"));
1768
+ const index = allPs.indexOf(firstP);
1769
+ let hash = 2166136261;
1770
+ const str = `fallback-paraId-${index}`;
1771
+ for (let i = 0; i < str.length; i++) {
1772
+ hash ^= str.charCodeAt(i);
1773
+ hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
1774
+ }
1775
+ paraId = (hash >>> 0).toString(16).toUpperCase().padStart(8, "0");
1776
+ firstP.setAttribute("w14:paraId", paraId);
1777
+ }
1704
1778
  if (paraId && firstP) {
1705
1779
  const cellPara = new Paragraph(firstP, cell);
1706
1780
  this._add_virtual_text("", current, cellPara);
1781
+ const has_content = current > cell_start;
1782
+ if (has_content) {
1783
+ this._add_virtual_text(" ", current, cellPara);
1784
+ current += 1;
1785
+ }
1707
1786
  const anchor = `{#cell:${paraId}}`;
1708
1787
  this._add_virtual_text(anchor, current, cellPara);
1709
1788
  current += anchor.length;
@@ -2054,6 +2133,8 @@ ${header}`;
2054
2133
  const change_lines = [];
2055
2134
  const comment_lines = [];
2056
2135
  const seen_sigs = /* @__PURE__ */ new Set();
2136
+ const pair_map = compute_change_pair_map(states_list);
2137
+ const pairSuffix = (uid) => pair_map[uid] ? ` (pairs with ${pair_map[uid]})` : "";
2057
2138
  for (const [ins_map, del_map, comments_set, fmt_map] of states_list) {
2058
2139
  for (const [uid, meta] of Object.entries(
2059
2140
  ins_map
@@ -2061,7 +2142,7 @@ ${header}`;
2061
2142
  const sig = `Chg:${uid}`;
2062
2143
  if (!seen_sigs.has(sig)) {
2063
2144
  const auth = meta.author || "Unknown";
2064
- change_lines.push(`[${sig} insert] ${auth}`);
2145
+ change_lines.push(`[${sig} insert] ${auth}${pairSuffix(uid)}`);
2065
2146
  seen_sigs.add(sig);
2066
2147
  }
2067
2148
  }
@@ -2071,7 +2152,7 @@ ${header}`;
2071
2152
  const sig = `Chg:${uid}`;
2072
2153
  if (!seen_sigs.has(sig)) {
2073
2154
  const auth = meta.author || "Unknown";
2074
- change_lines.push(`[${sig} delete] ${auth}`);
2155
+ change_lines.push(`[${sig} delete] ${auth}${pairSuffix(uid)}`);
2075
2156
  seen_sigs.add(sig);
2076
2157
  }
2077
2158
  }
@@ -2211,21 +2292,60 @@ ${header}`;
2211
2292
  }
2212
2293
  return results;
2213
2294
  }
2295
+ /**
2296
+ * True when no run-backed span overlaps [start, start+length): the range
2297
+ * covers only virtual projection text — meta bubbles (change/comment
2298
+ * headers, timestamps), style markers, list prefixes. Such text does not
2299
+ * exist in the document, so it can neither satisfy a match nor count
2300
+ * toward ambiguity (QA 2026-07-19 ADEU-QA-002 C): an edit targeting "4"
2301
+ * used to be rejected as "appears 8 times" because a comment bubble's
2302
+ * timestamp matched.
2303
+ *
2304
+ * Anchor tokens ({#Bookmark}, {#cell:paraId}) are the exception: they are
2305
+ * deliberate virtual TARGETING surfaces (empty-cell writes, bookmark
2306
+ * anchors) and must stay matchable.
2307
+ */
2308
+ range_is_virtual_only(start, length) {
2309
+ const end = start + length;
2310
+ const overlapping = this.spans.filter(
2311
+ (s) => s.end > start && s.start < end
2312
+ );
2313
+ if (overlapping.some((s) => s.run !== null)) return false;
2314
+ return !overlapping.some(
2315
+ (s) => s.run === null && s.text.startsWith("{#")
2316
+ );
2317
+ }
2318
+ /** Filters find_all_match_indices output down to matches that touch at
2319
+ * least one run-backed span. See range_is_virtual_only. */
2320
+ drop_virtual_only_matches(matches) {
2321
+ return matches.filter(
2322
+ ([start, length]) => !this.range_is_virtual_only(start, length)
2323
+ );
2324
+ }
2214
2325
  find_match_index(target_text, is_regex = false) {
2215
2326
  if (is_regex) {
2216
2327
  try {
2217
2328
  const match = userSearch(target_text, this.full_text);
2218
- if (match) return [match.start, match.end - match.start];
2329
+ if (match && !this.range_is_virtual_only(match.start, match.end - match.start)) {
2330
+ return [match.start, match.end - match.start];
2331
+ }
2219
2332
  } catch (e) {
2220
2333
  if (e instanceof RegexTimeoutError) throw e;
2221
2334
  }
2222
2335
  return [-1, 0];
2223
2336
  }
2224
- let start_idx = this.full_text.indexOf(target_text);
2225
- if (start_idx !== -1) return [start_idx, target_text.length];
2337
+ let from = 0;
2338
+ while (true) {
2339
+ const idx = this.full_text.indexOf(target_text, from);
2340
+ if (idx === -1) break;
2341
+ if (!this.range_is_virtual_only(idx, target_text.length)) {
2342
+ return [idx, target_text.length];
2343
+ }
2344
+ from = idx + 1;
2345
+ }
2226
2346
  const norm_full = this._replace_smart_quotes(this.full_text);
2227
2347
  const norm_target = this._replace_smart_quotes(target_text);
2228
- start_idx = norm_full.indexOf(norm_target);
2348
+ let start_idx = norm_full.indexOf(norm_target);
2229
2349
  if (start_idx !== -1) return [start_idx, target_text.length];
2230
2350
  const stripped_target = this._strip_markdown_formatting(target_text);
2231
2351
  if (this.full_text.includes(stripped_target)) {
@@ -2235,9 +2355,12 @@ ${header}`;
2235
2355
  const plain_first = this._find_plain_projection_matches(target_text);
2236
2356
  if (plain_first.length > 0) return plain_first[0];
2237
2357
  try {
2238
- const pattern = new RegExp(this._make_fuzzy_regex(target_text));
2239
- const match = pattern.exec(this.full_text);
2240
- if (match) return [match.index, match[0].length];
2358
+ const pattern = new RegExp(this._make_fuzzy_regex(target_text), "g");
2359
+ for (const match of this.full_text.matchAll(pattern)) {
2360
+ if (!this.range_is_virtual_only(match.index, match[0].length)) {
2361
+ return [match.index, match[0].length];
2362
+ }
2363
+ }
2241
2364
  } catch (e) {
2242
2365
  }
2243
2366
  return [-1, 0];
@@ -2766,6 +2889,157 @@ function _sequence_opcodes(a, b) {
2766
2889
  flushPending();
2767
2890
  return ops;
2768
2891
  }
2892
+ function _is_table_blob(block) {
2893
+ const lines = block.split("\n");
2894
+ return lines.length > 0 && lines.every((line) => line.includes(" | "));
2895
+ }
2896
+ function _table_blob_row_edits(orig_blob, mod_blob) {
2897
+ const rows_o = orig_blob.split("\n");
2898
+ const rows_m = mod_blob.split("\n");
2899
+ if (rows_o.length !== rows_m.length) return null;
2900
+ const row_edits = [];
2901
+ for (let k = 0; k < rows_o.length; k++) {
2902
+ if (rows_o[k] === rows_m[k]) continue;
2903
+ row_edits.push({
2904
+ type: "modify",
2905
+ target_text: rows_o[k],
2906
+ new_text: rows_m[k],
2907
+ comment: "Diff: Table row modified",
2908
+ _is_table_edit: true
2909
+ });
2910
+ }
2911
+ return row_edits;
2912
+ }
2913
+ function _split_cross_paragraph_hunks(edits) {
2914
+ const out = [];
2915
+ for (const e of edits) {
2916
+ const target = e.target_text || "";
2917
+ const newText = e.new_text || "";
2918
+ const idx = e._match_start_index;
2919
+ if (idx === void 0 || idx === null || !target.includes("\n\n") || target.split("\n\n").length - 1 === newText.split("\n\n").length - 1) {
2920
+ out.push(e);
2921
+ continue;
2922
+ }
2923
+ const parts = target.split("\n\n");
2924
+ if (parts.length < 2 || !parts[0].trim() || !parts[parts.length - 1].trim()) {
2925
+ out.push(e);
2926
+ continue;
2927
+ }
2928
+ let offset = idx;
2929
+ for (const piece of parts.slice(0, -1)) {
2930
+ out.push({
2931
+ type: "modify",
2932
+ target_text: piece + "\n\n",
2933
+ new_text: "",
2934
+ comment: e.comment || "Diff: Text deleted",
2935
+ _match_start_index: offset
2936
+ });
2937
+ offset += piece.length + 2;
2938
+ }
2939
+ out.push({
2940
+ type: "modify",
2941
+ target_text: parts[parts.length - 1],
2942
+ new_text: newText,
2943
+ comment: e.comment,
2944
+ _match_start_index: offset
2945
+ });
2946
+ }
2947
+ return out;
2948
+ }
2949
+ function generate_edits_via_paragraph_alignment(original_text, modified_text) {
2950
+ const orig_stripped = original_text.trimEnd();
2951
+ const orig_ws = original_text.slice(orig_stripped.length);
2952
+ const mod_stripped = modified_text.trimEnd();
2953
+ modified_text = mod_stripped + orig_ws;
2954
+ const orig_paragraphs = original_text.split("\n\n");
2955
+ const mod_paragraphs = modified_text.split("\n\n");
2956
+ const orig_offsets = [];
2957
+ let current_offset = 0;
2958
+ for (const p of orig_paragraphs) {
2959
+ orig_offsets.push(current_offset);
2960
+ current_offset += p.length + 2;
2961
+ }
2962
+ const opcodes = _sequence_opcodes(orig_paragraphs, mod_paragraphs);
2963
+ const edits = [];
2964
+ for (const [tag, i1, i2, j1, j2] of opcodes) {
2965
+ if (tag === "equal") continue;
2966
+ const offset = i1 < orig_offsets.length ? orig_offsets[i1] : original_text.length;
2967
+ if (tag === "delete") {
2968
+ if (i2 < orig_paragraphs.length) {
2969
+ let piece_offset = offset;
2970
+ for (let k = i1; k < i2; k++) {
2971
+ const piece = orig_paragraphs[k] + "\n\n";
2972
+ edits.push({
2973
+ type: "modify",
2974
+ target_text: piece,
2975
+ new_text: "",
2976
+ comment: "Diff: Text deleted",
2977
+ _match_start_index: piece_offset
2978
+ });
2979
+ piece_offset += piece.length;
2980
+ }
2981
+ } else {
2982
+ let deleted_text = orig_paragraphs.slice(i1, i2).join("\n\n");
2983
+ let del_offset = offset;
2984
+ if (i1 > 0) {
2985
+ deleted_text = "\n\n" + deleted_text;
2986
+ del_offset -= 2;
2987
+ }
2988
+ edits.push({
2989
+ type: "modify",
2990
+ target_text: deleted_text,
2991
+ new_text: "",
2992
+ comment: "Diff: Text deleted",
2993
+ _match_start_index: del_offset
2994
+ });
2995
+ }
2996
+ } else if (tag === "insert") {
2997
+ let inserted_text = mod_paragraphs.slice(j1, j2).join("\n\n");
2998
+ if (i1 < orig_paragraphs.length) {
2999
+ inserted_text = inserted_text + "\n\n";
3000
+ } else {
3001
+ inserted_text = "\n\n" + inserted_text;
3002
+ }
3003
+ edits.push({
3004
+ type: "modify",
3005
+ target_text: "",
3006
+ new_text: inserted_text,
3007
+ comment: "Diff: Text inserted",
3008
+ _match_start_index: offset
3009
+ });
3010
+ } else if (tag === "replace") {
3011
+ if (i2 - i1 === j2 - j1 && Array.from({ length: i2 - i1 }).some(
3012
+ (_, k) => _is_table_blob(orig_paragraphs[i1 + k]) || _is_table_blob(mod_paragraphs[j1 + k])
3013
+ )) {
3014
+ for (let k = 0; k < i2 - i1; k++) {
3015
+ const orig_p = orig_paragraphs[i1 + k];
3016
+ const mod_p = mod_paragraphs[j1 + k];
3017
+ if (orig_p === mod_p) continue;
3018
+ const pair_offset = orig_offsets[i1 + k];
3019
+ const row_edits = _is_table_blob(orig_p) && _is_table_blob(mod_p) ? _table_blob_row_edits(orig_p, mod_p) : null;
3020
+ if (row_edits !== null) {
3021
+ edits.push(...row_edits);
3022
+ continue;
3023
+ }
3024
+ const pair_edits = generate_edits_from_text(orig_p, mod_p);
3025
+ for (const ce of pair_edits) {
3026
+ ce._match_start_index = (ce._match_start_index || 0) + pair_offset;
3027
+ edits.push(ce);
3028
+ }
3029
+ }
3030
+ continue;
3031
+ }
3032
+ const orig_chunk = orig_paragraphs.slice(i1, i2).join("\n\n");
3033
+ const mod_chunk = mod_paragraphs.slice(j1, j2).join("\n\n");
3034
+ const chunk_edits = generate_edits_from_text(orig_chunk, mod_chunk);
3035
+ for (const ce of chunk_edits) {
3036
+ ce._match_start_index = (ce._match_start_index || 0) + offset;
3037
+ edits.push(ce);
3038
+ }
3039
+ }
3040
+ }
3041
+ return _split_cross_paragraph_hunks(edits);
3042
+ }
2769
3043
  var _IMAGE_MARKER_RE = /!\[[^\]]*\]\(docx-image:[^)]*\)/g;
2770
3044
  function _drop_image_marker_hunks(edits, warnings) {
2771
3045
  const _normalized = (s) => s.replace(_IMAGE_MARKER_RE, "").replace(/\s+/g, " ").trim();
@@ -2963,7 +3237,7 @@ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod)
2963
3237
  );
2964
3238
  const flat = _drop_marker_interior_hunks(
2965
3239
  _drop_image_marker_hunks(
2966
- generate_edits_from_text(text_orig, text_mod),
3240
+ generate_edits_via_paragraph_alignment(text_orig, text_mod),
2967
3241
  warnings
2968
3242
  ),
2969
3243
  text_orig,
@@ -2989,7 +3263,7 @@ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod)
2989
3263
  );
2990
3264
  }
2991
3265
  if (!tables_alignable) {
2992
- const part_edits = generate_edits_from_text(
3266
+ const part_edits = generate_edits_via_paragraph_alignment(
2993
3267
  text_orig.substring(po_start, po_end),
2994
3268
  text_mod.substring(pm_start, pm_end)
2995
3269
  );
@@ -3014,7 +3288,7 @@ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod)
3014
3288
  const seg_o_end = boundaries_o[seg_idx + 1][0];
3015
3289
  const seg_m_start = boundaries_m[seg_idx][1];
3016
3290
  const seg_m_end = boundaries_m[seg_idx + 1][0];
3017
- const seg_edits = generate_edits_from_text(
3291
+ const seg_edits = generate_edits_via_paragraph_alignment(
3018
3292
  text_orig.substring(seg_o_start, seg_o_end),
3019
3293
  text_mod.substring(seg_m_start, seg_m_end)
3020
3294
  );
@@ -4462,10 +4736,21 @@ var RedlineEngine = class _RedlineEngine {
4462
4736
  parts_to_process.push(part._element);
4463
4737
  }
4464
4738
  }
4739
+ let accepted_insertions = 0;
4740
+ let accepted_deletions = 0;
4741
+ let accepted_formatting = 0;
4742
+ for (const root_element of parts_to_process) {
4743
+ accepted_insertions += findAllDescendants(root_element, "w:ins").length;
4744
+ accepted_deletions += findAllDescendants(root_element, "w:del").length;
4745
+ for (const tag of ["w:rPrChange", "w:pPrChange", "w:sectPrChange"]) {
4746
+ accepted_formatting += findAllDescendants(root_element, tag).length;
4747
+ }
4748
+ }
4749
+ let removed_comments = 0;
4465
4750
  for (const root_element of parts_to_process) {
4466
4751
  const insNodes = findAllDescendants(root_element, "w:ins");
4467
4752
  for (const ins of insNodes) {
4468
- this._clean_wrapping_comments(ins);
4753
+ removed_comments += this._clean_wrapping_comments(ins);
4469
4754
  const parent = ins.parentNode;
4470
4755
  if (!parent) continue;
4471
4756
  if (parent.tagName === "w:trPr") {
@@ -4509,8 +4794,8 @@ var RedlineEngine = class _RedlineEngine {
4509
4794
  if (has_content) {
4510
4795
  rPr.removeChild(delMark);
4511
4796
  } else {
4512
- this._clean_wrapping_comments(p);
4513
- this._delete_comments_in_element(p);
4797
+ removed_comments += this._clean_wrapping_comments(p);
4798
+ removed_comments += this._delete_comments_in_element(p);
4514
4799
  if (p.parentNode) {
4515
4800
  p.parentNode.removeChild(p);
4516
4801
  }
@@ -4520,8 +4805,8 @@ var RedlineEngine = class _RedlineEngine {
4520
4805
  }
4521
4806
  const delNodes = findAllDescendants(root_element, "w:del");
4522
4807
  for (const d of delNodes) {
4523
- this._clean_wrapping_comments(d);
4524
- this._delete_comments_in_element(d);
4808
+ removed_comments += this._clean_wrapping_comments(d);
4809
+ removed_comments += this._delete_comments_in_element(d);
4525
4810
  const parent = d.parentNode;
4526
4811
  if (parent) {
4527
4812
  if (parent.tagName === "w:trPr") {
@@ -4616,6 +4901,12 @@ var RedlineEngine = class _RedlineEngine {
4616
4901
  }
4617
4902
  }
4618
4903
  }
4904
+ return {
4905
+ accepted_insertions,
4906
+ accepted_deletions,
4907
+ accepted_formatting,
4908
+ removed_comments
4909
+ };
4619
4910
  }
4620
4911
  /**
4621
4912
  * Revert every tracked change, returning the document to the state it had
@@ -4868,6 +5159,24 @@ var RedlineEngine = class _RedlineEngine {
4868
5159
  *
4869
5160
  * Does NOT attach comments; callers handle that.
4870
5161
  */
5162
+ _clone_pPr_scrubbing_headings(existing_pPr) {
5163
+ const pPr_clone = existing_pPr.cloneNode(true);
5164
+ const pStyle_el = findChild(pPr_clone, "w:pStyle");
5165
+ if (pStyle_el) {
5166
+ const style_val = pStyle_el.getAttribute("w:val");
5167
+ if (style_val) {
5168
+ const is_heading = style_val.startsWith("Heading") || style_val === "Title" || style_val.replace(/\s+/g, "").startsWith("Heading");
5169
+ if (is_heading) {
5170
+ pPr_clone.removeChild(pStyle_el);
5171
+ }
5172
+ }
5173
+ }
5174
+ const outlineLvl_el = findChild(pPr_clone, "w:outlineLvl");
5175
+ if (outlineLvl_el) {
5176
+ pPr_clone.removeChild(outlineLvl_el);
5177
+ }
5178
+ return pPr_clone;
5179
+ }
4871
5180
  _track_insert_multiline(text, anchor_run, anchor_paragraph, reuse_id, positional_anchor_el = null, suppress_emphasis = false, insert_before = false) {
4872
5181
  if (!text) {
4873
5182
  return {
@@ -4989,7 +5298,7 @@ var RedlineEngine = class _RedlineEngine {
4989
5298
  } else {
4990
5299
  const existing_pPr = findChild(current_p, "w:pPr");
4991
5300
  if (existing_pPr) {
4992
- new_p.appendChild(existing_pPr.cloneNode(true));
5301
+ new_p.appendChild(this._clone_pPr_scrubbing_headings(existing_pPr));
4993
5302
  }
4994
5303
  }
4995
5304
  let pPr = findChild(new_p, "w:pPr");
@@ -5254,7 +5563,10 @@ var RedlineEngine = class _RedlineEngine {
5254
5563
  }
5255
5564
  return null;
5256
5565
  }
5566
+ /** Returns how many comment BODIES were actually deleted (see below: only
5567
+ * our own are; foreign ones keep their body and lose only the anchor). */
5257
5568
  _clean_wrapping_comments(element) {
5569
+ let deleted = 0;
5258
5570
  let first_node = element;
5259
5571
  while (true) {
5260
5572
  const prev2 = getPreviousElement(first_node);
@@ -5322,6 +5634,7 @@ var RedlineEngine = class _RedlineEngine {
5322
5634
  const is_own = author !== null && author === this.author;
5323
5635
  if (is_own) {
5324
5636
  this.comments_manager.deleteComment(c_id);
5637
+ deleted++;
5325
5638
  }
5326
5639
  if (s.parentNode) s.parentNode.removeChild(s);
5327
5640
  for (const e of ends_to_remove) {
@@ -5339,13 +5652,17 @@ var RedlineEngine = class _RedlineEngine {
5339
5652
  }
5340
5653
  }
5341
5654
  }
5655
+ return deleted;
5342
5656
  }
5657
+ /** Returns how many comment bodies were deleted. */
5343
5658
  _delete_comments_in_element(element) {
5659
+ let deleted = 0;
5344
5660
  const refs = findAllDescendants(element, "w:commentReference");
5345
5661
  for (const ref of refs) {
5346
5662
  const c_id = ref.getAttribute("w:id");
5347
5663
  if (c_id) {
5348
5664
  this.comments_manager.deleteComment(c_id);
5665
+ deleted++;
5349
5666
  for (const tag of ["w:commentRangeStart", "w:commentRangeEnd"]) {
5350
5667
  const nodes = findAllDescendants(this.doc.element, tag);
5351
5668
  for (const node of nodes) {
@@ -5356,6 +5673,7 @@ var RedlineEngine = class _RedlineEngine {
5356
5673
  }
5357
5674
  }
5358
5675
  }
5676
+ return deleted;
5359
5677
  }
5360
5678
  validate_edits(edits, index_offset = 0) {
5361
5679
  const errors = [];
@@ -5384,18 +5702,16 @@ var RedlineEngine = class _RedlineEngine {
5384
5702
  continue;
5385
5703
  }
5386
5704
  }
5387
- let matches = this.mapper.find_all_match_indices(
5388
- edit.target_text,
5389
- is_regex
5705
+ let matches = this.mapper.drop_virtual_only_matches(
5706
+ this.mapper.find_all_match_indices(edit.target_text, is_regex)
5390
5707
  );
5391
5708
  let activeText = this.mapper.full_text;
5392
5709
  let target_mapper = this.mapper;
5393
5710
  if (matches.length === 0) {
5394
5711
  if (!this.clean_mapper)
5395
5712
  this.clean_mapper = new DocumentMapper(this.doc, true);
5396
- matches = this.clean_mapper.find_all_match_indices(
5397
- edit.target_text,
5398
- is_regex
5713
+ matches = this.clean_mapper.drop_virtual_only_matches(
5714
+ this.clean_mapper.find_all_match_indices(edit.target_text, is_regex)
5399
5715
  );
5400
5716
  if (matches.length > 0) {
5401
5717
  activeText = this.clean_mapper.full_text;
@@ -5418,9 +5734,11 @@ var RedlineEngine = class _RedlineEngine {
5418
5734
  if (!this.original_mapper) {
5419
5735
  this.original_mapper = new DocumentMapper(this.doc, false, true);
5420
5736
  }
5421
- const orig_matches = this.original_mapper.find_all_match_indices(
5422
- edit.target_text,
5423
- is_regex
5737
+ const orig_matches = this.original_mapper.drop_virtual_only_matches(
5738
+ this.original_mapper.find_all_match_indices(
5739
+ edit.target_text,
5740
+ is_regex
5741
+ )
5424
5742
  );
5425
5743
  if (orig_matches.length > 0) {
5426
5744
  is_deleted_text = true;
@@ -5642,10 +5960,15 @@ var RedlineEngine = class _RedlineEngine {
5642
5960
  }
5643
5961
  static _column_count_at(mapper, start, length) {
5644
5962
  for (const s of mapper.spans) {
5645
- if (s.run === null || s.end <= start || s.start >= start + length) {
5963
+ if (s.end <= start || s.start >= start + length) {
5646
5964
  continue;
5647
5965
  }
5648
- let curr = s.run._element;
5966
+ let curr = null;
5967
+ if (s.run !== null) {
5968
+ curr = s.run._element;
5969
+ } else if (s.paragraph !== null) {
5970
+ curr = s.paragraph._element;
5971
+ }
5649
5972
  while (curr) {
5650
5973
  if (curr.nodeType === 1 && curr.tagName === "w:tr") {
5651
5974
  return findAllDescendants(curr, "w:tc").filter(
@@ -5785,7 +6108,10 @@ var RedlineEngine = class _RedlineEngine {
5785
6108
  (c) => c === null || typeof c !== "object" || !["accept", "reject", "reply"].includes(c.type)
5786
6109
  );
5787
6110
  if (!dry_run_mode) {
5788
- const action_errors = actions.length > 0 ? this.validate_review_actions(actions) : [];
6111
+ let action_errors = actions.length > 0 ? this.validate_review_actions(actions) : [];
6112
+ if (actions.length > 0 && action_errors.length === 0) {
6113
+ action_errors = this.validate_action_pairing(actions);
6114
+ }
5789
6115
  const validate_edits_now = edits.length > 0 && action_errors.length > 0;
5790
6116
  const edit_errors = validate_edits_now ? this.validate_edits(edits) : [];
5791
6117
  const all_errors = [...action_errors, ...edit_errors];
@@ -5794,7 +6120,10 @@ var RedlineEngine = class _RedlineEngine {
5794
6120
  }
5795
6121
  } else {
5796
6122
  if (actions.length > 0) {
5797
- const action_errors = this.validate_review_actions(actions);
6123
+ let action_errors = this.validate_review_actions(actions);
6124
+ if (action_errors.length === 0) {
6125
+ action_errors = this.validate_action_pairing(actions);
6126
+ }
5798
6127
  if (action_errors.length > 0) {
5799
6128
  throw new BatchValidationError(action_errors);
5800
6129
  }
@@ -5802,10 +6131,12 @@ var RedlineEngine = class _RedlineEngine {
5802
6131
  }
5803
6132
  let applied_actions = 0;
5804
6133
  let skipped_actions = 0;
6134
+ let already_resolved_actions = 0;
5805
6135
  if (actions.length > 0) {
5806
6136
  const res = this.apply_review_actions(actions);
5807
6137
  applied_actions = res[0];
5808
6138
  skipped_actions = res[1];
6139
+ already_resolved_actions = res[2];
5809
6140
  if (skipped_actions > 0) {
5810
6141
  throw new BatchValidationError(this.skipped_details);
5811
6142
  }
@@ -5859,6 +6190,7 @@ var RedlineEngine = class _RedlineEngine {
5859
6190
  );
5860
6191
  reports_by_input[i] = {
5861
6192
  status: "failed",
6193
+ type: edit.type || "modify",
5862
6194
  target_text: truncate_middle(edit.target_text || "", REPORT_ECHO_CAP),
5863
6195
  new_text: truncate_middle(_RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
5864
6196
  warning,
@@ -5874,6 +6206,7 @@ var RedlineEngine = class _RedlineEngine {
5874
6206
  const previews = this._build_edit_context_previews(edit);
5875
6207
  reports_by_input[i] = {
5876
6208
  status: "applied",
6209
+ type: edit.type || "modify",
5877
6210
  target_text: truncate_middle(edit.target_text || "", REPORT_ECHO_CAP),
5878
6211
  new_text: truncate_middle(_RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
5879
6212
  warning: null,
@@ -5895,6 +6228,7 @@ var RedlineEngine = class _RedlineEngine {
5895
6228
  );
5896
6229
  reports_by_input[i] = {
5897
6230
  status: "failed",
6231
+ type: edit.type || "modify",
5898
6232
  target_text: truncate_middle(edit.target_text || "", REPORT_ECHO_CAP),
5899
6233
  new_text: truncate_middle(_RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
5900
6234
  warning,
@@ -5913,6 +6247,7 @@ var RedlineEngine = class _RedlineEngine {
5913
6247
  if (report.status === "applied") {
5914
6248
  reports_by_input[i] = {
5915
6249
  status: "failed",
6250
+ type: report.type || "modify",
5916
6251
  target_text: report.target_text,
5917
6252
  new_text: report.new_text,
5918
6253
  warning: null,
@@ -5983,6 +6318,7 @@ var RedlineEngine = class _RedlineEngine {
5983
6318
  }
5984
6319
  edits_reports.push({
5985
6320
  status: success ? "applied" : "failed",
6321
+ type: edit.type || "modify",
5986
6322
  target_text: truncate_middle(edit.target_text || "", REPORT_ECHO_CAP),
5987
6323
  new_text: truncate_middle(_RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
5988
6324
  warning,
@@ -6000,6 +6336,11 @@ var RedlineEngine = class _RedlineEngine {
6000
6336
  return {
6001
6337
  actions_applied: applied_actions,
6002
6338
  actions_skipped: skipped_actions,
6339
+ // Actions whose target was already resolved by an earlier action of
6340
+ // this batch (via its replacement pair): consistent no-ops, never
6341
+ // counted as applied — every reported "applied" action causes an
6342
+ // observable state transition (ADEU-QA-004).
6343
+ actions_already_resolved: already_resolved_actions,
6003
6344
  edits_applied: applied_edits,
6004
6345
  edits_skipped: skipped_edits,
6005
6346
  // edits_applied counts change OBJECTS; this is the total number of
@@ -6041,19 +6382,66 @@ var RedlineEngine = class _RedlineEngine {
6041
6382
  edit._resolved_start_idx = edit._match_start_index;
6042
6383
  resolved_edits.push([edit, edit.new_text || null]);
6043
6384
  } else if (edit.type === "insert_row" || edit.type === "delete_row") {
6044
- let matches = this.mapper.find_all_match_indices(edit.target_text);
6385
+ let matches = this.mapper.drop_virtual_only_matches(
6386
+ this.mapper.find_all_match_indices(edit.target_text)
6387
+ );
6045
6388
  let resolved_mapper = this.mapper;
6046
6389
  if (matches.length === 0) {
6047
6390
  if (!this.clean_mapper) {
6048
6391
  this.clean_mapper = new DocumentMapper(this.doc, true);
6049
6392
  }
6050
- matches = this.clean_mapper.find_all_match_indices(edit.target_text);
6393
+ matches = this.clean_mapper.drop_virtual_only_matches(
6394
+ this.clean_mapper.find_all_match_indices(edit.target_text)
6395
+ );
6051
6396
  resolved_mapper = this.clean_mapper;
6052
6397
  }
6053
6398
  if (matches.length > 0) {
6054
- edit._resolved_start_idx = matches[0][0];
6055
- edit._active_mapper_ref = resolved_mapper;
6056
- resolved_edits.push([edit, null]);
6399
+ const match_mode = edit.match_mode || "strict";
6400
+ const unique_matches = [];
6401
+ const seen_trs = /* @__PURE__ */ new Set();
6402
+ for (const match of matches) {
6403
+ const start_idx = match[0];
6404
+ const [anchor_run, anchor_para] = resolved_mapper.get_insertion_anchor(start_idx, false);
6405
+ let target_element = null;
6406
+ if (anchor_run) target_element = anchor_run._element;
6407
+ else if (anchor_para) target_element = anchor_para._element;
6408
+ let tr = target_element;
6409
+ while (tr && tr.tagName !== "w:tr") tr = tr.parentNode;
6410
+ if (tr) {
6411
+ if (!seen_trs.has(tr)) {
6412
+ seen_trs.add(tr);
6413
+ unique_matches.push(match);
6414
+ }
6415
+ }
6416
+ }
6417
+ if (unique_matches.length > 0) {
6418
+ let matches_to_apply = unique_matches;
6419
+ if (match_mode === "strict" || match_mode === "first") {
6420
+ matches_to_apply = unique_matches.slice(0, 1);
6421
+ }
6422
+ if (match_mode === "all" || matches_to_apply.length > 1) {
6423
+ for (const m of matches_to_apply) {
6424
+ const sub_edit = {
6425
+ ...edit,
6426
+ _resolved_start_idx: m[0],
6427
+ _active_mapper_ref: resolved_mapper,
6428
+ _parent_edit_ref: edit
6429
+ };
6430
+ resolved_edits.push([sub_edit, null]);
6431
+ }
6432
+ } else {
6433
+ edit._resolved_start_idx = matches_to_apply[0][0];
6434
+ edit._active_mapper_ref = resolved_mapper;
6435
+ resolved_edits.push([edit, null]);
6436
+ }
6437
+ } else {
6438
+ skipped++;
6439
+ edit._applied_status = false;
6440
+ const target_snippet = (edit.target_text || "").trim().substring(0, 40);
6441
+ const msg = `- Failed to locate row target: '${target_snippet}...'`;
6442
+ this.skipped_details.push(msg);
6443
+ edit._error_msg = msg;
6444
+ }
6057
6445
  } else {
6058
6446
  skipped++;
6059
6447
  edit._applied_status = false;
@@ -6218,10 +6606,156 @@ var RedlineEngine = class _RedlineEngine {
6218
6606
  }
6219
6607
  return [applied_logical, skipped_logical];
6220
6608
  }
6609
+ /**
6610
+ * True when the paragraph still carries visible content (w:t text, w:tab,
6611
+ * w:br) that is NOT wrapped in a tracked deletion — i.e. the paragraph
6612
+ * would render non-empty in the accepted document.
6613
+ */
6614
+ _paragraph_has_visible_content(p_elem) {
6615
+ for (const tag of ["w:t", "w:tab", "w:br"]) {
6616
+ const nodes = findAllDescendants(p_elem, tag);
6617
+ for (const node of nodes) {
6618
+ let is_deleted = false;
6619
+ let curr = node.parentNode;
6620
+ while (curr && curr !== p_elem.parentNode) {
6621
+ if (curr.tagName === "w:del") {
6622
+ is_deleted = true;
6623
+ break;
6624
+ }
6625
+ curr = curr.parentNode;
6626
+ }
6627
+ if (!is_deleted) {
6628
+ if (tag === "w:t" && !node.textContent) continue;
6629
+ return true;
6630
+ }
6631
+ }
6632
+ }
6633
+ return false;
6634
+ }
6635
+ /**
6636
+ * All contiguous same-author w:ins/w:del siblings that form one logical
6637
+ * modification block with `node` (a replacement's del+ins pair). Mirrors
6638
+ * the Python engine's _get_paired_nodes: comment range markers and
6639
+ * rPr/pPr are transparent; a different author or any other element breaks
6640
+ * the group.
6641
+ */
6642
+ _get_paired_nodes(node) {
6643
+ const pairs = [];
6644
+ const author = node.getAttribute("w:author");
6645
+ const transparent = /* @__PURE__ */ new Set([
6646
+ "w:commentRangeStart",
6647
+ "w:commentRangeEnd",
6648
+ "w:commentReference",
6649
+ "w:rPr",
6650
+ "w:pPr"
6651
+ ]);
6652
+ const walk = (start, dir) => {
6653
+ let cur = dir === "next" ? start.nextSibling : start.previousSibling;
6654
+ while (cur) {
6655
+ if (cur.nodeType !== 1) {
6656
+ cur = dir === "next" ? cur.nextSibling : cur.previousSibling;
6657
+ continue;
6658
+ }
6659
+ const el = cur;
6660
+ if (transparent.has(el.tagName)) {
6661
+ cur = dir === "next" ? cur.nextSibling : cur.previousSibling;
6662
+ continue;
6663
+ }
6664
+ if ((el.tagName === "w:ins" || el.tagName === "w:del") && el.getAttribute("w:author") === author) {
6665
+ pairs.push(el);
6666
+ cur = dir === "next" ? cur.nextSibling : cur.previousSibling;
6667
+ continue;
6668
+ }
6669
+ break;
6670
+ }
6671
+ };
6672
+ walk(node, "next");
6673
+ walk(node, "prev");
6674
+ return pairs;
6675
+ }
6676
+ /**
6677
+ * All revision ids that resolve as ONE unit with `target_id`: the ids of
6678
+ * every contiguous same-author w:ins/w:del sibling of its elements (a
6679
+ * replacement's del+ins pair), plus the id itself.
6680
+ */
6681
+ _resolution_group_ids(target_id) {
6682
+ const nodes = [
6683
+ ...findAllDescendants(this.doc.element, "w:ins"),
6684
+ ...findAllDescendants(this.doc.element, "w:del")
6685
+ ].filter((n) => n.getAttribute("w:id") === target_id);
6686
+ const group = /* @__PURE__ */ new Set();
6687
+ if (nodes.length === 0) return group;
6688
+ group.add(target_id);
6689
+ for (const node of nodes) {
6690
+ for (const paired of this._get_paired_nodes(node)) {
6691
+ const pid = paired.getAttribute("w:id");
6692
+ if (pid) group.add(pid);
6693
+ }
6694
+ }
6695
+ return group;
6696
+ }
6697
+ /**
6698
+ * Document-aware validation (QA 2026-07-19 ADEU-QA-004): a replacement's
6699
+ * del+ins pair carries two distinct ids but resolves as one unit, so a
6700
+ * batch that accepts one side and rejects the other is contradictory.
6701
+ * Rejecting it up front — before any action mutates the document — keeps
6702
+ * the batch transactional.
6703
+ */
6704
+ validate_action_pairing(actions) {
6705
+ const errors = [];
6706
+ const group_first = /* @__PURE__ */ new Map();
6707
+ for (let pos = 0; pos < actions.length; pos++) {
6708
+ const act = actions[pos];
6709
+ if (act.type !== "accept" && act.type !== "reject") continue;
6710
+ const raw_id = String(act.target_id ?? "");
6711
+ if (raw_id.startsWith("Com:")) continue;
6712
+ const target_id = raw_id.startsWith("Chg:") ? raw_id.slice(4) : raw_id;
6713
+ const group = this._resolution_group_ids(target_id);
6714
+ if (group.size === 0) continue;
6715
+ let conflict = null;
6716
+ for (const gid of group) {
6717
+ const prior = group_first.get(gid);
6718
+ if (prior !== void 0 && prior[1] !== act.type) {
6719
+ conflict = prior;
6720
+ break;
6721
+ }
6722
+ }
6723
+ if (conflict !== null) {
6724
+ const [first_pos, first_type, first_id] = conflict;
6725
+ errors.push(
6726
+ `- Action ${pos + 1} Failed: conflicting actions on one replacement \u2014 Action ${first_pos + 1} applies '${first_type}' to Chg:${first_id}, and Chg:${target_id} is part of the same change (a replacement's contiguous del+ins pair resolves as one unit, so '${first_type}' already decides both sides). Accepting one side and rejecting the other is contradictory \u2014 decide the outcome and submit exactly one action for the pair.`
6727
+ );
6728
+ continue;
6729
+ }
6730
+ for (const gid of group) {
6731
+ if (!group_first.has(gid)) {
6732
+ group_first.set(gid, [pos, act.type, target_id]);
6733
+ }
6734
+ }
6735
+ }
6736
+ return errors;
6737
+ }
6738
+ /**
6739
+ * Returns [applied, skipped, already_resolved]. `applied` counts actions
6740
+ * that caused an observable state transition; an action naming an id an
6741
+ * earlier action of this batch already resolved (via its replacement pair)
6742
+ * is counted in `already_resolved` instead — never as applied
6743
+ * (QA 2026-07-19 ADEU-QA-004).
6744
+ */
6221
6745
  apply_review_actions(actions) {
6222
6746
  let applied = 0;
6223
6747
  let skipped = 0;
6224
- for (const action of actions) {
6748
+ let already_resolved = 0;
6749
+ const resolved_history = /* @__PURE__ */ new Map();
6750
+ const sortedActions = actions.map((action, pos) => ({ action, pos })).sort((a, b) => {
6751
+ const aPri = a.action.type === "reply" ? 0 : 1;
6752
+ const bPri = b.action.type === "reply" ? 0 : 1;
6753
+ if (aPri !== bPri) {
6754
+ return aPri - bPri;
6755
+ }
6756
+ return a.pos - b.pos;
6757
+ });
6758
+ for (const { action, pos } of sortedActions) {
6225
6759
  const type = action.type;
6226
6760
  if (type === "reply") {
6227
6761
  const cid = action.target_id.replace("Com:", "");
@@ -6235,6 +6769,21 @@ var RedlineEngine = class _RedlineEngine {
6235
6769
  continue;
6236
6770
  }
6237
6771
  const target_id = action.target_id.replace("Chg:", "");
6772
+ const prior_type = resolved_history.get(target_id);
6773
+ if (prior_type !== void 0) {
6774
+ if (prior_type === type) {
6775
+ already_resolved++;
6776
+ this.skipped_details.push(
6777
+ `- Note: Action ${pos + 1} ('${type}' on ${action.target_id}) had no additional effect \u2014 the change was already resolved together with its replacement pair by an earlier action in this batch. Counted as already_resolved, not applied.`
6778
+ );
6779
+ continue;
6780
+ }
6781
+ this.skipped_details.push(
6782
+ `- Action ${pos + 1} Failed: contradictory action \u2014 '${type}' on ${action.target_id}, but the change was already resolved as '${prior_type}' together with its replacement pair by an earlier action in this batch.`
6783
+ );
6784
+ skipped++;
6785
+ continue;
6786
+ }
6238
6787
  const all_ins = findAllDescendants(this.doc.element, "w:ins").filter(
6239
6788
  (n) => n.getAttribute("w:id") === target_id
6240
6789
  );
@@ -6259,7 +6808,18 @@ var RedlineEngine = class _RedlineEngine {
6259
6808
  );
6260
6809
  continue;
6261
6810
  }
6811
+ const group_nodes = new Set(all_nodes);
6262
6812
  for (const node of all_nodes) {
6813
+ for (const paired of this._get_paired_nodes(node)) {
6814
+ group_nodes.add(paired);
6815
+ }
6816
+ }
6817
+ const resolved_now = /* @__PURE__ */ new Set();
6818
+ for (const node of group_nodes) {
6819
+ const rid = node.getAttribute("w:id");
6820
+ if (rid) resolved_now.add(rid);
6821
+ }
6822
+ for (const node of group_nodes) {
6263
6823
  const is_ins = node.tagName === "w:ins";
6264
6824
  const parent_tag = node.parentNode ? node.parentNode.tagName : "";
6265
6825
  const is_trPr = parent_tag === "w:trPr";
@@ -6311,9 +6871,12 @@ var RedlineEngine = class _RedlineEngine {
6311
6871
  }
6312
6872
  }
6313
6873
  }
6874
+ for (const rid of resolved_now) {
6875
+ resolved_history.set(rid, type);
6876
+ }
6314
6877
  applied++;
6315
6878
  }
6316
- return [applied, skipped];
6879
+ return [applied, skipped, already_resolved];
6317
6880
  }
6318
6881
  _apply_table_edit(edit, rebuild_map) {
6319
6882
  const start_idx = edit._resolved_start_idx !== void 0 && edit._resolved_start_idx !== null ? edit._resolved_start_idx : edit._match_start_index || 0;
@@ -6373,17 +6936,15 @@ var RedlineEngine = class _RedlineEngine {
6373
6936
  if (!edit.target_text) return null;
6374
6937
  const is_regex = edit.regex || false;
6375
6938
  const match_mode = edit.match_mode || "strict";
6376
- let matches = this.mapper.find_all_match_indices(
6377
- edit.target_text,
6378
- is_regex
6939
+ let matches = this.mapper.drop_virtual_only_matches(
6940
+ this.mapper.find_all_match_indices(edit.target_text, is_regex)
6379
6941
  );
6380
6942
  let use_clean_map = false;
6381
6943
  if (matches.length === 0) {
6382
6944
  if (!this.clean_mapper)
6383
6945
  this.clean_mapper = new DocumentMapper(this.doc, true);
6384
- matches = this.clean_mapper.find_all_match_indices(
6385
- edit.target_text,
6386
- is_regex
6946
+ matches = this.clean_mapper.drop_virtual_only_matches(
6947
+ this.clean_mapper.find_all_match_indices(edit.target_text, is_regex)
6387
6948
  );
6388
6949
  if (matches.length > 0) use_clean_map = true;
6389
6950
  else return null;
@@ -6893,7 +7454,9 @@ var RedlineEngine = class _RedlineEngine {
6893
7454
  this._set_paragraph_style(new_p, style_name);
6894
7455
  } else {
6895
7456
  const existing_pPr = findChild(_bug233_target_para, "w:pPr");
6896
- if (existing_pPr) new_p.appendChild(existing_pPr.cloneNode(true));
7457
+ if (existing_pPr) {
7458
+ new_p.appendChild(this._clone_pPr_scrubbing_headings(existing_pPr));
7459
+ }
6897
7460
  }
6898
7461
  let pPr = findChild(new_p, "w:pPr");
6899
7462
  if (!pPr) {
@@ -7151,7 +7714,24 @@ var RedlineEngine = class _RedlineEngine {
7151
7714
  p2_element = getNextElement(p2_element);
7152
7715
  }
7153
7716
  if (p2_element && p2_element.tagName === "w:p") {
7717
+ const p1_fully_deleted = !this._paragraph_has_visible_content(p1_element);
7154
7718
  let pPr = findChild(p1_element, "w:pPr");
7719
+ if (p1_fully_deleted) {
7720
+ const p2_pPr = findChild(p2_element, "w:pPr");
7721
+ const adopted = p2_pPr ? p2_pPr.cloneNode(true) : p1_element.ownerDocument.createElement("w:pPr");
7722
+ if (pPr) {
7723
+ const sect = findChild(pPr, "w:sectPr");
7724
+ if (sect && !findChild(adopted, "w:sectPr")) {
7725
+ adopted.appendChild(sect.cloneNode(true));
7726
+ }
7727
+ p1_element.removeChild(pPr);
7728
+ }
7729
+ p1_element.insertBefore(
7730
+ adopted,
7731
+ p1_element.firstChild
7732
+ );
7733
+ pPr = adopted;
7734
+ }
7155
7735
  if (!pPr) {
7156
7736
  pPr = p1_element.ownerDocument.createElement("w:pPr");
7157
7737
  p1_element.insertBefore(
@@ -7164,8 +7744,10 @@ var RedlineEngine = class _RedlineEngine {
7164
7744
  rPr = p1_element.ownerDocument.createElement("w:rPr");
7165
7745
  pPr.appendChild(rPr);
7166
7746
  }
7167
- const del_mark = this._create_track_change_tag("w:del");
7168
- rPr.appendChild(del_mark);
7747
+ if (!findChild(rPr, "w:del")) {
7748
+ const del_mark = this._create_track_change_tag("w:del");
7749
+ rPr.appendChild(del_mark);
7750
+ }
7169
7751
  const children = Array.from(p2_element.childNodes);
7170
7752
  for (const child of children) {
7171
7753
  if (child.nodeType === 1 && child.tagName === "w:pPr") {
@@ -7219,27 +7801,7 @@ var RedlineEngine = class _RedlineEngine {
7219
7801
  }
7220
7802
  }
7221
7803
  for (const p_elem of affected_ps) {
7222
- let has_visible = false;
7223
- for (const tag of ["w:t", "w:tab", "w:br"]) {
7224
- const nodes = findAllDescendants(p_elem, tag);
7225
- for (const node of nodes) {
7226
- let is_deleted = false;
7227
- let curr = node.parentNode;
7228
- while (curr && curr !== p_elem.parentNode) {
7229
- if (curr.tagName === "w:del") {
7230
- is_deleted = true;
7231
- break;
7232
- }
7233
- curr = curr.parentNode;
7234
- }
7235
- if (!is_deleted) {
7236
- if (tag === "w:t" && !node.textContent) continue;
7237
- has_visible = true;
7238
- break;
7239
- }
7240
- }
7241
- if (has_visible) break;
7242
- }
7804
+ const has_visible = this._paragraph_has_visible_content(p_elem);
7243
7805
  if (!has_visible) {
7244
7806
  let pPr = findChild(p_elem, "w:pPr");
7245
7807
  if (!pPr) {
@@ -7798,6 +8360,31 @@ function strip_custom_properties(doc) {
7798
8360
  const count = Math.max(lines.length, 1);
7799
8361
  return [`Custom document properties: ${count} removed (docProps/custom.xml)`].concat(lines);
7800
8362
  }
8363
+ function strip_document_variables(doc) {
8364
+ const settingsPart = doc.pkg.getPartByPath("word/settings.xml");
8365
+ if (!settingsPart) return [];
8366
+ const names = [];
8367
+ let removedAny = false;
8368
+ for (const container of findDescendantsByLocalName(settingsPart._element, "docVars")) {
8369
+ for (const child of Array.from(container.childNodes)) {
8370
+ const el = child;
8371
+ const tag = el.tagName || "";
8372
+ if (tag === "docVar" || tag.endsWith(":docVar")) {
8373
+ names.push(el.getAttribute("w:name") || el.getAttribute("name") || "(unnamed)");
8374
+ }
8375
+ }
8376
+ container.parentNode?.removeChild(container);
8377
+ removedAny = true;
8378
+ }
8379
+ for (const stray of findDescendantsByLocalName(settingsPart._element, "docVar")) {
8380
+ names.push(stray.getAttribute("w:name") || stray.getAttribute("name") || "(unnamed)");
8381
+ stray.parentNode?.removeChild(stray);
8382
+ removedAny = true;
8383
+ }
8384
+ if (!removedAny) return [];
8385
+ const shown = Array.from(new Set(names)).sort().join(", ") || "(unnamed)";
8386
+ return [`Document variables: ${names.length} removed (${shown})`];
8387
+ }
7801
8388
  function strip_image_alt_text(doc) {
7802
8389
  let count = 0;
7803
8390
  for (const docPr of findDescendantsByLocalName(doc.element, "docPr")) {
@@ -8316,10 +8903,28 @@ function extract_table(table, comments_map, cleanView, cursor, paragraph_offsets
8316
8903
  paragraph_offsets
8317
8904
  );
8318
8905
  if (!cleanView) {
8319
- const firstP = cell._element.getElementsByTagName("w:p")[0];
8320
- const paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
8906
+ let firstP = cell._element.getElementsByTagName("w:p")[0];
8907
+ let paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
8908
+ if (!paraId && (!cell_content || cell_content.trim() === "")) {
8909
+ if (!firstP) {
8910
+ const xmlDoc = cell._element.ownerDocument;
8911
+ firstP = xmlDoc.createElement("w:p");
8912
+ cell._element.appendChild(firstP);
8913
+ }
8914
+ const allPs = Array.from(cell._element.ownerDocument.getElementsByTagName("w:p"));
8915
+ const index = allPs.indexOf(firstP);
8916
+ let hash = 2166136261;
8917
+ const str = `fallback-paraId-${index}`;
8918
+ for (let i = 0; i < str.length; i++) {
8919
+ hash ^= str.charCodeAt(i);
8920
+ hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
8921
+ }
8922
+ paraId = (hash >>> 0).toString(16).toUpperCase().padStart(8, "0");
8923
+ firstP.setAttribute("w14:paraId", paraId);
8924
+ }
8321
8925
  if (paraId) {
8322
- const anchor = `{#cell:${paraId}}`;
8926
+ const space_pad = cell_content ? " " : "";
8927
+ const anchor = `${space_pad}{#cell:${paraId}}`;
8323
8928
  cell_content = cell_content + anchor;
8324
8929
  }
8325
8930
  }
@@ -8584,6 +9189,8 @@ function _build_merged_meta_block(states_list, comments_map) {
8584
9189
  const change_lines = [];
8585
9190
  const comment_lines = [];
8586
9191
  const seen_sigs = /* @__PURE__ */ new Set();
9192
+ const pair_map = compute_change_pair_map(states_list);
9193
+ const pairSuffix = (uid) => pair_map[uid] ? ` (pairs with ${pair_map[uid]})` : "";
8587
9194
  for (const [ins_map, del_map, comments_set, fmt_map] of states_list) {
8588
9195
  let render_comment2 = function(cid) {
8589
9196
  if (!comments_map[cid]) return;
@@ -8612,7 +9219,9 @@ function _build_merged_meta_block(states_list, comments_map) {
8612
9219
  )) {
8613
9220
  const sig = `Chg:${uid}`;
8614
9221
  if (!seen_sigs.has(sig)) {
8615
- change_lines.push(`[${sig} insert] ${meta.author || "Unknown"}`);
9222
+ change_lines.push(
9223
+ `[${sig} insert] ${meta.author || "Unknown"}${pairSuffix(uid)}`
9224
+ );
8616
9225
  seen_sigs.add(sig);
8617
9226
  }
8618
9227
  }
@@ -8621,7 +9230,9 @@ function _build_merged_meta_block(states_list, comments_map) {
8621
9230
  )) {
8622
9231
  const sig = `Chg:${uid}`;
8623
9232
  if (!seen_sigs.has(sig)) {
8624
- change_lines.push(`[${sig} delete] ${meta.author || "Unknown"}`);
9233
+ change_lines.push(
9234
+ `[${sig} delete] ${meta.author || "Unknown"}${pairSuffix(uid)}`
9235
+ );
8625
9236
  seen_sigs.add(sig);
8626
9237
  }
8627
9238
  }
@@ -9183,14 +9794,14 @@ var SanitizeReport = class {
9183
9794
  const lower = line.toLowerCase();
9184
9795
  if (lower.includes("tracked change") || lower.includes("insertion") || lower.includes("deletion") || lower.includes("accepted")) {
9185
9796
  this.change_lines.push(line);
9797
+ } else if (lower.includes("author") || lower.includes("template") || lower.includes("company") || lower.includes("manager") || lower.includes("metadata") || lower.includes("timestamp") || lower.includes("custom xml") || lower.includes("custom propert") || lower.includes("identifier") || lower.includes("document variable") || lower.includes("language") || lower.includes("version") || lower.includes("last modified by") || lower.includes("revision count") || lower.includes("last printed") || lower.includes("description/comments")) {
9798
+ this.metadata_lines.push(line);
9186
9799
  } else if (lower.includes("comment") || lower.includes("[open]") || lower.includes("[resolved]")) {
9187
9800
  if (lower.includes("kept") || lower.includes("visible")) {
9188
9801
  this.kept_comment_lines.push(line);
9189
9802
  } else {
9190
9803
  this.removed_comment_lines.push(line);
9191
9804
  }
9192
- } else if (lower.includes("author") || lower.includes("template") || lower.includes("company") || lower.includes("manager") || lower.includes("metadata") || lower.includes("timestamp") || lower.includes("custom xml") || lower.includes("custom propert") || lower.includes("identifier") || lower.includes("language") || lower.includes("version") || lower.includes("last modified by") || lower.includes("revision count") || lower.includes("last printed")) {
9193
- this.metadata_lines.push(line);
9194
9805
  } else if (lower.includes("hyperlink") || lower.includes("warning")) {
9195
9806
  this.warnings.push(line);
9196
9807
  } else {
@@ -9269,7 +9880,7 @@ async function finalize_document(doc, options) {
9269
9880
  report.tracked_changes_found = total;
9270
9881
  if (total > 0 && !options.accept_all) {
9271
9882
  report.status = "blocked";
9272
- report.blocked_reason = `Document contains ${total} unresolved tracked changes (${counts[0]} insertions, ${counts[1]} deletions, ${counts[2]} formatting). Review in Word first, or set accept_all=true.`;
9883
+ report.blocked_reason = `Document contains ${total} unresolved tracked changes (${counts[0]} insertions, ${counts[1]} deletions, ${counts[2]} formatting). Review in Word first, set accept_all=true, or set sanitize_mode='keep-markup'.`;
9273
9884
  return { reportText: report.render() };
9274
9885
  }
9275
9886
  if (total > 0) {
@@ -9303,6 +9914,7 @@ async function finalize_document(doc, options) {
9303
9914
  report.add_transform_lines(scrub_timestamps(doc));
9304
9915
  report.add_transform_lines(strip_custom_xml(doc));
9305
9916
  report.add_transform_lines(strip_custom_properties(doc));
9917
+ report.add_transform_lines(strip_document_variables(doc));
9306
9918
  report.add_transform_lines(strip_image_alt_text(doc));
9307
9919
  const warnings = audit_hyperlinks(doc);
9308
9920
  for (const w of warnings) report.warnings.push(w);
@@ -9386,6 +9998,12 @@ function verifySanitizedPackage(outBuffer) {
9386
9998
  }
9387
9999
  }
9388
10000
  }
10001
+ if (names.includes("word/settings.xml")) {
10002
+ const settings = parseXml((0, import_fflate3.strFromU8)(unzipped["word/settings.xml"]));
10003
+ if (findDescendantsByLocalName(settings.documentElement, "docVar").length > 0) {
10004
+ problems.push("word/settings.xml still contains document variables (w:docVar)");
10005
+ }
10006
+ }
9389
10007
  if (problems.length > 0) {
9390
10008
  throw new Error(
9391
10009
  "Sanitize integrity check failed \u2014 the saved package still contains metadata this run claims to remove:\n - " + problems.join("\n - ") + "\nRefusing to report a clean document."