@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.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 = start_idx - 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(" | ")) {
@@ -2845,7 +2922,8 @@ function _row_ops_for_table(table_o, table_m, opcodes, warnings) {
2845
2922
  type: "modify",
2846
2923
  target_text: o_txt,
2847
2924
  new_text: m_txt,
2848
- comment: "Diff: Table row modified"
2925
+ comment: "Diff: Table row modified",
2926
+ _is_table_edit: true
2849
2927
  });
2850
2928
  }
2851
2929
  }
@@ -2883,8 +2961,12 @@ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod)
2883
2961
  warnings.push(
2884
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.`
2885
2963
  );
2886
- const flat = _drop_image_marker_hunks(
2887
- 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,
2888
2970
  warnings
2889
2971
  );
2890
2972
  return { edits: [...flat], warnings };
@@ -2947,14 +3029,18 @@ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod)
2947
3029
  if (row_opcodes !== null) {
2948
3030
  edits.push(..._row_ops_for_table(t_o, t_m, row_opcodes, warnings));
2949
3031
  } else {
2950
- const tbl_edits = generate_edits_from_text(
2951
- text_orig.substring(t_o.start, t_o.end),
2952
- text_mod.substring(t_m.start, t_m.end)
2953
- );
2954
- for (const e of tbl_edits) {
2955
- e._match_start_index = (e._match_start_index || 0) + t_o.start;
3032
+ for (let k = 0; k < t_o.rows.length; k++) {
3033
+ const o_txt = t_o.rows[k].cells.join(" | ");
3034
+ const m_txt = t_m.rows[k].cells.join(" | ");
3035
+ if (o_txt === m_txt) continue;
3036
+ edits.push({
3037
+ type: "modify",
3038
+ target_text: o_txt,
3039
+ new_text: m_txt,
3040
+ comment: "Diff: Table row modified",
3041
+ _is_table_edit: true
3042
+ });
2956
3043
  }
2957
- edits.push(...tbl_edits);
2958
3044
  }
2959
3045
  }
2960
3046
  }
@@ -2972,7 +3058,7 @@ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod)
2972
3058
  };
2973
3059
  let ambiguous_anchor_warned = false;
2974
3060
  for (const e of edits) {
2975
- if ((e.type === "insert_row" || e.type === "delete_row") && !ambiguous_anchor_warned) {
3061
+ if ((e.type === "insert_row" || e.type === "delete_row" || e._is_table_edit) && !ambiguous_anchor_warned) {
2976
3062
  if (e.target_text && countOccurrences(text_orig, e.target_text) > 1) {
2977
3063
  warnings.push(
2978
3064
  `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.`
@@ -2984,12 +3070,63 @@ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod)
2984
3070
  const modify_edits = edits.filter(
2985
3071
  (e) => e.type === "modify"
2986
3072
  );
2987
- 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
+ );
2988
3080
  const final_edits = edits.filter(
2989
3081
  (e) => e.type !== "modify" || kept_modifies.has(e)
2990
3082
  );
2991
3083
  return { edits: final_edits, warnings };
2992
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
+ }
2993
3130
  function create_unified_diff(original_text, modified_text, context_lines = 3) {
2994
3131
  const dmp = new import_diff_match_patch.default.diff_match_patch();
2995
3132
  dmp.Diff_Timeout = 2;
@@ -3944,6 +4081,13 @@ var RedlineEngine = class _RedlineEngine {
3944
4081
  console.error("generate_edits_from_text failed, falling back to wholesale edit", e);
3945
4082
  raw_sub_edits = [];
3946
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
+ );
3947
4091
  if (!raw_sub_edits || raw_sub_edits.length === 0) {
3948
4092
  const fallback_edit = {
3949
4093
  type: "modify",
@@ -4724,7 +4868,7 @@ var RedlineEngine = class _RedlineEngine {
4724
4868
  *
4725
4869
  * Does NOT attach comments; callers handle that.
4726
4870
  */
4727
- _track_insert_multiline(text, anchor_run, anchor_paragraph, reuse_id) {
4871
+ _track_insert_multiline(text, anchor_run, anchor_paragraph, reuse_id, positional_anchor_el = null, suppress_emphasis = false) {
4728
4872
  if (!text) {
4729
4873
  return {
4730
4874
  first_node: null,
@@ -4745,7 +4889,29 @@ var RedlineEngine = class _RedlineEngine {
4745
4889
  }
4746
4890
  current_p = walker;
4747
4891
  }
4748
- while (lines.length > 1 && lines[lines.length - 1] === "") {
4892
+ const suffix_nodes = [];
4893
+ const pos_source = positional_anchor_el && positional_anchor_el.parentNode ? positional_anchor_el : anchor_run !== null && anchor_run._element.parentNode ? anchor_run._element : null;
4894
+ if (current_p !== null && pos_source !== null) {
4895
+ let pos_anchor = pos_source;
4896
+ while (pos_anchor && pos_anchor.parentNode !== current_p) {
4897
+ pos_anchor = pos_anchor.parentNode;
4898
+ if (pos_anchor === current_p) {
4899
+ pos_anchor = null;
4900
+ break;
4901
+ }
4902
+ }
4903
+ if (pos_anchor) {
4904
+ const relocatable = /* @__PURE__ */ new Set(["w:r", "w:ins", "w:del"]);
4905
+ let nxt = pos_anchor.nextSibling;
4906
+ while (nxt) {
4907
+ if (nxt.nodeType === 1 && relocatable.has(nxt.tagName)) {
4908
+ suffix_nodes.push(nxt);
4909
+ }
4910
+ nxt = nxt.nextSibling;
4911
+ }
4912
+ }
4913
+ }
4914
+ while (lines.length > 1 && lines[lines.length - 1] === "" && suffix_nodes.length === 0) {
4749
4915
  lines.pop();
4750
4916
  }
4751
4917
  if (lines.length === 0) {
@@ -4766,7 +4932,8 @@ var RedlineEngine = class _RedlineEngine {
4766
4932
  first_clean === lines[0] ? lines[0] : lines[0],
4767
4933
  anchor_run,
4768
4934
  reuse_id,
4769
- xmlDoc
4935
+ xmlDoc,
4936
+ suppress_emphasis
4770
4937
  );
4771
4938
  first_node = inline_ins;
4772
4939
  }
@@ -4830,7 +4997,8 @@ var RedlineEngine = class _RedlineEngine {
4830
4997
  clean_text,
4831
4998
  anchor_run,
4832
4999
  reuse_id,
4833
- xmlDoc
5000
+ xmlDoc,
5001
+ suppress_emphasis
4834
5002
  );
4835
5003
  if (content_ins) {
4836
5004
  new_p.appendChild(content_ins);
@@ -4843,6 +5011,12 @@ var RedlineEngine = class _RedlineEngine {
4843
5011
  first_node = new_p;
4844
5012
  }
4845
5013
  }
5014
+ if (!block_mode && last_p && suffix_nodes.length > 0) {
5015
+ for (const node of suffix_nodes) {
5016
+ node.parentNode?.removeChild(node);
5017
+ last_p.appendChild(node);
5018
+ }
5019
+ }
4846
5020
  return { first_node, last_p, last_ins, used_block_mode: block_mode };
4847
5021
  }
4848
5022
  /**
@@ -4850,7 +5024,7 @@ var RedlineEngine = class _RedlineEngine {
4850
5024
  * <w:r> elements representing the inline markdown segments of `line_text`.
4851
5025
  * Returns null if line_text is empty.
4852
5026
  */
4853
- _build_tracked_ins_for_line(line_text, anchor_run, reuse_id, xmlDoc) {
5027
+ _build_tracked_ins_for_line(line_text, anchor_run, reuse_id, xmlDoc, suppress_emphasis = false) {
4854
5028
  if (!line_text && line_text !== "") return null;
4855
5029
  const ins = this._create_track_change_tag("w:ins", "", reuse_id);
4856
5030
  const segments = this._parse_inline_markdown(line_text);
@@ -4863,15 +5037,11 @@ var RedlineEngine = class _RedlineEngine {
4863
5037
  const anchor_rPr = findChild(anchor_run._element, "w:rPr");
4864
5038
  if (anchor_rPr) {
4865
5039
  const clone = anchor_rPr.cloneNode(true);
4866
- for (const tag of [
4867
- "w:vanish",
4868
- "w:strike",
4869
- "w:dstrike",
4870
- "w:i",
4871
- "w:iCs",
4872
- "w:b",
4873
- "w:bCs"
4874
- ]) {
5040
+ const strip_tags = ["w:vanish", "w:strike", "w:dstrike", "w:i", "w:iCs"];
5041
+ if (suppress_emphasis) {
5042
+ strip_tags.push("w:b", "w:bCs");
5043
+ }
5044
+ for (const tag of strip_tags) {
4875
5045
  const found = findChild(clone, tag);
4876
5046
  if (found) clone.removeChild(found);
4877
5047
  }
@@ -4900,12 +5070,33 @@ var RedlineEngine = class _RedlineEngine {
4900
5070
  if (stripped_text.startsWith("* ") || stripped_text.startsWith("- ")) {
4901
5071
  return [stripped_text.substring(2).trim(), "List Paragraph"];
4902
5072
  }
4903
- const match = stripped_text.match(/^\d+\.\s+/);
5073
+ const match = stripped_text.match(/^1\.\s+/);
4904
5074
  if (match) {
4905
5075
  return [stripped_text.substring(match[0].length).trim(), "List Number"];
4906
5076
  }
4907
5077
  return [text, null];
4908
5078
  }
5079
+ /**
5080
+ * True when this edit's target or replacement text carries explicit
5081
+ * bold/italic markers, making the markers AUTHORITATIVE for the inserted
5082
+ * runs' formatting. Replacing `**X**` with `_X_` must yield italic-only
5083
+ * text, and replacing `**X**` with `X` must yield plain text — inheriting
5084
+ * the replaced span's run properties on top of (or instead of) the
5085
+ * requested markers silently produces the wrong document while the report
5086
+ * claims success (QA 2026-07-19 F-02). Plain-text edits (no markers on
5087
+ * either side) keep inheriting the context style so partial replacements
5088
+ * inside a styled span never lose formatting.
5089
+ */
5090
+ _edit_declares_emphasis(edit) {
5091
+ for (const text of [edit?.target_text, edit?.new_text]) {
5092
+ if (!text || !text.includes("**") && !text.includes("_")) continue;
5093
+ const segments = this._parse_inline_markdown(text);
5094
+ if (segments.some(([, props]) => props && Object.keys(props).length > 0)) {
5095
+ return true;
5096
+ }
5097
+ }
5098
+ return false;
5099
+ }
4909
5100
  _parse_inline_markdown(text, baseStyle = {}) {
4910
5101
  if (!text) return [];
4911
5102
  const tokenPattern = /(\*\*.*?\*\*)|(_.*?_)/;
@@ -5172,6 +5363,16 @@ var RedlineEngine = class _RedlineEngine {
5172
5363
  continue;
5173
5364
  const is_regex = edit.regex || false;
5174
5365
  const match_mode = edit.match_mode || "strict";
5366
+ if (is_regex) {
5367
+ try {
5368
+ new RegExp(edit.target_text);
5369
+ } catch (regex_err) {
5370
+ errors.push(
5371
+ `- 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.`
5372
+ );
5373
+ continue;
5374
+ }
5375
+ }
5175
5376
  let matches = this.mapper.find_all_match_indices(
5176
5377
  edit.target_text,
5177
5378
  is_regex
@@ -5751,6 +5952,14 @@ var RedlineEngine = class _RedlineEngine {
5751
5952
  actions_skipped: skipped_actions,
5752
5953
  edits_applied: applied_edits,
5753
5954
  edits_skipped: skipped_edits,
5955
+ // edits_applied counts change OBJECTS; this is the total number of
5956
+ // document occurrences they modified (match_mode="all" fan-out), so
5957
+ // automation never has to guess which of the two a count means
5958
+ // (QA 2026-07-19 F-21).
5959
+ occurrences_modified: edits_reports.reduce(
5960
+ (sum, r) => sum + (r.occurrences_modified || 0),
5961
+ 0
5962
+ ),
5754
5963
  skipped_details: this.skipped_details,
5755
5964
  edits: edits_reports,
5756
5965
  engine: "node",
@@ -5772,6 +5981,7 @@ var RedlineEngine = class _RedlineEngine {
5772
5981
  }
5773
5982
  edit._applied_status = false;
5774
5983
  edit._error_msg = null;
5984
+ edit._any_sub_failure = false;
5775
5985
  }
5776
5986
  for (const edit of edits) {
5777
5987
  if (typeof edit !== "object" || edit === null) continue;
@@ -5782,14 +5992,17 @@ var RedlineEngine = class _RedlineEngine {
5782
5992
  resolved_edits.push([edit, edit.new_text || null]);
5783
5993
  } else if (edit.type === "insert_row" || edit.type === "delete_row") {
5784
5994
  let matches = this.mapper.find_all_match_indices(edit.target_text);
5995
+ let resolved_mapper = this.mapper;
5785
5996
  if (matches.length === 0) {
5786
5997
  if (!this.clean_mapper) {
5787
5998
  this.clean_mapper = new DocumentMapper(this.doc, true);
5788
5999
  }
5789
6000
  matches = this.clean_mapper.find_all_match_indices(edit.target_text);
6001
+ resolved_mapper = this.clean_mapper;
5790
6002
  }
5791
6003
  if (matches.length > 0) {
5792
6004
  edit._resolved_start_idx = matches[0][0];
6005
+ edit._active_mapper_ref = resolved_mapper;
5793
6006
  resolved_edits.push([edit, null]);
5794
6007
  } else {
5795
6008
  skipped++;
@@ -5868,17 +6081,18 @@ var RedlineEngine = class _RedlineEngine {
5868
6081
  this.skipped_details.push(msg);
5869
6082
  edit._applied_status = false;
5870
6083
  edit._error_msg = msg;
6084
+ edit._any_sub_failure = true;
5871
6085
  const parent = edit._parent_edit_ref;
5872
6086
  if (parent) {
5873
6087
  parent._applied_status = false;
5874
6088
  parent._error_msg = msg;
6089
+ parent._any_sub_failure = true;
5875
6090
  }
5876
6091
  continue;
5877
6092
  }
5878
6093
  let success = false;
5879
6094
  if (edit.type === "modify") {
5880
- const rebuild = edit._split_group_id !== void 0 && edit._split_group_id !== null;
5881
- success = this._apply_single_edit_indexed(edit, orig_new, rebuild);
6095
+ success = this._apply_single_edit_indexed(edit, orig_new, false);
5882
6096
  } else if (edit.type === "insert_row" || edit.type === "delete_row") {
5883
6097
  success = this._apply_table_edit(edit, false);
5884
6098
  }
@@ -5928,8 +6142,10 @@ var RedlineEngine = class _RedlineEngine {
5928
6142
  this.skipped_details.push(msg);
5929
6143
  edit._applied_status = false;
5930
6144
  edit._error_msg = msg;
6145
+ edit._any_sub_failure = true;
5931
6146
  const parent = edit._parent_edit_ref;
5932
6147
  if (parent) {
6148
+ parent._any_sub_failure = true;
5933
6149
  if (!parent._applied_status) {
5934
6150
  parent._applied_status = false;
5935
6151
  parent._error_msg = msg;
@@ -5937,7 +6153,20 @@ var RedlineEngine = class _RedlineEngine {
5937
6153
  }
5938
6154
  }
5939
6155
  }
5940
- return [applied, skipped];
6156
+ let applied_logical = 0;
6157
+ let skipped_logical = 0;
6158
+ for (const input_edit of edits) {
6159
+ if (typeof input_edit !== "object" || input_edit === null) {
6160
+ skipped_logical++;
6161
+ continue;
6162
+ }
6163
+ if (input_edit._applied_status && !input_edit._any_sub_failure) {
6164
+ applied_logical++;
6165
+ } else {
6166
+ skipped_logical++;
6167
+ }
6168
+ }
6169
+ return [applied_logical, skipped_logical];
5941
6170
  }
5942
6171
  apply_review_actions(actions) {
5943
6172
  let applied = 0;
@@ -6038,7 +6267,8 @@ var RedlineEngine = class _RedlineEngine {
6038
6267
  }
6039
6268
  _apply_table_edit(edit, rebuild_map) {
6040
6269
  const start_idx = edit._resolved_start_idx !== void 0 && edit._resolved_start_idx !== null ? edit._resolved_start_idx : edit._match_start_index || 0;
6041
- const [anchor_run, anchor_para] = this.mapper.get_insertion_anchor(
6270
+ const active_mapper = edit._active_mapper_ref || this.mapper;
6271
+ const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
6042
6272
  start_idx,
6043
6273
  rebuild_map
6044
6274
  );
@@ -6219,9 +6449,14 @@ var RedlineEngine = class _RedlineEngine {
6219
6449
  if (overlaps_virtual_pipe) {
6220
6450
  const actual_cells = actual_doc_text.split("|");
6221
6451
  const new_cells = current_effective_new_text.split("|");
6222
- if (actual_cells.length === new_cells.length && actual_cells.length > 1) {
6452
+ if (actual_cells.length !== new_cells.length) {
6453
+ throw new BatchValidationError([
6454
+ `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).`
6455
+ ]);
6456
+ }
6457
+ if (actual_cells.length > 1) {
6223
6458
  const sub_edits2 = [];
6224
- let search_offset = start_idx;
6459
+ let cell_start_in_target = 0;
6225
6460
  let target_comment_idx = 0;
6226
6461
  for (let idx = 0; idx < actual_cells.length; idx++) {
6227
6462
  if (actual_cells[idx].trim() !== new_cells[idx].trim()) {
@@ -6234,13 +6469,7 @@ var RedlineEngine = class _RedlineEngine {
6234
6469
  const n_cell = new_cells[cell_idx];
6235
6470
  const a_clean = a_cell.trim();
6236
6471
  const n_clean = n_cell.trim();
6237
- let actual_start = search_offset;
6238
- if (a_clean) {
6239
- actual_start = this.mapper.full_text.indexOf(a_clean, search_offset);
6240
- if (actual_start === -1 || actual_start > search_offset + 10) {
6241
- actual_start = search_offset;
6242
- }
6243
- }
6472
+ const actual_start = start_idx + cell_start_in_target + (a_clean ? a_cell.indexOf(a_clean) : 0);
6244
6473
  const should_attach_comment = edit.comment !== null && edit.comment !== void 0 && cell_idx === target_comment_idx;
6245
6474
  if (a_clean !== n_clean || should_attach_comment) {
6246
6475
  const cell_sub_edits = this._word_diff_sub_edits(
@@ -6257,24 +6486,12 @@ var RedlineEngine = class _RedlineEngine {
6257
6486
  sub_edits2.push(se);
6258
6487
  }
6259
6488
  }
6260
- if (a_clean) {
6261
- search_offset = actual_start + a_clean.length;
6262
- }
6263
- const next_pipe = this.mapper.full_text.indexOf(" | ", search_offset);
6264
- if (next_pipe !== -1 && next_pipe <= search_offset + 10) {
6265
- search_offset = next_pipe + 3;
6266
- } else {
6267
- search_offset += a_cell.length + 1;
6268
- }
6489
+ cell_start_in_target += a_cell.length + 1;
6269
6490
  }
6270
6491
  for (const sub of sub_edits2) {
6271
6492
  all_sub_edits.push(sub);
6272
6493
  }
6273
6494
  continue;
6274
- } else {
6275
- throw new BatchValidationError([
6276
- `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).`
6277
- ]);
6278
6495
  }
6279
6496
  }
6280
6497
  let has_markdown = false;
@@ -6368,6 +6585,19 @@ var RedlineEngine = class _RedlineEngine {
6368
6585
  continue;
6369
6586
  }
6370
6587
  }
6588
+ if (!final_target && final_new) {
6589
+ all_sub_edits.push({
6590
+ type: "modify",
6591
+ target_text: "",
6592
+ new_text: final_new,
6593
+ comment: edit.comment,
6594
+ _resolved_start_idx: effective_start_idx,
6595
+ _match_start_index: effective_start_idx,
6596
+ _internal_op: "INSERTION",
6597
+ _active_mapper_ref: active_mapper
6598
+ });
6599
+ continue;
6600
+ }
6371
6601
  const sub_edits = this._word_diff_sub_edits(
6372
6602
  actual_doc_text,
6373
6603
  current_effective_new_text,
@@ -6429,6 +6659,7 @@ var RedlineEngine = class _RedlineEngine {
6429
6659
  op = "MODIFICATION";
6430
6660
  }
6431
6661
  }
6662
+ const suppress_emphasis = this._edit_declares_emphasis(edit);
6432
6663
  if (op === "STYLE_ONLY" || op === "STYLE_AND_TEXT") {
6433
6664
  const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
6434
6665
  start_idx,
@@ -6629,7 +6860,8 @@ var RedlineEngine = class _RedlineEngine {
6629
6860
  clean_text,
6630
6861
  anchor_run,
6631
6862
  ins_id,
6632
- xmlDoc
6863
+ xmlDoc,
6864
+ suppress_emphasis
6633
6865
  );
6634
6866
  if (content_ins) new_p.appendChild(content_ins);
6635
6867
  body.insertBefore(new_p, _bug233_target_para);
@@ -6661,7 +6893,9 @@ var RedlineEngine = class _RedlineEngine {
6661
6893
  final_new_text,
6662
6894
  anchor_run,
6663
6895
  anchor_para,
6664
- ins_id
6896
+ ins_id,
6897
+ null,
6898
+ suppress_emphasis
6665
6899
  );
6666
6900
  if (!result.first_node) return false;
6667
6901
  const is_inline_first = result.first_node.tagName === "w:ins";
@@ -6809,7 +7043,11 @@ var RedlineEngine = class _RedlineEngine {
6809
7043
  edit.new_text,
6810
7044
  style_source_run,
6811
7045
  mod_anchor_para,
6812
- ins_id
7046
+ ins_id,
7047
+ // The insertion physically follows the deletion block; the style
7048
+ // run was detached when the deletion cloned it into <w:del>.
7049
+ last_del,
7050
+ suppress_emphasis
6813
7051
  );
6814
7052
  if (result.first_node) {
6815
7053
  const is_inline_first = result.first_node.tagName === "w:ins";
@@ -6843,7 +7081,9 @@ var RedlineEngine = class _RedlineEngine {
6843
7081
  edit.new_text,
6844
7082
  anchor,
6845
7083
  first_span.paragraph,
6846
- ins_id
7084
+ ins_id,
7085
+ null,
7086
+ suppress_emphasis
6847
7087
  );
6848
7088
  if (result.first_node) {
6849
7089
  p1_el.appendChild(result.first_node);
@@ -7327,6 +7567,26 @@ function normalize_change_dates(doc) {
7327
7567
  }
7328
7568
  return count ? [`Track change timestamps: ${count} normalized`] : [];
7329
7569
  }
7570
+ function normalize_comment_dates(doc) {
7571
+ let count = 0;
7572
+ const fixed = "2025-01-01T00:00:00Z";
7573
+ for (const part of doc.pkg.parts) {
7574
+ if (!part.contentType.includes("comments")) continue;
7575
+ for (const el of findAllDescendants(part._element, "w:comment")) {
7576
+ if (el.hasAttribute("w:date")) {
7577
+ el.setAttribute("w:date", fixed);
7578
+ count++;
7579
+ }
7580
+ }
7581
+ for (const el of findAllDescendants(part._element, "w16cex:commentExtensible")) {
7582
+ if (el.hasAttribute("w16cex:dateUtc")) {
7583
+ el.setAttribute("w16cex:dateUtc", fixed);
7584
+ count++;
7585
+ }
7586
+ }
7587
+ }
7588
+ return count ? [`Comment timestamps: ${count} normalized`] : [];
7589
+ }
7330
7590
  function scrub_doc_properties(doc) {
7331
7591
  const lines = [];
7332
7592
  const corePart = doc.pkg.getPartByPath("docProps/core.xml");
@@ -7356,7 +7616,10 @@ function scrub_doc_properties(doc) {
7356
7616
  ["keywords", "Keywords"],
7357
7617
  ["subject", "Subject"],
7358
7618
  ["contentStatus", "Content status"],
7359
- ["description", "Description/comments"]
7619
+ ["description", "Description/comments"],
7620
+ ["identifier", "Identifier"],
7621
+ ["language", "Language"],
7622
+ ["version", "Version"]
7360
7623
  ];
7361
7624
  for (const [local, label] of leakFields) {
7362
7625
  findDescendantsByLocalName(corePart._element, local).forEach((c) => {
@@ -7414,27 +7677,73 @@ function scrub_timestamps(doc) {
7414
7677
  }
7415
7678
  return modified ? ["Timestamps normalized to epoch"] : [];
7416
7679
  }
7417
- function strip_custom_xml(doc) {
7418
- const customParts = doc.pkg.parts.filter((p) => p.partname.includes("/customXml"));
7419
- if (customParts.length === 0) return [];
7420
- const partnames = new Set(customParts.map((p) => p.partname));
7421
- doc.pkg.parts = doc.pkg.parts.filter((p) => !partnames.has(p.partname));
7422
- const removeRelationsTo = (relsPart) => {
7680
+ function ejectPackageMembers(doc, matcher, relTargetMatcher) {
7681
+ const pkg = doc.pkg;
7682
+ const normalized = (p) => p.startsWith("/") ? p.substring(1) : p;
7683
+ for (const part of pkg.parts) {
7684
+ if (!part.partname.endsWith(".rels")) continue;
7423
7685
  const toRemove = [];
7424
- for (const rel of findAllDescendants(relsPart._element, "Relationship")) {
7425
- const target = rel.getAttribute("Target");
7426
- if (target && target.includes("customXml")) toRemove.push(rel);
7686
+ for (const rel of findAllDescendants(part._element, "Relationship")) {
7687
+ const target = rel.getAttribute("Target") || "";
7688
+ if (relTargetMatcher(target)) {
7689
+ toRemove.push(rel);
7690
+ const sourcePath = part.partname.replace("/_rels/", "/").replace(".rels", "");
7691
+ const sourcePart = pkg.getPartByPath(sourcePath);
7692
+ if (sourcePart) {
7693
+ const relId = rel.getAttribute("Id");
7694
+ if (relId) sourcePart.rels.delete(relId);
7695
+ }
7696
+ }
7427
7697
  }
7428
7698
  toRemove.forEach((r) => r.parentNode?.removeChild(r));
7429
- };
7430
- const rootRels = doc.pkg.getPartByPath("_rels/.rels");
7431
- if (rootRels) removeRelationsTo(rootRels);
7432
- const docRels = doc.pkg.getOrCreateRelsPart(doc.part.partname);
7433
- if (docRels) removeRelationsTo(docRels);
7699
+ }
7700
+ const ctPart = pkg.getPartByPath("[Content_Types].xml");
7701
+ if (ctPart) {
7702
+ const toRemove = [];
7703
+ for (const override of findAllDescendants(ctPart._element, "Override")) {
7704
+ const partName = override.getAttribute("PartName") || "";
7705
+ if (matcher(normalized(partName))) toRemove.push(override);
7706
+ }
7707
+ toRemove.forEach((o) => o.parentNode?.removeChild(o));
7708
+ }
7709
+ pkg.parts = pkg.parts.filter((p) => !matcher(normalized(p.partname)));
7710
+ for (const key of Object.keys(pkg.unzipped)) {
7711
+ if (matcher(normalized(key))) delete pkg.unzipped[key];
7712
+ }
7713
+ }
7714
+ function strip_custom_xml(doc) {
7715
+ const isCustomXml = (path) => path.startsWith("customXml/");
7716
+ const customParts = doc.pkg.parts.filter(
7717
+ (p) => isCustomXml(p.partname.startsWith("/") ? p.partname.substring(1) : p.partname)
7718
+ );
7719
+ const leakedMembers = Object.keys(doc.pkg.unzipped).filter(isCustomXml);
7720
+ if (customParts.length === 0 && leakedMembers.length === 0) return [];
7721
+ ejectPackageMembers(doc, isCustomXml, (target) => target.includes("customXml"));
7434
7722
  for (const sdtPr of findAllDescendants(doc.element, "w:sdtPr")) {
7435
7723
  findChildren(sdtPr, "w:dataBinding").forEach((b) => sdtPr.removeChild(b));
7436
7724
  }
7437
- return [`Custom XML parts: ${customParts.length} removed`];
7725
+ return [`Custom XML parts: ${Math.max(customParts.length, leakedMembers.length)} removed`];
7726
+ }
7727
+ var CUSTOM_PROPS_PATH = "docProps/custom.xml";
7728
+ function strip_custom_properties(doc) {
7729
+ const part = doc.pkg.getPartByPath(CUSTOM_PROPS_PATH);
7730
+ const leaked = Object.keys(doc.pkg.unzipped).includes(CUSTOM_PROPS_PATH);
7731
+ if (!part && !leaked) return [];
7732
+ const lines = [];
7733
+ if (part) {
7734
+ for (const prop of findDescendantsByLocalName(part._element, "property")) {
7735
+ const name = prop.getAttribute("name") || "(unnamed)";
7736
+ const value = (prop.textContent || "").trim();
7737
+ lines.push(` Custom property removed: ${name} = "${_truncate(value, 60)}"`);
7738
+ }
7739
+ }
7740
+ ejectPackageMembers(
7741
+ doc,
7742
+ (path) => path === CUSTOM_PROPS_PATH,
7743
+ (target) => target.includes("docProps/custom.xml")
7744
+ );
7745
+ const count = Math.max(lines.length, 1);
7746
+ return [`Custom document properties: ${count} removed (docProps/custom.xml)`].concat(lines);
7438
7747
  }
7439
7748
  function strip_image_alt_text(doc) {
7440
7749
  let count = 0;
@@ -8021,8 +8330,10 @@ function build_paragraph_text(paragraph, comments_map, cleanView, style_cache, d
8021
8330
  const new_wrappers = cleanView ? ["", ""] : _get_wrappers(active_ins, active_del, active_comments, active_fmt);
8022
8331
  const new_style = [prefix, suffix];
8023
8332
  if (pending_text && new_wrappers[0] === current_wrappers[0] && new_wrappers[1] === current_wrappers[1]) {
8024
- 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])) {
8025
- pending_text = pending_text.slice(0, -current_style[1].length) + seg.slice(new_style[0].length);
8333
+ const escaped_prefix = new_style[0].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8334
+ const lead_match = new_style[0] !== "" || new_style[1] !== "" ? seg.match(new RegExp("^(\\s*)" + escaped_prefix)) : null;
8335
+ 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) {
8336
+ pending_text = pending_text.slice(0, -current_style[1].length) + lead_match[1] + seg.slice(lead_match[0].length);
8026
8337
  } else {
8027
8338
  pending_text += seg;
8028
8339
  }
@@ -8788,6 +9099,9 @@ function _collect_footnote_ids_fast(owned_items) {
8788
9099
  return ordered;
8789
9100
  }
8790
9101
 
9102
+ // src/sanitize/core.ts
9103
+ var import_fflate3 = require("fflate");
9104
+
8791
9105
  // src/sanitize/report.ts
8792
9106
  var SanitizeReport = class {
8793
9107
  filename;
@@ -8822,7 +9136,7 @@ var SanitizeReport = class {
8822
9136
  } else {
8823
9137
  this.removed_comment_lines.push(line);
8824
9138
  }
8825
- } 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")) {
9139
+ } 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")) {
8826
9140
  this.metadata_lines.push(line);
8827
9141
  } else if (lower.includes("hyperlink") || lower.includes("warning")) {
8828
9142
  this.warnings.push(line);
@@ -8935,11 +9249,13 @@ async function finalize_document(doc, options) {
8935
9249
  report.add_transform_lines(scrub_doc_properties(doc));
8936
9250
  report.add_transform_lines(scrub_timestamps(doc));
8937
9251
  report.add_transform_lines(strip_custom_xml(doc));
9252
+ report.add_transform_lines(strip_custom_properties(doc));
8938
9253
  report.add_transform_lines(strip_image_alt_text(doc));
8939
9254
  const warnings = audit_hyperlinks(doc);
8940
9255
  for (const w of warnings) report.warnings.push(w);
8941
9256
  for (const w of detect_watermarks(doc)) report.warnings.push(w);
8942
9257
  report.add_transform_lines(normalize_change_dates(doc));
9258
+ report.add_transform_lines(normalize_comment_dates(doc));
8943
9259
  if (options.protection_mode === "read_only" || options.protection_mode === "encrypt") {
8944
9260
  if (options.protection_mode === "encrypt") {
8945
9261
  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.");
@@ -8982,8 +9298,47 @@ async function finalize_document(doc, options) {
8982
9298
  }
8983
9299
  if (report.warnings.length > 0) report.status = "clean_with_warnings";
8984
9300
  const outBuffer = await doc.save();
9301
+ verifySanitizedPackage(outBuffer);
8985
9302
  return { reportText: report.render(), outBuffer };
8986
9303
  }
9304
+ var VERIFIED_CORE_FIELDS = [
9305
+ ["creator", "author (dc:creator)"],
9306
+ ["lastModifiedBy", "last modified by (cp:lastModifiedBy)"],
9307
+ ["identifier", "identifier (dc:identifier)"],
9308
+ ["description", "description (dc:description)"],
9309
+ ["keywords", "keywords (cp:keywords)"],
9310
+ ["category", "category (cp:category)"],
9311
+ ["subject", "subject (dc:subject)"],
9312
+ ["contentStatus", "content status (cp:contentStatus)"],
9313
+ ["language", "language (dc:language)"],
9314
+ ["version", "version (cp:version)"]
9315
+ ];
9316
+ function verifySanitizedPackage(outBuffer) {
9317
+ const unzipped = (0, import_fflate3.unzipSync)(new Uint8Array(outBuffer));
9318
+ const names = Object.keys(unzipped);
9319
+ const problems = [];
9320
+ if (names.includes("docProps/custom.xml")) {
9321
+ problems.push("docProps/custom.xml (custom document properties) is still in the package");
9322
+ }
9323
+ if (names.some((n) => n.startsWith("customXml/"))) {
9324
+ problems.push("customXml/* parts are still in the package");
9325
+ }
9326
+ if (names.includes("docProps/core.xml")) {
9327
+ const core = parseXml((0, import_fflate3.strFromU8)(unzipped["docProps/core.xml"]));
9328
+ for (const [local, label] of VERIFIED_CORE_FIELDS) {
9329
+ for (const el of findDescendantsByLocalName(core.documentElement, local)) {
9330
+ if ((el.textContent || "").trim()) {
9331
+ problems.push(`core property ${label} still contains a value`);
9332
+ }
9333
+ }
9334
+ }
9335
+ }
9336
+ if (problems.length > 0) {
9337
+ throw new Error(
9338
+ "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."
9339
+ );
9340
+ }
9341
+ }
8987
9342
 
8988
9343
  // src/index.ts
8989
9344
  function identifyEngine() {
@@ -8999,6 +9354,7 @@ function identifyEngine() {
8999
9354
  USER_PATTERN_TIMEOUT_MS,
9000
9355
  _extractTextFromDoc,
9001
9356
  apply_edits_to_markdown,
9357
+ collect_media_difference_warnings,
9002
9358
  create_unified_diff,
9003
9359
  create_word_patch_diff,
9004
9360
  describe_illegal_control_chars,