@adeu/core 1.27.0 → 1.28.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 "";
@@ -1645,6 +1701,11 @@ ${header}`;
1645
1701
  if (paraId && firstP) {
1646
1702
  const cellPara = new Paragraph(firstP, cell);
1647
1703
  this._add_virtual_text("", current, cellPara);
1704
+ const has_content = current > cell_start;
1705
+ if (has_content) {
1706
+ this._add_virtual_text(" ", current, cellPara);
1707
+ current += 1;
1708
+ }
1648
1709
  const anchor = `{#cell:${paraId}}`;
1649
1710
  this._add_virtual_text(anchor, current, cellPara);
1650
1711
  current += anchor.length;
@@ -1995,6 +2056,8 @@ ${header}`;
1995
2056
  const change_lines = [];
1996
2057
  const comment_lines = [];
1997
2058
  const seen_sigs = /* @__PURE__ */ new Set();
2059
+ const pair_map = compute_change_pair_map(states_list);
2060
+ const pairSuffix = (uid) => pair_map[uid] ? ` (pairs with ${pair_map[uid]})` : "";
1998
2061
  for (const [ins_map, del_map, comments_set, fmt_map] of states_list) {
1999
2062
  for (const [uid, meta] of Object.entries(
2000
2063
  ins_map
@@ -2002,7 +2065,7 @@ ${header}`;
2002
2065
  const sig = `Chg:${uid}`;
2003
2066
  if (!seen_sigs.has(sig)) {
2004
2067
  const auth = meta.author || "Unknown";
2005
- change_lines.push(`[${sig} insert] ${auth}`);
2068
+ change_lines.push(`[${sig} insert] ${auth}${pairSuffix(uid)}`);
2006
2069
  seen_sigs.add(sig);
2007
2070
  }
2008
2071
  }
@@ -2012,7 +2075,7 @@ ${header}`;
2012
2075
  const sig = `Chg:${uid}`;
2013
2076
  if (!seen_sigs.has(sig)) {
2014
2077
  const auth = meta.author || "Unknown";
2015
- change_lines.push(`[${sig} delete] ${auth}`);
2078
+ change_lines.push(`[${sig} delete] ${auth}${pairSuffix(uid)}`);
2016
2079
  seen_sigs.add(sig);
2017
2080
  }
2018
2081
  }
@@ -2152,21 +2215,60 @@ ${header}`;
2152
2215
  }
2153
2216
  return results;
2154
2217
  }
2218
+ /**
2219
+ * True when no run-backed span overlaps [start, start+length): the range
2220
+ * covers only virtual projection text — meta bubbles (change/comment
2221
+ * headers, timestamps), style markers, list prefixes. Such text does not
2222
+ * exist in the document, so it can neither satisfy a match nor count
2223
+ * toward ambiguity (QA 2026-07-19 ADEU-QA-002 C): an edit targeting "4"
2224
+ * used to be rejected as "appears 8 times" because a comment bubble's
2225
+ * timestamp matched.
2226
+ *
2227
+ * Anchor tokens ({#Bookmark}, {#cell:paraId}) are the exception: they are
2228
+ * deliberate virtual TARGETING surfaces (empty-cell writes, bookmark
2229
+ * anchors) and must stay matchable.
2230
+ */
2231
+ range_is_virtual_only(start, length) {
2232
+ const end = start + length;
2233
+ const overlapping = this.spans.filter(
2234
+ (s) => s.end > start && s.start < end
2235
+ );
2236
+ if (overlapping.some((s) => s.run !== null)) return false;
2237
+ return !overlapping.some(
2238
+ (s) => s.run === null && s.text.startsWith("{#")
2239
+ );
2240
+ }
2241
+ /** Filters find_all_match_indices output down to matches that touch at
2242
+ * least one run-backed span. See range_is_virtual_only. */
2243
+ drop_virtual_only_matches(matches) {
2244
+ return matches.filter(
2245
+ ([start, length]) => !this.range_is_virtual_only(start, length)
2246
+ );
2247
+ }
2155
2248
  find_match_index(target_text, is_regex = false) {
2156
2249
  if (is_regex) {
2157
2250
  try {
2158
2251
  const match = userSearch(target_text, this.full_text);
2159
- if (match) return [match.start, match.end - match.start];
2252
+ if (match && !this.range_is_virtual_only(match.start, match.end - match.start)) {
2253
+ return [match.start, match.end - match.start];
2254
+ }
2160
2255
  } catch (e) {
2161
2256
  if (e instanceof RegexTimeoutError) throw e;
2162
2257
  }
2163
2258
  return [-1, 0];
2164
2259
  }
2165
- let start_idx = this.full_text.indexOf(target_text);
2166
- if (start_idx !== -1) return [start_idx, target_text.length];
2260
+ let from = 0;
2261
+ while (true) {
2262
+ const idx = this.full_text.indexOf(target_text, from);
2263
+ if (idx === -1) break;
2264
+ if (!this.range_is_virtual_only(idx, target_text.length)) {
2265
+ return [idx, target_text.length];
2266
+ }
2267
+ from = idx + 1;
2268
+ }
2167
2269
  const norm_full = this._replace_smart_quotes(this.full_text);
2168
2270
  const norm_target = this._replace_smart_quotes(target_text);
2169
- start_idx = norm_full.indexOf(norm_target);
2271
+ let start_idx = norm_full.indexOf(norm_target);
2170
2272
  if (start_idx !== -1) return [start_idx, target_text.length];
2171
2273
  const stripped_target = this._strip_markdown_formatting(target_text);
2172
2274
  if (this.full_text.includes(stripped_target)) {
@@ -2176,9 +2278,12 @@ ${header}`;
2176
2278
  const plain_first = this._find_plain_projection_matches(target_text);
2177
2279
  if (plain_first.length > 0) return plain_first[0];
2178
2280
  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];
2281
+ const pattern = new RegExp(this._make_fuzzy_regex(target_text), "g");
2282
+ for (const match of this.full_text.matchAll(pattern)) {
2283
+ if (!this.range_is_virtual_only(match.index, match[0].length)) {
2284
+ return [match.index, match[0].length];
2285
+ }
2286
+ }
2182
2287
  } catch (e) {
2183
2288
  }
2184
2289
  return [-1, 0];
@@ -2707,6 +2812,153 @@ function _sequence_opcodes(a, b) {
2707
2812
  flushPending();
2708
2813
  return ops;
2709
2814
  }
2815
+ function _is_table_blob(block) {
2816
+ const lines = block.split("\n");
2817
+ return lines.length > 0 && lines.every((line) => line.includes(" | "));
2818
+ }
2819
+ function _table_blob_row_edits(orig_blob, mod_blob) {
2820
+ const rows_o = orig_blob.split("\n");
2821
+ const rows_m = mod_blob.split("\n");
2822
+ if (rows_o.length !== rows_m.length) return null;
2823
+ const row_edits = [];
2824
+ for (let k = 0; k < rows_o.length; k++) {
2825
+ if (rows_o[k] === rows_m[k]) continue;
2826
+ row_edits.push({
2827
+ type: "modify",
2828
+ target_text: rows_o[k],
2829
+ new_text: rows_m[k],
2830
+ comment: "Diff: Table row modified",
2831
+ _is_table_edit: true
2832
+ });
2833
+ }
2834
+ return row_edits;
2835
+ }
2836
+ function _split_cross_paragraph_hunks(edits) {
2837
+ const out = [];
2838
+ for (const e of edits) {
2839
+ const target = e.target_text || "";
2840
+ const newText = e.new_text || "";
2841
+ const idx = e._match_start_index;
2842
+ if (idx === void 0 || idx === null || !target.includes("\n\n") || target.split("\n\n").length - 1 === newText.split("\n\n").length - 1) {
2843
+ out.push(e);
2844
+ continue;
2845
+ }
2846
+ const parts = target.split("\n\n");
2847
+ if (parts.length < 2 || !parts[0].trim() || !parts[parts.length - 1].trim()) {
2848
+ out.push(e);
2849
+ continue;
2850
+ }
2851
+ let offset = idx;
2852
+ for (const piece of parts.slice(0, -1)) {
2853
+ out.push({
2854
+ type: "modify",
2855
+ target_text: piece + "\n\n",
2856
+ new_text: "",
2857
+ comment: e.comment || "Diff: Text deleted",
2858
+ _match_start_index: offset
2859
+ });
2860
+ offset += piece.length + 2;
2861
+ }
2862
+ out.push({
2863
+ type: "modify",
2864
+ target_text: parts[parts.length - 1],
2865
+ new_text: newText,
2866
+ comment: e.comment,
2867
+ _match_start_index: offset
2868
+ });
2869
+ }
2870
+ return out;
2871
+ }
2872
+ function generate_edits_via_paragraph_alignment(original_text, modified_text) {
2873
+ const orig_paragraphs = original_text.split("\n\n");
2874
+ const mod_paragraphs = modified_text.split("\n\n");
2875
+ const orig_offsets = [];
2876
+ let current_offset = 0;
2877
+ for (const p of orig_paragraphs) {
2878
+ orig_offsets.push(current_offset);
2879
+ current_offset += p.length + 2;
2880
+ }
2881
+ const opcodes = _sequence_opcodes(orig_paragraphs, mod_paragraphs);
2882
+ const edits = [];
2883
+ for (const [tag, i1, i2, j1, j2] of opcodes) {
2884
+ if (tag === "equal") continue;
2885
+ const offset = i1 < orig_offsets.length ? orig_offsets[i1] : original_text.length;
2886
+ if (tag === "delete") {
2887
+ if (i2 < orig_paragraphs.length) {
2888
+ let piece_offset = offset;
2889
+ for (let k = i1; k < i2; k++) {
2890
+ const piece = orig_paragraphs[k] + "\n\n";
2891
+ edits.push({
2892
+ type: "modify",
2893
+ target_text: piece,
2894
+ new_text: "",
2895
+ comment: "Diff: Text deleted",
2896
+ _match_start_index: piece_offset
2897
+ });
2898
+ piece_offset += piece.length;
2899
+ }
2900
+ } else {
2901
+ let deleted_text = orig_paragraphs.slice(i1, i2).join("\n\n");
2902
+ let del_offset = offset;
2903
+ if (i1 > 0) {
2904
+ deleted_text = "\n\n" + deleted_text;
2905
+ del_offset -= 2;
2906
+ }
2907
+ edits.push({
2908
+ type: "modify",
2909
+ target_text: deleted_text,
2910
+ new_text: "",
2911
+ comment: "Diff: Text deleted",
2912
+ _match_start_index: del_offset
2913
+ });
2914
+ }
2915
+ } else if (tag === "insert") {
2916
+ let inserted_text = mod_paragraphs.slice(j1, j2).join("\n\n");
2917
+ if (i1 < orig_paragraphs.length) {
2918
+ inserted_text = inserted_text + "\n\n";
2919
+ } else {
2920
+ inserted_text = "\n\n" + inserted_text;
2921
+ }
2922
+ edits.push({
2923
+ type: "modify",
2924
+ target_text: "",
2925
+ new_text: inserted_text,
2926
+ comment: "Diff: Text inserted",
2927
+ _match_start_index: offset
2928
+ });
2929
+ } else if (tag === "replace") {
2930
+ if (i2 - i1 === j2 - j1 && Array.from({ length: i2 - i1 }).some(
2931
+ (_, k) => _is_table_blob(orig_paragraphs[i1 + k]) || _is_table_blob(mod_paragraphs[j1 + k])
2932
+ )) {
2933
+ for (let k = 0; k < i2 - i1; k++) {
2934
+ const orig_p = orig_paragraphs[i1 + k];
2935
+ const mod_p = mod_paragraphs[j1 + k];
2936
+ if (orig_p === mod_p) continue;
2937
+ const pair_offset = orig_offsets[i1 + k];
2938
+ const row_edits = _is_table_blob(orig_p) && _is_table_blob(mod_p) ? _table_blob_row_edits(orig_p, mod_p) : null;
2939
+ if (row_edits !== null) {
2940
+ edits.push(...row_edits);
2941
+ continue;
2942
+ }
2943
+ const pair_edits = generate_edits_from_text(orig_p, mod_p);
2944
+ for (const ce of pair_edits) {
2945
+ ce._match_start_index = (ce._match_start_index || 0) + pair_offset;
2946
+ edits.push(ce);
2947
+ }
2948
+ }
2949
+ continue;
2950
+ }
2951
+ const orig_chunk = orig_paragraphs.slice(i1, i2).join("\n\n");
2952
+ const mod_chunk = mod_paragraphs.slice(j1, j2).join("\n\n");
2953
+ const chunk_edits = generate_edits_from_text(orig_chunk, mod_chunk);
2954
+ for (const ce of chunk_edits) {
2955
+ ce._match_start_index = (ce._match_start_index || 0) + offset;
2956
+ edits.push(ce);
2957
+ }
2958
+ }
2959
+ }
2960
+ return _split_cross_paragraph_hunks(edits);
2961
+ }
2710
2962
  var _IMAGE_MARKER_RE = /!\[[^\]]*\]\(docx-image:[^)]*\)/g;
2711
2963
  function _drop_image_marker_hunks(edits, warnings) {
2712
2964
  const _normalized = (s) => s.replace(_IMAGE_MARKER_RE, "").replace(/\s+/g, " ").trim();
@@ -2904,7 +3156,7 @@ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod)
2904
3156
  );
2905
3157
  const flat = _drop_marker_interior_hunks(
2906
3158
  _drop_image_marker_hunks(
2907
- generate_edits_from_text(text_orig, text_mod),
3159
+ generate_edits_via_paragraph_alignment(text_orig, text_mod),
2908
3160
  warnings
2909
3161
  ),
2910
3162
  text_orig,
@@ -2930,7 +3182,7 @@ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod)
2930
3182
  );
2931
3183
  }
2932
3184
  if (!tables_alignable) {
2933
- const part_edits = generate_edits_from_text(
3185
+ const part_edits = generate_edits_via_paragraph_alignment(
2934
3186
  text_orig.substring(po_start, po_end),
2935
3187
  text_mod.substring(pm_start, pm_end)
2936
3188
  );
@@ -2955,7 +3207,7 @@ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod)
2955
3207
  const seg_o_end = boundaries_o[seg_idx + 1][0];
2956
3208
  const seg_m_start = boundaries_m[seg_idx][1];
2957
3209
  const seg_m_end = boundaries_m[seg_idx + 1][0];
2958
- const seg_edits = generate_edits_from_text(
3210
+ const seg_edits = generate_edits_via_paragraph_alignment(
2959
3211
  text_orig.substring(seg_o_start, seg_o_end),
2960
3212
  text_mod.substring(seg_m_start, seg_m_end)
2961
3213
  );
@@ -4809,6 +5061,24 @@ var RedlineEngine = class _RedlineEngine {
4809
5061
  *
4810
5062
  * Does NOT attach comments; callers handle that.
4811
5063
  */
5064
+ _clone_pPr_scrubbing_headings(existing_pPr) {
5065
+ const pPr_clone = existing_pPr.cloneNode(true);
5066
+ const pStyle_el = findChild(pPr_clone, "w:pStyle");
5067
+ if (pStyle_el) {
5068
+ const style_val = pStyle_el.getAttribute("w:val");
5069
+ if (style_val) {
5070
+ const is_heading = style_val.startsWith("Heading") || style_val === "Title" || style_val.replace(/\s+/g, "").startsWith("Heading");
5071
+ if (is_heading) {
5072
+ pPr_clone.removeChild(pStyle_el);
5073
+ }
5074
+ }
5075
+ }
5076
+ const outlineLvl_el = findChild(pPr_clone, "w:outlineLvl");
5077
+ if (outlineLvl_el) {
5078
+ pPr_clone.removeChild(outlineLvl_el);
5079
+ }
5080
+ return pPr_clone;
5081
+ }
4812
5082
  _track_insert_multiline(text, anchor_run, anchor_paragraph, reuse_id, positional_anchor_el = null, suppress_emphasis = false, insert_before = false) {
4813
5083
  if (!text) {
4814
5084
  return {
@@ -4930,7 +5200,7 @@ var RedlineEngine = class _RedlineEngine {
4930
5200
  } else {
4931
5201
  const existing_pPr = findChild(current_p, "w:pPr");
4932
5202
  if (existing_pPr) {
4933
- new_p.appendChild(existing_pPr.cloneNode(true));
5203
+ new_p.appendChild(this._clone_pPr_scrubbing_headings(existing_pPr));
4934
5204
  }
4935
5205
  }
4936
5206
  let pPr = findChild(new_p, "w:pPr");
@@ -5325,18 +5595,16 @@ var RedlineEngine = class _RedlineEngine {
5325
5595
  continue;
5326
5596
  }
5327
5597
  }
5328
- let matches = this.mapper.find_all_match_indices(
5329
- edit.target_text,
5330
- is_regex
5598
+ let matches = this.mapper.drop_virtual_only_matches(
5599
+ this.mapper.find_all_match_indices(edit.target_text, is_regex)
5331
5600
  );
5332
5601
  let activeText = this.mapper.full_text;
5333
5602
  let target_mapper = this.mapper;
5334
5603
  if (matches.length === 0) {
5335
5604
  if (!this.clean_mapper)
5336
5605
  this.clean_mapper = new DocumentMapper(this.doc, true);
5337
- matches = this.clean_mapper.find_all_match_indices(
5338
- edit.target_text,
5339
- is_regex
5606
+ matches = this.clean_mapper.drop_virtual_only_matches(
5607
+ this.clean_mapper.find_all_match_indices(edit.target_text, is_regex)
5340
5608
  );
5341
5609
  if (matches.length > 0) {
5342
5610
  activeText = this.clean_mapper.full_text;
@@ -5359,9 +5627,11 @@ var RedlineEngine = class _RedlineEngine {
5359
5627
  if (!this.original_mapper) {
5360
5628
  this.original_mapper = new DocumentMapper(this.doc, false, true);
5361
5629
  }
5362
- const orig_matches = this.original_mapper.find_all_match_indices(
5363
- edit.target_text,
5364
- is_regex
5630
+ const orig_matches = this.original_mapper.drop_virtual_only_matches(
5631
+ this.original_mapper.find_all_match_indices(
5632
+ edit.target_text,
5633
+ is_regex
5634
+ )
5365
5635
  );
5366
5636
  if (orig_matches.length > 0) {
5367
5637
  is_deleted_text = true;
@@ -5583,10 +5853,15 @@ var RedlineEngine = class _RedlineEngine {
5583
5853
  }
5584
5854
  static _column_count_at(mapper, start, length) {
5585
5855
  for (const s of mapper.spans) {
5586
- if (s.run === null || s.end <= start || s.start >= start + length) {
5856
+ if (s.end <= start || s.start >= start + length) {
5587
5857
  continue;
5588
5858
  }
5589
- let curr = s.run._element;
5859
+ let curr = null;
5860
+ if (s.run !== null) {
5861
+ curr = s.run._element;
5862
+ } else if (s.paragraph !== null) {
5863
+ curr = s.paragraph._element;
5864
+ }
5590
5865
  while (curr) {
5591
5866
  if (curr.nodeType === 1 && curr.tagName === "w:tr") {
5592
5867
  return findAllDescendants(curr, "w:tc").filter(
@@ -5726,7 +6001,10 @@ var RedlineEngine = class _RedlineEngine {
5726
6001
  (c) => c === null || typeof c !== "object" || !["accept", "reject", "reply"].includes(c.type)
5727
6002
  );
5728
6003
  if (!dry_run_mode) {
5729
- const action_errors = actions.length > 0 ? this.validate_review_actions(actions) : [];
6004
+ let action_errors = actions.length > 0 ? this.validate_review_actions(actions) : [];
6005
+ if (actions.length > 0 && action_errors.length === 0) {
6006
+ action_errors = this.validate_action_pairing(actions);
6007
+ }
5730
6008
  const validate_edits_now = edits.length > 0 && action_errors.length > 0;
5731
6009
  const edit_errors = validate_edits_now ? this.validate_edits(edits) : [];
5732
6010
  const all_errors = [...action_errors, ...edit_errors];
@@ -5735,7 +6013,10 @@ var RedlineEngine = class _RedlineEngine {
5735
6013
  }
5736
6014
  } else {
5737
6015
  if (actions.length > 0) {
5738
- const action_errors = this.validate_review_actions(actions);
6016
+ let action_errors = this.validate_review_actions(actions);
6017
+ if (action_errors.length === 0) {
6018
+ action_errors = this.validate_action_pairing(actions);
6019
+ }
5739
6020
  if (action_errors.length > 0) {
5740
6021
  throw new BatchValidationError(action_errors);
5741
6022
  }
@@ -5743,10 +6024,12 @@ var RedlineEngine = class _RedlineEngine {
5743
6024
  }
5744
6025
  let applied_actions = 0;
5745
6026
  let skipped_actions = 0;
6027
+ let already_resolved_actions = 0;
5746
6028
  if (actions.length > 0) {
5747
6029
  const res = this.apply_review_actions(actions);
5748
6030
  applied_actions = res[0];
5749
6031
  skipped_actions = res[1];
6032
+ already_resolved_actions = res[2];
5750
6033
  if (skipped_actions > 0) {
5751
6034
  throw new BatchValidationError(this.skipped_details);
5752
6035
  }
@@ -5941,6 +6224,11 @@ var RedlineEngine = class _RedlineEngine {
5941
6224
  return {
5942
6225
  actions_applied: applied_actions,
5943
6226
  actions_skipped: skipped_actions,
6227
+ // Actions whose target was already resolved by an earlier action of
6228
+ // this batch (via its replacement pair): consistent no-ops, never
6229
+ // counted as applied — every reported "applied" action causes an
6230
+ // observable state transition (ADEU-QA-004).
6231
+ actions_already_resolved: already_resolved_actions,
5944
6232
  edits_applied: applied_edits,
5945
6233
  edits_skipped: skipped_edits,
5946
6234
  // edits_applied counts change OBJECTS; this is the total number of
@@ -5982,13 +6270,17 @@ var RedlineEngine = class _RedlineEngine {
5982
6270
  edit._resolved_start_idx = edit._match_start_index;
5983
6271
  resolved_edits.push([edit, edit.new_text || null]);
5984
6272
  } else if (edit.type === "insert_row" || edit.type === "delete_row") {
5985
- let matches = this.mapper.find_all_match_indices(edit.target_text);
6273
+ let matches = this.mapper.drop_virtual_only_matches(
6274
+ this.mapper.find_all_match_indices(edit.target_text)
6275
+ );
5986
6276
  let resolved_mapper = this.mapper;
5987
6277
  if (matches.length === 0) {
5988
6278
  if (!this.clean_mapper) {
5989
6279
  this.clean_mapper = new DocumentMapper(this.doc, true);
5990
6280
  }
5991
- matches = this.clean_mapper.find_all_match_indices(edit.target_text);
6281
+ matches = this.clean_mapper.drop_virtual_only_matches(
6282
+ this.clean_mapper.find_all_match_indices(edit.target_text)
6283
+ );
5992
6284
  resolved_mapper = this.clean_mapper;
5993
6285
  }
5994
6286
  if (matches.length > 0) {
@@ -6159,10 +6451,149 @@ var RedlineEngine = class _RedlineEngine {
6159
6451
  }
6160
6452
  return [applied_logical, skipped_logical];
6161
6453
  }
6454
+ /**
6455
+ * True when the paragraph still carries visible content (w:t text, w:tab,
6456
+ * w:br) that is NOT wrapped in a tracked deletion — i.e. the paragraph
6457
+ * would render non-empty in the accepted document.
6458
+ */
6459
+ _paragraph_has_visible_content(p_elem) {
6460
+ for (const tag of ["w:t", "w:tab", "w:br"]) {
6461
+ const nodes = findAllDescendants(p_elem, tag);
6462
+ for (const node of nodes) {
6463
+ let is_deleted = false;
6464
+ let curr = node.parentNode;
6465
+ while (curr && curr !== p_elem.parentNode) {
6466
+ if (curr.tagName === "w:del") {
6467
+ is_deleted = true;
6468
+ break;
6469
+ }
6470
+ curr = curr.parentNode;
6471
+ }
6472
+ if (!is_deleted) {
6473
+ if (tag === "w:t" && !node.textContent) continue;
6474
+ return true;
6475
+ }
6476
+ }
6477
+ }
6478
+ return false;
6479
+ }
6480
+ /**
6481
+ * All contiguous same-author w:ins/w:del siblings that form one logical
6482
+ * modification block with `node` (a replacement's del+ins pair). Mirrors
6483
+ * the Python engine's _get_paired_nodes: comment range markers and
6484
+ * rPr/pPr are transparent; a different author or any other element breaks
6485
+ * the group.
6486
+ */
6487
+ _get_paired_nodes(node) {
6488
+ const pairs = [];
6489
+ const author = node.getAttribute("w:author");
6490
+ const transparent = /* @__PURE__ */ new Set([
6491
+ "w:commentRangeStart",
6492
+ "w:commentRangeEnd",
6493
+ "w:commentReference",
6494
+ "w:rPr",
6495
+ "w:pPr"
6496
+ ]);
6497
+ const walk = (start, dir) => {
6498
+ let cur = dir === "next" ? start.nextSibling : start.previousSibling;
6499
+ while (cur) {
6500
+ if (cur.nodeType !== 1) {
6501
+ cur = dir === "next" ? cur.nextSibling : cur.previousSibling;
6502
+ continue;
6503
+ }
6504
+ const el = cur;
6505
+ if (transparent.has(el.tagName)) {
6506
+ cur = dir === "next" ? cur.nextSibling : cur.previousSibling;
6507
+ continue;
6508
+ }
6509
+ if ((el.tagName === "w:ins" || el.tagName === "w:del") && el.getAttribute("w:author") === author) {
6510
+ pairs.push(el);
6511
+ cur = dir === "next" ? cur.nextSibling : cur.previousSibling;
6512
+ continue;
6513
+ }
6514
+ break;
6515
+ }
6516
+ };
6517
+ walk(node, "next");
6518
+ walk(node, "prev");
6519
+ return pairs;
6520
+ }
6521
+ /**
6522
+ * All revision ids that resolve as ONE unit with `target_id`: the ids of
6523
+ * every contiguous same-author w:ins/w:del sibling of its elements (a
6524
+ * replacement's del+ins pair), plus the id itself.
6525
+ */
6526
+ _resolution_group_ids(target_id) {
6527
+ const nodes = [
6528
+ ...findAllDescendants(this.doc.element, "w:ins"),
6529
+ ...findAllDescendants(this.doc.element, "w:del")
6530
+ ].filter((n) => n.getAttribute("w:id") === target_id);
6531
+ const group = /* @__PURE__ */ new Set();
6532
+ if (nodes.length === 0) return group;
6533
+ group.add(target_id);
6534
+ for (const node of nodes) {
6535
+ for (const paired of this._get_paired_nodes(node)) {
6536
+ const pid = paired.getAttribute("w:id");
6537
+ if (pid) group.add(pid);
6538
+ }
6539
+ }
6540
+ return group;
6541
+ }
6542
+ /**
6543
+ * Document-aware validation (QA 2026-07-19 ADEU-QA-004): a replacement's
6544
+ * del+ins pair carries two distinct ids but resolves as one unit, so a
6545
+ * batch that accepts one side and rejects the other is contradictory.
6546
+ * Rejecting it up front — before any action mutates the document — keeps
6547
+ * the batch transactional.
6548
+ */
6549
+ validate_action_pairing(actions) {
6550
+ const errors = [];
6551
+ const group_first = /* @__PURE__ */ new Map();
6552
+ for (let pos = 0; pos < actions.length; pos++) {
6553
+ const act = actions[pos];
6554
+ if (act.type !== "accept" && act.type !== "reject") continue;
6555
+ const raw_id = String(act.target_id ?? "");
6556
+ if (raw_id.startsWith("Com:")) continue;
6557
+ const target_id = raw_id.startsWith("Chg:") ? raw_id.slice(4) : raw_id;
6558
+ const group = this._resolution_group_ids(target_id);
6559
+ if (group.size === 0) continue;
6560
+ let conflict = null;
6561
+ for (const gid of group) {
6562
+ const prior = group_first.get(gid);
6563
+ if (prior !== void 0 && prior[1] !== act.type) {
6564
+ conflict = prior;
6565
+ break;
6566
+ }
6567
+ }
6568
+ if (conflict !== null) {
6569
+ const [first_pos, first_type, first_id] = conflict;
6570
+ errors.push(
6571
+ `- 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.`
6572
+ );
6573
+ continue;
6574
+ }
6575
+ for (const gid of group) {
6576
+ if (!group_first.has(gid)) {
6577
+ group_first.set(gid, [pos, act.type, target_id]);
6578
+ }
6579
+ }
6580
+ }
6581
+ return errors;
6582
+ }
6583
+ /**
6584
+ * Returns [applied, skipped, already_resolved]. `applied` counts actions
6585
+ * that caused an observable state transition; an action naming an id an
6586
+ * earlier action of this batch already resolved (via its replacement pair)
6587
+ * is counted in `already_resolved` instead — never as applied
6588
+ * (QA 2026-07-19 ADEU-QA-004).
6589
+ */
6162
6590
  apply_review_actions(actions) {
6163
6591
  let applied = 0;
6164
6592
  let skipped = 0;
6165
- for (const action of actions) {
6593
+ let already_resolved = 0;
6594
+ const resolved_history = /* @__PURE__ */ new Map();
6595
+ for (let pos = 0; pos < actions.length; pos++) {
6596
+ const action = actions[pos];
6166
6597
  const type = action.type;
6167
6598
  if (type === "reply") {
6168
6599
  const cid = action.target_id.replace("Com:", "");
@@ -6176,6 +6607,21 @@ var RedlineEngine = class _RedlineEngine {
6176
6607
  continue;
6177
6608
  }
6178
6609
  const target_id = action.target_id.replace("Chg:", "");
6610
+ const prior_type = resolved_history.get(target_id);
6611
+ if (prior_type !== void 0) {
6612
+ if (prior_type === type) {
6613
+ already_resolved++;
6614
+ this.skipped_details.push(
6615
+ `- 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.`
6616
+ );
6617
+ continue;
6618
+ }
6619
+ this.skipped_details.push(
6620
+ `- 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.`
6621
+ );
6622
+ skipped++;
6623
+ continue;
6624
+ }
6179
6625
  const all_ins = findAllDescendants(this.doc.element, "w:ins").filter(
6180
6626
  (n) => n.getAttribute("w:id") === target_id
6181
6627
  );
@@ -6200,7 +6646,18 @@ var RedlineEngine = class _RedlineEngine {
6200
6646
  );
6201
6647
  continue;
6202
6648
  }
6649
+ const group_nodes = new Set(all_nodes);
6203
6650
  for (const node of all_nodes) {
6651
+ for (const paired of this._get_paired_nodes(node)) {
6652
+ group_nodes.add(paired);
6653
+ }
6654
+ }
6655
+ const resolved_now = /* @__PURE__ */ new Set();
6656
+ for (const node of group_nodes) {
6657
+ const rid = node.getAttribute("w:id");
6658
+ if (rid) resolved_now.add(rid);
6659
+ }
6660
+ for (const node of group_nodes) {
6204
6661
  const is_ins = node.tagName === "w:ins";
6205
6662
  const parent_tag = node.parentNode ? node.parentNode.tagName : "";
6206
6663
  const is_trPr = parent_tag === "w:trPr";
@@ -6252,9 +6709,12 @@ var RedlineEngine = class _RedlineEngine {
6252
6709
  }
6253
6710
  }
6254
6711
  }
6712
+ for (const rid of resolved_now) {
6713
+ resolved_history.set(rid, type);
6714
+ }
6255
6715
  applied++;
6256
6716
  }
6257
- return [applied, skipped];
6717
+ return [applied, skipped, already_resolved];
6258
6718
  }
6259
6719
  _apply_table_edit(edit, rebuild_map) {
6260
6720
  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 +6774,15 @@ var RedlineEngine = class _RedlineEngine {
6314
6774
  if (!edit.target_text) return null;
6315
6775
  const is_regex = edit.regex || false;
6316
6776
  const match_mode = edit.match_mode || "strict";
6317
- let matches = this.mapper.find_all_match_indices(
6318
- edit.target_text,
6319
- is_regex
6777
+ let matches = this.mapper.drop_virtual_only_matches(
6778
+ this.mapper.find_all_match_indices(edit.target_text, is_regex)
6320
6779
  );
6321
6780
  let use_clean_map = false;
6322
6781
  if (matches.length === 0) {
6323
6782
  if (!this.clean_mapper)
6324
6783
  this.clean_mapper = new DocumentMapper(this.doc, true);
6325
- matches = this.clean_mapper.find_all_match_indices(
6326
- edit.target_text,
6327
- is_regex
6784
+ matches = this.clean_mapper.drop_virtual_only_matches(
6785
+ this.clean_mapper.find_all_match_indices(edit.target_text, is_regex)
6328
6786
  );
6329
6787
  if (matches.length > 0) use_clean_map = true;
6330
6788
  else return null;
@@ -6834,7 +7292,9 @@ var RedlineEngine = class _RedlineEngine {
6834
7292
  this._set_paragraph_style(new_p, style_name);
6835
7293
  } else {
6836
7294
  const existing_pPr = findChild(_bug233_target_para, "w:pPr");
6837
- if (existing_pPr) new_p.appendChild(existing_pPr.cloneNode(true));
7295
+ if (existing_pPr) {
7296
+ new_p.appendChild(this._clone_pPr_scrubbing_headings(existing_pPr));
7297
+ }
6838
7298
  }
6839
7299
  let pPr = findChild(new_p, "w:pPr");
6840
7300
  if (!pPr) {
@@ -7092,7 +7552,24 @@ var RedlineEngine = class _RedlineEngine {
7092
7552
  p2_element = getNextElement(p2_element);
7093
7553
  }
7094
7554
  if (p2_element && p2_element.tagName === "w:p") {
7555
+ const p1_fully_deleted = !this._paragraph_has_visible_content(p1_element);
7095
7556
  let pPr = findChild(p1_element, "w:pPr");
7557
+ if (p1_fully_deleted) {
7558
+ const p2_pPr = findChild(p2_element, "w:pPr");
7559
+ const adopted = p2_pPr ? p2_pPr.cloneNode(true) : p1_element.ownerDocument.createElement("w:pPr");
7560
+ if (pPr) {
7561
+ const sect = findChild(pPr, "w:sectPr");
7562
+ if (sect && !findChild(adopted, "w:sectPr")) {
7563
+ adopted.appendChild(sect.cloneNode(true));
7564
+ }
7565
+ p1_element.removeChild(pPr);
7566
+ }
7567
+ p1_element.insertBefore(
7568
+ adopted,
7569
+ p1_element.firstChild
7570
+ );
7571
+ pPr = adopted;
7572
+ }
7096
7573
  if (!pPr) {
7097
7574
  pPr = p1_element.ownerDocument.createElement("w:pPr");
7098
7575
  p1_element.insertBefore(
@@ -7105,8 +7582,10 @@ var RedlineEngine = class _RedlineEngine {
7105
7582
  rPr = p1_element.ownerDocument.createElement("w:rPr");
7106
7583
  pPr.appendChild(rPr);
7107
7584
  }
7108
- const del_mark = this._create_track_change_tag("w:del");
7109
- rPr.appendChild(del_mark);
7585
+ if (!findChild(rPr, "w:del")) {
7586
+ const del_mark = this._create_track_change_tag("w:del");
7587
+ rPr.appendChild(del_mark);
7588
+ }
7110
7589
  const children = Array.from(p2_element.childNodes);
7111
7590
  for (const child of children) {
7112
7591
  if (child.nodeType === 1 && child.tagName === "w:pPr") {
@@ -7160,27 +7639,7 @@ var RedlineEngine = class _RedlineEngine {
7160
7639
  }
7161
7640
  }
7162
7641
  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
- }
7642
+ const has_visible = this._paragraph_has_visible_content(p_elem);
7184
7643
  if (!has_visible) {
7185
7644
  let pPr = findChild(p_elem, "w:pPr");
7186
7645
  if (!pPr) {
@@ -7739,6 +8198,31 @@ function strip_custom_properties(doc) {
7739
8198
  const count = Math.max(lines.length, 1);
7740
8199
  return [`Custom document properties: ${count} removed (docProps/custom.xml)`].concat(lines);
7741
8200
  }
8201
+ function strip_document_variables(doc) {
8202
+ const settingsPart = doc.pkg.getPartByPath("word/settings.xml");
8203
+ if (!settingsPart) return [];
8204
+ const names = [];
8205
+ let removedAny = false;
8206
+ for (const container of findDescendantsByLocalName(settingsPart._element, "docVars")) {
8207
+ for (const child of Array.from(container.childNodes)) {
8208
+ const el = child;
8209
+ const tag = el.tagName || "";
8210
+ if (tag === "docVar" || tag.endsWith(":docVar")) {
8211
+ names.push(el.getAttribute("w:name") || el.getAttribute("name") || "(unnamed)");
8212
+ }
8213
+ }
8214
+ container.parentNode?.removeChild(container);
8215
+ removedAny = true;
8216
+ }
8217
+ for (const stray of findDescendantsByLocalName(settingsPart._element, "docVar")) {
8218
+ names.push(stray.getAttribute("w:name") || stray.getAttribute("name") || "(unnamed)");
8219
+ stray.parentNode?.removeChild(stray);
8220
+ removedAny = true;
8221
+ }
8222
+ if (!removedAny) return [];
8223
+ const shown = Array.from(new Set(names)).sort().join(", ") || "(unnamed)";
8224
+ return [`Document variables: ${names.length} removed (${shown})`];
8225
+ }
7742
8226
  function strip_image_alt_text(doc) {
7743
8227
  let count = 0;
7744
8228
  for (const docPr of findDescendantsByLocalName(doc.element, "docPr")) {
@@ -8260,7 +8744,8 @@ function extract_table(table, comments_map, cleanView, cursor, paragraph_offsets
8260
8744
  const firstP = cell._element.getElementsByTagName("w:p")[0];
8261
8745
  const paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
8262
8746
  if (paraId) {
8263
- const anchor = `{#cell:${paraId}}`;
8747
+ const space_pad = cell_content ? " " : "";
8748
+ const anchor = `${space_pad}{#cell:${paraId}}`;
8264
8749
  cell_content = cell_content + anchor;
8265
8750
  }
8266
8751
  }
@@ -8525,6 +9010,8 @@ function _build_merged_meta_block(states_list, comments_map) {
8525
9010
  const change_lines = [];
8526
9011
  const comment_lines = [];
8527
9012
  const seen_sigs = /* @__PURE__ */ new Set();
9013
+ const pair_map = compute_change_pair_map(states_list);
9014
+ const pairSuffix = (uid) => pair_map[uid] ? ` (pairs with ${pair_map[uid]})` : "";
8528
9015
  for (const [ins_map, del_map, comments_set, fmt_map] of states_list) {
8529
9016
  let render_comment2 = function(cid) {
8530
9017
  if (!comments_map[cid]) return;
@@ -8553,7 +9040,9 @@ function _build_merged_meta_block(states_list, comments_map) {
8553
9040
  )) {
8554
9041
  const sig = `Chg:${uid}`;
8555
9042
  if (!seen_sigs.has(sig)) {
8556
- change_lines.push(`[${sig} insert] ${meta.author || "Unknown"}`);
9043
+ change_lines.push(
9044
+ `[${sig} insert] ${meta.author || "Unknown"}${pairSuffix(uid)}`
9045
+ );
8557
9046
  seen_sigs.add(sig);
8558
9047
  }
8559
9048
  }
@@ -8562,7 +9051,9 @@ function _build_merged_meta_block(states_list, comments_map) {
8562
9051
  )) {
8563
9052
  const sig = `Chg:${uid}`;
8564
9053
  if (!seen_sigs.has(sig)) {
8565
- change_lines.push(`[${sig} delete] ${meta.author || "Unknown"}`);
9054
+ change_lines.push(
9055
+ `[${sig} delete] ${meta.author || "Unknown"}${pairSuffix(uid)}`
9056
+ );
8566
9057
  seen_sigs.add(sig);
8567
9058
  }
8568
9059
  }
@@ -9124,14 +9615,14 @@ var SanitizeReport = class {
9124
9615
  const lower = line.toLowerCase();
9125
9616
  if (lower.includes("tracked change") || lower.includes("insertion") || lower.includes("deletion") || lower.includes("accepted")) {
9126
9617
  this.change_lines.push(line);
9618
+ } 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")) {
9619
+ this.metadata_lines.push(line);
9127
9620
  } else if (lower.includes("comment") || lower.includes("[open]") || lower.includes("[resolved]")) {
9128
9621
  if (lower.includes("kept") || lower.includes("visible")) {
9129
9622
  this.kept_comment_lines.push(line);
9130
9623
  } else {
9131
9624
  this.removed_comment_lines.push(line);
9132
9625
  }
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
9626
  } else if (lower.includes("hyperlink") || lower.includes("warning")) {
9136
9627
  this.warnings.push(line);
9137
9628
  } else {
@@ -9244,6 +9735,7 @@ async function finalize_document(doc, options) {
9244
9735
  report.add_transform_lines(scrub_timestamps(doc));
9245
9736
  report.add_transform_lines(strip_custom_xml(doc));
9246
9737
  report.add_transform_lines(strip_custom_properties(doc));
9738
+ report.add_transform_lines(strip_document_variables(doc));
9247
9739
  report.add_transform_lines(strip_image_alt_text(doc));
9248
9740
  const warnings = audit_hyperlinks(doc);
9249
9741
  for (const w of warnings) report.warnings.push(w);
@@ -9327,6 +9819,12 @@ function verifySanitizedPackage(outBuffer) {
9327
9819
  }
9328
9820
  }
9329
9821
  }
9822
+ if (names.includes("word/settings.xml")) {
9823
+ const settings = parseXml(strFromU82(unzipped["word/settings.xml"]));
9824
+ if (findDescendantsByLocalName(settings.documentElement, "docVar").length > 0) {
9825
+ problems.push("word/settings.xml still contains document variables (w:docVar)");
9826
+ }
9827
+ }
9330
9828
  if (problems.length > 0) {
9331
9829
  throw new Error(
9332
9830
  "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."