@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.js CHANGED
@@ -1167,6 +1167,18 @@ function get_paragraph_prefix(paragraph, style_cache, default_pstyle) {
1167
1167
  if (custom_level !== null) return "#".repeat(custom_level) + " ";
1168
1168
  }
1169
1169
  if (!style_name || style_name === "Normal") {
1170
+ let is_inside_tc = false;
1171
+ let curr = paragraph._element;
1172
+ while (curr) {
1173
+ if (curr.tagName === "w:tc") {
1174
+ is_inside_tc = true;
1175
+ break;
1176
+ }
1177
+ curr = curr.parentNode;
1178
+ }
1179
+ if (is_inside_tc) {
1180
+ return "";
1181
+ }
1170
1182
  const text = paragraph.text.trim();
1171
1183
  if (text && text.length < 100 && text === text.toUpperCase()) {
1172
1184
  let is_bold = false;
@@ -1238,6 +1250,50 @@ function split_boundary_whitespace(text) {
1238
1250
  text.substring(lead_len + core.length)
1239
1251
  ];
1240
1252
  }
1253
+ function compute_change_pair_map(states_list) {
1254
+ const groups = [];
1255
+ let current = [];
1256
+ const seen_ids = /* @__PURE__ */ new Set();
1257
+ for (const [ins_map, del_map] of states_list) {
1258
+ const ins_entries = Object.entries(ins_map);
1259
+ const del_entries = Object.entries(del_map);
1260
+ if (ins_entries.length === 0 && del_entries.length === 0) {
1261
+ if (current.length > 0) {
1262
+ groups.push(current);
1263
+ current = [];
1264
+ }
1265
+ continue;
1266
+ }
1267
+ const state_new = [];
1268
+ for (const [uid, meta] of ins_entries) {
1269
+ if (!seen_ids.has(uid)) {
1270
+ state_new.push([uid, meta && meta.author || "Unknown"]);
1271
+ }
1272
+ }
1273
+ for (const [uid, meta] of del_entries) {
1274
+ if (!seen_ids.has(uid)) {
1275
+ state_new.push([uid, meta && meta.author || "Unknown"]);
1276
+ }
1277
+ }
1278
+ for (const [uid, author] of state_new) {
1279
+ seen_ids.add(uid);
1280
+ if (current.length > 0 && current[current.length - 1][1] !== author) {
1281
+ groups.push(current);
1282
+ current = [];
1283
+ }
1284
+ current.push([uid, author]);
1285
+ }
1286
+ }
1287
+ if (current.length > 0) groups.push(current);
1288
+ const pair_map = {};
1289
+ for (const group of groups) {
1290
+ if (group.length < 2) continue;
1291
+ for (const [uid] of group) {
1292
+ pair_map[uid] = group.filter(([u]) => u !== uid).map(([u]) => `Chg:${u}`).join(", ");
1293
+ }
1294
+ }
1295
+ return pair_map;
1296
+ }
1241
1297
  function apply_formatting_to_segments(text, prefix, suffix) {
1242
1298
  if (!prefix && !suffix) return text;
1243
1299
  if (!text) return "";
@@ -1640,11 +1696,34 @@ ${header}`;
1640
1696
  const cell_start = current;
1641
1697
  current = this._map_blocks(cell, current);
1642
1698
  if (!this.clean_view && !this.original_view) {
1643
- const firstP = cell._element.getElementsByTagName("w:p")[0];
1644
- const paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
1699
+ let firstP = cell._element.getElementsByTagName("w:p")[0];
1700
+ let paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
1701
+ const is_empty = current === cell_start;
1702
+ if (!paraId && is_empty) {
1703
+ if (!firstP) {
1704
+ const xmlDoc = cell._element.ownerDocument;
1705
+ firstP = xmlDoc.createElement("w:p");
1706
+ cell._element.appendChild(firstP);
1707
+ }
1708
+ const allPs = Array.from(cell._element.ownerDocument.getElementsByTagName("w:p"));
1709
+ const index = allPs.indexOf(firstP);
1710
+ let hash = 2166136261;
1711
+ const str = `fallback-paraId-${index}`;
1712
+ for (let i = 0; i < str.length; i++) {
1713
+ hash ^= str.charCodeAt(i);
1714
+ hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
1715
+ }
1716
+ paraId = (hash >>> 0).toString(16).toUpperCase().padStart(8, "0");
1717
+ firstP.setAttribute("w14:paraId", paraId);
1718
+ }
1645
1719
  if (paraId && firstP) {
1646
1720
  const cellPara = new Paragraph(firstP, cell);
1647
1721
  this._add_virtual_text("", current, cellPara);
1722
+ const has_content = current > cell_start;
1723
+ if (has_content) {
1724
+ this._add_virtual_text(" ", current, cellPara);
1725
+ current += 1;
1726
+ }
1648
1727
  const anchor = `{#cell:${paraId}}`;
1649
1728
  this._add_virtual_text(anchor, current, cellPara);
1650
1729
  current += anchor.length;
@@ -1995,6 +2074,8 @@ ${header}`;
1995
2074
  const change_lines = [];
1996
2075
  const comment_lines = [];
1997
2076
  const seen_sigs = /* @__PURE__ */ new Set();
2077
+ const pair_map = compute_change_pair_map(states_list);
2078
+ const pairSuffix = (uid) => pair_map[uid] ? ` (pairs with ${pair_map[uid]})` : "";
1998
2079
  for (const [ins_map, del_map, comments_set, fmt_map] of states_list) {
1999
2080
  for (const [uid, meta] of Object.entries(
2000
2081
  ins_map
@@ -2002,7 +2083,7 @@ ${header}`;
2002
2083
  const sig = `Chg:${uid}`;
2003
2084
  if (!seen_sigs.has(sig)) {
2004
2085
  const auth = meta.author || "Unknown";
2005
- change_lines.push(`[${sig} insert] ${auth}`);
2086
+ change_lines.push(`[${sig} insert] ${auth}${pairSuffix(uid)}`);
2006
2087
  seen_sigs.add(sig);
2007
2088
  }
2008
2089
  }
@@ -2012,7 +2093,7 @@ ${header}`;
2012
2093
  const sig = `Chg:${uid}`;
2013
2094
  if (!seen_sigs.has(sig)) {
2014
2095
  const auth = meta.author || "Unknown";
2015
- change_lines.push(`[${sig} delete] ${auth}`);
2096
+ change_lines.push(`[${sig} delete] ${auth}${pairSuffix(uid)}`);
2016
2097
  seen_sigs.add(sig);
2017
2098
  }
2018
2099
  }
@@ -2152,21 +2233,60 @@ ${header}`;
2152
2233
  }
2153
2234
  return results;
2154
2235
  }
2236
+ /**
2237
+ * True when no run-backed span overlaps [start, start+length): the range
2238
+ * covers only virtual projection text — meta bubbles (change/comment
2239
+ * headers, timestamps), style markers, list prefixes. Such text does not
2240
+ * exist in the document, so it can neither satisfy a match nor count
2241
+ * toward ambiguity (QA 2026-07-19 ADEU-QA-002 C): an edit targeting "4"
2242
+ * used to be rejected as "appears 8 times" because a comment bubble's
2243
+ * timestamp matched.
2244
+ *
2245
+ * Anchor tokens ({#Bookmark}, {#cell:paraId}) are the exception: they are
2246
+ * deliberate virtual TARGETING surfaces (empty-cell writes, bookmark
2247
+ * anchors) and must stay matchable.
2248
+ */
2249
+ range_is_virtual_only(start, length) {
2250
+ const end = start + length;
2251
+ const overlapping = this.spans.filter(
2252
+ (s) => s.end > start && s.start < end
2253
+ );
2254
+ if (overlapping.some((s) => s.run !== null)) return false;
2255
+ return !overlapping.some(
2256
+ (s) => s.run === null && s.text.startsWith("{#")
2257
+ );
2258
+ }
2259
+ /** Filters find_all_match_indices output down to matches that touch at
2260
+ * least one run-backed span. See range_is_virtual_only. */
2261
+ drop_virtual_only_matches(matches) {
2262
+ return matches.filter(
2263
+ ([start, length]) => !this.range_is_virtual_only(start, length)
2264
+ );
2265
+ }
2155
2266
  find_match_index(target_text, is_regex = false) {
2156
2267
  if (is_regex) {
2157
2268
  try {
2158
2269
  const match = userSearch(target_text, this.full_text);
2159
- if (match) return [match.start, match.end - match.start];
2270
+ if (match && !this.range_is_virtual_only(match.start, match.end - match.start)) {
2271
+ return [match.start, match.end - match.start];
2272
+ }
2160
2273
  } catch (e) {
2161
2274
  if (e instanceof RegexTimeoutError) throw e;
2162
2275
  }
2163
2276
  return [-1, 0];
2164
2277
  }
2165
- let start_idx = this.full_text.indexOf(target_text);
2166
- if (start_idx !== -1) return [start_idx, target_text.length];
2278
+ let from = 0;
2279
+ while (true) {
2280
+ const idx = this.full_text.indexOf(target_text, from);
2281
+ if (idx === -1) break;
2282
+ if (!this.range_is_virtual_only(idx, target_text.length)) {
2283
+ return [idx, target_text.length];
2284
+ }
2285
+ from = idx + 1;
2286
+ }
2167
2287
  const norm_full = this._replace_smart_quotes(this.full_text);
2168
2288
  const norm_target = this._replace_smart_quotes(target_text);
2169
- start_idx = norm_full.indexOf(norm_target);
2289
+ let start_idx = norm_full.indexOf(norm_target);
2170
2290
  if (start_idx !== -1) return [start_idx, target_text.length];
2171
2291
  const stripped_target = this._strip_markdown_formatting(target_text);
2172
2292
  if (this.full_text.includes(stripped_target)) {
@@ -2176,9 +2296,12 @@ ${header}`;
2176
2296
  const plain_first = this._find_plain_projection_matches(target_text);
2177
2297
  if (plain_first.length > 0) return plain_first[0];
2178
2298
  try {
2179
- const pattern = new RegExp(this._make_fuzzy_regex(target_text));
2180
- const match = pattern.exec(this.full_text);
2181
- if (match) return [match.index, match[0].length];
2299
+ const pattern = new RegExp(this._make_fuzzy_regex(target_text), "g");
2300
+ for (const match of this.full_text.matchAll(pattern)) {
2301
+ if (!this.range_is_virtual_only(match.index, match[0].length)) {
2302
+ return [match.index, match[0].length];
2303
+ }
2304
+ }
2182
2305
  } catch (e) {
2183
2306
  }
2184
2307
  return [-1, 0];
@@ -2707,6 +2830,157 @@ function _sequence_opcodes(a, b) {
2707
2830
  flushPending();
2708
2831
  return ops;
2709
2832
  }
2833
+ function _is_table_blob(block) {
2834
+ const lines = block.split("\n");
2835
+ return lines.length > 0 && lines.every((line) => line.includes(" | "));
2836
+ }
2837
+ function _table_blob_row_edits(orig_blob, mod_blob) {
2838
+ const rows_o = orig_blob.split("\n");
2839
+ const rows_m = mod_blob.split("\n");
2840
+ if (rows_o.length !== rows_m.length) return null;
2841
+ const row_edits = [];
2842
+ for (let k = 0; k < rows_o.length; k++) {
2843
+ if (rows_o[k] === rows_m[k]) continue;
2844
+ row_edits.push({
2845
+ type: "modify",
2846
+ target_text: rows_o[k],
2847
+ new_text: rows_m[k],
2848
+ comment: "Diff: Table row modified",
2849
+ _is_table_edit: true
2850
+ });
2851
+ }
2852
+ return row_edits;
2853
+ }
2854
+ function _split_cross_paragraph_hunks(edits) {
2855
+ const out = [];
2856
+ for (const e of edits) {
2857
+ const target = e.target_text || "";
2858
+ const newText = e.new_text || "";
2859
+ const idx = e._match_start_index;
2860
+ if (idx === void 0 || idx === null || !target.includes("\n\n") || target.split("\n\n").length - 1 === newText.split("\n\n").length - 1) {
2861
+ out.push(e);
2862
+ continue;
2863
+ }
2864
+ const parts = target.split("\n\n");
2865
+ if (parts.length < 2 || !parts[0].trim() || !parts[parts.length - 1].trim()) {
2866
+ out.push(e);
2867
+ continue;
2868
+ }
2869
+ let offset = idx;
2870
+ for (const piece of parts.slice(0, -1)) {
2871
+ out.push({
2872
+ type: "modify",
2873
+ target_text: piece + "\n\n",
2874
+ new_text: "",
2875
+ comment: e.comment || "Diff: Text deleted",
2876
+ _match_start_index: offset
2877
+ });
2878
+ offset += piece.length + 2;
2879
+ }
2880
+ out.push({
2881
+ type: "modify",
2882
+ target_text: parts[parts.length - 1],
2883
+ new_text: newText,
2884
+ comment: e.comment,
2885
+ _match_start_index: offset
2886
+ });
2887
+ }
2888
+ return out;
2889
+ }
2890
+ function generate_edits_via_paragraph_alignment(original_text, modified_text) {
2891
+ const orig_stripped = original_text.trimEnd();
2892
+ const orig_ws = original_text.slice(orig_stripped.length);
2893
+ const mod_stripped = modified_text.trimEnd();
2894
+ modified_text = mod_stripped + orig_ws;
2895
+ const orig_paragraphs = original_text.split("\n\n");
2896
+ const mod_paragraphs = modified_text.split("\n\n");
2897
+ const orig_offsets = [];
2898
+ let current_offset = 0;
2899
+ for (const p of orig_paragraphs) {
2900
+ orig_offsets.push(current_offset);
2901
+ current_offset += p.length + 2;
2902
+ }
2903
+ const opcodes = _sequence_opcodes(orig_paragraphs, mod_paragraphs);
2904
+ const edits = [];
2905
+ for (const [tag, i1, i2, j1, j2] of opcodes) {
2906
+ if (tag === "equal") continue;
2907
+ const offset = i1 < orig_offsets.length ? orig_offsets[i1] : original_text.length;
2908
+ if (tag === "delete") {
2909
+ if (i2 < orig_paragraphs.length) {
2910
+ let piece_offset = offset;
2911
+ for (let k = i1; k < i2; k++) {
2912
+ const piece = orig_paragraphs[k] + "\n\n";
2913
+ edits.push({
2914
+ type: "modify",
2915
+ target_text: piece,
2916
+ new_text: "",
2917
+ comment: "Diff: Text deleted",
2918
+ _match_start_index: piece_offset
2919
+ });
2920
+ piece_offset += piece.length;
2921
+ }
2922
+ } else {
2923
+ let deleted_text = orig_paragraphs.slice(i1, i2).join("\n\n");
2924
+ let del_offset = offset;
2925
+ if (i1 > 0) {
2926
+ deleted_text = "\n\n" + deleted_text;
2927
+ del_offset -= 2;
2928
+ }
2929
+ edits.push({
2930
+ type: "modify",
2931
+ target_text: deleted_text,
2932
+ new_text: "",
2933
+ comment: "Diff: Text deleted",
2934
+ _match_start_index: del_offset
2935
+ });
2936
+ }
2937
+ } else if (tag === "insert") {
2938
+ let inserted_text = mod_paragraphs.slice(j1, j2).join("\n\n");
2939
+ if (i1 < orig_paragraphs.length) {
2940
+ inserted_text = inserted_text + "\n\n";
2941
+ } else {
2942
+ inserted_text = "\n\n" + inserted_text;
2943
+ }
2944
+ edits.push({
2945
+ type: "modify",
2946
+ target_text: "",
2947
+ new_text: inserted_text,
2948
+ comment: "Diff: Text inserted",
2949
+ _match_start_index: offset
2950
+ });
2951
+ } else if (tag === "replace") {
2952
+ if (i2 - i1 === j2 - j1 && Array.from({ length: i2 - i1 }).some(
2953
+ (_, k) => _is_table_blob(orig_paragraphs[i1 + k]) || _is_table_blob(mod_paragraphs[j1 + k])
2954
+ )) {
2955
+ for (let k = 0; k < i2 - i1; k++) {
2956
+ const orig_p = orig_paragraphs[i1 + k];
2957
+ const mod_p = mod_paragraphs[j1 + k];
2958
+ if (orig_p === mod_p) continue;
2959
+ const pair_offset = orig_offsets[i1 + k];
2960
+ const row_edits = _is_table_blob(orig_p) && _is_table_blob(mod_p) ? _table_blob_row_edits(orig_p, mod_p) : null;
2961
+ if (row_edits !== null) {
2962
+ edits.push(...row_edits);
2963
+ continue;
2964
+ }
2965
+ const pair_edits = generate_edits_from_text(orig_p, mod_p);
2966
+ for (const ce of pair_edits) {
2967
+ ce._match_start_index = (ce._match_start_index || 0) + pair_offset;
2968
+ edits.push(ce);
2969
+ }
2970
+ }
2971
+ continue;
2972
+ }
2973
+ const orig_chunk = orig_paragraphs.slice(i1, i2).join("\n\n");
2974
+ const mod_chunk = mod_paragraphs.slice(j1, j2).join("\n\n");
2975
+ const chunk_edits = generate_edits_from_text(orig_chunk, mod_chunk);
2976
+ for (const ce of chunk_edits) {
2977
+ ce._match_start_index = (ce._match_start_index || 0) + offset;
2978
+ edits.push(ce);
2979
+ }
2980
+ }
2981
+ }
2982
+ return _split_cross_paragraph_hunks(edits);
2983
+ }
2710
2984
  var _IMAGE_MARKER_RE = /!\[[^\]]*\]\(docx-image:[^)]*\)/g;
2711
2985
  function _drop_image_marker_hunks(edits, warnings) {
2712
2986
  const _normalized = (s) => s.replace(_IMAGE_MARKER_RE, "").replace(/\s+/g, " ").trim();
@@ -2904,7 +3178,7 @@ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod)
2904
3178
  );
2905
3179
  const flat = _drop_marker_interior_hunks(
2906
3180
  _drop_image_marker_hunks(
2907
- generate_edits_from_text(text_orig, text_mod),
3181
+ generate_edits_via_paragraph_alignment(text_orig, text_mod),
2908
3182
  warnings
2909
3183
  ),
2910
3184
  text_orig,
@@ -2930,7 +3204,7 @@ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod)
2930
3204
  );
2931
3205
  }
2932
3206
  if (!tables_alignable) {
2933
- const part_edits = generate_edits_from_text(
3207
+ const part_edits = generate_edits_via_paragraph_alignment(
2934
3208
  text_orig.substring(po_start, po_end),
2935
3209
  text_mod.substring(pm_start, pm_end)
2936
3210
  );
@@ -2955,7 +3229,7 @@ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod)
2955
3229
  const seg_o_end = boundaries_o[seg_idx + 1][0];
2956
3230
  const seg_m_start = boundaries_m[seg_idx][1];
2957
3231
  const seg_m_end = boundaries_m[seg_idx + 1][0];
2958
- const seg_edits = generate_edits_from_text(
3232
+ const seg_edits = generate_edits_via_paragraph_alignment(
2959
3233
  text_orig.substring(seg_o_start, seg_o_end),
2960
3234
  text_mod.substring(seg_m_start, seg_m_end)
2961
3235
  );
@@ -4403,10 +4677,21 @@ var RedlineEngine = class _RedlineEngine {
4403
4677
  parts_to_process.push(part._element);
4404
4678
  }
4405
4679
  }
4680
+ let accepted_insertions = 0;
4681
+ let accepted_deletions = 0;
4682
+ let accepted_formatting = 0;
4683
+ for (const root_element of parts_to_process) {
4684
+ accepted_insertions += findAllDescendants(root_element, "w:ins").length;
4685
+ accepted_deletions += findAllDescendants(root_element, "w:del").length;
4686
+ for (const tag of ["w:rPrChange", "w:pPrChange", "w:sectPrChange"]) {
4687
+ accepted_formatting += findAllDescendants(root_element, tag).length;
4688
+ }
4689
+ }
4690
+ let removed_comments = 0;
4406
4691
  for (const root_element of parts_to_process) {
4407
4692
  const insNodes = findAllDescendants(root_element, "w:ins");
4408
4693
  for (const ins of insNodes) {
4409
- this._clean_wrapping_comments(ins);
4694
+ removed_comments += this._clean_wrapping_comments(ins);
4410
4695
  const parent = ins.parentNode;
4411
4696
  if (!parent) continue;
4412
4697
  if (parent.tagName === "w:trPr") {
@@ -4450,8 +4735,8 @@ var RedlineEngine = class _RedlineEngine {
4450
4735
  if (has_content) {
4451
4736
  rPr.removeChild(delMark);
4452
4737
  } else {
4453
- this._clean_wrapping_comments(p);
4454
- this._delete_comments_in_element(p);
4738
+ removed_comments += this._clean_wrapping_comments(p);
4739
+ removed_comments += this._delete_comments_in_element(p);
4455
4740
  if (p.parentNode) {
4456
4741
  p.parentNode.removeChild(p);
4457
4742
  }
@@ -4461,8 +4746,8 @@ var RedlineEngine = class _RedlineEngine {
4461
4746
  }
4462
4747
  const delNodes = findAllDescendants(root_element, "w:del");
4463
4748
  for (const d of delNodes) {
4464
- this._clean_wrapping_comments(d);
4465
- this._delete_comments_in_element(d);
4749
+ removed_comments += this._clean_wrapping_comments(d);
4750
+ removed_comments += this._delete_comments_in_element(d);
4466
4751
  const parent = d.parentNode;
4467
4752
  if (parent) {
4468
4753
  if (parent.tagName === "w:trPr") {
@@ -4557,6 +4842,12 @@ var RedlineEngine = class _RedlineEngine {
4557
4842
  }
4558
4843
  }
4559
4844
  }
4845
+ return {
4846
+ accepted_insertions,
4847
+ accepted_deletions,
4848
+ accepted_formatting,
4849
+ removed_comments
4850
+ };
4560
4851
  }
4561
4852
  /**
4562
4853
  * Revert every tracked change, returning the document to the state it had
@@ -4809,6 +5100,24 @@ var RedlineEngine = class _RedlineEngine {
4809
5100
  *
4810
5101
  * Does NOT attach comments; callers handle that.
4811
5102
  */
5103
+ _clone_pPr_scrubbing_headings(existing_pPr) {
5104
+ const pPr_clone = existing_pPr.cloneNode(true);
5105
+ const pStyle_el = findChild(pPr_clone, "w:pStyle");
5106
+ if (pStyle_el) {
5107
+ const style_val = pStyle_el.getAttribute("w:val");
5108
+ if (style_val) {
5109
+ const is_heading = style_val.startsWith("Heading") || style_val === "Title" || style_val.replace(/\s+/g, "").startsWith("Heading");
5110
+ if (is_heading) {
5111
+ pPr_clone.removeChild(pStyle_el);
5112
+ }
5113
+ }
5114
+ }
5115
+ const outlineLvl_el = findChild(pPr_clone, "w:outlineLvl");
5116
+ if (outlineLvl_el) {
5117
+ pPr_clone.removeChild(outlineLvl_el);
5118
+ }
5119
+ return pPr_clone;
5120
+ }
4812
5121
  _track_insert_multiline(text, anchor_run, anchor_paragraph, reuse_id, positional_anchor_el = null, suppress_emphasis = false, insert_before = false) {
4813
5122
  if (!text) {
4814
5123
  return {
@@ -4930,7 +5239,7 @@ var RedlineEngine = class _RedlineEngine {
4930
5239
  } else {
4931
5240
  const existing_pPr = findChild(current_p, "w:pPr");
4932
5241
  if (existing_pPr) {
4933
- new_p.appendChild(existing_pPr.cloneNode(true));
5242
+ new_p.appendChild(this._clone_pPr_scrubbing_headings(existing_pPr));
4934
5243
  }
4935
5244
  }
4936
5245
  let pPr = findChild(new_p, "w:pPr");
@@ -5195,7 +5504,10 @@ var RedlineEngine = class _RedlineEngine {
5195
5504
  }
5196
5505
  return null;
5197
5506
  }
5507
+ /** Returns how many comment BODIES were actually deleted (see below: only
5508
+ * our own are; foreign ones keep their body and lose only the anchor). */
5198
5509
  _clean_wrapping_comments(element) {
5510
+ let deleted = 0;
5199
5511
  let first_node = element;
5200
5512
  while (true) {
5201
5513
  const prev2 = getPreviousElement(first_node);
@@ -5263,6 +5575,7 @@ var RedlineEngine = class _RedlineEngine {
5263
5575
  const is_own = author !== null && author === this.author;
5264
5576
  if (is_own) {
5265
5577
  this.comments_manager.deleteComment(c_id);
5578
+ deleted++;
5266
5579
  }
5267
5580
  if (s.parentNode) s.parentNode.removeChild(s);
5268
5581
  for (const e of ends_to_remove) {
@@ -5280,13 +5593,17 @@ var RedlineEngine = class _RedlineEngine {
5280
5593
  }
5281
5594
  }
5282
5595
  }
5596
+ return deleted;
5283
5597
  }
5598
+ /** Returns how many comment bodies were deleted. */
5284
5599
  _delete_comments_in_element(element) {
5600
+ let deleted = 0;
5285
5601
  const refs = findAllDescendants(element, "w:commentReference");
5286
5602
  for (const ref of refs) {
5287
5603
  const c_id = ref.getAttribute("w:id");
5288
5604
  if (c_id) {
5289
5605
  this.comments_manager.deleteComment(c_id);
5606
+ deleted++;
5290
5607
  for (const tag of ["w:commentRangeStart", "w:commentRangeEnd"]) {
5291
5608
  const nodes = findAllDescendants(this.doc.element, tag);
5292
5609
  for (const node of nodes) {
@@ -5297,6 +5614,7 @@ var RedlineEngine = class _RedlineEngine {
5297
5614
  }
5298
5615
  }
5299
5616
  }
5617
+ return deleted;
5300
5618
  }
5301
5619
  validate_edits(edits, index_offset = 0) {
5302
5620
  const errors = [];
@@ -5325,18 +5643,16 @@ var RedlineEngine = class _RedlineEngine {
5325
5643
  continue;
5326
5644
  }
5327
5645
  }
5328
- let matches = this.mapper.find_all_match_indices(
5329
- edit.target_text,
5330
- is_regex
5646
+ let matches = this.mapper.drop_virtual_only_matches(
5647
+ this.mapper.find_all_match_indices(edit.target_text, is_regex)
5331
5648
  );
5332
5649
  let activeText = this.mapper.full_text;
5333
5650
  let target_mapper = this.mapper;
5334
5651
  if (matches.length === 0) {
5335
5652
  if (!this.clean_mapper)
5336
5653
  this.clean_mapper = new DocumentMapper(this.doc, true);
5337
- matches = this.clean_mapper.find_all_match_indices(
5338
- edit.target_text,
5339
- is_regex
5654
+ matches = this.clean_mapper.drop_virtual_only_matches(
5655
+ this.clean_mapper.find_all_match_indices(edit.target_text, is_regex)
5340
5656
  );
5341
5657
  if (matches.length > 0) {
5342
5658
  activeText = this.clean_mapper.full_text;
@@ -5359,9 +5675,11 @@ var RedlineEngine = class _RedlineEngine {
5359
5675
  if (!this.original_mapper) {
5360
5676
  this.original_mapper = new DocumentMapper(this.doc, false, true);
5361
5677
  }
5362
- const orig_matches = this.original_mapper.find_all_match_indices(
5363
- edit.target_text,
5364
- is_regex
5678
+ const orig_matches = this.original_mapper.drop_virtual_only_matches(
5679
+ this.original_mapper.find_all_match_indices(
5680
+ edit.target_text,
5681
+ is_regex
5682
+ )
5365
5683
  );
5366
5684
  if (orig_matches.length > 0) {
5367
5685
  is_deleted_text = true;
@@ -5583,10 +5901,15 @@ var RedlineEngine = class _RedlineEngine {
5583
5901
  }
5584
5902
  static _column_count_at(mapper, start, length) {
5585
5903
  for (const s of mapper.spans) {
5586
- if (s.run === null || s.end <= start || s.start >= start + length) {
5904
+ if (s.end <= start || s.start >= start + length) {
5587
5905
  continue;
5588
5906
  }
5589
- let curr = s.run._element;
5907
+ let curr = null;
5908
+ if (s.run !== null) {
5909
+ curr = s.run._element;
5910
+ } else if (s.paragraph !== null) {
5911
+ curr = s.paragraph._element;
5912
+ }
5590
5913
  while (curr) {
5591
5914
  if (curr.nodeType === 1 && curr.tagName === "w:tr") {
5592
5915
  return findAllDescendants(curr, "w:tc").filter(
@@ -5726,7 +6049,10 @@ var RedlineEngine = class _RedlineEngine {
5726
6049
  (c) => c === null || typeof c !== "object" || !["accept", "reject", "reply"].includes(c.type)
5727
6050
  );
5728
6051
  if (!dry_run_mode) {
5729
- const action_errors = actions.length > 0 ? this.validate_review_actions(actions) : [];
6052
+ let action_errors = actions.length > 0 ? this.validate_review_actions(actions) : [];
6053
+ if (actions.length > 0 && action_errors.length === 0) {
6054
+ action_errors = this.validate_action_pairing(actions);
6055
+ }
5730
6056
  const validate_edits_now = edits.length > 0 && action_errors.length > 0;
5731
6057
  const edit_errors = validate_edits_now ? this.validate_edits(edits) : [];
5732
6058
  const all_errors = [...action_errors, ...edit_errors];
@@ -5735,7 +6061,10 @@ var RedlineEngine = class _RedlineEngine {
5735
6061
  }
5736
6062
  } else {
5737
6063
  if (actions.length > 0) {
5738
- const action_errors = this.validate_review_actions(actions);
6064
+ let action_errors = this.validate_review_actions(actions);
6065
+ if (action_errors.length === 0) {
6066
+ action_errors = this.validate_action_pairing(actions);
6067
+ }
5739
6068
  if (action_errors.length > 0) {
5740
6069
  throw new BatchValidationError(action_errors);
5741
6070
  }
@@ -5743,10 +6072,12 @@ var RedlineEngine = class _RedlineEngine {
5743
6072
  }
5744
6073
  let applied_actions = 0;
5745
6074
  let skipped_actions = 0;
6075
+ let already_resolved_actions = 0;
5746
6076
  if (actions.length > 0) {
5747
6077
  const res = this.apply_review_actions(actions);
5748
6078
  applied_actions = res[0];
5749
6079
  skipped_actions = res[1];
6080
+ already_resolved_actions = res[2];
5750
6081
  if (skipped_actions > 0) {
5751
6082
  throw new BatchValidationError(this.skipped_details);
5752
6083
  }
@@ -5800,6 +6131,7 @@ var RedlineEngine = class _RedlineEngine {
5800
6131
  );
5801
6132
  reports_by_input[i] = {
5802
6133
  status: "failed",
6134
+ type: edit.type || "modify",
5803
6135
  target_text: truncate_middle(edit.target_text || "", REPORT_ECHO_CAP),
5804
6136
  new_text: truncate_middle(_RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
5805
6137
  warning,
@@ -5815,6 +6147,7 @@ var RedlineEngine = class _RedlineEngine {
5815
6147
  const previews = this._build_edit_context_previews(edit);
5816
6148
  reports_by_input[i] = {
5817
6149
  status: "applied",
6150
+ type: edit.type || "modify",
5818
6151
  target_text: truncate_middle(edit.target_text || "", REPORT_ECHO_CAP),
5819
6152
  new_text: truncate_middle(_RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
5820
6153
  warning: null,
@@ -5836,6 +6169,7 @@ var RedlineEngine = class _RedlineEngine {
5836
6169
  );
5837
6170
  reports_by_input[i] = {
5838
6171
  status: "failed",
6172
+ type: edit.type || "modify",
5839
6173
  target_text: truncate_middle(edit.target_text || "", REPORT_ECHO_CAP),
5840
6174
  new_text: truncate_middle(_RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
5841
6175
  warning,
@@ -5854,6 +6188,7 @@ var RedlineEngine = class _RedlineEngine {
5854
6188
  if (report.status === "applied") {
5855
6189
  reports_by_input[i] = {
5856
6190
  status: "failed",
6191
+ type: report.type || "modify",
5857
6192
  target_text: report.target_text,
5858
6193
  new_text: report.new_text,
5859
6194
  warning: null,
@@ -5924,6 +6259,7 @@ var RedlineEngine = class _RedlineEngine {
5924
6259
  }
5925
6260
  edits_reports.push({
5926
6261
  status: success ? "applied" : "failed",
6262
+ type: edit.type || "modify",
5927
6263
  target_text: truncate_middle(edit.target_text || "", REPORT_ECHO_CAP),
5928
6264
  new_text: truncate_middle(_RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
5929
6265
  warning,
@@ -5941,6 +6277,11 @@ var RedlineEngine = class _RedlineEngine {
5941
6277
  return {
5942
6278
  actions_applied: applied_actions,
5943
6279
  actions_skipped: skipped_actions,
6280
+ // Actions whose target was already resolved by an earlier action of
6281
+ // this batch (via its replacement pair): consistent no-ops, never
6282
+ // counted as applied — every reported "applied" action causes an
6283
+ // observable state transition (ADEU-QA-004).
6284
+ actions_already_resolved: already_resolved_actions,
5944
6285
  edits_applied: applied_edits,
5945
6286
  edits_skipped: skipped_edits,
5946
6287
  // edits_applied counts change OBJECTS; this is the total number of
@@ -5982,19 +6323,66 @@ var RedlineEngine = class _RedlineEngine {
5982
6323
  edit._resolved_start_idx = edit._match_start_index;
5983
6324
  resolved_edits.push([edit, edit.new_text || null]);
5984
6325
  } else if (edit.type === "insert_row" || edit.type === "delete_row") {
5985
- let matches = this.mapper.find_all_match_indices(edit.target_text);
6326
+ let matches = this.mapper.drop_virtual_only_matches(
6327
+ this.mapper.find_all_match_indices(edit.target_text)
6328
+ );
5986
6329
  let resolved_mapper = this.mapper;
5987
6330
  if (matches.length === 0) {
5988
6331
  if (!this.clean_mapper) {
5989
6332
  this.clean_mapper = new DocumentMapper(this.doc, true);
5990
6333
  }
5991
- matches = this.clean_mapper.find_all_match_indices(edit.target_text);
6334
+ matches = this.clean_mapper.drop_virtual_only_matches(
6335
+ this.clean_mapper.find_all_match_indices(edit.target_text)
6336
+ );
5992
6337
  resolved_mapper = this.clean_mapper;
5993
6338
  }
5994
6339
  if (matches.length > 0) {
5995
- edit._resolved_start_idx = matches[0][0];
5996
- edit._active_mapper_ref = resolved_mapper;
5997
- resolved_edits.push([edit, null]);
6340
+ const match_mode = edit.match_mode || "strict";
6341
+ const unique_matches = [];
6342
+ const seen_trs = /* @__PURE__ */ new Set();
6343
+ for (const match of matches) {
6344
+ const start_idx = match[0];
6345
+ const [anchor_run, anchor_para] = resolved_mapper.get_insertion_anchor(start_idx, false);
6346
+ let target_element = null;
6347
+ if (anchor_run) target_element = anchor_run._element;
6348
+ else if (anchor_para) target_element = anchor_para._element;
6349
+ let tr = target_element;
6350
+ while (tr && tr.tagName !== "w:tr") tr = tr.parentNode;
6351
+ if (tr) {
6352
+ if (!seen_trs.has(tr)) {
6353
+ seen_trs.add(tr);
6354
+ unique_matches.push(match);
6355
+ }
6356
+ }
6357
+ }
6358
+ if (unique_matches.length > 0) {
6359
+ let matches_to_apply = unique_matches;
6360
+ if (match_mode === "strict" || match_mode === "first") {
6361
+ matches_to_apply = unique_matches.slice(0, 1);
6362
+ }
6363
+ if (match_mode === "all" || matches_to_apply.length > 1) {
6364
+ for (const m of matches_to_apply) {
6365
+ const sub_edit = {
6366
+ ...edit,
6367
+ _resolved_start_idx: m[0],
6368
+ _active_mapper_ref: resolved_mapper,
6369
+ _parent_edit_ref: edit
6370
+ };
6371
+ resolved_edits.push([sub_edit, null]);
6372
+ }
6373
+ } else {
6374
+ edit._resolved_start_idx = matches_to_apply[0][0];
6375
+ edit._active_mapper_ref = resolved_mapper;
6376
+ resolved_edits.push([edit, null]);
6377
+ }
6378
+ } else {
6379
+ skipped++;
6380
+ edit._applied_status = false;
6381
+ const target_snippet = (edit.target_text || "").trim().substring(0, 40);
6382
+ const msg = `- Failed to locate row target: '${target_snippet}...'`;
6383
+ this.skipped_details.push(msg);
6384
+ edit._error_msg = msg;
6385
+ }
5998
6386
  } else {
5999
6387
  skipped++;
6000
6388
  edit._applied_status = false;
@@ -6159,10 +6547,156 @@ var RedlineEngine = class _RedlineEngine {
6159
6547
  }
6160
6548
  return [applied_logical, skipped_logical];
6161
6549
  }
6550
+ /**
6551
+ * True when the paragraph still carries visible content (w:t text, w:tab,
6552
+ * w:br) that is NOT wrapped in a tracked deletion — i.e. the paragraph
6553
+ * would render non-empty in the accepted document.
6554
+ */
6555
+ _paragraph_has_visible_content(p_elem) {
6556
+ for (const tag of ["w:t", "w:tab", "w:br"]) {
6557
+ const nodes = findAllDescendants(p_elem, tag);
6558
+ for (const node of nodes) {
6559
+ let is_deleted = false;
6560
+ let curr = node.parentNode;
6561
+ while (curr && curr !== p_elem.parentNode) {
6562
+ if (curr.tagName === "w:del") {
6563
+ is_deleted = true;
6564
+ break;
6565
+ }
6566
+ curr = curr.parentNode;
6567
+ }
6568
+ if (!is_deleted) {
6569
+ if (tag === "w:t" && !node.textContent) continue;
6570
+ return true;
6571
+ }
6572
+ }
6573
+ }
6574
+ return false;
6575
+ }
6576
+ /**
6577
+ * All contiguous same-author w:ins/w:del siblings that form one logical
6578
+ * modification block with `node` (a replacement's del+ins pair). Mirrors
6579
+ * the Python engine's _get_paired_nodes: comment range markers and
6580
+ * rPr/pPr are transparent; a different author or any other element breaks
6581
+ * the group.
6582
+ */
6583
+ _get_paired_nodes(node) {
6584
+ const pairs = [];
6585
+ const author = node.getAttribute("w:author");
6586
+ const transparent = /* @__PURE__ */ new Set([
6587
+ "w:commentRangeStart",
6588
+ "w:commentRangeEnd",
6589
+ "w:commentReference",
6590
+ "w:rPr",
6591
+ "w:pPr"
6592
+ ]);
6593
+ const walk = (start, dir) => {
6594
+ let cur = dir === "next" ? start.nextSibling : start.previousSibling;
6595
+ while (cur) {
6596
+ if (cur.nodeType !== 1) {
6597
+ cur = dir === "next" ? cur.nextSibling : cur.previousSibling;
6598
+ continue;
6599
+ }
6600
+ const el = cur;
6601
+ if (transparent.has(el.tagName)) {
6602
+ cur = dir === "next" ? cur.nextSibling : cur.previousSibling;
6603
+ continue;
6604
+ }
6605
+ if ((el.tagName === "w:ins" || el.tagName === "w:del") && el.getAttribute("w:author") === author) {
6606
+ pairs.push(el);
6607
+ cur = dir === "next" ? cur.nextSibling : cur.previousSibling;
6608
+ continue;
6609
+ }
6610
+ break;
6611
+ }
6612
+ };
6613
+ walk(node, "next");
6614
+ walk(node, "prev");
6615
+ return pairs;
6616
+ }
6617
+ /**
6618
+ * All revision ids that resolve as ONE unit with `target_id`: the ids of
6619
+ * every contiguous same-author w:ins/w:del sibling of its elements (a
6620
+ * replacement's del+ins pair), plus the id itself.
6621
+ */
6622
+ _resolution_group_ids(target_id) {
6623
+ const nodes = [
6624
+ ...findAllDescendants(this.doc.element, "w:ins"),
6625
+ ...findAllDescendants(this.doc.element, "w:del")
6626
+ ].filter((n) => n.getAttribute("w:id") === target_id);
6627
+ const group = /* @__PURE__ */ new Set();
6628
+ if (nodes.length === 0) return group;
6629
+ group.add(target_id);
6630
+ for (const node of nodes) {
6631
+ for (const paired of this._get_paired_nodes(node)) {
6632
+ const pid = paired.getAttribute("w:id");
6633
+ if (pid) group.add(pid);
6634
+ }
6635
+ }
6636
+ return group;
6637
+ }
6638
+ /**
6639
+ * Document-aware validation (QA 2026-07-19 ADEU-QA-004): a replacement's
6640
+ * del+ins pair carries two distinct ids but resolves as one unit, so a
6641
+ * batch that accepts one side and rejects the other is contradictory.
6642
+ * Rejecting it up front — before any action mutates the document — keeps
6643
+ * the batch transactional.
6644
+ */
6645
+ validate_action_pairing(actions) {
6646
+ const errors = [];
6647
+ const group_first = /* @__PURE__ */ new Map();
6648
+ for (let pos = 0; pos < actions.length; pos++) {
6649
+ const act = actions[pos];
6650
+ if (act.type !== "accept" && act.type !== "reject") continue;
6651
+ const raw_id = String(act.target_id ?? "");
6652
+ if (raw_id.startsWith("Com:")) continue;
6653
+ const target_id = raw_id.startsWith("Chg:") ? raw_id.slice(4) : raw_id;
6654
+ const group = this._resolution_group_ids(target_id);
6655
+ if (group.size === 0) continue;
6656
+ let conflict = null;
6657
+ for (const gid of group) {
6658
+ const prior = group_first.get(gid);
6659
+ if (prior !== void 0 && prior[1] !== act.type) {
6660
+ conflict = prior;
6661
+ break;
6662
+ }
6663
+ }
6664
+ if (conflict !== null) {
6665
+ const [first_pos, first_type, first_id] = conflict;
6666
+ errors.push(
6667
+ `- 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.`
6668
+ );
6669
+ continue;
6670
+ }
6671
+ for (const gid of group) {
6672
+ if (!group_first.has(gid)) {
6673
+ group_first.set(gid, [pos, act.type, target_id]);
6674
+ }
6675
+ }
6676
+ }
6677
+ return errors;
6678
+ }
6679
+ /**
6680
+ * Returns [applied, skipped, already_resolved]. `applied` counts actions
6681
+ * that caused an observable state transition; an action naming an id an
6682
+ * earlier action of this batch already resolved (via its replacement pair)
6683
+ * is counted in `already_resolved` instead — never as applied
6684
+ * (QA 2026-07-19 ADEU-QA-004).
6685
+ */
6162
6686
  apply_review_actions(actions) {
6163
6687
  let applied = 0;
6164
6688
  let skipped = 0;
6165
- for (const action of actions) {
6689
+ let already_resolved = 0;
6690
+ const resolved_history = /* @__PURE__ */ new Map();
6691
+ const sortedActions = actions.map((action, pos) => ({ action, pos })).sort((a, b) => {
6692
+ const aPri = a.action.type === "reply" ? 0 : 1;
6693
+ const bPri = b.action.type === "reply" ? 0 : 1;
6694
+ if (aPri !== bPri) {
6695
+ return aPri - bPri;
6696
+ }
6697
+ return a.pos - b.pos;
6698
+ });
6699
+ for (const { action, pos } of sortedActions) {
6166
6700
  const type = action.type;
6167
6701
  if (type === "reply") {
6168
6702
  const cid = action.target_id.replace("Com:", "");
@@ -6176,6 +6710,21 @@ var RedlineEngine = class _RedlineEngine {
6176
6710
  continue;
6177
6711
  }
6178
6712
  const target_id = action.target_id.replace("Chg:", "");
6713
+ const prior_type = resolved_history.get(target_id);
6714
+ if (prior_type !== void 0) {
6715
+ if (prior_type === type) {
6716
+ already_resolved++;
6717
+ this.skipped_details.push(
6718
+ `- 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.`
6719
+ );
6720
+ continue;
6721
+ }
6722
+ this.skipped_details.push(
6723
+ `- 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.`
6724
+ );
6725
+ skipped++;
6726
+ continue;
6727
+ }
6179
6728
  const all_ins = findAllDescendants(this.doc.element, "w:ins").filter(
6180
6729
  (n) => n.getAttribute("w:id") === target_id
6181
6730
  );
@@ -6200,7 +6749,18 @@ var RedlineEngine = class _RedlineEngine {
6200
6749
  );
6201
6750
  continue;
6202
6751
  }
6752
+ const group_nodes = new Set(all_nodes);
6203
6753
  for (const node of all_nodes) {
6754
+ for (const paired of this._get_paired_nodes(node)) {
6755
+ group_nodes.add(paired);
6756
+ }
6757
+ }
6758
+ const resolved_now = /* @__PURE__ */ new Set();
6759
+ for (const node of group_nodes) {
6760
+ const rid = node.getAttribute("w:id");
6761
+ if (rid) resolved_now.add(rid);
6762
+ }
6763
+ for (const node of group_nodes) {
6204
6764
  const is_ins = node.tagName === "w:ins";
6205
6765
  const parent_tag = node.parentNode ? node.parentNode.tagName : "";
6206
6766
  const is_trPr = parent_tag === "w:trPr";
@@ -6252,9 +6812,12 @@ var RedlineEngine = class _RedlineEngine {
6252
6812
  }
6253
6813
  }
6254
6814
  }
6815
+ for (const rid of resolved_now) {
6816
+ resolved_history.set(rid, type);
6817
+ }
6255
6818
  applied++;
6256
6819
  }
6257
- return [applied, skipped];
6820
+ return [applied, skipped, already_resolved];
6258
6821
  }
6259
6822
  _apply_table_edit(edit, rebuild_map) {
6260
6823
  const start_idx = edit._resolved_start_idx !== void 0 && edit._resolved_start_idx !== null ? edit._resolved_start_idx : edit._match_start_index || 0;
@@ -6314,17 +6877,15 @@ var RedlineEngine = class _RedlineEngine {
6314
6877
  if (!edit.target_text) return null;
6315
6878
  const is_regex = edit.regex || false;
6316
6879
  const match_mode = edit.match_mode || "strict";
6317
- let matches = this.mapper.find_all_match_indices(
6318
- edit.target_text,
6319
- is_regex
6880
+ let matches = this.mapper.drop_virtual_only_matches(
6881
+ this.mapper.find_all_match_indices(edit.target_text, is_regex)
6320
6882
  );
6321
6883
  let use_clean_map = false;
6322
6884
  if (matches.length === 0) {
6323
6885
  if (!this.clean_mapper)
6324
6886
  this.clean_mapper = new DocumentMapper(this.doc, true);
6325
- matches = this.clean_mapper.find_all_match_indices(
6326
- edit.target_text,
6327
- is_regex
6887
+ matches = this.clean_mapper.drop_virtual_only_matches(
6888
+ this.clean_mapper.find_all_match_indices(edit.target_text, is_regex)
6328
6889
  );
6329
6890
  if (matches.length > 0) use_clean_map = true;
6330
6891
  else return null;
@@ -6834,7 +7395,9 @@ var RedlineEngine = class _RedlineEngine {
6834
7395
  this._set_paragraph_style(new_p, style_name);
6835
7396
  } else {
6836
7397
  const existing_pPr = findChild(_bug233_target_para, "w:pPr");
6837
- if (existing_pPr) new_p.appendChild(existing_pPr.cloneNode(true));
7398
+ if (existing_pPr) {
7399
+ new_p.appendChild(this._clone_pPr_scrubbing_headings(existing_pPr));
7400
+ }
6838
7401
  }
6839
7402
  let pPr = findChild(new_p, "w:pPr");
6840
7403
  if (!pPr) {
@@ -7092,7 +7655,24 @@ var RedlineEngine = class _RedlineEngine {
7092
7655
  p2_element = getNextElement(p2_element);
7093
7656
  }
7094
7657
  if (p2_element && p2_element.tagName === "w:p") {
7658
+ const p1_fully_deleted = !this._paragraph_has_visible_content(p1_element);
7095
7659
  let pPr = findChild(p1_element, "w:pPr");
7660
+ if (p1_fully_deleted) {
7661
+ const p2_pPr = findChild(p2_element, "w:pPr");
7662
+ const adopted = p2_pPr ? p2_pPr.cloneNode(true) : p1_element.ownerDocument.createElement("w:pPr");
7663
+ if (pPr) {
7664
+ const sect = findChild(pPr, "w:sectPr");
7665
+ if (sect && !findChild(adopted, "w:sectPr")) {
7666
+ adopted.appendChild(sect.cloneNode(true));
7667
+ }
7668
+ p1_element.removeChild(pPr);
7669
+ }
7670
+ p1_element.insertBefore(
7671
+ adopted,
7672
+ p1_element.firstChild
7673
+ );
7674
+ pPr = adopted;
7675
+ }
7096
7676
  if (!pPr) {
7097
7677
  pPr = p1_element.ownerDocument.createElement("w:pPr");
7098
7678
  p1_element.insertBefore(
@@ -7105,8 +7685,10 @@ var RedlineEngine = class _RedlineEngine {
7105
7685
  rPr = p1_element.ownerDocument.createElement("w:rPr");
7106
7686
  pPr.appendChild(rPr);
7107
7687
  }
7108
- const del_mark = this._create_track_change_tag("w:del");
7109
- rPr.appendChild(del_mark);
7688
+ if (!findChild(rPr, "w:del")) {
7689
+ const del_mark = this._create_track_change_tag("w:del");
7690
+ rPr.appendChild(del_mark);
7691
+ }
7110
7692
  const children = Array.from(p2_element.childNodes);
7111
7693
  for (const child of children) {
7112
7694
  if (child.nodeType === 1 && child.tagName === "w:pPr") {
@@ -7160,27 +7742,7 @@ var RedlineEngine = class _RedlineEngine {
7160
7742
  }
7161
7743
  }
7162
7744
  for (const p_elem of affected_ps) {
7163
- let has_visible = false;
7164
- for (const tag of ["w:t", "w:tab", "w:br"]) {
7165
- const nodes = findAllDescendants(p_elem, tag);
7166
- for (const node of nodes) {
7167
- let is_deleted = false;
7168
- let curr = node.parentNode;
7169
- while (curr && curr !== p_elem.parentNode) {
7170
- if (curr.tagName === "w:del") {
7171
- is_deleted = true;
7172
- break;
7173
- }
7174
- curr = curr.parentNode;
7175
- }
7176
- if (!is_deleted) {
7177
- if (tag === "w:t" && !node.textContent) continue;
7178
- has_visible = true;
7179
- break;
7180
- }
7181
- }
7182
- if (has_visible) break;
7183
- }
7745
+ const has_visible = this._paragraph_has_visible_content(p_elem);
7184
7746
  if (!has_visible) {
7185
7747
  let pPr = findChild(p_elem, "w:pPr");
7186
7748
  if (!pPr) {
@@ -7739,6 +8301,31 @@ function strip_custom_properties(doc) {
7739
8301
  const count = Math.max(lines.length, 1);
7740
8302
  return [`Custom document properties: ${count} removed (docProps/custom.xml)`].concat(lines);
7741
8303
  }
8304
+ function strip_document_variables(doc) {
8305
+ const settingsPart = doc.pkg.getPartByPath("word/settings.xml");
8306
+ if (!settingsPart) return [];
8307
+ const names = [];
8308
+ let removedAny = false;
8309
+ for (const container of findDescendantsByLocalName(settingsPart._element, "docVars")) {
8310
+ for (const child of Array.from(container.childNodes)) {
8311
+ const el = child;
8312
+ const tag = el.tagName || "";
8313
+ if (tag === "docVar" || tag.endsWith(":docVar")) {
8314
+ names.push(el.getAttribute("w:name") || el.getAttribute("name") || "(unnamed)");
8315
+ }
8316
+ }
8317
+ container.parentNode?.removeChild(container);
8318
+ removedAny = true;
8319
+ }
8320
+ for (const stray of findDescendantsByLocalName(settingsPart._element, "docVar")) {
8321
+ names.push(stray.getAttribute("w:name") || stray.getAttribute("name") || "(unnamed)");
8322
+ stray.parentNode?.removeChild(stray);
8323
+ removedAny = true;
8324
+ }
8325
+ if (!removedAny) return [];
8326
+ const shown = Array.from(new Set(names)).sort().join(", ") || "(unnamed)";
8327
+ return [`Document variables: ${names.length} removed (${shown})`];
8328
+ }
7742
8329
  function strip_image_alt_text(doc) {
7743
8330
  let count = 0;
7744
8331
  for (const docPr of findDescendantsByLocalName(doc.element, "docPr")) {
@@ -8257,10 +8844,28 @@ function extract_table(table, comments_map, cleanView, cursor, paragraph_offsets
8257
8844
  paragraph_offsets
8258
8845
  );
8259
8846
  if (!cleanView) {
8260
- const firstP = cell._element.getElementsByTagName("w:p")[0];
8261
- const paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
8847
+ let firstP = cell._element.getElementsByTagName("w:p")[0];
8848
+ let paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
8849
+ if (!paraId && (!cell_content || cell_content.trim() === "")) {
8850
+ if (!firstP) {
8851
+ const xmlDoc = cell._element.ownerDocument;
8852
+ firstP = xmlDoc.createElement("w:p");
8853
+ cell._element.appendChild(firstP);
8854
+ }
8855
+ const allPs = Array.from(cell._element.ownerDocument.getElementsByTagName("w:p"));
8856
+ const index = allPs.indexOf(firstP);
8857
+ let hash = 2166136261;
8858
+ const str = `fallback-paraId-${index}`;
8859
+ for (let i = 0; i < str.length; i++) {
8860
+ hash ^= str.charCodeAt(i);
8861
+ hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
8862
+ }
8863
+ paraId = (hash >>> 0).toString(16).toUpperCase().padStart(8, "0");
8864
+ firstP.setAttribute("w14:paraId", paraId);
8865
+ }
8262
8866
  if (paraId) {
8263
- const anchor = `{#cell:${paraId}}`;
8867
+ const space_pad = cell_content ? " " : "";
8868
+ const anchor = `${space_pad}{#cell:${paraId}}`;
8264
8869
  cell_content = cell_content + anchor;
8265
8870
  }
8266
8871
  }
@@ -8525,6 +9130,8 @@ function _build_merged_meta_block(states_list, comments_map) {
8525
9130
  const change_lines = [];
8526
9131
  const comment_lines = [];
8527
9132
  const seen_sigs = /* @__PURE__ */ new Set();
9133
+ const pair_map = compute_change_pair_map(states_list);
9134
+ const pairSuffix = (uid) => pair_map[uid] ? ` (pairs with ${pair_map[uid]})` : "";
8528
9135
  for (const [ins_map, del_map, comments_set, fmt_map] of states_list) {
8529
9136
  let render_comment2 = function(cid) {
8530
9137
  if (!comments_map[cid]) return;
@@ -8553,7 +9160,9 @@ function _build_merged_meta_block(states_list, comments_map) {
8553
9160
  )) {
8554
9161
  const sig = `Chg:${uid}`;
8555
9162
  if (!seen_sigs.has(sig)) {
8556
- change_lines.push(`[${sig} insert] ${meta.author || "Unknown"}`);
9163
+ change_lines.push(
9164
+ `[${sig} insert] ${meta.author || "Unknown"}${pairSuffix(uid)}`
9165
+ );
8557
9166
  seen_sigs.add(sig);
8558
9167
  }
8559
9168
  }
@@ -8562,7 +9171,9 @@ function _build_merged_meta_block(states_list, comments_map) {
8562
9171
  )) {
8563
9172
  const sig = `Chg:${uid}`;
8564
9173
  if (!seen_sigs.has(sig)) {
8565
- change_lines.push(`[${sig} delete] ${meta.author || "Unknown"}`);
9174
+ change_lines.push(
9175
+ `[${sig} delete] ${meta.author || "Unknown"}${pairSuffix(uid)}`
9176
+ );
8566
9177
  seen_sigs.add(sig);
8567
9178
  }
8568
9179
  }
@@ -9124,14 +9735,14 @@ var SanitizeReport = class {
9124
9735
  const lower = line.toLowerCase();
9125
9736
  if (lower.includes("tracked change") || lower.includes("insertion") || lower.includes("deletion") || lower.includes("accepted")) {
9126
9737
  this.change_lines.push(line);
9738
+ } 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")) {
9739
+ this.metadata_lines.push(line);
9127
9740
  } else if (lower.includes("comment") || lower.includes("[open]") || lower.includes("[resolved]")) {
9128
9741
  if (lower.includes("kept") || lower.includes("visible")) {
9129
9742
  this.kept_comment_lines.push(line);
9130
9743
  } else {
9131
9744
  this.removed_comment_lines.push(line);
9132
9745
  }
9133
- } 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")) {
9134
- this.metadata_lines.push(line);
9135
9746
  } else if (lower.includes("hyperlink") || lower.includes("warning")) {
9136
9747
  this.warnings.push(line);
9137
9748
  } else {
@@ -9210,7 +9821,7 @@ async function finalize_document(doc, options) {
9210
9821
  report.tracked_changes_found = total;
9211
9822
  if (total > 0 && !options.accept_all) {
9212
9823
  report.status = "blocked";
9213
- 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.`;
9824
+ 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'.`;
9214
9825
  return { reportText: report.render() };
9215
9826
  }
9216
9827
  if (total > 0) {
@@ -9244,6 +9855,7 @@ async function finalize_document(doc, options) {
9244
9855
  report.add_transform_lines(scrub_timestamps(doc));
9245
9856
  report.add_transform_lines(strip_custom_xml(doc));
9246
9857
  report.add_transform_lines(strip_custom_properties(doc));
9858
+ report.add_transform_lines(strip_document_variables(doc));
9247
9859
  report.add_transform_lines(strip_image_alt_text(doc));
9248
9860
  const warnings = audit_hyperlinks(doc);
9249
9861
  for (const w of warnings) report.warnings.push(w);
@@ -9327,6 +9939,12 @@ function verifySanitizedPackage(outBuffer) {
9327
9939
  }
9328
9940
  }
9329
9941
  }
9942
+ if (names.includes("word/settings.xml")) {
9943
+ const settings = parseXml(strFromU82(unzipped["word/settings.xml"]));
9944
+ if (findDescendantsByLocalName(settings.documentElement, "docVar").length > 0) {
9945
+ problems.push("word/settings.xml still contains document variables (w:docVar)");
9946
+ }
9947
+ }
9330
9948
  if (problems.length > 0) {
9331
9949
  throw new Error(
9332
9950
  "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."