@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 +1157 -115
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +114 -8
- package/dist/index.d.ts +114 -8
- package/dist/index.js +1156 -115
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/diff.ts +515 -1
- package/src/docx/bridge.ts +13 -0
- package/src/domain.ts +58 -14
- package/src/engine.ts +411 -38
- package/src/index.ts +3 -3
- package/src/ingest.ts +77 -5
- package/src/mapper.ts +85 -3
- package/src/markup.ts +211 -19
- package/src/repro_qa_report_2026_07_18.test.ts +1244 -0
- package/src/repro_qa_report_v6.test.ts +486 -0
- package/src/sanitize/core.ts +60 -1
- package/src/sanitize/report.ts +5 -3
- package/src/sanitize/sanitize.test.ts +5 -4
- package/src/sanitize/transforms.ts +134 -16
- package/src/utils/docx.ts +257 -16
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] = {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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 ===
|
|
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
|
-
|
|
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 === "
|
|
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 = ``;
|
|
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,332 @@ 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
|
+
_is_table_edit: true
|
|
2792
|
+
});
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
for (let k = i1 + pairs; k < i2; k++) {
|
|
2796
|
+
ops.push({
|
|
2797
|
+
type: "delete_row",
|
|
2798
|
+
target_text: row_text(rows_o[k]),
|
|
2799
|
+
_match_start_index: rows_o[k].start
|
|
2800
|
+
});
|
|
2801
|
+
}
|
|
2802
|
+
const surplus_new = rows_m.slice(j1 + pairs, j2);
|
|
2803
|
+
if (surplus_new.length > 0) {
|
|
2804
|
+
ops.push(...insert_ops(surplus_new, i2));
|
|
2805
|
+
}
|
|
2806
|
+
} else if (tag === "delete") {
|
|
2807
|
+
for (let k = i1; k < i2; k++) {
|
|
2808
|
+
ops.push({
|
|
2809
|
+
type: "delete_row",
|
|
2810
|
+
target_text: row_text(rows_o[k]),
|
|
2811
|
+
_match_start_index: rows_o[k].start
|
|
2812
|
+
});
|
|
2813
|
+
}
|
|
2814
|
+
} else if (tag === "insert") {
|
|
2815
|
+
ops.push(...insert_ops(rows_m.slice(j1, j2), i1));
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
return ops;
|
|
2819
|
+
}
|
|
2820
|
+
function generate_structured_edits(text_orig, struct_orig, text_mod, struct_mod) {
|
|
2821
|
+
const warnings = [];
|
|
2822
|
+
const edits = [];
|
|
2823
|
+
const kinds_o = struct_orig.part_ranges.filter(([s, e]) => e > s).map(([, , k]) => k);
|
|
2824
|
+
const kinds_m = struct_mod.part_ranges.filter(([s, e]) => e > s).map(([, , k]) => k);
|
|
2825
|
+
if (JSON.stringify(kinds_o) !== JSON.stringify(kinds_m)) {
|
|
2826
|
+
warnings.push(
|
|
2827
|
+
`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.`
|
|
2828
|
+
);
|
|
2829
|
+
const flat = _drop_image_marker_hunks(
|
|
2830
|
+
generate_edits_from_text(text_orig, text_mod),
|
|
2831
|
+
warnings
|
|
2832
|
+
);
|
|
2833
|
+
return { edits: [...flat], warnings };
|
|
2834
|
+
}
|
|
2835
|
+
const ranges_o = struct_orig.part_ranges.filter(([s, e]) => e > s).map(([s, e]) => [s, e]);
|
|
2836
|
+
const ranges_m = struct_mod.part_ranges.filter(([s, e]) => e > s).map(([s, e]) => [s, e]);
|
|
2837
|
+
for (let p = 0; p < ranges_o.length; p++) {
|
|
2838
|
+
const [po_start, po_end] = ranges_o[p];
|
|
2839
|
+
const [pm_start, pm_end] = ranges_m[p];
|
|
2840
|
+
const tables_o = struct_orig.tables.filter(
|
|
2841
|
+
(t) => po_start <= t.start && t.end <= po_end
|
|
2842
|
+
);
|
|
2843
|
+
const tables_m = struct_mod.tables.filter(
|
|
2844
|
+
(t) => pm_start <= t.start && t.end <= pm_end
|
|
2845
|
+
);
|
|
2846
|
+
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));
|
|
2847
|
+
if (tables_o.length !== tables_m.length) {
|
|
2848
|
+
warnings.push(
|
|
2849
|
+
`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.`
|
|
2850
|
+
);
|
|
2851
|
+
}
|
|
2852
|
+
if (!tables_alignable) {
|
|
2853
|
+
const part_edits = generate_edits_from_text(
|
|
2854
|
+
text_orig.substring(po_start, po_end),
|
|
2855
|
+
text_mod.substring(pm_start, pm_end)
|
|
2856
|
+
);
|
|
2857
|
+
for (const e of part_edits) {
|
|
2858
|
+
e._match_start_index = (e._match_start_index || 0) + po_start;
|
|
2859
|
+
}
|
|
2860
|
+
edits.push(...part_edits);
|
|
2861
|
+
continue;
|
|
2862
|
+
}
|
|
2863
|
+
const boundaries_o = [
|
|
2864
|
+
[po_start, po_start],
|
|
2865
|
+
...tables_o.map((t) => [t.start, t.end]),
|
|
2866
|
+
[po_end, po_end]
|
|
2867
|
+
];
|
|
2868
|
+
const boundaries_m = [
|
|
2869
|
+
[pm_start, pm_start],
|
|
2870
|
+
...tables_m.map((t) => [t.start, t.end]),
|
|
2871
|
+
[pm_end, pm_end]
|
|
2872
|
+
];
|
|
2873
|
+
for (let seg_idx = 0; seg_idx < boundaries_o.length - 1; seg_idx++) {
|
|
2874
|
+
const seg_o_start = boundaries_o[seg_idx][1];
|
|
2875
|
+
const seg_o_end = boundaries_o[seg_idx + 1][0];
|
|
2876
|
+
const seg_m_start = boundaries_m[seg_idx][1];
|
|
2877
|
+
const seg_m_end = boundaries_m[seg_idx + 1][0];
|
|
2878
|
+
const seg_edits = generate_edits_from_text(
|
|
2879
|
+
text_orig.substring(seg_o_start, seg_o_end),
|
|
2880
|
+
text_mod.substring(seg_m_start, seg_m_end)
|
|
2881
|
+
);
|
|
2882
|
+
for (const e of seg_edits) {
|
|
2883
|
+
e._match_start_index = (e._match_start_index || 0) + seg_o_start;
|
|
2884
|
+
}
|
|
2885
|
+
edits.push(...seg_edits);
|
|
2886
|
+
if (seg_idx < tables_o.length) {
|
|
2887
|
+
const t_o = tables_o[seg_idx];
|
|
2888
|
+
const t_m = tables_m[seg_idx];
|
|
2889
|
+
const row_opcodes = _table_row_opcodes(t_o.rows, t_m.rows);
|
|
2890
|
+
if (row_opcodes !== null) {
|
|
2891
|
+
edits.push(..._row_ops_for_table(t_o, t_m, row_opcodes, warnings));
|
|
2892
|
+
} else {
|
|
2893
|
+
for (let k = 0; k < t_o.rows.length; k++) {
|
|
2894
|
+
const o_txt = t_o.rows[k].cells.join(" | ");
|
|
2895
|
+
const m_txt = t_m.rows[k].cells.join(" | ");
|
|
2896
|
+
if (o_txt === m_txt) continue;
|
|
2897
|
+
edits.push({
|
|
2898
|
+
type: "modify",
|
|
2899
|
+
target_text: o_txt,
|
|
2900
|
+
new_text: m_txt,
|
|
2901
|
+
comment: "Diff: Table row modified",
|
|
2902
|
+
_is_table_edit: true
|
|
2903
|
+
});
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
const countOccurrences = (haystack, needle) => {
|
|
2910
|
+
let count = 0;
|
|
2911
|
+
let from = 0;
|
|
2912
|
+
while (true) {
|
|
2913
|
+
const idx = haystack.indexOf(needle, from);
|
|
2914
|
+
if (idx === -1) break;
|
|
2915
|
+
count++;
|
|
2916
|
+
from = idx + needle.length;
|
|
2917
|
+
}
|
|
2918
|
+
return count;
|
|
2919
|
+
};
|
|
2920
|
+
let ambiguous_anchor_warned = false;
|
|
2921
|
+
for (const e of edits) {
|
|
2922
|
+
if ((e.type === "insert_row" || e.type === "delete_row" || e._is_table_edit) && !ambiguous_anchor_warned) {
|
|
2923
|
+
if (e.target_text && countOccurrences(text_orig, e.target_text) > 1) {
|
|
2924
|
+
warnings.push(
|
|
2925
|
+
`The row anchor "${e.target_text.substring(0, 60)}" appears more than once in the document. Applying this diff from its JSON output may be rejected as ambiguous \u2014 make the anchor rows unique, or apply the row changes with explicit insert_row/delete_row edits.`
|
|
2926
|
+
);
|
|
2927
|
+
ambiguous_anchor_warned = true;
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
const modify_edits = edits.filter(
|
|
2932
|
+
(e) => e.type === "modify"
|
|
2933
|
+
);
|
|
2934
|
+
const kept_modifies = new Set(_drop_image_marker_hunks(modify_edits, warnings));
|
|
2935
|
+
const final_edits = edits.filter(
|
|
2936
|
+
(e) => e.type !== "modify" || kept_modifies.has(e)
|
|
2937
|
+
);
|
|
2938
|
+
return { edits: final_edits, warnings };
|
|
2939
|
+
}
|
|
2372
2940
|
function create_unified_diff(original_text, modified_text, context_lines = 3) {
|
|
2373
2941
|
const dmp = new diff_match_patch.diff_match_patch();
|
|
2374
2942
|
dmp.Diff_Timeout = 2;
|
|
@@ -2676,6 +3244,30 @@ function _strip_balanced_markers(text) {
|
|
|
2676
3244
|
function _replace_smart_quotes(text) {
|
|
2677
3245
|
return text.replace(/“/g, '"').replace(/”/g, '"').replace(/‘/g, "'").replace(/’/g, "'");
|
|
2678
3246
|
}
|
|
3247
|
+
function _strip_markdown_for_matching(text) {
|
|
3248
|
+
const result = [];
|
|
3249
|
+
const position_map = [];
|
|
3250
|
+
let i = 0;
|
|
3251
|
+
while (i < text.length) {
|
|
3252
|
+
const pair = text.substring(i, i + 2);
|
|
3253
|
+
if (i < text.length - 1 && (pair === "**" || pair === "__")) {
|
|
3254
|
+
i += 2;
|
|
3255
|
+
continue;
|
|
3256
|
+
}
|
|
3257
|
+
if (text[i] === "*" || text[i] === "_") {
|
|
3258
|
+
const prev_char = i > 0 ? text[i - 1] : " ";
|
|
3259
|
+
const next_char = i < text.length - 1 ? text[i + 1] : " ";
|
|
3260
|
+
if ([" ", "\n", " "].includes(prev_char) || [" ", "\n", " "].includes(next_char)) {
|
|
3261
|
+
i += 1;
|
|
3262
|
+
continue;
|
|
3263
|
+
}
|
|
3264
|
+
}
|
|
3265
|
+
position_map.push(i);
|
|
3266
|
+
result.push(text[i]);
|
|
3267
|
+
i += 1;
|
|
3268
|
+
}
|
|
3269
|
+
return [result.join(""), position_map];
|
|
3270
|
+
}
|
|
2679
3271
|
function _find_safe_boundaries(text, start, end) {
|
|
2680
3272
|
let new_start = start;
|
|
2681
3273
|
let new_end = end;
|
|
@@ -2779,31 +3371,63 @@ function _make_fuzzy_regex(target_text) {
|
|
|
2779
3371
|
if (remaining) parts.push(escapeRegExp(remaining));
|
|
2780
3372
|
return parts.join("");
|
|
2781
3373
|
}
|
|
2782
|
-
function
|
|
2783
|
-
if (!target) return [
|
|
2784
|
-
|
|
2785
|
-
|
|
3374
|
+
function _find_all_matches_in_text(text, target, is_regex = false) {
|
|
3375
|
+
if (!target) return [];
|
|
3376
|
+
if (is_regex) {
|
|
3377
|
+
try {
|
|
3378
|
+
return userFindAllMatches(target, text).map((m) => [m.start, m.end]);
|
|
3379
|
+
} catch (e) {
|
|
3380
|
+
if (e instanceof RegexTimeoutError) throw e;
|
|
3381
|
+
return [];
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
const findAllLiteral = (haystack, needle) => {
|
|
3385
|
+
const out = [];
|
|
3386
|
+
let from = 0;
|
|
3387
|
+
while (true) {
|
|
3388
|
+
const idx = haystack.indexOf(needle, from);
|
|
3389
|
+
if (idx === -1) break;
|
|
3390
|
+
out.push([idx, idx + needle.length]);
|
|
3391
|
+
from = idx + needle.length;
|
|
3392
|
+
}
|
|
3393
|
+
return out;
|
|
3394
|
+
};
|
|
3395
|
+
let spans = findAllLiteral(text, target);
|
|
3396
|
+
if (spans.length > 0) {
|
|
3397
|
+
return spans.map(([s, e]) => _find_safe_boundaries(text, s, e));
|
|
3398
|
+
}
|
|
2786
3399
|
const norm_text = _replace_smart_quotes(text);
|
|
2787
3400
|
const norm_target = _replace_smart_quotes(target);
|
|
2788
|
-
|
|
2789
|
-
if (
|
|
2790
|
-
return _find_safe_boundaries(text,
|
|
3401
|
+
spans = findAllLiteral(norm_text, norm_target);
|
|
3402
|
+
if (spans.length > 0) {
|
|
3403
|
+
return spans.map(([s, e]) => _find_safe_boundaries(text, s, e));
|
|
3404
|
+
}
|
|
3405
|
+
const [stripped_text, pos_map] = _strip_markdown_for_matching(norm_text);
|
|
3406
|
+
const [stripped_target] = _strip_markdown_for_matching(norm_target);
|
|
3407
|
+
if (stripped_target && (stripped_text !== norm_text || stripped_target !== norm_target)) {
|
|
3408
|
+
const results = [];
|
|
3409
|
+
for (const [p_start, p_end] of findAllLiteral(stripped_text, stripped_target)) {
|
|
3410
|
+
const raw_start = pos_map[p_start];
|
|
3411
|
+
const raw_end = pos_map[p_end - 1] + 1;
|
|
3412
|
+
results.push(_find_safe_boundaries(text, raw_start, raw_end));
|
|
3413
|
+
}
|
|
3414
|
+
if (results.length > 0) return results;
|
|
3415
|
+
}
|
|
2791
3416
|
try {
|
|
2792
|
-
const pattern = new RegExp(_make_fuzzy_regex(target));
|
|
2793
|
-
const
|
|
2794
|
-
|
|
2795
|
-
const raw_start = match.index;
|
|
2796
|
-
const raw_end = match.index + match[0].length;
|
|
3417
|
+
const pattern = new RegExp(_make_fuzzy_regex(target), "g");
|
|
3418
|
+
const results = [];
|
|
3419
|
+
for (const match of text.matchAll(pattern)) {
|
|
2797
3420
|
const [refined_start, refined_end] = _refine_match_boundaries(
|
|
2798
3421
|
text,
|
|
2799
|
-
|
|
2800
|
-
|
|
3422
|
+
match.index,
|
|
3423
|
+
match.index + match[0].length
|
|
2801
3424
|
);
|
|
2802
|
-
|
|
3425
|
+
results.push(_find_safe_boundaries(text, refined_start, refined_end));
|
|
2803
3426
|
}
|
|
3427
|
+
if (results.length > 0) return results;
|
|
2804
3428
|
} catch (e) {
|
|
2805
3429
|
}
|
|
2806
|
-
return [
|
|
3430
|
+
return [];
|
|
2807
3431
|
}
|
|
2808
3432
|
function _build_critic_markup(target_text, new_text, comment, edit_index, include_index, highlight_only) {
|
|
2809
3433
|
const parts = [];
|
|
@@ -2835,19 +3459,55 @@ function _build_critic_markup(target_text, new_text, comment, edit_index, includ
|
|
|
2835
3459
|
}
|
|
2836
3460
|
return parts.join("");
|
|
2837
3461
|
}
|
|
2838
|
-
function apply_edits_to_markdown(markdown_text, edits, include_index = false, highlight_only = false) {
|
|
3462
|
+
function apply_edits_to_markdown(markdown_text, edits, include_index = false, highlight_only = false, edit_reports) {
|
|
2839
3463
|
if (!edits || edits.length === 0) return markdown_text;
|
|
3464
|
+
const _report = (idx, status, error = null, occurrences = 0) => {
|
|
3465
|
+
if (edit_reports) edit_reports.push({ index: idx, status, error, occurrences });
|
|
3466
|
+
};
|
|
2840
3467
|
const matched_edits = [];
|
|
2841
3468
|
for (let idx = 0; idx < edits.length; idx++) {
|
|
2842
3469
|
const edit = edits[idx];
|
|
2843
3470
|
const target = edit.target_text || "";
|
|
3471
|
+
const match_mode = edit.match_mode || "strict";
|
|
3472
|
+
const is_regex = Boolean(edit.regex);
|
|
2844
3473
|
if (!target) {
|
|
3474
|
+
_report(
|
|
3475
|
+
idx,
|
|
3476
|
+
"failed",
|
|
3477
|
+
`- 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.`
|
|
3478
|
+
);
|
|
3479
|
+
continue;
|
|
3480
|
+
}
|
|
3481
|
+
let spans;
|
|
3482
|
+
try {
|
|
3483
|
+
spans = _find_all_matches_in_text(markdown_text, target, is_regex);
|
|
3484
|
+
} catch (e) {
|
|
3485
|
+
if (!(e instanceof RegexTimeoutError)) throw e;
|
|
3486
|
+
_report(idx, "failed", `- Edit ${idx + 1} Failed: ${e.message}`);
|
|
3487
|
+
continue;
|
|
3488
|
+
}
|
|
3489
|
+
if (spans.length === 0) {
|
|
3490
|
+
_report(
|
|
3491
|
+
idx,
|
|
3492
|
+
"failed",
|
|
3493
|
+
`- Edit ${idx + 1} Failed: Target text not found in document:
|
|
3494
|
+
"${target.substring(0, 80)}"`
|
|
3495
|
+
);
|
|
3496
|
+
continue;
|
|
3497
|
+
}
|
|
3498
|
+
if (spans.length > 1 && match_mode === "strict") {
|
|
3499
|
+
_report(
|
|
3500
|
+
idx,
|
|
3501
|
+
"failed",
|
|
3502
|
+
format_ambiguity_error(idx + 1, target, markdown_text, spans)
|
|
3503
|
+
);
|
|
2845
3504
|
continue;
|
|
2846
3505
|
}
|
|
2847
|
-
const
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
3506
|
+
const selected = match_mode === "strict" || match_mode === "first" ? spans.slice(0, 1) : spans;
|
|
3507
|
+
for (const [start, end] of selected) {
|
|
3508
|
+
matched_edits.push([start, end, markdown_text.substring(start, end), edit, idx]);
|
|
3509
|
+
}
|
|
3510
|
+
_report(idx, "applied", null, selected.length);
|
|
2851
3511
|
}
|
|
2852
3512
|
const matched_edits_filtered = [];
|
|
2853
3513
|
const occupied_ranges = [];
|
|
@@ -2857,6 +3517,16 @@ function apply_edits_to_markdown(markdown_text, edits, include_index = false, hi
|
|
|
2857
3517
|
for (const [occ_start, occ_end] of occupied_ranges) {
|
|
2858
3518
|
if (start < occ_end && end > occ_start) {
|
|
2859
3519
|
overlaps = true;
|
|
3520
|
+
if (edit_reports) {
|
|
3521
|
+
const msg = `- Edit ${orig_idx + 1} Failed: overlaps with a previously matched edit.`;
|
|
3522
|
+
for (const r of edit_reports) {
|
|
3523
|
+
if (r.index === orig_idx) {
|
|
3524
|
+
r.status = "failed";
|
|
3525
|
+
r.error = msg;
|
|
3526
|
+
r.occurrences = 0;
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
}
|
|
2860
3530
|
break;
|
|
2861
3531
|
}
|
|
2862
3532
|
}
|
|
@@ -3125,6 +3795,15 @@ function validate_edit_strings(edits, index_offset = 0) {
|
|
|
3125
3795
|
}
|
|
3126
3796
|
}
|
|
3127
3797
|
}
|
|
3798
|
+
if (t_text.includes("docx-image:") || n_text.includes("docx-image:")) {
|
|
3799
|
+
const t_imgs = (t_text.match(/!\[[^\]]*\]\(docx-image:[^)]*\)/g) || []).sort();
|
|
3800
|
+
const n_imgs = (n_text.match(/!\[[^\]]*\]\(docx-image:[^)]*\)/g) || []).sort();
|
|
3801
|
+
if (JSON.stringify(t_imgs) !== JSON.stringify(n_imgs)) {
|
|
3802
|
+
errors.push(
|
|
3803
|
+
`- Edit ${i + 1 + index_offset} Failed: image markers () 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.`
|
|
3804
|
+
);
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3128
3807
|
if (t_text.includes("{#") || n_text.includes("{#")) {
|
|
3129
3808
|
const t_anchors = t_text.match(/\{#[^\}]+\}/g) || [];
|
|
3130
3809
|
const n_anchors = n_text.match(/\{#[^\}]+\}/g) || [];
|
|
@@ -3848,6 +4527,43 @@ var RedlineEngine = class _RedlineEngine {
|
|
|
3848
4527
|
element.setAttribute("xml:space", "preserve");
|
|
3849
4528
|
}
|
|
3850
4529
|
}
|
|
4530
|
+
/**
|
|
4531
|
+
* Walks `element` to its XML root element. Word (and LibreOffice, which
|
|
4532
|
+
* refuses to LOAD such files) only supports comment ranges in the main
|
|
4533
|
+
* document story ("w:document") — never in headers, footers, footnotes or
|
|
4534
|
+
* endnotes (QA 2026-07-18 H4/C1).
|
|
4535
|
+
*/
|
|
4536
|
+
_comment_anchor_in_main_story(element) {
|
|
4537
|
+
let root = element;
|
|
4538
|
+
while (root.parentNode && root.parentNode.nodeType === 1) {
|
|
4539
|
+
root = root.parentNode;
|
|
4540
|
+
}
|
|
4541
|
+
return root.tagName === "w:document";
|
|
4542
|
+
}
|
|
4543
|
+
/**
|
|
4544
|
+
* When the anchor lives outside the main document story, records a
|
|
4545
|
+
* user-visible warning and returns true (caller must skip the comment).
|
|
4546
|
+
* The tracked change itself still applies — only the bubble is dropped.
|
|
4547
|
+
*/
|
|
4548
|
+
_skip_comment_outside_main_story(element, text) {
|
|
4549
|
+
if (this._comment_anchor_in_main_story(element)) return false;
|
|
4550
|
+
let root = element;
|
|
4551
|
+
while (root.parentNode && root.parentNode.nodeType === 1) {
|
|
4552
|
+
root = root.parentNode;
|
|
4553
|
+
}
|
|
4554
|
+
const story = {
|
|
4555
|
+
"w:ftr": "footer",
|
|
4556
|
+
"w:hdr": "header",
|
|
4557
|
+
"w:footnotes": "footnote",
|
|
4558
|
+
"w:endnotes": "endnote"
|
|
4559
|
+
}[root.tagName] || "non-body";
|
|
4560
|
+
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.`;
|
|
4561
|
+
this.skipped_details.push(msg);
|
|
4562
|
+
console.error(
|
|
4563
|
+
`Comment anchor outside main story; comment dropped (story=${story})`
|
|
4564
|
+
);
|
|
4565
|
+
return true;
|
|
4566
|
+
}
|
|
3851
4567
|
/**
|
|
3852
4568
|
* Attaches a comment that wraps a contiguous range within a single paragraph.
|
|
3853
4569
|
* start_element and end_element must both be direct children of parent_element
|
|
@@ -3856,6 +4572,8 @@ var RedlineEngine = class _RedlineEngine {
|
|
|
3856
4572
|
*/
|
|
3857
4573
|
_attach_comment(parent_element, start_element, end_element, text) {
|
|
3858
4574
|
if (!text) return;
|
|
4575
|
+
if (!parent_element || !start_element || !end_element) return;
|
|
4576
|
+
if (this._skip_comment_outside_main_story(parent_element, text)) return;
|
|
3859
4577
|
const comment_id = this.comments_manager.addComment(this.author, text);
|
|
3860
4578
|
const xmlDoc = parent_element.ownerDocument;
|
|
3861
4579
|
const range_start = xmlDoc.createElement("w:commentRangeStart");
|
|
@@ -3889,6 +4607,9 @@ var RedlineEngine = class _RedlineEngine {
|
|
|
3889
4607
|
*/
|
|
3890
4608
|
_attach_comment_spanning(start_p, start_el, end_p, end_el, text) {
|
|
3891
4609
|
if (!text) return;
|
|
4610
|
+
if (!start_p || !end_p) return;
|
|
4611
|
+
if (this._skip_comment_outside_main_story(start_p, text) || this._skip_comment_outside_main_story(end_p, text))
|
|
4612
|
+
return;
|
|
3892
4613
|
const comment_id = this.comments_manager.addComment(this.author, text);
|
|
3893
4614
|
const xmlDocStart = start_p.ownerDocument;
|
|
3894
4615
|
const xmlDocEnd = end_p.ownerDocument;
|
|
@@ -3950,7 +4671,7 @@ var RedlineEngine = class _RedlineEngine {
|
|
|
3950
4671
|
*
|
|
3951
4672
|
* Does NOT attach comments; callers handle that.
|
|
3952
4673
|
*/
|
|
3953
|
-
_track_insert_multiline(text, anchor_run, anchor_paragraph, reuse_id) {
|
|
4674
|
+
_track_insert_multiline(text, anchor_run, anchor_paragraph, reuse_id, positional_anchor_el = null) {
|
|
3954
4675
|
if (!text) {
|
|
3955
4676
|
return {
|
|
3956
4677
|
first_node: null,
|
|
@@ -3971,7 +4692,29 @@ var RedlineEngine = class _RedlineEngine {
|
|
|
3971
4692
|
}
|
|
3972
4693
|
current_p = walker;
|
|
3973
4694
|
}
|
|
3974
|
-
|
|
4695
|
+
const suffix_nodes = [];
|
|
4696
|
+
const pos_source = positional_anchor_el && positional_anchor_el.parentNode ? positional_anchor_el : anchor_run !== null && anchor_run._element.parentNode ? anchor_run._element : null;
|
|
4697
|
+
if (current_p !== null && pos_source !== null) {
|
|
4698
|
+
let pos_anchor = pos_source;
|
|
4699
|
+
while (pos_anchor && pos_anchor.parentNode !== current_p) {
|
|
4700
|
+
pos_anchor = pos_anchor.parentNode;
|
|
4701
|
+
if (pos_anchor === current_p) {
|
|
4702
|
+
pos_anchor = null;
|
|
4703
|
+
break;
|
|
4704
|
+
}
|
|
4705
|
+
}
|
|
4706
|
+
if (pos_anchor) {
|
|
4707
|
+
const relocatable = /* @__PURE__ */ new Set(["w:r", "w:ins", "w:del"]);
|
|
4708
|
+
let nxt = pos_anchor.nextSibling;
|
|
4709
|
+
while (nxt) {
|
|
4710
|
+
if (nxt.nodeType === 1 && relocatable.has(nxt.tagName)) {
|
|
4711
|
+
suffix_nodes.push(nxt);
|
|
4712
|
+
}
|
|
4713
|
+
nxt = nxt.nextSibling;
|
|
4714
|
+
}
|
|
4715
|
+
}
|
|
4716
|
+
}
|
|
4717
|
+
while (lines.length > 1 && lines[lines.length - 1] === "" && suffix_nodes.length === 0) {
|
|
3975
4718
|
lines.pop();
|
|
3976
4719
|
}
|
|
3977
4720
|
if (lines.length === 0) {
|
|
@@ -4069,6 +4812,12 @@ var RedlineEngine = class _RedlineEngine {
|
|
|
4069
4812
|
first_node = new_p;
|
|
4070
4813
|
}
|
|
4071
4814
|
}
|
|
4815
|
+
if (!block_mode && last_p && suffix_nodes.length > 0) {
|
|
4816
|
+
for (const node of suffix_nodes) {
|
|
4817
|
+
node.parentNode?.removeChild(node);
|
|
4818
|
+
last_p.appendChild(node);
|
|
4819
|
+
}
|
|
4820
|
+
}
|
|
4072
4821
|
return { first_node, last_p, last_ins, used_block_mode: block_mode };
|
|
4073
4822
|
}
|
|
4074
4823
|
/**
|
|
@@ -4126,7 +4875,7 @@ var RedlineEngine = class _RedlineEngine {
|
|
|
4126
4875
|
if (stripped_text.startsWith("* ") || stripped_text.startsWith("- ")) {
|
|
4127
4876
|
return [stripped_text.substring(2).trim(), "List Paragraph"];
|
|
4128
4877
|
}
|
|
4129
|
-
const match = stripped_text.match(
|
|
4878
|
+
const match = stripped_text.match(/^1\.\s+/);
|
|
4130
4879
|
if (match) {
|
|
4131
4880
|
return [stripped_text.substring(match[0].length).trim(), "List Number"];
|
|
4132
4881
|
}
|
|
@@ -4500,6 +5249,51 @@ var RedlineEngine = class _RedlineEngine {
|
|
|
4500
5249
|
pfx,
|
|
4501
5250
|
(edit.new_text || "").length - sfx
|
|
4502
5251
|
);
|
|
5252
|
+
const multi_part_doc = target_mapper.part_ranges.filter((r) => r[1] > r[0]).length > 1;
|
|
5253
|
+
const raw_span_parts = multi_part_doc ? Array.from(
|
|
5254
|
+
new Set(
|
|
5255
|
+
target_mapper.spans.filter(
|
|
5256
|
+
(s) => s.run !== null && s.end > m_start && s.start < m_start + m_len
|
|
5257
|
+
).map((s) => s.part_index)
|
|
5258
|
+
)
|
|
5259
|
+
).sort((a, b) => a - b) : [];
|
|
5260
|
+
if (raw_span_parts.length > 1) {
|
|
5261
|
+
const kinds = raw_span_parts.map((pi) => target_mapper.part_kind_of(pi) || "?").join(" \u2192 ");
|
|
5262
|
+
errors.push(
|
|
5263
|
+
`- 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).`
|
|
5264
|
+
);
|
|
5265
|
+
}
|
|
5266
|
+
const eff_start = m_start + pfx;
|
|
5267
|
+
const eff_end = m_start + m_len - sfx;
|
|
5268
|
+
if (eff_end > eff_start) {
|
|
5269
|
+
const overlapping = target_mapper.spans.filter(
|
|
5270
|
+
(s) => s.end > eff_start && s.start < eff_end && (s.run !== null || s.text.trim() !== "")
|
|
5271
|
+
);
|
|
5272
|
+
if (overlapping.some((s) => s.is_image_marker)) {
|
|
5273
|
+
errors.push(
|
|
5274
|
+
`- Edit ${i + 1 + index_offset} Failed: the target overlaps a read-only image marker (). Images cannot be edited or removed via text replacement \u2014 target the text around the image instead.`
|
|
5275
|
+
);
|
|
5276
|
+
}
|
|
5277
|
+
}
|
|
5278
|
+
if (edit.comment && (edit.new_text || "") === (edit.target_text || "")) {
|
|
5279
|
+
const kind_here = target_mapper.part_kind_at(m_start);
|
|
5280
|
+
if (kind_here !== null && kind_here !== "body") {
|
|
5281
|
+
errors.push(
|
|
5282
|
+
`- 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.`
|
|
5283
|
+
);
|
|
5284
|
+
}
|
|
5285
|
+
}
|
|
5286
|
+
if (_RedlineEngine._introduces_table_row_text(
|
|
5287
|
+
target_mapper,
|
|
5288
|
+
m_start,
|
|
5289
|
+
m_len,
|
|
5290
|
+
final_target,
|
|
5291
|
+
final_new
|
|
5292
|
+
)) {
|
|
5293
|
+
errors.push(
|
|
5294
|
+
`- 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": ["...", "..."]}).`
|
|
5295
|
+
);
|
|
5296
|
+
}
|
|
4503
5297
|
if (final_target.includes("\n\n")) {
|
|
4504
5298
|
const balanced = matched.split("\n\n").length === (edit.new_text || "").split("\n\n").length;
|
|
4505
5299
|
if (!balanced) {
|
|
@@ -4596,6 +5390,19 @@ var RedlineEngine = class _RedlineEngine {
|
|
|
4596
5390
|
* [start, start+length) in `mapper`, or null if that text is not inside a
|
|
4597
5391
|
* table row.
|
|
4598
5392
|
*/
|
|
5393
|
+
/**
|
|
5394
|
+
* True when a replacement anchored in a table would ADD line-separated
|
|
5395
|
+
* pipe-delimited content — the text shape of a table row. Writing that
|
|
5396
|
+
* into a cell renders a fake row inside one cell while the real grid
|
|
5397
|
+
* stays unchanged (QA 2026-07-18 C2); such edits must use insert_row.
|
|
5398
|
+
*/
|
|
5399
|
+
static _introduces_table_row_text(mapper, start, length, final_target, final_new) {
|
|
5400
|
+
if (!final_new.includes("\n") || !final_new.includes(" | ")) return false;
|
|
5401
|
+
const new_pipe_lines = final_new.split("\n").filter((line) => line.includes(" | ")).length;
|
|
5402
|
+
const old_pipe_lines = final_target.split("\n").filter((line) => line.includes(" | ")).length;
|
|
5403
|
+
if (new_pipe_lines <= old_pipe_lines) return false;
|
|
5404
|
+
return _RedlineEngine._column_count_at(mapper, start, Math.max(length, 1)) !== null;
|
|
5405
|
+
}
|
|
4599
5406
|
static _column_count_at(mapper, start, length) {
|
|
4600
5407
|
for (const s of mapper.spans) {
|
|
4601
5408
|
if (s.run === null || s.end <= start || s.start >= start + length) {
|
|
@@ -4950,14 +5757,17 @@ var RedlineEngine = class _RedlineEngine {
|
|
|
4950
5757
|
resolved_edits.push([edit, edit.new_text || null]);
|
|
4951
5758
|
} else if (edit.type === "insert_row" || edit.type === "delete_row") {
|
|
4952
5759
|
let matches = this.mapper.find_all_match_indices(edit.target_text);
|
|
5760
|
+
let resolved_mapper = this.mapper;
|
|
4953
5761
|
if (matches.length === 0) {
|
|
4954
5762
|
if (!this.clean_mapper) {
|
|
4955
5763
|
this.clean_mapper = new DocumentMapper(this.doc, true);
|
|
4956
5764
|
}
|
|
4957
5765
|
matches = this.clean_mapper.find_all_match_indices(edit.target_text);
|
|
5766
|
+
resolved_mapper = this.clean_mapper;
|
|
4958
5767
|
}
|
|
4959
5768
|
if (matches.length > 0) {
|
|
4960
5769
|
edit._resolved_start_idx = matches[0][0];
|
|
5770
|
+
edit._active_mapper_ref = resolved_mapper;
|
|
4961
5771
|
resolved_edits.push([edit, null]);
|
|
4962
5772
|
} else {
|
|
4963
5773
|
skipped++;
|
|
@@ -5024,7 +5834,7 @@ var RedlineEngine = class _RedlineEngine {
|
|
|
5024
5834
|
const counted_split_groups = /* @__PURE__ */ new Set();
|
|
5025
5835
|
for (const [edit, orig_new] of resolved_edits) {
|
|
5026
5836
|
const start = edit._resolved_start_idx || 0;
|
|
5027
|
-
const end = start + (edit.target_text ? edit.target_text.length : 0);
|
|
5837
|
+
const end = edit.type === "insert_row" ? start : start + (edit.target_text ? edit.target_text.length : 0);
|
|
5028
5838
|
const overlaps = occupied_ranges.some(
|
|
5029
5839
|
([occ_start, occ_end]) => start < occ_end && end > occ_start
|
|
5030
5840
|
);
|
|
@@ -5206,7 +6016,8 @@ var RedlineEngine = class _RedlineEngine {
|
|
|
5206
6016
|
}
|
|
5207
6017
|
_apply_table_edit(edit, rebuild_map) {
|
|
5208
6018
|
const start_idx = edit._resolved_start_idx !== void 0 && edit._resolved_start_idx !== null ? edit._resolved_start_idx : edit._match_start_index || 0;
|
|
5209
|
-
const
|
|
6019
|
+
const active_mapper = edit._active_mapper_ref || this.mapper;
|
|
6020
|
+
const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
|
|
5210
6021
|
start_idx,
|
|
5211
6022
|
rebuild_map
|
|
5212
6023
|
);
|
|
@@ -5387,9 +6198,14 @@ var RedlineEngine = class _RedlineEngine {
|
|
|
5387
6198
|
if (overlaps_virtual_pipe) {
|
|
5388
6199
|
const actual_cells = actual_doc_text.split("|");
|
|
5389
6200
|
const new_cells = current_effective_new_text.split("|");
|
|
5390
|
-
if (actual_cells.length
|
|
6201
|
+
if (actual_cells.length !== new_cells.length) {
|
|
6202
|
+
throw new BatchValidationError([
|
|
6203
|
+
`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).`
|
|
6204
|
+
]);
|
|
6205
|
+
}
|
|
6206
|
+
if (actual_cells.length > 1) {
|
|
5391
6207
|
const sub_edits2 = [];
|
|
5392
|
-
let
|
|
6208
|
+
let cell_start_in_target = 0;
|
|
5393
6209
|
let target_comment_idx = 0;
|
|
5394
6210
|
for (let idx = 0; idx < actual_cells.length; idx++) {
|
|
5395
6211
|
if (actual_cells[idx].trim() !== new_cells[idx].trim()) {
|
|
@@ -5402,13 +6218,7 @@ var RedlineEngine = class _RedlineEngine {
|
|
|
5402
6218
|
const n_cell = new_cells[cell_idx];
|
|
5403
6219
|
const a_clean = a_cell.trim();
|
|
5404
6220
|
const n_clean = n_cell.trim();
|
|
5405
|
-
|
|
5406
|
-
if (a_clean) {
|
|
5407
|
-
actual_start = this.mapper.full_text.indexOf(a_clean, search_offset);
|
|
5408
|
-
if (actual_start === -1 || actual_start > search_offset + 10) {
|
|
5409
|
-
actual_start = search_offset;
|
|
5410
|
-
}
|
|
5411
|
-
}
|
|
6221
|
+
const actual_start = start_idx + cell_start_in_target + (a_clean ? a_cell.indexOf(a_clean) : 0);
|
|
5412
6222
|
const should_attach_comment = edit.comment !== null && edit.comment !== void 0 && cell_idx === target_comment_idx;
|
|
5413
6223
|
if (a_clean !== n_clean || should_attach_comment) {
|
|
5414
6224
|
const cell_sub_edits = this._word_diff_sub_edits(
|
|
@@ -5425,24 +6235,12 @@ var RedlineEngine = class _RedlineEngine {
|
|
|
5425
6235
|
sub_edits2.push(se);
|
|
5426
6236
|
}
|
|
5427
6237
|
}
|
|
5428
|
-
|
|
5429
|
-
search_offset = actual_start + a_clean.length;
|
|
5430
|
-
}
|
|
5431
|
-
const next_pipe = this.mapper.full_text.indexOf(" | ", search_offset);
|
|
5432
|
-
if (next_pipe !== -1 && next_pipe <= search_offset + 10) {
|
|
5433
|
-
search_offset = next_pipe + 3;
|
|
5434
|
-
} else {
|
|
5435
|
-
search_offset += a_cell.length + 1;
|
|
5436
|
-
}
|
|
6238
|
+
cell_start_in_target += a_cell.length + 1;
|
|
5437
6239
|
}
|
|
5438
6240
|
for (const sub of sub_edits2) {
|
|
5439
6241
|
all_sub_edits.push(sub);
|
|
5440
6242
|
}
|
|
5441
6243
|
continue;
|
|
5442
|
-
} else {
|
|
5443
|
-
throw new BatchValidationError([
|
|
5444
|
-
`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).`
|
|
5445
|
-
]);
|
|
5446
6244
|
}
|
|
5447
6245
|
}
|
|
5448
6246
|
let has_markdown = false;
|
|
@@ -5536,6 +6334,19 @@ var RedlineEngine = class _RedlineEngine {
|
|
|
5536
6334
|
continue;
|
|
5537
6335
|
}
|
|
5538
6336
|
}
|
|
6337
|
+
if (!final_target && final_new) {
|
|
6338
|
+
all_sub_edits.push({
|
|
6339
|
+
type: "modify",
|
|
6340
|
+
target_text: "",
|
|
6341
|
+
new_text: final_new,
|
|
6342
|
+
comment: edit.comment,
|
|
6343
|
+
_resolved_start_idx: effective_start_idx,
|
|
6344
|
+
_match_start_index: effective_start_idx,
|
|
6345
|
+
_internal_op: "INSERTION",
|
|
6346
|
+
_active_mapper_ref: active_mapper
|
|
6347
|
+
});
|
|
6348
|
+
continue;
|
|
6349
|
+
}
|
|
5539
6350
|
const sub_edits = this._word_diff_sub_edits(
|
|
5540
6351
|
actual_doc_text,
|
|
5541
6352
|
current_effective_new_text,
|
|
@@ -5714,13 +6525,49 @@ var RedlineEngine = class _RedlineEngine {
|
|
|
5714
6525
|
return true;
|
|
5715
6526
|
}
|
|
5716
6527
|
if (op === "INSERTION") {
|
|
5717
|
-
|
|
5718
|
-
|
|
5719
|
-
|
|
5720
|
-
);
|
|
6528
|
+
let final_new_text = edit.new_text || "";
|
|
6529
|
+
let boundary_anchor = null;
|
|
6530
|
+
const boundary = typeof active_mapper.part_boundary_at === "function" ? active_mapper.part_boundary_at(start_idx) : null;
|
|
6531
|
+
const is_machine_pure_insertion = !edit.target_text && (edit._parent_edit_ref === void 0 || edit._parent_edit_ref === null);
|
|
6532
|
+
if (boundary !== null && is_machine_pure_insertion) {
|
|
6533
|
+
const [prev_i, next_i] = boundary;
|
|
6534
|
+
const prev_kind = active_mapper.part_kind_of(prev_i);
|
|
6535
|
+
const next_kind = active_mapper.part_kind_of(next_i);
|
|
6536
|
+
if (prev_kind === "body" && next_kind !== "body") {
|
|
6537
|
+
const real_before = active_mapper.spans.filter(
|
|
6538
|
+
(s) => s.run !== null && s.part_index === prev_i
|
|
6539
|
+
);
|
|
6540
|
+
if (real_before.length > 0) {
|
|
6541
|
+
boundary_anchor = real_before[real_before.length - 1];
|
|
6542
|
+
}
|
|
6543
|
+
}
|
|
6544
|
+
}
|
|
6545
|
+
let anchor_run;
|
|
6546
|
+
let anchor_para;
|
|
6547
|
+
if (boundary_anchor !== null) {
|
|
6548
|
+
anchor_run = boundary_anchor.run;
|
|
6549
|
+
anchor_para = boundary_anchor.paragraph;
|
|
6550
|
+
if (!final_new_text.startsWith("\n")) {
|
|
6551
|
+
final_new_text = "\n\n" + final_new_text;
|
|
6552
|
+
}
|
|
6553
|
+
} else {
|
|
6554
|
+
[anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
|
|
6555
|
+
start_idx,
|
|
6556
|
+
rebuild_map
|
|
6557
|
+
);
|
|
6558
|
+
}
|
|
5721
6559
|
if (!anchor_run && !anchor_para) return false;
|
|
5722
|
-
|
|
5723
|
-
|
|
6560
|
+
if (_RedlineEngine._introduces_table_row_text(
|
|
6561
|
+
active_mapper,
|
|
6562
|
+
start_idx,
|
|
6563
|
+
1,
|
|
6564
|
+
"",
|
|
6565
|
+
final_new_text
|
|
6566
|
+
)) {
|
|
6567
|
+
return false;
|
|
6568
|
+
}
|
|
6569
|
+
const _bug233_new = final_new_text;
|
|
6570
|
+
const _bug233_trailing_break = boundary_anchor === null && /\n\s*$/.test(_bug233_new);
|
|
5724
6571
|
let _bug233_target_para = null;
|
|
5725
6572
|
{
|
|
5726
6573
|
const startingSpans = active_mapper.spans.filter(
|
|
@@ -5790,7 +6637,7 @@ var RedlineEngine = class _RedlineEngine {
|
|
|
5790
6637
|
}
|
|
5791
6638
|
}
|
|
5792
6639
|
const result = this._track_insert_multiline(
|
|
5793
|
-
|
|
6640
|
+
final_new_text,
|
|
5794
6641
|
anchor_run,
|
|
5795
6642
|
anchor_para,
|
|
5796
6643
|
ins_id
|
|
@@ -5879,6 +6726,20 @@ var RedlineEngine = class _RedlineEngine {
|
|
|
5879
6726
|
}
|
|
5880
6727
|
return true;
|
|
5881
6728
|
}
|
|
6729
|
+
if ((op === "DELETION" || op === "MODIFICATION") && length && active_mapper.part_ranges.filter((r) => r[1] > r[0]).length > 1) {
|
|
6730
|
+
const crossed_parts = /* @__PURE__ */ new Set();
|
|
6731
|
+
for (const s of active_mapper.spans) {
|
|
6732
|
+
if (s.run !== null && s.end > start_idx && s.start < start_idx + length) {
|
|
6733
|
+
crossed_parts.add(s.part_index);
|
|
6734
|
+
}
|
|
6735
|
+
}
|
|
6736
|
+
if (crossed_parts.size > 1) {
|
|
6737
|
+
console.error(
|
|
6738
|
+
`Refusing edit that spans OPC part boundary (start=${start_idx}, parts=${Array.from(crossed_parts).sort().join(",")})`
|
|
6739
|
+
);
|
|
6740
|
+
return false;
|
|
6741
|
+
}
|
|
6742
|
+
}
|
|
5882
6743
|
const target_runs = active_mapper.find_target_runs_by_index(
|
|
5883
6744
|
start_idx,
|
|
5884
6745
|
length,
|
|
@@ -5927,7 +6788,10 @@ var RedlineEngine = class _RedlineEngine {
|
|
|
5927
6788
|
edit.new_text,
|
|
5928
6789
|
style_source_run,
|
|
5929
6790
|
mod_anchor_para,
|
|
5930
|
-
ins_id
|
|
6791
|
+
ins_id,
|
|
6792
|
+
// The insertion physically follows the deletion block; the style
|
|
6793
|
+
// run was detached when the deletion cloned it into <w:del>.
|
|
6794
|
+
last_del
|
|
5931
6795
|
);
|
|
5932
6796
|
if (result.first_node) {
|
|
5933
6797
|
const is_inline_first = result.first_node.tagName === "w:ins";
|
|
@@ -6474,7 +7338,10 @@ function scrub_doc_properties(doc) {
|
|
|
6474
7338
|
["keywords", "Keywords"],
|
|
6475
7339
|
["subject", "Subject"],
|
|
6476
7340
|
["contentStatus", "Content status"],
|
|
6477
|
-
["description", "Description/comments"]
|
|
7341
|
+
["description", "Description/comments"],
|
|
7342
|
+
["identifier", "Identifier"],
|
|
7343
|
+
["language", "Language"],
|
|
7344
|
+
["version", "Version"]
|
|
6478
7345
|
];
|
|
6479
7346
|
for (const [local, label] of leakFields) {
|
|
6480
7347
|
findDescendantsByLocalName(corePart._element, local).forEach((c) => {
|
|
@@ -6532,27 +7399,73 @@ function scrub_timestamps(doc) {
|
|
|
6532
7399
|
}
|
|
6533
7400
|
return modified ? ["Timestamps normalized to epoch"] : [];
|
|
6534
7401
|
}
|
|
6535
|
-
function
|
|
6536
|
-
const
|
|
6537
|
-
|
|
6538
|
-
const
|
|
6539
|
-
|
|
6540
|
-
const removeRelationsTo = (relsPart) => {
|
|
7402
|
+
function ejectPackageMembers(doc, matcher, relTargetMatcher) {
|
|
7403
|
+
const pkg = doc.pkg;
|
|
7404
|
+
const normalized = (p) => p.startsWith("/") ? p.substring(1) : p;
|
|
7405
|
+
for (const part of pkg.parts) {
|
|
7406
|
+
if (!part.partname.endsWith(".rels")) continue;
|
|
6541
7407
|
const toRemove = [];
|
|
6542
|
-
for (const rel of findAllDescendants(
|
|
6543
|
-
const target = rel.getAttribute("Target");
|
|
6544
|
-
if (target
|
|
7408
|
+
for (const rel of findAllDescendants(part._element, "Relationship")) {
|
|
7409
|
+
const target = rel.getAttribute("Target") || "";
|
|
7410
|
+
if (relTargetMatcher(target)) {
|
|
7411
|
+
toRemove.push(rel);
|
|
7412
|
+
const sourcePath = part.partname.replace("/_rels/", "/").replace(".rels", "");
|
|
7413
|
+
const sourcePart = pkg.getPartByPath(sourcePath);
|
|
7414
|
+
if (sourcePart) {
|
|
7415
|
+
const relId = rel.getAttribute("Id");
|
|
7416
|
+
if (relId) sourcePart.rels.delete(relId);
|
|
7417
|
+
}
|
|
7418
|
+
}
|
|
6545
7419
|
}
|
|
6546
7420
|
toRemove.forEach((r) => r.parentNode?.removeChild(r));
|
|
6547
|
-
}
|
|
6548
|
-
const
|
|
6549
|
-
if (
|
|
6550
|
-
|
|
6551
|
-
|
|
7421
|
+
}
|
|
7422
|
+
const ctPart = pkg.getPartByPath("[Content_Types].xml");
|
|
7423
|
+
if (ctPart) {
|
|
7424
|
+
const toRemove = [];
|
|
7425
|
+
for (const override of findAllDescendants(ctPart._element, "Override")) {
|
|
7426
|
+
const partName = override.getAttribute("PartName") || "";
|
|
7427
|
+
if (matcher(normalized(partName))) toRemove.push(override);
|
|
7428
|
+
}
|
|
7429
|
+
toRemove.forEach((o) => o.parentNode?.removeChild(o));
|
|
7430
|
+
}
|
|
7431
|
+
pkg.parts = pkg.parts.filter((p) => !matcher(normalized(p.partname)));
|
|
7432
|
+
for (const key of Object.keys(pkg.unzipped)) {
|
|
7433
|
+
if (matcher(normalized(key))) delete pkg.unzipped[key];
|
|
7434
|
+
}
|
|
7435
|
+
}
|
|
7436
|
+
function strip_custom_xml(doc) {
|
|
7437
|
+
const isCustomXml = (path) => path.startsWith("customXml/");
|
|
7438
|
+
const customParts = doc.pkg.parts.filter(
|
|
7439
|
+
(p) => isCustomXml(p.partname.startsWith("/") ? p.partname.substring(1) : p.partname)
|
|
7440
|
+
);
|
|
7441
|
+
const leakedMembers = Object.keys(doc.pkg.unzipped).filter(isCustomXml);
|
|
7442
|
+
if (customParts.length === 0 && leakedMembers.length === 0) return [];
|
|
7443
|
+
ejectPackageMembers(doc, isCustomXml, (target) => target.includes("customXml"));
|
|
6552
7444
|
for (const sdtPr of findAllDescendants(doc.element, "w:sdtPr")) {
|
|
6553
7445
|
findChildren(sdtPr, "w:dataBinding").forEach((b) => sdtPr.removeChild(b));
|
|
6554
7446
|
}
|
|
6555
|
-
return [`Custom XML parts: ${customParts.length} removed`];
|
|
7447
|
+
return [`Custom XML parts: ${Math.max(customParts.length, leakedMembers.length)} removed`];
|
|
7448
|
+
}
|
|
7449
|
+
var CUSTOM_PROPS_PATH = "docProps/custom.xml";
|
|
7450
|
+
function strip_custom_properties(doc) {
|
|
7451
|
+
const part = doc.pkg.getPartByPath(CUSTOM_PROPS_PATH);
|
|
7452
|
+
const leaked = Object.keys(doc.pkg.unzipped).includes(CUSTOM_PROPS_PATH);
|
|
7453
|
+
if (!part && !leaked) return [];
|
|
7454
|
+
const lines = [];
|
|
7455
|
+
if (part) {
|
|
7456
|
+
for (const prop of findDescendantsByLocalName(part._element, "property")) {
|
|
7457
|
+
const name = prop.getAttribute("name") || "(unnamed)";
|
|
7458
|
+
const value = (prop.textContent || "").trim();
|
|
7459
|
+
lines.push(` Custom property removed: ${name} = "${_truncate(value, 60)}"`);
|
|
7460
|
+
}
|
|
7461
|
+
}
|
|
7462
|
+
ejectPackageMembers(
|
|
7463
|
+
doc,
|
|
7464
|
+
(path) => path === CUSTOM_PROPS_PATH,
|
|
7465
|
+
(target) => target.includes("docProps/custom.xml")
|
|
7466
|
+
);
|
|
7467
|
+
const count = Math.max(lines.length, 1);
|
|
7468
|
+
return [`Custom document properties: ${count} removed (docProps/custom.xml)`].concat(lines);
|
|
6556
7469
|
}
|
|
6557
7470
|
function strip_image_alt_text(doc) {
|
|
6558
7471
|
let count = 0;
|
|
@@ -6569,6 +7482,35 @@ function strip_image_alt_text(doc) {
|
|
|
6569
7482
|
}
|
|
6570
7483
|
return count ? [`Image alt text: ${count} auto-generated descriptions removed`] : [];
|
|
6571
7484
|
}
|
|
7485
|
+
function detect_watermarks(doc) {
|
|
7486
|
+
const warnings = [];
|
|
7487
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7488
|
+
for (const part of doc.pkg.parts) {
|
|
7489
|
+
const name = String(part.partname);
|
|
7490
|
+
let location;
|
|
7491
|
+
if (name.startsWith("/word/header")) location = "header";
|
|
7492
|
+
else if (name.startsWith("/word/footer")) location = "footer";
|
|
7493
|
+
else if (name === "/word/document.xml") location = "body";
|
|
7494
|
+
else continue;
|
|
7495
|
+
let textpaths;
|
|
7496
|
+
try {
|
|
7497
|
+
textpaths = findDescendantsByLocalName(part._element, "textpath");
|
|
7498
|
+
} catch {
|
|
7499
|
+
continue;
|
|
7500
|
+
}
|
|
7501
|
+
for (const tp of textpaths) {
|
|
7502
|
+
const text = (tp.getAttribute("string") || "").trim();
|
|
7503
|
+
const key = `${location}${text}`;
|
|
7504
|
+
if (seen.has(key)) continue;
|
|
7505
|
+
seen.add(key);
|
|
7506
|
+
const label = text ? `"${_truncate(text, 60)}"` : "(no text)";
|
|
7507
|
+
warnings.push(
|
|
7508
|
+
`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.`
|
|
7509
|
+
);
|
|
7510
|
+
}
|
|
7511
|
+
}
|
|
7512
|
+
return warnings;
|
|
7513
|
+
}
|
|
6572
7514
|
function audit_hyperlinks(doc) {
|
|
6573
7515
|
const internal = ["sharepoint.com", "onedrive.com", ".internal", "intranet", "localhost", "10.", "192.168.", "172.16."];
|
|
6574
7516
|
const warnings = [];
|
|
@@ -6621,25 +7563,53 @@ function _get_paragraph_text(p) {
|
|
|
6621
7563
|
}
|
|
6622
7564
|
return text;
|
|
6623
7565
|
}
|
|
7566
|
+
var _TERM_BODY = "[A-Z][A-Za-z0-9\\s\\-&'\u2019]{1,60}";
|
|
7567
|
+
var _LEADING_TERM_RE = new RegExp(
|
|
7568
|
+
`^(?:[\\d.\\-()a-zA-Z]+\\s*)?["\u201C](${_TERM_BODY})["\u201D]`,
|
|
7569
|
+
"d"
|
|
7570
|
+
);
|
|
7571
|
+
var _SENTENCE_TERM_RE = new RegExp(
|
|
7572
|
+
`(?<=[.;:!?])\\s+(?:[\\d.\\-()a-zA-Z]+\\s+)?["\u201C](${_TERM_BODY})["\u201D]`,
|
|
7573
|
+
"dg"
|
|
7574
|
+
);
|
|
7575
|
+
var _INLINE_TERM_RE = new RegExp(
|
|
7576
|
+
`\\([^)]*?["\u201C](${_TERM_BODY})["\u201D][^)]*?\\)`,
|
|
7577
|
+
"dg"
|
|
7578
|
+
);
|
|
7579
|
+
function extract_terms_from_paragraph(text) {
|
|
7580
|
+
const found = [];
|
|
7581
|
+
const leading = _LEADING_TERM_RE.exec(text);
|
|
7582
|
+
if (leading && leading.indices && leading.indices[1]) {
|
|
7583
|
+
found.push([leading.indices[1][0], leading[1].trim()]);
|
|
7584
|
+
}
|
|
7585
|
+
for (const m of text.matchAll(_SENTENCE_TERM_RE)) {
|
|
7586
|
+
const indices = m.indices;
|
|
7587
|
+
if (indices && indices[1]) found.push([indices[1][0], m[1].trim()]);
|
|
7588
|
+
}
|
|
7589
|
+
for (const m of text.matchAll(_INLINE_TERM_RE)) {
|
|
7590
|
+
const indices = m.indices;
|
|
7591
|
+
if (indices && indices[1]) found.push([indices[1][0], m[1].trim()]);
|
|
7592
|
+
}
|
|
7593
|
+
const terms = [];
|
|
7594
|
+
const seen_positions = /* @__PURE__ */ new Set();
|
|
7595
|
+
found.sort((a, b) => a[0] - b[0]);
|
|
7596
|
+
for (const [pos, term] of found) {
|
|
7597
|
+
if (seen_positions.has(pos)) continue;
|
|
7598
|
+
seen_positions.add(pos);
|
|
7599
|
+
terms.push(term);
|
|
7600
|
+
}
|
|
7601
|
+
return terms;
|
|
7602
|
+
}
|
|
6624
7603
|
function extract_all_domain_metadata(doc, base_text) {
|
|
6625
7604
|
const definitions = {};
|
|
6626
7605
|
const duplicates = /* @__PURE__ */ new Set();
|
|
6627
7606
|
const raw_anchors = {};
|
|
6628
7607
|
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
7608
|
for (const item of iter_block_items(doc)) {
|
|
6632
7609
|
if (!(item instanceof Paragraph)) continue;
|
|
6633
7610
|
const text = _get_paragraph_text(item).trim();
|
|
6634
7611
|
if (!text) continue;
|
|
6635
|
-
const
|
|
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) {
|
|
7612
|
+
for (const term of extract_terms_from_paragraph(text)) {
|
|
6643
7613
|
if (definitions[term]) duplicates.add(term);
|
|
6644
7614
|
else definitions[term] = { count: 0 };
|
|
6645
7615
|
}
|
|
@@ -6867,27 +7837,32 @@ function build_structural_appendix(doc, base_text) {
|
|
|
6867
7837
|
}
|
|
6868
7838
|
|
|
6869
7839
|
// src/ingest.ts
|
|
6870
|
-
async function extractTextFromBuffer(buffer, cleanView = false) {
|
|
7840
|
+
async function extractTextFromBuffer(buffer, cleanView = false, includeAppendix = true) {
|
|
6871
7841
|
const doc = await DocumentObject.load(buffer);
|
|
6872
|
-
return _extractTextFromDoc(doc, cleanView);
|
|
7842
|
+
return _extractTextFromDoc(doc, cleanView, includeAppendix);
|
|
6873
7843
|
}
|
|
6874
|
-
function _extractTextFromDoc(doc, cleanView = false, includeAppendix = true, return_paragraph_offsets = false) {
|
|
7844
|
+
function _extractTextFromDoc(doc, cleanView = false, includeAppendix = true, return_paragraph_offsets = false, return_structure = false) {
|
|
6875
7845
|
const comments_map = extract_comments_data(doc.pkg);
|
|
6876
7846
|
const full_text = [];
|
|
6877
7847
|
const paragraph_offsets = /* @__PURE__ */ new Map();
|
|
7848
|
+
const structure = return_structure ? { part_ranges: [], tables: [] } : null;
|
|
6878
7849
|
let cursor = 0;
|
|
6879
|
-
for (const part of
|
|
7850
|
+
for (const [part, part_kind] of iter_document_parts_with_kind(doc)) {
|
|
6880
7851
|
const part_cursor = full_text.length > 0 ? cursor + 2 : cursor;
|
|
6881
7852
|
const part_text = _extract_blocks(
|
|
6882
7853
|
part,
|
|
6883
7854
|
comments_map,
|
|
6884
7855
|
cleanView,
|
|
6885
7856
|
part_cursor,
|
|
6886
|
-
return_paragraph_offsets ? paragraph_offsets : void 0
|
|
7857
|
+
return_paragraph_offsets ? paragraph_offsets : void 0,
|
|
7858
|
+
structure ? structure.tables : void 0
|
|
6887
7859
|
);
|
|
6888
7860
|
if (part_text) {
|
|
6889
7861
|
if (full_text.length > 0) cursor += 2;
|
|
6890
7862
|
full_text.push(part_text);
|
|
7863
|
+
if (structure) {
|
|
7864
|
+
structure.part_ranges.push([cursor, cursor + part_text.length, part_kind]);
|
|
7865
|
+
}
|
|
6891
7866
|
cursor += part_text.length;
|
|
6892
7867
|
}
|
|
6893
7868
|
}
|
|
@@ -6899,9 +7874,12 @@ function _extractTextFromDoc(doc, cleanView = false, includeAppendix = true, ret
|
|
|
6899
7874
|
if (return_paragraph_offsets) {
|
|
6900
7875
|
return { text: base_text, paragraph_offsets };
|
|
6901
7876
|
}
|
|
7877
|
+
if (structure) {
|
|
7878
|
+
return { text: base_text, structure };
|
|
7879
|
+
}
|
|
6902
7880
|
return base_text;
|
|
6903
7881
|
}
|
|
6904
|
-
function _extract_blocks(container, comments_map, cleanView, cursor, paragraph_offsets) {
|
|
7882
|
+
function _extract_blocks(container, comments_map, cleanView, cursor, paragraph_offsets, table_acc) {
|
|
6905
7883
|
const part = container.part || container;
|
|
6906
7884
|
const [style_cache, default_pstyle] = _get_style_cache(part);
|
|
6907
7885
|
const blocks = [];
|
|
@@ -6955,17 +7933,23 @@ ${header}`;
|
|
|
6955
7933
|
is_first_para = false;
|
|
6956
7934
|
is_first_block = false;
|
|
6957
7935
|
} else if (item instanceof Table) {
|
|
7936
|
+
const geometry = table_acc ? { start: block_start, end: block_start, rows: [] } : null;
|
|
6958
7937
|
const table_text = extract_table(
|
|
6959
7938
|
item,
|
|
6960
7939
|
comments_map,
|
|
6961
7940
|
cleanView,
|
|
6962
7941
|
block_start,
|
|
6963
|
-
paragraph_offsets
|
|
7942
|
+
paragraph_offsets,
|
|
7943
|
+
geometry
|
|
6964
7944
|
);
|
|
6965
7945
|
if (table_text) {
|
|
6966
7946
|
blocks.push(table_text);
|
|
6967
7947
|
local_cursor = block_start + table_text.length;
|
|
6968
7948
|
is_first_block = false;
|
|
7949
|
+
if (geometry && table_acc) {
|
|
7950
|
+
geometry.end = block_start + table_text.length;
|
|
7951
|
+
table_acc.push(geometry);
|
|
7952
|
+
}
|
|
6969
7953
|
} else if (!is_first_block) {
|
|
6970
7954
|
local_cursor -= 2;
|
|
6971
7955
|
}
|
|
@@ -6974,7 +7958,7 @@ ${header}`;
|
|
|
6974
7958
|
}
|
|
6975
7959
|
return blocks.join("\n\n");
|
|
6976
7960
|
}
|
|
6977
|
-
function extract_table(table, comments_map, cleanView, cursor, paragraph_offsets) {
|
|
7961
|
+
function extract_table(table, comments_map, cleanView, cursor, paragraph_offsets, geometry) {
|
|
6978
7962
|
const rows_text = [];
|
|
6979
7963
|
let rows_processed = 0;
|
|
6980
7964
|
let local_cursor = cursor;
|
|
@@ -7020,6 +8004,13 @@ function extract_table(table, comments_map, cleanView, cursor, paragraph_offsets
|
|
|
7020
8004
|
}
|
|
7021
8005
|
rows_text.push(row_str);
|
|
7022
8006
|
local_cursor = row_start + row_str.length;
|
|
8007
|
+
if (geometry) {
|
|
8008
|
+
geometry.rows.push({
|
|
8009
|
+
start: row_start,
|
|
8010
|
+
end: local_cursor,
|
|
8011
|
+
cells: [...cell_texts]
|
|
8012
|
+
});
|
|
8013
|
+
}
|
|
7023
8014
|
rows_processed++;
|
|
7024
8015
|
}
|
|
7025
8016
|
return rows_text.join("\n");
|
|
@@ -7171,7 +8162,12 @@ function build_paragraph_text(paragraph, comments_map, cleanView, style_cache, d
|
|
|
7171
8162
|
else if (ev.type === "del_end") delete active_del[ev.id];
|
|
7172
8163
|
else if (ev.type === "fmt_start") active_fmt[ev.id] = ev;
|
|
7173
8164
|
else if (ev.type === "fmt_end") delete active_fmt[ev.id];
|
|
7174
|
-
else if (ev.type === "
|
|
8165
|
+
else if (ev.type === "image") {
|
|
8166
|
+
if (!(cleanView && Object.keys(active_del).length > 0)) {
|
|
8167
|
+
const alt = (ev.date || "image").replace(/\]/g, ")").replace(/\n/g, " ");
|
|
8168
|
+
parts.push(``);
|
|
8169
|
+
}
|
|
8170
|
+
} else if (ev.type === "footnote" || ev.type === "endnote") {
|
|
7175
8171
|
if (pending_text) {
|
|
7176
8172
|
parts.push(
|
|
7177
8173
|
`${current_wrappers[0]}${pending_text}${current_wrappers[1]}`
|
|
@@ -7823,6 +8819,9 @@ function _collect_footnote_ids_fast(owned_items) {
|
|
|
7823
8819
|
return ordered;
|
|
7824
8820
|
}
|
|
7825
8821
|
|
|
8822
|
+
// src/sanitize/core.ts
|
|
8823
|
+
import { strFromU8 as strFromU82, unzipSync as unzipSync2 } from "fflate";
|
|
8824
|
+
|
|
7826
8825
|
// src/sanitize/report.ts
|
|
7827
8826
|
var SanitizeReport = class {
|
|
7828
8827
|
filename;
|
|
@@ -7857,7 +8856,7 @@ var SanitizeReport = class {
|
|
|
7857
8856
|
} else {
|
|
7858
8857
|
this.removed_comment_lines.push(line);
|
|
7859
8858
|
}
|
|
7860
|
-
} 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")) {
|
|
8859
|
+
} 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")) {
|
|
7861
8860
|
this.metadata_lines.push(line);
|
|
7862
8861
|
} else if (lower.includes("hyperlink") || lower.includes("warning")) {
|
|
7863
8862
|
this.warnings.push(line);
|
|
@@ -7970,9 +8969,11 @@ async function finalize_document(doc, options) {
|
|
|
7970
8969
|
report.add_transform_lines(scrub_doc_properties(doc));
|
|
7971
8970
|
report.add_transform_lines(scrub_timestamps(doc));
|
|
7972
8971
|
report.add_transform_lines(strip_custom_xml(doc));
|
|
8972
|
+
report.add_transform_lines(strip_custom_properties(doc));
|
|
7973
8973
|
report.add_transform_lines(strip_image_alt_text(doc));
|
|
7974
8974
|
const warnings = audit_hyperlinks(doc);
|
|
7975
8975
|
for (const w of warnings) report.warnings.push(w);
|
|
8976
|
+
for (const w of detect_watermarks(doc)) report.warnings.push(w);
|
|
7976
8977
|
report.add_transform_lines(normalize_change_dates(doc));
|
|
7977
8978
|
if (options.protection_mode === "read_only" || options.protection_mode === "encrypt") {
|
|
7978
8979
|
if (options.protection_mode === "encrypt") {
|
|
@@ -8016,8 +9017,47 @@ async function finalize_document(doc, options) {
|
|
|
8016
9017
|
}
|
|
8017
9018
|
if (report.warnings.length > 0) report.status = "clean_with_warnings";
|
|
8018
9019
|
const outBuffer = await doc.save();
|
|
9020
|
+
verifySanitizedPackage(outBuffer);
|
|
8019
9021
|
return { reportText: report.render(), outBuffer };
|
|
8020
9022
|
}
|
|
9023
|
+
var VERIFIED_CORE_FIELDS = [
|
|
9024
|
+
["creator", "author (dc:creator)"],
|
|
9025
|
+
["lastModifiedBy", "last modified by (cp:lastModifiedBy)"],
|
|
9026
|
+
["identifier", "identifier (dc:identifier)"],
|
|
9027
|
+
["description", "description (dc:description)"],
|
|
9028
|
+
["keywords", "keywords (cp:keywords)"],
|
|
9029
|
+
["category", "category (cp:category)"],
|
|
9030
|
+
["subject", "subject (dc:subject)"],
|
|
9031
|
+
["contentStatus", "content status (cp:contentStatus)"],
|
|
9032
|
+
["language", "language (dc:language)"],
|
|
9033
|
+
["version", "version (cp:version)"]
|
|
9034
|
+
];
|
|
9035
|
+
function verifySanitizedPackage(outBuffer) {
|
|
9036
|
+
const unzipped = unzipSync2(new Uint8Array(outBuffer));
|
|
9037
|
+
const names = Object.keys(unzipped);
|
|
9038
|
+
const problems = [];
|
|
9039
|
+
if (names.includes("docProps/custom.xml")) {
|
|
9040
|
+
problems.push("docProps/custom.xml (custom document properties) is still in the package");
|
|
9041
|
+
}
|
|
9042
|
+
if (names.some((n) => n.startsWith("customXml/"))) {
|
|
9043
|
+
problems.push("customXml/* parts are still in the package");
|
|
9044
|
+
}
|
|
9045
|
+
if (names.includes("docProps/core.xml")) {
|
|
9046
|
+
const core = parseXml(strFromU82(unzipped["docProps/core.xml"]));
|
|
9047
|
+
for (const [local, label] of VERIFIED_CORE_FIELDS) {
|
|
9048
|
+
for (const el of findDescendantsByLocalName(core.documentElement, local)) {
|
|
9049
|
+
if ((el.textContent || "").trim()) {
|
|
9050
|
+
problems.push(`core property ${label} still contains a value`);
|
|
9051
|
+
}
|
|
9052
|
+
}
|
|
9053
|
+
}
|
|
9054
|
+
}
|
|
9055
|
+
if (problems.length > 0) {
|
|
9056
|
+
throw new Error(
|
|
9057
|
+
"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."
|
|
9058
|
+
);
|
|
9059
|
+
}
|
|
9060
|
+
}
|
|
8021
9061
|
|
|
8022
9062
|
// src/index.ts
|
|
8023
9063
|
function identifyEngine() {
|
|
@@ -8039,6 +9079,7 @@ export {
|
|
|
8039
9079
|
extract_outline,
|
|
8040
9080
|
finalize_document,
|
|
8041
9081
|
generate_edits_from_text,
|
|
9082
|
+
generate_structured_edits,
|
|
8042
9083
|
identifyEngine,
|
|
8043
9084
|
paginate,
|
|
8044
9085
|
split_structural_appendix,
|