@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/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 (![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
+
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
- // Drop trailing empty line. "foo\n\nbar\n\n" splits to
1357
- // ['foo', '', 'bar', '']; that trailing empty is just a terminator, not
1358
- // a real empty paragraph.
1359
- while (lines.length > 1 && lines[lines.length - 1] === "") {
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
- const match = stripped_text.match(/^\d+\.\s+/);
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
+ "(![alt](docx-image:N)). 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
- const end = start + (edit.target_text ? edit.target_text.length : 0);
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
- const [anchor_run, anchor_para] = this.mapper.get_insertion_anchor(
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 === new_cells.length && actual_cells.length > 1) {
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
- let search_offset = start_idx;
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
- let actual_start = search_offset;
3222
- if (a_clean) {
3223
- actual_start = this.mapper.full_text.indexOf(a_clean, search_offset);
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
- if (a_clean) {
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
- const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
3609
- start_idx,
3610
- rebuild_map,
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
- const _bug233_new = edit.new_text || "";
3621
- const _bug233_trailing_break = /\n\s*$/.test(_bug233_new);
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
- edit.new_text || "",
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';