@adeu/core 1.23.0 → 1.26.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
@@ -437,6 +437,26 @@ export class RedlineEngine {
437
437
  raw_sub_edits = [];
438
438
  }
439
439
 
440
+ // Hunks made purely of style markers are projection artifacts, never
441
+ // user intent: they arise when a PLAIN target fuzzy-matched styled
442
+ // document text ("Net 90 Days" against "**Net 90 Days**"), and the
443
+ // resulting `**`-deletion sub-edits target virtual spans that can never
444
+ // apply — phantom skips while the formatting silently stays (QA
445
+ // 2026-07-19 F-02 sibling). Edits that DO declare markers never reach
446
+ // this word-diff path (they resolve as whole-span markdown proxies).
447
+ const _marker_only = (text: string): boolean => {
448
+ const stripped = text.trim();
449
+ return stripped.length > 0 && /^[*_]+$/.test(stripped);
450
+ };
451
+ raw_sub_edits = raw_sub_edits.filter(
452
+ (e: any) =>
453
+ !(
454
+ (!e.target_text || _marker_only(e.target_text)) &&
455
+ (!e.new_text || _marker_only(e.new_text)) &&
456
+ (e.target_text || e.new_text)
457
+ ),
458
+ );
459
+
440
460
  if (!raw_sub_edits || raw_sub_edits.length === 0) {
441
461
  const fallback_edit: any = {
442
462
  type: "modify",
@@ -1394,6 +1414,15 @@ export class RedlineEngine {
1394
1414
  anchor_run: Run | null,
1395
1415
  anchor_paragraph: Paragraph | null,
1396
1416
  reuse_id: string,
1417
+ // The attached DOM element the insertion physically follows. anchor_run
1418
+ // supplies STYLING and may already be detached (the deletion step clones
1419
+ // runs into <w:del> and replaces the originals); suffix relocation for
1420
+ // paragraph-splitting insertions keys on this element instead.
1421
+ positional_anchor_el: Element | null = null,
1422
+ // When the edit declares explicit emphasis markers, the markers are
1423
+ // authoritative: strip inherited bold/italic from the anchor style
1424
+ // (QA 2026-07-19 F-02).
1425
+ suppress_emphasis: boolean = false,
1397
1426
  ): {
1398
1427
  first_node: Element | null;
1399
1428
  last_p: Element | null;
@@ -1424,10 +1453,48 @@ export class RedlineEngine {
1424
1453
  current_p = walker;
1425
1454
  }
1426
1455
 
1427
- // Drop trailing empty line. "foo\n\nbar\n\n" splits to
1428
- // ['foo', '', 'bar', '']; that trailing empty is just a terminator, not
1429
- // a real empty paragraph.
1430
- while (lines.length > 1 && lines[lines.length - 1] === "") {
1456
+ // Suffix nodes: content that follows the anchor inside current_p. When
1457
+ // the inserted text carries paragraph breaks, this content belongs in
1458
+ // the LAST new paragraph. The positional anchor is attached to the
1459
+ // DOM, and the insertion lands immediately after it, so its following
1460
+ // child-of-paragraph siblings are exactly the suffix.
1461
+ const suffix_nodes: Element[] = [];
1462
+ const pos_source =
1463
+ positional_anchor_el && positional_anchor_el.parentNode
1464
+ ? positional_anchor_el
1465
+ : anchor_run !== null && anchor_run._element.parentNode
1466
+ ? anchor_run._element
1467
+ : null;
1468
+ if (current_p !== null && pos_source !== null) {
1469
+ let pos_anchor: Element | null = pos_source;
1470
+ while (pos_anchor && pos_anchor.parentNode !== current_p) {
1471
+ pos_anchor = pos_anchor.parentNode as Element | null;
1472
+ if (pos_anchor === current_p) {
1473
+ pos_anchor = null;
1474
+ break;
1475
+ }
1476
+ }
1477
+ if (pos_anchor) {
1478
+ const relocatable = new Set(["w:r", "w:ins", "w:del"]);
1479
+ let nxt = pos_anchor.nextSibling;
1480
+ while (nxt) {
1481
+ if (nxt.nodeType === 1 && relocatable.has((nxt as Element).tagName)) {
1482
+ suffix_nodes.push(nxt as Element);
1483
+ }
1484
+ nxt = nxt.nextSibling;
1485
+ }
1486
+ }
1487
+ }
1488
+
1489
+ // Drop the trailing empty line ONLY when there is no suffix to relocate.
1490
+ // "foo\n\nbar\n\n" splits to ['foo', '', 'bar', '']; without a suffix
1491
+ // the trailing empty is just a terminator, but with one it is the fresh
1492
+ // destination paragraph the suffix moves into.
1493
+ while (
1494
+ lines.length > 1 &&
1495
+ lines[lines.length - 1] === "" &&
1496
+ suffix_nodes.length === 0
1497
+ ) {
1431
1498
  lines.pop();
1432
1499
  }
1433
1500
  if (lines.length === 0) {
@@ -1454,6 +1521,7 @@ export class RedlineEngine {
1454
1521
  anchor_run,
1455
1522
  reuse_id,
1456
1523
  xmlDoc,
1524
+ suppress_emphasis,
1457
1525
  );
1458
1526
  first_node = inline_ins;
1459
1527
  // Caller will attach `inline_ins` to the DOM later — keep it for now.
@@ -1539,6 +1607,7 @@ export class RedlineEngine {
1539
1607
  anchor_run,
1540
1608
  reuse_id,
1541
1609
  xmlDoc,
1610
+ suppress_emphasis,
1542
1611
  );
1543
1612
  if (content_ins) {
1544
1613
  new_p.appendChild(content_ins);
@@ -1555,6 +1624,16 @@ export class RedlineEngine {
1555
1624
  }
1556
1625
  }
1557
1626
 
1627
+ // Relocate the suffix into the last new paragraph: the paragraph break
1628
+ // the insertion introduced splits current_p at the anchor, so everything
1629
+ // after the anchor continues in the final inserted paragraph.
1630
+ if (!block_mode && last_p && suffix_nodes.length > 0) {
1631
+ for (const node of suffix_nodes) {
1632
+ node.parentNode?.removeChild(node);
1633
+ last_p.appendChild(node);
1634
+ }
1635
+ }
1636
+
1558
1637
  return { first_node, last_p, last_ins, used_block_mode: block_mode };
1559
1638
  }
1560
1639
 
@@ -1568,6 +1647,7 @@ export class RedlineEngine {
1568
1647
  anchor_run: Run | null,
1569
1648
  reuse_id: string,
1570
1649
  xmlDoc: Document,
1650
+ suppress_emphasis: boolean = false,
1571
1651
  ): Element | null {
1572
1652
  if (!line_text && line_text !== "") return null;
1573
1653
  const ins = this._create_track_change_tag("w:ins", "", reuse_id);
@@ -1577,25 +1657,26 @@ export class RedlineEngine {
1577
1657
  }
1578
1658
  for (const [segText, segProps] of segments) {
1579
1659
  const r = xmlDoc.createElement("w:r");
1580
- // Inherit run formatting (e.g. bold from a heading style) only when we
1581
- // have an anchor run AND we are not overriding via segment props.
1660
+ // Inherit run formatting from the anchor so partial replacements inside
1661
+ // a styled span keep the style (matching Word's type-into-selection
1662
+ // behavior and the Python engine — the old blanket strip made
1663
+ // "Important" -> "Critical" inside a bold span come out unstyled).
1582
1664
  if (anchor_run && anchor_run._element) {
1583
1665
  const anchor_rPr = findChild(anchor_run._element, "w:rPr");
1584
1666
  if (anchor_rPr) {
1585
1667
  const clone = anchor_rPr.cloneNode(true) as Element;
1586
- // Strip vanish / strike to avoid invisible inserts, and emphasis
1587
- // (bold/italic) so inserted replacement text does not silently
1588
- // inherit the anchor run's character formatting (BUG-23-2). Explicit
1589
- // markdown emphasis is re-applied per-segment via _apply_run_props.
1590
- for (const tag of [
1591
- "w:vanish",
1592
- "w:strike",
1593
- "w:dstrike",
1594
- "w:i",
1595
- "w:iCs",
1596
- "w:b",
1597
- "w:bCs",
1598
- ]) {
1668
+ // Always strip vanish / strike (invisible inserts) and italic
1669
+ // (BUG-23-2: an inserted replacement must not silently inherit the
1670
+ // surrounding italic styling). Bold is preserved it usually
1671
+ // carries structural meaning (headings, defined terms) — UNLESS
1672
+ // the edit's own markers are authoritative (QA 2026-07-19 F-02):
1673
+ // `**X**` -> `_X_` must yield italic-only, `**X**` -> `X` plain.
1674
+ // Mirrors the Python engine's _track_insert_inline exactly.
1675
+ const strip_tags = ["w:vanish", "w:strike", "w:dstrike", "w:i", "w:iCs"];
1676
+ if (suppress_emphasis) {
1677
+ strip_tags.push("w:b", "w:bCs");
1678
+ }
1679
+ for (const tag of strip_tags) {
1599
1680
  const found = findChild(clone, tag);
1600
1681
  if (found) clone.removeChild(found);
1601
1682
  }
@@ -1628,7 +1709,13 @@ export class RedlineEngine {
1628
1709
  return [stripped_text.substring(2).trim(), "List Paragraph"];
1629
1710
  }
1630
1711
 
1631
- const match = stripped_text.match(/^\d+\.\s+/);
1712
+ // Numbered lists: the projection emits ordered items with a CONSTANT
1713
+ // "1. " marker (Markdown renumbers), so only that exact shape converts
1714
+ // back into a list style. Any other leading number ("2024. Year in
1715
+ // review", "3. Clause text") is literal document text. Continuation
1716
+ // items inside an existing list anchor keep full "\d+." handling via
1717
+ // the list-anchored insertion path.
1718
+ const match = stripped_text.match(/^1\.\s+/);
1632
1719
  if (match) {
1633
1720
  return [stripped_text.substring(match[0].length).trim(), "List Number"];
1634
1721
  }
@@ -1636,6 +1723,28 @@ export class RedlineEngine {
1636
1723
  return [text, null];
1637
1724
  }
1638
1725
 
1726
+ /**
1727
+ * True when this edit's target or replacement text carries explicit
1728
+ * bold/italic markers, making the markers AUTHORITATIVE for the inserted
1729
+ * runs' formatting. Replacing `**X**` with `_X_` must yield italic-only
1730
+ * text, and replacing `**X**` with `X` must yield plain text — inheriting
1731
+ * the replaced span's run properties on top of (or instead of) the
1732
+ * requested markers silently produces the wrong document while the report
1733
+ * claims success (QA 2026-07-19 F-02). Plain-text edits (no markers on
1734
+ * either side) keep inheriting the context style so partial replacements
1735
+ * inside a styled span never lose formatting.
1736
+ */
1737
+ private _edit_declares_emphasis(edit: any): boolean {
1738
+ for (const text of [edit?.target_text, edit?.new_text]) {
1739
+ if (!text || (!text.includes("**") && !text.includes("_"))) continue;
1740
+ const segments = this._parse_inline_markdown(text);
1741
+ if (segments.some(([, props]: [string, any]) => props && Object.keys(props).length > 0)) {
1742
+ return true;
1743
+ }
1744
+ }
1745
+ return false;
1746
+ }
1747
+
1639
1748
  private _parse_inline_markdown(
1640
1749
  text: string,
1641
1750
  baseStyle: any = {},
@@ -1978,6 +2087,23 @@ export class RedlineEngine {
1978
2087
  const is_regex = (edit as any).regex || false;
1979
2088
  const match_mode = (edit as any).match_mode || "strict";
1980
2089
 
2090
+ if (is_regex) {
2091
+ // An unparsable pattern must be diagnosed as a regex problem. Without
2092
+ // this check it falls through the matcher's silent guard and surfaces
2093
+ // as "target text not found", sending the user hunting for a typo in
2094
+ // the document instead of in the pattern (QA 2026-07-19 F-13).
2095
+ try {
2096
+ new RegExp(edit.target_text);
2097
+ } catch (regex_err: any) {
2098
+ errors.push(
2099
+ `- Edit ${i + 1 + index_offset} Failed: target_text is not a valid regular expression ` +
2100
+ `(${regex_err?.message ?? regex_err}). Fix the pattern, or set "regex": false to ` +
2101
+ "match the text literally.",
2102
+ );
2103
+ continue;
2104
+ }
2105
+ }
2106
+
1981
2107
  let matches = this.mapper.find_all_match_indices(
1982
2108
  edit.target_text,
1983
2109
  is_regex,
@@ -2796,6 +2922,14 @@ export class RedlineEngine {
2796
2922
  actions_skipped: skipped_actions,
2797
2923
  edits_applied: applied_edits,
2798
2924
  edits_skipped: skipped_edits,
2925
+ // edits_applied counts change OBJECTS; this is the total number of
2926
+ // document occurrences they modified (match_mode="all" fan-out), so
2927
+ // automation never has to guess which of the two a count means
2928
+ // (QA 2026-07-19 F-21).
2929
+ occurrences_modified: edits_reports.reduce(
2930
+ (sum: number, r: any) => sum + (r.occurrences_modified || 0),
2931
+ 0,
2932
+ ),
2799
2933
  skipped_details: this.skipped_details,
2800
2934
  edits: edits_reports,
2801
2935
  engine: "node",
@@ -2823,6 +2957,7 @@ export class RedlineEngine {
2823
2957
  }
2824
2958
  edit._applied_status = false;
2825
2959
  edit._error_msg = null;
2960
+ edit._any_sub_failure = false;
2826
2961
  }
2827
2962
 
2828
2963
  for (const edit of edits) {
@@ -2841,15 +2976,22 @@ export class RedlineEngine {
2841
2976
  resolved_edits.push([edit, edit.new_text || null]);
2842
2977
  } else if (edit.type === "insert_row" || edit.type === "delete_row") {
2843
2978
  let matches = this.mapper.find_all_match_indices(edit.target_text);
2979
+ let resolved_mapper = this.mapper;
2844
2980
  if (matches.length === 0) {
2845
2981
  if (!this.clean_mapper) {
2846
2982
  this.clean_mapper = new DocumentMapper(this.doc, true);
2847
2983
  }
2848
2984
  matches = this.clean_mapper.find_all_match_indices(edit.target_text);
2985
+ resolved_mapper = this.clean_mapper;
2849
2986
  }
2850
2987
 
2851
2988
  if (matches.length > 0) {
2989
+ // Record WHICH mapper produced the offset: a clean-view index
2990
+ // resolved against the raw mapper lands rows at the wrong position
2991
+ // once earlier edits in the batch put tracked changes in the
2992
+ // anchor row.
2852
2993
  edit._resolved_start_idx = matches[0][0];
2994
+ edit._active_mapper_ref = resolved_mapper;
2853
2995
  resolved_edits.push([edit, null]);
2854
2996
  } else {
2855
2997
  skipped++;
@@ -2954,18 +3096,25 @@ export class RedlineEngine {
2954
3096
  this.skipped_details.push(msg);
2955
3097
  edit._applied_status = false;
2956
3098
  edit._error_msg = msg;
3099
+ edit._any_sub_failure = true;
2957
3100
  const parent = edit._parent_edit_ref;
2958
3101
  if (parent) {
2959
3102
  parent._applied_status = false;
2960
3103
  parent._error_msg = msg;
3104
+ parent._any_sub_failure = true;
2961
3105
  }
2962
3106
  continue;
2963
3107
  }
2964
3108
 
2965
3109
  let success = false;
2966
3110
  if (edit.type === "modify") {
2967
- const rebuild = edit._split_group_id !== undefined && edit._split_group_id !== null;
2968
- success = this._apply_single_edit_indexed(edit, orig_new, rebuild);
3111
+ // Never rebuild the map inside the sweep: sub-edits apply in strictly
3112
+ // descending offset order, and every DOM mutation (run splits, w:del
3113
+ // wraps, w:ins insertions, bottom-up paragraph merges) happens at or
3114
+ // above the current offset, so spans below it stay valid in the stale
3115
+ // map. Rebuilding here made regex + match_mode="all"
3116
+ // O(occurrences × document) (QA 2026-07-19 F-06).
3117
+ success = this._apply_single_edit_indexed(edit, orig_new, false);
2969
3118
  } else if (edit.type === "insert_row" || edit.type === "delete_row") {
2970
3119
  success = this._apply_table_edit(edit, false);
2971
3120
  }
@@ -3023,8 +3172,10 @@ export class RedlineEngine {
3023
3172
  this.skipped_details.push(msg);
3024
3173
  edit._applied_status = false;
3025
3174
  edit._error_msg = msg;
3175
+ edit._any_sub_failure = true;
3026
3176
  const parent = edit._parent_edit_ref;
3027
3177
  if (parent) {
3178
+ parent._any_sub_failure = true;
3028
3179
  if (!parent._applied_status) {
3029
3180
  parent._applied_status = false;
3030
3181
  parent._error_msg = msg;
@@ -3033,7 +3184,25 @@ export class RedlineEngine {
3033
3184
  }
3034
3185
  }
3035
3186
 
3036
- return [applied, skipped];
3187
+ // Return LOGICAL edit counts over the caller's input list: one
3188
+ // match_mode="all" edit over N occurrences is one applied edit (its
3189
+ // occurrence count lives in _occurrences_modified / the report), never N
3190
+ // (QA 2026-07-19 F-21). An edit with any failed or skipped sub-edit
3191
+ // counts as skipped so the all-or-nothing batch contract is unchanged.
3192
+ let applied_logical = 0;
3193
+ let skipped_logical = 0;
3194
+ for (const input_edit of edits) {
3195
+ if (typeof input_edit !== "object" || input_edit === null) {
3196
+ skipped_logical++;
3197
+ continue;
3198
+ }
3199
+ if (input_edit._applied_status && !input_edit._any_sub_failure) {
3200
+ applied_logical++;
3201
+ } else {
3202
+ skipped_logical++;
3203
+ }
3204
+ }
3205
+ return [applied_logical, skipped_logical];
3037
3206
  }
3038
3207
 
3039
3208
  public apply_review_actions(actions: any[]): [number, number] {
@@ -3160,7 +3329,11 @@ export class RedlineEngine {
3160
3329
  edit._resolved_start_idx !== null
3161
3330
  ? edit._resolved_start_idx
3162
3331
  : edit._match_start_index || 0;
3163
- const [anchor_run, anchor_para] = this.mapper.get_insertion_anchor(
3332
+ // The offset must be looked up in the coordinate space it was resolved
3333
+ // in: a clean-view offset applied to the raw
3334
+ // mapper points at earlier text once tracked changes exist.
3335
+ const active_mapper: DocumentMapper = edit._active_mapper_ref || this.mapper;
3336
+ const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
3164
3337
  start_idx,
3165
3338
  rebuild_map,
3166
3339
  );
@@ -3396,9 +3569,22 @@ export class RedlineEngine {
3396
3569
  const actual_cells = actual_doc_text.split("|");
3397
3570
  const new_cells = current_effective_new_text.split("|");
3398
3571
 
3399
- if (actual_cells.length === new_cells.length && actual_cells.length > 1) {
3572
+ if (actual_cells.length !== new_cells.length) {
3573
+ throw new BatchValidationError([
3574
+ `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).`
3575
+ ]);
3576
+ }
3577
+
3578
+ if (actual_cells.length > 1) {
3400
3579
  const sub_edits: any[] = [];
3401
- let search_offset = start_idx;
3580
+
3581
+ // actual_doc_text IS the document slice at
3582
+ // [start_idx, start_idx + len): per-cell offsets are exact
3583
+ // arithmetic over that slice — never a search of mapper.full_text,
3584
+ // which cannot distinguish repeated cell text and lands in the
3585
+ // wrong cell when the matched range starts inside a " | "
3586
+ // separator.
3587
+ let cell_start_in_target = 0;
3402
3588
 
3403
3589
  // Determine which cell receives the comment
3404
3590
  let target_comment_idx = 0;
@@ -3414,14 +3600,10 @@ export class RedlineEngine {
3414
3600
  const n_cell = new_cells[cell_idx];
3415
3601
  const a_clean = a_cell.trim();
3416
3602
  const n_clean = n_cell.trim();
3417
-
3418
- let actual_start = search_offset;
3419
- if (a_clean) {
3420
- actual_start = this.mapper.full_text.indexOf(a_clean, search_offset);
3421
- if (actual_start === -1 || actual_start > search_offset + 10) {
3422
- actual_start = search_offset;
3423
- }
3424
- }
3603
+ const actual_start =
3604
+ start_idx +
3605
+ cell_start_in_target +
3606
+ (a_clean ? a_cell.indexOf(a_clean) : 0);
3425
3607
 
3426
3608
  const should_attach_comment = (edit.comment !== null && edit.comment !== undefined) && (cell_idx === target_comment_idx);
3427
3609
 
@@ -3441,27 +3623,18 @@ export class RedlineEngine {
3441
3623
  }
3442
3624
  }
3443
3625
 
3444
- if (a_clean) {
3445
- search_offset = actual_start + a_clean.length;
3446
- }
3447
-
3448
- const next_pipe = this.mapper.full_text.indexOf(" | ", search_offset);
3449
- if (next_pipe !== -1 && next_pipe <= search_offset + 10) {
3450
- search_offset = next_pipe + 3;
3451
- } else {
3452
- search_offset += a_cell.length + 1;
3453
- }
3626
+ cell_start_in_target += a_cell.length + 1; // +1 for the '|'
3454
3627
  }
3455
3628
 
3456
3629
  for (const sub of sub_edits) {
3457
3630
  all_sub_edits.push(sub);
3458
3631
  }
3459
3632
  continue;
3460
- } else {
3461
- throw new BatchValidationError([
3462
- `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).`
3463
- ]);
3464
3633
  }
3634
+ // Exactly one "cell": the target merely brushes a separator (its
3635
+ // match range starts or ends inside " | ") without crossing into
3636
+ // another cell's text. That is an ordinary in-cell edit — fall
3637
+ // through to the standard resolution.
3465
3638
  }
3466
3639
 
3467
3640
  let has_markdown = false;
@@ -3575,6 +3748,26 @@ export class RedlineEngine {
3575
3748
  }
3576
3749
  }
3577
3750
 
3751
+ // After trimming shared context, an edit whose target remainder is
3752
+ // EMPTY is a pure insertion with exactly one hunk. Resolve it
3753
+ // directly at the effective offset instead of word-diffing the full
3754
+ // strings: dmp's alignment can cross-match punctuation between the
3755
+ // shared context and the inserted text (pairing the period of "two."
3756
+ // with "marker.") and split the insertion apart.
3757
+ if (!final_target && final_new) {
3758
+ all_sub_edits.push({
3759
+ type: "modify",
3760
+ target_text: "",
3761
+ new_text: final_new,
3762
+ comment: edit.comment,
3763
+ _resolved_start_idx: effective_start_idx,
3764
+ _match_start_index: effective_start_idx,
3765
+ _internal_op: "INSERTION",
3766
+ _active_mapper_ref: active_mapper,
3767
+ });
3768
+ continue;
3769
+ }
3770
+
3578
3771
  const sub_edits = this._word_diff_sub_edits(
3579
3772
  actual_doc_text,
3580
3773
  current_effective_new_text,
@@ -3663,6 +3856,14 @@ export class RedlineEngine {
3663
3856
  }
3664
3857
  }
3665
3858
 
3859
+ // Explicit bold/italic markers in the edit make the markers
3860
+ // authoritative: inserted runs must not additionally inherit the replaced
3861
+ // span's emphasis (QA 2026-07-19 F-02). Keys on THIS resolved edit's
3862
+ // post-trim fields: identical markers on both sides were absorbed into
3863
+ // context (formatting unchanged — keep inheriting), and plain edits
3864
+ // fuzzy-matched onto styled text never receive marker hunks at all.
3865
+ const suppress_emphasis = this._edit_declares_emphasis(edit);
3866
+
3666
3867
  if (op === "STYLE_ONLY" || op === "STYLE_AND_TEXT") {
3667
3868
  const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
3668
3869
  start_idx,
@@ -3804,15 +4005,15 @@ export class RedlineEngine {
3804
4005
  if (op === "INSERTION") {
3805
4006
  let final_new_text = edit.new_text || "";
3806
4007
 
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.
4008
+ // A MACHINE-PINNED pure insertion (diff/text round-trip output:
4009
+ // authored with an empty target and no parent edit) positioned in the
4010
+ // separator gap between the body and a following part anchors to the
4011
+ // end of the BODY with forced new-paragraph semantics anchoring on
4012
+ // the next part's first paragraph writes the new final body paragraph
4013
+ // into word/footer1.xml. Insertions DERIVED from a target-anchored
4014
+ // edit (parent ref set — e.g. prepending "DRAFT " to "FOOTER MARKER")
4015
+ // keep the user's chosen anchor: their context names the part they
4016
+ // meant.
3816
4017
  let boundary_anchor: TextSpan | null = null;
3817
4018
  const boundary =
3818
4019
  typeof (active_mapper as any).part_boundary_at === "function"
@@ -3923,6 +4124,7 @@ export class RedlineEngine {
3923
4124
  anchor_run,
3924
4125
  ins_id!,
3925
4126
  xmlDoc,
4127
+ suppress_emphasis,
3926
4128
  );
3927
4129
  if (content_ins) new_p.appendChild(content_ins);
3928
4130
  body.insertBefore(new_p, _bug233_target_para);
@@ -3957,6 +4159,8 @@ export class RedlineEngine {
3957
4159
  anchor_run,
3958
4160
  anchor_para,
3959
4161
  ins_id!,
4162
+ null,
4163
+ suppress_emphasis,
3960
4164
  );
3961
4165
 
3962
4166
  if (!result.first_node) return false;
@@ -4168,6 +4372,10 @@ export class RedlineEngine {
4168
4372
  style_source_run,
4169
4373
  mod_anchor_para,
4170
4374
  ins_id!,
4375
+ // The insertion physically follows the deletion block; the style
4376
+ // run was detached when the deletion cloned it into <w:del>.
4377
+ last_del,
4378
+ suppress_emphasis,
4171
4379
  );
4172
4380
 
4173
4381
  if (result.first_node) {
@@ -4221,6 +4429,8 @@ export class RedlineEngine {
4221
4429
  anchor,
4222
4430
  first_span.paragraph,
4223
4431
  ins_id!,
4432
+ null,
4433
+ suppress_emphasis,
4224
4434
  );
4225
4435
  if (result.first_node) {
4226
4436
  p1_el.appendChild(result.first_node);
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@ 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, generate_structured_edits, trim_common_context, create_unified_diff, create_word_patch_diff, DiffEdit } from './diff.js';
8
+ export { generate_edits_from_text, generate_structured_edits, trim_common_context, create_unified_diff, create_word_patch_diff, collect_media_difference_warnings, DiffEdit } from './diff.js';
9
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';
package/src/ingest.ts CHANGED
@@ -347,16 +347,25 @@ export function build_paragraph_text(
347
347
  new_wrappers[0] === current_wrappers[0] &&
348
348
  new_wrappers[1] === current_wrappers[1]
349
349
  ) {
350
+ // Hoisted leading whitespace may sit before the incoming segment's
351
+ // opening marker ("**A**" + " **B**" -> "**A B**"), mirroring the
352
+ // mapper's part-level elision exactly (QA 2026-07-19 F-03).
353
+ const escaped_prefix = new_style[0].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
354
+ const lead_match =
355
+ new_style[0] !== "" || new_style[1] !== ""
356
+ ? seg.match(new RegExp("^(\\s*)" + escaped_prefix))
357
+ : null;
350
358
  if (
351
359
  new_style[0] === current_style[0] &&
352
360
  new_style[1] === current_style[1] &&
353
361
  current_style[0] !== "" &&
354
362
  pending_text.endsWith(current_style[1]) &&
355
- seg.startsWith(new_style[0])
363
+ lead_match !== null
356
364
  ) {
357
365
  pending_text =
358
366
  pending_text.slice(0, -current_style[1].length) +
359
- seg.slice(new_style[0].length);
367
+ lead_match[1] +
368
+ seg.slice(lead_match[0].length);
360
369
  } else {
361
370
  pending_text += seg;
362
371
  }