@adeu/core 1.22.0 → 1.25.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
@@ -45,6 +45,7 @@ __export(index_exports, {
45
45
  extract_outline: () => extract_outline,
46
46
  finalize_document: () => finalize_document,
47
47
  generate_edits_from_text: () => generate_edits_from_text,
48
+ generate_structured_edits: () => generate_structured_edits,
48
49
  identifyEngine: () => identifyEngine,
49
50
  paginate: () => paginate,
50
51
  split_structural_appendix: () => split_structural_appendix,
@@ -310,6 +311,13 @@ var DocumentObject = class _DocumentObject {
310
311
  async save() {
311
312
  for (const part of this.pkg.parts) {
312
313
  let xmlStr = serializeXml(part._element.ownerDocument || part._element);
314
+ if (xmlStr.includes("w16du:") && !xmlStr.includes("xmlns:w16du=")) {
315
+ part._element.setAttribute(
316
+ "xmlns:w16du",
317
+ "http://schemas.microsoft.com/office/word/2023/wordml/word16du"
318
+ );
319
+ xmlStr = serializeXml(part._element.ownerDocument || part._element);
320
+ }
313
321
  if (!xmlStr.startsWith("<?xml")) {
314
322
  xmlStr = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + xmlStr;
315
323
  }
@@ -897,7 +905,27 @@ var QN_W_OUTLINELVL = "w:outlineLvl";
897
905
  var QN_W_NUMPR = "w:numPr";
898
906
  var QN_W_NUMID = "w:numId";
899
907
  var QN_W_ILVL = "w:ilvl";
908
+ var QN_W_DRAWING = "w:drawing";
909
+ var QN_W_OBJECT = "w:object";
910
+ var QN_W_PICT = "w:pict";
911
+ var QN_WP_DOCPR = "wp:docPr";
912
+ var QN_V_IMAGEDATA = "v:imagedata";
913
+ var QN_O_TITLE = "o:title";
900
914
  var _CUSTOM_HEADING_NAME_RE = /Heading[ ]?([1-6])(?![0-9])/;
915
+ function _resolve_package(obj) {
916
+ let cur = obj;
917
+ const seen = /* @__PURE__ */ new Set();
918
+ while (cur && !seen.has(cur)) {
919
+ seen.add(cur);
920
+ if (cur.package) return cur.package;
921
+ if (cur.pkg) return cur.pkg;
922
+ if (cur.part && (cur.part.package || cur.part.pkg)) {
923
+ return cur.part.package || cur.part.pkg;
924
+ }
925
+ cur = cur._parent || cur.part || null;
926
+ }
927
+ return null;
928
+ }
901
929
  function _get_style_cache(part) {
902
930
  const pkg = part.package || part.pkg || (part.part ? part.part.pkg : null);
903
931
  if (pkg && pkg._adeu_style_cache) {
@@ -931,6 +959,8 @@ function _get_style_cache(part) {
931
959
  const based_on_el = findChild(s, "w:basedOn");
932
960
  const based_on = based_on_el ? based_on_el.getAttribute("w:val") : null;
933
961
  let outline_lvl = null;
962
+ let num_id = null;
963
+ let num_ilvl = null;
934
964
  const pPr = findChild(s, "w:pPr");
935
965
  if (pPr) {
936
966
  const oLvl = findChild(pPr, "w:outlineLvl");
@@ -938,6 +968,19 @@ function _get_style_cache(part) {
938
968
  const val = oLvl.getAttribute("w:val");
939
969
  if (val && /^\d+$/.test(val)) outline_lvl = parseInt(val, 10);
940
970
  }
971
+ const numPr = findChild(pPr, "w:numPr");
972
+ if (numPr) {
973
+ const numId_el = findChild(numPr, "w:numId");
974
+ if (numId_el) {
975
+ const n_val = numId_el.getAttribute("w:val");
976
+ if (n_val && n_val !== "0") num_id = n_val;
977
+ }
978
+ const ilvl_el = findChild(numPr, "w:ilvl");
979
+ if (ilvl_el) {
980
+ const i_val = ilvl_el.getAttribute("w:val");
981
+ if (i_val && /^\d+$/.test(i_val)) num_ilvl = parseInt(i_val, 10);
982
+ }
983
+ }
941
984
  }
942
985
  let bold = null;
943
986
  const rPr = findChild(s, "w:rPr");
@@ -948,23 +991,46 @@ function _get_style_cache(part) {
948
991
  bold = val !== "0" && val !== "false" && val !== "off";
949
992
  }
950
993
  }
951
- raw_styles[s_id] = { name, based_on, outline_level: outline_lvl, bold };
994
+ raw_styles[s_id] = {
995
+ name,
996
+ based_on,
997
+ outline_level: outline_lvl,
998
+ bold,
999
+ num_id,
1000
+ num_ilvl
1001
+ };
952
1002
  }
953
1003
  const resolve_style = (s_id, visited) => {
954
1004
  if (cache[s_id]) return cache[s_id];
955
1005
  if (visited.has(s_id) || !raw_styles[s_id])
956
- return { name: s_id, outline_level: null, bold: false };
1006
+ return {
1007
+ name: s_id,
1008
+ outline_level: null,
1009
+ bold: false,
1010
+ num_id: null,
1011
+ num_ilvl: null
1012
+ };
957
1013
  visited.add(s_id);
958
1014
  const raw = raw_styles[s_id];
959
1015
  const based_on_id = raw.based_on;
960
1016
  let o_lvl = raw.outline_level;
961
1017
  let bold_val = raw.bold !== null ? raw.bold : false;
1018
+ let n_id = raw.num_id;
1019
+ let n_ilvl = raw.num_ilvl;
962
1020
  if (based_on_id) {
963
1021
  const parent = resolve_style(based_on_id, visited);
964
1022
  if (o_lvl === null) o_lvl = parent.outline_level;
965
1023
  if (raw.bold === null) bold_val = parent.bold;
966
- }
967
- const resolved = { name: raw.name, outline_level: o_lvl, bold: bold_val };
1024
+ if (n_id === null) n_id = parent.num_id ?? null;
1025
+ if (n_ilvl === null) n_ilvl = parent.num_ilvl ?? null;
1026
+ }
1027
+ const resolved = {
1028
+ name: raw.name,
1029
+ outline_level: o_lvl,
1030
+ bold: bold_val,
1031
+ num_id: n_id,
1032
+ num_ilvl: n_ilvl
1033
+ };
968
1034
  cache[s_id] = resolved;
969
1035
  return resolved;
970
1036
  };
@@ -973,6 +1039,68 @@ function _get_style_cache(part) {
973
1039
  if (pkg) pkg._adeu_style_cache = result;
974
1040
  return result;
975
1041
  }
1042
+ function _get_numbering_cache(part) {
1043
+ const pkg = _resolve_package(part);
1044
+ if (!pkg) return {};
1045
+ if (pkg._adeu_numbering_cache) return pkg._adeu_numbering_cache;
1046
+ const cache = {};
1047
+ let numbering_root = null;
1048
+ try {
1049
+ const numberingPart = (pkg.parts || []).find(
1050
+ (p) => String(p.partname).endsWith("/numbering.xml")
1051
+ );
1052
+ if (numberingPart) numbering_root = numberingPart._element;
1053
+ } catch {
1054
+ numbering_root = null;
1055
+ }
1056
+ if (numbering_root) {
1057
+ const abstract_fmts = {};
1058
+ for (const abstract of findAllDescendants(numbering_root, "w:abstractNum")) {
1059
+ const a_id = abstract.getAttribute("w:abstractNumId");
1060
+ if (a_id === null) continue;
1061
+ const lvl_map = {};
1062
+ for (const lvl of findAllDescendants(abstract, "w:lvl")) {
1063
+ const ilvl_val = lvl.getAttribute("w:ilvl");
1064
+ const fmt_el = findChild(lvl, "w:numFmt");
1065
+ if (ilvl_val !== null && /^-?\d+$/.test(ilvl_val) && fmt_el) {
1066
+ const fmt = fmt_el.getAttribute("w:val");
1067
+ if (fmt) lvl_map[parseInt(ilvl_val, 10)] = fmt;
1068
+ }
1069
+ }
1070
+ abstract_fmts[a_id] = lvl_map;
1071
+ }
1072
+ for (const num of findAllDescendants(numbering_root, "w:num")) {
1073
+ const n_id = num.getAttribute("w:numId");
1074
+ const a_ref = findChild(num, "w:abstractNumId");
1075
+ if (n_id === null || !a_ref) continue;
1076
+ const a_id = a_ref.getAttribute("w:val");
1077
+ if (a_id !== null && abstract_fmts[a_id] !== void 0) {
1078
+ cache[n_id] = abstract_fmts[a_id];
1079
+ }
1080
+ }
1081
+ }
1082
+ pkg._adeu_numbering_cache = cache;
1083
+ return cache;
1084
+ }
1085
+ function get_list_marker(paragraph_part, num_id, ilvl) {
1086
+ let fmt = null;
1087
+ if (num_id !== null && num_id !== void 0) {
1088
+ const lvl_map = _get_numbering_cache(paragraph_part)[num_id];
1089
+ if (lvl_map && Object.keys(lvl_map).length > 0) {
1090
+ fmt = lvl_map[ilvl] !== void 0 ? lvl_map[ilvl] : null;
1091
+ if (fmt === null) {
1092
+ for (let lookup = ilvl; lookup >= 0; lookup--) {
1093
+ if (lvl_map[lookup] !== void 0) {
1094
+ fmt = lvl_map[lookup];
1095
+ break;
1096
+ }
1097
+ }
1098
+ }
1099
+ }
1100
+ }
1101
+ if (fmt !== null && fmt !== "bullet") return "1. ";
1102
+ return "* ";
1103
+ }
976
1104
  function _detect_heading_level_from_name(name) {
977
1105
  if (!name) return null;
978
1106
  const match = name.match(_CUSTOM_HEADING_NAME_RE);
@@ -1050,21 +1178,48 @@ function get_paragraph_prefix(paragraph, style_cache, default_pstyle) {
1050
1178
  if (/^\d+$/.test(match)) return "#".repeat(parseInt(match, 10)) + " ";
1051
1179
  }
1052
1180
  if (style_name === "Title") return "# ";
1181
+ let list_num_id = null;
1182
+ let list_ilvl = null;
1183
+ let numbering_disabled = false;
1053
1184
  if (pPr) {
1054
1185
  const numPr = findChild(pPr, QN_W_NUMPR);
1055
1186
  if (numPr) {
1056
1187
  const numId = findChild(numPr, QN_W_NUMID);
1057
- if (numId && numId.getAttribute(QN_W_VAL) !== "0") {
1058
- let level = 0;
1059
- const ilvl = findChild(numPr, QN_W_ILVL);
1060
- if (ilvl) {
1061
- const valAttr = ilvl.getAttribute(QN_W_VAL);
1062
- if (valAttr) level = parseInt(valAttr, 10) || 0;
1188
+ if (numId) {
1189
+ const val = numId.getAttribute(QN_W_VAL);
1190
+ if (val === "0") {
1191
+ numbering_disabled = true;
1192
+ } else if (val) {
1193
+ list_num_id = val;
1194
+ const ilvl = findChild(numPr, QN_W_ILVL);
1195
+ if (ilvl) {
1196
+ const valAttr = ilvl.getAttribute(QN_W_VAL);
1197
+ if (valAttr !== null && /^\d+$/.test(valAttr)) {
1198
+ list_ilvl = parseInt(valAttr, 10);
1199
+ }
1200
+ }
1063
1201
  }
1064
- return " ".repeat(level) + "* ";
1065
1202
  }
1066
1203
  }
1067
1204
  }
1205
+ if (list_num_id === null && !numbering_disabled && style_info) {
1206
+ const style_num_id = style_info.num_id;
1207
+ if (style_num_id) {
1208
+ list_num_id = style_num_id;
1209
+ if (list_ilvl === null) {
1210
+ list_ilvl = style_info.num_ilvl !== void 0 ? style_info.num_ilvl : null;
1211
+ }
1212
+ }
1213
+ }
1214
+ if (list_num_id !== null) {
1215
+ const level = list_ilvl !== null ? list_ilvl : 0;
1216
+ const marker = get_list_marker(
1217
+ paragraph._parent ? paragraph._parent.part || paragraph._parent : null,
1218
+ list_num_id,
1219
+ level
1220
+ );
1221
+ return " ".repeat(level) + marker;
1222
+ }
1068
1223
  if (style_name && style_name !== "Normal") {
1069
1224
  const custom_level = _detect_heading_level_from_name(style_name);
1070
1225
  if (custom_level !== null) return "#".repeat(custom_level) + " ";
@@ -1162,6 +1317,9 @@ function* iter_block_items(parent) {
1162
1317
  for (const child of notes) {
1163
1318
  if (child.getAttribute("w:type") === "separator" || child.getAttribute("w:type") === "continuationSeparator")
1164
1319
  continue;
1320
+ const note_id = child.getAttribute("w:id");
1321
+ if (note_id !== null && /^\s*[-+]?\d+\s*$/.test(note_id) && parseInt(note_id, 10) <= 0)
1322
+ continue;
1165
1323
  yield new FootnoteItem(child, parent, parent.note_type);
1166
1324
  }
1167
1325
  return;
@@ -1177,19 +1335,24 @@ function* iter_block_items(parent) {
1177
1335
  }
1178
1336
  }
1179
1337
  function* iter_document_parts(doc) {
1338
+ for (const [container] of iter_document_parts_with_kind(doc)) {
1339
+ yield container;
1340
+ }
1341
+ }
1342
+ function* iter_document_parts_with_kind(doc) {
1180
1343
  const headers = doc.pkg.parts.filter(
1181
1344
  (p) => p.contentType === "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"
1182
1345
  );
1183
- for (const h of headers) yield h;
1184
- yield doc;
1346
+ for (const h of headers) yield [h, "header"];
1347
+ yield [doc, "body"];
1185
1348
  const footers = doc.pkg.parts.filter(
1186
1349
  (p) => p.contentType === "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"
1187
1350
  );
1188
- for (const f of footers) yield f;
1351
+ for (const f of footers) yield [f, "footer"];
1189
1352
  const fnPart = doc.pkg.getPartByPath("word/footnotes.xml");
1190
1353
  const enPart = doc.pkg.getPartByPath("word/endnotes.xml");
1191
- if (fnPart) yield new NotesPart(fnPart, "fn");
1192
- if (enPart) yield new NotesPart(enPart, "en");
1354
+ if (fnPart) yield [new NotesPart(fnPart, "fn"), "footnotes"];
1355
+ if (enPart) yield [new NotesPart(enPart, "en"), "endnotes"];
1193
1356
  }
1194
1357
  function _is_page_instr(instr) {
1195
1358
  if (!instr) return false;
@@ -1227,7 +1390,22 @@ function* iter_paragraph_content(paragraph) {
1227
1390
  const child = r_element.childNodes[i];
1228
1391
  if (child.nodeType !== 1) continue;
1229
1392
  const tag = child.tagName;
1230
- if (tag === QN_W_COMMENTREFERENCE) {
1393
+ if (tag === QN_W_DRAWING || tag === QN_W_OBJECT) {
1394
+ const doc_pr = findAllDescendants(child, QN_WP_DOCPR)[0] || null;
1395
+ let alt = "";
1396
+ let img_id = "0";
1397
+ if (doc_pr) {
1398
+ alt = doc_pr.getAttribute("descr") || doc_pr.getAttribute("title") || "";
1399
+ img_id = doc_pr.getAttribute("id") || "0";
1400
+ }
1401
+ yield { type: "image", id: img_id, date: alt };
1402
+ } else if (tag === QN_W_PICT) {
1403
+ const imagedata = findAllDescendants(child, QN_V_IMAGEDATA)[0] || null;
1404
+ if (imagedata) {
1405
+ const alt = imagedata.getAttribute(QN_O_TITLE) || "";
1406
+ yield { type: "image", id: "vml", date: alt };
1407
+ }
1408
+ } else if (tag === QN_W_COMMENTREFERENCE) {
1231
1409
  const ref_id = child.getAttribute(QN_W_ID);
1232
1410
  if (ref_id) yield { type: "ref", id: ref_id };
1233
1411
  } else if (tag === QN_W_FOOTNOTEREFERENCE) {
@@ -1335,6 +1513,11 @@ var DocumentMapper = class {
1335
1513
  full_text = "";
1336
1514
  spans = [];
1337
1515
  appendix_start_index = -1;
1516
+ // [start, end, kind] per projected part, in projection order. Spans carry
1517
+ // the matching index in .part_index. Together these let the engine refuse
1518
+ // or re-anchor edits at OPC part boundaries (QA 2026-07-18 C1).
1519
+ part_ranges = [];
1520
+ _current_part_index = 0;
1338
1521
  _text_chunks = [];
1339
1522
  _plain_projection = null;
1340
1523
  constructor(doc, clean_view = false, original_view = false) {
@@ -1350,12 +1533,19 @@ var DocumentMapper = class {
1350
1533
  this._text_chunks = [];
1351
1534
  this.full_text = "";
1352
1535
  this._plain_projection = null;
1353
- for (const part of iter_document_parts(this.doc)) {
1536
+ this.part_ranges = [];
1537
+ this._current_part_index = 0;
1538
+ let part_idx = 0;
1539
+ for (const [part, part_kind] of iter_document_parts_with_kind(this.doc)) {
1540
+ this._current_part_index = part_idx;
1541
+ const part_start = current_offset;
1354
1542
  current_offset = this._map_blocks(part, current_offset);
1543
+ this.part_ranges.push([part_start, current_offset, part_kind]);
1355
1544
  if (this.spans.length > 0 && this.spans[this.spans.length - 1].text !== "\n\n") {
1356
1545
  this._add_virtual_text("\n\n", current_offset, null);
1357
1546
  current_offset += 2;
1358
1547
  }
1548
+ part_idx++;
1359
1549
  }
1360
1550
  while (this.spans.length > 0 && this.spans[this.spans.length - 1].text === "\n\n") {
1361
1551
  this.spans.pop();
@@ -1364,6 +1554,47 @@ var DocumentMapper = class {
1364
1554
  this.full_text = this._text_chunks.join("");
1365
1555
  this.appendix_start_index = -1;
1366
1556
  }
1557
+ /** [part_index, start, end, kind] for parts that projected any text. */
1558
+ _nonempty_part_ranges() {
1559
+ const out = [];
1560
+ for (let i = 0; i < this.part_ranges.length; i++) {
1561
+ const [s, e, k] = this.part_ranges[i];
1562
+ if (e > s) out.push([i, s, e, k]);
1563
+ }
1564
+ return out;
1565
+ }
1566
+ part_kind_of(part_index) {
1567
+ if (part_index >= 0 && part_index < this.part_ranges.length) {
1568
+ return this.part_ranges[part_index][2];
1569
+ }
1570
+ return null;
1571
+ }
1572
+ /** Kind of the part whose projected range contains `index`, or null. */
1573
+ part_kind_at(index) {
1574
+ for (const [, start, end, kind] of this._nonempty_part_ranges()) {
1575
+ if (start <= index && index <= end) return kind;
1576
+ }
1577
+ return null;
1578
+ }
1579
+ /**
1580
+ * When `index` falls strictly AFTER one part's text and at-or-before the
1581
+ * start of the next part's text (i.e. inside the "\n\n" separator or
1582
+ * exactly at the next part's first character), returns
1583
+ * [previous_part_index, next_part_index]. Returns null everywhere else —
1584
+ * including index == previous part's end, which is an ordinary
1585
+ * end-of-part text position, not a boundary gap.
1586
+ */
1587
+ part_boundary_at(index) {
1588
+ const ranges = this._nonempty_part_ranges();
1589
+ for (let j = 1; j < ranges.length; j++) {
1590
+ const [prev_i, , prev_end] = ranges[j - 1];
1591
+ const [next_i, next_start] = ranges[j];
1592
+ if (prev_end < index && index <= next_start) {
1593
+ return [prev_i, next_i];
1594
+ }
1595
+ }
1596
+ return null;
1597
+ }
1367
1598
  _map_blocks(container, offset) {
1368
1599
  let current = offset;
1369
1600
  const c_type = container.constructor.name;
@@ -1493,7 +1724,8 @@ ${header}`;
1493
1724
  end: current,
1494
1725
  text: "",
1495
1726
  run: null,
1496
- paragraph
1727
+ paragraph,
1728
+ part_index: this._current_part_index
1497
1729
  };
1498
1730
  this.spans.push(span);
1499
1731
  const active_ids = /* @__PURE__ */ new Set();
@@ -1525,7 +1757,8 @@ ${header}`;
1525
1757
  ins_id: i_id || void 0,
1526
1758
  del_id: d_id || void 0,
1527
1759
  hyperlink_id: active_hyperlink_id || void 0,
1528
- comment_ids: c_ids.length > 0 ? c_ids : void 0
1760
+ comment_ids: c_ids.length > 0 ? c_ids : void 0,
1761
+ part_index: this._current_part_index
1529
1762
  };
1530
1763
  this.spans.push(s);
1531
1764
  this._text_chunks.push(txt);
@@ -1704,7 +1937,15 @@ ${header}`;
1704
1937
  else if (ev.type === "del_end") delete active_del[ev.id];
1705
1938
  else if (ev.type === "fmt_start") active_fmt[ev.id] = ev;
1706
1939
  else if (ev.type === "fmt_end") delete active_fmt[ev.id];
1707
- else if (ev.type === "footnote" || ev.type === "endnote") {
1940
+ else if (ev.type === "image") {
1941
+ const hidden = this.clean_view && Object.keys(active_del).length > 0 || this.original_view && Object.keys(active_ins).length > 0;
1942
+ if (!hidden) {
1943
+ const alt = (ev.date || "image").replace(/\]/g, ")").replace(/\n/g, " ");
1944
+ const txt = `![${alt}](docx-image:${ev.id})`;
1945
+ this._add_virtual_text(txt, current, paragraph, null, true);
1946
+ current += txt.length;
1947
+ }
1948
+ } else if (ev.type === "footnote" || ev.type === "endnote") {
1708
1949
  flush_pending_runs();
1709
1950
  current_wrappers = ["", ""];
1710
1951
  current_style = ["", ""];
@@ -1819,14 +2060,16 @@ ${header}`;
1819
2060
  }
1820
2061
  return [...change_lines, ...comment_lines].join("\n");
1821
2062
  }
1822
- _add_virtual_text(text, offset, context_paragraph, hyperlink_id = null) {
2063
+ _add_virtual_text(text, offset, context_paragraph, hyperlink_id = null, is_image_marker = false) {
1823
2064
  const span = {
1824
2065
  start: offset,
1825
2066
  end: offset + text.length,
1826
2067
  text,
1827
2068
  run: null,
1828
2069
  paragraph: context_paragraph,
1829
- hyperlink_id: hyperlink_id || void 0
2070
+ hyperlink_id: hyperlink_id || void 0,
2071
+ part_index: this._current_part_index,
2072
+ is_image_marker: is_image_marker || void 0
1830
2073
  };
1831
2074
  this.spans.push(span);
1832
2075
  this._text_chunks.push(text);
@@ -2426,6 +2669,332 @@ function generate_edits_from_text(original_text, modified_text) {
2426
2669
  }
2427
2670
  return edits;
2428
2671
  }
2672
+ function _sequence_opcodes(a, b) {
2673
+ const n = a.length;
2674
+ const m = b.length;
2675
+ const dp = [];
2676
+ for (let i2 = 0; i2 <= n; i2++) dp.push(new Int32Array(m + 1));
2677
+ for (let i2 = n - 1; i2 >= 0; i2--) {
2678
+ for (let j2 = m - 1; j2 >= 0; j2--) {
2679
+ dp[i2][j2] = a[i2] === b[j2] ? dp[i2 + 1][j2 + 1] + 1 : Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
2680
+ }
2681
+ }
2682
+ const ops = [];
2683
+ let i = 0;
2684
+ let j = 0;
2685
+ let pend_i1 = 0;
2686
+ let pend_j1 = 0;
2687
+ let pend_del = 0;
2688
+ let pend_ins = 0;
2689
+ const flushPending = () => {
2690
+ if (pend_del === 0 && pend_ins === 0) return;
2691
+ const tag = pend_del > 0 && pend_ins > 0 ? "replace" : pend_del > 0 ? "delete" : "insert";
2692
+ ops.push([tag, pend_i1, pend_i1 + pend_del, pend_j1, pend_j1 + pend_ins]);
2693
+ pend_del = 0;
2694
+ pend_ins = 0;
2695
+ };
2696
+ while (i < n || j < m) {
2697
+ if (i < n && j < m && a[i] === b[j]) {
2698
+ flushPending();
2699
+ const i1 = i;
2700
+ const j1 = j;
2701
+ while (i < n && j < m && a[i] === b[j]) {
2702
+ i++;
2703
+ j++;
2704
+ }
2705
+ ops.push(["equal", i1, i, j1, j]);
2706
+ } else {
2707
+ if (pend_del === 0 && pend_ins === 0) {
2708
+ pend_i1 = i;
2709
+ pend_j1 = j;
2710
+ }
2711
+ if (j < m && (i === n || dp[i][j + 1] >= dp[i + 1][j])) {
2712
+ pend_ins++;
2713
+ j++;
2714
+ } else {
2715
+ pend_del++;
2716
+ i++;
2717
+ }
2718
+ }
2719
+ }
2720
+ flushPending();
2721
+ return ops;
2722
+ }
2723
+ var _IMAGE_MARKER_RE = /!\[[^\]]*\]\(docx-image:[^)]*\)/g;
2724
+ function _drop_image_marker_hunks(edits, warnings) {
2725
+ const _normalized = (s) => s.replace(_IMAGE_MARKER_RE, "").replace(/\s+/g, " ").trim();
2726
+ const kept = [];
2727
+ for (const e of edits) {
2728
+ const t_imgs = ((e.target_text || "").match(_IMAGE_MARKER_RE) || []).sort();
2729
+ const n_imgs = ((e.new_text || "").match(_IMAGE_MARKER_RE) || []).sort();
2730
+ if (JSON.stringify(t_imgs) === JSON.stringify(n_imgs)) {
2731
+ kept.push(e);
2732
+ continue;
2733
+ }
2734
+ if (_normalized(e.target_text || "") === _normalized(e.new_text || "")) {
2735
+ warnings.push(
2736
+ "An inline image was added or removed between the documents. Images cannot be transferred by text edits \u2014 the image-only difference was skipped; apply it manually in Word."
2737
+ );
2738
+ } else {
2739
+ warnings.push(
2740
+ "A text change overlapping an added/removed inline image was skipped \u2014 images cannot be transferred by text edits. Apply that section manually in Word."
2741
+ );
2742
+ }
2743
+ }
2744
+ return kept;
2745
+ }
2746
+ function _rows_are_plain(text, table) {
2747
+ for (const row of table.rows) {
2748
+ if (text.substring(row.start, row.end) !== row.cells.join(" | ")) {
2749
+ return false;
2750
+ }
2751
+ }
2752
+ return true;
2753
+ }
2754
+ var _ROW_KEY_SEP = "";
2755
+ function _table_row_opcodes(rows_o, rows_m) {
2756
+ const keys_o = rows_o.map((r) => r.cells.join(_ROW_KEY_SEP));
2757
+ const keys_m = rows_m.map((r) => r.cells.join(_ROW_KEY_SEP));
2758
+ const opcodes = _sequence_opcodes(keys_o, keys_m);
2759
+ for (const [tag, i1, i2, j1, j2] of opcodes) {
2760
+ if (tag === "insert" || tag === "delete" || tag === "replace" && i2 - i1 !== j2 - j1) {
2761
+ return opcodes;
2762
+ }
2763
+ }
2764
+ return null;
2765
+ }
2766
+ function _row_ops_for_table(table_o, table_m, opcodes, warnings) {
2767
+ const rows_o = table_o.rows;
2768
+ const rows_m = table_m.rows;
2769
+ const keys_o = rows_o.map((r) => r.cells.join(_ROW_KEY_SEP));
2770
+ const row_text = (r) => r.cells.join(" | ");
2771
+ if (new Set(keys_o).size !== keys_o.length) {
2772
+ warnings.push(
2773
+ "A table contains rows with identical text; the generated row operations anchor by row text and may be rejected as ambiguous at apply time. If that happens, apply the row changes with explicit insert_row/delete_row edits."
2774
+ );
2775
+ }
2776
+ const removed = /* @__PURE__ */ new Set();
2777
+ const replaced_new_text = {};
2778
+ for (const [tag, i1, i2, j1, j2] of opcodes) {
2779
+ if (tag === "delete") {
2780
+ for (let k = i1; k < i2; k++) removed.add(k);
2781
+ } else if (tag === "replace") {
2782
+ const pairs = Math.min(i2 - i1, j2 - j1);
2783
+ for (let k = i1 + pairs; k < i2; k++) removed.add(k);
2784
+ for (let k = 0; k < pairs; k++) {
2785
+ replaced_new_text[i1 + k] = row_text(rows_m[j1 + k]);
2786
+ }
2787
+ }
2788
+ }
2789
+ const surviving = [];
2790
+ for (let k = 0; k < rows_o.length; k++) {
2791
+ if (!removed.has(k)) surviving.push(k);
2792
+ }
2793
+ const anchor_text = (orig_idx) => {
2794
+ return replaced_new_text[orig_idx] !== void 0 ? replaced_new_text[orig_idx] : row_text(rows_o[orig_idx]);
2795
+ };
2796
+ const insert_ops = (new_rows, at_orig_index) => {
2797
+ const ops2 = [];
2798
+ const before = surviving.filter((k) => k < at_orig_index);
2799
+ const after = surviving.filter((k) => k >= at_orig_index);
2800
+ if (before.length > 0) {
2801
+ const anchor_idx = before[before.length - 1];
2802
+ const anchor = anchor_text(anchor_idx);
2803
+ for (const r of [...new_rows].reverse()) {
2804
+ ops2.push({
2805
+ type: "insert_row",
2806
+ target_text: anchor,
2807
+ position: "below",
2808
+ cells: [...r.cells],
2809
+ // Pin to the anchor row's offset: text anchors alone are
2810
+ // ambiguous when tables share identical rows. Pins do not
2811
+ // survive JSON round-trips (the strict text match applies then,
2812
+ // failing closed with the duplicate-row warning above).
2813
+ _match_start_index: rows_o[anchor_idx].start
2814
+ });
2815
+ }
2816
+ } else if (after.length > 0) {
2817
+ const anchor_idx = after[0];
2818
+ const anchor = anchor_text(anchor_idx);
2819
+ for (const r of new_rows) {
2820
+ ops2.push({
2821
+ type: "insert_row",
2822
+ target_text: anchor,
2823
+ position: "above",
2824
+ cells: [...r.cells],
2825
+ _match_start_index: rows_o[anchor_idx].start
2826
+ });
2827
+ }
2828
+ } else {
2829
+ warnings.push(
2830
+ "A table gained rows but no original row survives to anchor them; these row insertions were skipped \u2014 add them with explicit insert_row operations."
2831
+ );
2832
+ }
2833
+ return ops2;
2834
+ };
2835
+ const ops = [];
2836
+ for (const [tag, i1, i2, j1, j2] of opcodes) {
2837
+ if (tag === "equal") continue;
2838
+ if (tag === "replace") {
2839
+ const pairs = Math.min(i2 - i1, j2 - j1);
2840
+ for (let k = 0; k < pairs; k++) {
2841
+ const o_txt = row_text(rows_o[i1 + k]);
2842
+ const m_txt = row_text(rows_m[j1 + k]);
2843
+ if (o_txt !== m_txt) {
2844
+ ops.push({
2845
+ type: "modify",
2846
+ target_text: o_txt,
2847
+ new_text: m_txt,
2848
+ comment: "Diff: Table row modified",
2849
+ _is_table_edit: true
2850
+ });
2851
+ }
2852
+ }
2853
+ for (let k = i1 + pairs; k < i2; k++) {
2854
+ ops.push({
2855
+ type: "delete_row",
2856
+ target_text: row_text(rows_o[k]),
2857
+ _match_start_index: rows_o[k].start
2858
+ });
2859
+ }
2860
+ const surplus_new = rows_m.slice(j1 + pairs, j2);
2861
+ if (surplus_new.length > 0) {
2862
+ ops.push(...insert_ops(surplus_new, i2));
2863
+ }
2864
+ } else if (tag === "delete") {
2865
+ for (let k = i1; k < i2; k++) {
2866
+ ops.push({
2867
+ type: "delete_row",
2868
+ target_text: row_text(rows_o[k]),
2869
+ _match_start_index: rows_o[k].start
2870
+ });
2871
+ }
2872
+ } else if (tag === "insert") {
2873
+ ops.push(...insert_ops(rows_m.slice(j1, j2), i1));
2874
+ }
2875
+ }
2876
+ return ops;
2877
+ }
2878
+ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod) {
2879
+ const warnings = [];
2880
+ const edits = [];
2881
+ const kinds_o = struct_orig.part_ranges.filter(([s, e]) => e > s).map(([, , k]) => k);
2882
+ const kinds_m = struct_mod.part_ranges.filter(([s, e]) => e > s).map(([, , k]) => k);
2883
+ if (JSON.stringify(kinds_o) !== JSON.stringify(kinds_m)) {
2884
+ warnings.push(
2885
+ `The documents have different part layouts (${kinds_o.join(" + ") || "none"} vs ${kinds_m.join(" + ") || "none"}); comparing flattened text instead. Header/footer additions or removals cannot be expressed as text edits.`
2886
+ );
2887
+ const flat = _drop_image_marker_hunks(
2888
+ generate_edits_from_text(text_orig, text_mod),
2889
+ warnings
2890
+ );
2891
+ return { edits: [...flat], warnings };
2892
+ }
2893
+ const ranges_o = struct_orig.part_ranges.filter(([s, e]) => e > s).map(([s, e]) => [s, e]);
2894
+ const ranges_m = struct_mod.part_ranges.filter(([s, e]) => e > s).map(([s, e]) => [s, e]);
2895
+ for (let p = 0; p < ranges_o.length; p++) {
2896
+ const [po_start, po_end] = ranges_o[p];
2897
+ const [pm_start, pm_end] = ranges_m[p];
2898
+ const tables_o = struct_orig.tables.filter(
2899
+ (t) => po_start <= t.start && t.end <= po_end
2900
+ );
2901
+ const tables_m = struct_mod.tables.filter(
2902
+ (t) => pm_start <= t.start && t.end <= pm_end
2903
+ );
2904
+ const tables_alignable = tables_o.length === tables_m.length && tables_o.every((t) => _rows_are_plain(text_orig, t)) && tables_m.every((t) => _rows_are_plain(text_mod, t));
2905
+ if (tables_o.length !== tables_m.length) {
2906
+ warnings.push(
2907
+ `A ${kinds_o[p]} part has ${tables_o.length} table(s) in the original but ${tables_m.length} in the modified document; its tables were compared as plain text. Adding or removing whole tables is not supported via diff/apply.`
2908
+ );
2909
+ }
2910
+ if (!tables_alignable) {
2911
+ const part_edits = generate_edits_from_text(
2912
+ text_orig.substring(po_start, po_end),
2913
+ text_mod.substring(pm_start, pm_end)
2914
+ );
2915
+ for (const e of part_edits) {
2916
+ e._match_start_index = (e._match_start_index || 0) + po_start;
2917
+ }
2918
+ edits.push(...part_edits);
2919
+ continue;
2920
+ }
2921
+ const boundaries_o = [
2922
+ [po_start, po_start],
2923
+ ...tables_o.map((t) => [t.start, t.end]),
2924
+ [po_end, po_end]
2925
+ ];
2926
+ const boundaries_m = [
2927
+ [pm_start, pm_start],
2928
+ ...tables_m.map((t) => [t.start, t.end]),
2929
+ [pm_end, pm_end]
2930
+ ];
2931
+ for (let seg_idx = 0; seg_idx < boundaries_o.length - 1; seg_idx++) {
2932
+ const seg_o_start = boundaries_o[seg_idx][1];
2933
+ const seg_o_end = boundaries_o[seg_idx + 1][0];
2934
+ const seg_m_start = boundaries_m[seg_idx][1];
2935
+ const seg_m_end = boundaries_m[seg_idx + 1][0];
2936
+ const seg_edits = generate_edits_from_text(
2937
+ text_orig.substring(seg_o_start, seg_o_end),
2938
+ text_mod.substring(seg_m_start, seg_m_end)
2939
+ );
2940
+ for (const e of seg_edits) {
2941
+ e._match_start_index = (e._match_start_index || 0) + seg_o_start;
2942
+ }
2943
+ edits.push(...seg_edits);
2944
+ if (seg_idx < tables_o.length) {
2945
+ const t_o = tables_o[seg_idx];
2946
+ const t_m = tables_m[seg_idx];
2947
+ const row_opcodes = _table_row_opcodes(t_o.rows, t_m.rows);
2948
+ if (row_opcodes !== null) {
2949
+ edits.push(..._row_ops_for_table(t_o, t_m, row_opcodes, warnings));
2950
+ } else {
2951
+ for (let k = 0; k < t_o.rows.length; k++) {
2952
+ const o_txt = t_o.rows[k].cells.join(" | ");
2953
+ const m_txt = t_m.rows[k].cells.join(" | ");
2954
+ if (o_txt === m_txt) continue;
2955
+ edits.push({
2956
+ type: "modify",
2957
+ target_text: o_txt,
2958
+ new_text: m_txt,
2959
+ comment: "Diff: Table row modified",
2960
+ _is_table_edit: true
2961
+ });
2962
+ }
2963
+ }
2964
+ }
2965
+ }
2966
+ }
2967
+ const countOccurrences = (haystack, needle) => {
2968
+ let count = 0;
2969
+ let from = 0;
2970
+ while (true) {
2971
+ const idx = haystack.indexOf(needle, from);
2972
+ if (idx === -1) break;
2973
+ count++;
2974
+ from = idx + needle.length;
2975
+ }
2976
+ return count;
2977
+ };
2978
+ let ambiguous_anchor_warned = false;
2979
+ for (const e of edits) {
2980
+ if ((e.type === "insert_row" || e.type === "delete_row" || e._is_table_edit) && !ambiguous_anchor_warned) {
2981
+ if (e.target_text && countOccurrences(text_orig, e.target_text) > 1) {
2982
+ warnings.push(
2983
+ `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
+ );
2985
+ ambiguous_anchor_warned = true;
2986
+ }
2987
+ }
2988
+ }
2989
+ const modify_edits = edits.filter(
2990
+ (e) => e.type === "modify"
2991
+ );
2992
+ const kept_modifies = new Set(_drop_image_marker_hunks(modify_edits, warnings));
2993
+ const final_edits = edits.filter(
2994
+ (e) => e.type !== "modify" || kept_modifies.has(e)
2995
+ );
2996
+ return { edits: final_edits, warnings };
2997
+ }
2429
2998
  function create_unified_diff(original_text, modified_text, context_lines = 3) {
2430
2999
  const dmp = new import_diff_match_patch.default.diff_match_patch();
2431
3000
  dmp.Diff_Timeout = 2;
@@ -2733,6 +3302,30 @@ function _strip_balanced_markers(text) {
2733
3302
  function _replace_smart_quotes(text) {
2734
3303
  return text.replace(/“/g, '"').replace(/”/g, '"').replace(/‘/g, "'").replace(/’/g, "'");
2735
3304
  }
3305
+ function _strip_markdown_for_matching(text) {
3306
+ const result = [];
3307
+ const position_map = [];
3308
+ let i = 0;
3309
+ while (i < text.length) {
3310
+ const pair = text.substring(i, i + 2);
3311
+ if (i < text.length - 1 && (pair === "**" || pair === "__")) {
3312
+ i += 2;
3313
+ continue;
3314
+ }
3315
+ if (text[i] === "*" || text[i] === "_") {
3316
+ const prev_char = i > 0 ? text[i - 1] : " ";
3317
+ const next_char = i < text.length - 1 ? text[i + 1] : " ";
3318
+ if ([" ", "\n", " "].includes(prev_char) || [" ", "\n", " "].includes(next_char)) {
3319
+ i += 1;
3320
+ continue;
3321
+ }
3322
+ }
3323
+ position_map.push(i);
3324
+ result.push(text[i]);
3325
+ i += 1;
3326
+ }
3327
+ return [result.join(""), position_map];
3328
+ }
2736
3329
  function _find_safe_boundaries(text, start, end) {
2737
3330
  let new_start = start;
2738
3331
  let new_end = end;
@@ -2836,31 +3429,63 @@ function _make_fuzzy_regex(target_text) {
2836
3429
  if (remaining) parts.push(escapeRegExp(remaining));
2837
3430
  return parts.join("");
2838
3431
  }
2839
- function _find_match_in_text(text, target) {
2840
- if (!target) return [-1, -1];
2841
- let idx = text.indexOf(target);
2842
- if (idx !== -1) return _find_safe_boundaries(text, idx, idx + target.length);
3432
+ function _find_all_matches_in_text(text, target, is_regex = false) {
3433
+ if (!target) return [];
3434
+ if (is_regex) {
3435
+ try {
3436
+ return userFindAllMatches(target, text).map((m) => [m.start, m.end]);
3437
+ } catch (e) {
3438
+ if (e instanceof RegexTimeoutError) throw e;
3439
+ return [];
3440
+ }
3441
+ }
3442
+ const findAllLiteral = (haystack, needle) => {
3443
+ const out = [];
3444
+ let from = 0;
3445
+ while (true) {
3446
+ const idx = haystack.indexOf(needle, from);
3447
+ if (idx === -1) break;
3448
+ out.push([idx, idx + needle.length]);
3449
+ from = idx + needle.length;
3450
+ }
3451
+ return out;
3452
+ };
3453
+ let spans = findAllLiteral(text, target);
3454
+ if (spans.length > 0) {
3455
+ return spans.map(([s, e]) => _find_safe_boundaries(text, s, e));
3456
+ }
2843
3457
  const norm_text = _replace_smart_quotes(text);
2844
3458
  const norm_target = _replace_smart_quotes(target);
2845
- idx = norm_text.indexOf(norm_target);
2846
- if (idx !== -1)
2847
- return _find_safe_boundaries(text, idx, idx + norm_target.length);
3459
+ spans = findAllLiteral(norm_text, norm_target);
3460
+ if (spans.length > 0) {
3461
+ return spans.map(([s, e]) => _find_safe_boundaries(text, s, e));
3462
+ }
3463
+ const [stripped_text, pos_map] = _strip_markdown_for_matching(norm_text);
3464
+ const [stripped_target] = _strip_markdown_for_matching(norm_target);
3465
+ if (stripped_target && (stripped_text !== norm_text || stripped_target !== norm_target)) {
3466
+ const results = [];
3467
+ for (const [p_start, p_end] of findAllLiteral(stripped_text, stripped_target)) {
3468
+ const raw_start = pos_map[p_start];
3469
+ const raw_end = pos_map[p_end - 1] + 1;
3470
+ results.push(_find_safe_boundaries(text, raw_start, raw_end));
3471
+ }
3472
+ if (results.length > 0) return results;
3473
+ }
2848
3474
  try {
2849
- const pattern = new RegExp(_make_fuzzy_regex(target));
2850
- const match = pattern.exec(text);
2851
- if (match) {
2852
- const raw_start = match.index;
2853
- const raw_end = match.index + match[0].length;
3475
+ const pattern = new RegExp(_make_fuzzy_regex(target), "g");
3476
+ const results = [];
3477
+ for (const match of text.matchAll(pattern)) {
2854
3478
  const [refined_start, refined_end] = _refine_match_boundaries(
2855
3479
  text,
2856
- raw_start,
2857
- raw_end
3480
+ match.index,
3481
+ match.index + match[0].length
2858
3482
  );
2859
- return _find_safe_boundaries(text, refined_start, refined_end);
3483
+ results.push(_find_safe_boundaries(text, refined_start, refined_end));
2860
3484
  }
3485
+ if (results.length > 0) return results;
2861
3486
  } catch (e) {
2862
3487
  }
2863
- return [-1, -1];
3488
+ return [];
2864
3489
  }
2865
3490
  function _build_critic_markup(target_text, new_text, comment, edit_index, include_index, highlight_only) {
2866
3491
  const parts = [];
@@ -2892,19 +3517,55 @@ function _build_critic_markup(target_text, new_text, comment, edit_index, includ
2892
3517
  }
2893
3518
  return parts.join("");
2894
3519
  }
2895
- function apply_edits_to_markdown(markdown_text, edits, include_index = false, highlight_only = false) {
3520
+ function apply_edits_to_markdown(markdown_text, edits, include_index = false, highlight_only = false, edit_reports) {
2896
3521
  if (!edits || edits.length === 0) return markdown_text;
3522
+ const _report = (idx, status, error = null, occurrences = 0) => {
3523
+ if (edit_reports) edit_reports.push({ index: idx, status, error, occurrences });
3524
+ };
2897
3525
  const matched_edits = [];
2898
3526
  for (let idx = 0; idx < edits.length; idx++) {
2899
3527
  const edit = edits[idx];
2900
3528
  const target = edit.target_text || "";
3529
+ const match_mode = edit.match_mode || "strict";
3530
+ const is_regex = Boolean(edit.regex);
2901
3531
  if (!target) {
3532
+ _report(
3533
+ idx,
3534
+ "failed",
3535
+ `- Edit ${idx + 1} Failed: target_text is empty. Pure insertions are expressed as a replacement: put the text immediately around the insertion point in target_text and repeat it (plus the new text) in new_text.`
3536
+ );
3537
+ continue;
3538
+ }
3539
+ let spans;
3540
+ try {
3541
+ spans = _find_all_matches_in_text(markdown_text, target, is_regex);
3542
+ } catch (e) {
3543
+ if (!(e instanceof RegexTimeoutError)) throw e;
3544
+ _report(idx, "failed", `- Edit ${idx + 1} Failed: ${e.message}`);
3545
+ continue;
3546
+ }
3547
+ if (spans.length === 0) {
3548
+ _report(
3549
+ idx,
3550
+ "failed",
3551
+ `- Edit ${idx + 1} Failed: Target text not found in document:
3552
+ "${target.substring(0, 80)}"`
3553
+ );
3554
+ continue;
3555
+ }
3556
+ if (spans.length > 1 && match_mode === "strict") {
3557
+ _report(
3558
+ idx,
3559
+ "failed",
3560
+ format_ambiguity_error(idx + 1, target, markdown_text, spans)
3561
+ );
2902
3562
  continue;
2903
3563
  }
2904
- const [start, end] = _find_match_in_text(markdown_text, target);
2905
- if (start === -1) continue;
2906
- const actual_matched_text = markdown_text.substring(start, end);
2907
- matched_edits.push([start, end, actual_matched_text, edit, idx]);
3564
+ const selected = match_mode === "strict" || match_mode === "first" ? spans.slice(0, 1) : spans;
3565
+ for (const [start, end] of selected) {
3566
+ matched_edits.push([start, end, markdown_text.substring(start, end), edit, idx]);
3567
+ }
3568
+ _report(idx, "applied", null, selected.length);
2908
3569
  }
2909
3570
  const matched_edits_filtered = [];
2910
3571
  const occupied_ranges = [];
@@ -2914,6 +3575,16 @@ function apply_edits_to_markdown(markdown_text, edits, include_index = false, hi
2914
3575
  for (const [occ_start, occ_end] of occupied_ranges) {
2915
3576
  if (start < occ_end && end > occ_start) {
2916
3577
  overlaps = true;
3578
+ if (edit_reports) {
3579
+ const msg = `- Edit ${orig_idx + 1} Failed: overlaps with a previously matched edit.`;
3580
+ for (const r of edit_reports) {
3581
+ if (r.index === orig_idx) {
3582
+ r.status = "failed";
3583
+ r.error = msg;
3584
+ r.occurrences = 0;
3585
+ }
3586
+ }
3587
+ }
2917
3588
  break;
2918
3589
  }
2919
3590
  }
@@ -3182,6 +3853,15 @@ function validate_edit_strings(edits, index_offset = 0) {
3182
3853
  }
3183
3854
  }
3184
3855
  }
3856
+ if (t_text.includes("docx-image:") || n_text.includes("docx-image:")) {
3857
+ const t_imgs = (t_text.match(/!\[[^\]]*\]\(docx-image:[^)]*\)/g) || []).sort();
3858
+ const n_imgs = (n_text.match(/!\[[^\]]*\]\(docx-image:[^)]*\)/g) || []).sort();
3859
+ if (JSON.stringify(t_imgs) !== JSON.stringify(n_imgs)) {
3860
+ errors.push(
3861
+ `- Edit ${i + 1 + index_offset} Failed: image markers (![alt](docx-image:N)) are read-only projections of embedded images. They cannot be inserted, altered, or removed via text replacement \u2014 edit the text around the image instead.`
3862
+ );
3863
+ }
3864
+ }
3185
3865
  if (t_text.includes("{#") || n_text.includes("{#")) {
3186
3866
  const t_anchors = t_text.match(/\{#[^\}]+\}/g) || [];
3187
3867
  const n_anchors = n_text.match(/\{#[^\}]+\}/g) || [];
@@ -3905,6 +4585,43 @@ var RedlineEngine = class _RedlineEngine {
3905
4585
  element.setAttribute("xml:space", "preserve");
3906
4586
  }
3907
4587
  }
4588
+ /**
4589
+ * Walks `element` to its XML root element. Word (and LibreOffice, which
4590
+ * refuses to LOAD such files) only supports comment ranges in the main
4591
+ * document story ("w:document") — never in headers, footers, footnotes or
4592
+ * endnotes (QA 2026-07-18 H4/C1).
4593
+ */
4594
+ _comment_anchor_in_main_story(element) {
4595
+ let root = element;
4596
+ while (root.parentNode && root.parentNode.nodeType === 1) {
4597
+ root = root.parentNode;
4598
+ }
4599
+ return root.tagName === "w:document";
4600
+ }
4601
+ /**
4602
+ * When the anchor lives outside the main document story, records a
4603
+ * user-visible warning and returns true (caller must skip the comment).
4604
+ * The tracked change itself still applies — only the bubble is dropped.
4605
+ */
4606
+ _skip_comment_outside_main_story(element, text) {
4607
+ if (this._comment_anchor_in_main_story(element)) return false;
4608
+ let root = element;
4609
+ while (root.parentNode && root.parentNode.nodeType === 1) {
4610
+ root = root.parentNode;
4611
+ }
4612
+ const story = {
4613
+ "w:ftr": "footer",
4614
+ "w:hdr": "header",
4615
+ "w:footnotes": "footnote",
4616
+ "w:endnotes": "endnote"
4617
+ }[root.tagName] || "non-body";
4618
+ const msg = `- Warning: the comment "${text.substring(0, 60)}" was NOT attached: Word does not support comments inside a ${story} part, and writing one produces a document other applications cannot open. The tracked change itself was applied.`;
4619
+ this.skipped_details.push(msg);
4620
+ console.error(
4621
+ `Comment anchor outside main story; comment dropped (story=${story})`
4622
+ );
4623
+ return true;
4624
+ }
3908
4625
  /**
3909
4626
  * Attaches a comment that wraps a contiguous range within a single paragraph.
3910
4627
  * start_element and end_element must both be direct children of parent_element
@@ -3913,6 +4630,8 @@ var RedlineEngine = class _RedlineEngine {
3913
4630
  */
3914
4631
  _attach_comment(parent_element, start_element, end_element, text) {
3915
4632
  if (!text) return;
4633
+ if (!parent_element || !start_element || !end_element) return;
4634
+ if (this._skip_comment_outside_main_story(parent_element, text)) return;
3916
4635
  const comment_id = this.comments_manager.addComment(this.author, text);
3917
4636
  const xmlDoc = parent_element.ownerDocument;
3918
4637
  const range_start = xmlDoc.createElement("w:commentRangeStart");
@@ -3946,6 +4665,9 @@ var RedlineEngine = class _RedlineEngine {
3946
4665
  */
3947
4666
  _attach_comment_spanning(start_p, start_el, end_p, end_el, text) {
3948
4667
  if (!text) return;
4668
+ if (!start_p || !end_p) return;
4669
+ if (this._skip_comment_outside_main_story(start_p, text) || this._skip_comment_outside_main_story(end_p, text))
4670
+ return;
3949
4671
  const comment_id = this.comments_manager.addComment(this.author, text);
3950
4672
  const xmlDocStart = start_p.ownerDocument;
3951
4673
  const xmlDocEnd = end_p.ownerDocument;
@@ -4007,7 +4729,7 @@ var RedlineEngine = class _RedlineEngine {
4007
4729
  *
4008
4730
  * Does NOT attach comments; callers handle that.
4009
4731
  */
4010
- _track_insert_multiline(text, anchor_run, anchor_paragraph, reuse_id) {
4732
+ _track_insert_multiline(text, anchor_run, anchor_paragraph, reuse_id, positional_anchor_el = null) {
4011
4733
  if (!text) {
4012
4734
  return {
4013
4735
  first_node: null,
@@ -4028,7 +4750,29 @@ var RedlineEngine = class _RedlineEngine {
4028
4750
  }
4029
4751
  current_p = walker;
4030
4752
  }
4031
- while (lines.length > 1 && lines[lines.length - 1] === "") {
4753
+ const suffix_nodes = [];
4754
+ const pos_source = positional_anchor_el && positional_anchor_el.parentNode ? positional_anchor_el : anchor_run !== null && anchor_run._element.parentNode ? anchor_run._element : null;
4755
+ if (current_p !== null && pos_source !== null) {
4756
+ let pos_anchor = pos_source;
4757
+ while (pos_anchor && pos_anchor.parentNode !== current_p) {
4758
+ pos_anchor = pos_anchor.parentNode;
4759
+ if (pos_anchor === current_p) {
4760
+ pos_anchor = null;
4761
+ break;
4762
+ }
4763
+ }
4764
+ if (pos_anchor) {
4765
+ const relocatable = /* @__PURE__ */ new Set(["w:r", "w:ins", "w:del"]);
4766
+ let nxt = pos_anchor.nextSibling;
4767
+ while (nxt) {
4768
+ if (nxt.nodeType === 1 && relocatable.has(nxt.tagName)) {
4769
+ suffix_nodes.push(nxt);
4770
+ }
4771
+ nxt = nxt.nextSibling;
4772
+ }
4773
+ }
4774
+ }
4775
+ while (lines.length > 1 && lines[lines.length - 1] === "" && suffix_nodes.length === 0) {
4032
4776
  lines.pop();
4033
4777
  }
4034
4778
  if (lines.length === 0) {
@@ -4126,6 +4870,12 @@ var RedlineEngine = class _RedlineEngine {
4126
4870
  first_node = new_p;
4127
4871
  }
4128
4872
  }
4873
+ if (!block_mode && last_p && suffix_nodes.length > 0) {
4874
+ for (const node of suffix_nodes) {
4875
+ node.parentNode?.removeChild(node);
4876
+ last_p.appendChild(node);
4877
+ }
4878
+ }
4129
4879
  return { first_node, last_p, last_ins, used_block_mode: block_mode };
4130
4880
  }
4131
4881
  /**
@@ -4183,7 +4933,7 @@ var RedlineEngine = class _RedlineEngine {
4183
4933
  if (stripped_text.startsWith("* ") || stripped_text.startsWith("- ")) {
4184
4934
  return [stripped_text.substring(2).trim(), "List Paragraph"];
4185
4935
  }
4186
- const match = stripped_text.match(/^\d+\.\s+/);
4936
+ const match = stripped_text.match(/^1\.\s+/);
4187
4937
  if (match) {
4188
4938
  return [stripped_text.substring(match[0].length).trim(), "List Number"];
4189
4939
  }
@@ -4557,6 +5307,51 @@ var RedlineEngine = class _RedlineEngine {
4557
5307
  pfx,
4558
5308
  (edit.new_text || "").length - sfx
4559
5309
  );
5310
+ const multi_part_doc = target_mapper.part_ranges.filter((r) => r[1] > r[0]).length > 1;
5311
+ const raw_span_parts = multi_part_doc ? Array.from(
5312
+ new Set(
5313
+ target_mapper.spans.filter(
5314
+ (s) => s.run !== null && s.end > m_start && s.start < m_start + m_len
5315
+ ).map((s) => s.part_index)
5316
+ )
5317
+ ).sort((a, b) => a - b) : [];
5318
+ if (raw_span_parts.length > 1) {
5319
+ const kinds = raw_span_parts.map((pi) => target_mapper.part_kind_of(pi) || "?").join(" \u2192 ");
5320
+ errors.push(
5321
+ `- Edit ${i + 1 + index_offset} Failed: target_text spans a structural document-part boundary (${kinds}). Headers, body, footers and footnotes are separate Word parts \u2014 an edit cannot cross between them. Anchor the edit on text within a single part (split it into one edit per part if both sides must change).`
5322
+ );
5323
+ }
5324
+ const eff_start = m_start + pfx;
5325
+ const eff_end = m_start + m_len - sfx;
5326
+ if (eff_end > eff_start) {
5327
+ const overlapping = target_mapper.spans.filter(
5328
+ (s) => s.end > eff_start && s.start < eff_end && (s.run !== null || s.text.trim() !== "")
5329
+ );
5330
+ if (overlapping.some((s) => s.is_image_marker)) {
5331
+ errors.push(
5332
+ `- Edit ${i + 1 + index_offset} Failed: the target overlaps a read-only image marker (![alt](docx-image:N)). Images cannot be edited or removed via text replacement \u2014 target the text around the image instead.`
5333
+ );
5334
+ }
5335
+ }
5336
+ if (edit.comment && (edit.new_text || "") === (edit.target_text || "")) {
5337
+ const kind_here = target_mapper.part_kind_at(m_start);
5338
+ if (kind_here !== null && kind_here !== "body") {
5339
+ errors.push(
5340
+ `- Edit ${i + 1 + index_offset} Failed: comments cannot be anchored inside a ${kind_here} part \u2014 Word only supports comments in the main document body. Comment on the related body text instead.`
5341
+ );
5342
+ }
5343
+ }
5344
+ if (_RedlineEngine._introduces_table_row_text(
5345
+ target_mapper,
5346
+ m_start,
5347
+ m_len,
5348
+ final_target,
5349
+ final_new
5350
+ )) {
5351
+ errors.push(
5352
+ `- Edit ${i + 1 + index_offset} Failed: new_text introduces a pipe-delimited row line inside a table. Text replacement cannot create table rows \u2014 use the structured 'insert_row' operation instead (e.g. {"type": "insert_row", "target_text": "<anchor row text>", "cells": ["...", "..."]}).`
5353
+ );
5354
+ }
4560
5355
  if (final_target.includes("\n\n")) {
4561
5356
  const balanced = matched.split("\n\n").length === (edit.new_text || "").split("\n\n").length;
4562
5357
  if (!balanced) {
@@ -4653,6 +5448,19 @@ var RedlineEngine = class _RedlineEngine {
4653
5448
  * [start, start+length) in `mapper`, or null if that text is not inside a
4654
5449
  * table row.
4655
5450
  */
5451
+ /**
5452
+ * True when a replacement anchored in a table would ADD line-separated
5453
+ * pipe-delimited content — the text shape of a table row. Writing that
5454
+ * into a cell renders a fake row inside one cell while the real grid
5455
+ * stays unchanged (QA 2026-07-18 C2); such edits must use insert_row.
5456
+ */
5457
+ static _introduces_table_row_text(mapper, start, length, final_target, final_new) {
5458
+ if (!final_new.includes("\n") || !final_new.includes(" | ")) return false;
5459
+ const new_pipe_lines = final_new.split("\n").filter((line) => line.includes(" | ")).length;
5460
+ const old_pipe_lines = final_target.split("\n").filter((line) => line.includes(" | ")).length;
5461
+ if (new_pipe_lines <= old_pipe_lines) return false;
5462
+ return _RedlineEngine._column_count_at(mapper, start, Math.max(length, 1)) !== null;
5463
+ }
4656
5464
  static _column_count_at(mapper, start, length) {
4657
5465
  for (const s of mapper.spans) {
4658
5466
  if (s.run === null || s.end <= start || s.start >= start + length) {
@@ -5007,14 +5815,17 @@ var RedlineEngine = class _RedlineEngine {
5007
5815
  resolved_edits.push([edit, edit.new_text || null]);
5008
5816
  } else if (edit.type === "insert_row" || edit.type === "delete_row") {
5009
5817
  let matches = this.mapper.find_all_match_indices(edit.target_text);
5818
+ let resolved_mapper = this.mapper;
5010
5819
  if (matches.length === 0) {
5011
5820
  if (!this.clean_mapper) {
5012
5821
  this.clean_mapper = new DocumentMapper(this.doc, true);
5013
5822
  }
5014
5823
  matches = this.clean_mapper.find_all_match_indices(edit.target_text);
5824
+ resolved_mapper = this.clean_mapper;
5015
5825
  }
5016
5826
  if (matches.length > 0) {
5017
5827
  edit._resolved_start_idx = matches[0][0];
5828
+ edit._active_mapper_ref = resolved_mapper;
5018
5829
  resolved_edits.push([edit, null]);
5019
5830
  } else {
5020
5831
  skipped++;
@@ -5081,7 +5892,7 @@ var RedlineEngine = class _RedlineEngine {
5081
5892
  const counted_split_groups = /* @__PURE__ */ new Set();
5082
5893
  for (const [edit, orig_new] of resolved_edits) {
5083
5894
  const start = edit._resolved_start_idx || 0;
5084
- const end = start + (edit.target_text ? edit.target_text.length : 0);
5895
+ const end = edit.type === "insert_row" ? start : start + (edit.target_text ? edit.target_text.length : 0);
5085
5896
  const overlaps = occupied_ranges.some(
5086
5897
  ([occ_start, occ_end]) => start < occ_end && end > occ_start
5087
5898
  );
@@ -5263,7 +6074,8 @@ var RedlineEngine = class _RedlineEngine {
5263
6074
  }
5264
6075
  _apply_table_edit(edit, rebuild_map) {
5265
6076
  const start_idx = edit._resolved_start_idx !== void 0 && edit._resolved_start_idx !== null ? edit._resolved_start_idx : edit._match_start_index || 0;
5266
- const [anchor_run, anchor_para] = this.mapper.get_insertion_anchor(
6077
+ const active_mapper = edit._active_mapper_ref || this.mapper;
6078
+ const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
5267
6079
  start_idx,
5268
6080
  rebuild_map
5269
6081
  );
@@ -5444,9 +6256,14 @@ var RedlineEngine = class _RedlineEngine {
5444
6256
  if (overlaps_virtual_pipe) {
5445
6257
  const actual_cells = actual_doc_text.split("|");
5446
6258
  const new_cells = current_effective_new_text.split("|");
5447
- if (actual_cells.length === new_cells.length && actual_cells.length > 1) {
6259
+ if (actual_cells.length !== new_cells.length) {
6260
+ throw new BatchValidationError([
6261
+ `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).`
6262
+ ]);
6263
+ }
6264
+ if (actual_cells.length > 1) {
5448
6265
  const sub_edits2 = [];
5449
- let search_offset = start_idx;
6266
+ let cell_start_in_target = 0;
5450
6267
  let target_comment_idx = 0;
5451
6268
  for (let idx = 0; idx < actual_cells.length; idx++) {
5452
6269
  if (actual_cells[idx].trim() !== new_cells[idx].trim()) {
@@ -5459,13 +6276,7 @@ var RedlineEngine = class _RedlineEngine {
5459
6276
  const n_cell = new_cells[cell_idx];
5460
6277
  const a_clean = a_cell.trim();
5461
6278
  const n_clean = n_cell.trim();
5462
- let actual_start = search_offset;
5463
- if (a_clean) {
5464
- actual_start = this.mapper.full_text.indexOf(a_clean, search_offset);
5465
- if (actual_start === -1 || actual_start > search_offset + 10) {
5466
- actual_start = search_offset;
5467
- }
5468
- }
6279
+ const actual_start = start_idx + cell_start_in_target + (a_clean ? a_cell.indexOf(a_clean) : 0);
5469
6280
  const should_attach_comment = edit.comment !== null && edit.comment !== void 0 && cell_idx === target_comment_idx;
5470
6281
  if (a_clean !== n_clean || should_attach_comment) {
5471
6282
  const cell_sub_edits = this._word_diff_sub_edits(
@@ -5482,24 +6293,12 @@ var RedlineEngine = class _RedlineEngine {
5482
6293
  sub_edits2.push(se);
5483
6294
  }
5484
6295
  }
5485
- if (a_clean) {
5486
- search_offset = actual_start + a_clean.length;
5487
- }
5488
- const next_pipe = this.mapper.full_text.indexOf(" | ", search_offset);
5489
- if (next_pipe !== -1 && next_pipe <= search_offset + 10) {
5490
- search_offset = next_pipe + 3;
5491
- } else {
5492
- search_offset += a_cell.length + 1;
5493
- }
6296
+ cell_start_in_target += a_cell.length + 1;
5494
6297
  }
5495
6298
  for (const sub of sub_edits2) {
5496
6299
  all_sub_edits.push(sub);
5497
6300
  }
5498
6301
  continue;
5499
- } else {
5500
- throw new BatchValidationError([
5501
- `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).`
5502
- ]);
5503
6302
  }
5504
6303
  }
5505
6304
  let has_markdown = false;
@@ -5593,6 +6392,19 @@ var RedlineEngine = class _RedlineEngine {
5593
6392
  continue;
5594
6393
  }
5595
6394
  }
6395
+ if (!final_target && final_new) {
6396
+ all_sub_edits.push({
6397
+ type: "modify",
6398
+ target_text: "",
6399
+ new_text: final_new,
6400
+ comment: edit.comment,
6401
+ _resolved_start_idx: effective_start_idx,
6402
+ _match_start_index: effective_start_idx,
6403
+ _internal_op: "INSERTION",
6404
+ _active_mapper_ref: active_mapper
6405
+ });
6406
+ continue;
6407
+ }
5596
6408
  const sub_edits = this._word_diff_sub_edits(
5597
6409
  actual_doc_text,
5598
6410
  current_effective_new_text,
@@ -5771,13 +6583,49 @@ var RedlineEngine = class _RedlineEngine {
5771
6583
  return true;
5772
6584
  }
5773
6585
  if (op === "INSERTION") {
5774
- const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
5775
- start_idx,
5776
- rebuild_map
5777
- );
6586
+ let final_new_text = edit.new_text || "";
6587
+ let boundary_anchor = null;
6588
+ const boundary = typeof active_mapper.part_boundary_at === "function" ? active_mapper.part_boundary_at(start_idx) : null;
6589
+ const is_machine_pure_insertion = !edit.target_text && (edit._parent_edit_ref === void 0 || edit._parent_edit_ref === null);
6590
+ if (boundary !== null && is_machine_pure_insertion) {
6591
+ const [prev_i, next_i] = boundary;
6592
+ const prev_kind = active_mapper.part_kind_of(prev_i);
6593
+ const next_kind = active_mapper.part_kind_of(next_i);
6594
+ if (prev_kind === "body" && next_kind !== "body") {
6595
+ const real_before = active_mapper.spans.filter(
6596
+ (s) => s.run !== null && s.part_index === prev_i
6597
+ );
6598
+ if (real_before.length > 0) {
6599
+ boundary_anchor = real_before[real_before.length - 1];
6600
+ }
6601
+ }
6602
+ }
6603
+ let anchor_run;
6604
+ let anchor_para;
6605
+ if (boundary_anchor !== null) {
6606
+ anchor_run = boundary_anchor.run;
6607
+ anchor_para = boundary_anchor.paragraph;
6608
+ if (!final_new_text.startsWith("\n")) {
6609
+ final_new_text = "\n\n" + final_new_text;
6610
+ }
6611
+ } else {
6612
+ [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
6613
+ start_idx,
6614
+ rebuild_map
6615
+ );
6616
+ }
5778
6617
  if (!anchor_run && !anchor_para) return false;
5779
- const _bug233_new = edit.new_text || "";
5780
- const _bug233_trailing_break = /\n\s*$/.test(_bug233_new);
6618
+ if (_RedlineEngine._introduces_table_row_text(
6619
+ active_mapper,
6620
+ start_idx,
6621
+ 1,
6622
+ "",
6623
+ final_new_text
6624
+ )) {
6625
+ return false;
6626
+ }
6627
+ const _bug233_new = final_new_text;
6628
+ const _bug233_trailing_break = boundary_anchor === null && /\n\s*$/.test(_bug233_new);
5781
6629
  let _bug233_target_para = null;
5782
6630
  {
5783
6631
  const startingSpans = active_mapper.spans.filter(
@@ -5847,7 +6695,7 @@ var RedlineEngine = class _RedlineEngine {
5847
6695
  }
5848
6696
  }
5849
6697
  const result = this._track_insert_multiline(
5850
- edit.new_text || "",
6698
+ final_new_text,
5851
6699
  anchor_run,
5852
6700
  anchor_para,
5853
6701
  ins_id
@@ -5936,6 +6784,20 @@ var RedlineEngine = class _RedlineEngine {
5936
6784
  }
5937
6785
  return true;
5938
6786
  }
6787
+ if ((op === "DELETION" || op === "MODIFICATION") && length && active_mapper.part_ranges.filter((r) => r[1] > r[0]).length > 1) {
6788
+ const crossed_parts = /* @__PURE__ */ new Set();
6789
+ for (const s of active_mapper.spans) {
6790
+ if (s.run !== null && s.end > start_idx && s.start < start_idx + length) {
6791
+ crossed_parts.add(s.part_index);
6792
+ }
6793
+ }
6794
+ if (crossed_parts.size > 1) {
6795
+ console.error(
6796
+ `Refusing edit that spans OPC part boundary (start=${start_idx}, parts=${Array.from(crossed_parts).sort().join(",")})`
6797
+ );
6798
+ return false;
6799
+ }
6800
+ }
5939
6801
  const target_runs = active_mapper.find_target_runs_by_index(
5940
6802
  start_idx,
5941
6803
  length,
@@ -5984,7 +6846,10 @@ var RedlineEngine = class _RedlineEngine {
5984
6846
  edit.new_text,
5985
6847
  style_source_run,
5986
6848
  mod_anchor_para,
5987
- ins_id
6849
+ ins_id,
6850
+ // The insertion physically follows the deletion block; the style
6851
+ // run was detached when the deletion cloned it into <w:del>.
6852
+ last_del
5988
6853
  );
5989
6854
  if (result.first_node) {
5990
6855
  const is_inline_first = result.first_node.tagName === "w:ins";
@@ -6531,7 +7396,10 @@ function scrub_doc_properties(doc) {
6531
7396
  ["keywords", "Keywords"],
6532
7397
  ["subject", "Subject"],
6533
7398
  ["contentStatus", "Content status"],
6534
- ["description", "Description/comments"]
7399
+ ["description", "Description/comments"],
7400
+ ["identifier", "Identifier"],
7401
+ ["language", "Language"],
7402
+ ["version", "Version"]
6535
7403
  ];
6536
7404
  for (const [local, label] of leakFields) {
6537
7405
  findDescendantsByLocalName(corePart._element, local).forEach((c) => {
@@ -6589,27 +7457,73 @@ function scrub_timestamps(doc) {
6589
7457
  }
6590
7458
  return modified ? ["Timestamps normalized to epoch"] : [];
6591
7459
  }
6592
- function strip_custom_xml(doc) {
6593
- const customParts = doc.pkg.parts.filter((p) => p.partname.includes("/customXml"));
6594
- if (customParts.length === 0) return [];
6595
- const partnames = new Set(customParts.map((p) => p.partname));
6596
- doc.pkg.parts = doc.pkg.parts.filter((p) => !partnames.has(p.partname));
6597
- const removeRelationsTo = (relsPart) => {
7460
+ function ejectPackageMembers(doc, matcher, relTargetMatcher) {
7461
+ const pkg = doc.pkg;
7462
+ const normalized = (p) => p.startsWith("/") ? p.substring(1) : p;
7463
+ for (const part of pkg.parts) {
7464
+ if (!part.partname.endsWith(".rels")) continue;
6598
7465
  const toRemove = [];
6599
- for (const rel of findAllDescendants(relsPart._element, "Relationship")) {
6600
- const target = rel.getAttribute("Target");
6601
- if (target && target.includes("customXml")) toRemove.push(rel);
7466
+ for (const rel of findAllDescendants(part._element, "Relationship")) {
7467
+ const target = rel.getAttribute("Target") || "";
7468
+ if (relTargetMatcher(target)) {
7469
+ toRemove.push(rel);
7470
+ const sourcePath = part.partname.replace("/_rels/", "/").replace(".rels", "");
7471
+ const sourcePart = pkg.getPartByPath(sourcePath);
7472
+ if (sourcePart) {
7473
+ const relId = rel.getAttribute("Id");
7474
+ if (relId) sourcePart.rels.delete(relId);
7475
+ }
7476
+ }
6602
7477
  }
6603
7478
  toRemove.forEach((r) => r.parentNode?.removeChild(r));
6604
- };
6605
- const rootRels = doc.pkg.getPartByPath("_rels/.rels");
6606
- if (rootRels) removeRelationsTo(rootRels);
6607
- const docRels = doc.pkg.getOrCreateRelsPart(doc.part.partname);
6608
- if (docRels) removeRelationsTo(docRels);
7479
+ }
7480
+ const ctPart = pkg.getPartByPath("[Content_Types].xml");
7481
+ if (ctPart) {
7482
+ const toRemove = [];
7483
+ for (const override of findAllDescendants(ctPart._element, "Override")) {
7484
+ const partName = override.getAttribute("PartName") || "";
7485
+ if (matcher(normalized(partName))) toRemove.push(override);
7486
+ }
7487
+ toRemove.forEach((o) => o.parentNode?.removeChild(o));
7488
+ }
7489
+ pkg.parts = pkg.parts.filter((p) => !matcher(normalized(p.partname)));
7490
+ for (const key of Object.keys(pkg.unzipped)) {
7491
+ if (matcher(normalized(key))) delete pkg.unzipped[key];
7492
+ }
7493
+ }
7494
+ function strip_custom_xml(doc) {
7495
+ const isCustomXml = (path) => path.startsWith("customXml/");
7496
+ const customParts = doc.pkg.parts.filter(
7497
+ (p) => isCustomXml(p.partname.startsWith("/") ? p.partname.substring(1) : p.partname)
7498
+ );
7499
+ const leakedMembers = Object.keys(doc.pkg.unzipped).filter(isCustomXml);
7500
+ if (customParts.length === 0 && leakedMembers.length === 0) return [];
7501
+ ejectPackageMembers(doc, isCustomXml, (target) => target.includes("customXml"));
6609
7502
  for (const sdtPr of findAllDescendants(doc.element, "w:sdtPr")) {
6610
7503
  findChildren(sdtPr, "w:dataBinding").forEach((b) => sdtPr.removeChild(b));
6611
7504
  }
6612
- return [`Custom XML parts: ${customParts.length} removed`];
7505
+ return [`Custom XML parts: ${Math.max(customParts.length, leakedMembers.length)} removed`];
7506
+ }
7507
+ var CUSTOM_PROPS_PATH = "docProps/custom.xml";
7508
+ function strip_custom_properties(doc) {
7509
+ const part = doc.pkg.getPartByPath(CUSTOM_PROPS_PATH);
7510
+ const leaked = Object.keys(doc.pkg.unzipped).includes(CUSTOM_PROPS_PATH);
7511
+ if (!part && !leaked) return [];
7512
+ const lines = [];
7513
+ if (part) {
7514
+ for (const prop of findDescendantsByLocalName(part._element, "property")) {
7515
+ const name = prop.getAttribute("name") || "(unnamed)";
7516
+ const value = (prop.textContent || "").trim();
7517
+ lines.push(` Custom property removed: ${name} = "${_truncate(value, 60)}"`);
7518
+ }
7519
+ }
7520
+ ejectPackageMembers(
7521
+ doc,
7522
+ (path) => path === CUSTOM_PROPS_PATH,
7523
+ (target) => target.includes("docProps/custom.xml")
7524
+ );
7525
+ const count = Math.max(lines.length, 1);
7526
+ return [`Custom document properties: ${count} removed (docProps/custom.xml)`].concat(lines);
6613
7527
  }
6614
7528
  function strip_image_alt_text(doc) {
6615
7529
  let count = 0;
@@ -6626,6 +7540,35 @@ function strip_image_alt_text(doc) {
6626
7540
  }
6627
7541
  return count ? [`Image alt text: ${count} auto-generated descriptions removed`] : [];
6628
7542
  }
7543
+ function detect_watermarks(doc) {
7544
+ const warnings = [];
7545
+ const seen = /* @__PURE__ */ new Set();
7546
+ for (const part of doc.pkg.parts) {
7547
+ const name = String(part.partname);
7548
+ let location;
7549
+ if (name.startsWith("/word/header")) location = "header";
7550
+ else if (name.startsWith("/word/footer")) location = "footer";
7551
+ else if (name === "/word/document.xml") location = "body";
7552
+ else continue;
7553
+ let textpaths;
7554
+ try {
7555
+ textpaths = findDescendantsByLocalName(part._element, "textpath");
7556
+ } catch {
7557
+ continue;
7558
+ }
7559
+ for (const tp of textpaths) {
7560
+ const text = (tp.getAttribute("string") || "").trim();
7561
+ const key = `${location}${text}`;
7562
+ if (seen.has(key)) continue;
7563
+ seen.add(key);
7564
+ const label = text ? `"${_truncate(text, 60)}"` : "(no text)";
7565
+ warnings.push(
7566
+ `Watermark-like text object in ${location}: ${label} \u2014 NOT removed. It remains in the document package and may render in other applications; review and remove it in Word if it must not reach the counterparty.`
7567
+ );
7568
+ }
7569
+ }
7570
+ return warnings;
7571
+ }
6629
7572
  function audit_hyperlinks(doc) {
6630
7573
  const internal = ["sharepoint.com", "onedrive.com", ".internal", "intranet", "localhost", "10.", "192.168.", "172.16."];
6631
7574
  const warnings = [];
@@ -6678,25 +7621,53 @@ function _get_paragraph_text(p) {
6678
7621
  }
6679
7622
  return text;
6680
7623
  }
7624
+ var _TERM_BODY = "[A-Z][A-Za-z0-9\\s\\-&'\u2019]{1,60}";
7625
+ var _LEADING_TERM_RE = new RegExp(
7626
+ `^(?:[\\d.\\-()a-zA-Z]+\\s*)?["\u201C](${_TERM_BODY})["\u201D]`,
7627
+ "d"
7628
+ );
7629
+ var _SENTENCE_TERM_RE = new RegExp(
7630
+ `(?<=[.;:!?])\\s+(?:[\\d.\\-()a-zA-Z]+\\s+)?["\u201C](${_TERM_BODY})["\u201D]`,
7631
+ "dg"
7632
+ );
7633
+ var _INLINE_TERM_RE = new RegExp(
7634
+ `\\([^)]*?["\u201C](${_TERM_BODY})["\u201D][^)]*?\\)`,
7635
+ "dg"
7636
+ );
7637
+ function extract_terms_from_paragraph(text) {
7638
+ const found = [];
7639
+ const leading = _LEADING_TERM_RE.exec(text);
7640
+ if (leading && leading.indices && leading.indices[1]) {
7641
+ found.push([leading.indices[1][0], leading[1].trim()]);
7642
+ }
7643
+ for (const m of text.matchAll(_SENTENCE_TERM_RE)) {
7644
+ const indices = m.indices;
7645
+ if (indices && indices[1]) found.push([indices[1][0], m[1].trim()]);
7646
+ }
7647
+ for (const m of text.matchAll(_INLINE_TERM_RE)) {
7648
+ const indices = m.indices;
7649
+ if (indices && indices[1]) found.push([indices[1][0], m[1].trim()]);
7650
+ }
7651
+ const terms = [];
7652
+ const seen_positions = /* @__PURE__ */ new Set();
7653
+ found.sort((a, b) => a[0] - b[0]);
7654
+ for (const [pos, term] of found) {
7655
+ if (seen_positions.has(pos)) continue;
7656
+ seen_positions.add(pos);
7657
+ terms.push(term);
7658
+ }
7659
+ return terms;
7660
+ }
6681
7661
  function extract_all_domain_metadata(doc, base_text) {
6682
7662
  const definitions = {};
6683
7663
  const duplicates = /* @__PURE__ */ new Set();
6684
7664
  const raw_anchors = {};
6685
7665
  const raw_references = [];
6686
- const leading_re = /^(?:[\d.\-()a-zA-Z]+\s*)?["“]([A-Z][A-Za-z0-9\s\-&'’]{1,60})["”]/;
6687
- const inline_re = /\([^)]*?["“]([A-Z][A-Za-z0-9\s\-&'’]{1,60})["”][^)]*?\)/g;
6688
7666
  for (const item of iter_block_items(doc)) {
6689
7667
  if (!(item instanceof Paragraph)) continue;
6690
7668
  const text = _get_paragraph_text(item).trim();
6691
7669
  if (!text) continue;
6692
- const extracted_terms = [];
6693
- const leading_match = text.match(leading_re);
6694
- if (leading_match) extracted_terms.push(leading_match[1].trim());
6695
- const inline_matches = text.matchAll(inline_re);
6696
- for (const m of inline_matches) {
6697
- extracted_terms.push(m[1].trim());
6698
- }
6699
- for (const term of extracted_terms) {
7670
+ for (const term of extract_terms_from_paragraph(text)) {
6700
7671
  if (definitions[term]) duplicates.add(term);
6701
7672
  else definitions[term] = { count: 0 };
6702
7673
  }
@@ -6924,27 +7895,32 @@ function build_structural_appendix(doc, base_text) {
6924
7895
  }
6925
7896
 
6926
7897
  // src/ingest.ts
6927
- async function extractTextFromBuffer(buffer, cleanView = false) {
7898
+ async function extractTextFromBuffer(buffer, cleanView = false, includeAppendix = true) {
6928
7899
  const doc = await DocumentObject.load(buffer);
6929
- return _extractTextFromDoc(doc, cleanView);
7900
+ return _extractTextFromDoc(doc, cleanView, includeAppendix);
6930
7901
  }
6931
- function _extractTextFromDoc(doc, cleanView = false, includeAppendix = true, return_paragraph_offsets = false) {
7902
+ function _extractTextFromDoc(doc, cleanView = false, includeAppendix = true, return_paragraph_offsets = false, return_structure = false) {
6932
7903
  const comments_map = extract_comments_data(doc.pkg);
6933
7904
  const full_text = [];
6934
7905
  const paragraph_offsets = /* @__PURE__ */ new Map();
7906
+ const structure = return_structure ? { part_ranges: [], tables: [] } : null;
6935
7907
  let cursor = 0;
6936
- for (const part of iter_document_parts(doc)) {
7908
+ for (const [part, part_kind] of iter_document_parts_with_kind(doc)) {
6937
7909
  const part_cursor = full_text.length > 0 ? cursor + 2 : cursor;
6938
7910
  const part_text = _extract_blocks(
6939
7911
  part,
6940
7912
  comments_map,
6941
7913
  cleanView,
6942
7914
  part_cursor,
6943
- return_paragraph_offsets ? paragraph_offsets : void 0
7915
+ return_paragraph_offsets ? paragraph_offsets : void 0,
7916
+ structure ? structure.tables : void 0
6944
7917
  );
6945
7918
  if (part_text) {
6946
7919
  if (full_text.length > 0) cursor += 2;
6947
7920
  full_text.push(part_text);
7921
+ if (structure) {
7922
+ structure.part_ranges.push([cursor, cursor + part_text.length, part_kind]);
7923
+ }
6948
7924
  cursor += part_text.length;
6949
7925
  }
6950
7926
  }
@@ -6956,9 +7932,12 @@ function _extractTextFromDoc(doc, cleanView = false, includeAppendix = true, ret
6956
7932
  if (return_paragraph_offsets) {
6957
7933
  return { text: base_text, paragraph_offsets };
6958
7934
  }
7935
+ if (structure) {
7936
+ return { text: base_text, structure };
7937
+ }
6959
7938
  return base_text;
6960
7939
  }
6961
- function _extract_blocks(container, comments_map, cleanView, cursor, paragraph_offsets) {
7940
+ function _extract_blocks(container, comments_map, cleanView, cursor, paragraph_offsets, table_acc) {
6962
7941
  const part = container.part || container;
6963
7942
  const [style_cache, default_pstyle] = _get_style_cache(part);
6964
7943
  const blocks = [];
@@ -7012,17 +7991,23 @@ ${header}`;
7012
7991
  is_first_para = false;
7013
7992
  is_first_block = false;
7014
7993
  } else if (item instanceof Table) {
7994
+ const geometry = table_acc ? { start: block_start, end: block_start, rows: [] } : null;
7015
7995
  const table_text = extract_table(
7016
7996
  item,
7017
7997
  comments_map,
7018
7998
  cleanView,
7019
7999
  block_start,
7020
- paragraph_offsets
8000
+ paragraph_offsets,
8001
+ geometry
7021
8002
  );
7022
8003
  if (table_text) {
7023
8004
  blocks.push(table_text);
7024
8005
  local_cursor = block_start + table_text.length;
7025
8006
  is_first_block = false;
8007
+ if (geometry && table_acc) {
8008
+ geometry.end = block_start + table_text.length;
8009
+ table_acc.push(geometry);
8010
+ }
7026
8011
  } else if (!is_first_block) {
7027
8012
  local_cursor -= 2;
7028
8013
  }
@@ -7031,7 +8016,7 @@ ${header}`;
7031
8016
  }
7032
8017
  return blocks.join("\n\n");
7033
8018
  }
7034
- function extract_table(table, comments_map, cleanView, cursor, paragraph_offsets) {
8019
+ function extract_table(table, comments_map, cleanView, cursor, paragraph_offsets, geometry) {
7035
8020
  const rows_text = [];
7036
8021
  let rows_processed = 0;
7037
8022
  let local_cursor = cursor;
@@ -7077,6 +8062,13 @@ function extract_table(table, comments_map, cleanView, cursor, paragraph_offsets
7077
8062
  }
7078
8063
  rows_text.push(row_str);
7079
8064
  local_cursor = row_start + row_str.length;
8065
+ if (geometry) {
8066
+ geometry.rows.push({
8067
+ start: row_start,
8068
+ end: local_cursor,
8069
+ cells: [...cell_texts]
8070
+ });
8071
+ }
7080
8072
  rows_processed++;
7081
8073
  }
7082
8074
  return rows_text.join("\n");
@@ -7228,7 +8220,12 @@ function build_paragraph_text(paragraph, comments_map, cleanView, style_cache, d
7228
8220
  else if (ev.type === "del_end") delete active_del[ev.id];
7229
8221
  else if (ev.type === "fmt_start") active_fmt[ev.id] = ev;
7230
8222
  else if (ev.type === "fmt_end") delete active_fmt[ev.id];
7231
- else if (ev.type === "footnote" || ev.type === "endnote") {
8223
+ else if (ev.type === "image") {
8224
+ if (!(cleanView && Object.keys(active_del).length > 0)) {
8225
+ const alt = (ev.date || "image").replace(/\]/g, ")").replace(/\n/g, " ");
8226
+ parts.push(`![${alt}](docx-image:${ev.id})`);
8227
+ }
8228
+ } else if (ev.type === "footnote" || ev.type === "endnote") {
7232
8229
  if (pending_text) {
7233
8230
  parts.push(
7234
8231
  `${current_wrappers[0]}${pending_text}${current_wrappers[1]}`
@@ -7880,6 +8877,9 @@ function _collect_footnote_ids_fast(owned_items) {
7880
8877
  return ordered;
7881
8878
  }
7882
8879
 
8880
+ // src/sanitize/core.ts
8881
+ var import_fflate2 = require("fflate");
8882
+
7883
8883
  // src/sanitize/report.ts
7884
8884
  var SanitizeReport = class {
7885
8885
  filename;
@@ -7914,7 +8914,7 @@ var SanitizeReport = class {
7914
8914
  } else {
7915
8915
  this.removed_comment_lines.push(line);
7916
8916
  }
7917
- } 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")) {
8917
+ } 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")) {
7918
8918
  this.metadata_lines.push(line);
7919
8919
  } else if (lower.includes("hyperlink") || lower.includes("warning")) {
7920
8920
  this.warnings.push(line);
@@ -8027,9 +9027,11 @@ async function finalize_document(doc, options) {
8027
9027
  report.add_transform_lines(scrub_doc_properties(doc));
8028
9028
  report.add_transform_lines(scrub_timestamps(doc));
8029
9029
  report.add_transform_lines(strip_custom_xml(doc));
9030
+ report.add_transform_lines(strip_custom_properties(doc));
8030
9031
  report.add_transform_lines(strip_image_alt_text(doc));
8031
9032
  const warnings = audit_hyperlinks(doc);
8032
9033
  for (const w of warnings) report.warnings.push(w);
9034
+ for (const w of detect_watermarks(doc)) report.warnings.push(w);
8033
9035
  report.add_transform_lines(normalize_change_dates(doc));
8034
9036
  if (options.protection_mode === "read_only" || options.protection_mode === "encrypt") {
8035
9037
  if (options.protection_mode === "encrypt") {
@@ -8073,8 +9075,47 @@ async function finalize_document(doc, options) {
8073
9075
  }
8074
9076
  if (report.warnings.length > 0) report.status = "clean_with_warnings";
8075
9077
  const outBuffer = await doc.save();
9078
+ verifySanitizedPackage(outBuffer);
8076
9079
  return { reportText: report.render(), outBuffer };
8077
9080
  }
9081
+ var VERIFIED_CORE_FIELDS = [
9082
+ ["creator", "author (dc:creator)"],
9083
+ ["lastModifiedBy", "last modified by (cp:lastModifiedBy)"],
9084
+ ["identifier", "identifier (dc:identifier)"],
9085
+ ["description", "description (dc:description)"],
9086
+ ["keywords", "keywords (cp:keywords)"],
9087
+ ["category", "category (cp:category)"],
9088
+ ["subject", "subject (dc:subject)"],
9089
+ ["contentStatus", "content status (cp:contentStatus)"],
9090
+ ["language", "language (dc:language)"],
9091
+ ["version", "version (cp:version)"]
9092
+ ];
9093
+ function verifySanitizedPackage(outBuffer) {
9094
+ const unzipped = (0, import_fflate2.unzipSync)(new Uint8Array(outBuffer));
9095
+ const names = Object.keys(unzipped);
9096
+ const problems = [];
9097
+ if (names.includes("docProps/custom.xml")) {
9098
+ problems.push("docProps/custom.xml (custom document properties) is still in the package");
9099
+ }
9100
+ if (names.some((n) => n.startsWith("customXml/"))) {
9101
+ problems.push("customXml/* parts are still in the package");
9102
+ }
9103
+ if (names.includes("docProps/core.xml")) {
9104
+ const core = parseXml((0, import_fflate2.strFromU8)(unzipped["docProps/core.xml"]));
9105
+ for (const [local, label] of VERIFIED_CORE_FIELDS) {
9106
+ for (const el of findDescendantsByLocalName(core.documentElement, local)) {
9107
+ if ((el.textContent || "").trim()) {
9108
+ problems.push(`core property ${label} still contains a value`);
9109
+ }
9110
+ }
9111
+ }
9112
+ }
9113
+ if (problems.length > 0) {
9114
+ throw new Error(
9115
+ "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."
9116
+ );
9117
+ }
9118
+ }
8078
9119
 
8079
9120
  // src/index.ts
8080
9121
  function identifyEngine() {
@@ -8097,6 +9138,7 @@ function identifyEngine() {
8097
9138
  extract_outline,
8098
9139
  finalize_document,
8099
9140
  generate_edits_from_text,
9141
+ generate_structured_edits,
8100
9142
  identifyEngine,
8101
9143
  paginate,
8102
9144
  split_structural_appendix,