@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/src/engine.ts
CHANGED
|
@@ -303,6 +303,21 @@ export function validate_edit_strings(
|
|
|
303
303
|
}
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
// QA 2026-07-18 M5: image markers are read-only projections of
|
|
307
|
+
// w:drawing elements. They cannot be fabricated, duplicated or removed
|
|
308
|
+
// through text replacement.
|
|
309
|
+
if (t_text.includes("docx-image:") || n_text.includes("docx-image:")) {
|
|
310
|
+
const t_imgs = (t_text.match(/!\[[^\]]*\]\(docx-image:[^)]*\)/g) || []).sort();
|
|
311
|
+
const n_imgs = (n_text.match(/!\[[^\]]*\]\(docx-image:[^)]*\)/g) || []).sort();
|
|
312
|
+
if (JSON.stringify(t_imgs) !== JSON.stringify(n_imgs)) {
|
|
313
|
+
errors.push(
|
|
314
|
+
`- Edit ${i + 1 + index_offset} Failed: image markers () are read-only ` +
|
|
315
|
+
"projections of embedded images. They cannot be inserted, altered, or removed " +
|
|
316
|
+
"via text replacement — edit the text around the image instead.",
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
306
321
|
if (t_text.includes("{#") || n_text.includes("{#")) {
|
|
307
322
|
const t_anchors = t_text.match(/\{#[^\}]+\}/g) || [];
|
|
308
323
|
const n_anchors = n_text.match(/\{#[^\}]+\}/g) || [];
|
|
@@ -1183,6 +1198,54 @@ export class RedlineEngine {
|
|
|
1183
1198
|
}
|
|
1184
1199
|
}
|
|
1185
1200
|
|
|
1201
|
+
/**
|
|
1202
|
+
* Walks `element` to its XML root element. Word (and LibreOffice, which
|
|
1203
|
+
* refuses to LOAD such files) only supports comment ranges in the main
|
|
1204
|
+
* document story ("w:document") — never in headers, footers, footnotes or
|
|
1205
|
+
* endnotes (QA 2026-07-18 H4/C1).
|
|
1206
|
+
*/
|
|
1207
|
+
private _comment_anchor_in_main_story(element: Element): boolean {
|
|
1208
|
+
let root: Element = element;
|
|
1209
|
+
while (root.parentNode && root.parentNode.nodeType === 1) {
|
|
1210
|
+
root = root.parentNode as Element;
|
|
1211
|
+
}
|
|
1212
|
+
return root.tagName === "w:document";
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* When the anchor lives outside the main document story, records a
|
|
1217
|
+
* user-visible warning and returns true (caller must skip the comment).
|
|
1218
|
+
* The tracked change itself still applies — only the bubble is dropped.
|
|
1219
|
+
*/
|
|
1220
|
+
private _skip_comment_outside_main_story(
|
|
1221
|
+
element: Element,
|
|
1222
|
+
text: string,
|
|
1223
|
+
): boolean {
|
|
1224
|
+
if (this._comment_anchor_in_main_story(element)) return false;
|
|
1225
|
+
let root: Element = element;
|
|
1226
|
+
while (root.parentNode && root.parentNode.nodeType === 1) {
|
|
1227
|
+
root = root.parentNode as Element;
|
|
1228
|
+
}
|
|
1229
|
+
const story =
|
|
1230
|
+
(
|
|
1231
|
+
{
|
|
1232
|
+
"w:ftr": "footer",
|
|
1233
|
+
"w:hdr": "header",
|
|
1234
|
+
"w:footnotes": "footnote",
|
|
1235
|
+
"w:endnotes": "endnote",
|
|
1236
|
+
} as Record<string, string>
|
|
1237
|
+
)[root.tagName] || "non-body";
|
|
1238
|
+
const msg =
|
|
1239
|
+
`- Warning: the comment "${text.substring(0, 60)}" was NOT attached: Word does not support ` +
|
|
1240
|
+
`comments inside a ${story} part, and writing one produces a document other ` +
|
|
1241
|
+
"applications cannot open. The tracked change itself was applied.";
|
|
1242
|
+
this.skipped_details.push(msg);
|
|
1243
|
+
console.error(
|
|
1244
|
+
`Comment anchor outside main story; comment dropped (story=${story})`,
|
|
1245
|
+
);
|
|
1246
|
+
return true;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1186
1249
|
/**
|
|
1187
1250
|
* Attaches a comment that wraps a contiguous range within a single paragraph.
|
|
1188
1251
|
* start_element and end_element must both be direct children of parent_element
|
|
@@ -1196,6 +1259,8 @@ export class RedlineEngine {
|
|
|
1196
1259
|
text: string,
|
|
1197
1260
|
) {
|
|
1198
1261
|
if (!text) return;
|
|
1262
|
+
if (!parent_element || !start_element || !end_element) return;
|
|
1263
|
+
if (this._skip_comment_outside_main_story(parent_element, text)) return;
|
|
1199
1264
|
|
|
1200
1265
|
const comment_id = this.comments_manager.addComment(this.author, text);
|
|
1201
1266
|
const xmlDoc = parent_element.ownerDocument!;
|
|
@@ -1247,6 +1312,12 @@ export class RedlineEngine {
|
|
|
1247
1312
|
text: string,
|
|
1248
1313
|
) {
|
|
1249
1314
|
if (!text) return;
|
|
1315
|
+
if (!start_p || !end_p) return;
|
|
1316
|
+
if (
|
|
1317
|
+
this._skip_comment_outside_main_story(start_p, text) ||
|
|
1318
|
+
this._skip_comment_outside_main_story(end_p, text)
|
|
1319
|
+
)
|
|
1320
|
+
return;
|
|
1250
1321
|
|
|
1251
1322
|
const comment_id = this.comments_manager.addComment(this.author, text);
|
|
1252
1323
|
const xmlDocStart = start_p.ownerDocument!;
|
|
@@ -1323,6 +1394,11 @@ export class RedlineEngine {
|
|
|
1323
1394
|
anchor_run: Run | null,
|
|
1324
1395
|
anchor_paragraph: Paragraph | null,
|
|
1325
1396
|
reuse_id: string,
|
|
1397
|
+
// The attached DOM element the insertion physically follows. anchor_run
|
|
1398
|
+
// supplies STYLING and may already be detached (the deletion step clones
|
|
1399
|
+
// runs into <w:del> and replaces the originals); suffix relocation for
|
|
1400
|
+
// paragraph-splitting insertions keys on this element instead.
|
|
1401
|
+
positional_anchor_el: Element | null = null,
|
|
1326
1402
|
): {
|
|
1327
1403
|
first_node: Element | null;
|
|
1328
1404
|
last_p: Element | null;
|
|
@@ -1353,10 +1429,48 @@ export class RedlineEngine {
|
|
|
1353
1429
|
current_p = walker;
|
|
1354
1430
|
}
|
|
1355
1431
|
|
|
1356
|
-
//
|
|
1357
|
-
//
|
|
1358
|
-
//
|
|
1359
|
-
|
|
1432
|
+
// Suffix nodes: content that follows the anchor inside current_p. When
|
|
1433
|
+
// the inserted text carries paragraph breaks, this content belongs in
|
|
1434
|
+
// the LAST new paragraph. The positional anchor is attached to the
|
|
1435
|
+
// DOM, and the insertion lands immediately after it, so its following
|
|
1436
|
+
// child-of-paragraph siblings are exactly the suffix.
|
|
1437
|
+
const suffix_nodes: Element[] = [];
|
|
1438
|
+
const pos_source =
|
|
1439
|
+
positional_anchor_el && positional_anchor_el.parentNode
|
|
1440
|
+
? positional_anchor_el
|
|
1441
|
+
: anchor_run !== null && anchor_run._element.parentNode
|
|
1442
|
+
? anchor_run._element
|
|
1443
|
+
: null;
|
|
1444
|
+
if (current_p !== null && pos_source !== null) {
|
|
1445
|
+
let pos_anchor: Element | null = pos_source;
|
|
1446
|
+
while (pos_anchor && pos_anchor.parentNode !== current_p) {
|
|
1447
|
+
pos_anchor = pos_anchor.parentNode as Element | null;
|
|
1448
|
+
if (pos_anchor === current_p) {
|
|
1449
|
+
pos_anchor = null;
|
|
1450
|
+
break;
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
if (pos_anchor) {
|
|
1454
|
+
const relocatable = new Set(["w:r", "w:ins", "w:del"]);
|
|
1455
|
+
let nxt = pos_anchor.nextSibling;
|
|
1456
|
+
while (nxt) {
|
|
1457
|
+
if (nxt.nodeType === 1 && relocatable.has((nxt as Element).tagName)) {
|
|
1458
|
+
suffix_nodes.push(nxt as Element);
|
|
1459
|
+
}
|
|
1460
|
+
nxt = nxt.nextSibling;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// Drop the trailing empty line ONLY when there is no suffix to relocate.
|
|
1466
|
+
// "foo\n\nbar\n\n" splits to ['foo', '', 'bar', '']; without a suffix
|
|
1467
|
+
// the trailing empty is just a terminator, but with one it is the fresh
|
|
1468
|
+
// destination paragraph the suffix moves into.
|
|
1469
|
+
while (
|
|
1470
|
+
lines.length > 1 &&
|
|
1471
|
+
lines[lines.length - 1] === "" &&
|
|
1472
|
+
suffix_nodes.length === 0
|
|
1473
|
+
) {
|
|
1360
1474
|
lines.pop();
|
|
1361
1475
|
}
|
|
1362
1476
|
if (lines.length === 0) {
|
|
@@ -1484,6 +1598,16 @@ export class RedlineEngine {
|
|
|
1484
1598
|
}
|
|
1485
1599
|
}
|
|
1486
1600
|
|
|
1601
|
+
// Relocate the suffix into the last new paragraph: the paragraph break
|
|
1602
|
+
// the insertion introduced splits current_p at the anchor, so everything
|
|
1603
|
+
// after the anchor continues in the final inserted paragraph.
|
|
1604
|
+
if (!block_mode && last_p && suffix_nodes.length > 0) {
|
|
1605
|
+
for (const node of suffix_nodes) {
|
|
1606
|
+
node.parentNode?.removeChild(node);
|
|
1607
|
+
last_p.appendChild(node);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1487
1611
|
return { first_node, last_p, last_ins, used_block_mode: block_mode };
|
|
1488
1612
|
}
|
|
1489
1613
|
|
|
@@ -1557,7 +1681,13 @@ export class RedlineEngine {
|
|
|
1557
1681
|
return [stripped_text.substring(2).trim(), "List Paragraph"];
|
|
1558
1682
|
}
|
|
1559
1683
|
|
|
1560
|
-
|
|
1684
|
+
// Numbered lists: the projection emits ordered items with a CONSTANT
|
|
1685
|
+
// "1. " marker (Markdown renumbers), so only that exact shape converts
|
|
1686
|
+
// back into a list style. Any other leading number ("2024. Year in
|
|
1687
|
+
// review", "3. Clause text") is literal document text. Continuation
|
|
1688
|
+
// items inside an existing list anchor keep full "\d+." handling via
|
|
1689
|
+
// the list-anchored insertion path.
|
|
1690
|
+
const match = stripped_text.match(/^1\.\s+/);
|
|
1561
1691
|
if (match) {
|
|
1562
1692
|
return [stripped_text.substring(match[0].length).trim(), "List Number"];
|
|
1563
1693
|
}
|
|
@@ -2030,6 +2160,99 @@ export class RedlineEngine {
|
|
|
2030
2160
|
pfx,
|
|
2031
2161
|
(edit.new_text || "").length - sfx,
|
|
2032
2162
|
);
|
|
2163
|
+
|
|
2164
|
+
// QA 2026-07-18 C1: the projection flattens headers, body, footers
|
|
2165
|
+
// and notes into one string, but a text edit whose matched span
|
|
2166
|
+
// covers real text from two different OPC parts cannot be applied
|
|
2167
|
+
// without putting content in the wrong part — including the
|
|
2168
|
+
// insertion shape, whose anchor point at the part gap is inherently
|
|
2169
|
+
// ambiguous. Refuse the RAW match range outright and ask for a
|
|
2170
|
+
// single-part anchor. (Single-part documents skip the scan.)
|
|
2171
|
+
const multi_part_doc =
|
|
2172
|
+
target_mapper.part_ranges.filter((r) => r[1] > r[0]).length > 1;
|
|
2173
|
+
const raw_span_parts = multi_part_doc
|
|
2174
|
+
? Array.from(
|
|
2175
|
+
new Set(
|
|
2176
|
+
target_mapper.spans
|
|
2177
|
+
.filter(
|
|
2178
|
+
(s) =>
|
|
2179
|
+
s.run !== null &&
|
|
2180
|
+
s.end > m_start &&
|
|
2181
|
+
s.start < m_start + m_len,
|
|
2182
|
+
)
|
|
2183
|
+
.map((s) => s.part_index),
|
|
2184
|
+
),
|
|
2185
|
+
).sort((a, b) => a - b)
|
|
2186
|
+
: [];
|
|
2187
|
+
if (raw_span_parts.length > 1) {
|
|
2188
|
+
const kinds = raw_span_parts
|
|
2189
|
+
.map((pi) => target_mapper.part_kind_of(pi) || "?")
|
|
2190
|
+
.join(" → ");
|
|
2191
|
+
errors.push(
|
|
2192
|
+
`- Edit ${i + 1 + index_offset} Failed: target_text spans a structural document-part ` +
|
|
2193
|
+
`boundary (${kinds}). Headers, body, footers and footnotes are separate ` +
|
|
2194
|
+
"Word parts — an edit cannot cross between them. Anchor the edit on text " +
|
|
2195
|
+
"within a single part (split it into one edit per part if both sides " +
|
|
2196
|
+
"must change).",
|
|
2197
|
+
);
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// QA 2026-07-18 M5: image markers are read-only projections. Only
|
|
2201
|
+
// the CHANGED span matters — markers sitting untouched in the
|
|
2202
|
+
// shared context are fine.
|
|
2203
|
+
const eff_start = m_start + pfx;
|
|
2204
|
+
const eff_end = m_start + m_len - sfx;
|
|
2205
|
+
if (eff_end > eff_start) {
|
|
2206
|
+
const overlapping = target_mapper.spans.filter(
|
|
2207
|
+
(s) =>
|
|
2208
|
+
s.end > eff_start &&
|
|
2209
|
+
s.start < eff_end &&
|
|
2210
|
+
(s.run !== null || s.text.trim() !== ""),
|
|
2211
|
+
);
|
|
2212
|
+
if (overlapping.some((s) => (s as any).is_image_marker)) {
|
|
2213
|
+
errors.push(
|
|
2214
|
+
`- Edit ${i + 1 + index_offset} Failed: the target overlaps a read-only image marker ` +
|
|
2215
|
+
"(). Images cannot be edited or removed via text " +
|
|
2216
|
+
"replacement — target the text around the image instead.",
|
|
2217
|
+
);
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
// QA 2026-07-18 H4: comments can only be anchored in the main
|
|
2222
|
+
// document story. A comment-only edit (target == new) whose match
|
|
2223
|
+
// lives in a header/footer/footnote has no effect Word or
|
|
2224
|
+
// LibreOffice could render — refuse it clearly.
|
|
2225
|
+
if (edit.comment && (edit.new_text || "") === (edit.target_text || "")) {
|
|
2226
|
+
const kind_here = target_mapper.part_kind_at(m_start);
|
|
2227
|
+
if (kind_here !== null && kind_here !== "body") {
|
|
2228
|
+
errors.push(
|
|
2229
|
+
`- Edit ${i + 1 + index_offset} Failed: comments cannot be anchored inside a ${kind_here} ` +
|
|
2230
|
+
"part — Word only supports comments in the main document body. Comment on " +
|
|
2231
|
+
"the related body text instead.",
|
|
2232
|
+
);
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
// QA 2026-07-18 C2: a replacement may not smuggle new pipe-delimited
|
|
2237
|
+
// row lines into a table cell. Rows are structural; adding one
|
|
2238
|
+
// requires the insert_row operation.
|
|
2239
|
+
if (
|
|
2240
|
+
RedlineEngine._introduces_table_row_text(
|
|
2241
|
+
target_mapper,
|
|
2242
|
+
m_start,
|
|
2243
|
+
m_len,
|
|
2244
|
+
final_target,
|
|
2245
|
+
final_new,
|
|
2246
|
+
)
|
|
2247
|
+
) {
|
|
2248
|
+
errors.push(
|
|
2249
|
+
`- Edit ${i + 1 + index_offset} Failed: new_text introduces a pipe-delimited row line inside ` +
|
|
2250
|
+
"a table. Text replacement cannot create table rows — use the structured " +
|
|
2251
|
+
`'insert_row' operation instead (e.g. {"type": "insert_row", ` +
|
|
2252
|
+
`"target_text": "<anchor row text>", "cells": ["...", "..."]}).`,
|
|
2253
|
+
);
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2033
2256
|
if (final_target.includes("\n\n")) {
|
|
2034
2257
|
// A *balanced* multi-paragraph modification (target and replacement
|
|
2035
2258
|
// carry the same number of paragraph breaks) is safe: it is split
|
|
@@ -2182,6 +2405,33 @@ export class RedlineEngine {
|
|
|
2182
2405
|
* [start, start+length) in `mapper`, or null if that text is not inside a
|
|
2183
2406
|
* table row.
|
|
2184
2407
|
*/
|
|
2408
|
+
/**
|
|
2409
|
+
* True when a replacement anchored in a table would ADD line-separated
|
|
2410
|
+
* pipe-delimited content — the text shape of a table row. Writing that
|
|
2411
|
+
* into a cell renders a fake row inside one cell while the real grid
|
|
2412
|
+
* stays unchanged (QA 2026-07-18 C2); such edits must use insert_row.
|
|
2413
|
+
*/
|
|
2414
|
+
private static _introduces_table_row_text(
|
|
2415
|
+
mapper: DocumentMapper,
|
|
2416
|
+
start: number,
|
|
2417
|
+
length: number,
|
|
2418
|
+
final_target: string,
|
|
2419
|
+
final_new: string,
|
|
2420
|
+
): boolean {
|
|
2421
|
+
if (!final_new.includes("\n") || !final_new.includes(" | ")) return false;
|
|
2422
|
+
const new_pipe_lines = final_new
|
|
2423
|
+
.split("\n")
|
|
2424
|
+
.filter((line) => line.includes(" | ")).length;
|
|
2425
|
+
const old_pipe_lines = final_target
|
|
2426
|
+
.split("\n")
|
|
2427
|
+
.filter((line) => line.includes(" | ")).length;
|
|
2428
|
+
if (new_pipe_lines <= old_pipe_lines) return false;
|
|
2429
|
+
return (
|
|
2430
|
+
RedlineEngine._column_count_at(mapper, start, Math.max(length, 1)) !==
|
|
2431
|
+
null
|
|
2432
|
+
);
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2185
2435
|
private static _column_count_at(
|
|
2186
2436
|
mapper: DocumentMapper,
|
|
2187
2437
|
start: number,
|
|
@@ -2650,15 +2900,22 @@ export class RedlineEngine {
|
|
|
2650
2900
|
resolved_edits.push([edit, edit.new_text || null]);
|
|
2651
2901
|
} else if (edit.type === "insert_row" || edit.type === "delete_row") {
|
|
2652
2902
|
let matches = this.mapper.find_all_match_indices(edit.target_text);
|
|
2903
|
+
let resolved_mapper = this.mapper;
|
|
2653
2904
|
if (matches.length === 0) {
|
|
2654
2905
|
if (!this.clean_mapper) {
|
|
2655
2906
|
this.clean_mapper = new DocumentMapper(this.doc, true);
|
|
2656
2907
|
}
|
|
2657
2908
|
matches = this.clean_mapper.find_all_match_indices(edit.target_text);
|
|
2909
|
+
resolved_mapper = this.clean_mapper;
|
|
2658
2910
|
}
|
|
2659
2911
|
|
|
2660
2912
|
if (matches.length > 0) {
|
|
2913
|
+
// Record WHICH mapper produced the offset: a clean-view index
|
|
2914
|
+
// resolved against the raw mapper lands rows at the wrong position
|
|
2915
|
+
// once earlier edits in the batch put tracked changes in the
|
|
2916
|
+
// anchor row.
|
|
2661
2917
|
edit._resolved_start_idx = matches[0][0];
|
|
2918
|
+
edit._active_mapper_ref = resolved_mapper;
|
|
2662
2919
|
resolved_edits.push([edit, null]);
|
|
2663
2920
|
} else {
|
|
2664
2921
|
skipped++;
|
|
@@ -2744,7 +3001,13 @@ export class RedlineEngine {
|
|
|
2744
3001
|
|
|
2745
3002
|
for (const [edit, orig_new] of resolved_edits) {
|
|
2746
3003
|
const start = edit._resolved_start_idx || 0;
|
|
2747
|
-
|
|
3004
|
+
// An insert_row does not consume its anchor text — it adds an adjacent
|
|
3005
|
+
// row. Give it a zero-width range so several inserts sharing one
|
|
3006
|
+
// anchor (consecutive new rows) never flag each other as overlapping.
|
|
3007
|
+
const end =
|
|
3008
|
+
edit.type === "insert_row"
|
|
3009
|
+
? start
|
|
3010
|
+
: start + (edit.target_text ? edit.target_text.length : 0);
|
|
2748
3011
|
|
|
2749
3012
|
const overlaps = occupied_ranges.some(
|
|
2750
3013
|
([occ_start, occ_end]) => start < occ_end && end > occ_start,
|
|
@@ -2963,7 +3226,11 @@ export class RedlineEngine {
|
|
|
2963
3226
|
edit._resolved_start_idx !== null
|
|
2964
3227
|
? edit._resolved_start_idx
|
|
2965
3228
|
: edit._match_start_index || 0;
|
|
2966
|
-
|
|
3229
|
+
// The offset must be looked up in the coordinate space it was resolved
|
|
3230
|
+
// in: a clean-view offset applied to the raw
|
|
3231
|
+
// mapper points at earlier text once tracked changes exist.
|
|
3232
|
+
const active_mapper: DocumentMapper = edit._active_mapper_ref || this.mapper;
|
|
3233
|
+
const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
|
|
2967
3234
|
start_idx,
|
|
2968
3235
|
rebuild_map,
|
|
2969
3236
|
);
|
|
@@ -3199,9 +3466,22 @@ export class RedlineEngine {
|
|
|
3199
3466
|
const actual_cells = actual_doc_text.split("|");
|
|
3200
3467
|
const new_cells = current_effective_new_text.split("|");
|
|
3201
3468
|
|
|
3202
|
-
if (actual_cells.length
|
|
3469
|
+
if (actual_cells.length !== new_cells.length) {
|
|
3470
|
+
throw new BatchValidationError([
|
|
3471
|
+
`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).`
|
|
3472
|
+
]);
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
if (actual_cells.length > 1) {
|
|
3203
3476
|
const sub_edits: any[] = [];
|
|
3204
|
-
|
|
3477
|
+
|
|
3478
|
+
// actual_doc_text IS the document slice at
|
|
3479
|
+
// [start_idx, start_idx + len): per-cell offsets are exact
|
|
3480
|
+
// arithmetic over that slice — never a search of mapper.full_text,
|
|
3481
|
+
// which cannot distinguish repeated cell text and lands in the
|
|
3482
|
+
// wrong cell when the matched range starts inside a " | "
|
|
3483
|
+
// separator.
|
|
3484
|
+
let cell_start_in_target = 0;
|
|
3205
3485
|
|
|
3206
3486
|
// Determine which cell receives the comment
|
|
3207
3487
|
let target_comment_idx = 0;
|
|
@@ -3217,14 +3497,10 @@ export class RedlineEngine {
|
|
|
3217
3497
|
const n_cell = new_cells[cell_idx];
|
|
3218
3498
|
const a_clean = a_cell.trim();
|
|
3219
3499
|
const n_clean = n_cell.trim();
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
if (actual_start === -1 || actual_start > search_offset + 10) {
|
|
3225
|
-
actual_start = search_offset;
|
|
3226
|
-
}
|
|
3227
|
-
}
|
|
3500
|
+
const actual_start =
|
|
3501
|
+
start_idx +
|
|
3502
|
+
cell_start_in_target +
|
|
3503
|
+
(a_clean ? a_cell.indexOf(a_clean) : 0);
|
|
3228
3504
|
|
|
3229
3505
|
const should_attach_comment = (edit.comment !== null && edit.comment !== undefined) && (cell_idx === target_comment_idx);
|
|
3230
3506
|
|
|
@@ -3244,27 +3520,18 @@ export class RedlineEngine {
|
|
|
3244
3520
|
}
|
|
3245
3521
|
}
|
|
3246
3522
|
|
|
3247
|
-
|
|
3248
|
-
search_offset = actual_start + a_clean.length;
|
|
3249
|
-
}
|
|
3250
|
-
|
|
3251
|
-
const next_pipe = this.mapper.full_text.indexOf(" | ", search_offset);
|
|
3252
|
-
if (next_pipe !== -1 && next_pipe <= search_offset + 10) {
|
|
3253
|
-
search_offset = next_pipe + 3;
|
|
3254
|
-
} else {
|
|
3255
|
-
search_offset += a_cell.length + 1;
|
|
3256
|
-
}
|
|
3523
|
+
cell_start_in_target += a_cell.length + 1; // +1 for the '|'
|
|
3257
3524
|
}
|
|
3258
3525
|
|
|
3259
3526
|
for (const sub of sub_edits) {
|
|
3260
3527
|
all_sub_edits.push(sub);
|
|
3261
3528
|
}
|
|
3262
3529
|
continue;
|
|
3263
|
-
} else {
|
|
3264
|
-
throw new BatchValidationError([
|
|
3265
|
-
`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).`
|
|
3266
|
-
]);
|
|
3267
3530
|
}
|
|
3531
|
+
// Exactly one "cell": the target merely brushes a separator (its
|
|
3532
|
+
// match range starts or ends inside " | ") without crossing into
|
|
3533
|
+
// another cell's text. That is an ordinary in-cell edit — fall
|
|
3534
|
+
// through to the standard resolution.
|
|
3268
3535
|
}
|
|
3269
3536
|
|
|
3270
3537
|
let has_markdown = false;
|
|
@@ -3378,6 +3645,26 @@ export class RedlineEngine {
|
|
|
3378
3645
|
}
|
|
3379
3646
|
}
|
|
3380
3647
|
|
|
3648
|
+
// After trimming shared context, an edit whose target remainder is
|
|
3649
|
+
// EMPTY is a pure insertion with exactly one hunk. Resolve it
|
|
3650
|
+
// directly at the effective offset instead of word-diffing the full
|
|
3651
|
+
// strings: dmp's alignment can cross-match punctuation between the
|
|
3652
|
+
// shared context and the inserted text (pairing the period of "two."
|
|
3653
|
+
// with "marker.") and split the insertion apart.
|
|
3654
|
+
if (!final_target && final_new) {
|
|
3655
|
+
all_sub_edits.push({
|
|
3656
|
+
type: "modify",
|
|
3657
|
+
target_text: "",
|
|
3658
|
+
new_text: final_new,
|
|
3659
|
+
comment: edit.comment,
|
|
3660
|
+
_resolved_start_idx: effective_start_idx,
|
|
3661
|
+
_match_start_index: effective_start_idx,
|
|
3662
|
+
_internal_op: "INSERTION",
|
|
3663
|
+
_active_mapper_ref: active_mapper,
|
|
3664
|
+
});
|
|
3665
|
+
continue;
|
|
3666
|
+
}
|
|
3667
|
+
|
|
3381
3668
|
const sub_edits = this._word_diff_sub_edits(
|
|
3382
3669
|
actual_doc_text,
|
|
3383
3670
|
current_effective_new_text,
|
|
@@ -3605,20 +3892,80 @@ export class RedlineEngine {
|
|
|
3605
3892
|
return true;
|
|
3606
3893
|
}
|
|
3607
3894
|
if (op === "INSERTION") {
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
)
|
|
3895
|
+
let final_new_text = edit.new_text || "";
|
|
3896
|
+
|
|
3897
|
+
// A MACHINE-PINNED pure insertion (diff/text round-trip output:
|
|
3898
|
+
// authored with an empty target and no parent edit) positioned in the
|
|
3899
|
+
// separator gap between the body and a following part anchors to the
|
|
3900
|
+
// end of the BODY with forced new-paragraph semantics — anchoring on
|
|
3901
|
+
// the next part's first paragraph writes the new final body paragraph
|
|
3902
|
+
// into word/footer1.xml. Insertions DERIVED from a target-anchored
|
|
3903
|
+
// edit (parent ref set — e.g. prepending "DRAFT " to "FOOTER MARKER")
|
|
3904
|
+
// keep the user's chosen anchor: their context names the part they
|
|
3905
|
+
// meant.
|
|
3906
|
+
let boundary_anchor: TextSpan | null = null;
|
|
3907
|
+
const boundary =
|
|
3908
|
+
typeof (active_mapper as any).part_boundary_at === "function"
|
|
3909
|
+
? active_mapper.part_boundary_at(start_idx)
|
|
3910
|
+
: null;
|
|
3911
|
+
const is_machine_pure_insertion =
|
|
3912
|
+
!edit.target_text &&
|
|
3913
|
+
(edit._parent_edit_ref === undefined || edit._parent_edit_ref === null);
|
|
3914
|
+
if (boundary !== null && is_machine_pure_insertion) {
|
|
3915
|
+
const [prev_i, next_i] = boundary;
|
|
3916
|
+
const prev_kind = active_mapper.part_kind_of(prev_i);
|
|
3917
|
+
const next_kind = active_mapper.part_kind_of(next_i);
|
|
3918
|
+
if (prev_kind === "body" && next_kind !== "body") {
|
|
3919
|
+
const real_before = active_mapper.spans.filter(
|
|
3920
|
+
(s: TextSpan) => s.run !== null && s.part_index === prev_i,
|
|
3921
|
+
);
|
|
3922
|
+
if (real_before.length > 0) {
|
|
3923
|
+
boundary_anchor = real_before[real_before.length - 1];
|
|
3924
|
+
}
|
|
3925
|
+
}
|
|
3926
|
+
}
|
|
3927
|
+
|
|
3928
|
+
let anchor_run: Run | null;
|
|
3929
|
+
let anchor_para: Paragraph | null;
|
|
3930
|
+
if (boundary_anchor !== null) {
|
|
3931
|
+
anchor_run = boundary_anchor.run;
|
|
3932
|
+
anchor_para = boundary_anchor.paragraph;
|
|
3933
|
+
if (!final_new_text.startsWith("\n")) {
|
|
3934
|
+
final_new_text = "\n\n" + final_new_text;
|
|
3935
|
+
}
|
|
3936
|
+
} else {
|
|
3937
|
+
[anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
|
|
3938
|
+
start_idx,
|
|
3939
|
+
rebuild_map,
|
|
3940
|
+
);
|
|
3941
|
+
}
|
|
3612
3942
|
if (!anchor_run && !anchor_para) return false;
|
|
3613
3943
|
|
|
3944
|
+
// QA 2026-07-18 C2 (apply-level backstop, pinned edits bypass
|
|
3945
|
+
// validate_edits): refuse insertions that would write row-shaped pipe
|
|
3946
|
+
// text into a table cell.
|
|
3947
|
+
if (
|
|
3948
|
+
RedlineEngine._introduces_table_row_text(
|
|
3949
|
+
active_mapper,
|
|
3950
|
+
start_idx,
|
|
3951
|
+
1,
|
|
3952
|
+
"",
|
|
3953
|
+
final_new_text,
|
|
3954
|
+
)
|
|
3955
|
+
) {
|
|
3956
|
+
return false;
|
|
3957
|
+
}
|
|
3958
|
+
|
|
3614
3959
|
// BUG-23-3: a prefix insertion whose new_text ends in a paragraph break
|
|
3615
3960
|
// (e.g. "Summary\n\n" inserted before "Conclusion") must become a NEW
|
|
3616
3961
|
// paragraph placed BEFORE the anchor paragraph, not inline text merged
|
|
3617
3962
|
// into a neighbouring paragraph. _track_insert_multiline drops the
|
|
3618
3963
|
// trailing break and inlines the remainder, which both loses the
|
|
3619
3964
|
// paragraph boundary and mis-orders the content. Handle this case here.
|
|
3620
|
-
|
|
3621
|
-
const
|
|
3965
|
+
// (Skipped when the C1 boundary re-anchor above took over the anchor.)
|
|
3966
|
+
const _bug233_new = final_new_text;
|
|
3967
|
+
const _bug233_trailing_break =
|
|
3968
|
+
boundary_anchor === null && /\n\s*$/.test(_bug233_new);
|
|
3622
3969
|
let _bug233_target_para: Element | null = null;
|
|
3623
3970
|
{
|
|
3624
3971
|
const startingSpans = active_mapper.spans.filter(
|
|
@@ -3696,7 +4043,7 @@ export class RedlineEngine {
|
|
|
3696
4043
|
}
|
|
3697
4044
|
|
|
3698
4045
|
const result = this._track_insert_multiline(
|
|
3699
|
-
|
|
4046
|
+
final_new_text,
|
|
3700
4047
|
anchor_run,
|
|
3701
4048
|
anchor_para,
|
|
3702
4049
|
ins_id!,
|
|
@@ -3823,6 +4170,29 @@ export class RedlineEngine {
|
|
|
3823
4170
|
return true;
|
|
3824
4171
|
}
|
|
3825
4172
|
|
|
4173
|
+
// QA 2026-07-18 C1 (apply-level backstop, pinned edits bypass
|
|
4174
|
+
// validate_edits): a modification/deletion may never mutate real text
|
|
4175
|
+
// from two different OPC parts in one span. Single-part documents skip
|
|
4176
|
+
// the scan.
|
|
4177
|
+
if (
|
|
4178
|
+
(op === "DELETION" || op === "MODIFICATION") &&
|
|
4179
|
+
length &&
|
|
4180
|
+
active_mapper.part_ranges.filter((r: [number, number, string]) => r[1] > r[0]).length > 1
|
|
4181
|
+
) {
|
|
4182
|
+
const crossed_parts = new Set<number>();
|
|
4183
|
+
for (const s of active_mapper.spans) {
|
|
4184
|
+
if (s.run !== null && s.end > start_idx && s.start < start_idx + length) {
|
|
4185
|
+
crossed_parts.add(s.part_index);
|
|
4186
|
+
}
|
|
4187
|
+
}
|
|
4188
|
+
if (crossed_parts.size > 1) {
|
|
4189
|
+
console.error(
|
|
4190
|
+
`Refusing edit that spans OPC part boundary (start=${start_idx}, parts=${Array.from(crossed_parts).sort().join(",")})`,
|
|
4191
|
+
);
|
|
4192
|
+
return false;
|
|
4193
|
+
}
|
|
4194
|
+
}
|
|
4195
|
+
|
|
3826
4196
|
// DELETION / MODIFICATION
|
|
3827
4197
|
const target_runs = active_mapper.find_target_runs_by_index(
|
|
3828
4198
|
start_idx,
|
|
@@ -3888,6 +4258,9 @@ export class RedlineEngine {
|
|
|
3888
4258
|
style_source_run,
|
|
3889
4259
|
mod_anchor_para,
|
|
3890
4260
|
ins_id!,
|
|
4261
|
+
// The insertion physically follows the deletion block; the style
|
|
4262
|
+
// run was detached when the deletion cloned it into <w:del>.
|
|
4263
|
+
last_del,
|
|
3891
4264
|
);
|
|
3892
4265
|
|
|
3893
4266
|
if (result.first_node) {
|
package/src/index.ts
CHANGED
|
@@ -5,10 +5,10 @@ export function identifyEngine() {
|
|
|
5
5
|
export { DocumentObject } from './docx/bridge.js';
|
|
6
6
|
export { DocumentMapper, TextSpan } from './mapper.js';
|
|
7
7
|
export { RedlineEngine, BatchValidationError, validate_edit_strings, describe_illegal_control_chars } from './engine.js';
|
|
8
|
-
export { generate_edits_from_text, trim_common_context, create_unified_diff, create_word_patch_diff } from './diff.js';
|
|
9
|
-
export { apply_edits_to_markdown } from './markup.js';
|
|
8
|
+
export { generate_edits_from_text, generate_structured_edits, trim_common_context, create_unified_diff, create_word_patch_diff, DiffEdit } from './diff.js';
|
|
9
|
+
export { apply_edits_to_markdown, MarkupEditReport } from './markup.js';
|
|
10
10
|
export { paginate, split_structural_appendix, PaginationResult, PageInfo } from './pagination.js';
|
|
11
11
|
export { extract_outline, OutlineNode } from './outline.js';
|
|
12
|
-
export { extractTextFromBuffer, _extractTextFromDoc } from './ingest.js';
|
|
12
|
+
export { extractTextFromBuffer, _extractTextFromDoc, ExtractStructure, TableGeometry, RowGeometry } from './ingest.js';
|
|
13
13
|
export { finalize_document, FinalizeOptions, FinalizeResult } from './sanitize/core.js';
|
|
14
14
|
export { RegexTimeoutError, userFindAllMatches, userSearch, USER_PATTERN_TIMEOUT_MS } from './utils/safe-regex.js';
|