@adeu/core 1.22.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -253,6 +253,13 @@ var DocumentObject = class _DocumentObject {
253
253
  async save() {
254
254
  for (const part of this.pkg.parts) {
255
255
  let xmlStr = serializeXml(part._element.ownerDocument || part._element);
256
+ if (xmlStr.includes("w16du:") && !xmlStr.includes("xmlns:w16du=")) {
257
+ part._element.setAttribute(
258
+ "xmlns:w16du",
259
+ "http://schemas.microsoft.com/office/word/2023/wordml/word16du"
260
+ );
261
+ xmlStr = serializeXml(part._element.ownerDocument || part._element);
262
+ }
256
263
  if (!xmlStr.startsWith("<?xml")) {
257
264
  xmlStr = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + xmlStr;
258
265
  }
@@ -840,7 +847,27 @@ var QN_W_OUTLINELVL = "w:outlineLvl";
840
847
  var QN_W_NUMPR = "w:numPr";
841
848
  var QN_W_NUMID = "w:numId";
842
849
  var QN_W_ILVL = "w:ilvl";
850
+ var QN_W_DRAWING = "w:drawing";
851
+ var QN_W_OBJECT = "w:object";
852
+ var QN_W_PICT = "w:pict";
853
+ var QN_WP_DOCPR = "wp:docPr";
854
+ var QN_V_IMAGEDATA = "v:imagedata";
855
+ var QN_O_TITLE = "o:title";
843
856
  var _CUSTOM_HEADING_NAME_RE = /Heading[ ]?([1-6])(?![0-9])/;
857
+ function _resolve_package(obj) {
858
+ let cur = obj;
859
+ const seen = /* @__PURE__ */ new Set();
860
+ while (cur && !seen.has(cur)) {
861
+ seen.add(cur);
862
+ if (cur.package) return cur.package;
863
+ if (cur.pkg) return cur.pkg;
864
+ if (cur.part && (cur.part.package || cur.part.pkg)) {
865
+ return cur.part.package || cur.part.pkg;
866
+ }
867
+ cur = cur._parent || cur.part || null;
868
+ }
869
+ return null;
870
+ }
844
871
  function _get_style_cache(part) {
845
872
  const pkg = part.package || part.pkg || (part.part ? part.part.pkg : null);
846
873
  if (pkg && pkg._adeu_style_cache) {
@@ -874,6 +901,8 @@ function _get_style_cache(part) {
874
901
  const based_on_el = findChild(s, "w:basedOn");
875
902
  const based_on = based_on_el ? based_on_el.getAttribute("w:val") : null;
876
903
  let outline_lvl = null;
904
+ let num_id = null;
905
+ let num_ilvl = null;
877
906
  const pPr = findChild(s, "w:pPr");
878
907
  if (pPr) {
879
908
  const oLvl = findChild(pPr, "w:outlineLvl");
@@ -881,6 +910,19 @@ function _get_style_cache(part) {
881
910
  const val = oLvl.getAttribute("w:val");
882
911
  if (val && /^\d+$/.test(val)) outline_lvl = parseInt(val, 10);
883
912
  }
913
+ const numPr = findChild(pPr, "w:numPr");
914
+ if (numPr) {
915
+ const numId_el = findChild(numPr, "w:numId");
916
+ if (numId_el) {
917
+ const n_val = numId_el.getAttribute("w:val");
918
+ if (n_val && n_val !== "0") num_id = n_val;
919
+ }
920
+ const ilvl_el = findChild(numPr, "w:ilvl");
921
+ if (ilvl_el) {
922
+ const i_val = ilvl_el.getAttribute("w:val");
923
+ if (i_val && /^\d+$/.test(i_val)) num_ilvl = parseInt(i_val, 10);
924
+ }
925
+ }
884
926
  }
885
927
  let bold = null;
886
928
  const rPr = findChild(s, "w:rPr");
@@ -891,23 +933,46 @@ function _get_style_cache(part) {
891
933
  bold = val !== "0" && val !== "false" && val !== "off";
892
934
  }
893
935
  }
894
- raw_styles[s_id] = { name, based_on, outline_level: outline_lvl, bold };
936
+ raw_styles[s_id] = {
937
+ name,
938
+ based_on,
939
+ outline_level: outline_lvl,
940
+ bold,
941
+ num_id,
942
+ num_ilvl
943
+ };
895
944
  }
896
945
  const resolve_style = (s_id, visited) => {
897
946
  if (cache[s_id]) return cache[s_id];
898
947
  if (visited.has(s_id) || !raw_styles[s_id])
899
- return { name: s_id, outline_level: null, bold: false };
948
+ return {
949
+ name: s_id,
950
+ outline_level: null,
951
+ bold: false,
952
+ num_id: null,
953
+ num_ilvl: null
954
+ };
900
955
  visited.add(s_id);
901
956
  const raw = raw_styles[s_id];
902
957
  const based_on_id = raw.based_on;
903
958
  let o_lvl = raw.outline_level;
904
959
  let bold_val = raw.bold !== null ? raw.bold : false;
960
+ let n_id = raw.num_id;
961
+ let n_ilvl = raw.num_ilvl;
905
962
  if (based_on_id) {
906
963
  const parent = resolve_style(based_on_id, visited);
907
964
  if (o_lvl === null) o_lvl = parent.outline_level;
908
965
  if (raw.bold === null) bold_val = parent.bold;
909
- }
910
- const resolved = { name: raw.name, outline_level: o_lvl, bold: bold_val };
966
+ if (n_id === null) n_id = parent.num_id ?? null;
967
+ if (n_ilvl === null) n_ilvl = parent.num_ilvl ?? null;
968
+ }
969
+ const resolved = {
970
+ name: raw.name,
971
+ outline_level: o_lvl,
972
+ bold: bold_val,
973
+ num_id: n_id,
974
+ num_ilvl: n_ilvl
975
+ };
911
976
  cache[s_id] = resolved;
912
977
  return resolved;
913
978
  };
@@ -916,6 +981,68 @@ function _get_style_cache(part) {
916
981
  if (pkg) pkg._adeu_style_cache = result;
917
982
  return result;
918
983
  }
984
+ function _get_numbering_cache(part) {
985
+ const pkg = _resolve_package(part);
986
+ if (!pkg) return {};
987
+ if (pkg._adeu_numbering_cache) return pkg._adeu_numbering_cache;
988
+ const cache = {};
989
+ let numbering_root = null;
990
+ try {
991
+ const numberingPart = (pkg.parts || []).find(
992
+ (p) => String(p.partname).endsWith("/numbering.xml")
993
+ );
994
+ if (numberingPart) numbering_root = numberingPart._element;
995
+ } catch {
996
+ numbering_root = null;
997
+ }
998
+ if (numbering_root) {
999
+ const abstract_fmts = {};
1000
+ for (const abstract of findAllDescendants(numbering_root, "w:abstractNum")) {
1001
+ const a_id = abstract.getAttribute("w:abstractNumId");
1002
+ if (a_id === null) continue;
1003
+ const lvl_map = {};
1004
+ for (const lvl of findAllDescendants(abstract, "w:lvl")) {
1005
+ const ilvl_val = lvl.getAttribute("w:ilvl");
1006
+ const fmt_el = findChild(lvl, "w:numFmt");
1007
+ if (ilvl_val !== null && /^-?\d+$/.test(ilvl_val) && fmt_el) {
1008
+ const fmt = fmt_el.getAttribute("w:val");
1009
+ if (fmt) lvl_map[parseInt(ilvl_val, 10)] = fmt;
1010
+ }
1011
+ }
1012
+ abstract_fmts[a_id] = lvl_map;
1013
+ }
1014
+ for (const num of findAllDescendants(numbering_root, "w:num")) {
1015
+ const n_id = num.getAttribute("w:numId");
1016
+ const a_ref = findChild(num, "w:abstractNumId");
1017
+ if (n_id === null || !a_ref) continue;
1018
+ const a_id = a_ref.getAttribute("w:val");
1019
+ if (a_id !== null && abstract_fmts[a_id] !== void 0) {
1020
+ cache[n_id] = abstract_fmts[a_id];
1021
+ }
1022
+ }
1023
+ }
1024
+ pkg._adeu_numbering_cache = cache;
1025
+ return cache;
1026
+ }
1027
+ function get_list_marker(paragraph_part, num_id, ilvl) {
1028
+ let fmt = null;
1029
+ if (num_id !== null && num_id !== void 0) {
1030
+ const lvl_map = _get_numbering_cache(paragraph_part)[num_id];
1031
+ if (lvl_map && Object.keys(lvl_map).length > 0) {
1032
+ fmt = lvl_map[ilvl] !== void 0 ? lvl_map[ilvl] : null;
1033
+ if (fmt === null) {
1034
+ for (let lookup = ilvl; lookup >= 0; lookup--) {
1035
+ if (lvl_map[lookup] !== void 0) {
1036
+ fmt = lvl_map[lookup];
1037
+ break;
1038
+ }
1039
+ }
1040
+ }
1041
+ }
1042
+ }
1043
+ if (fmt !== null && fmt !== "bullet") return "1. ";
1044
+ return "* ";
1045
+ }
919
1046
  function _detect_heading_level_from_name(name) {
920
1047
  if (!name) return null;
921
1048
  const match = name.match(_CUSTOM_HEADING_NAME_RE);
@@ -993,21 +1120,48 @@ function get_paragraph_prefix(paragraph, style_cache, default_pstyle) {
993
1120
  if (/^\d+$/.test(match)) return "#".repeat(parseInt(match, 10)) + " ";
994
1121
  }
995
1122
  if (style_name === "Title") return "# ";
1123
+ let list_num_id = null;
1124
+ let list_ilvl = null;
1125
+ let numbering_disabled = false;
996
1126
  if (pPr) {
997
1127
  const numPr = findChild(pPr, QN_W_NUMPR);
998
1128
  if (numPr) {
999
1129
  const numId = findChild(numPr, QN_W_NUMID);
1000
- if (numId && numId.getAttribute(QN_W_VAL) !== "0") {
1001
- let level = 0;
1002
- const ilvl = findChild(numPr, QN_W_ILVL);
1003
- if (ilvl) {
1004
- const valAttr = ilvl.getAttribute(QN_W_VAL);
1005
- if (valAttr) level = parseInt(valAttr, 10) || 0;
1130
+ if (numId) {
1131
+ const val = numId.getAttribute(QN_W_VAL);
1132
+ if (val === "0") {
1133
+ numbering_disabled = true;
1134
+ } else if (val) {
1135
+ list_num_id = val;
1136
+ const ilvl = findChild(numPr, QN_W_ILVL);
1137
+ if (ilvl) {
1138
+ const valAttr = ilvl.getAttribute(QN_W_VAL);
1139
+ if (valAttr !== null && /^\d+$/.test(valAttr)) {
1140
+ list_ilvl = parseInt(valAttr, 10);
1141
+ }
1142
+ }
1006
1143
  }
1007
- return " ".repeat(level) + "* ";
1008
1144
  }
1009
1145
  }
1010
1146
  }
1147
+ if (list_num_id === null && !numbering_disabled && style_info) {
1148
+ const style_num_id = style_info.num_id;
1149
+ if (style_num_id) {
1150
+ list_num_id = style_num_id;
1151
+ if (list_ilvl === null) {
1152
+ list_ilvl = style_info.num_ilvl !== void 0 ? style_info.num_ilvl : null;
1153
+ }
1154
+ }
1155
+ }
1156
+ if (list_num_id !== null) {
1157
+ const level = list_ilvl !== null ? list_ilvl : 0;
1158
+ const marker = get_list_marker(
1159
+ paragraph._parent ? paragraph._parent.part || paragraph._parent : null,
1160
+ list_num_id,
1161
+ level
1162
+ );
1163
+ return " ".repeat(level) + marker;
1164
+ }
1011
1165
  if (style_name && style_name !== "Normal") {
1012
1166
  const custom_level = _detect_heading_level_from_name(style_name);
1013
1167
  if (custom_level !== null) return "#".repeat(custom_level) + " ";
@@ -1105,6 +1259,9 @@ function* iter_block_items(parent) {
1105
1259
  for (const child of notes) {
1106
1260
  if (child.getAttribute("w:type") === "separator" || child.getAttribute("w:type") === "continuationSeparator")
1107
1261
  continue;
1262
+ const note_id = child.getAttribute("w:id");
1263
+ if (note_id !== null && /^\s*[-+]?\d+\s*$/.test(note_id) && parseInt(note_id, 10) <= 0)
1264
+ continue;
1108
1265
  yield new FootnoteItem(child, parent, parent.note_type);
1109
1266
  }
1110
1267
  return;
@@ -1120,19 +1277,24 @@ function* iter_block_items(parent) {
1120
1277
  }
1121
1278
  }
1122
1279
  function* iter_document_parts(doc) {
1280
+ for (const [container] of iter_document_parts_with_kind(doc)) {
1281
+ yield container;
1282
+ }
1283
+ }
1284
+ function* iter_document_parts_with_kind(doc) {
1123
1285
  const headers = doc.pkg.parts.filter(
1124
1286
  (p) => p.contentType === "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"
1125
1287
  );
1126
- for (const h of headers) yield h;
1127
- yield doc;
1288
+ for (const h of headers) yield [h, "header"];
1289
+ yield [doc, "body"];
1128
1290
  const footers = doc.pkg.parts.filter(
1129
1291
  (p) => p.contentType === "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"
1130
1292
  );
1131
- for (const f of footers) yield f;
1293
+ for (const f of footers) yield [f, "footer"];
1132
1294
  const fnPart = doc.pkg.getPartByPath("word/footnotes.xml");
1133
1295
  const enPart = doc.pkg.getPartByPath("word/endnotes.xml");
1134
- if (fnPart) yield new NotesPart(fnPart, "fn");
1135
- if (enPart) yield new NotesPart(enPart, "en");
1296
+ if (fnPart) yield [new NotesPart(fnPart, "fn"), "footnotes"];
1297
+ if (enPart) yield [new NotesPart(enPart, "en"), "endnotes"];
1136
1298
  }
1137
1299
  function _is_page_instr(instr) {
1138
1300
  if (!instr) return false;
@@ -1170,7 +1332,22 @@ function* iter_paragraph_content(paragraph) {
1170
1332
  const child = r_element.childNodes[i];
1171
1333
  if (child.nodeType !== 1) continue;
1172
1334
  const tag = child.tagName;
1173
- if (tag === QN_W_COMMENTREFERENCE) {
1335
+ if (tag === QN_W_DRAWING || tag === QN_W_OBJECT) {
1336
+ const doc_pr = findAllDescendants(child, QN_WP_DOCPR)[0] || null;
1337
+ let alt = "";
1338
+ let img_id = "0";
1339
+ if (doc_pr) {
1340
+ alt = doc_pr.getAttribute("descr") || doc_pr.getAttribute("title") || "";
1341
+ img_id = doc_pr.getAttribute("id") || "0";
1342
+ }
1343
+ yield { type: "image", id: img_id, date: alt };
1344
+ } else if (tag === QN_W_PICT) {
1345
+ const imagedata = findAllDescendants(child, QN_V_IMAGEDATA)[0] || null;
1346
+ if (imagedata) {
1347
+ const alt = imagedata.getAttribute(QN_O_TITLE) || "";
1348
+ yield { type: "image", id: "vml", date: alt };
1349
+ }
1350
+ } else if (tag === QN_W_COMMENTREFERENCE) {
1174
1351
  const ref_id = child.getAttribute(QN_W_ID);
1175
1352
  if (ref_id) yield { type: "ref", id: ref_id };
1176
1353
  } else if (tag === QN_W_FOOTNOTEREFERENCE) {
@@ -1278,6 +1455,11 @@ var DocumentMapper = class {
1278
1455
  full_text = "";
1279
1456
  spans = [];
1280
1457
  appendix_start_index = -1;
1458
+ // [start, end, kind] per projected part, in projection order. Spans carry
1459
+ // the matching index in .part_index. Together these let the engine refuse
1460
+ // or re-anchor edits at OPC part boundaries (QA 2026-07-18 C1).
1461
+ part_ranges = [];
1462
+ _current_part_index = 0;
1281
1463
  _text_chunks = [];
1282
1464
  _plain_projection = null;
1283
1465
  constructor(doc, clean_view = false, original_view = false) {
@@ -1293,12 +1475,19 @@ var DocumentMapper = class {
1293
1475
  this._text_chunks = [];
1294
1476
  this.full_text = "";
1295
1477
  this._plain_projection = null;
1296
- for (const part of iter_document_parts(this.doc)) {
1478
+ this.part_ranges = [];
1479
+ this._current_part_index = 0;
1480
+ let part_idx = 0;
1481
+ for (const [part, part_kind] of iter_document_parts_with_kind(this.doc)) {
1482
+ this._current_part_index = part_idx;
1483
+ const part_start = current_offset;
1297
1484
  current_offset = this._map_blocks(part, current_offset);
1485
+ this.part_ranges.push([part_start, current_offset, part_kind]);
1298
1486
  if (this.spans.length > 0 && this.spans[this.spans.length - 1].text !== "\n\n") {
1299
1487
  this._add_virtual_text("\n\n", current_offset, null);
1300
1488
  current_offset += 2;
1301
1489
  }
1490
+ part_idx++;
1302
1491
  }
1303
1492
  while (this.spans.length > 0 && this.spans[this.spans.length - 1].text === "\n\n") {
1304
1493
  this.spans.pop();
@@ -1307,6 +1496,47 @@ var DocumentMapper = class {
1307
1496
  this.full_text = this._text_chunks.join("");
1308
1497
  this.appendix_start_index = -1;
1309
1498
  }
1499
+ /** [part_index, start, end, kind] for parts that projected any text. */
1500
+ _nonempty_part_ranges() {
1501
+ const out = [];
1502
+ for (let i = 0; i < this.part_ranges.length; i++) {
1503
+ const [s, e, k] = this.part_ranges[i];
1504
+ if (e > s) out.push([i, s, e, k]);
1505
+ }
1506
+ return out;
1507
+ }
1508
+ part_kind_of(part_index) {
1509
+ if (part_index >= 0 && part_index < this.part_ranges.length) {
1510
+ return this.part_ranges[part_index][2];
1511
+ }
1512
+ return null;
1513
+ }
1514
+ /** Kind of the part whose projected range contains `index`, or null. */
1515
+ part_kind_at(index) {
1516
+ for (const [, start, end, kind] of this._nonempty_part_ranges()) {
1517
+ if (start <= index && index <= end) return kind;
1518
+ }
1519
+ return null;
1520
+ }
1521
+ /**
1522
+ * When `index` falls strictly AFTER one part's text and at-or-before the
1523
+ * start of the next part's text (i.e. inside the "\n\n" separator or
1524
+ * exactly at the next part's first character), returns
1525
+ * [previous_part_index, next_part_index]. Returns null everywhere else —
1526
+ * including index == previous part's end, which is an ordinary
1527
+ * end-of-part text position, not a boundary gap.
1528
+ */
1529
+ part_boundary_at(index) {
1530
+ const ranges = this._nonempty_part_ranges();
1531
+ for (let j = 1; j < ranges.length; j++) {
1532
+ const [prev_i, , prev_end] = ranges[j - 1];
1533
+ const [next_i, next_start] = ranges[j];
1534
+ if (prev_end < index && index <= next_start) {
1535
+ return [prev_i, next_i];
1536
+ }
1537
+ }
1538
+ return null;
1539
+ }
1310
1540
  _map_blocks(container, offset) {
1311
1541
  let current = offset;
1312
1542
  const c_type = container.constructor.name;
@@ -1436,7 +1666,8 @@ ${header}`;
1436
1666
  end: current,
1437
1667
  text: "",
1438
1668
  run: null,
1439
- paragraph
1669
+ paragraph,
1670
+ part_index: this._current_part_index
1440
1671
  };
1441
1672
  this.spans.push(span);
1442
1673
  const active_ids = /* @__PURE__ */ new Set();
@@ -1468,7 +1699,8 @@ ${header}`;
1468
1699
  ins_id: i_id || void 0,
1469
1700
  del_id: d_id || void 0,
1470
1701
  hyperlink_id: active_hyperlink_id || void 0,
1471
- comment_ids: c_ids.length > 0 ? c_ids : void 0
1702
+ comment_ids: c_ids.length > 0 ? c_ids : void 0,
1703
+ part_index: this._current_part_index
1472
1704
  };
1473
1705
  this.spans.push(s);
1474
1706
  this._text_chunks.push(txt);
@@ -1647,7 +1879,15 @@ ${header}`;
1647
1879
  else if (ev.type === "del_end") delete active_del[ev.id];
1648
1880
  else if (ev.type === "fmt_start") active_fmt[ev.id] = ev;
1649
1881
  else if (ev.type === "fmt_end") delete active_fmt[ev.id];
1650
- else if (ev.type === "footnote" || ev.type === "endnote") {
1882
+ else if (ev.type === "image") {
1883
+ const hidden = this.clean_view && Object.keys(active_del).length > 0 || this.original_view && Object.keys(active_ins).length > 0;
1884
+ if (!hidden) {
1885
+ const alt = (ev.date || "image").replace(/\]/g, ")").replace(/\n/g, " ");
1886
+ const txt = `![${alt}](docx-image:${ev.id})`;
1887
+ this._add_virtual_text(txt, current, paragraph, null, true);
1888
+ current += txt.length;
1889
+ }
1890
+ } else if (ev.type === "footnote" || ev.type === "endnote") {
1651
1891
  flush_pending_runs();
1652
1892
  current_wrappers = ["", ""];
1653
1893
  current_style = ["", ""];
@@ -1762,14 +2002,16 @@ ${header}`;
1762
2002
  }
1763
2003
  return [...change_lines, ...comment_lines].join("\n");
1764
2004
  }
1765
- _add_virtual_text(text, offset, context_paragraph, hyperlink_id = null) {
2005
+ _add_virtual_text(text, offset, context_paragraph, hyperlink_id = null, is_image_marker = false) {
1766
2006
  const span = {
1767
2007
  start: offset,
1768
2008
  end: offset + text.length,
1769
2009
  text,
1770
2010
  run: null,
1771
2011
  paragraph: context_paragraph,
1772
- hyperlink_id: hyperlink_id || void 0
2012
+ hyperlink_id: hyperlink_id || void 0,
2013
+ part_index: this._current_part_index,
2014
+ is_image_marker: is_image_marker || void 0
1773
2015
  };
1774
2016
  this.spans.push(span);
1775
2017
  this._text_chunks.push(text);
@@ -2369,6 +2611,327 @@ function generate_edits_from_text(original_text, modified_text) {
2369
2611
  }
2370
2612
  return edits;
2371
2613
  }
2614
+ function _sequence_opcodes(a, b) {
2615
+ const n = a.length;
2616
+ const m = b.length;
2617
+ const dp = [];
2618
+ for (let i2 = 0; i2 <= n; i2++) dp.push(new Int32Array(m + 1));
2619
+ for (let i2 = n - 1; i2 >= 0; i2--) {
2620
+ for (let j2 = m - 1; j2 >= 0; j2--) {
2621
+ dp[i2][j2] = a[i2] === b[j2] ? dp[i2 + 1][j2 + 1] + 1 : Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
2622
+ }
2623
+ }
2624
+ const ops = [];
2625
+ let i = 0;
2626
+ let j = 0;
2627
+ let pend_i1 = 0;
2628
+ let pend_j1 = 0;
2629
+ let pend_del = 0;
2630
+ let pend_ins = 0;
2631
+ const flushPending = () => {
2632
+ if (pend_del === 0 && pend_ins === 0) return;
2633
+ const tag = pend_del > 0 && pend_ins > 0 ? "replace" : pend_del > 0 ? "delete" : "insert";
2634
+ ops.push([tag, pend_i1, pend_i1 + pend_del, pend_j1, pend_j1 + pend_ins]);
2635
+ pend_del = 0;
2636
+ pend_ins = 0;
2637
+ };
2638
+ while (i < n || j < m) {
2639
+ if (i < n && j < m && a[i] === b[j]) {
2640
+ flushPending();
2641
+ const i1 = i;
2642
+ const j1 = j;
2643
+ while (i < n && j < m && a[i] === b[j]) {
2644
+ i++;
2645
+ j++;
2646
+ }
2647
+ ops.push(["equal", i1, i, j1, j]);
2648
+ } else {
2649
+ if (pend_del === 0 && pend_ins === 0) {
2650
+ pend_i1 = i;
2651
+ pend_j1 = j;
2652
+ }
2653
+ if (j < m && (i === n || dp[i][j + 1] >= dp[i + 1][j])) {
2654
+ pend_ins++;
2655
+ j++;
2656
+ } else {
2657
+ pend_del++;
2658
+ i++;
2659
+ }
2660
+ }
2661
+ }
2662
+ flushPending();
2663
+ return ops;
2664
+ }
2665
+ var _IMAGE_MARKER_RE = /!\[[^\]]*\]\(docx-image:[^)]*\)/g;
2666
+ function _drop_image_marker_hunks(edits, warnings) {
2667
+ const _normalized = (s) => s.replace(_IMAGE_MARKER_RE, "").replace(/\s+/g, " ").trim();
2668
+ const kept = [];
2669
+ for (const e of edits) {
2670
+ const t_imgs = ((e.target_text || "").match(_IMAGE_MARKER_RE) || []).sort();
2671
+ const n_imgs = ((e.new_text || "").match(_IMAGE_MARKER_RE) || []).sort();
2672
+ if (JSON.stringify(t_imgs) === JSON.stringify(n_imgs)) {
2673
+ kept.push(e);
2674
+ continue;
2675
+ }
2676
+ if (_normalized(e.target_text || "") === _normalized(e.new_text || "")) {
2677
+ warnings.push(
2678
+ "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."
2679
+ );
2680
+ } else {
2681
+ warnings.push(
2682
+ "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."
2683
+ );
2684
+ }
2685
+ }
2686
+ return kept;
2687
+ }
2688
+ function _rows_are_plain(text, table) {
2689
+ for (const row of table.rows) {
2690
+ if (text.substring(row.start, row.end) !== row.cells.join(" | ")) {
2691
+ return false;
2692
+ }
2693
+ }
2694
+ return true;
2695
+ }
2696
+ var _ROW_KEY_SEP = "";
2697
+ function _table_row_opcodes(rows_o, rows_m) {
2698
+ const keys_o = rows_o.map((r) => r.cells.join(_ROW_KEY_SEP));
2699
+ const keys_m = rows_m.map((r) => r.cells.join(_ROW_KEY_SEP));
2700
+ const opcodes = _sequence_opcodes(keys_o, keys_m);
2701
+ for (const [tag, i1, i2, j1, j2] of opcodes) {
2702
+ if (tag === "insert" || tag === "delete" || tag === "replace" && i2 - i1 !== j2 - j1) {
2703
+ return opcodes;
2704
+ }
2705
+ }
2706
+ return null;
2707
+ }
2708
+ function _row_ops_for_table(table_o, table_m, opcodes, warnings) {
2709
+ const rows_o = table_o.rows;
2710
+ const rows_m = table_m.rows;
2711
+ const keys_o = rows_o.map((r) => r.cells.join(_ROW_KEY_SEP));
2712
+ const row_text = (r) => r.cells.join(" | ");
2713
+ if (new Set(keys_o).size !== keys_o.length) {
2714
+ warnings.push(
2715
+ "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."
2716
+ );
2717
+ }
2718
+ const removed = /* @__PURE__ */ new Set();
2719
+ const replaced_new_text = {};
2720
+ for (const [tag, i1, i2, j1, j2] of opcodes) {
2721
+ if (tag === "delete") {
2722
+ for (let k = i1; k < i2; k++) removed.add(k);
2723
+ } else if (tag === "replace") {
2724
+ const pairs = Math.min(i2 - i1, j2 - j1);
2725
+ for (let k = i1 + pairs; k < i2; k++) removed.add(k);
2726
+ for (let k = 0; k < pairs; k++) {
2727
+ replaced_new_text[i1 + k] = row_text(rows_m[j1 + k]);
2728
+ }
2729
+ }
2730
+ }
2731
+ const surviving = [];
2732
+ for (let k = 0; k < rows_o.length; k++) {
2733
+ if (!removed.has(k)) surviving.push(k);
2734
+ }
2735
+ const anchor_text = (orig_idx) => {
2736
+ return replaced_new_text[orig_idx] !== void 0 ? replaced_new_text[orig_idx] : row_text(rows_o[orig_idx]);
2737
+ };
2738
+ const insert_ops = (new_rows, at_orig_index) => {
2739
+ const ops2 = [];
2740
+ const before = surviving.filter((k) => k < at_orig_index);
2741
+ const after = surviving.filter((k) => k >= at_orig_index);
2742
+ if (before.length > 0) {
2743
+ const anchor_idx = before[before.length - 1];
2744
+ const anchor = anchor_text(anchor_idx);
2745
+ for (const r of [...new_rows].reverse()) {
2746
+ ops2.push({
2747
+ type: "insert_row",
2748
+ target_text: anchor,
2749
+ position: "below",
2750
+ cells: [...r.cells],
2751
+ // Pin to the anchor row's offset: text anchors alone are
2752
+ // ambiguous when tables share identical rows. Pins do not
2753
+ // survive JSON round-trips (the strict text match applies then,
2754
+ // failing closed with the duplicate-row warning above).
2755
+ _match_start_index: rows_o[anchor_idx].start
2756
+ });
2757
+ }
2758
+ } else if (after.length > 0) {
2759
+ const anchor_idx = after[0];
2760
+ const anchor = anchor_text(anchor_idx);
2761
+ for (const r of new_rows) {
2762
+ ops2.push({
2763
+ type: "insert_row",
2764
+ target_text: anchor,
2765
+ position: "above",
2766
+ cells: [...r.cells],
2767
+ _match_start_index: rows_o[anchor_idx].start
2768
+ });
2769
+ }
2770
+ } else {
2771
+ warnings.push(
2772
+ "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."
2773
+ );
2774
+ }
2775
+ return ops2;
2776
+ };
2777
+ const ops = [];
2778
+ for (const [tag, i1, i2, j1, j2] of opcodes) {
2779
+ if (tag === "equal") continue;
2780
+ if (tag === "replace") {
2781
+ const pairs = Math.min(i2 - i1, j2 - j1);
2782
+ for (let k = 0; k < pairs; k++) {
2783
+ const o_txt = row_text(rows_o[i1 + k]);
2784
+ const m_txt = row_text(rows_m[j1 + k]);
2785
+ if (o_txt !== m_txt) {
2786
+ ops.push({
2787
+ type: "modify",
2788
+ target_text: o_txt,
2789
+ new_text: m_txt,
2790
+ comment: "Diff: Table row modified"
2791
+ });
2792
+ }
2793
+ }
2794
+ for (let k = i1 + pairs; k < i2; k++) {
2795
+ ops.push({
2796
+ type: "delete_row",
2797
+ target_text: row_text(rows_o[k]),
2798
+ _match_start_index: rows_o[k].start
2799
+ });
2800
+ }
2801
+ const surplus_new = rows_m.slice(j1 + pairs, j2);
2802
+ if (surplus_new.length > 0) {
2803
+ ops.push(...insert_ops(surplus_new, i2));
2804
+ }
2805
+ } else if (tag === "delete") {
2806
+ for (let k = i1; k < i2; k++) {
2807
+ ops.push({
2808
+ type: "delete_row",
2809
+ target_text: row_text(rows_o[k]),
2810
+ _match_start_index: rows_o[k].start
2811
+ });
2812
+ }
2813
+ } else if (tag === "insert") {
2814
+ ops.push(...insert_ops(rows_m.slice(j1, j2), i1));
2815
+ }
2816
+ }
2817
+ return ops;
2818
+ }
2819
+ function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod) {
2820
+ const warnings = [];
2821
+ const edits = [];
2822
+ const kinds_o = struct_orig.part_ranges.filter(([s, e]) => e > s).map(([, , k]) => k);
2823
+ const kinds_m = struct_mod.part_ranges.filter(([s, e]) => e > s).map(([, , k]) => k);
2824
+ if (JSON.stringify(kinds_o) !== JSON.stringify(kinds_m)) {
2825
+ warnings.push(
2826
+ `The documents have different part layouts (${kinds_o.join(" + ") || "none"} vs ${kinds_m.join(" + ") || "none"}); comparing flattened text instead. Header/footer additions or removals cannot be expressed as text edits.`
2827
+ );
2828
+ const flat = _drop_image_marker_hunks(
2829
+ generate_edits_from_text(text_orig, text_mod),
2830
+ warnings
2831
+ );
2832
+ return { edits: [...flat], warnings };
2833
+ }
2834
+ const ranges_o = struct_orig.part_ranges.filter(([s, e]) => e > s).map(([s, e]) => [s, e]);
2835
+ const ranges_m = struct_mod.part_ranges.filter(([s, e]) => e > s).map(([s, e]) => [s, e]);
2836
+ for (let p = 0; p < ranges_o.length; p++) {
2837
+ const [po_start, po_end] = ranges_o[p];
2838
+ const [pm_start, pm_end] = ranges_m[p];
2839
+ const tables_o = struct_orig.tables.filter(
2840
+ (t) => po_start <= t.start && t.end <= po_end
2841
+ );
2842
+ const tables_m = struct_mod.tables.filter(
2843
+ (t) => pm_start <= t.start && t.end <= pm_end
2844
+ );
2845
+ 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));
2846
+ if (tables_o.length !== tables_m.length) {
2847
+ warnings.push(
2848
+ `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.`
2849
+ );
2850
+ }
2851
+ if (!tables_alignable) {
2852
+ const part_edits = generate_edits_from_text(
2853
+ text_orig.substring(po_start, po_end),
2854
+ text_mod.substring(pm_start, pm_end)
2855
+ );
2856
+ for (const e of part_edits) {
2857
+ e._match_start_index = (e._match_start_index || 0) + po_start;
2858
+ }
2859
+ edits.push(...part_edits);
2860
+ continue;
2861
+ }
2862
+ const boundaries_o = [
2863
+ [po_start, po_start],
2864
+ ...tables_o.map((t) => [t.start, t.end]),
2865
+ [po_end, po_end]
2866
+ ];
2867
+ const boundaries_m = [
2868
+ [pm_start, pm_start],
2869
+ ...tables_m.map((t) => [t.start, t.end]),
2870
+ [pm_end, pm_end]
2871
+ ];
2872
+ for (let seg_idx = 0; seg_idx < boundaries_o.length - 1; seg_idx++) {
2873
+ const seg_o_start = boundaries_o[seg_idx][1];
2874
+ const seg_o_end = boundaries_o[seg_idx + 1][0];
2875
+ const seg_m_start = boundaries_m[seg_idx][1];
2876
+ const seg_m_end = boundaries_m[seg_idx + 1][0];
2877
+ const seg_edits = generate_edits_from_text(
2878
+ text_orig.substring(seg_o_start, seg_o_end),
2879
+ text_mod.substring(seg_m_start, seg_m_end)
2880
+ );
2881
+ for (const e of seg_edits) {
2882
+ e._match_start_index = (e._match_start_index || 0) + seg_o_start;
2883
+ }
2884
+ edits.push(...seg_edits);
2885
+ if (seg_idx < tables_o.length) {
2886
+ const t_o = tables_o[seg_idx];
2887
+ const t_m = tables_m[seg_idx];
2888
+ const row_opcodes = _table_row_opcodes(t_o.rows, t_m.rows);
2889
+ if (row_opcodes !== null) {
2890
+ edits.push(..._row_ops_for_table(t_o, t_m, row_opcodes, warnings));
2891
+ } else {
2892
+ const tbl_edits = generate_edits_from_text(
2893
+ text_orig.substring(t_o.start, t_o.end),
2894
+ text_mod.substring(t_m.start, t_m.end)
2895
+ );
2896
+ for (const e of tbl_edits) {
2897
+ e._match_start_index = (e._match_start_index || 0) + t_o.start;
2898
+ }
2899
+ edits.push(...tbl_edits);
2900
+ }
2901
+ }
2902
+ }
2903
+ }
2904
+ const countOccurrences = (haystack, needle) => {
2905
+ let count = 0;
2906
+ let from = 0;
2907
+ while (true) {
2908
+ const idx = haystack.indexOf(needle, from);
2909
+ if (idx === -1) break;
2910
+ count++;
2911
+ from = idx + needle.length;
2912
+ }
2913
+ return count;
2914
+ };
2915
+ let ambiguous_anchor_warned = false;
2916
+ for (const e of edits) {
2917
+ if ((e.type === "insert_row" || e.type === "delete_row") && !ambiguous_anchor_warned) {
2918
+ if (e.target_text && countOccurrences(text_orig, e.target_text) > 1) {
2919
+ warnings.push(
2920
+ `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.`
2921
+ );
2922
+ ambiguous_anchor_warned = true;
2923
+ }
2924
+ }
2925
+ }
2926
+ const modify_edits = edits.filter(
2927
+ (e) => e.type === "modify"
2928
+ );
2929
+ const kept_modifies = new Set(_drop_image_marker_hunks(modify_edits, warnings));
2930
+ const final_edits = edits.filter(
2931
+ (e) => e.type !== "modify" || kept_modifies.has(e)
2932
+ );
2933
+ return { edits: final_edits, warnings };
2934
+ }
2372
2935
  function create_unified_diff(original_text, modified_text, context_lines = 3) {
2373
2936
  const dmp = new diff_match_patch.diff_match_patch();
2374
2937
  dmp.Diff_Timeout = 2;
@@ -2676,6 +3239,30 @@ function _strip_balanced_markers(text) {
2676
3239
  function _replace_smart_quotes(text) {
2677
3240
  return text.replace(/“/g, '"').replace(/”/g, '"').replace(/‘/g, "'").replace(/’/g, "'");
2678
3241
  }
3242
+ function _strip_markdown_for_matching(text) {
3243
+ const result = [];
3244
+ const position_map = [];
3245
+ let i = 0;
3246
+ while (i < text.length) {
3247
+ const pair = text.substring(i, i + 2);
3248
+ if (i < text.length - 1 && (pair === "**" || pair === "__")) {
3249
+ i += 2;
3250
+ continue;
3251
+ }
3252
+ if (text[i] === "*" || text[i] === "_") {
3253
+ const prev_char = i > 0 ? text[i - 1] : " ";
3254
+ const next_char = i < text.length - 1 ? text[i + 1] : " ";
3255
+ if ([" ", "\n", " "].includes(prev_char) || [" ", "\n", " "].includes(next_char)) {
3256
+ i += 1;
3257
+ continue;
3258
+ }
3259
+ }
3260
+ position_map.push(i);
3261
+ result.push(text[i]);
3262
+ i += 1;
3263
+ }
3264
+ return [result.join(""), position_map];
3265
+ }
2679
3266
  function _find_safe_boundaries(text, start, end) {
2680
3267
  let new_start = start;
2681
3268
  let new_end = end;
@@ -2779,31 +3366,63 @@ function _make_fuzzy_regex(target_text) {
2779
3366
  if (remaining) parts.push(escapeRegExp(remaining));
2780
3367
  return parts.join("");
2781
3368
  }
2782
- function _find_match_in_text(text, target) {
2783
- if (!target) return [-1, -1];
2784
- let idx = text.indexOf(target);
2785
- if (idx !== -1) return _find_safe_boundaries(text, idx, idx + target.length);
3369
+ function _find_all_matches_in_text(text, target, is_regex = false) {
3370
+ if (!target) return [];
3371
+ if (is_regex) {
3372
+ try {
3373
+ return userFindAllMatches(target, text).map((m) => [m.start, m.end]);
3374
+ } catch (e) {
3375
+ if (e instanceof RegexTimeoutError) throw e;
3376
+ return [];
3377
+ }
3378
+ }
3379
+ const findAllLiteral = (haystack, needle) => {
3380
+ const out = [];
3381
+ let from = 0;
3382
+ while (true) {
3383
+ const idx = haystack.indexOf(needle, from);
3384
+ if (idx === -1) break;
3385
+ out.push([idx, idx + needle.length]);
3386
+ from = idx + needle.length;
3387
+ }
3388
+ return out;
3389
+ };
3390
+ let spans = findAllLiteral(text, target);
3391
+ if (spans.length > 0) {
3392
+ return spans.map(([s, e]) => _find_safe_boundaries(text, s, e));
3393
+ }
2786
3394
  const norm_text = _replace_smart_quotes(text);
2787
3395
  const norm_target = _replace_smart_quotes(target);
2788
- idx = norm_text.indexOf(norm_target);
2789
- if (idx !== -1)
2790
- return _find_safe_boundaries(text, idx, idx + norm_target.length);
3396
+ spans = findAllLiteral(norm_text, norm_target);
3397
+ if (spans.length > 0) {
3398
+ return spans.map(([s, e]) => _find_safe_boundaries(text, s, e));
3399
+ }
3400
+ const [stripped_text, pos_map] = _strip_markdown_for_matching(norm_text);
3401
+ const [stripped_target] = _strip_markdown_for_matching(norm_target);
3402
+ if (stripped_target && (stripped_text !== norm_text || stripped_target !== norm_target)) {
3403
+ const results = [];
3404
+ for (const [p_start, p_end] of findAllLiteral(stripped_text, stripped_target)) {
3405
+ const raw_start = pos_map[p_start];
3406
+ const raw_end = pos_map[p_end - 1] + 1;
3407
+ results.push(_find_safe_boundaries(text, raw_start, raw_end));
3408
+ }
3409
+ if (results.length > 0) return results;
3410
+ }
2791
3411
  try {
2792
- const pattern = new RegExp(_make_fuzzy_regex(target));
2793
- const match = pattern.exec(text);
2794
- if (match) {
2795
- const raw_start = match.index;
2796
- const raw_end = match.index + match[0].length;
3412
+ const pattern = new RegExp(_make_fuzzy_regex(target), "g");
3413
+ const results = [];
3414
+ for (const match of text.matchAll(pattern)) {
2797
3415
  const [refined_start, refined_end] = _refine_match_boundaries(
2798
3416
  text,
2799
- raw_start,
2800
- raw_end
3417
+ match.index,
3418
+ match.index + match[0].length
2801
3419
  );
2802
- return _find_safe_boundaries(text, refined_start, refined_end);
3420
+ results.push(_find_safe_boundaries(text, refined_start, refined_end));
2803
3421
  }
3422
+ if (results.length > 0) return results;
2804
3423
  } catch (e) {
2805
3424
  }
2806
- return [-1, -1];
3425
+ return [];
2807
3426
  }
2808
3427
  function _build_critic_markup(target_text, new_text, comment, edit_index, include_index, highlight_only) {
2809
3428
  const parts = [];
@@ -2835,19 +3454,55 @@ function _build_critic_markup(target_text, new_text, comment, edit_index, includ
2835
3454
  }
2836
3455
  return parts.join("");
2837
3456
  }
2838
- function apply_edits_to_markdown(markdown_text, edits, include_index = false, highlight_only = false) {
3457
+ function apply_edits_to_markdown(markdown_text, edits, include_index = false, highlight_only = false, edit_reports) {
2839
3458
  if (!edits || edits.length === 0) return markdown_text;
3459
+ const _report = (idx, status, error = null, occurrences = 0) => {
3460
+ if (edit_reports) edit_reports.push({ index: idx, status, error, occurrences });
3461
+ };
2840
3462
  const matched_edits = [];
2841
3463
  for (let idx = 0; idx < edits.length; idx++) {
2842
3464
  const edit = edits[idx];
2843
3465
  const target = edit.target_text || "";
3466
+ const match_mode = edit.match_mode || "strict";
3467
+ const is_regex = Boolean(edit.regex);
2844
3468
  if (!target) {
3469
+ _report(
3470
+ idx,
3471
+ "failed",
3472
+ `- 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.`
3473
+ );
3474
+ continue;
3475
+ }
3476
+ let spans;
3477
+ try {
3478
+ spans = _find_all_matches_in_text(markdown_text, target, is_regex);
3479
+ } catch (e) {
3480
+ if (!(e instanceof RegexTimeoutError)) throw e;
3481
+ _report(idx, "failed", `- Edit ${idx + 1} Failed: ${e.message}`);
2845
3482
  continue;
2846
3483
  }
2847
- const [start, end] = _find_match_in_text(markdown_text, target);
2848
- if (start === -1) continue;
2849
- const actual_matched_text = markdown_text.substring(start, end);
2850
- matched_edits.push([start, end, actual_matched_text, edit, idx]);
3484
+ if (spans.length === 0) {
3485
+ _report(
3486
+ idx,
3487
+ "failed",
3488
+ `- Edit ${idx + 1} Failed: Target text not found in document:
3489
+ "${target.substring(0, 80)}"`
3490
+ );
3491
+ continue;
3492
+ }
3493
+ if (spans.length > 1 && match_mode === "strict") {
3494
+ _report(
3495
+ idx,
3496
+ "failed",
3497
+ format_ambiguity_error(idx + 1, target, markdown_text, spans)
3498
+ );
3499
+ continue;
3500
+ }
3501
+ const selected = match_mode === "strict" || match_mode === "first" ? spans.slice(0, 1) : spans;
3502
+ for (const [start, end] of selected) {
3503
+ matched_edits.push([start, end, markdown_text.substring(start, end), edit, idx]);
3504
+ }
3505
+ _report(idx, "applied", null, selected.length);
2851
3506
  }
2852
3507
  const matched_edits_filtered = [];
2853
3508
  const occupied_ranges = [];
@@ -2857,6 +3512,16 @@ function apply_edits_to_markdown(markdown_text, edits, include_index = false, hi
2857
3512
  for (const [occ_start, occ_end] of occupied_ranges) {
2858
3513
  if (start < occ_end && end > occ_start) {
2859
3514
  overlaps = true;
3515
+ if (edit_reports) {
3516
+ const msg = `- Edit ${orig_idx + 1} Failed: overlaps with a previously matched edit.`;
3517
+ for (const r of edit_reports) {
3518
+ if (r.index === orig_idx) {
3519
+ r.status = "failed";
3520
+ r.error = msg;
3521
+ r.occurrences = 0;
3522
+ }
3523
+ }
3524
+ }
2860
3525
  break;
2861
3526
  }
2862
3527
  }
@@ -3125,6 +3790,15 @@ function validate_edit_strings(edits, index_offset = 0) {
3125
3790
  }
3126
3791
  }
3127
3792
  }
3793
+ if (t_text.includes("docx-image:") || n_text.includes("docx-image:")) {
3794
+ const t_imgs = (t_text.match(/!\[[^\]]*\]\(docx-image:[^)]*\)/g) || []).sort();
3795
+ const n_imgs = (n_text.match(/!\[[^\]]*\]\(docx-image:[^)]*\)/g) || []).sort();
3796
+ if (JSON.stringify(t_imgs) !== JSON.stringify(n_imgs)) {
3797
+ errors.push(
3798
+ `- 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.`
3799
+ );
3800
+ }
3801
+ }
3128
3802
  if (t_text.includes("{#") || n_text.includes("{#")) {
3129
3803
  const t_anchors = t_text.match(/\{#[^\}]+\}/g) || [];
3130
3804
  const n_anchors = n_text.match(/\{#[^\}]+\}/g) || [];
@@ -3848,6 +4522,43 @@ var RedlineEngine = class _RedlineEngine {
3848
4522
  element.setAttribute("xml:space", "preserve");
3849
4523
  }
3850
4524
  }
4525
+ /**
4526
+ * Walks `element` to its XML root element. Word (and LibreOffice, which
4527
+ * refuses to LOAD such files) only supports comment ranges in the main
4528
+ * document story ("w:document") — never in headers, footers, footnotes or
4529
+ * endnotes (QA 2026-07-18 H4/C1).
4530
+ */
4531
+ _comment_anchor_in_main_story(element) {
4532
+ let root = element;
4533
+ while (root.parentNode && root.parentNode.nodeType === 1) {
4534
+ root = root.parentNode;
4535
+ }
4536
+ return root.tagName === "w:document";
4537
+ }
4538
+ /**
4539
+ * When the anchor lives outside the main document story, records a
4540
+ * user-visible warning and returns true (caller must skip the comment).
4541
+ * The tracked change itself still applies — only the bubble is dropped.
4542
+ */
4543
+ _skip_comment_outside_main_story(element, text) {
4544
+ if (this._comment_anchor_in_main_story(element)) return false;
4545
+ let root = element;
4546
+ while (root.parentNode && root.parentNode.nodeType === 1) {
4547
+ root = root.parentNode;
4548
+ }
4549
+ const story = {
4550
+ "w:ftr": "footer",
4551
+ "w:hdr": "header",
4552
+ "w:footnotes": "footnote",
4553
+ "w:endnotes": "endnote"
4554
+ }[root.tagName] || "non-body";
4555
+ 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.`;
4556
+ this.skipped_details.push(msg);
4557
+ console.error(
4558
+ `Comment anchor outside main story; comment dropped (story=${story})`
4559
+ );
4560
+ return true;
4561
+ }
3851
4562
  /**
3852
4563
  * Attaches a comment that wraps a contiguous range within a single paragraph.
3853
4564
  * start_element and end_element must both be direct children of parent_element
@@ -3856,6 +4567,8 @@ var RedlineEngine = class _RedlineEngine {
3856
4567
  */
3857
4568
  _attach_comment(parent_element, start_element, end_element, text) {
3858
4569
  if (!text) return;
4570
+ if (!parent_element || !start_element || !end_element) return;
4571
+ if (this._skip_comment_outside_main_story(parent_element, text)) return;
3859
4572
  const comment_id = this.comments_manager.addComment(this.author, text);
3860
4573
  const xmlDoc = parent_element.ownerDocument;
3861
4574
  const range_start = xmlDoc.createElement("w:commentRangeStart");
@@ -3889,6 +4602,9 @@ var RedlineEngine = class _RedlineEngine {
3889
4602
  */
3890
4603
  _attach_comment_spanning(start_p, start_el, end_p, end_el, text) {
3891
4604
  if (!text) return;
4605
+ if (!start_p || !end_p) return;
4606
+ if (this._skip_comment_outside_main_story(start_p, text) || this._skip_comment_outside_main_story(end_p, text))
4607
+ return;
3892
4608
  const comment_id = this.comments_manager.addComment(this.author, text);
3893
4609
  const xmlDocStart = start_p.ownerDocument;
3894
4610
  const xmlDocEnd = end_p.ownerDocument;
@@ -4500,6 +5216,51 @@ var RedlineEngine = class _RedlineEngine {
4500
5216
  pfx,
4501
5217
  (edit.new_text || "").length - sfx
4502
5218
  );
5219
+ const multi_part_doc = target_mapper.part_ranges.filter((r) => r[1] > r[0]).length > 1;
5220
+ const raw_span_parts = multi_part_doc ? Array.from(
5221
+ new Set(
5222
+ target_mapper.spans.filter(
5223
+ (s) => s.run !== null && s.end > m_start && s.start < m_start + m_len
5224
+ ).map((s) => s.part_index)
5225
+ )
5226
+ ).sort((a, b) => a - b) : [];
5227
+ if (raw_span_parts.length > 1) {
5228
+ const kinds = raw_span_parts.map((pi) => target_mapper.part_kind_of(pi) || "?").join(" \u2192 ");
5229
+ errors.push(
5230
+ `- 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).`
5231
+ );
5232
+ }
5233
+ const eff_start = m_start + pfx;
5234
+ const eff_end = m_start + m_len - sfx;
5235
+ if (eff_end > eff_start) {
5236
+ const overlapping = target_mapper.spans.filter(
5237
+ (s) => s.end > eff_start && s.start < eff_end && (s.run !== null || s.text.trim() !== "")
5238
+ );
5239
+ if (overlapping.some((s) => s.is_image_marker)) {
5240
+ errors.push(
5241
+ `- 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.`
5242
+ );
5243
+ }
5244
+ }
5245
+ if (edit.comment && (edit.new_text || "") === (edit.target_text || "")) {
5246
+ const kind_here = target_mapper.part_kind_at(m_start);
5247
+ if (kind_here !== null && kind_here !== "body") {
5248
+ errors.push(
5249
+ `- 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.`
5250
+ );
5251
+ }
5252
+ }
5253
+ if (_RedlineEngine._introduces_table_row_text(
5254
+ target_mapper,
5255
+ m_start,
5256
+ m_len,
5257
+ final_target,
5258
+ final_new
5259
+ )) {
5260
+ errors.push(
5261
+ `- 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": ["...", "..."]}).`
5262
+ );
5263
+ }
4503
5264
  if (final_target.includes("\n\n")) {
4504
5265
  const balanced = matched.split("\n\n").length === (edit.new_text || "").split("\n\n").length;
4505
5266
  if (!balanced) {
@@ -4596,6 +5357,19 @@ var RedlineEngine = class _RedlineEngine {
4596
5357
  * [start, start+length) in `mapper`, or null if that text is not inside a
4597
5358
  * table row.
4598
5359
  */
5360
+ /**
5361
+ * True when a replacement anchored in a table would ADD line-separated
5362
+ * pipe-delimited content — the text shape of a table row. Writing that
5363
+ * into a cell renders a fake row inside one cell while the real grid
5364
+ * stays unchanged (QA 2026-07-18 C2); such edits must use insert_row.
5365
+ */
5366
+ static _introduces_table_row_text(mapper, start, length, final_target, final_new) {
5367
+ if (!final_new.includes("\n") || !final_new.includes(" | ")) return false;
5368
+ const new_pipe_lines = final_new.split("\n").filter((line) => line.includes(" | ")).length;
5369
+ const old_pipe_lines = final_target.split("\n").filter((line) => line.includes(" | ")).length;
5370
+ if (new_pipe_lines <= old_pipe_lines) return false;
5371
+ return _RedlineEngine._column_count_at(mapper, start, Math.max(length, 1)) !== null;
5372
+ }
4599
5373
  static _column_count_at(mapper, start, length) {
4600
5374
  for (const s of mapper.spans) {
4601
5375
  if (s.run === null || s.end <= start || s.start >= start + length) {
@@ -5024,7 +5798,7 @@ var RedlineEngine = class _RedlineEngine {
5024
5798
  const counted_split_groups = /* @__PURE__ */ new Set();
5025
5799
  for (const [edit, orig_new] of resolved_edits) {
5026
5800
  const start = edit._resolved_start_idx || 0;
5027
- const end = start + (edit.target_text ? edit.target_text.length : 0);
5801
+ const end = edit.type === "insert_row" ? start : start + (edit.target_text ? edit.target_text.length : 0);
5028
5802
  const overlaps = occupied_ranges.some(
5029
5803
  ([occ_start, occ_end]) => start < occ_end && end > occ_start
5030
5804
  );
@@ -5714,13 +6488,49 @@ var RedlineEngine = class _RedlineEngine {
5714
6488
  return true;
5715
6489
  }
5716
6490
  if (op === "INSERTION") {
5717
- const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
5718
- start_idx,
5719
- rebuild_map
5720
- );
6491
+ let final_new_text = edit.new_text || "";
6492
+ let boundary_anchor = null;
6493
+ const boundary = typeof active_mapper.part_boundary_at === "function" ? active_mapper.part_boundary_at(start_idx) : null;
6494
+ const is_machine_pure_insertion = !edit.target_text && (edit._parent_edit_ref === void 0 || edit._parent_edit_ref === null);
6495
+ if (boundary !== null && is_machine_pure_insertion) {
6496
+ const [prev_i, next_i] = boundary;
6497
+ const prev_kind = active_mapper.part_kind_of(prev_i);
6498
+ const next_kind = active_mapper.part_kind_of(next_i);
6499
+ if (prev_kind === "body" && next_kind !== "body") {
6500
+ const real_before = active_mapper.spans.filter(
6501
+ (s) => s.run !== null && s.part_index === prev_i
6502
+ );
6503
+ if (real_before.length > 0) {
6504
+ boundary_anchor = real_before[real_before.length - 1];
6505
+ }
6506
+ }
6507
+ }
6508
+ let anchor_run;
6509
+ let anchor_para;
6510
+ if (boundary_anchor !== null) {
6511
+ anchor_run = boundary_anchor.run;
6512
+ anchor_para = boundary_anchor.paragraph;
6513
+ if (!final_new_text.startsWith("\n")) {
6514
+ final_new_text = "\n\n" + final_new_text;
6515
+ }
6516
+ } else {
6517
+ [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
6518
+ start_idx,
6519
+ rebuild_map
6520
+ );
6521
+ }
5721
6522
  if (!anchor_run && !anchor_para) return false;
5722
- const _bug233_new = edit.new_text || "";
5723
- const _bug233_trailing_break = /\n\s*$/.test(_bug233_new);
6523
+ if (_RedlineEngine._introduces_table_row_text(
6524
+ active_mapper,
6525
+ start_idx,
6526
+ 1,
6527
+ "",
6528
+ final_new_text
6529
+ )) {
6530
+ return false;
6531
+ }
6532
+ const _bug233_new = final_new_text;
6533
+ const _bug233_trailing_break = boundary_anchor === null && /\n\s*$/.test(_bug233_new);
5724
6534
  let _bug233_target_para = null;
5725
6535
  {
5726
6536
  const startingSpans = active_mapper.spans.filter(
@@ -5790,7 +6600,7 @@ var RedlineEngine = class _RedlineEngine {
5790
6600
  }
5791
6601
  }
5792
6602
  const result = this._track_insert_multiline(
5793
- edit.new_text || "",
6603
+ final_new_text,
5794
6604
  anchor_run,
5795
6605
  anchor_para,
5796
6606
  ins_id
@@ -5879,6 +6689,20 @@ var RedlineEngine = class _RedlineEngine {
5879
6689
  }
5880
6690
  return true;
5881
6691
  }
6692
+ if ((op === "DELETION" || op === "MODIFICATION") && length && active_mapper.part_ranges.filter((r) => r[1] > r[0]).length > 1) {
6693
+ const crossed_parts = /* @__PURE__ */ new Set();
6694
+ for (const s of active_mapper.spans) {
6695
+ if (s.run !== null && s.end > start_idx && s.start < start_idx + length) {
6696
+ crossed_parts.add(s.part_index);
6697
+ }
6698
+ }
6699
+ if (crossed_parts.size > 1) {
6700
+ console.error(
6701
+ `Refusing edit that spans OPC part boundary (start=${start_idx}, parts=${Array.from(crossed_parts).sort().join(",")})`
6702
+ );
6703
+ return false;
6704
+ }
6705
+ }
5882
6706
  const target_runs = active_mapper.find_target_runs_by_index(
5883
6707
  start_idx,
5884
6708
  length,
@@ -6569,6 +7393,35 @@ function strip_image_alt_text(doc) {
6569
7393
  }
6570
7394
  return count ? [`Image alt text: ${count} auto-generated descriptions removed`] : [];
6571
7395
  }
7396
+ function detect_watermarks(doc) {
7397
+ const warnings = [];
7398
+ const seen = /* @__PURE__ */ new Set();
7399
+ for (const part of doc.pkg.parts) {
7400
+ const name = String(part.partname);
7401
+ let location;
7402
+ if (name.startsWith("/word/header")) location = "header";
7403
+ else if (name.startsWith("/word/footer")) location = "footer";
7404
+ else if (name === "/word/document.xml") location = "body";
7405
+ else continue;
7406
+ let textpaths;
7407
+ try {
7408
+ textpaths = findDescendantsByLocalName(part._element, "textpath");
7409
+ } catch {
7410
+ continue;
7411
+ }
7412
+ for (const tp of textpaths) {
7413
+ const text = (tp.getAttribute("string") || "").trim();
7414
+ const key = `${location}${text}`;
7415
+ if (seen.has(key)) continue;
7416
+ seen.add(key);
7417
+ const label = text ? `"${_truncate(text, 60)}"` : "(no text)";
7418
+ warnings.push(
7419
+ `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.`
7420
+ );
7421
+ }
7422
+ }
7423
+ return warnings;
7424
+ }
6572
7425
  function audit_hyperlinks(doc) {
6573
7426
  const internal = ["sharepoint.com", "onedrive.com", ".internal", "intranet", "localhost", "10.", "192.168.", "172.16."];
6574
7427
  const warnings = [];
@@ -6621,25 +7474,53 @@ function _get_paragraph_text(p) {
6621
7474
  }
6622
7475
  return text;
6623
7476
  }
7477
+ var _TERM_BODY = "[A-Z][A-Za-z0-9\\s\\-&'\u2019]{1,60}";
7478
+ var _LEADING_TERM_RE = new RegExp(
7479
+ `^(?:[\\d.\\-()a-zA-Z]+\\s*)?["\u201C](${_TERM_BODY})["\u201D]`,
7480
+ "d"
7481
+ );
7482
+ var _SENTENCE_TERM_RE = new RegExp(
7483
+ `(?<=[.;:!?])\\s+(?:[\\d.\\-()a-zA-Z]+\\s+)?["\u201C](${_TERM_BODY})["\u201D]`,
7484
+ "dg"
7485
+ );
7486
+ var _INLINE_TERM_RE = new RegExp(
7487
+ `\\([^)]*?["\u201C](${_TERM_BODY})["\u201D][^)]*?\\)`,
7488
+ "dg"
7489
+ );
7490
+ function extract_terms_from_paragraph(text) {
7491
+ const found = [];
7492
+ const leading = _LEADING_TERM_RE.exec(text);
7493
+ if (leading && leading.indices && leading.indices[1]) {
7494
+ found.push([leading.indices[1][0], leading[1].trim()]);
7495
+ }
7496
+ for (const m of text.matchAll(_SENTENCE_TERM_RE)) {
7497
+ const indices = m.indices;
7498
+ if (indices && indices[1]) found.push([indices[1][0], m[1].trim()]);
7499
+ }
7500
+ for (const m of text.matchAll(_INLINE_TERM_RE)) {
7501
+ const indices = m.indices;
7502
+ if (indices && indices[1]) found.push([indices[1][0], m[1].trim()]);
7503
+ }
7504
+ const terms = [];
7505
+ const seen_positions = /* @__PURE__ */ new Set();
7506
+ found.sort((a, b) => a[0] - b[0]);
7507
+ for (const [pos, term] of found) {
7508
+ if (seen_positions.has(pos)) continue;
7509
+ seen_positions.add(pos);
7510
+ terms.push(term);
7511
+ }
7512
+ return terms;
7513
+ }
6624
7514
  function extract_all_domain_metadata(doc, base_text) {
6625
7515
  const definitions = {};
6626
7516
  const duplicates = /* @__PURE__ */ new Set();
6627
7517
  const raw_anchors = {};
6628
7518
  const raw_references = [];
6629
- const leading_re = /^(?:[\d.\-()a-zA-Z]+\s*)?["“]([A-Z][A-Za-z0-9\s\-&'’]{1,60})["”]/;
6630
- const inline_re = /\([^)]*?["“]([A-Z][A-Za-z0-9\s\-&'’]{1,60})["”][^)]*?\)/g;
6631
7519
  for (const item of iter_block_items(doc)) {
6632
7520
  if (!(item instanceof Paragraph)) continue;
6633
7521
  const text = _get_paragraph_text(item).trim();
6634
7522
  if (!text) continue;
6635
- const extracted_terms = [];
6636
- const leading_match = text.match(leading_re);
6637
- if (leading_match) extracted_terms.push(leading_match[1].trim());
6638
- const inline_matches = text.matchAll(inline_re);
6639
- for (const m of inline_matches) {
6640
- extracted_terms.push(m[1].trim());
6641
- }
6642
- for (const term of extracted_terms) {
7523
+ for (const term of extract_terms_from_paragraph(text)) {
6643
7524
  if (definitions[term]) duplicates.add(term);
6644
7525
  else definitions[term] = { count: 0 };
6645
7526
  }
@@ -6867,27 +7748,32 @@ function build_structural_appendix(doc, base_text) {
6867
7748
  }
6868
7749
 
6869
7750
  // src/ingest.ts
6870
- async function extractTextFromBuffer(buffer, cleanView = false) {
7751
+ async function extractTextFromBuffer(buffer, cleanView = false, includeAppendix = true) {
6871
7752
  const doc = await DocumentObject.load(buffer);
6872
- return _extractTextFromDoc(doc, cleanView);
7753
+ return _extractTextFromDoc(doc, cleanView, includeAppendix);
6873
7754
  }
6874
- function _extractTextFromDoc(doc, cleanView = false, includeAppendix = true, return_paragraph_offsets = false) {
7755
+ function _extractTextFromDoc(doc, cleanView = false, includeAppendix = true, return_paragraph_offsets = false, return_structure = false) {
6875
7756
  const comments_map = extract_comments_data(doc.pkg);
6876
7757
  const full_text = [];
6877
7758
  const paragraph_offsets = /* @__PURE__ */ new Map();
7759
+ const structure = return_structure ? { part_ranges: [], tables: [] } : null;
6878
7760
  let cursor = 0;
6879
- for (const part of iter_document_parts(doc)) {
7761
+ for (const [part, part_kind] of iter_document_parts_with_kind(doc)) {
6880
7762
  const part_cursor = full_text.length > 0 ? cursor + 2 : cursor;
6881
7763
  const part_text = _extract_blocks(
6882
7764
  part,
6883
7765
  comments_map,
6884
7766
  cleanView,
6885
7767
  part_cursor,
6886
- return_paragraph_offsets ? paragraph_offsets : void 0
7768
+ return_paragraph_offsets ? paragraph_offsets : void 0,
7769
+ structure ? structure.tables : void 0
6887
7770
  );
6888
7771
  if (part_text) {
6889
7772
  if (full_text.length > 0) cursor += 2;
6890
7773
  full_text.push(part_text);
7774
+ if (structure) {
7775
+ structure.part_ranges.push([cursor, cursor + part_text.length, part_kind]);
7776
+ }
6891
7777
  cursor += part_text.length;
6892
7778
  }
6893
7779
  }
@@ -6899,9 +7785,12 @@ function _extractTextFromDoc(doc, cleanView = false, includeAppendix = true, ret
6899
7785
  if (return_paragraph_offsets) {
6900
7786
  return { text: base_text, paragraph_offsets };
6901
7787
  }
7788
+ if (structure) {
7789
+ return { text: base_text, structure };
7790
+ }
6902
7791
  return base_text;
6903
7792
  }
6904
- function _extract_blocks(container, comments_map, cleanView, cursor, paragraph_offsets) {
7793
+ function _extract_blocks(container, comments_map, cleanView, cursor, paragraph_offsets, table_acc) {
6905
7794
  const part = container.part || container;
6906
7795
  const [style_cache, default_pstyle] = _get_style_cache(part);
6907
7796
  const blocks = [];
@@ -6955,17 +7844,23 @@ ${header}`;
6955
7844
  is_first_para = false;
6956
7845
  is_first_block = false;
6957
7846
  } else if (item instanceof Table) {
7847
+ const geometry = table_acc ? { start: block_start, end: block_start, rows: [] } : null;
6958
7848
  const table_text = extract_table(
6959
7849
  item,
6960
7850
  comments_map,
6961
7851
  cleanView,
6962
7852
  block_start,
6963
- paragraph_offsets
7853
+ paragraph_offsets,
7854
+ geometry
6964
7855
  );
6965
7856
  if (table_text) {
6966
7857
  blocks.push(table_text);
6967
7858
  local_cursor = block_start + table_text.length;
6968
7859
  is_first_block = false;
7860
+ if (geometry && table_acc) {
7861
+ geometry.end = block_start + table_text.length;
7862
+ table_acc.push(geometry);
7863
+ }
6969
7864
  } else if (!is_first_block) {
6970
7865
  local_cursor -= 2;
6971
7866
  }
@@ -6974,7 +7869,7 @@ ${header}`;
6974
7869
  }
6975
7870
  return blocks.join("\n\n");
6976
7871
  }
6977
- function extract_table(table, comments_map, cleanView, cursor, paragraph_offsets) {
7872
+ function extract_table(table, comments_map, cleanView, cursor, paragraph_offsets, geometry) {
6978
7873
  const rows_text = [];
6979
7874
  let rows_processed = 0;
6980
7875
  let local_cursor = cursor;
@@ -7020,6 +7915,13 @@ function extract_table(table, comments_map, cleanView, cursor, paragraph_offsets
7020
7915
  }
7021
7916
  rows_text.push(row_str);
7022
7917
  local_cursor = row_start + row_str.length;
7918
+ if (geometry) {
7919
+ geometry.rows.push({
7920
+ start: row_start,
7921
+ end: local_cursor,
7922
+ cells: [...cell_texts]
7923
+ });
7924
+ }
7023
7925
  rows_processed++;
7024
7926
  }
7025
7927
  return rows_text.join("\n");
@@ -7171,7 +8073,12 @@ function build_paragraph_text(paragraph, comments_map, cleanView, style_cache, d
7171
8073
  else if (ev.type === "del_end") delete active_del[ev.id];
7172
8074
  else if (ev.type === "fmt_start") active_fmt[ev.id] = ev;
7173
8075
  else if (ev.type === "fmt_end") delete active_fmt[ev.id];
7174
- else if (ev.type === "footnote" || ev.type === "endnote") {
8076
+ else if (ev.type === "image") {
8077
+ if (!(cleanView && Object.keys(active_del).length > 0)) {
8078
+ const alt = (ev.date || "image").replace(/\]/g, ")").replace(/\n/g, " ");
8079
+ parts.push(`![${alt}](docx-image:${ev.id})`);
8080
+ }
8081
+ } else if (ev.type === "footnote" || ev.type === "endnote") {
7175
8082
  if (pending_text) {
7176
8083
  parts.push(
7177
8084
  `${current_wrappers[0]}${pending_text}${current_wrappers[1]}`
@@ -7973,6 +8880,7 @@ async function finalize_document(doc, options) {
7973
8880
  report.add_transform_lines(strip_image_alt_text(doc));
7974
8881
  const warnings = audit_hyperlinks(doc);
7975
8882
  for (const w of warnings) report.warnings.push(w);
8883
+ for (const w of detect_watermarks(doc)) report.warnings.push(w);
7976
8884
  report.add_transform_lines(normalize_change_dates(doc));
7977
8885
  if (options.protection_mode === "read_only" || options.protection_mode === "encrypt") {
7978
8886
  if (options.protection_mode === "encrypt") {
@@ -8039,6 +8947,7 @@ export {
8039
8947
  extract_outline,
8040
8948
  finalize_document,
8041
8949
  generate_edits_from_text,
8950
+ generate_structured_edits,
8042
8951
  identifyEngine,
8043
8952
  paginate,
8044
8953
  split_structural_appendix,