@adeu/core 1.23.0 → 1.26.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
@@ -1228,12 +1228,27 @@ function get_run_style_markers(run, is_heading = null) {
1228
1228
  }
1229
1229
  return [prefix, suffix];
1230
1230
  }
1231
+ function split_boundary_whitespace(text) {
1232
+ const core = text.trim();
1233
+ if (!core) return ["", "", text];
1234
+ const lead_len = text.length - text.trimStart().length;
1235
+ return [
1236
+ text.substring(0, lead_len),
1237
+ text.substring(lead_len, lead_len + core.length),
1238
+ text.substring(lead_len + core.length)
1239
+ ];
1240
+ }
1231
1241
  function apply_formatting_to_segments(text, prefix, suffix) {
1232
1242
  if (!prefix && !suffix) return text;
1233
1243
  if (!text) return "";
1234
- if (!text.includes("\n")) return `${prefix}${text}${suffix}`;
1244
+ const wrap = (segment) => {
1245
+ const [lead, core, trail] = split_boundary_whitespace(segment);
1246
+ if (!core) return segment;
1247
+ return `${lead}${prefix}${core}${suffix}${trail}`;
1248
+ };
1249
+ if (!text.includes("\n")) return wrap(text);
1235
1250
  const parts = text.split("\n");
1236
- return parts.map((p) => p ? `${prefix}${p}${suffix}` : "").join("\n");
1251
+ return parts.map((p) => p ? wrap(p) : "").join("\n");
1237
1252
  }
1238
1253
  function get_run_text(run) {
1239
1254
  let text = "";
@@ -1686,7 +1701,7 @@ ${header}`;
1686
1701
  this._add_virtual_text(s_tok, current, paragraph);
1687
1702
  current += s_tok.length;
1688
1703
  }
1689
- for (const [kind, txt, r_obj, i_id, d_id, c_ids] of pending_runs) {
1704
+ for (const [kind, txt, r_obj, r_off, i_id, d_id, c_ids] of pending_runs) {
1690
1705
  if (kind === "virtual") {
1691
1706
  this._add_virtual_text(txt, current, paragraph, active_hyperlink_id);
1692
1707
  } else {
@@ -1700,7 +1715,8 @@ ${header}`;
1700
1715
  del_id: d_id || void 0,
1701
1716
  hyperlink_id: active_hyperlink_id || void 0,
1702
1717
  comment_ids: c_ids.length > 0 ? c_ids : void 0,
1703
- part_index: this._current_part_index
1718
+ part_index: this._current_part_index,
1719
+ run_offset: r_off
1704
1720
  };
1705
1721
  this.spans.push(s);
1706
1722
  this._text_chunks.push(txt);
@@ -1735,20 +1751,42 @@ ${header}`;
1735
1751
  if (text === "" || /^\s*$/.test(text)) continue;
1736
1752
  leading_strip_active = false;
1737
1753
  }
1754
+ let run_local = 0;
1755
+ const append_wrapped = (segment) => {
1756
+ const [lead, core, trail] = split_boundary_whitespace(segment);
1757
+ if (!core) {
1758
+ run_parts.push(["real", segment, item, run_local]);
1759
+ run_local += segment.length;
1760
+ return;
1761
+ }
1762
+ if (lead) {
1763
+ run_parts.push(["real", lead, item, run_local]);
1764
+ run_local += lead.length;
1765
+ }
1766
+ if (prefix) run_parts.push(["virtual", prefix, null, 0]);
1767
+ run_parts.push(["real", core, item, run_local]);
1768
+ run_local += core.length;
1769
+ if (suffix) run_parts.push(["virtual", suffix, null, 0]);
1770
+ if (trail) {
1771
+ run_parts.push(["real", trail, item, run_local]);
1772
+ run_local += trail.length;
1773
+ }
1774
+ };
1738
1775
  if (text.includes("\n") && (prefix || suffix)) {
1739
1776
  const parts = text.split("\n");
1740
1777
  for (let idx = 0; idx < parts.length; idx++) {
1741
- if (idx > 0) run_parts.push(["real", "\n", item]);
1742
- if (parts[idx]) {
1743
- if (prefix) run_parts.push(["virtual", prefix, null]);
1744
- run_parts.push(["real", parts[idx], item]);
1745
- if (suffix) run_parts.push(["virtual", suffix, null]);
1778
+ if (idx > 0) {
1779
+ run_parts.push(["real", "\n", item, run_local]);
1780
+ run_local += 1;
1746
1781
  }
1782
+ if (parts[idx]) append_wrapped(parts[idx]);
1747
1783
  }
1784
+ } else if ((prefix || suffix) && text) {
1785
+ append_wrapped(text);
1748
1786
  } else {
1749
- if (prefix) run_parts.push(["virtual", prefix, null]);
1750
- if (text) run_parts.push(["real", text, item]);
1751
- if (suffix) run_parts.push(["virtual", suffix, null]);
1787
+ if (prefix) run_parts.push(["virtual", prefix, null, 0]);
1788
+ if (text) run_parts.push(["real", text, item, 0]);
1789
+ if (suffix) run_parts.push(["virtual", suffix, null, 0]);
1752
1790
  }
1753
1791
  if (this.clean_view && Object.keys(active_del).length > 0) {
1754
1792
  }
@@ -1772,7 +1810,7 @@ ${header}`;
1772
1810
  skip_leading_prefix = true;
1773
1811
  }
1774
1812
  const curr_comment_ids = Array.from(active_ids);
1775
- for (const [kind, txt, r_obj] of run_parts) {
1813
+ for (const [kind, txt, r_obj, r_off] of run_parts) {
1776
1814
  if (skip_leading_prefix && kind === "virtual" && txt === new_style[0]) {
1777
1815
  skip_leading_prefix = false;
1778
1816
  continue;
@@ -1781,6 +1819,7 @@ ${header}`;
1781
1819
  kind,
1782
1820
  txt,
1783
1821
  r_obj,
1822
+ r_off,
1784
1823
  curr_ins_id,
1785
1824
  curr_del_id,
1786
1825
  curr_comment_ids
@@ -1792,11 +1831,12 @@ ${header}`;
1792
1831
  current_wrappers = new_wrappers;
1793
1832
  current_style = new_style;
1794
1833
  const curr_comment_ids = Array.from(active_ids);
1795
- for (const [kind, txt, r_obj] of run_parts) {
1834
+ for (const [kind, txt, r_obj, r_off] of run_parts) {
1796
1835
  pending_runs.push([
1797
1836
  kind,
1798
1837
  txt,
1799
1838
  r_obj,
1839
+ r_off,
1800
1840
  curr_ins_id,
1801
1841
  curr_del_id,
1802
1842
  curr_comment_ids
@@ -2208,39 +2248,43 @@ ${header}`;
2208
2248
  (s) => s.end > start_idx && s.start < end_idx
2209
2249
  );
2210
2250
  if (affected_spans.length === 0) return [];
2211
- const working_runs = affected_spans.filter((s) => s.run !== null).map((s) => s.run);
2212
- if (working_runs.length === 0) return [];
2213
- let dom_modified = false;
2214
- const first_real_span = affected_spans.find((s) => s.run !== null);
2215
- let start_split_adjustment = 0;
2216
- if (first_real_span) {
2217
- const local_start = start_idx - first_real_span.start;
2218
- if (local_start > 0) {
2219
- const idx_in_working = 0;
2220
- const [, right_run] = this._split_run_at_index(
2221
- working_runs[idx_in_working],
2222
- local_start
2223
- );
2224
- working_runs[idx_in_working] = right_run;
2225
- dom_modified = true;
2226
- start_split_adjustment = local_start;
2251
+ const real_spans = affected_spans.filter((s) => s.run !== null);
2252
+ if (real_spans.length === 0) return [];
2253
+ const working_runs = [];
2254
+ for (const s of real_spans) {
2255
+ if (!working_runs.some((r) => r === s.run)) {
2256
+ working_runs.push(s.run);
2227
2257
  }
2228
2258
  }
2229
- const last_real_span = [...affected_spans].reverse().find((s) => s.run !== null);
2230
- if (last_real_span) {
2231
- const is_same_run = first_real_span === last_real_span;
2232
- const run_to_split = working_runs[working_runs.length - 1];
2233
- let overlap_end = Math.min(last_real_span.end, end_idx);
2234
- let local_end = overlap_end - last_real_span.start;
2235
- if (is_same_run && start_split_adjustment > 0) {
2236
- local_end -= start_split_adjustment;
2237
- }
2238
- const run_text = get_run_text(run_to_split);
2239
- if (local_end > 0 && local_end < run_text.length) {
2240
- const [left_run] = this._split_run_at_index(run_to_split, local_end);
2241
- working_runs[working_runs.length - 1] = left_run;
2242
- dom_modified = true;
2243
- }
2259
+ let dom_modified = false;
2260
+ const first_real_span = real_spans[0];
2261
+ let start_split_adjustment = 0;
2262
+ const local_start = start_idx - first_real_span.start + (first_real_span.run_offset || 0);
2263
+ if (local_start > 0) {
2264
+ const split_source = working_runs[0];
2265
+ const [, right_run] = this._split_run_at_index(
2266
+ split_source,
2267
+ local_start
2268
+ );
2269
+ for (let w = 0; w < working_runs.length; w++) {
2270
+ if (working_runs[w] === split_source) working_runs[w] = right_run;
2271
+ }
2272
+ dom_modified = true;
2273
+ start_split_adjustment = local_start;
2274
+ }
2275
+ const last_real_span = real_spans[real_spans.length - 1];
2276
+ const is_same_run = first_real_span.run === last_real_span.run;
2277
+ const run_to_split = working_runs[working_runs.length - 1];
2278
+ const overlap_end = Math.min(last_real_span.end, end_idx);
2279
+ let local_end = overlap_end - last_real_span.start + (last_real_span.run_offset || 0);
2280
+ if (is_same_run && start_split_adjustment > 0) {
2281
+ local_end -= start_split_adjustment;
2282
+ }
2283
+ const run_text = get_run_text(run_to_split);
2284
+ if (local_end > 0 && local_end < run_text.length) {
2285
+ const [left_run] = this._split_run_at_index(run_to_split, local_end);
2286
+ working_runs[working_runs.length - 1] = left_run;
2287
+ dom_modified = true;
2244
2288
  }
2245
2289
  if (dom_modified && rebuild_map) {
2246
2290
  this._build_map();
@@ -2277,7 +2321,7 @@ ${header}`;
2277
2321
  }
2278
2322
  return [null, span.paragraph];
2279
2323
  } else {
2280
- const offset = index - span.start;
2324
+ const offset = index - span.start + (span.run_offset || 0);
2281
2325
  const [left] = this._split_run_at_index(span.run, offset);
2282
2326
  if (rebuild_map) this._build_map();
2283
2327
  return [left, span.paragraph];
@@ -2348,6 +2392,7 @@ ${header}`;
2348
2392
  };
2349
2393
 
2350
2394
  // src/diff.ts
2395
+ import { unzipSync as unzipSync2 } from "fflate";
2351
2396
  import diff_match_patch from "diff-match-patch";
2352
2397
  function _count_standalone_underscores(s) {
2353
2398
  let count = 0;
@@ -2685,6 +2730,37 @@ function _drop_image_marker_hunks(edits, warnings) {
2685
2730
  }
2686
2731
  return kept;
2687
2732
  }
2733
+ function _drop_marker_interior_hunks(edits, text_orig, warnings) {
2734
+ const marker_spans = [];
2735
+ for (const m of text_orig.matchAll(_IMAGE_MARKER_RE)) {
2736
+ marker_spans.push([m.index, m.index + m[0].length]);
2737
+ }
2738
+ if (marker_spans.length === 0) return edits;
2739
+ const kept = [];
2740
+ let warned = false;
2741
+ for (const e of edits) {
2742
+ const idx = e._match_start_index;
2743
+ if (idx === void 0 || idx === null) {
2744
+ kept.push(e);
2745
+ continue;
2746
+ }
2747
+ const end = idx + (e.target_text || "").length;
2748
+ const cuts_into_marker = marker_spans.some(
2749
+ ([m_start, m_end]) => m_start < end && idx < m_end && !(idx <= m_start && m_end <= end)
2750
+ );
2751
+ if (!cuts_into_marker) {
2752
+ kept.push(e);
2753
+ continue;
2754
+ }
2755
+ if (!warned) {
2756
+ warnings.push(
2757
+ "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."
2758
+ );
2759
+ warned = true;
2760
+ }
2761
+ }
2762
+ return kept;
2763
+ }
2688
2764
  function _rows_are_plain(text, table) {
2689
2765
  for (const row of table.rows) {
2690
2766
  if (text.substring(row.start, row.end) !== row.cells.join(" | ")) {
@@ -2787,7 +2863,8 @@ function _row_ops_for_table(table_o, table_m, opcodes, warnings) {
2787
2863
  type: "modify",
2788
2864
  target_text: o_txt,
2789
2865
  new_text: m_txt,
2790
- comment: "Diff: Table row modified"
2866
+ comment: "Diff: Table row modified",
2867
+ _is_table_edit: true
2791
2868
  });
2792
2869
  }
2793
2870
  }
@@ -2825,8 +2902,12 @@ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod)
2825
2902
  warnings.push(
2826
2903
  `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.`
2827
2904
  );
2828
- const flat = _drop_image_marker_hunks(
2829
- generate_edits_from_text(text_orig, text_mod),
2905
+ const flat = _drop_marker_interior_hunks(
2906
+ _drop_image_marker_hunks(
2907
+ generate_edits_from_text(text_orig, text_mod),
2908
+ warnings
2909
+ ),
2910
+ text_orig,
2830
2911
  warnings
2831
2912
  );
2832
2913
  return { edits: [...flat], warnings };
@@ -2889,14 +2970,18 @@ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod)
2889
2970
  if (row_opcodes !== null) {
2890
2971
  edits.push(..._row_ops_for_table(t_o, t_m, row_opcodes, warnings));
2891
2972
  } else {
2892
- const tbl_edits = generate_edits_from_text(
2893
- text_orig.substring(t_o.start, t_o.end),
2894
- text_mod.substring(t_m.start, t_m.end)
2895
- );
2896
- for (const e of tbl_edits) {
2897
- e._match_start_index = (e._match_start_index || 0) + t_o.start;
2973
+ for (let k = 0; k < t_o.rows.length; k++) {
2974
+ const o_txt = t_o.rows[k].cells.join(" | ");
2975
+ const m_txt = t_m.rows[k].cells.join(" | ");
2976
+ if (o_txt === m_txt) continue;
2977
+ edits.push({
2978
+ type: "modify",
2979
+ target_text: o_txt,
2980
+ new_text: m_txt,
2981
+ comment: "Diff: Table row modified",
2982
+ _is_table_edit: true
2983
+ });
2898
2984
  }
2899
- edits.push(...tbl_edits);
2900
2985
  }
2901
2986
  }
2902
2987
  }
@@ -2914,7 +2999,7 @@ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod)
2914
2999
  };
2915
3000
  let ambiguous_anchor_warned = false;
2916
3001
  for (const e of edits) {
2917
- if ((e.type === "insert_row" || e.type === "delete_row") && !ambiguous_anchor_warned) {
3002
+ if ((e.type === "insert_row" || e.type === "delete_row" || e._is_table_edit) && !ambiguous_anchor_warned) {
2918
3003
  if (e.target_text && countOccurrences(text_orig, e.target_text) > 1) {
2919
3004
  warnings.push(
2920
3005
  `The row anchor "${e.target_text.substring(0, 60)}" appears more than once in the document. Applying this diff from its JSON output may be rejected as ambiguous \u2014 make the anchor rows unique, or apply the row changes with explicit insert_row/delete_row edits.`
@@ -2926,12 +3011,63 @@ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod)
2926
3011
  const modify_edits = edits.filter(
2927
3012
  (e) => e.type === "modify"
2928
3013
  );
2929
- const kept_modifies = new Set(_drop_image_marker_hunks(modify_edits, warnings));
3014
+ const kept_modifies = new Set(
3015
+ _drop_marker_interior_hunks(
3016
+ _drop_image_marker_hunks(modify_edits, warnings),
3017
+ text_orig,
3018
+ warnings
3019
+ )
3020
+ );
2930
3021
  const final_edits = edits.filter(
2931
3022
  (e) => e.type !== "modify" || kept_modifies.has(e)
2932
3023
  );
2933
3024
  return { edits: final_edits, warnings };
2934
3025
  }
3026
+ function collect_media_difference_warnings(original_docx, modified_docx) {
3027
+ const media_hashes = (data) => {
3028
+ const hashes = /* @__PURE__ */ new Map();
3029
+ try {
3030
+ const unzipped = unzipSync2(data);
3031
+ for (const [name, bytes] of Object.entries(unzipped)) {
3032
+ if (!name.startsWith("word/media/")) continue;
3033
+ let h1 = 2166136261;
3034
+ let h2 = 3421674724;
3035
+ for (let i = 0; i < bytes.length; i++) {
3036
+ h1 = Math.imul(h1 ^ bytes[i], 16777619) >>> 0;
3037
+ h2 = Math.imul(h2 ^ bytes[bytes.length - 1 - i], 16777619) >>> 0;
3038
+ }
3039
+ hashes.set(name, `${bytes.length}:${h1.toString(16)}:${h2.toString(16)}`);
3040
+ }
3041
+ } catch {
3042
+ return /* @__PURE__ */ new Map();
3043
+ }
3044
+ return hashes;
3045
+ };
3046
+ const hashes_orig = media_hashes(original_docx);
3047
+ const hashes_mod = media_hashes(modified_docx);
3048
+ const changed = [];
3049
+ const added = [];
3050
+ const removed = [];
3051
+ for (const [name, hash] of hashes_orig) {
3052
+ if (!hashes_mod.has(name)) removed.push(name);
3053
+ else if (hashes_mod.get(name) !== hash) changed.push(name);
3054
+ }
3055
+ for (const name of hashes_mod.keys()) {
3056
+ if (!hashes_orig.has(name)) added.push(name);
3057
+ }
3058
+ changed.sort();
3059
+ added.sort();
3060
+ removed.sort();
3061
+ if (changed.length + added.length + removed.length === 0) return [];
3062
+ const parts = [];
3063
+ if (changed.length) parts.push(`${changed.length} changed`);
3064
+ if (added.length) parts.push(`${added.length} added`);
3065
+ if (removed.length) parts.push(`${removed.length} removed`);
3066
+ const names = [...changed, ...added, ...removed].slice(0, 5).join(", ");
3067
+ return [
3068
+ `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.`
3069
+ ];
3070
+ }
2935
3071
  function create_unified_diff(original_text, modified_text, context_lines = 3) {
2936
3072
  const dmp = new diff_match_patch.diff_match_patch();
2937
3073
  dmp.Diff_Timeout = 2;
@@ -3886,6 +4022,13 @@ var RedlineEngine = class _RedlineEngine {
3886
4022
  console.error("generate_edits_from_text failed, falling back to wholesale edit", e);
3887
4023
  raw_sub_edits = [];
3888
4024
  }
4025
+ const _marker_only = (text) => {
4026
+ const stripped = text.trim();
4027
+ return stripped.length > 0 && /^[*_]+$/.test(stripped);
4028
+ };
4029
+ raw_sub_edits = raw_sub_edits.filter(
4030
+ (e) => !((!e.target_text || _marker_only(e.target_text)) && (!e.new_text || _marker_only(e.new_text)) && (e.target_text || e.new_text))
4031
+ );
3889
4032
  if (!raw_sub_edits || raw_sub_edits.length === 0) {
3890
4033
  const fallback_edit = {
3891
4034
  type: "modify",
@@ -4666,7 +4809,7 @@ var RedlineEngine = class _RedlineEngine {
4666
4809
  *
4667
4810
  * Does NOT attach comments; callers handle that.
4668
4811
  */
4669
- _track_insert_multiline(text, anchor_run, anchor_paragraph, reuse_id) {
4812
+ _track_insert_multiline(text, anchor_run, anchor_paragraph, reuse_id, positional_anchor_el = null, suppress_emphasis = false) {
4670
4813
  if (!text) {
4671
4814
  return {
4672
4815
  first_node: null,
@@ -4687,7 +4830,29 @@ var RedlineEngine = class _RedlineEngine {
4687
4830
  }
4688
4831
  current_p = walker;
4689
4832
  }
4690
- while (lines.length > 1 && lines[lines.length - 1] === "") {
4833
+ const suffix_nodes = [];
4834
+ const pos_source = positional_anchor_el && positional_anchor_el.parentNode ? positional_anchor_el : anchor_run !== null && anchor_run._element.parentNode ? anchor_run._element : null;
4835
+ if (current_p !== null && pos_source !== null) {
4836
+ let pos_anchor = pos_source;
4837
+ while (pos_anchor && pos_anchor.parentNode !== current_p) {
4838
+ pos_anchor = pos_anchor.parentNode;
4839
+ if (pos_anchor === current_p) {
4840
+ pos_anchor = null;
4841
+ break;
4842
+ }
4843
+ }
4844
+ if (pos_anchor) {
4845
+ const relocatable = /* @__PURE__ */ new Set(["w:r", "w:ins", "w:del"]);
4846
+ let nxt = pos_anchor.nextSibling;
4847
+ while (nxt) {
4848
+ if (nxt.nodeType === 1 && relocatable.has(nxt.tagName)) {
4849
+ suffix_nodes.push(nxt);
4850
+ }
4851
+ nxt = nxt.nextSibling;
4852
+ }
4853
+ }
4854
+ }
4855
+ while (lines.length > 1 && lines[lines.length - 1] === "" && suffix_nodes.length === 0) {
4691
4856
  lines.pop();
4692
4857
  }
4693
4858
  if (lines.length === 0) {
@@ -4708,7 +4873,8 @@ var RedlineEngine = class _RedlineEngine {
4708
4873
  first_clean === lines[0] ? lines[0] : lines[0],
4709
4874
  anchor_run,
4710
4875
  reuse_id,
4711
- xmlDoc
4876
+ xmlDoc,
4877
+ suppress_emphasis
4712
4878
  );
4713
4879
  first_node = inline_ins;
4714
4880
  }
@@ -4772,7 +4938,8 @@ var RedlineEngine = class _RedlineEngine {
4772
4938
  clean_text,
4773
4939
  anchor_run,
4774
4940
  reuse_id,
4775
- xmlDoc
4941
+ xmlDoc,
4942
+ suppress_emphasis
4776
4943
  );
4777
4944
  if (content_ins) {
4778
4945
  new_p.appendChild(content_ins);
@@ -4785,6 +4952,12 @@ var RedlineEngine = class _RedlineEngine {
4785
4952
  first_node = new_p;
4786
4953
  }
4787
4954
  }
4955
+ if (!block_mode && last_p && suffix_nodes.length > 0) {
4956
+ for (const node of suffix_nodes) {
4957
+ node.parentNode?.removeChild(node);
4958
+ last_p.appendChild(node);
4959
+ }
4960
+ }
4788
4961
  return { first_node, last_p, last_ins, used_block_mode: block_mode };
4789
4962
  }
4790
4963
  /**
@@ -4792,7 +4965,7 @@ var RedlineEngine = class _RedlineEngine {
4792
4965
  * <w:r> elements representing the inline markdown segments of `line_text`.
4793
4966
  * Returns null if line_text is empty.
4794
4967
  */
4795
- _build_tracked_ins_for_line(line_text, anchor_run, reuse_id, xmlDoc) {
4968
+ _build_tracked_ins_for_line(line_text, anchor_run, reuse_id, xmlDoc, suppress_emphasis = false) {
4796
4969
  if (!line_text && line_text !== "") return null;
4797
4970
  const ins = this._create_track_change_tag("w:ins", "", reuse_id);
4798
4971
  const segments = this._parse_inline_markdown(line_text);
@@ -4805,15 +4978,11 @@ var RedlineEngine = class _RedlineEngine {
4805
4978
  const anchor_rPr = findChild(anchor_run._element, "w:rPr");
4806
4979
  if (anchor_rPr) {
4807
4980
  const clone = anchor_rPr.cloneNode(true);
4808
- for (const tag of [
4809
- "w:vanish",
4810
- "w:strike",
4811
- "w:dstrike",
4812
- "w:i",
4813
- "w:iCs",
4814
- "w:b",
4815
- "w:bCs"
4816
- ]) {
4981
+ const strip_tags = ["w:vanish", "w:strike", "w:dstrike", "w:i", "w:iCs"];
4982
+ if (suppress_emphasis) {
4983
+ strip_tags.push("w:b", "w:bCs");
4984
+ }
4985
+ for (const tag of strip_tags) {
4817
4986
  const found = findChild(clone, tag);
4818
4987
  if (found) clone.removeChild(found);
4819
4988
  }
@@ -4842,12 +5011,33 @@ var RedlineEngine = class _RedlineEngine {
4842
5011
  if (stripped_text.startsWith("* ") || stripped_text.startsWith("- ")) {
4843
5012
  return [stripped_text.substring(2).trim(), "List Paragraph"];
4844
5013
  }
4845
- const match = stripped_text.match(/^\d+\.\s+/);
5014
+ const match = stripped_text.match(/^1\.\s+/);
4846
5015
  if (match) {
4847
5016
  return [stripped_text.substring(match[0].length).trim(), "List Number"];
4848
5017
  }
4849
5018
  return [text, null];
4850
5019
  }
5020
+ /**
5021
+ * True when this edit's target or replacement text carries explicit
5022
+ * bold/italic markers, making the markers AUTHORITATIVE for the inserted
5023
+ * runs' formatting. Replacing `**X**` with `_X_` must yield italic-only
5024
+ * text, and replacing `**X**` with `X` must yield plain text — inheriting
5025
+ * the replaced span's run properties on top of (or instead of) the
5026
+ * requested markers silently produces the wrong document while the report
5027
+ * claims success (QA 2026-07-19 F-02). Plain-text edits (no markers on
5028
+ * either side) keep inheriting the context style so partial replacements
5029
+ * inside a styled span never lose formatting.
5030
+ */
5031
+ _edit_declares_emphasis(edit) {
5032
+ for (const text of [edit?.target_text, edit?.new_text]) {
5033
+ if (!text || !text.includes("**") && !text.includes("_")) continue;
5034
+ const segments = this._parse_inline_markdown(text);
5035
+ if (segments.some(([, props]) => props && Object.keys(props).length > 0)) {
5036
+ return true;
5037
+ }
5038
+ }
5039
+ return false;
5040
+ }
4851
5041
  _parse_inline_markdown(text, baseStyle = {}) {
4852
5042
  if (!text) return [];
4853
5043
  const tokenPattern = /(\*\*.*?\*\*)|(_.*?_)/;
@@ -5114,6 +5304,16 @@ var RedlineEngine = class _RedlineEngine {
5114
5304
  continue;
5115
5305
  const is_regex = edit.regex || false;
5116
5306
  const match_mode = edit.match_mode || "strict";
5307
+ if (is_regex) {
5308
+ try {
5309
+ new RegExp(edit.target_text);
5310
+ } catch (regex_err) {
5311
+ errors.push(
5312
+ `- 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.`
5313
+ );
5314
+ continue;
5315
+ }
5316
+ }
5117
5317
  let matches = this.mapper.find_all_match_indices(
5118
5318
  edit.target_text,
5119
5319
  is_regex
@@ -5693,6 +5893,14 @@ var RedlineEngine = class _RedlineEngine {
5693
5893
  actions_skipped: skipped_actions,
5694
5894
  edits_applied: applied_edits,
5695
5895
  edits_skipped: skipped_edits,
5896
+ // edits_applied counts change OBJECTS; this is the total number of
5897
+ // document occurrences they modified (match_mode="all" fan-out), so
5898
+ // automation never has to guess which of the two a count means
5899
+ // (QA 2026-07-19 F-21).
5900
+ occurrences_modified: edits_reports.reduce(
5901
+ (sum, r) => sum + (r.occurrences_modified || 0),
5902
+ 0
5903
+ ),
5696
5904
  skipped_details: this.skipped_details,
5697
5905
  edits: edits_reports,
5698
5906
  engine: "node",
@@ -5714,6 +5922,7 @@ var RedlineEngine = class _RedlineEngine {
5714
5922
  }
5715
5923
  edit._applied_status = false;
5716
5924
  edit._error_msg = null;
5925
+ edit._any_sub_failure = false;
5717
5926
  }
5718
5927
  for (const edit of edits) {
5719
5928
  if (typeof edit !== "object" || edit === null) continue;
@@ -5724,14 +5933,17 @@ var RedlineEngine = class _RedlineEngine {
5724
5933
  resolved_edits.push([edit, edit.new_text || null]);
5725
5934
  } else if (edit.type === "insert_row" || edit.type === "delete_row") {
5726
5935
  let matches = this.mapper.find_all_match_indices(edit.target_text);
5936
+ let resolved_mapper = this.mapper;
5727
5937
  if (matches.length === 0) {
5728
5938
  if (!this.clean_mapper) {
5729
5939
  this.clean_mapper = new DocumentMapper(this.doc, true);
5730
5940
  }
5731
5941
  matches = this.clean_mapper.find_all_match_indices(edit.target_text);
5942
+ resolved_mapper = this.clean_mapper;
5732
5943
  }
5733
5944
  if (matches.length > 0) {
5734
5945
  edit._resolved_start_idx = matches[0][0];
5946
+ edit._active_mapper_ref = resolved_mapper;
5735
5947
  resolved_edits.push([edit, null]);
5736
5948
  } else {
5737
5949
  skipped++;
@@ -5810,17 +6022,18 @@ var RedlineEngine = class _RedlineEngine {
5810
6022
  this.skipped_details.push(msg);
5811
6023
  edit._applied_status = false;
5812
6024
  edit._error_msg = msg;
6025
+ edit._any_sub_failure = true;
5813
6026
  const parent = edit._parent_edit_ref;
5814
6027
  if (parent) {
5815
6028
  parent._applied_status = false;
5816
6029
  parent._error_msg = msg;
6030
+ parent._any_sub_failure = true;
5817
6031
  }
5818
6032
  continue;
5819
6033
  }
5820
6034
  let success = false;
5821
6035
  if (edit.type === "modify") {
5822
- const rebuild = edit._split_group_id !== void 0 && edit._split_group_id !== null;
5823
- success = this._apply_single_edit_indexed(edit, orig_new, rebuild);
6036
+ success = this._apply_single_edit_indexed(edit, orig_new, false);
5824
6037
  } else if (edit.type === "insert_row" || edit.type === "delete_row") {
5825
6038
  success = this._apply_table_edit(edit, false);
5826
6039
  }
@@ -5870,8 +6083,10 @@ var RedlineEngine = class _RedlineEngine {
5870
6083
  this.skipped_details.push(msg);
5871
6084
  edit._applied_status = false;
5872
6085
  edit._error_msg = msg;
6086
+ edit._any_sub_failure = true;
5873
6087
  const parent = edit._parent_edit_ref;
5874
6088
  if (parent) {
6089
+ parent._any_sub_failure = true;
5875
6090
  if (!parent._applied_status) {
5876
6091
  parent._applied_status = false;
5877
6092
  parent._error_msg = msg;
@@ -5879,7 +6094,20 @@ var RedlineEngine = class _RedlineEngine {
5879
6094
  }
5880
6095
  }
5881
6096
  }
5882
- return [applied, skipped];
6097
+ let applied_logical = 0;
6098
+ let skipped_logical = 0;
6099
+ for (const input_edit of edits) {
6100
+ if (typeof input_edit !== "object" || input_edit === null) {
6101
+ skipped_logical++;
6102
+ continue;
6103
+ }
6104
+ if (input_edit._applied_status && !input_edit._any_sub_failure) {
6105
+ applied_logical++;
6106
+ } else {
6107
+ skipped_logical++;
6108
+ }
6109
+ }
6110
+ return [applied_logical, skipped_logical];
5883
6111
  }
5884
6112
  apply_review_actions(actions) {
5885
6113
  let applied = 0;
@@ -5980,7 +6208,8 @@ var RedlineEngine = class _RedlineEngine {
5980
6208
  }
5981
6209
  _apply_table_edit(edit, rebuild_map) {
5982
6210
  const start_idx = edit._resolved_start_idx !== void 0 && edit._resolved_start_idx !== null ? edit._resolved_start_idx : edit._match_start_index || 0;
5983
- const [anchor_run, anchor_para] = this.mapper.get_insertion_anchor(
6211
+ const active_mapper = edit._active_mapper_ref || this.mapper;
6212
+ const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
5984
6213
  start_idx,
5985
6214
  rebuild_map
5986
6215
  );
@@ -6161,9 +6390,14 @@ var RedlineEngine = class _RedlineEngine {
6161
6390
  if (overlaps_virtual_pipe) {
6162
6391
  const actual_cells = actual_doc_text.split("|");
6163
6392
  const new_cells = current_effective_new_text.split("|");
6164
- if (actual_cells.length === new_cells.length && actual_cells.length > 1) {
6393
+ if (actual_cells.length !== new_cells.length) {
6394
+ throw new BatchValidationError([
6395
+ `Target text spans ${actual_cells.length} table cells, but replacement provides ${new_cells.length}. To modify text without altering table structure (rows or columns), ensure the replacement contains the exact same number of '|' separators (e.g., replace with 'CellC | ' to empty the second cell).`
6396
+ ]);
6397
+ }
6398
+ if (actual_cells.length > 1) {
6165
6399
  const sub_edits2 = [];
6166
- let search_offset = start_idx;
6400
+ let cell_start_in_target = 0;
6167
6401
  let target_comment_idx = 0;
6168
6402
  for (let idx = 0; idx < actual_cells.length; idx++) {
6169
6403
  if (actual_cells[idx].trim() !== new_cells[idx].trim()) {
@@ -6176,13 +6410,7 @@ var RedlineEngine = class _RedlineEngine {
6176
6410
  const n_cell = new_cells[cell_idx];
6177
6411
  const a_clean = a_cell.trim();
6178
6412
  const n_clean = n_cell.trim();
6179
- let actual_start = search_offset;
6180
- if (a_clean) {
6181
- actual_start = this.mapper.full_text.indexOf(a_clean, search_offset);
6182
- if (actual_start === -1 || actual_start > search_offset + 10) {
6183
- actual_start = search_offset;
6184
- }
6185
- }
6413
+ const actual_start = start_idx + cell_start_in_target + (a_clean ? a_cell.indexOf(a_clean) : 0);
6186
6414
  const should_attach_comment = edit.comment !== null && edit.comment !== void 0 && cell_idx === target_comment_idx;
6187
6415
  if (a_clean !== n_clean || should_attach_comment) {
6188
6416
  const cell_sub_edits = this._word_diff_sub_edits(
@@ -6199,24 +6427,12 @@ var RedlineEngine = class _RedlineEngine {
6199
6427
  sub_edits2.push(se);
6200
6428
  }
6201
6429
  }
6202
- if (a_clean) {
6203
- search_offset = actual_start + a_clean.length;
6204
- }
6205
- const next_pipe = this.mapper.full_text.indexOf(" | ", search_offset);
6206
- if (next_pipe !== -1 && next_pipe <= search_offset + 10) {
6207
- search_offset = next_pipe + 3;
6208
- } else {
6209
- search_offset += a_cell.length + 1;
6210
- }
6430
+ cell_start_in_target += a_cell.length + 1;
6211
6431
  }
6212
6432
  for (const sub of sub_edits2) {
6213
6433
  all_sub_edits.push(sub);
6214
6434
  }
6215
6435
  continue;
6216
- } else {
6217
- throw new BatchValidationError([
6218
- `Target text spans ${actual_cells.length} table cells, but replacement provides ${new_cells.length}. To modify text without altering table structure (rows or columns), ensure the replacement contains the exact same number of '|' separators (e.g., replace with 'CellC | ' to empty the second cell).`
6219
- ]);
6220
6436
  }
6221
6437
  }
6222
6438
  let has_markdown = false;
@@ -6310,6 +6526,19 @@ var RedlineEngine = class _RedlineEngine {
6310
6526
  continue;
6311
6527
  }
6312
6528
  }
6529
+ if (!final_target && final_new) {
6530
+ all_sub_edits.push({
6531
+ type: "modify",
6532
+ target_text: "",
6533
+ new_text: final_new,
6534
+ comment: edit.comment,
6535
+ _resolved_start_idx: effective_start_idx,
6536
+ _match_start_index: effective_start_idx,
6537
+ _internal_op: "INSERTION",
6538
+ _active_mapper_ref: active_mapper
6539
+ });
6540
+ continue;
6541
+ }
6313
6542
  const sub_edits = this._word_diff_sub_edits(
6314
6543
  actual_doc_text,
6315
6544
  current_effective_new_text,
@@ -6371,6 +6600,7 @@ var RedlineEngine = class _RedlineEngine {
6371
6600
  op = "MODIFICATION";
6372
6601
  }
6373
6602
  }
6603
+ const suppress_emphasis = this._edit_declares_emphasis(edit);
6374
6604
  if (op === "STYLE_ONLY" || op === "STYLE_AND_TEXT") {
6375
6605
  const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
6376
6606
  start_idx,
@@ -6571,7 +6801,8 @@ var RedlineEngine = class _RedlineEngine {
6571
6801
  clean_text,
6572
6802
  anchor_run,
6573
6803
  ins_id,
6574
- xmlDoc
6804
+ xmlDoc,
6805
+ suppress_emphasis
6575
6806
  );
6576
6807
  if (content_ins) new_p.appendChild(content_ins);
6577
6808
  body.insertBefore(new_p, _bug233_target_para);
@@ -6603,7 +6834,9 @@ var RedlineEngine = class _RedlineEngine {
6603
6834
  final_new_text,
6604
6835
  anchor_run,
6605
6836
  anchor_para,
6606
- ins_id
6837
+ ins_id,
6838
+ null,
6839
+ suppress_emphasis
6607
6840
  );
6608
6841
  if (!result.first_node) return false;
6609
6842
  const is_inline_first = result.first_node.tagName === "w:ins";
@@ -6751,7 +6984,11 @@ var RedlineEngine = class _RedlineEngine {
6751
6984
  edit.new_text,
6752
6985
  style_source_run,
6753
6986
  mod_anchor_para,
6754
- ins_id
6987
+ ins_id,
6988
+ // The insertion physically follows the deletion block; the style
6989
+ // run was detached when the deletion cloned it into <w:del>.
6990
+ last_del,
6991
+ suppress_emphasis
6755
6992
  );
6756
6993
  if (result.first_node) {
6757
6994
  const is_inline_first = result.first_node.tagName === "w:ins";
@@ -6785,7 +7022,9 @@ var RedlineEngine = class _RedlineEngine {
6785
7022
  edit.new_text,
6786
7023
  anchor,
6787
7024
  first_span.paragraph,
6788
- ins_id
7025
+ ins_id,
7026
+ null,
7027
+ suppress_emphasis
6789
7028
  );
6790
7029
  if (result.first_node) {
6791
7030
  p1_el.appendChild(result.first_node);
@@ -7269,6 +7508,26 @@ function normalize_change_dates(doc) {
7269
7508
  }
7270
7509
  return count ? [`Track change timestamps: ${count} normalized`] : [];
7271
7510
  }
7511
+ function normalize_comment_dates(doc) {
7512
+ let count = 0;
7513
+ const fixed = "2025-01-01T00:00:00Z";
7514
+ for (const part of doc.pkg.parts) {
7515
+ if (!part.contentType.includes("comments")) continue;
7516
+ for (const el of findAllDescendants(part._element, "w:comment")) {
7517
+ if (el.hasAttribute("w:date")) {
7518
+ el.setAttribute("w:date", fixed);
7519
+ count++;
7520
+ }
7521
+ }
7522
+ for (const el of findAllDescendants(part._element, "w16cex:commentExtensible")) {
7523
+ if (el.hasAttribute("w16cex:dateUtc")) {
7524
+ el.setAttribute("w16cex:dateUtc", fixed);
7525
+ count++;
7526
+ }
7527
+ }
7528
+ }
7529
+ return count ? [`Comment timestamps: ${count} normalized`] : [];
7530
+ }
7272
7531
  function scrub_doc_properties(doc) {
7273
7532
  const lines = [];
7274
7533
  const corePart = doc.pkg.getPartByPath("docProps/core.xml");
@@ -7298,7 +7557,10 @@ function scrub_doc_properties(doc) {
7298
7557
  ["keywords", "Keywords"],
7299
7558
  ["subject", "Subject"],
7300
7559
  ["contentStatus", "Content status"],
7301
- ["description", "Description/comments"]
7560
+ ["description", "Description/comments"],
7561
+ ["identifier", "Identifier"],
7562
+ ["language", "Language"],
7563
+ ["version", "Version"]
7302
7564
  ];
7303
7565
  for (const [local, label] of leakFields) {
7304
7566
  findDescendantsByLocalName(corePart._element, local).forEach((c) => {
@@ -7356,27 +7618,73 @@ function scrub_timestamps(doc) {
7356
7618
  }
7357
7619
  return modified ? ["Timestamps normalized to epoch"] : [];
7358
7620
  }
7359
- function strip_custom_xml(doc) {
7360
- const customParts = doc.pkg.parts.filter((p) => p.partname.includes("/customXml"));
7361
- if (customParts.length === 0) return [];
7362
- const partnames = new Set(customParts.map((p) => p.partname));
7363
- doc.pkg.parts = doc.pkg.parts.filter((p) => !partnames.has(p.partname));
7364
- const removeRelationsTo = (relsPart) => {
7621
+ function ejectPackageMembers(doc, matcher, relTargetMatcher) {
7622
+ const pkg = doc.pkg;
7623
+ const normalized = (p) => p.startsWith("/") ? p.substring(1) : p;
7624
+ for (const part of pkg.parts) {
7625
+ if (!part.partname.endsWith(".rels")) continue;
7365
7626
  const toRemove = [];
7366
- for (const rel of findAllDescendants(relsPart._element, "Relationship")) {
7367
- const target = rel.getAttribute("Target");
7368
- if (target && target.includes("customXml")) toRemove.push(rel);
7627
+ for (const rel of findAllDescendants(part._element, "Relationship")) {
7628
+ const target = rel.getAttribute("Target") || "";
7629
+ if (relTargetMatcher(target)) {
7630
+ toRemove.push(rel);
7631
+ const sourcePath = part.partname.replace("/_rels/", "/").replace(".rels", "");
7632
+ const sourcePart = pkg.getPartByPath(sourcePath);
7633
+ if (sourcePart) {
7634
+ const relId = rel.getAttribute("Id");
7635
+ if (relId) sourcePart.rels.delete(relId);
7636
+ }
7637
+ }
7369
7638
  }
7370
7639
  toRemove.forEach((r) => r.parentNode?.removeChild(r));
7371
- };
7372
- const rootRels = doc.pkg.getPartByPath("_rels/.rels");
7373
- if (rootRels) removeRelationsTo(rootRels);
7374
- const docRels = doc.pkg.getOrCreateRelsPart(doc.part.partname);
7375
- if (docRels) removeRelationsTo(docRels);
7640
+ }
7641
+ const ctPart = pkg.getPartByPath("[Content_Types].xml");
7642
+ if (ctPart) {
7643
+ const toRemove = [];
7644
+ for (const override of findAllDescendants(ctPart._element, "Override")) {
7645
+ const partName = override.getAttribute("PartName") || "";
7646
+ if (matcher(normalized(partName))) toRemove.push(override);
7647
+ }
7648
+ toRemove.forEach((o) => o.parentNode?.removeChild(o));
7649
+ }
7650
+ pkg.parts = pkg.parts.filter((p) => !matcher(normalized(p.partname)));
7651
+ for (const key of Object.keys(pkg.unzipped)) {
7652
+ if (matcher(normalized(key))) delete pkg.unzipped[key];
7653
+ }
7654
+ }
7655
+ function strip_custom_xml(doc) {
7656
+ const isCustomXml = (path) => path.startsWith("customXml/");
7657
+ const customParts = doc.pkg.parts.filter(
7658
+ (p) => isCustomXml(p.partname.startsWith("/") ? p.partname.substring(1) : p.partname)
7659
+ );
7660
+ const leakedMembers = Object.keys(doc.pkg.unzipped).filter(isCustomXml);
7661
+ if (customParts.length === 0 && leakedMembers.length === 0) return [];
7662
+ ejectPackageMembers(doc, isCustomXml, (target) => target.includes("customXml"));
7376
7663
  for (const sdtPr of findAllDescendants(doc.element, "w:sdtPr")) {
7377
7664
  findChildren(sdtPr, "w:dataBinding").forEach((b) => sdtPr.removeChild(b));
7378
7665
  }
7379
- return [`Custom XML parts: ${customParts.length} removed`];
7666
+ return [`Custom XML parts: ${Math.max(customParts.length, leakedMembers.length)} removed`];
7667
+ }
7668
+ var CUSTOM_PROPS_PATH = "docProps/custom.xml";
7669
+ function strip_custom_properties(doc) {
7670
+ const part = doc.pkg.getPartByPath(CUSTOM_PROPS_PATH);
7671
+ const leaked = Object.keys(doc.pkg.unzipped).includes(CUSTOM_PROPS_PATH);
7672
+ if (!part && !leaked) return [];
7673
+ const lines = [];
7674
+ if (part) {
7675
+ for (const prop of findDescendantsByLocalName(part._element, "property")) {
7676
+ const name = prop.getAttribute("name") || "(unnamed)";
7677
+ const value = (prop.textContent || "").trim();
7678
+ lines.push(` Custom property removed: ${name} = "${_truncate(value, 60)}"`);
7679
+ }
7680
+ }
7681
+ ejectPackageMembers(
7682
+ doc,
7683
+ (path) => path === CUSTOM_PROPS_PATH,
7684
+ (target) => target.includes("docProps/custom.xml")
7685
+ );
7686
+ const count = Math.max(lines.length, 1);
7687
+ return [`Custom document properties: ${count} removed (docProps/custom.xml)`].concat(lines);
7380
7688
  }
7381
7689
  function strip_image_alt_text(doc) {
7382
7690
  let count = 0;
@@ -7963,8 +8271,10 @@ function build_paragraph_text(paragraph, comments_map, cleanView, style_cache, d
7963
8271
  const new_wrappers = cleanView ? ["", ""] : _get_wrappers(active_ins, active_del, active_comments, active_fmt);
7964
8272
  const new_style = [prefix, suffix];
7965
8273
  if (pending_text && new_wrappers[0] === current_wrappers[0] && new_wrappers[1] === current_wrappers[1]) {
7966
- 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])) {
7967
- pending_text = pending_text.slice(0, -current_style[1].length) + seg.slice(new_style[0].length);
8274
+ const escaped_prefix = new_style[0].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8275
+ const lead_match = new_style[0] !== "" || new_style[1] !== "" ? seg.match(new RegExp("^(\\s*)" + escaped_prefix)) : null;
8276
+ 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) {
8277
+ pending_text = pending_text.slice(0, -current_style[1].length) + lead_match[1] + seg.slice(lead_match[0].length);
7968
8278
  } else {
7969
8279
  pending_text += seg;
7970
8280
  }
@@ -8730,6 +9040,9 @@ function _collect_footnote_ids_fast(owned_items) {
8730
9040
  return ordered;
8731
9041
  }
8732
9042
 
9043
+ // src/sanitize/core.ts
9044
+ import { strFromU8 as strFromU82, unzipSync as unzipSync3 } from "fflate";
9045
+
8733
9046
  // src/sanitize/report.ts
8734
9047
  var SanitizeReport = class {
8735
9048
  filename;
@@ -8764,7 +9077,7 @@ var SanitizeReport = class {
8764
9077
  } else {
8765
9078
  this.removed_comment_lines.push(line);
8766
9079
  }
8767
- } 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("last modified by") || lower.includes("revision count") || lower.includes("last printed")) {
9080
+ } 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")) {
8768
9081
  this.metadata_lines.push(line);
8769
9082
  } else if (lower.includes("hyperlink") || lower.includes("warning")) {
8770
9083
  this.warnings.push(line);
@@ -8877,11 +9190,13 @@ async function finalize_document(doc, options) {
8877
9190
  report.add_transform_lines(scrub_doc_properties(doc));
8878
9191
  report.add_transform_lines(scrub_timestamps(doc));
8879
9192
  report.add_transform_lines(strip_custom_xml(doc));
9193
+ report.add_transform_lines(strip_custom_properties(doc));
8880
9194
  report.add_transform_lines(strip_image_alt_text(doc));
8881
9195
  const warnings = audit_hyperlinks(doc);
8882
9196
  for (const w of warnings) report.warnings.push(w);
8883
9197
  for (const w of detect_watermarks(doc)) report.warnings.push(w);
8884
9198
  report.add_transform_lines(normalize_change_dates(doc));
9199
+ report.add_transform_lines(normalize_comment_dates(doc));
8885
9200
  if (options.protection_mode === "read_only" || options.protection_mode === "encrypt") {
8886
9201
  if (options.protection_mode === "encrypt") {
8887
9202
  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.");
@@ -8924,8 +9239,47 @@ async function finalize_document(doc, options) {
8924
9239
  }
8925
9240
  if (report.warnings.length > 0) report.status = "clean_with_warnings";
8926
9241
  const outBuffer = await doc.save();
9242
+ verifySanitizedPackage(outBuffer);
8927
9243
  return { reportText: report.render(), outBuffer };
8928
9244
  }
9245
+ var VERIFIED_CORE_FIELDS = [
9246
+ ["creator", "author (dc:creator)"],
9247
+ ["lastModifiedBy", "last modified by (cp:lastModifiedBy)"],
9248
+ ["identifier", "identifier (dc:identifier)"],
9249
+ ["description", "description (dc:description)"],
9250
+ ["keywords", "keywords (cp:keywords)"],
9251
+ ["category", "category (cp:category)"],
9252
+ ["subject", "subject (dc:subject)"],
9253
+ ["contentStatus", "content status (cp:contentStatus)"],
9254
+ ["language", "language (dc:language)"],
9255
+ ["version", "version (cp:version)"]
9256
+ ];
9257
+ function verifySanitizedPackage(outBuffer) {
9258
+ const unzipped = unzipSync3(new Uint8Array(outBuffer));
9259
+ const names = Object.keys(unzipped);
9260
+ const problems = [];
9261
+ if (names.includes("docProps/custom.xml")) {
9262
+ problems.push("docProps/custom.xml (custom document properties) is still in the package");
9263
+ }
9264
+ if (names.some((n) => n.startsWith("customXml/"))) {
9265
+ problems.push("customXml/* parts are still in the package");
9266
+ }
9267
+ if (names.includes("docProps/core.xml")) {
9268
+ const core = parseXml(strFromU82(unzipped["docProps/core.xml"]));
9269
+ for (const [local, label] of VERIFIED_CORE_FIELDS) {
9270
+ for (const el of findDescendantsByLocalName(core.documentElement, local)) {
9271
+ if ((el.textContent || "").trim()) {
9272
+ problems.push(`core property ${label} still contains a value`);
9273
+ }
9274
+ }
9275
+ }
9276
+ }
9277
+ if (problems.length > 0) {
9278
+ throw new Error(
9279
+ "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."
9280
+ );
9281
+ }
9282
+ }
8929
9283
 
8930
9284
  // src/index.ts
8931
9285
  function identifyEngine() {
@@ -8940,6 +9294,7 @@ export {
8940
9294
  USER_PATTERN_TIMEOUT_MS,
8941
9295
  _extractTextFromDoc,
8942
9296
  apply_edits_to_markdown,
9297
+ collect_media_difference_warnings,
8943
9298
  create_unified_diff,
8944
9299
  create_word_patch_diff,
8945
9300
  describe_illegal_control_chars,