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