@adeu/core 1.21.0 → 1.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1150 -89
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +156 -12
- package/dist/index.d.ts +156 -12
- package/dist/index.js +1142 -88
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/diff.ts +500 -1
- package/src/docx/bridge.ts +13 -0
- package/src/domain.ts +68 -15
- package/src/engine.ts +391 -28
- package/src/index.ts +6 -5
- package/src/ingest.ts +77 -5
- package/src/mapper.ts +101 -12
- package/src/markup.test.ts +2 -1
- package/src/markup.ts +214 -20
- package/src/repro_qa_report_2026_07_18.test.ts +1244 -0
- package/src/repro_qa_report_v5.test.ts +399 -0
- package/src/sanitize/core.ts +3 -0
- package/src/sanitize/transforms.ts +66 -2
- package/src/utils/docx.ts +257 -16
- package/src/utils/safe-regex.ts +90 -0
package/src/engine.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
REPORT_ECHO_CAP,
|
|
28
28
|
truncate_middle,
|
|
29
29
|
} from "./utils/text.js";
|
|
30
|
+
import { RegexTimeoutError } from "./utils/safe-regex.js";
|
|
30
31
|
|
|
31
32
|
// Width of the surrounding-document window shown in redline previews.
|
|
32
33
|
const PREVIEW_CONTEXT_CHARS = 30;
|
|
@@ -178,6 +179,20 @@ const TRANSACTIONAL_NOT_APPLIED_ERROR =
|
|
|
178
179
|
"Not applied: the batch is transactional and other edits failed " +
|
|
179
180
|
"validation (see their errors). Fix or remove those edits and re-run.";
|
|
180
181
|
|
|
182
|
+
// Characters XML 1.0 cannot represent: C0 controls except tab/newline/CR.
|
|
183
|
+
// Word refuses to open a package carrying them, and @xmldom serializes them
|
|
184
|
+
// silently, so they must be rejected before they reach the DOM
|
|
185
|
+
// (QA 2026-07-17 F11; mirrors Python's clean per-edit error).
|
|
186
|
+
const XML_ILLEGAL_CHARS_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f]/g;
|
|
187
|
+
|
|
188
|
+
export function describe_illegal_control_chars(text: string): string | null {
|
|
189
|
+
if (!text) return null;
|
|
190
|
+
const found = text.match(XML_ILLEGAL_CHARS_RE);
|
|
191
|
+
if (!found) return null;
|
|
192
|
+
const codes = Array.from(new Set(found.map((c) => `0x${c.charCodeAt(0).toString(16).padStart(2, "0")}`))).sort();
|
|
193
|
+
return codes.join(", ");
|
|
194
|
+
}
|
|
195
|
+
|
|
181
196
|
export function validate_edit_strings(
|
|
182
197
|
edits: any[],
|
|
183
198
|
index_offset: number = 0,
|
|
@@ -189,6 +204,25 @@ export function validate_edit_strings(
|
|
|
189
204
|
const t_text = edit.target_text || "";
|
|
190
205
|
const n_text = edit.new_text || "";
|
|
191
206
|
|
|
207
|
+
// VAL-CRIT-8: XML-illegal control characters (QA 2026-07-17 F11).
|
|
208
|
+
const checked_fields: Array<[string, string]> = [
|
|
209
|
+
["target_text", t_text],
|
|
210
|
+
["new_text", n_text],
|
|
211
|
+
];
|
|
212
|
+
if (edit.comment) checked_fields.push(["comment", edit.comment]);
|
|
213
|
+
(edit.cells || []).forEach((cell: string, cell_idx: number) => {
|
|
214
|
+
checked_fields.push([`cells[${cell_idx}]`, cell || ""]);
|
|
215
|
+
});
|
|
216
|
+
for (const [field_name, field_value] of checked_fields) {
|
|
217
|
+
const described = describe_illegal_control_chars(field_value);
|
|
218
|
+
if (described) {
|
|
219
|
+
errors.push(
|
|
220
|
+
`- Edit ${i + 1 + index_offset} Failed: \`${field_name}\` contains control character(s) ` +
|
|
221
|
+
`(${described}) that cannot be stored in a DOCX. Remove them and re-submit.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
192
226
|
if (
|
|
193
227
|
n_text.includes("{++") ||
|
|
194
228
|
n_text.includes("{--") ||
|
|
@@ -269,6 +303,21 @@ export function validate_edit_strings(
|
|
|
269
303
|
}
|
|
270
304
|
}
|
|
271
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
|
+
|
|
272
321
|
if (t_text.includes("{#") || n_text.includes("{#")) {
|
|
273
322
|
const t_anchors = t_text.match(/\{#[^\}]+\}/g) || [];
|
|
274
323
|
const n_anchors = n_text.match(/\{#[^\}]+\}/g) || [];
|
|
@@ -571,8 +620,8 @@ export class RedlineEngine {
|
|
|
571
620
|
* Snapshots the document text around a resolved edit BEFORE anything is
|
|
572
621
|
* applied. Previews rendered after the batch mutates the DOM cannot slice
|
|
573
622
|
* full_text at the stored offsets: applied edits shift offsets and inject
|
|
574
|
-
* tracked-change markup,
|
|
575
|
-
*
|
|
623
|
+
* tracked-change markup, garbling previews with unrelated edits and
|
|
624
|
+
* internal scaffolding (QA H1).
|
|
576
625
|
*/
|
|
577
626
|
private _capture_preview_context(edit: any): void {
|
|
578
627
|
if (edit.type !== "modify") return;
|
|
@@ -690,8 +739,8 @@ export class RedlineEngine {
|
|
|
690
739
|
|
|
691
740
|
/**
|
|
692
741
|
* The "new text" a batch report should show for an edit. InsertTableRow has
|
|
693
|
-
* no new_text field — surface its cell contents
|
|
694
|
-
* empty string
|
|
742
|
+
* no new_text field — surface its cell contents rather than a misleading
|
|
743
|
+
* empty string (QA M4).
|
|
695
744
|
*/
|
|
696
745
|
private static _report_new_text(edit: any): string {
|
|
697
746
|
if (edit && edit.type === "insert_row" && Array.isArray(edit.cells)) {
|
|
@@ -1149,6 +1198,54 @@ export class RedlineEngine {
|
|
|
1149
1198
|
}
|
|
1150
1199
|
}
|
|
1151
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
|
+
|
|
1152
1249
|
/**
|
|
1153
1250
|
* Attaches a comment that wraps a contiguous range within a single paragraph.
|
|
1154
1251
|
* start_element and end_element must both be direct children of parent_element
|
|
@@ -1162,6 +1259,8 @@ export class RedlineEngine {
|
|
|
1162
1259
|
text: string,
|
|
1163
1260
|
) {
|
|
1164
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;
|
|
1165
1264
|
|
|
1166
1265
|
const comment_id = this.comments_manager.addComment(this.author, text);
|
|
1167
1266
|
const xmlDoc = parent_element.ownerDocument!;
|
|
@@ -1213,6 +1312,12 @@ export class RedlineEngine {
|
|
|
1213
1312
|
text: string,
|
|
1214
1313
|
) {
|
|
1215
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;
|
|
1216
1321
|
|
|
1217
1322
|
const comment_id = this.comments_manager.addComment(this.author, text);
|
|
1218
1323
|
const xmlDocStart = start_p.ownerDocument!;
|
|
@@ -1996,6 +2101,99 @@ export class RedlineEngine {
|
|
|
1996
2101
|
pfx,
|
|
1997
2102
|
(edit.new_text || "").length - sfx,
|
|
1998
2103
|
);
|
|
2104
|
+
|
|
2105
|
+
// QA 2026-07-18 C1: the projection flattens headers, body, footers
|
|
2106
|
+
// and notes into one string, but a text edit whose matched span
|
|
2107
|
+
// covers real text from two different OPC parts cannot be applied
|
|
2108
|
+
// without putting content in the wrong part — including the
|
|
2109
|
+
// insertion shape, whose anchor point at the part gap is inherently
|
|
2110
|
+
// ambiguous. Refuse the RAW match range outright and ask for a
|
|
2111
|
+
// single-part anchor. (Single-part documents skip the scan.)
|
|
2112
|
+
const multi_part_doc =
|
|
2113
|
+
target_mapper.part_ranges.filter((r) => r[1] > r[0]).length > 1;
|
|
2114
|
+
const raw_span_parts = multi_part_doc
|
|
2115
|
+
? Array.from(
|
|
2116
|
+
new Set(
|
|
2117
|
+
target_mapper.spans
|
|
2118
|
+
.filter(
|
|
2119
|
+
(s) =>
|
|
2120
|
+
s.run !== null &&
|
|
2121
|
+
s.end > m_start &&
|
|
2122
|
+
s.start < m_start + m_len,
|
|
2123
|
+
)
|
|
2124
|
+
.map((s) => s.part_index),
|
|
2125
|
+
),
|
|
2126
|
+
).sort((a, b) => a - b)
|
|
2127
|
+
: [];
|
|
2128
|
+
if (raw_span_parts.length > 1) {
|
|
2129
|
+
const kinds = raw_span_parts
|
|
2130
|
+
.map((pi) => target_mapper.part_kind_of(pi) || "?")
|
|
2131
|
+
.join(" → ");
|
|
2132
|
+
errors.push(
|
|
2133
|
+
`- Edit ${i + 1 + index_offset} Failed: target_text spans a structural document-part ` +
|
|
2134
|
+
`boundary (${kinds}). Headers, body, footers and footnotes are separate ` +
|
|
2135
|
+
"Word parts — an edit cannot cross between them. Anchor the edit on text " +
|
|
2136
|
+
"within a single part (split it into one edit per part if both sides " +
|
|
2137
|
+
"must change).",
|
|
2138
|
+
);
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
// QA 2026-07-18 M5: image markers are read-only projections. Only
|
|
2142
|
+
// the CHANGED span matters — markers sitting untouched in the
|
|
2143
|
+
// shared context are fine.
|
|
2144
|
+
const eff_start = m_start + pfx;
|
|
2145
|
+
const eff_end = m_start + m_len - sfx;
|
|
2146
|
+
if (eff_end > eff_start) {
|
|
2147
|
+
const overlapping = target_mapper.spans.filter(
|
|
2148
|
+
(s) =>
|
|
2149
|
+
s.end > eff_start &&
|
|
2150
|
+
s.start < eff_end &&
|
|
2151
|
+
(s.run !== null || s.text.trim() !== ""),
|
|
2152
|
+
);
|
|
2153
|
+
if (overlapping.some((s) => (s as any).is_image_marker)) {
|
|
2154
|
+
errors.push(
|
|
2155
|
+
`- Edit ${i + 1 + index_offset} Failed: the target overlaps a read-only image marker ` +
|
|
2156
|
+
"(). Images cannot be edited or removed via text " +
|
|
2157
|
+
"replacement — target the text around the image instead.",
|
|
2158
|
+
);
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
// QA 2026-07-18 H4: comments can only be anchored in the main
|
|
2163
|
+
// document story. A comment-only edit (target == new) whose match
|
|
2164
|
+
// lives in a header/footer/footnote has no effect Word or
|
|
2165
|
+
// LibreOffice could render — refuse it clearly.
|
|
2166
|
+
if (edit.comment && (edit.new_text || "") === (edit.target_text || "")) {
|
|
2167
|
+
const kind_here = target_mapper.part_kind_at(m_start);
|
|
2168
|
+
if (kind_here !== null && kind_here !== "body") {
|
|
2169
|
+
errors.push(
|
|
2170
|
+
`- Edit ${i + 1 + index_offset} Failed: comments cannot be anchored inside a ${kind_here} ` +
|
|
2171
|
+
"part — Word only supports comments in the main document body. Comment on " +
|
|
2172
|
+
"the related body text instead.",
|
|
2173
|
+
);
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
// QA 2026-07-18 C2: a replacement may not smuggle new pipe-delimited
|
|
2178
|
+
// row lines into a table cell. Rows are structural; adding one
|
|
2179
|
+
// requires the insert_row operation.
|
|
2180
|
+
if (
|
|
2181
|
+
RedlineEngine._introduces_table_row_text(
|
|
2182
|
+
target_mapper,
|
|
2183
|
+
m_start,
|
|
2184
|
+
m_len,
|
|
2185
|
+
final_target,
|
|
2186
|
+
final_new,
|
|
2187
|
+
)
|
|
2188
|
+
) {
|
|
2189
|
+
errors.push(
|
|
2190
|
+
`- Edit ${i + 1 + index_offset} Failed: new_text introduces a pipe-delimited row line inside ` +
|
|
2191
|
+
"a table. Text replacement cannot create table rows — use the structured " +
|
|
2192
|
+
`'insert_row' operation instead (e.g. {"type": "insert_row", ` +
|
|
2193
|
+
`"target_text": "<anchor row text>", "cells": ["...", "..."]}).`,
|
|
2194
|
+
);
|
|
2195
|
+
}
|
|
2196
|
+
|
|
1999
2197
|
if (final_target.includes("\n\n")) {
|
|
2000
2198
|
// A *balanced* multi-paragraph modification (target and replacement
|
|
2001
2199
|
// carry the same number of paragraph breaks) is safe: it is split
|
|
@@ -2114,7 +2312,7 @@ export class RedlineEngine {
|
|
|
2114
2312
|
|
|
2115
2313
|
// Structural table edits: verify the anchor really is a table row, and
|
|
2116
2314
|
// that insert_row does not provide more cells than the row has columns —
|
|
2117
|
-
// extra cells
|
|
2315
|
+
// extra cells must never produce a structurally inconsistent row (QA M3).
|
|
2118
2316
|
if (
|
|
2119
2317
|
(edit.type === "insert_row" || edit.type === "delete_row") &&
|
|
2120
2318
|
matches.length > 0
|
|
@@ -2148,6 +2346,33 @@ export class RedlineEngine {
|
|
|
2148
2346
|
* [start, start+length) in `mapper`, or null if that text is not inside a
|
|
2149
2347
|
* table row.
|
|
2150
2348
|
*/
|
|
2349
|
+
/**
|
|
2350
|
+
* True when a replacement anchored in a table would ADD line-separated
|
|
2351
|
+
* pipe-delimited content — the text shape of a table row. Writing that
|
|
2352
|
+
* into a cell renders a fake row inside one cell while the real grid
|
|
2353
|
+
* stays unchanged (QA 2026-07-18 C2); such edits must use insert_row.
|
|
2354
|
+
*/
|
|
2355
|
+
private static _introduces_table_row_text(
|
|
2356
|
+
mapper: DocumentMapper,
|
|
2357
|
+
start: number,
|
|
2358
|
+
length: number,
|
|
2359
|
+
final_target: string,
|
|
2360
|
+
final_new: string,
|
|
2361
|
+
): boolean {
|
|
2362
|
+
if (!final_new.includes("\n") || !final_new.includes(" | ")) return false;
|
|
2363
|
+
const new_pipe_lines = final_new
|
|
2364
|
+
.split("\n")
|
|
2365
|
+
.filter((line) => line.includes(" | ")).length;
|
|
2366
|
+
const old_pipe_lines = final_target
|
|
2367
|
+
.split("\n")
|
|
2368
|
+
.filter((line) => line.includes(" | ")).length;
|
|
2369
|
+
if (new_pipe_lines <= old_pipe_lines) return false;
|
|
2370
|
+
return (
|
|
2371
|
+
RedlineEngine._column_count_at(mapper, start, Math.max(length, 1)) !==
|
|
2372
|
+
null
|
|
2373
|
+
);
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2151
2376
|
private static _column_count_at(
|
|
2152
2377
|
mapper: DocumentMapper,
|
|
2153
2378
|
start: number,
|
|
@@ -2297,16 +2522,14 @@ export class RedlineEngine {
|
|
|
2297
2522
|
!["accept", "reject", "reply"].includes(c.type),
|
|
2298
2523
|
);
|
|
2299
2524
|
|
|
2300
|
-
//
|
|
2301
|
-
//
|
|
2302
|
-
//
|
|
2303
|
-
//
|
|
2304
|
-
//
|
|
2305
|
-
//
|
|
2306
|
-
//
|
|
2307
|
-
//
|
|
2308
|
-
// fully CONTAINED inside a foreign <w:ins> stays allowed and is handled by
|
|
2309
|
-
// nesting the <w:del> inside that <w:ins> (see _apply_single_edit_indexed /
|
|
2525
|
+
// Never pre-unwrap a foreign author's <w:ins> to make a partially
|
|
2526
|
+
// straddling edit fit: that turns their tracked-inserted text into
|
|
2527
|
+
// untracked committed body text before the edit applies, destroying
|
|
2528
|
+
// their provenance. A partial straddle surfaces the standard validation
|
|
2529
|
+
// error ("Modification targets an active insertion from another author
|
|
2530
|
+
// …") via validate_edits, matching the Python engine. An edit fully
|
|
2531
|
+
// CONTAINED inside a foreign <w:ins> is allowed and handled by nesting
|
|
2532
|
+
// the <w:del> inside that <w:ins> (see _apply_single_edit_indexed /
|
|
2310
2533
|
// _insert_and_split_ins).
|
|
2311
2534
|
|
|
2312
2535
|
// BUG-7: Unified single-pass validation in wet-run / standard mode.
|
|
@@ -2391,7 +2614,15 @@ export class RedlineEngine {
|
|
|
2391
2614
|
// not claim any edit "applied" (transactional parity with Python).
|
|
2392
2615
|
const validation_failed_idx = new Set<number>();
|
|
2393
2616
|
for (const { edit, i } of ordered_edits) {
|
|
2394
|
-
let single_errors
|
|
2617
|
+
let single_errors: string[];
|
|
2618
|
+
try {
|
|
2619
|
+
single_errors = this.validate_edits([edit], i);
|
|
2620
|
+
} catch (e) {
|
|
2621
|
+
// A pathological user pattern must fail as a clean per-edit
|
|
2622
|
+
// validation error, never a hang or crash (QA 2026-07-17 F5).
|
|
2623
|
+
if (!(e instanceof RegexTimeoutError)) throw e;
|
|
2624
|
+
single_errors = [`- Edit ${i + 1} Failed: ${e.message}`];
|
|
2625
|
+
}
|
|
2395
2626
|
if (single_errors.length > 0) {
|
|
2396
2627
|
if (applied_edits > 0) {
|
|
2397
2628
|
const hint = sequential_context_hint(applied_edits);
|
|
@@ -2489,7 +2720,14 @@ export class RedlineEngine {
|
|
|
2489
2720
|
const sequential_errors: string[] = [];
|
|
2490
2721
|
let applied_so_far = 0;
|
|
2491
2722
|
for (const { edit, i } of ordered_edits) {
|
|
2492
|
-
let single_errors
|
|
2723
|
+
let single_errors: string[];
|
|
2724
|
+
try {
|
|
2725
|
+
single_errors = this.validate_edits([edit], i);
|
|
2726
|
+
} catch (e) {
|
|
2727
|
+
// Clean per-edit failure for time-budget violations (QA F5).
|
|
2728
|
+
if (!(e instanceof RegexTimeoutError)) throw e;
|
|
2729
|
+
single_errors = [`- Edit ${i + 1} Failed: ${e.message}`];
|
|
2730
|
+
}
|
|
2493
2731
|
if (single_errors.length > 0) {
|
|
2494
2732
|
if (applied_so_far > 0) {
|
|
2495
2733
|
const hint = sequential_context_hint(applied_so_far);
|
|
@@ -2624,7 +2862,20 @@ export class RedlineEngine {
|
|
|
2624
2862
|
edit._error_msg = msg;
|
|
2625
2863
|
}
|
|
2626
2864
|
} else {
|
|
2627
|
-
|
|
2865
|
+
let resolved: any;
|
|
2866
|
+
try {
|
|
2867
|
+
resolved = this._pre_resolve_heuristic_edit(edit);
|
|
2868
|
+
} catch (e) {
|
|
2869
|
+
// Direct apply_edits callers bypass validate_edits; the time
|
|
2870
|
+
// budget must still fail cleanly here (QA F5).
|
|
2871
|
+
if (!(e instanceof RegexTimeoutError)) throw e;
|
|
2872
|
+
skipped++;
|
|
2873
|
+
edit._applied_status = false;
|
|
2874
|
+
const msg = `- Failed to apply edit targeting: '${(edit.target_text || "").substring(0, 40)}...' (${e.message})`;
|
|
2875
|
+
this.skipped_details.push(msg);
|
|
2876
|
+
edit._error_msg = msg;
|
|
2877
|
+
continue;
|
|
2878
|
+
}
|
|
2628
2879
|
if (resolved) {
|
|
2629
2880
|
if (Array.isArray(resolved)) {
|
|
2630
2881
|
for (const r of resolved) {
|
|
@@ -2667,8 +2918,8 @@ export class RedlineEngine {
|
|
|
2667
2918
|
|
|
2668
2919
|
// Snapshot preview context now, while every resolved offset still refers
|
|
2669
2920
|
// to the untouched document. The sweep below mutates the DOM and rebuilds
|
|
2670
|
-
// the map,
|
|
2671
|
-
// slicing full_text at report time
|
|
2921
|
+
// the map, shifting offsets and injecting tracked-change markup —
|
|
2922
|
+
// slicing full_text at report time garbles previews (QA H1).
|
|
2672
2923
|
for (const [res_edit] of resolved_edits) {
|
|
2673
2924
|
this._capture_preview_context(res_edit);
|
|
2674
2925
|
if (res_edit._parent_edit_ref) {
|
|
@@ -2684,7 +2935,13 @@ export class RedlineEngine {
|
|
|
2684
2935
|
|
|
2685
2936
|
for (const [edit, orig_new] of resolved_edits) {
|
|
2686
2937
|
const start = edit._resolved_start_idx || 0;
|
|
2687
|
-
|
|
2938
|
+
// An insert_row does not consume its anchor text — it adds an adjacent
|
|
2939
|
+
// row. Give it a zero-width range so several inserts sharing one
|
|
2940
|
+
// anchor (consecutive new rows) never flag each other as overlapping.
|
|
2941
|
+
const end =
|
|
2942
|
+
edit.type === "insert_row"
|
|
2943
|
+
? start
|
|
2944
|
+
: start + (edit.target_text ? edit.target_text.length : 0);
|
|
2688
2945
|
|
|
2689
2946
|
const overlaps = occupied_ranges.some(
|
|
2690
2947
|
([occ_start, occ_end]) => start < occ_end && end > occ_start,
|
|
@@ -2814,6 +3071,29 @@ export class RedlineEngine {
|
|
|
2814
3071
|
continue;
|
|
2815
3072
|
}
|
|
2816
3073
|
|
|
3074
|
+
// Refuse accept/reject on a w:id shared by revisions from DIFFERENT
|
|
3075
|
+
// authors. Uniqueness of w:id is assumed but not guaranteed for
|
|
3076
|
+
// externally produced documents (merges, cross-document copy-paste),
|
|
3077
|
+
// where one action would silently resolve several unrelated changes
|
|
3078
|
+
// (QA 2026-07-17 F9). Same-author reuse is legitimate — this engine
|
|
3079
|
+
// itself mints one id across every element of a single logical edit —
|
|
3080
|
+
// so authorship is the discriminator.
|
|
3081
|
+
const dup_authors = Array.from(
|
|
3082
|
+
new Set(all_nodes.map((n) => n.getAttribute("w:author") || "Unknown")),
|
|
3083
|
+
).sort();
|
|
3084
|
+
if (dup_authors.length > 1) {
|
|
3085
|
+
skipped++;
|
|
3086
|
+
this.skipped_details.push(
|
|
3087
|
+
`- Failed to apply action: ${type} on Chg:${target_id} is ambiguous. The document ` +
|
|
3088
|
+
`contains ${all_nodes.length} tracked-change elements sharing w:id=${target_id} from ` +
|
|
3089
|
+
`different authors (${dup_authors.join(", ")}) — duplicate revision IDs produced ` +
|
|
3090
|
+
`outside this engine (e.g. by a document merge or copy-paste). Acting on this ID ` +
|
|
3091
|
+
`would resolve all of them at once. Resolve these changes individually in Word, or ` +
|
|
3092
|
+
`apply the intended outcome as an explicit text edit instead.`,
|
|
3093
|
+
);
|
|
3094
|
+
continue;
|
|
3095
|
+
}
|
|
3096
|
+
|
|
2817
3097
|
for (const node of all_nodes) {
|
|
2818
3098
|
const is_ins = node.tagName === "w:ins";
|
|
2819
3099
|
const parent_tag = node.parentNode
|
|
@@ -3522,20 +3802,80 @@ export class RedlineEngine {
|
|
|
3522
3802
|
return true;
|
|
3523
3803
|
}
|
|
3524
3804
|
if (op === "INSERTION") {
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3805
|
+
let final_new_text = edit.new_text || "";
|
|
3806
|
+
|
|
3807
|
+
// QA 2026-07-18 C1: a MACHINE-PINNED pure insertion (diff/text
|
|
3808
|
+
// round-trip output: authored with an empty target and no parent
|
|
3809
|
+
// edit) positioned in the separator gap between the body and a
|
|
3810
|
+
// following part used to anchor on the NEXT part's first paragraph,
|
|
3811
|
+
// writing the new final body paragraph into word/footer1.xml.
|
|
3812
|
+
// Re-anchor it to the end of the body and force new-paragraph
|
|
3813
|
+
// semantics. Insertions DERIVED from a target-anchored edit (parent
|
|
3814
|
+
// ref set — e.g. prepending "DRAFT " to "FOOTER MARKER") keep the
|
|
3815
|
+
// user's chosen anchor: their context names the part they meant.
|
|
3816
|
+
let boundary_anchor: TextSpan | null = null;
|
|
3817
|
+
const boundary =
|
|
3818
|
+
typeof (active_mapper as any).part_boundary_at === "function"
|
|
3819
|
+
? active_mapper.part_boundary_at(start_idx)
|
|
3820
|
+
: null;
|
|
3821
|
+
const is_machine_pure_insertion =
|
|
3822
|
+
!edit.target_text &&
|
|
3823
|
+
(edit._parent_edit_ref === undefined || edit._parent_edit_ref === null);
|
|
3824
|
+
if (boundary !== null && is_machine_pure_insertion) {
|
|
3825
|
+
const [prev_i, next_i] = boundary;
|
|
3826
|
+
const prev_kind = active_mapper.part_kind_of(prev_i);
|
|
3827
|
+
const next_kind = active_mapper.part_kind_of(next_i);
|
|
3828
|
+
if (prev_kind === "body" && next_kind !== "body") {
|
|
3829
|
+
const real_before = active_mapper.spans.filter(
|
|
3830
|
+
(s: TextSpan) => s.run !== null && s.part_index === prev_i,
|
|
3831
|
+
);
|
|
3832
|
+
if (real_before.length > 0) {
|
|
3833
|
+
boundary_anchor = real_before[real_before.length - 1];
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3836
|
+
}
|
|
3837
|
+
|
|
3838
|
+
let anchor_run: Run | null;
|
|
3839
|
+
let anchor_para: Paragraph | null;
|
|
3840
|
+
if (boundary_anchor !== null) {
|
|
3841
|
+
anchor_run = boundary_anchor.run;
|
|
3842
|
+
anchor_para = boundary_anchor.paragraph;
|
|
3843
|
+
if (!final_new_text.startsWith("\n")) {
|
|
3844
|
+
final_new_text = "\n\n" + final_new_text;
|
|
3845
|
+
}
|
|
3846
|
+
} else {
|
|
3847
|
+
[anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
|
|
3848
|
+
start_idx,
|
|
3849
|
+
rebuild_map,
|
|
3850
|
+
);
|
|
3851
|
+
}
|
|
3529
3852
|
if (!anchor_run && !anchor_para) return false;
|
|
3530
3853
|
|
|
3854
|
+
// QA 2026-07-18 C2 (apply-level backstop, pinned edits bypass
|
|
3855
|
+
// validate_edits): refuse insertions that would write row-shaped pipe
|
|
3856
|
+
// text into a table cell.
|
|
3857
|
+
if (
|
|
3858
|
+
RedlineEngine._introduces_table_row_text(
|
|
3859
|
+
active_mapper,
|
|
3860
|
+
start_idx,
|
|
3861
|
+
1,
|
|
3862
|
+
"",
|
|
3863
|
+
final_new_text,
|
|
3864
|
+
)
|
|
3865
|
+
) {
|
|
3866
|
+
return false;
|
|
3867
|
+
}
|
|
3868
|
+
|
|
3531
3869
|
// BUG-23-3: a prefix insertion whose new_text ends in a paragraph break
|
|
3532
3870
|
// (e.g. "Summary\n\n" inserted before "Conclusion") must become a NEW
|
|
3533
3871
|
// paragraph placed BEFORE the anchor paragraph, not inline text merged
|
|
3534
3872
|
// into a neighbouring paragraph. _track_insert_multiline drops the
|
|
3535
3873
|
// trailing break and inlines the remainder, which both loses the
|
|
3536
3874
|
// paragraph boundary and mis-orders the content. Handle this case here.
|
|
3537
|
-
|
|
3538
|
-
const
|
|
3875
|
+
// (Skipped when the C1 boundary re-anchor above took over the anchor.)
|
|
3876
|
+
const _bug233_new = final_new_text;
|
|
3877
|
+
const _bug233_trailing_break =
|
|
3878
|
+
boundary_anchor === null && /\n\s*$/.test(_bug233_new);
|
|
3539
3879
|
let _bug233_target_para: Element | null = null;
|
|
3540
3880
|
{
|
|
3541
3881
|
const startingSpans = active_mapper.spans.filter(
|
|
@@ -3613,7 +3953,7 @@ export class RedlineEngine {
|
|
|
3613
3953
|
}
|
|
3614
3954
|
|
|
3615
3955
|
const result = this._track_insert_multiline(
|
|
3616
|
-
|
|
3956
|
+
final_new_text,
|
|
3617
3957
|
anchor_run,
|
|
3618
3958
|
anchor_para,
|
|
3619
3959
|
ins_id!,
|
|
@@ -3740,6 +4080,29 @@ export class RedlineEngine {
|
|
|
3740
4080
|
return true;
|
|
3741
4081
|
}
|
|
3742
4082
|
|
|
4083
|
+
// QA 2026-07-18 C1 (apply-level backstop, pinned edits bypass
|
|
4084
|
+
// validate_edits): a modification/deletion may never mutate real text
|
|
4085
|
+
// from two different OPC parts in one span. Single-part documents skip
|
|
4086
|
+
// the scan.
|
|
4087
|
+
if (
|
|
4088
|
+
(op === "DELETION" || op === "MODIFICATION") &&
|
|
4089
|
+
length &&
|
|
4090
|
+
active_mapper.part_ranges.filter((r: [number, number, string]) => r[1] > r[0]).length > 1
|
|
4091
|
+
) {
|
|
4092
|
+
const crossed_parts = new Set<number>();
|
|
4093
|
+
for (const s of active_mapper.spans) {
|
|
4094
|
+
if (s.run !== null && s.end > start_idx && s.start < start_idx + length) {
|
|
4095
|
+
crossed_parts.add(s.part_index);
|
|
4096
|
+
}
|
|
4097
|
+
}
|
|
4098
|
+
if (crossed_parts.size > 1) {
|
|
4099
|
+
console.error(
|
|
4100
|
+
`Refusing edit that spans OPC part boundary (start=${start_idx}, parts=${Array.from(crossed_parts).sort().join(",")})`,
|
|
4101
|
+
);
|
|
4102
|
+
return false;
|
|
4103
|
+
}
|
|
4104
|
+
}
|
|
4105
|
+
|
|
3743
4106
|
// DELETION / MODIFICATION
|
|
3744
4107
|
const target_runs = active_mapper.find_target_runs_by_index(
|
|
3745
4108
|
start_idx,
|
package/src/index.ts
CHANGED
|
@@ -4,10 +4,11 @@ export function identifyEngine() {
|
|
|
4
4
|
|
|
5
5
|
export { DocumentObject } from './docx/bridge.js';
|
|
6
6
|
export { DocumentMapper, TextSpan } from './mapper.js';
|
|
7
|
-
export { RedlineEngine, BatchValidationError } 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';
|
|
7
|
+
export { RedlineEngine, BatchValidationError, validate_edit_strings, describe_illegal_control_chars } from './engine.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';
|
|
13
|
-
export { finalize_document, FinalizeOptions, FinalizeResult } from './sanitize/core.js';
|
|
12
|
+
export { extractTextFromBuffer, _extractTextFromDoc, ExtractStructure, TableGeometry, RowGeometry } from './ingest.js';
|
|
13
|
+
export { finalize_document, FinalizeOptions, FinalizeResult } from './sanitize/core.js';
|
|
14
|
+
export { RegexTimeoutError, userFindAllMatches, userSearch, USER_PATTERN_TIMEOUT_MS } from './utils/safe-regex.js';
|