@adeu/core 1.25.0 → 1.27.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
@@ -38,6 +38,7 @@ __export(index_exports, {
38
38
  USER_PATTERN_TIMEOUT_MS: () => USER_PATTERN_TIMEOUT_MS,
39
39
  _extractTextFromDoc: () => _extractTextFromDoc,
40
40
  apply_edits_to_markdown: () => apply_edits_to_markdown,
41
+ collect_media_difference_warnings: () => collect_media_difference_warnings,
41
42
  create_unified_diff: () => create_unified_diff,
42
43
  create_word_patch_diff: () => create_word_patch_diff,
43
44
  describe_illegal_control_chars: () => describe_illegal_control_chars,
@@ -1286,12 +1287,27 @@ function get_run_style_markers(run, is_heading = null) {
1286
1287
  }
1287
1288
  return [prefix, suffix];
1288
1289
  }
1290
+ function split_boundary_whitespace(text) {
1291
+ const core = text.trim();
1292
+ if (!core) return ["", "", text];
1293
+ const lead_len = text.length - text.trimStart().length;
1294
+ return [
1295
+ text.substring(0, lead_len),
1296
+ text.substring(lead_len, lead_len + core.length),
1297
+ text.substring(lead_len + core.length)
1298
+ ];
1299
+ }
1289
1300
  function apply_formatting_to_segments(text, prefix, suffix) {
1290
1301
  if (!prefix && !suffix) return text;
1291
1302
  if (!text) return "";
1292
- if (!text.includes("\n")) return `${prefix}${text}${suffix}`;
1303
+ const wrap = (segment) => {
1304
+ const [lead, core, trail] = split_boundary_whitespace(segment);
1305
+ if (!core) return segment;
1306
+ return `${lead}${prefix}${core}${suffix}${trail}`;
1307
+ };
1308
+ if (!text.includes("\n")) return wrap(text);
1293
1309
  const parts = text.split("\n");
1294
- return parts.map((p) => p ? `${prefix}${p}${suffix}` : "").join("\n");
1310
+ return parts.map((p) => p ? wrap(p) : "").join("\n");
1295
1311
  }
1296
1312
  function get_run_text(run) {
1297
1313
  let text = "";
@@ -1744,7 +1760,7 @@ ${header}`;
1744
1760
  this._add_virtual_text(s_tok, current, paragraph);
1745
1761
  current += s_tok.length;
1746
1762
  }
1747
- for (const [kind, txt, r_obj, i_id, d_id, c_ids] of pending_runs) {
1763
+ for (const [kind, txt, r_obj, r_off, i_id, d_id, c_ids] of pending_runs) {
1748
1764
  if (kind === "virtual") {
1749
1765
  this._add_virtual_text(txt, current, paragraph, active_hyperlink_id);
1750
1766
  } else {
@@ -1758,7 +1774,8 @@ ${header}`;
1758
1774
  del_id: d_id || void 0,
1759
1775
  hyperlink_id: active_hyperlink_id || void 0,
1760
1776
  comment_ids: c_ids.length > 0 ? c_ids : void 0,
1761
- part_index: this._current_part_index
1777
+ part_index: this._current_part_index,
1778
+ run_offset: r_off
1762
1779
  };
1763
1780
  this.spans.push(s);
1764
1781
  this._text_chunks.push(txt);
@@ -1793,20 +1810,42 @@ ${header}`;
1793
1810
  if (text === "" || /^\s*$/.test(text)) continue;
1794
1811
  leading_strip_active = false;
1795
1812
  }
1813
+ let run_local = 0;
1814
+ const append_wrapped = (segment) => {
1815
+ const [lead, core, trail] = split_boundary_whitespace(segment);
1816
+ if (!core) {
1817
+ run_parts.push(["real", segment, item, run_local]);
1818
+ run_local += segment.length;
1819
+ return;
1820
+ }
1821
+ if (lead) {
1822
+ run_parts.push(["real", lead, item, run_local]);
1823
+ run_local += lead.length;
1824
+ }
1825
+ if (prefix) run_parts.push(["virtual", prefix, null, 0]);
1826
+ run_parts.push(["real", core, item, run_local]);
1827
+ run_local += core.length;
1828
+ if (suffix) run_parts.push(["virtual", suffix, null, 0]);
1829
+ if (trail) {
1830
+ run_parts.push(["real", trail, item, run_local]);
1831
+ run_local += trail.length;
1832
+ }
1833
+ };
1796
1834
  if (text.includes("\n") && (prefix || suffix)) {
1797
1835
  const parts = text.split("\n");
1798
1836
  for (let idx = 0; idx < parts.length; idx++) {
1799
- if (idx > 0) run_parts.push(["real", "\n", item]);
1800
- if (parts[idx]) {
1801
- if (prefix) run_parts.push(["virtual", prefix, null]);
1802
- run_parts.push(["real", parts[idx], item]);
1803
- if (suffix) run_parts.push(["virtual", suffix, null]);
1837
+ if (idx > 0) {
1838
+ run_parts.push(["real", "\n", item, run_local]);
1839
+ run_local += 1;
1804
1840
  }
1841
+ if (parts[idx]) append_wrapped(parts[idx]);
1805
1842
  }
1843
+ } else if ((prefix || suffix) && text) {
1844
+ append_wrapped(text);
1806
1845
  } else {
1807
- if (prefix) run_parts.push(["virtual", prefix, null]);
1808
- if (text) run_parts.push(["real", text, item]);
1809
- if (suffix) run_parts.push(["virtual", suffix, null]);
1846
+ if (prefix) run_parts.push(["virtual", prefix, null, 0]);
1847
+ if (text) run_parts.push(["real", text, item, 0]);
1848
+ if (suffix) run_parts.push(["virtual", suffix, null, 0]);
1810
1849
  }
1811
1850
  if (this.clean_view && Object.keys(active_del).length > 0) {
1812
1851
  }
@@ -1830,7 +1869,7 @@ ${header}`;
1830
1869
  skip_leading_prefix = true;
1831
1870
  }
1832
1871
  const curr_comment_ids = Array.from(active_ids);
1833
- for (const [kind, txt, r_obj] of run_parts) {
1872
+ for (const [kind, txt, r_obj, r_off] of run_parts) {
1834
1873
  if (skip_leading_prefix && kind === "virtual" && txt === new_style[0]) {
1835
1874
  skip_leading_prefix = false;
1836
1875
  continue;
@@ -1839,6 +1878,7 @@ ${header}`;
1839
1878
  kind,
1840
1879
  txt,
1841
1880
  r_obj,
1881
+ r_off,
1842
1882
  curr_ins_id,
1843
1883
  curr_del_id,
1844
1884
  curr_comment_ids
@@ -1850,11 +1890,12 @@ ${header}`;
1850
1890
  current_wrappers = new_wrappers;
1851
1891
  current_style = new_style;
1852
1892
  const curr_comment_ids = Array.from(active_ids);
1853
- for (const [kind, txt, r_obj] of run_parts) {
1893
+ for (const [kind, txt, r_obj, r_off] of run_parts) {
1854
1894
  pending_runs.push([
1855
1895
  kind,
1856
1896
  txt,
1857
1897
  r_obj,
1898
+ r_off,
1858
1899
  curr_ins_id,
1859
1900
  curr_del_id,
1860
1901
  curr_comment_ids
@@ -2266,39 +2307,43 @@ ${header}`;
2266
2307
  (s) => s.end > start_idx && s.start < end_idx
2267
2308
  );
2268
2309
  if (affected_spans.length === 0) return [];
2269
- const working_runs = affected_spans.filter((s) => s.run !== null).map((s) => s.run);
2270
- if (working_runs.length === 0) return [];
2271
- let dom_modified = false;
2272
- const first_real_span = affected_spans.find((s) => s.run !== null);
2273
- let start_split_adjustment = 0;
2274
- if (first_real_span) {
2275
- const local_start = start_idx - first_real_span.start;
2276
- if (local_start > 0) {
2277
- const idx_in_working = 0;
2278
- const [, right_run] = this._split_run_at_index(
2279
- working_runs[idx_in_working],
2280
- local_start
2281
- );
2282
- working_runs[idx_in_working] = right_run;
2283
- dom_modified = true;
2284
- start_split_adjustment = local_start;
2310
+ const real_spans = affected_spans.filter((s) => s.run !== null);
2311
+ if (real_spans.length === 0) return [];
2312
+ const working_runs = [];
2313
+ for (const s of real_spans) {
2314
+ if (!working_runs.some((r) => r === s.run)) {
2315
+ working_runs.push(s.run);
2285
2316
  }
2286
2317
  }
2287
- const last_real_span = [...affected_spans].reverse().find((s) => s.run !== null);
2288
- if (last_real_span) {
2289
- const is_same_run = first_real_span === last_real_span;
2290
- const run_to_split = working_runs[working_runs.length - 1];
2291
- let overlap_end = Math.min(last_real_span.end, end_idx);
2292
- let local_end = overlap_end - last_real_span.start;
2293
- if (is_same_run && start_split_adjustment > 0) {
2294
- local_end -= start_split_adjustment;
2295
- }
2296
- const run_text = get_run_text(run_to_split);
2297
- if (local_end > 0 && local_end < run_text.length) {
2298
- const [left_run] = this._split_run_at_index(run_to_split, local_end);
2299
- working_runs[working_runs.length - 1] = left_run;
2300
- dom_modified = true;
2301
- }
2318
+ let dom_modified = false;
2319
+ const first_real_span = real_spans[0];
2320
+ let start_split_adjustment = 0;
2321
+ const local_start = Math.max(start_idx, first_real_span.start) - first_real_span.start + (first_real_span.run_offset || 0);
2322
+ if (local_start > 0) {
2323
+ const split_source = working_runs[0];
2324
+ const [, right_run] = this._split_run_at_index(
2325
+ split_source,
2326
+ local_start
2327
+ );
2328
+ for (let w = 0; w < working_runs.length; w++) {
2329
+ if (working_runs[w] === split_source) working_runs[w] = right_run;
2330
+ }
2331
+ dom_modified = true;
2332
+ start_split_adjustment = local_start;
2333
+ }
2334
+ const last_real_span = real_spans[real_spans.length - 1];
2335
+ const is_same_run = first_real_span.run === last_real_span.run;
2336
+ const run_to_split = working_runs[working_runs.length - 1];
2337
+ const overlap_end = Math.min(last_real_span.end, end_idx);
2338
+ let local_end = overlap_end - last_real_span.start + (last_real_span.run_offset || 0);
2339
+ if (is_same_run && start_split_adjustment > 0) {
2340
+ local_end -= start_split_adjustment;
2341
+ }
2342
+ const run_text = get_run_text(run_to_split);
2343
+ if (local_end > 0 && local_end < run_text.length) {
2344
+ const [left_run] = this._split_run_at_index(run_to_split, local_end);
2345
+ working_runs[working_runs.length - 1] = left_run;
2346
+ dom_modified = true;
2302
2347
  }
2303
2348
  if (dom_modified && rebuild_map) {
2304
2349
  this._build_map();
@@ -2335,7 +2380,7 @@ ${header}`;
2335
2380
  }
2336
2381
  return [null, span.paragraph];
2337
2382
  } else {
2338
- const offset = index - span.start;
2383
+ const offset = index - span.start + (span.run_offset || 0);
2339
2384
  const [left] = this._split_run_at_index(span.run, offset);
2340
2385
  if (rebuild_map) this._build_map();
2341
2386
  return [left, span.paragraph];
@@ -2406,6 +2451,7 @@ ${header}`;
2406
2451
  };
2407
2452
 
2408
2453
  // src/diff.ts
2454
+ var import_fflate2 = require("fflate");
2409
2455
  var import_diff_match_patch = __toESM(require("diff-match-patch"), 1);
2410
2456
  function _count_standalone_underscores(s) {
2411
2457
  let count = 0;
@@ -2743,6 +2789,37 @@ function _drop_image_marker_hunks(edits, warnings) {
2743
2789
  }
2744
2790
  return kept;
2745
2791
  }
2792
+ function _drop_marker_interior_hunks(edits, text_orig, warnings) {
2793
+ const marker_spans = [];
2794
+ for (const m of text_orig.matchAll(_IMAGE_MARKER_RE)) {
2795
+ marker_spans.push([m.index, m.index + m[0].length]);
2796
+ }
2797
+ if (marker_spans.length === 0) return edits;
2798
+ const kept = [];
2799
+ let warned = false;
2800
+ for (const e of edits) {
2801
+ const idx = e._match_start_index;
2802
+ if (idx === void 0 || idx === null) {
2803
+ kept.push(e);
2804
+ continue;
2805
+ }
2806
+ const end = idx + (e.target_text || "").length;
2807
+ const cuts_into_marker = marker_spans.some(
2808
+ ([m_start, m_end]) => m_start < end && idx < m_end && !(idx <= m_start && m_end <= end)
2809
+ );
2810
+ if (!cuts_into_marker) {
2811
+ kept.push(e);
2812
+ continue;
2813
+ }
2814
+ if (!warned) {
2815
+ warnings.push(
2816
+ "An image's alternative text differs between the documents. Image markers (![alt](docx-image:N)) are read-only projections, so this difference cannot be expressed as a text edit \u2014 it was skipped; update the image or its alt text manually in Word."
2817
+ );
2818
+ warned = true;
2819
+ }
2820
+ }
2821
+ return kept;
2822
+ }
2746
2823
  function _rows_are_plain(text, table) {
2747
2824
  for (const row of table.rows) {
2748
2825
  if (text.substring(row.start, row.end) !== row.cells.join(" | ")) {
@@ -2884,8 +2961,12 @@ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod)
2884
2961
  warnings.push(
2885
2962
  `The documents have different part layouts (${kinds_o.join(" + ") || "none"} vs ${kinds_m.join(" + ") || "none"}); comparing flattened text instead. Header/footer additions or removals cannot be expressed as text edits.`
2886
2963
  );
2887
- const flat = _drop_image_marker_hunks(
2888
- generate_edits_from_text(text_orig, text_mod),
2964
+ const flat = _drop_marker_interior_hunks(
2965
+ _drop_image_marker_hunks(
2966
+ generate_edits_from_text(text_orig, text_mod),
2967
+ warnings
2968
+ ),
2969
+ text_orig,
2889
2970
  warnings
2890
2971
  );
2891
2972
  return { edits: [...flat], warnings };
@@ -2989,12 +3070,63 @@ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod)
2989
3070
  const modify_edits = edits.filter(
2990
3071
  (e) => e.type === "modify"
2991
3072
  );
2992
- const kept_modifies = new Set(_drop_image_marker_hunks(modify_edits, warnings));
3073
+ const kept_modifies = new Set(
3074
+ _drop_marker_interior_hunks(
3075
+ _drop_image_marker_hunks(modify_edits, warnings),
3076
+ text_orig,
3077
+ warnings
3078
+ )
3079
+ );
2993
3080
  const final_edits = edits.filter(
2994
3081
  (e) => e.type !== "modify" || kept_modifies.has(e)
2995
3082
  );
2996
3083
  return { edits: final_edits, warnings };
2997
3084
  }
3085
+ function collect_media_difference_warnings(original_docx, modified_docx) {
3086
+ const media_hashes = (data) => {
3087
+ const hashes = /* @__PURE__ */ new Map();
3088
+ try {
3089
+ const unzipped = (0, import_fflate2.unzipSync)(data);
3090
+ for (const [name, bytes] of Object.entries(unzipped)) {
3091
+ if (!name.startsWith("word/media/")) continue;
3092
+ let h1 = 2166136261;
3093
+ let h2 = 3421674724;
3094
+ for (let i = 0; i < bytes.length; i++) {
3095
+ h1 = Math.imul(h1 ^ bytes[i], 16777619) >>> 0;
3096
+ h2 = Math.imul(h2 ^ bytes[bytes.length - 1 - i], 16777619) >>> 0;
3097
+ }
3098
+ hashes.set(name, `${bytes.length}:${h1.toString(16)}:${h2.toString(16)}`);
3099
+ }
3100
+ } catch {
3101
+ return /* @__PURE__ */ new Map();
3102
+ }
3103
+ return hashes;
3104
+ };
3105
+ const hashes_orig = media_hashes(original_docx);
3106
+ const hashes_mod = media_hashes(modified_docx);
3107
+ const changed = [];
3108
+ const added = [];
3109
+ const removed = [];
3110
+ for (const [name, hash] of hashes_orig) {
3111
+ if (!hashes_mod.has(name)) removed.push(name);
3112
+ else if (hashes_mod.get(name) !== hash) changed.push(name);
3113
+ }
3114
+ for (const name of hashes_mod.keys()) {
3115
+ if (!hashes_orig.has(name)) added.push(name);
3116
+ }
3117
+ changed.sort();
3118
+ added.sort();
3119
+ removed.sort();
3120
+ if (changed.length + added.length + removed.length === 0) return [];
3121
+ const parts = [];
3122
+ if (changed.length) parts.push(`${changed.length} changed`);
3123
+ if (added.length) parts.push(`${added.length} added`);
3124
+ if (removed.length) parts.push(`${removed.length} removed`);
3125
+ const names = [...changed, ...added, ...removed].slice(0, 5).join(", ");
3126
+ return [
3127
+ `The documents' embedded media differ (${parts.join(", ")}: ${names}). This diff compares TEXT only \u2014 an empty edit list does not mean the documents are visually identical. Image changes must be applied manually in Word.`
3128
+ ];
3129
+ }
2998
3130
  function create_unified_diff(original_text, modified_text, context_lines = 3) {
2999
3131
  const dmp = new import_diff_match_patch.default.diff_match_patch();
3000
3132
  dmp.Diff_Timeout = 2;
@@ -3949,6 +4081,13 @@ var RedlineEngine = class _RedlineEngine {
3949
4081
  console.error("generate_edits_from_text failed, falling back to wholesale edit", e);
3950
4082
  raw_sub_edits = [];
3951
4083
  }
4084
+ const _marker_only = (text) => {
4085
+ const stripped = text.trim();
4086
+ return stripped.length > 0 && /^[*_]+$/.test(stripped);
4087
+ };
4088
+ raw_sub_edits = raw_sub_edits.filter(
4089
+ (e) => !((!e.target_text || _marker_only(e.target_text)) && (!e.new_text || _marker_only(e.new_text)) && (e.target_text || e.new_text))
4090
+ );
3952
4091
  if (!raw_sub_edits || raw_sub_edits.length === 0) {
3953
4092
  const fallback_edit = {
3954
4093
  type: "modify",
@@ -4729,7 +4868,7 @@ var RedlineEngine = class _RedlineEngine {
4729
4868
  *
4730
4869
  * Does NOT attach comments; callers handle that.
4731
4870
  */
4732
- _track_insert_multiline(text, anchor_run, anchor_paragraph, reuse_id, positional_anchor_el = null) {
4871
+ _track_insert_multiline(text, anchor_run, anchor_paragraph, reuse_id, positional_anchor_el = null, suppress_emphasis = false, insert_before = false) {
4733
4872
  if (!text) {
4734
4873
  return {
4735
4874
  first_node: null,
@@ -4751,7 +4890,11 @@ var RedlineEngine = class _RedlineEngine {
4751
4890
  current_p = walker;
4752
4891
  }
4753
4892
  const suffix_nodes = [];
4754
- const pos_source = positional_anchor_el && positional_anchor_el.parentNode ? positional_anchor_el : anchor_run !== null && anchor_run._element.parentNode ? anchor_run._element : null;
4893
+ const relocatable = /* @__PURE__ */ new Set(["w:r", "w:ins", "w:del"]);
4894
+ const pos_from_positional = positional_anchor_el && positional_anchor_el.parentNode ? positional_anchor_el : null;
4895
+ const pos_from_anchor_run = anchor_run !== null && anchor_run._element.parentNode ? anchor_run._element : null;
4896
+ const pos_source = pos_from_positional ?? pos_from_anchor_run;
4897
+ const suffix_includes_anchor = insert_before && pos_from_positional === null && pos_from_anchor_run !== null;
4755
4898
  if (current_p !== null && pos_source !== null) {
4756
4899
  let pos_anchor = pos_source;
4757
4900
  while (pos_anchor && pos_anchor.parentNode !== current_p) {
@@ -4762,8 +4905,7 @@ var RedlineEngine = class _RedlineEngine {
4762
4905
  }
4763
4906
  }
4764
4907
  if (pos_anchor) {
4765
- const relocatable = /* @__PURE__ */ new Set(["w:r", "w:ins", "w:del"]);
4766
- let nxt = pos_anchor.nextSibling;
4908
+ let nxt = suffix_includes_anchor ? pos_anchor : pos_anchor.nextSibling;
4767
4909
  while (nxt) {
4768
4910
  if (nxt.nodeType === 1 && relocatable.has(nxt.tagName)) {
4769
4911
  suffix_nodes.push(nxt);
@@ -4771,6 +4913,14 @@ var RedlineEngine = class _RedlineEngine {
4771
4913
  nxt = nxt.nextSibling;
4772
4914
  }
4773
4915
  }
4916
+ } else if (current_p !== null && insert_before) {
4917
+ let child = current_p.firstChild;
4918
+ while (child) {
4919
+ if (child.nodeType === 1 && relocatable.has(child.tagName)) {
4920
+ suffix_nodes.push(child);
4921
+ }
4922
+ child = child.nextSibling;
4923
+ }
4774
4924
  }
4775
4925
  while (lines.length > 1 && lines[lines.length - 1] === "" && suffix_nodes.length === 0) {
4776
4926
  lines.pop();
@@ -4793,7 +4943,8 @@ var RedlineEngine = class _RedlineEngine {
4793
4943
  first_clean === lines[0] ? lines[0] : lines[0],
4794
4944
  anchor_run,
4795
4945
  reuse_id,
4796
- xmlDoc
4946
+ xmlDoc,
4947
+ suppress_emphasis
4797
4948
  );
4798
4949
  first_node = inline_ins;
4799
4950
  }
@@ -4857,7 +5008,8 @@ var RedlineEngine = class _RedlineEngine {
4857
5008
  clean_text,
4858
5009
  anchor_run,
4859
5010
  reuse_id,
4860
- xmlDoc
5011
+ xmlDoc,
5012
+ suppress_emphasis
4861
5013
  );
4862
5014
  if (content_ins) {
4863
5015
  new_p.appendChild(content_ins);
@@ -4883,7 +5035,7 @@ var RedlineEngine = class _RedlineEngine {
4883
5035
  * <w:r> elements representing the inline markdown segments of `line_text`.
4884
5036
  * Returns null if line_text is empty.
4885
5037
  */
4886
- _build_tracked_ins_for_line(line_text, anchor_run, reuse_id, xmlDoc) {
5038
+ _build_tracked_ins_for_line(line_text, anchor_run, reuse_id, xmlDoc, suppress_emphasis = false) {
4887
5039
  if (!line_text && line_text !== "") return null;
4888
5040
  const ins = this._create_track_change_tag("w:ins", "", reuse_id);
4889
5041
  const segments = this._parse_inline_markdown(line_text);
@@ -4896,15 +5048,11 @@ var RedlineEngine = class _RedlineEngine {
4896
5048
  const anchor_rPr = findChild(anchor_run._element, "w:rPr");
4897
5049
  if (anchor_rPr) {
4898
5050
  const clone = anchor_rPr.cloneNode(true);
4899
- for (const tag of [
4900
- "w:vanish",
4901
- "w:strike",
4902
- "w:dstrike",
4903
- "w:i",
4904
- "w:iCs",
4905
- "w:b",
4906
- "w:bCs"
4907
- ]) {
5051
+ const strip_tags = ["w:vanish", "w:strike", "w:dstrike", "w:i", "w:iCs"];
5052
+ if (suppress_emphasis) {
5053
+ strip_tags.push("w:b", "w:bCs");
5054
+ }
5055
+ for (const tag of strip_tags) {
4908
5056
  const found = findChild(clone, tag);
4909
5057
  if (found) clone.removeChild(found);
4910
5058
  }
@@ -4939,6 +5087,27 @@ var RedlineEngine = class _RedlineEngine {
4939
5087
  }
4940
5088
  return [text, null];
4941
5089
  }
5090
+ /**
5091
+ * True when this edit's target or replacement text carries explicit
5092
+ * bold/italic markers, making the markers AUTHORITATIVE for the inserted
5093
+ * runs' formatting. Replacing `**X**` with `_X_` must yield italic-only
5094
+ * text, and replacing `**X**` with `X` must yield plain text — inheriting
5095
+ * the replaced span's run properties on top of (or instead of) the
5096
+ * requested markers silently produces the wrong document while the report
5097
+ * claims success (QA 2026-07-19 F-02). Plain-text edits (no markers on
5098
+ * either side) keep inheriting the context style so partial replacements
5099
+ * inside a styled span never lose formatting.
5100
+ */
5101
+ _edit_declares_emphasis(edit) {
5102
+ for (const text of [edit?.target_text, edit?.new_text]) {
5103
+ if (!text || !text.includes("**") && !text.includes("_")) continue;
5104
+ const segments = this._parse_inline_markdown(text);
5105
+ if (segments.some(([, props]) => props && Object.keys(props).length > 0)) {
5106
+ return true;
5107
+ }
5108
+ }
5109
+ return false;
5110
+ }
4942
5111
  _parse_inline_markdown(text, baseStyle = {}) {
4943
5112
  if (!text) return [];
4944
5113
  const tokenPattern = /(\*\*.*?\*\*)|(_.*?_)/;
@@ -5205,6 +5374,16 @@ var RedlineEngine = class _RedlineEngine {
5205
5374
  continue;
5206
5375
  const is_regex = edit.regex || false;
5207
5376
  const match_mode = edit.match_mode || "strict";
5377
+ if (is_regex) {
5378
+ try {
5379
+ new RegExp(edit.target_text);
5380
+ } catch (regex_err) {
5381
+ errors.push(
5382
+ `- Edit ${i + 1 + index_offset} Failed: target_text is not a valid regular expression (${regex_err?.message ?? regex_err}). Fix the pattern, or set "regex": false to match the text literally.`
5383
+ );
5384
+ continue;
5385
+ }
5386
+ }
5208
5387
  let matches = this.mapper.find_all_match_indices(
5209
5388
  edit.target_text,
5210
5389
  is_regex
@@ -5480,6 +5659,45 @@ var RedlineEngine = class _RedlineEngine {
5480
5659
  }
5481
5660
  validate_review_actions(actions) {
5482
5661
  const errors = [];
5662
+ const seen_resolutions = /* @__PURE__ */ new Map();
5663
+ const seen_replies = /* @__PURE__ */ new Set();
5664
+ for (let i = 0; i < actions.length; i++) {
5665
+ const action = actions[i];
5666
+ const type = action.type;
5667
+ const target_id = action.target_id ?? "";
5668
+ if (type === "reply") {
5669
+ if (!String(action.text ?? "").trim()) {
5670
+ errors.push(
5671
+ `- Action ${i + 1} Failed: reply text for ${target_id} is empty or whitespace-only. Word would show a blank comment bubble \u2014 provide the reply content in 'text'.`
5672
+ );
5673
+ continue;
5674
+ }
5675
+ const reply_key = `${target_id}\0${String(action.text).trim()}`;
5676
+ if (seen_replies.has(reply_key)) {
5677
+ errors.push(
5678
+ `- Action ${i + 1} Failed: duplicate reply \u2014 this batch already replies to ${target_id} with the same text. Remove the duplicate action.`
5679
+ );
5680
+ }
5681
+ seen_replies.add(reply_key);
5682
+ } else if (type === "accept" || type === "reject") {
5683
+ const prior = seen_resolutions.get(target_id);
5684
+ if (prior !== void 0) {
5685
+ const [first_idx, first_type] = prior;
5686
+ if (first_type === type) {
5687
+ errors.push(
5688
+ `- Action ${i + 1} Failed: duplicate action \u2014 Action ${first_idx + 1} in this batch already applies '${type}' to ${target_id}. A change can only be resolved once; remove the duplicate action.`
5689
+ );
5690
+ } else {
5691
+ errors.push(
5692
+ `- Action ${i + 1} Failed: conflicting actions \u2014 Action ${first_idx + 1} in this batch applies '${first_type}' to ${target_id}, but this action applies '${type}'. Decide the outcome and keep exactly one of them.`
5693
+ );
5694
+ }
5695
+ } else {
5696
+ seen_resolutions.set(target_id, [i, type]);
5697
+ }
5698
+ }
5699
+ }
5700
+ if (errors.length > 0) return errors;
5483
5701
  for (let i = 0; i < actions.length; i++) {
5484
5702
  const action = actions[i];
5485
5703
  const type = action.type;
@@ -5784,6 +6002,14 @@ var RedlineEngine = class _RedlineEngine {
5784
6002
  actions_skipped: skipped_actions,
5785
6003
  edits_applied: applied_edits,
5786
6004
  edits_skipped: skipped_edits,
6005
+ // edits_applied counts change OBJECTS; this is the total number of
6006
+ // document occurrences they modified (match_mode="all" fan-out), so
6007
+ // automation never has to guess which of the two a count means
6008
+ // (QA 2026-07-19 F-21).
6009
+ occurrences_modified: edits_reports.reduce(
6010
+ (sum, r) => sum + (r.occurrences_modified || 0),
6011
+ 0
6012
+ ),
5787
6013
  skipped_details: this.skipped_details,
5788
6014
  edits: edits_reports,
5789
6015
  engine: "node",
@@ -5805,6 +6031,7 @@ var RedlineEngine = class _RedlineEngine {
5805
6031
  }
5806
6032
  edit._applied_status = false;
5807
6033
  edit._error_msg = null;
6034
+ edit._any_sub_failure = false;
5808
6035
  }
5809
6036
  for (const edit of edits) {
5810
6037
  if (typeof edit !== "object" || edit === null) continue;
@@ -5904,17 +6131,18 @@ var RedlineEngine = class _RedlineEngine {
5904
6131
  this.skipped_details.push(msg);
5905
6132
  edit._applied_status = false;
5906
6133
  edit._error_msg = msg;
6134
+ edit._any_sub_failure = true;
5907
6135
  const parent = edit._parent_edit_ref;
5908
6136
  if (parent) {
5909
6137
  parent._applied_status = false;
5910
6138
  parent._error_msg = msg;
6139
+ parent._any_sub_failure = true;
5911
6140
  }
5912
6141
  continue;
5913
6142
  }
5914
6143
  let success = false;
5915
6144
  if (edit.type === "modify") {
5916
- const rebuild = edit._split_group_id !== void 0 && edit._split_group_id !== null;
5917
- success = this._apply_single_edit_indexed(edit, orig_new, rebuild);
6145
+ success = this._apply_single_edit_indexed(edit, orig_new, false);
5918
6146
  } else if (edit.type === "insert_row" || edit.type === "delete_row") {
5919
6147
  success = this._apply_table_edit(edit, false);
5920
6148
  }
@@ -5964,8 +6192,10 @@ var RedlineEngine = class _RedlineEngine {
5964
6192
  this.skipped_details.push(msg);
5965
6193
  edit._applied_status = false;
5966
6194
  edit._error_msg = msg;
6195
+ edit._any_sub_failure = true;
5967
6196
  const parent = edit._parent_edit_ref;
5968
6197
  if (parent) {
6198
+ parent._any_sub_failure = true;
5969
6199
  if (!parent._applied_status) {
5970
6200
  parent._applied_status = false;
5971
6201
  parent._error_msg = msg;
@@ -5973,7 +6203,20 @@ var RedlineEngine = class _RedlineEngine {
5973
6203
  }
5974
6204
  }
5975
6205
  }
5976
- return [applied, skipped];
6206
+ let applied_logical = 0;
6207
+ let skipped_logical = 0;
6208
+ for (const input_edit of edits) {
6209
+ if (typeof input_edit !== "object" || input_edit === null) {
6210
+ skipped_logical++;
6211
+ continue;
6212
+ }
6213
+ if (input_edit._applied_status && !input_edit._any_sub_failure) {
6214
+ applied_logical++;
6215
+ } else {
6216
+ skipped_logical++;
6217
+ }
6218
+ }
6219
+ return [applied_logical, skipped_logical];
5977
6220
  }
5978
6221
  apply_review_actions(actions) {
5979
6222
  let applied = 0;
@@ -6466,6 +6709,7 @@ var RedlineEngine = class _RedlineEngine {
6466
6709
  op = "MODIFICATION";
6467
6710
  }
6468
6711
  }
6712
+ const suppress_emphasis = this._edit_declares_emphasis(edit);
6469
6713
  if (op === "STYLE_ONLY" || op === "STYLE_AND_TEXT") {
6470
6714
  const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
6471
6715
  start_idx,
@@ -6666,7 +6910,8 @@ var RedlineEngine = class _RedlineEngine {
6666
6910
  clean_text,
6667
6911
  anchor_run,
6668
6912
  ins_id,
6669
- xmlDoc
6913
+ xmlDoc,
6914
+ suppress_emphasis
6670
6915
  );
6671
6916
  if (content_ins) new_p.appendChild(content_ins);
6672
6917
  body.insertBefore(new_p, _bug233_target_para);
@@ -6698,7 +6943,12 @@ var RedlineEngine = class _RedlineEngine {
6698
6943
  final_new_text,
6699
6944
  anchor_run,
6700
6945
  anchor_para,
6701
- ins_id
6946
+ ins_id,
6947
+ null,
6948
+ suppress_emphasis,
6949
+ // Paragraph-start insertions attach BEFORE the anchor (see
6950
+ // before_anchor below): the suffix relocation must know.
6951
+ start_idx === 0
6702
6952
  );
6703
6953
  if (!result.first_node) return false;
6704
6954
  const is_inline_first = result.first_node.tagName === "w:ins";
@@ -6849,7 +7099,8 @@ var RedlineEngine = class _RedlineEngine {
6849
7099
  ins_id,
6850
7100
  // The insertion physically follows the deletion block; the style
6851
7101
  // run was detached when the deletion cloned it into <w:del>.
6852
- last_del
7102
+ last_del,
7103
+ suppress_emphasis
6853
7104
  );
6854
7105
  if (result.first_node) {
6855
7106
  const is_inline_first = result.first_node.tagName === "w:ins";
@@ -6883,7 +7134,9 @@ var RedlineEngine = class _RedlineEngine {
6883
7134
  edit.new_text,
6884
7135
  anchor,
6885
7136
  first_span.paragraph,
6886
- ins_id
7137
+ ins_id,
7138
+ null,
7139
+ suppress_emphasis
6887
7140
  );
6888
7141
  if (result.first_node) {
6889
7142
  p1_el.appendChild(result.first_node);
@@ -7367,6 +7620,26 @@ function normalize_change_dates(doc) {
7367
7620
  }
7368
7621
  return count ? [`Track change timestamps: ${count} normalized`] : [];
7369
7622
  }
7623
+ function normalize_comment_dates(doc) {
7624
+ let count = 0;
7625
+ const fixed = "2025-01-01T00:00:00Z";
7626
+ for (const part of doc.pkg.parts) {
7627
+ if (!part.contentType.includes("comments")) continue;
7628
+ for (const el of findAllDescendants(part._element, "w:comment")) {
7629
+ if (el.hasAttribute("w:date")) {
7630
+ el.setAttribute("w:date", fixed);
7631
+ count++;
7632
+ }
7633
+ }
7634
+ for (const el of findAllDescendants(part._element, "w16cex:commentExtensible")) {
7635
+ if (el.hasAttribute("w16cex:dateUtc")) {
7636
+ el.setAttribute("w16cex:dateUtc", fixed);
7637
+ count++;
7638
+ }
7639
+ }
7640
+ }
7641
+ return count ? [`Comment timestamps: ${count} normalized`] : [];
7642
+ }
7370
7643
  function scrub_doc_properties(doc) {
7371
7644
  const lines = [];
7372
7645
  const corePart = doc.pkg.getPartByPath("docProps/core.xml");
@@ -8110,8 +8383,10 @@ function build_paragraph_text(paragraph, comments_map, cleanView, style_cache, d
8110
8383
  const new_wrappers = cleanView ? ["", ""] : _get_wrappers(active_ins, active_del, active_comments, active_fmt);
8111
8384
  const new_style = [prefix, suffix];
8112
8385
  if (pending_text && new_wrappers[0] === current_wrappers[0] && new_wrappers[1] === current_wrappers[1]) {
8113
- if (new_style[0] === current_style[0] && new_style[1] === current_style[1] && current_style[0] !== "" && pending_text.endsWith(current_style[1]) && seg.startsWith(new_style[0])) {
8114
- pending_text = pending_text.slice(0, -current_style[1].length) + seg.slice(new_style[0].length);
8386
+ const escaped_prefix = new_style[0].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8387
+ const lead_match = new_style[0] !== "" || new_style[1] !== "" ? seg.match(new RegExp("^(\\s*)" + escaped_prefix)) : null;
8388
+ if (new_style[0] === current_style[0] && new_style[1] === current_style[1] && current_style[0] !== "" && pending_text.endsWith(current_style[1]) && lead_match !== null) {
8389
+ pending_text = pending_text.slice(0, -current_style[1].length) + lead_match[1] + seg.slice(lead_match[0].length);
8115
8390
  } else {
8116
8391
  pending_text += seg;
8117
8392
  }
@@ -8878,7 +9153,7 @@ function _collect_footnote_ids_fast(owned_items) {
8878
9153
  }
8879
9154
 
8880
9155
  // src/sanitize/core.ts
8881
- var import_fflate2 = require("fflate");
9156
+ var import_fflate3 = require("fflate");
8882
9157
 
8883
9158
  // src/sanitize/report.ts
8884
9159
  var SanitizeReport = class {
@@ -9033,6 +9308,7 @@ async function finalize_document(doc, options) {
9033
9308
  for (const w of warnings) report.warnings.push(w);
9034
9309
  for (const w of detect_watermarks(doc)) report.warnings.push(w);
9035
9310
  report.add_transform_lines(normalize_change_dates(doc));
9311
+ report.add_transform_lines(normalize_comment_dates(doc));
9036
9312
  if (options.protection_mode === "read_only" || options.protection_mode === "encrypt") {
9037
9313
  if (options.protection_mode === "encrypt") {
9038
9314
  report.warnings.push("Encryption mode (AES compound wrappers) is strictly unsupported in the zero-dependency Node engine. Falling back to native Word Read-Only lock.");
@@ -9091,7 +9367,7 @@ var VERIFIED_CORE_FIELDS = [
9091
9367
  ["version", "version (cp:version)"]
9092
9368
  ];
9093
9369
  function verifySanitizedPackage(outBuffer) {
9094
- const unzipped = (0, import_fflate2.unzipSync)(new Uint8Array(outBuffer));
9370
+ const unzipped = (0, import_fflate3.unzipSync)(new Uint8Array(outBuffer));
9095
9371
  const names = Object.keys(unzipped);
9096
9372
  const problems = [];
9097
9373
  if (names.includes("docProps/custom.xml")) {
@@ -9101,7 +9377,7 @@ function verifySanitizedPackage(outBuffer) {
9101
9377
  problems.push("customXml/* parts are still in the package");
9102
9378
  }
9103
9379
  if (names.includes("docProps/core.xml")) {
9104
- const core = parseXml((0, import_fflate2.strFromU8)(unzipped["docProps/core.xml"]));
9380
+ const core = parseXml((0, import_fflate3.strFromU8)(unzipped["docProps/core.xml"]));
9105
9381
  for (const [local, label] of VERIFIED_CORE_FIELDS) {
9106
9382
  for (const el of findDescendantsByLocalName(core.documentElement, local)) {
9107
9383
  if ((el.textContent || "").trim()) {
@@ -9131,6 +9407,7 @@ function identifyEngine() {
9131
9407
  USER_PATTERN_TIMEOUT_MS,
9132
9408
  _extractTextFromDoc,
9133
9409
  apply_edits_to_markdown,
9410
+ collect_media_difference_warnings,
9134
9411
  create_unified_diff,
9135
9412
  create_word_patch_diff,
9136
9413
  describe_illegal_control_chars,