@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.cjs CHANGED
@@ -1226,6 +1226,18 @@ function get_paragraph_prefix(paragraph, style_cache, default_pstyle) {
1226
1226
  if (custom_level !== null) return "#".repeat(custom_level) + " ";
1227
1227
  }
1228
1228
  if (!style_name || style_name === "Normal") {
1229
+ let is_inside_tc = false;
1230
+ let curr = paragraph._element;
1231
+ while (curr) {
1232
+ if (curr.tagName === "w:tc") {
1233
+ is_inside_tc = true;
1234
+ break;
1235
+ }
1236
+ curr = curr.parentNode;
1237
+ }
1238
+ if (is_inside_tc) {
1239
+ return "";
1240
+ }
1229
1241
  const text = paragraph.text.trim();
1230
1242
  if (text && text.length < 100 && text === text.toUpperCase()) {
1231
1243
  let is_bold = false;
@@ -1297,6 +1309,50 @@ function split_boundary_whitespace(text) {
1297
1309
  text.substring(lead_len + core.length)
1298
1310
  ];
1299
1311
  }
1312
+ function compute_change_pair_map(states_list) {
1313
+ const groups = [];
1314
+ let current = [];
1315
+ const seen_ids = /* @__PURE__ */ new Set();
1316
+ for (const [ins_map, del_map] of states_list) {
1317
+ const ins_entries = Object.entries(ins_map);
1318
+ const del_entries = Object.entries(del_map);
1319
+ if (ins_entries.length === 0 && del_entries.length === 0) {
1320
+ if (current.length > 0) {
1321
+ groups.push(current);
1322
+ current = [];
1323
+ }
1324
+ continue;
1325
+ }
1326
+ const state_new = [];
1327
+ for (const [uid, meta] of ins_entries) {
1328
+ if (!seen_ids.has(uid)) {
1329
+ state_new.push([uid, meta && meta.author || "Unknown"]);
1330
+ }
1331
+ }
1332
+ for (const [uid, meta] of del_entries) {
1333
+ if (!seen_ids.has(uid)) {
1334
+ state_new.push([uid, meta && meta.author || "Unknown"]);
1335
+ }
1336
+ }
1337
+ for (const [uid, author] of state_new) {
1338
+ seen_ids.add(uid);
1339
+ if (current.length > 0 && current[current.length - 1][1] !== author) {
1340
+ groups.push(current);
1341
+ current = [];
1342
+ }
1343
+ current.push([uid, author]);
1344
+ }
1345
+ }
1346
+ if (current.length > 0) groups.push(current);
1347
+ const pair_map = {};
1348
+ for (const group of groups) {
1349
+ if (group.length < 2) continue;
1350
+ for (const [uid] of group) {
1351
+ pair_map[uid] = group.filter(([u]) => u !== uid).map(([u]) => `Chg:${u}`).join(", ");
1352
+ }
1353
+ }
1354
+ return pair_map;
1355
+ }
1300
1356
  function apply_formatting_to_segments(text, prefix, suffix) {
1301
1357
  if (!prefix && !suffix) return text;
1302
1358
  if (!text) return "";
@@ -1704,6 +1760,11 @@ ${header}`;
1704
1760
  if (paraId && firstP) {
1705
1761
  const cellPara = new Paragraph(firstP, cell);
1706
1762
  this._add_virtual_text("", current, cellPara);
1763
+ const has_content = current > cell_start;
1764
+ if (has_content) {
1765
+ this._add_virtual_text(" ", current, cellPara);
1766
+ current += 1;
1767
+ }
1707
1768
  const anchor = `{#cell:${paraId}}`;
1708
1769
  this._add_virtual_text(anchor, current, cellPara);
1709
1770
  current += anchor.length;
@@ -2054,6 +2115,8 @@ ${header}`;
2054
2115
  const change_lines = [];
2055
2116
  const comment_lines = [];
2056
2117
  const seen_sigs = /* @__PURE__ */ new Set();
2118
+ const pair_map = compute_change_pair_map(states_list);
2119
+ const pairSuffix = (uid) => pair_map[uid] ? ` (pairs with ${pair_map[uid]})` : "";
2057
2120
  for (const [ins_map, del_map, comments_set, fmt_map] of states_list) {
2058
2121
  for (const [uid, meta] of Object.entries(
2059
2122
  ins_map
@@ -2061,7 +2124,7 @@ ${header}`;
2061
2124
  const sig = `Chg:${uid}`;
2062
2125
  if (!seen_sigs.has(sig)) {
2063
2126
  const auth = meta.author || "Unknown";
2064
- change_lines.push(`[${sig} insert] ${auth}`);
2127
+ change_lines.push(`[${sig} insert] ${auth}${pairSuffix(uid)}`);
2065
2128
  seen_sigs.add(sig);
2066
2129
  }
2067
2130
  }
@@ -2071,7 +2134,7 @@ ${header}`;
2071
2134
  const sig = `Chg:${uid}`;
2072
2135
  if (!seen_sigs.has(sig)) {
2073
2136
  const auth = meta.author || "Unknown";
2074
- change_lines.push(`[${sig} delete] ${auth}`);
2137
+ change_lines.push(`[${sig} delete] ${auth}${pairSuffix(uid)}`);
2075
2138
  seen_sigs.add(sig);
2076
2139
  }
2077
2140
  }
@@ -2211,21 +2274,60 @@ ${header}`;
2211
2274
  }
2212
2275
  return results;
2213
2276
  }
2277
+ /**
2278
+ * True when no run-backed span overlaps [start, start+length): the range
2279
+ * covers only virtual projection text — meta bubbles (change/comment
2280
+ * headers, timestamps), style markers, list prefixes. Such text does not
2281
+ * exist in the document, so it can neither satisfy a match nor count
2282
+ * toward ambiguity (QA 2026-07-19 ADEU-QA-002 C): an edit targeting "4"
2283
+ * used to be rejected as "appears 8 times" because a comment bubble's
2284
+ * timestamp matched.
2285
+ *
2286
+ * Anchor tokens ({#Bookmark}, {#cell:paraId}) are the exception: they are
2287
+ * deliberate virtual TARGETING surfaces (empty-cell writes, bookmark
2288
+ * anchors) and must stay matchable.
2289
+ */
2290
+ range_is_virtual_only(start, length) {
2291
+ const end = start + length;
2292
+ const overlapping = this.spans.filter(
2293
+ (s) => s.end > start && s.start < end
2294
+ );
2295
+ if (overlapping.some((s) => s.run !== null)) return false;
2296
+ return !overlapping.some(
2297
+ (s) => s.run === null && s.text.startsWith("{#")
2298
+ );
2299
+ }
2300
+ /** Filters find_all_match_indices output down to matches that touch at
2301
+ * least one run-backed span. See range_is_virtual_only. */
2302
+ drop_virtual_only_matches(matches) {
2303
+ return matches.filter(
2304
+ ([start, length]) => !this.range_is_virtual_only(start, length)
2305
+ );
2306
+ }
2214
2307
  find_match_index(target_text, is_regex = false) {
2215
2308
  if (is_regex) {
2216
2309
  try {
2217
2310
  const match = userSearch(target_text, this.full_text);
2218
- if (match) return [match.start, match.end - match.start];
2311
+ if (match && !this.range_is_virtual_only(match.start, match.end - match.start)) {
2312
+ return [match.start, match.end - match.start];
2313
+ }
2219
2314
  } catch (e) {
2220
2315
  if (e instanceof RegexTimeoutError) throw e;
2221
2316
  }
2222
2317
  return [-1, 0];
2223
2318
  }
2224
- let start_idx = this.full_text.indexOf(target_text);
2225
- if (start_idx !== -1) return [start_idx, target_text.length];
2319
+ let from = 0;
2320
+ while (true) {
2321
+ const idx = this.full_text.indexOf(target_text, from);
2322
+ if (idx === -1) break;
2323
+ if (!this.range_is_virtual_only(idx, target_text.length)) {
2324
+ return [idx, target_text.length];
2325
+ }
2326
+ from = idx + 1;
2327
+ }
2226
2328
  const norm_full = this._replace_smart_quotes(this.full_text);
2227
2329
  const norm_target = this._replace_smart_quotes(target_text);
2228
- start_idx = norm_full.indexOf(norm_target);
2330
+ let start_idx = norm_full.indexOf(norm_target);
2229
2331
  if (start_idx !== -1) return [start_idx, target_text.length];
2230
2332
  const stripped_target = this._strip_markdown_formatting(target_text);
2231
2333
  if (this.full_text.includes(stripped_target)) {
@@ -2235,9 +2337,12 @@ ${header}`;
2235
2337
  const plain_first = this._find_plain_projection_matches(target_text);
2236
2338
  if (plain_first.length > 0) return plain_first[0];
2237
2339
  try {
2238
- const pattern = new RegExp(this._make_fuzzy_regex(target_text));
2239
- const match = pattern.exec(this.full_text);
2240
- if (match) return [match.index, match[0].length];
2340
+ const pattern = new RegExp(this._make_fuzzy_regex(target_text), "g");
2341
+ for (const match of this.full_text.matchAll(pattern)) {
2342
+ if (!this.range_is_virtual_only(match.index, match[0].length)) {
2343
+ return [match.index, match[0].length];
2344
+ }
2345
+ }
2241
2346
  } catch (e) {
2242
2347
  }
2243
2348
  return [-1, 0];
@@ -2766,6 +2871,153 @@ function _sequence_opcodes(a, b) {
2766
2871
  flushPending();
2767
2872
  return ops;
2768
2873
  }
2874
+ function _is_table_blob(block) {
2875
+ const lines = block.split("\n");
2876
+ return lines.length > 0 && lines.every((line) => line.includes(" | "));
2877
+ }
2878
+ function _table_blob_row_edits(orig_blob, mod_blob) {
2879
+ const rows_o = orig_blob.split("\n");
2880
+ const rows_m = mod_blob.split("\n");
2881
+ if (rows_o.length !== rows_m.length) return null;
2882
+ const row_edits = [];
2883
+ for (let k = 0; k < rows_o.length; k++) {
2884
+ if (rows_o[k] === rows_m[k]) continue;
2885
+ row_edits.push({
2886
+ type: "modify",
2887
+ target_text: rows_o[k],
2888
+ new_text: rows_m[k],
2889
+ comment: "Diff: Table row modified",
2890
+ _is_table_edit: true
2891
+ });
2892
+ }
2893
+ return row_edits;
2894
+ }
2895
+ function _split_cross_paragraph_hunks(edits) {
2896
+ const out = [];
2897
+ for (const e of edits) {
2898
+ const target = e.target_text || "";
2899
+ const newText = e.new_text || "";
2900
+ const idx = e._match_start_index;
2901
+ if (idx === void 0 || idx === null || !target.includes("\n\n") || target.split("\n\n").length - 1 === newText.split("\n\n").length - 1) {
2902
+ out.push(e);
2903
+ continue;
2904
+ }
2905
+ const parts = target.split("\n\n");
2906
+ if (parts.length < 2 || !parts[0].trim() || !parts[parts.length - 1].trim()) {
2907
+ out.push(e);
2908
+ continue;
2909
+ }
2910
+ let offset = idx;
2911
+ for (const piece of parts.slice(0, -1)) {
2912
+ out.push({
2913
+ type: "modify",
2914
+ target_text: piece + "\n\n",
2915
+ new_text: "",
2916
+ comment: e.comment || "Diff: Text deleted",
2917
+ _match_start_index: offset
2918
+ });
2919
+ offset += piece.length + 2;
2920
+ }
2921
+ out.push({
2922
+ type: "modify",
2923
+ target_text: parts[parts.length - 1],
2924
+ new_text: newText,
2925
+ comment: e.comment,
2926
+ _match_start_index: offset
2927
+ });
2928
+ }
2929
+ return out;
2930
+ }
2931
+ function generate_edits_via_paragraph_alignment(original_text, modified_text) {
2932
+ const orig_paragraphs = original_text.split("\n\n");
2933
+ const mod_paragraphs = modified_text.split("\n\n");
2934
+ const orig_offsets = [];
2935
+ let current_offset = 0;
2936
+ for (const p of orig_paragraphs) {
2937
+ orig_offsets.push(current_offset);
2938
+ current_offset += p.length + 2;
2939
+ }
2940
+ const opcodes = _sequence_opcodes(orig_paragraphs, mod_paragraphs);
2941
+ const edits = [];
2942
+ for (const [tag, i1, i2, j1, j2] of opcodes) {
2943
+ if (tag === "equal") continue;
2944
+ const offset = i1 < orig_offsets.length ? orig_offsets[i1] : original_text.length;
2945
+ if (tag === "delete") {
2946
+ if (i2 < orig_paragraphs.length) {
2947
+ let piece_offset = offset;
2948
+ for (let k = i1; k < i2; k++) {
2949
+ const piece = orig_paragraphs[k] + "\n\n";
2950
+ edits.push({
2951
+ type: "modify",
2952
+ target_text: piece,
2953
+ new_text: "",
2954
+ comment: "Diff: Text deleted",
2955
+ _match_start_index: piece_offset
2956
+ });
2957
+ piece_offset += piece.length;
2958
+ }
2959
+ } else {
2960
+ let deleted_text = orig_paragraphs.slice(i1, i2).join("\n\n");
2961
+ let del_offset = offset;
2962
+ if (i1 > 0) {
2963
+ deleted_text = "\n\n" + deleted_text;
2964
+ del_offset -= 2;
2965
+ }
2966
+ edits.push({
2967
+ type: "modify",
2968
+ target_text: deleted_text,
2969
+ new_text: "",
2970
+ comment: "Diff: Text deleted",
2971
+ _match_start_index: del_offset
2972
+ });
2973
+ }
2974
+ } else if (tag === "insert") {
2975
+ let inserted_text = mod_paragraphs.slice(j1, j2).join("\n\n");
2976
+ if (i1 < orig_paragraphs.length) {
2977
+ inserted_text = inserted_text + "\n\n";
2978
+ } else {
2979
+ inserted_text = "\n\n" + inserted_text;
2980
+ }
2981
+ edits.push({
2982
+ type: "modify",
2983
+ target_text: "",
2984
+ new_text: inserted_text,
2985
+ comment: "Diff: Text inserted",
2986
+ _match_start_index: offset
2987
+ });
2988
+ } else if (tag === "replace") {
2989
+ if (i2 - i1 === j2 - j1 && Array.from({ length: i2 - i1 }).some(
2990
+ (_, k) => _is_table_blob(orig_paragraphs[i1 + k]) || _is_table_blob(mod_paragraphs[j1 + k])
2991
+ )) {
2992
+ for (let k = 0; k < i2 - i1; k++) {
2993
+ const orig_p = orig_paragraphs[i1 + k];
2994
+ const mod_p = mod_paragraphs[j1 + k];
2995
+ if (orig_p === mod_p) continue;
2996
+ const pair_offset = orig_offsets[i1 + k];
2997
+ const row_edits = _is_table_blob(orig_p) && _is_table_blob(mod_p) ? _table_blob_row_edits(orig_p, mod_p) : null;
2998
+ if (row_edits !== null) {
2999
+ edits.push(...row_edits);
3000
+ continue;
3001
+ }
3002
+ const pair_edits = generate_edits_from_text(orig_p, mod_p);
3003
+ for (const ce of pair_edits) {
3004
+ ce._match_start_index = (ce._match_start_index || 0) + pair_offset;
3005
+ edits.push(ce);
3006
+ }
3007
+ }
3008
+ continue;
3009
+ }
3010
+ const orig_chunk = orig_paragraphs.slice(i1, i2).join("\n\n");
3011
+ const mod_chunk = mod_paragraphs.slice(j1, j2).join("\n\n");
3012
+ const chunk_edits = generate_edits_from_text(orig_chunk, mod_chunk);
3013
+ for (const ce of chunk_edits) {
3014
+ ce._match_start_index = (ce._match_start_index || 0) + offset;
3015
+ edits.push(ce);
3016
+ }
3017
+ }
3018
+ }
3019
+ return _split_cross_paragraph_hunks(edits);
3020
+ }
2769
3021
  var _IMAGE_MARKER_RE = /!\[[^\]]*\]\(docx-image:[^)]*\)/g;
2770
3022
  function _drop_image_marker_hunks(edits, warnings) {
2771
3023
  const _normalized = (s) => s.replace(_IMAGE_MARKER_RE, "").replace(/\s+/g, " ").trim();
@@ -2963,7 +3215,7 @@ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod)
2963
3215
  );
2964
3216
  const flat = _drop_marker_interior_hunks(
2965
3217
  _drop_image_marker_hunks(
2966
- generate_edits_from_text(text_orig, text_mod),
3218
+ generate_edits_via_paragraph_alignment(text_orig, text_mod),
2967
3219
  warnings
2968
3220
  ),
2969
3221
  text_orig,
@@ -2989,7 +3241,7 @@ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod)
2989
3241
  );
2990
3242
  }
2991
3243
  if (!tables_alignable) {
2992
- const part_edits = generate_edits_from_text(
3244
+ const part_edits = generate_edits_via_paragraph_alignment(
2993
3245
  text_orig.substring(po_start, po_end),
2994
3246
  text_mod.substring(pm_start, pm_end)
2995
3247
  );
@@ -3014,7 +3266,7 @@ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod)
3014
3266
  const seg_o_end = boundaries_o[seg_idx + 1][0];
3015
3267
  const seg_m_start = boundaries_m[seg_idx][1];
3016
3268
  const seg_m_end = boundaries_m[seg_idx + 1][0];
3017
- const seg_edits = generate_edits_from_text(
3269
+ const seg_edits = generate_edits_via_paragraph_alignment(
3018
3270
  text_orig.substring(seg_o_start, seg_o_end),
3019
3271
  text_mod.substring(seg_m_start, seg_m_end)
3020
3272
  );
@@ -4868,6 +5120,24 @@ var RedlineEngine = class _RedlineEngine {
4868
5120
  *
4869
5121
  * Does NOT attach comments; callers handle that.
4870
5122
  */
5123
+ _clone_pPr_scrubbing_headings(existing_pPr) {
5124
+ const pPr_clone = existing_pPr.cloneNode(true);
5125
+ const pStyle_el = findChild(pPr_clone, "w:pStyle");
5126
+ if (pStyle_el) {
5127
+ const style_val = pStyle_el.getAttribute("w:val");
5128
+ if (style_val) {
5129
+ const is_heading = style_val.startsWith("Heading") || style_val === "Title" || style_val.replace(/\s+/g, "").startsWith("Heading");
5130
+ if (is_heading) {
5131
+ pPr_clone.removeChild(pStyle_el);
5132
+ }
5133
+ }
5134
+ }
5135
+ const outlineLvl_el = findChild(pPr_clone, "w:outlineLvl");
5136
+ if (outlineLvl_el) {
5137
+ pPr_clone.removeChild(outlineLvl_el);
5138
+ }
5139
+ return pPr_clone;
5140
+ }
4871
5141
  _track_insert_multiline(text, anchor_run, anchor_paragraph, reuse_id, positional_anchor_el = null, suppress_emphasis = false, insert_before = false) {
4872
5142
  if (!text) {
4873
5143
  return {
@@ -4989,7 +5259,7 @@ var RedlineEngine = class _RedlineEngine {
4989
5259
  } else {
4990
5260
  const existing_pPr = findChild(current_p, "w:pPr");
4991
5261
  if (existing_pPr) {
4992
- new_p.appendChild(existing_pPr.cloneNode(true));
5262
+ new_p.appendChild(this._clone_pPr_scrubbing_headings(existing_pPr));
4993
5263
  }
4994
5264
  }
4995
5265
  let pPr = findChild(new_p, "w:pPr");
@@ -5384,18 +5654,16 @@ var RedlineEngine = class _RedlineEngine {
5384
5654
  continue;
5385
5655
  }
5386
5656
  }
5387
- let matches = this.mapper.find_all_match_indices(
5388
- edit.target_text,
5389
- is_regex
5657
+ let matches = this.mapper.drop_virtual_only_matches(
5658
+ this.mapper.find_all_match_indices(edit.target_text, is_regex)
5390
5659
  );
5391
5660
  let activeText = this.mapper.full_text;
5392
5661
  let target_mapper = this.mapper;
5393
5662
  if (matches.length === 0) {
5394
5663
  if (!this.clean_mapper)
5395
5664
  this.clean_mapper = new DocumentMapper(this.doc, true);
5396
- matches = this.clean_mapper.find_all_match_indices(
5397
- edit.target_text,
5398
- is_regex
5665
+ matches = this.clean_mapper.drop_virtual_only_matches(
5666
+ this.clean_mapper.find_all_match_indices(edit.target_text, is_regex)
5399
5667
  );
5400
5668
  if (matches.length > 0) {
5401
5669
  activeText = this.clean_mapper.full_text;
@@ -5418,9 +5686,11 @@ var RedlineEngine = class _RedlineEngine {
5418
5686
  if (!this.original_mapper) {
5419
5687
  this.original_mapper = new DocumentMapper(this.doc, false, true);
5420
5688
  }
5421
- const orig_matches = this.original_mapper.find_all_match_indices(
5422
- edit.target_text,
5423
- is_regex
5689
+ const orig_matches = this.original_mapper.drop_virtual_only_matches(
5690
+ this.original_mapper.find_all_match_indices(
5691
+ edit.target_text,
5692
+ is_regex
5693
+ )
5424
5694
  );
5425
5695
  if (orig_matches.length > 0) {
5426
5696
  is_deleted_text = true;
@@ -5642,10 +5912,15 @@ var RedlineEngine = class _RedlineEngine {
5642
5912
  }
5643
5913
  static _column_count_at(mapper, start, length) {
5644
5914
  for (const s of mapper.spans) {
5645
- if (s.run === null || s.end <= start || s.start >= start + length) {
5915
+ if (s.end <= start || s.start >= start + length) {
5646
5916
  continue;
5647
5917
  }
5648
- let curr = s.run._element;
5918
+ let curr = null;
5919
+ if (s.run !== null) {
5920
+ curr = s.run._element;
5921
+ } else if (s.paragraph !== null) {
5922
+ curr = s.paragraph._element;
5923
+ }
5649
5924
  while (curr) {
5650
5925
  if (curr.nodeType === 1 && curr.tagName === "w:tr") {
5651
5926
  return findAllDescendants(curr, "w:tc").filter(
@@ -5785,7 +6060,10 @@ var RedlineEngine = class _RedlineEngine {
5785
6060
  (c) => c === null || typeof c !== "object" || !["accept", "reject", "reply"].includes(c.type)
5786
6061
  );
5787
6062
  if (!dry_run_mode) {
5788
- const action_errors = actions.length > 0 ? this.validate_review_actions(actions) : [];
6063
+ let action_errors = actions.length > 0 ? this.validate_review_actions(actions) : [];
6064
+ if (actions.length > 0 && action_errors.length === 0) {
6065
+ action_errors = this.validate_action_pairing(actions);
6066
+ }
5789
6067
  const validate_edits_now = edits.length > 0 && action_errors.length > 0;
5790
6068
  const edit_errors = validate_edits_now ? this.validate_edits(edits) : [];
5791
6069
  const all_errors = [...action_errors, ...edit_errors];
@@ -5794,7 +6072,10 @@ var RedlineEngine = class _RedlineEngine {
5794
6072
  }
5795
6073
  } else {
5796
6074
  if (actions.length > 0) {
5797
- const action_errors = this.validate_review_actions(actions);
6075
+ let action_errors = this.validate_review_actions(actions);
6076
+ if (action_errors.length === 0) {
6077
+ action_errors = this.validate_action_pairing(actions);
6078
+ }
5798
6079
  if (action_errors.length > 0) {
5799
6080
  throw new BatchValidationError(action_errors);
5800
6081
  }
@@ -5802,10 +6083,12 @@ var RedlineEngine = class _RedlineEngine {
5802
6083
  }
5803
6084
  let applied_actions = 0;
5804
6085
  let skipped_actions = 0;
6086
+ let already_resolved_actions = 0;
5805
6087
  if (actions.length > 0) {
5806
6088
  const res = this.apply_review_actions(actions);
5807
6089
  applied_actions = res[0];
5808
6090
  skipped_actions = res[1];
6091
+ already_resolved_actions = res[2];
5809
6092
  if (skipped_actions > 0) {
5810
6093
  throw new BatchValidationError(this.skipped_details);
5811
6094
  }
@@ -6000,6 +6283,11 @@ var RedlineEngine = class _RedlineEngine {
6000
6283
  return {
6001
6284
  actions_applied: applied_actions,
6002
6285
  actions_skipped: skipped_actions,
6286
+ // Actions whose target was already resolved by an earlier action of
6287
+ // this batch (via its replacement pair): consistent no-ops, never
6288
+ // counted as applied — every reported "applied" action causes an
6289
+ // observable state transition (ADEU-QA-004).
6290
+ actions_already_resolved: already_resolved_actions,
6003
6291
  edits_applied: applied_edits,
6004
6292
  edits_skipped: skipped_edits,
6005
6293
  // edits_applied counts change OBJECTS; this is the total number of
@@ -6041,13 +6329,17 @@ var RedlineEngine = class _RedlineEngine {
6041
6329
  edit._resolved_start_idx = edit._match_start_index;
6042
6330
  resolved_edits.push([edit, edit.new_text || null]);
6043
6331
  } else if (edit.type === "insert_row" || edit.type === "delete_row") {
6044
- let matches = this.mapper.find_all_match_indices(edit.target_text);
6332
+ let matches = this.mapper.drop_virtual_only_matches(
6333
+ this.mapper.find_all_match_indices(edit.target_text)
6334
+ );
6045
6335
  let resolved_mapper = this.mapper;
6046
6336
  if (matches.length === 0) {
6047
6337
  if (!this.clean_mapper) {
6048
6338
  this.clean_mapper = new DocumentMapper(this.doc, true);
6049
6339
  }
6050
- matches = this.clean_mapper.find_all_match_indices(edit.target_text);
6340
+ matches = this.clean_mapper.drop_virtual_only_matches(
6341
+ this.clean_mapper.find_all_match_indices(edit.target_text)
6342
+ );
6051
6343
  resolved_mapper = this.clean_mapper;
6052
6344
  }
6053
6345
  if (matches.length > 0) {
@@ -6218,10 +6510,149 @@ var RedlineEngine = class _RedlineEngine {
6218
6510
  }
6219
6511
  return [applied_logical, skipped_logical];
6220
6512
  }
6513
+ /**
6514
+ * True when the paragraph still carries visible content (w:t text, w:tab,
6515
+ * w:br) that is NOT wrapped in a tracked deletion — i.e. the paragraph
6516
+ * would render non-empty in the accepted document.
6517
+ */
6518
+ _paragraph_has_visible_content(p_elem) {
6519
+ for (const tag of ["w:t", "w:tab", "w:br"]) {
6520
+ const nodes = findAllDescendants(p_elem, tag);
6521
+ for (const node of nodes) {
6522
+ let is_deleted = false;
6523
+ let curr = node.parentNode;
6524
+ while (curr && curr !== p_elem.parentNode) {
6525
+ if (curr.tagName === "w:del") {
6526
+ is_deleted = true;
6527
+ break;
6528
+ }
6529
+ curr = curr.parentNode;
6530
+ }
6531
+ if (!is_deleted) {
6532
+ if (tag === "w:t" && !node.textContent) continue;
6533
+ return true;
6534
+ }
6535
+ }
6536
+ }
6537
+ return false;
6538
+ }
6539
+ /**
6540
+ * All contiguous same-author w:ins/w:del siblings that form one logical
6541
+ * modification block with `node` (a replacement's del+ins pair). Mirrors
6542
+ * the Python engine's _get_paired_nodes: comment range markers and
6543
+ * rPr/pPr are transparent; a different author or any other element breaks
6544
+ * the group.
6545
+ */
6546
+ _get_paired_nodes(node) {
6547
+ const pairs = [];
6548
+ const author = node.getAttribute("w:author");
6549
+ const transparent = /* @__PURE__ */ new Set([
6550
+ "w:commentRangeStart",
6551
+ "w:commentRangeEnd",
6552
+ "w:commentReference",
6553
+ "w:rPr",
6554
+ "w:pPr"
6555
+ ]);
6556
+ const walk = (start, dir) => {
6557
+ let cur = dir === "next" ? start.nextSibling : start.previousSibling;
6558
+ while (cur) {
6559
+ if (cur.nodeType !== 1) {
6560
+ cur = dir === "next" ? cur.nextSibling : cur.previousSibling;
6561
+ continue;
6562
+ }
6563
+ const el = cur;
6564
+ if (transparent.has(el.tagName)) {
6565
+ cur = dir === "next" ? cur.nextSibling : cur.previousSibling;
6566
+ continue;
6567
+ }
6568
+ if ((el.tagName === "w:ins" || el.tagName === "w:del") && el.getAttribute("w:author") === author) {
6569
+ pairs.push(el);
6570
+ cur = dir === "next" ? cur.nextSibling : cur.previousSibling;
6571
+ continue;
6572
+ }
6573
+ break;
6574
+ }
6575
+ };
6576
+ walk(node, "next");
6577
+ walk(node, "prev");
6578
+ return pairs;
6579
+ }
6580
+ /**
6581
+ * All revision ids that resolve as ONE unit with `target_id`: the ids of
6582
+ * every contiguous same-author w:ins/w:del sibling of its elements (a
6583
+ * replacement's del+ins pair), plus the id itself.
6584
+ */
6585
+ _resolution_group_ids(target_id) {
6586
+ const nodes = [
6587
+ ...findAllDescendants(this.doc.element, "w:ins"),
6588
+ ...findAllDescendants(this.doc.element, "w:del")
6589
+ ].filter((n) => n.getAttribute("w:id") === target_id);
6590
+ const group = /* @__PURE__ */ new Set();
6591
+ if (nodes.length === 0) return group;
6592
+ group.add(target_id);
6593
+ for (const node of nodes) {
6594
+ for (const paired of this._get_paired_nodes(node)) {
6595
+ const pid = paired.getAttribute("w:id");
6596
+ if (pid) group.add(pid);
6597
+ }
6598
+ }
6599
+ return group;
6600
+ }
6601
+ /**
6602
+ * Document-aware validation (QA 2026-07-19 ADEU-QA-004): a replacement's
6603
+ * del+ins pair carries two distinct ids but resolves as one unit, so a
6604
+ * batch that accepts one side and rejects the other is contradictory.
6605
+ * Rejecting it up front — before any action mutates the document — keeps
6606
+ * the batch transactional.
6607
+ */
6608
+ validate_action_pairing(actions) {
6609
+ const errors = [];
6610
+ const group_first = /* @__PURE__ */ new Map();
6611
+ for (let pos = 0; pos < actions.length; pos++) {
6612
+ const act = actions[pos];
6613
+ if (act.type !== "accept" && act.type !== "reject") continue;
6614
+ const raw_id = String(act.target_id ?? "");
6615
+ if (raw_id.startsWith("Com:")) continue;
6616
+ const target_id = raw_id.startsWith("Chg:") ? raw_id.slice(4) : raw_id;
6617
+ const group = this._resolution_group_ids(target_id);
6618
+ if (group.size === 0) continue;
6619
+ let conflict = null;
6620
+ for (const gid of group) {
6621
+ const prior = group_first.get(gid);
6622
+ if (prior !== void 0 && prior[1] !== act.type) {
6623
+ conflict = prior;
6624
+ break;
6625
+ }
6626
+ }
6627
+ if (conflict !== null) {
6628
+ const [first_pos, first_type, first_id] = conflict;
6629
+ errors.push(
6630
+ `- 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.`
6631
+ );
6632
+ continue;
6633
+ }
6634
+ for (const gid of group) {
6635
+ if (!group_first.has(gid)) {
6636
+ group_first.set(gid, [pos, act.type, target_id]);
6637
+ }
6638
+ }
6639
+ }
6640
+ return errors;
6641
+ }
6642
+ /**
6643
+ * Returns [applied, skipped, already_resolved]. `applied` counts actions
6644
+ * that caused an observable state transition; an action naming an id an
6645
+ * earlier action of this batch already resolved (via its replacement pair)
6646
+ * is counted in `already_resolved` instead — never as applied
6647
+ * (QA 2026-07-19 ADEU-QA-004).
6648
+ */
6221
6649
  apply_review_actions(actions) {
6222
6650
  let applied = 0;
6223
6651
  let skipped = 0;
6224
- for (const action of actions) {
6652
+ let already_resolved = 0;
6653
+ const resolved_history = /* @__PURE__ */ new Map();
6654
+ for (let pos = 0; pos < actions.length; pos++) {
6655
+ const action = actions[pos];
6225
6656
  const type = action.type;
6226
6657
  if (type === "reply") {
6227
6658
  const cid = action.target_id.replace("Com:", "");
@@ -6235,6 +6666,21 @@ var RedlineEngine = class _RedlineEngine {
6235
6666
  continue;
6236
6667
  }
6237
6668
  const target_id = action.target_id.replace("Chg:", "");
6669
+ const prior_type = resolved_history.get(target_id);
6670
+ if (prior_type !== void 0) {
6671
+ if (prior_type === type) {
6672
+ already_resolved++;
6673
+ this.skipped_details.push(
6674
+ `- 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.`
6675
+ );
6676
+ continue;
6677
+ }
6678
+ this.skipped_details.push(
6679
+ `- 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.`
6680
+ );
6681
+ skipped++;
6682
+ continue;
6683
+ }
6238
6684
  const all_ins = findAllDescendants(this.doc.element, "w:ins").filter(
6239
6685
  (n) => n.getAttribute("w:id") === target_id
6240
6686
  );
@@ -6259,7 +6705,18 @@ var RedlineEngine = class _RedlineEngine {
6259
6705
  );
6260
6706
  continue;
6261
6707
  }
6708
+ const group_nodes = new Set(all_nodes);
6262
6709
  for (const node of all_nodes) {
6710
+ for (const paired of this._get_paired_nodes(node)) {
6711
+ group_nodes.add(paired);
6712
+ }
6713
+ }
6714
+ const resolved_now = /* @__PURE__ */ new Set();
6715
+ for (const node of group_nodes) {
6716
+ const rid = node.getAttribute("w:id");
6717
+ if (rid) resolved_now.add(rid);
6718
+ }
6719
+ for (const node of group_nodes) {
6263
6720
  const is_ins = node.tagName === "w:ins";
6264
6721
  const parent_tag = node.parentNode ? node.parentNode.tagName : "";
6265
6722
  const is_trPr = parent_tag === "w:trPr";
@@ -6311,9 +6768,12 @@ var RedlineEngine = class _RedlineEngine {
6311
6768
  }
6312
6769
  }
6313
6770
  }
6771
+ for (const rid of resolved_now) {
6772
+ resolved_history.set(rid, type);
6773
+ }
6314
6774
  applied++;
6315
6775
  }
6316
- return [applied, skipped];
6776
+ return [applied, skipped, already_resolved];
6317
6777
  }
6318
6778
  _apply_table_edit(edit, rebuild_map) {
6319
6779
  const start_idx = edit._resolved_start_idx !== void 0 && edit._resolved_start_idx !== null ? edit._resolved_start_idx : edit._match_start_index || 0;
@@ -6373,17 +6833,15 @@ var RedlineEngine = class _RedlineEngine {
6373
6833
  if (!edit.target_text) return null;
6374
6834
  const is_regex = edit.regex || false;
6375
6835
  const match_mode = edit.match_mode || "strict";
6376
- let matches = this.mapper.find_all_match_indices(
6377
- edit.target_text,
6378
- is_regex
6836
+ let matches = this.mapper.drop_virtual_only_matches(
6837
+ this.mapper.find_all_match_indices(edit.target_text, is_regex)
6379
6838
  );
6380
6839
  let use_clean_map = false;
6381
6840
  if (matches.length === 0) {
6382
6841
  if (!this.clean_mapper)
6383
6842
  this.clean_mapper = new DocumentMapper(this.doc, true);
6384
- matches = this.clean_mapper.find_all_match_indices(
6385
- edit.target_text,
6386
- is_regex
6843
+ matches = this.clean_mapper.drop_virtual_only_matches(
6844
+ this.clean_mapper.find_all_match_indices(edit.target_text, is_regex)
6387
6845
  );
6388
6846
  if (matches.length > 0) use_clean_map = true;
6389
6847
  else return null;
@@ -6893,7 +7351,9 @@ var RedlineEngine = class _RedlineEngine {
6893
7351
  this._set_paragraph_style(new_p, style_name);
6894
7352
  } else {
6895
7353
  const existing_pPr = findChild(_bug233_target_para, "w:pPr");
6896
- if (existing_pPr) new_p.appendChild(existing_pPr.cloneNode(true));
7354
+ if (existing_pPr) {
7355
+ new_p.appendChild(this._clone_pPr_scrubbing_headings(existing_pPr));
7356
+ }
6897
7357
  }
6898
7358
  let pPr = findChild(new_p, "w:pPr");
6899
7359
  if (!pPr) {
@@ -7151,7 +7611,24 @@ var RedlineEngine = class _RedlineEngine {
7151
7611
  p2_element = getNextElement(p2_element);
7152
7612
  }
7153
7613
  if (p2_element && p2_element.tagName === "w:p") {
7614
+ const p1_fully_deleted = !this._paragraph_has_visible_content(p1_element);
7154
7615
  let pPr = findChild(p1_element, "w:pPr");
7616
+ if (p1_fully_deleted) {
7617
+ const p2_pPr = findChild(p2_element, "w:pPr");
7618
+ const adopted = p2_pPr ? p2_pPr.cloneNode(true) : p1_element.ownerDocument.createElement("w:pPr");
7619
+ if (pPr) {
7620
+ const sect = findChild(pPr, "w:sectPr");
7621
+ if (sect && !findChild(adopted, "w:sectPr")) {
7622
+ adopted.appendChild(sect.cloneNode(true));
7623
+ }
7624
+ p1_element.removeChild(pPr);
7625
+ }
7626
+ p1_element.insertBefore(
7627
+ adopted,
7628
+ p1_element.firstChild
7629
+ );
7630
+ pPr = adopted;
7631
+ }
7155
7632
  if (!pPr) {
7156
7633
  pPr = p1_element.ownerDocument.createElement("w:pPr");
7157
7634
  p1_element.insertBefore(
@@ -7164,8 +7641,10 @@ var RedlineEngine = class _RedlineEngine {
7164
7641
  rPr = p1_element.ownerDocument.createElement("w:rPr");
7165
7642
  pPr.appendChild(rPr);
7166
7643
  }
7167
- const del_mark = this._create_track_change_tag("w:del");
7168
- rPr.appendChild(del_mark);
7644
+ if (!findChild(rPr, "w:del")) {
7645
+ const del_mark = this._create_track_change_tag("w:del");
7646
+ rPr.appendChild(del_mark);
7647
+ }
7169
7648
  const children = Array.from(p2_element.childNodes);
7170
7649
  for (const child of children) {
7171
7650
  if (child.nodeType === 1 && child.tagName === "w:pPr") {
@@ -7219,27 +7698,7 @@ var RedlineEngine = class _RedlineEngine {
7219
7698
  }
7220
7699
  }
7221
7700
  for (const p_elem of affected_ps) {
7222
- let has_visible = false;
7223
- for (const tag of ["w:t", "w:tab", "w:br"]) {
7224
- const nodes = findAllDescendants(p_elem, tag);
7225
- for (const node of nodes) {
7226
- let is_deleted = false;
7227
- let curr = node.parentNode;
7228
- while (curr && curr !== p_elem.parentNode) {
7229
- if (curr.tagName === "w:del") {
7230
- is_deleted = true;
7231
- break;
7232
- }
7233
- curr = curr.parentNode;
7234
- }
7235
- if (!is_deleted) {
7236
- if (tag === "w:t" && !node.textContent) continue;
7237
- has_visible = true;
7238
- break;
7239
- }
7240
- }
7241
- if (has_visible) break;
7242
- }
7701
+ const has_visible = this._paragraph_has_visible_content(p_elem);
7243
7702
  if (!has_visible) {
7244
7703
  let pPr = findChild(p_elem, "w:pPr");
7245
7704
  if (!pPr) {
@@ -7798,6 +8257,31 @@ function strip_custom_properties(doc) {
7798
8257
  const count = Math.max(lines.length, 1);
7799
8258
  return [`Custom document properties: ${count} removed (docProps/custom.xml)`].concat(lines);
7800
8259
  }
8260
+ function strip_document_variables(doc) {
8261
+ const settingsPart = doc.pkg.getPartByPath("word/settings.xml");
8262
+ if (!settingsPart) return [];
8263
+ const names = [];
8264
+ let removedAny = false;
8265
+ for (const container of findDescendantsByLocalName(settingsPart._element, "docVars")) {
8266
+ for (const child of Array.from(container.childNodes)) {
8267
+ const el = child;
8268
+ const tag = el.tagName || "";
8269
+ if (tag === "docVar" || tag.endsWith(":docVar")) {
8270
+ names.push(el.getAttribute("w:name") || el.getAttribute("name") || "(unnamed)");
8271
+ }
8272
+ }
8273
+ container.parentNode?.removeChild(container);
8274
+ removedAny = true;
8275
+ }
8276
+ for (const stray of findDescendantsByLocalName(settingsPart._element, "docVar")) {
8277
+ names.push(stray.getAttribute("w:name") || stray.getAttribute("name") || "(unnamed)");
8278
+ stray.parentNode?.removeChild(stray);
8279
+ removedAny = true;
8280
+ }
8281
+ if (!removedAny) return [];
8282
+ const shown = Array.from(new Set(names)).sort().join(", ") || "(unnamed)";
8283
+ return [`Document variables: ${names.length} removed (${shown})`];
8284
+ }
7801
8285
  function strip_image_alt_text(doc) {
7802
8286
  let count = 0;
7803
8287
  for (const docPr of findDescendantsByLocalName(doc.element, "docPr")) {
@@ -8319,7 +8803,8 @@ function extract_table(table, comments_map, cleanView, cursor, paragraph_offsets
8319
8803
  const firstP = cell._element.getElementsByTagName("w:p")[0];
8320
8804
  const paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
8321
8805
  if (paraId) {
8322
- const anchor = `{#cell:${paraId}}`;
8806
+ const space_pad = cell_content ? " " : "";
8807
+ const anchor = `${space_pad}{#cell:${paraId}}`;
8323
8808
  cell_content = cell_content + anchor;
8324
8809
  }
8325
8810
  }
@@ -8584,6 +9069,8 @@ function _build_merged_meta_block(states_list, comments_map) {
8584
9069
  const change_lines = [];
8585
9070
  const comment_lines = [];
8586
9071
  const seen_sigs = /* @__PURE__ */ new Set();
9072
+ const pair_map = compute_change_pair_map(states_list);
9073
+ const pairSuffix = (uid) => pair_map[uid] ? ` (pairs with ${pair_map[uid]})` : "";
8587
9074
  for (const [ins_map, del_map, comments_set, fmt_map] of states_list) {
8588
9075
  let render_comment2 = function(cid) {
8589
9076
  if (!comments_map[cid]) return;
@@ -8612,7 +9099,9 @@ function _build_merged_meta_block(states_list, comments_map) {
8612
9099
  )) {
8613
9100
  const sig = `Chg:${uid}`;
8614
9101
  if (!seen_sigs.has(sig)) {
8615
- change_lines.push(`[${sig} insert] ${meta.author || "Unknown"}`);
9102
+ change_lines.push(
9103
+ `[${sig} insert] ${meta.author || "Unknown"}${pairSuffix(uid)}`
9104
+ );
8616
9105
  seen_sigs.add(sig);
8617
9106
  }
8618
9107
  }
@@ -8621,7 +9110,9 @@ function _build_merged_meta_block(states_list, comments_map) {
8621
9110
  )) {
8622
9111
  const sig = `Chg:${uid}`;
8623
9112
  if (!seen_sigs.has(sig)) {
8624
- change_lines.push(`[${sig} delete] ${meta.author || "Unknown"}`);
9113
+ change_lines.push(
9114
+ `[${sig} delete] ${meta.author || "Unknown"}${pairSuffix(uid)}`
9115
+ );
8625
9116
  seen_sigs.add(sig);
8626
9117
  }
8627
9118
  }
@@ -9183,14 +9674,14 @@ var SanitizeReport = class {
9183
9674
  const lower = line.toLowerCase();
9184
9675
  if (lower.includes("tracked change") || lower.includes("insertion") || lower.includes("deletion") || lower.includes("accepted")) {
9185
9676
  this.change_lines.push(line);
9677
+ } 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")) {
9678
+ this.metadata_lines.push(line);
9186
9679
  } else if (lower.includes("comment") || lower.includes("[open]") || lower.includes("[resolved]")) {
9187
9680
  if (lower.includes("kept") || lower.includes("visible")) {
9188
9681
  this.kept_comment_lines.push(line);
9189
9682
  } else {
9190
9683
  this.removed_comment_lines.push(line);
9191
9684
  }
9192
- } else if (lower.includes("author") || lower.includes("template") || lower.includes("company") || lower.includes("manager") || lower.includes("metadata") || lower.includes("timestamp") || lower.includes("custom xml") || lower.includes("custom propert") || lower.includes("identifier") || lower.includes("language") || lower.includes("version") || lower.includes("last modified by") || lower.includes("revision count") || lower.includes("last printed")) {
9193
- this.metadata_lines.push(line);
9194
9685
  } else if (lower.includes("hyperlink") || lower.includes("warning")) {
9195
9686
  this.warnings.push(line);
9196
9687
  } else {
@@ -9303,6 +9794,7 @@ async function finalize_document(doc, options) {
9303
9794
  report.add_transform_lines(scrub_timestamps(doc));
9304
9795
  report.add_transform_lines(strip_custom_xml(doc));
9305
9796
  report.add_transform_lines(strip_custom_properties(doc));
9797
+ report.add_transform_lines(strip_document_variables(doc));
9306
9798
  report.add_transform_lines(strip_image_alt_text(doc));
9307
9799
  const warnings = audit_hyperlinks(doc);
9308
9800
  for (const w of warnings) report.warnings.push(w);
@@ -9386,6 +9878,12 @@ function verifySanitizedPackage(outBuffer) {
9386
9878
  }
9387
9879
  }
9388
9880
  }
9881
+ if (names.includes("word/settings.xml")) {
9882
+ const settings = parseXml((0, import_fflate3.strFromU8)(unzipped["word/settings.xml"]));
9883
+ if (findDescendantsByLocalName(settings.documentElement, "docVar").length > 0) {
9884
+ problems.push("word/settings.xml still contains document variables (w:docVar)");
9885
+ }
9886
+ }
9389
9887
  if (problems.length > 0) {
9390
9888
  throw new Error(
9391
9889
  "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."