@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/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 (![alt](docx-image:N)) 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, which produced garbled previews mixing unrelated
575
- * edits and internal scaffolding (QA H1).
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 instead of the misleading
694
- * empty string the report used to print (QA M4).
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
+ "(![alt](docx-image:N)). 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 used to produce a structurally inconsistent row (QA M3).
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
- // NOTE: a previous "edits_for_merge" pre-pass here silently UNWRAPPED a
2301
- // foreign author's <w:ins> when a strict/first edit only partially straddled
2302
- // its boundary turning that author's tracked-inserted text into untracked
2303
- // committed body text before the edit applied, destroying their provenance.
2304
- // That is the same provenance-laundering failure mode the canonical engine
2305
- // refuses, so it has been removed: a partial straddle now surfaces the
2306
- // standard validation error ("Modification targets an active insertion from
2307
- // another author …") via validate_edits, matching the Python engine. An edit
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 = this.validate_edits([edit], i);
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 = this.validate_edits([edit], i);
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
- const resolved = this._pre_resolve_heuristic_edit(edit);
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, which shifts offsets and injects tracked-change markup —
2671
- // slicing full_text at report time garbled previews (QA H1).
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
- const end = start + (edit.target_text ? edit.target_text.length : 0);
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
- const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
3526
- start_idx,
3527
- rebuild_map,
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
- const _bug233_new = edit.new_text || "";
3538
- const _bug233_trailing_break = /\n\s*$/.test(_bug233_new);
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
- edit.new_text || "",
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';