@adeu/core 1.25.0 → 1.27.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adeu/core",
3
- "version": "1.25.0",
3
+ "version": "1.27.0",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/diff.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { unzipSync } from "fflate";
1
2
  import diff_match_patch from "diff-match-patch";
2
3
  import { DeleteTableRow, InsertTableRow, ModifyText } from "./models.js";
3
4
  import type {
@@ -475,6 +476,55 @@ function _drop_image_marker_hunks(
475
476
  return kept;
476
477
  }
477
478
 
479
+ /**
480
+ * Removes generated hunks whose pinned range cuts INTO a read-only image
481
+ * marker without covering it whole — the shape an alt-text-only difference
482
+ * produces (`RED` -> `BLUE` inside `![RED logo](docx-image:1)`). The engine
483
+ * categorically rejects such edits, so emitting them makes diff output
484
+ * unappliable by apply (QA 2026-07-19 F-04/F-14). Hunks that contain
485
+ * complete markers are judged by _drop_image_marker_hunks instead.
486
+ */
487
+ function _drop_marker_interior_hunks(
488
+ edits: ModifyText[],
489
+ text_orig: string,
490
+ warnings: string[],
491
+ ): ModifyText[] {
492
+ const marker_spans: [number, number][] = [];
493
+ for (const m of text_orig.matchAll(_IMAGE_MARKER_RE)) {
494
+ marker_spans.push([m.index!, m.index! + m[0].length]);
495
+ }
496
+ if (marker_spans.length === 0) return edits;
497
+
498
+ const kept: ModifyText[] = [];
499
+ let warned = false;
500
+ for (const e of edits) {
501
+ const idx = e._match_start_index;
502
+ if (idx === undefined || idx === null) {
503
+ kept.push(e);
504
+ continue;
505
+ }
506
+ const end = idx + (e.target_text || "").length;
507
+ const cuts_into_marker = marker_spans.some(
508
+ ([m_start, m_end]) =>
509
+ m_start < end && idx < m_end && !(idx <= m_start && m_end <= end),
510
+ );
511
+ if (!cuts_into_marker) {
512
+ kept.push(e);
513
+ continue;
514
+ }
515
+ if (!warned) {
516
+ warnings.push(
517
+ "An image's alternative text differs between the documents. Image markers " +
518
+ "(![alt](docx-image:N)) are read-only projections, so this difference cannot be " +
519
+ "expressed as a text edit — it was skipped; update the image or its alt text " +
520
+ "manually in Word.",
521
+ );
522
+ warned = true;
523
+ }
524
+ }
525
+ return kept;
526
+ }
527
+
478
528
  /**
479
529
  * True when every projected row reads exactly as its cells joined by " | ".
480
530
  * Rows wrapped in tracked-row CriticMarkup ({++ … ++} / {-- … --}) do not,
@@ -704,8 +754,12 @@ export function generate_structured_edits(
704
754
  `${kinds_m.join(" + ") || "none"}); comparing flattened text instead. Header/footer ` +
705
755
  "additions or removals cannot be expressed as text edits.",
706
756
  );
707
- const flat = _drop_image_marker_hunks(
708
- generate_edits_from_text(text_orig, text_mod),
757
+ const flat = _drop_marker_interior_hunks(
758
+ _drop_image_marker_hunks(
759
+ generate_edits_from_text(text_orig, text_mod),
760
+ warnings,
761
+ ),
762
+ text_orig,
709
763
  warnings,
710
764
  );
711
765
  return { edits: [...flat], warnings };
@@ -848,11 +902,18 @@ export function generate_structured_edits(
848
902
  }
849
903
 
850
904
  // Our own output must never trip the engine's read-only image-marker
851
- // validation: an added/removed image becomes a warning, not an edit.
905
+ // validation: an added/removed image becomes a warning, not an edit
906
+ // and so does an alt-text change, whose hunk lands INSIDE a marker.
852
907
  const modify_edits = edits.filter(
853
908
  (e): e is ModifyText => e.type === "modify",
854
909
  );
855
- const kept_modifies = new Set(_drop_image_marker_hunks(modify_edits, warnings));
910
+ const kept_modifies = new Set(
911
+ _drop_marker_interior_hunks(
912
+ _drop_image_marker_hunks(modify_edits, warnings),
913
+ text_orig,
914
+ warnings,
915
+ ),
916
+ );
856
917
  const final_edits = edits.filter(
857
918
  (e) => e.type !== "modify" || kept_modifies.has(e as ModifyText),
858
919
  );
@@ -860,6 +921,70 @@ export function generate_structured_edits(
860
921
  return { edits: final_edits, warnings };
861
922
  }
862
923
 
924
+ /**
925
+ * Compares embedded media bytes (word/media/*) between two DOCX packages.
926
+ * Adeu's diff is a text comparison: two documents whose images differ but
927
+ * whose projections agree produce an empty diff, which reads as "visually
928
+ * identical" unless the caller says otherwise (QA 2026-07-19 F-04). Returns
929
+ * warning strings describing changed/added/removed media members; empty when
930
+ * the media sets are byte-identical (or either package is unreadable — the
931
+ * caller has already surfaced package-level errors).
932
+ */
933
+ export function collect_media_difference_warnings(
934
+ original_docx: Uint8Array,
935
+ modified_docx: Uint8Array,
936
+ ): string[] {
937
+ const media_hashes = (data: Uint8Array): Map<string, string> => {
938
+ const hashes = new Map<string, string>();
939
+ try {
940
+ const unzipped = unzipSync(data);
941
+ for (const [name, bytes] of Object.entries(unzipped)) {
942
+ if (!name.startsWith("word/media/")) continue;
943
+ // FNV-1a over the bytes: cheap, dependency-free content fingerprint.
944
+ let h1 = 0x811c9dc5;
945
+ let h2 = 0xcbf29ce4;
946
+ for (let i = 0; i < bytes.length; i++) {
947
+ h1 = Math.imul(h1 ^ bytes[i], 0x01000193) >>> 0;
948
+ h2 = Math.imul(h2 ^ bytes[bytes.length - 1 - i], 0x01000193) >>> 0;
949
+ }
950
+ hashes.set(name, `${bytes.length}:${h1.toString(16)}:${h2.toString(16)}`);
951
+ }
952
+ } catch {
953
+ return new Map();
954
+ }
955
+ return hashes;
956
+ };
957
+
958
+ const hashes_orig = media_hashes(original_docx);
959
+ const hashes_mod = media_hashes(modified_docx);
960
+
961
+ const changed: string[] = [];
962
+ const added: string[] = [];
963
+ const removed: string[] = [];
964
+ for (const [name, hash] of hashes_orig) {
965
+ if (!hashes_mod.has(name)) removed.push(name);
966
+ else if (hashes_mod.get(name) !== hash) changed.push(name);
967
+ }
968
+ for (const name of hashes_mod.keys()) {
969
+ if (!hashes_orig.has(name)) added.push(name);
970
+ }
971
+ changed.sort();
972
+ added.sort();
973
+ removed.sort();
974
+ if (changed.length + added.length + removed.length === 0) return [];
975
+
976
+ const parts: string[] = [];
977
+ if (changed.length) parts.push(`${changed.length} changed`);
978
+ if (added.length) parts.push(`${added.length} added`);
979
+ if (removed.length) parts.push(`${removed.length} removed`);
980
+ const names = [...changed, ...added, ...removed].slice(0, 5).join(", ");
981
+ return [
982
+ `The documents' embedded media differ (${parts.join(", ")}: ${names}). This diff compares ` +
983
+ "TEXT only — an empty edit list does not mean the documents are visually identical. " +
984
+ "Image changes must be applied manually in Word.",
985
+ ];
986
+ }
987
+
863
988
  export function create_unified_diff(
864
989
  original_text: string,
865
990
  modified_text: string,
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",
@@ -1399,6 +1419,16 @@ export class RedlineEngine {
1399
1419
  // runs into <w:del> and replaces the originals); suffix relocation for
1400
1420
  // paragraph-splitting insertions keys on this element instead.
1401
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,
1426
+ // True when the caller will attach the insertion BEFORE the anchor
1427
+ // (paragraph-start insertions): the anchor itself then belongs to the
1428
+ // relocating suffix (hunt-profile counterexample, 2026-07-19 —
1429
+ // "00." + insert "0.\n\n0 " must read "0.\n\n0 00.", never
1430
+ // "0.00.\n\n0 "). Mirrors the Python engine.
1431
+ insert_before: boolean = false,
1402
1432
  ): {
1403
1433
  first_node: Element | null;
1404
1434
  last_p: Element | null;
@@ -1435,12 +1465,21 @@ export class RedlineEngine {
1435
1465
  // DOM, and the insertion lands immediately after it, so its following
1436
1466
  // child-of-paragraph siblings are exactly the suffix.
1437
1467
  const suffix_nodes: Element[] = [];
1438
- const pos_source =
1468
+ const relocatable = new Set(["w:r", "w:ins", "w:del"]);
1469
+ // insert_before with a RUN anchor: the insertion precedes the anchor, so
1470
+ // the anchor run itself is part of the suffix. (The explicit
1471
+ // positional_anchor_el is only passed by flows that insert AFTER it.)
1472
+ const pos_from_positional =
1439
1473
  positional_anchor_el && positional_anchor_el.parentNode
1440
1474
  ? positional_anchor_el
1441
- : anchor_run !== null && anchor_run._element.parentNode
1442
- ? anchor_run._element
1443
- : null;
1475
+ : null;
1476
+ const pos_from_anchor_run =
1477
+ anchor_run !== null && anchor_run._element.parentNode
1478
+ ? anchor_run._element
1479
+ : null;
1480
+ const pos_source = pos_from_positional ?? pos_from_anchor_run;
1481
+ const suffix_includes_anchor =
1482
+ insert_before && pos_from_positional === null && pos_from_anchor_run !== null;
1444
1483
  if (current_p !== null && pos_source !== null) {
1445
1484
  let pos_anchor: Element | null = pos_source;
1446
1485
  while (pos_anchor && pos_anchor.parentNode !== current_p) {
@@ -1451,8 +1490,9 @@ export class RedlineEngine {
1451
1490
  }
1452
1491
  }
1453
1492
  if (pos_anchor) {
1454
- const relocatable = new Set(["w:r", "w:ins", "w:del"]);
1455
- let nxt = pos_anchor.nextSibling;
1493
+ let nxt: Node | null = suffix_includes_anchor
1494
+ ? pos_anchor
1495
+ : pos_anchor.nextSibling;
1456
1496
  while (nxt) {
1457
1497
  if (nxt.nodeType === 1 && relocatable.has((nxt as Element).tagName)) {
1458
1498
  suffix_nodes.push(nxt as Element);
@@ -1460,6 +1500,20 @@ export class RedlineEngine {
1460
1500
  nxt = nxt.nextSibling;
1461
1501
  }
1462
1502
  }
1503
+ } else if (current_p !== null && insert_before) {
1504
+ // No attached anchor run at all (paragraph-anchored insertion at
1505
+ // paragraph START): everything in the host paragraph follows the
1506
+ // insertion point, so it all relocates (mirrors the Python engine).
1507
+ let child = current_p.firstChild;
1508
+ while (child) {
1509
+ if (
1510
+ child.nodeType === 1 &&
1511
+ relocatable.has((child as Element).tagName)
1512
+ ) {
1513
+ suffix_nodes.push(child as Element);
1514
+ }
1515
+ child = child.nextSibling;
1516
+ }
1463
1517
  }
1464
1518
 
1465
1519
  // Drop the trailing empty line ONLY when there is no suffix to relocate.
@@ -1497,6 +1551,7 @@ export class RedlineEngine {
1497
1551
  anchor_run,
1498
1552
  reuse_id,
1499
1553
  xmlDoc,
1554
+ suppress_emphasis,
1500
1555
  );
1501
1556
  first_node = inline_ins;
1502
1557
  // Caller will attach `inline_ins` to the DOM later — keep it for now.
@@ -1582,6 +1637,7 @@ export class RedlineEngine {
1582
1637
  anchor_run,
1583
1638
  reuse_id,
1584
1639
  xmlDoc,
1640
+ suppress_emphasis,
1585
1641
  );
1586
1642
  if (content_ins) {
1587
1643
  new_p.appendChild(content_ins);
@@ -1621,6 +1677,7 @@ export class RedlineEngine {
1621
1677
  anchor_run: Run | null,
1622
1678
  reuse_id: string,
1623
1679
  xmlDoc: Document,
1680
+ suppress_emphasis: boolean = false,
1624
1681
  ): Element | null {
1625
1682
  if (!line_text && line_text !== "") return null;
1626
1683
  const ins = this._create_track_change_tag("w:ins", "", reuse_id);
@@ -1630,25 +1687,26 @@ export class RedlineEngine {
1630
1687
  }
1631
1688
  for (const [segText, segProps] of segments) {
1632
1689
  const r = xmlDoc.createElement("w:r");
1633
- // Inherit run formatting (e.g. bold from a heading style) only when we
1634
- // have an anchor run AND we are not overriding via segment props.
1690
+ // Inherit run formatting from the anchor so partial replacements inside
1691
+ // a styled span keep the style (matching Word's type-into-selection
1692
+ // behavior and the Python engine — the old blanket strip made
1693
+ // "Important" -> "Critical" inside a bold span come out unstyled).
1635
1694
  if (anchor_run && anchor_run._element) {
1636
1695
  const anchor_rPr = findChild(anchor_run._element, "w:rPr");
1637
1696
  if (anchor_rPr) {
1638
1697
  const clone = anchor_rPr.cloneNode(true) as Element;
1639
- // Strip vanish / strike to avoid invisible inserts, and emphasis
1640
- // (bold/italic) so inserted replacement text does not silently
1641
- // inherit the anchor run's character formatting (BUG-23-2). Explicit
1642
- // markdown emphasis is re-applied per-segment via _apply_run_props.
1643
- for (const tag of [
1644
- "w:vanish",
1645
- "w:strike",
1646
- "w:dstrike",
1647
- "w:i",
1648
- "w:iCs",
1649
- "w:b",
1650
- "w:bCs",
1651
- ]) {
1698
+ // Always strip vanish / strike (invisible inserts) and italic
1699
+ // (BUG-23-2: an inserted replacement must not silently inherit the
1700
+ // surrounding italic styling). Bold is preserved it usually
1701
+ // carries structural meaning (headings, defined terms) — UNLESS
1702
+ // the edit's own markers are authoritative (QA 2026-07-19 F-02):
1703
+ // `**X**` -> `_X_` must yield italic-only, `**X**` -> `X` plain.
1704
+ // Mirrors the Python engine's _track_insert_inline exactly.
1705
+ const strip_tags = ["w:vanish", "w:strike", "w:dstrike", "w:i", "w:iCs"];
1706
+ if (suppress_emphasis) {
1707
+ strip_tags.push("w:b", "w:bCs");
1708
+ }
1709
+ for (const tag of strip_tags) {
1652
1710
  const found = findChild(clone, tag);
1653
1711
  if (found) clone.removeChild(found);
1654
1712
  }
@@ -1695,6 +1753,28 @@ export class RedlineEngine {
1695
1753
  return [text, null];
1696
1754
  }
1697
1755
 
1756
+ /**
1757
+ * True when this edit's target or replacement text carries explicit
1758
+ * bold/italic markers, making the markers AUTHORITATIVE for the inserted
1759
+ * runs' formatting. Replacing `**X**` with `_X_` must yield italic-only
1760
+ * text, and replacing `**X**` with `X` must yield plain text — inheriting
1761
+ * the replaced span's run properties on top of (or instead of) the
1762
+ * requested markers silently produces the wrong document while the report
1763
+ * claims success (QA 2026-07-19 F-02). Plain-text edits (no markers on
1764
+ * either side) keep inheriting the context style so partial replacements
1765
+ * inside a styled span never lose formatting.
1766
+ */
1767
+ private _edit_declares_emphasis(edit: any): boolean {
1768
+ for (const text of [edit?.target_text, edit?.new_text]) {
1769
+ if (!text || (!text.includes("**") && !text.includes("_"))) continue;
1770
+ const segments = this._parse_inline_markdown(text);
1771
+ if (segments.some(([, props]: [string, any]) => props && Object.keys(props).length > 0)) {
1772
+ return true;
1773
+ }
1774
+ }
1775
+ return false;
1776
+ }
1777
+
1698
1778
  private _parse_inline_markdown(
1699
1779
  text: string,
1700
1780
  baseStyle: any = {},
@@ -2037,6 +2117,23 @@ export class RedlineEngine {
2037
2117
  const is_regex = (edit as any).regex || false;
2038
2118
  const match_mode = (edit as any).match_mode || "strict";
2039
2119
 
2120
+ if (is_regex) {
2121
+ // An unparsable pattern must be diagnosed as a regex problem. Without
2122
+ // this check it falls through the matcher's silent guard and surfaces
2123
+ // as "target text not found", sending the user hunting for a typo in
2124
+ // the document instead of in the pattern (QA 2026-07-19 F-13).
2125
+ try {
2126
+ new RegExp(edit.target_text);
2127
+ } catch (regex_err: any) {
2128
+ errors.push(
2129
+ `- Edit ${i + 1 + index_offset} Failed: target_text is not a valid regular expression ` +
2130
+ `(${regex_err?.message ?? regex_err}). Fix the pattern, or set "regex": false to ` +
2131
+ "match the text literally.",
2132
+ );
2133
+ continue;
2134
+ }
2135
+ }
2136
+
2040
2137
  let matches = this.mapper.find_all_match_indices(
2041
2138
  edit.target_text,
2042
2139
  is_regex,
@@ -2456,6 +2553,60 @@ export class RedlineEngine {
2456
2553
 
2457
2554
  public validate_review_actions(actions: any[]): string[] {
2458
2555
  const errors: string[] = [];
2556
+
2557
+ // Document-context-free shape checks (QA 2026-07-19 v8 F-07), mirroring
2558
+ // Python's validate_review_action_batch: blank replies render as empty
2559
+ // Word comment bubbles; a duplicated or conflicting accept/reject on one
2560
+ // target_id either double-counts as "applied" or contradicts itself.
2561
+ // Distinct IDs one action resolves as a group (a modification's del+ins
2562
+ // pair) stay legitimate, as do DIFFERENT replies to the same comment.
2563
+ const seen_resolutions = new Map<string, [number, string]>();
2564
+ const seen_replies = new Set<string>();
2565
+ for (let i = 0; i < actions.length; i++) {
2566
+ const action = actions[i];
2567
+ const type = action.type;
2568
+ const target_id = action.target_id ?? "";
2569
+ if (type === "reply") {
2570
+ if (!String(action.text ?? "").trim()) {
2571
+ errors.push(
2572
+ `- Action ${i + 1} Failed: reply text for ${target_id} is empty or ` +
2573
+ `whitespace-only. Word would show a blank comment bubble — provide the ` +
2574
+ `reply content in 'text'.`,
2575
+ );
2576
+ continue;
2577
+ }
2578
+ const reply_key = `${target_id}${String(action.text).trim()}`;
2579
+ if (seen_replies.has(reply_key)) {
2580
+ errors.push(
2581
+ `- Action ${i + 1} Failed: duplicate reply — this batch already replies to ` +
2582
+ `${target_id} with the same text. Remove the duplicate action.`,
2583
+ );
2584
+ }
2585
+ seen_replies.add(reply_key);
2586
+ } else if (type === "accept" || type === "reject") {
2587
+ const prior = seen_resolutions.get(target_id);
2588
+ if (prior !== undefined) {
2589
+ const [first_idx, first_type] = prior;
2590
+ if (first_type === type) {
2591
+ errors.push(
2592
+ `- Action ${i + 1} Failed: duplicate action — Action ${first_idx + 1} in this ` +
2593
+ `batch already applies '${type}' to ${target_id}. A change can only be ` +
2594
+ `resolved once; remove the duplicate action.`,
2595
+ );
2596
+ } else {
2597
+ errors.push(
2598
+ `- Action ${i + 1} Failed: conflicting actions — Action ${first_idx + 1} in ` +
2599
+ `this batch applies '${first_type}' to ${target_id}, but this action applies ` +
2600
+ `'${type}'. Decide the outcome and keep exactly one of them.`,
2601
+ );
2602
+ }
2603
+ } else {
2604
+ seen_resolutions.set(target_id, [i, type]);
2605
+ }
2606
+ }
2607
+ }
2608
+ if (errors.length > 0) return errors;
2609
+
2459
2610
  for (let i = 0; i < actions.length; i++) {
2460
2611
  const action = actions[i];
2461
2612
  const type = action.type;
@@ -2855,6 +3006,14 @@ export class RedlineEngine {
2855
3006
  actions_skipped: skipped_actions,
2856
3007
  edits_applied: applied_edits,
2857
3008
  edits_skipped: skipped_edits,
3009
+ // edits_applied counts change OBJECTS; this is the total number of
3010
+ // document occurrences they modified (match_mode="all" fan-out), so
3011
+ // automation never has to guess which of the two a count means
3012
+ // (QA 2026-07-19 F-21).
3013
+ occurrences_modified: edits_reports.reduce(
3014
+ (sum: number, r: any) => sum + (r.occurrences_modified || 0),
3015
+ 0,
3016
+ ),
2858
3017
  skipped_details: this.skipped_details,
2859
3018
  edits: edits_reports,
2860
3019
  engine: "node",
@@ -2882,6 +3041,7 @@ export class RedlineEngine {
2882
3041
  }
2883
3042
  edit._applied_status = false;
2884
3043
  edit._error_msg = null;
3044
+ edit._any_sub_failure = false;
2885
3045
  }
2886
3046
 
2887
3047
  for (const edit of edits) {
@@ -3020,18 +3180,25 @@ export class RedlineEngine {
3020
3180
  this.skipped_details.push(msg);
3021
3181
  edit._applied_status = false;
3022
3182
  edit._error_msg = msg;
3183
+ edit._any_sub_failure = true;
3023
3184
  const parent = edit._parent_edit_ref;
3024
3185
  if (parent) {
3025
3186
  parent._applied_status = false;
3026
3187
  parent._error_msg = msg;
3188
+ parent._any_sub_failure = true;
3027
3189
  }
3028
3190
  continue;
3029
3191
  }
3030
3192
 
3031
3193
  let success = false;
3032
3194
  if (edit.type === "modify") {
3033
- const rebuild = edit._split_group_id !== undefined && edit._split_group_id !== null;
3034
- success = this._apply_single_edit_indexed(edit, orig_new, rebuild);
3195
+ // Never rebuild the map inside the sweep: sub-edits apply in strictly
3196
+ // descending offset order, and every DOM mutation (run splits, w:del
3197
+ // wraps, w:ins insertions, bottom-up paragraph merges) happens at or
3198
+ // above the current offset, so spans below it stay valid in the stale
3199
+ // map. Rebuilding here made regex + match_mode="all"
3200
+ // O(occurrences × document) (QA 2026-07-19 F-06).
3201
+ success = this._apply_single_edit_indexed(edit, orig_new, false);
3035
3202
  } else if (edit.type === "insert_row" || edit.type === "delete_row") {
3036
3203
  success = this._apply_table_edit(edit, false);
3037
3204
  }
@@ -3089,8 +3256,10 @@ export class RedlineEngine {
3089
3256
  this.skipped_details.push(msg);
3090
3257
  edit._applied_status = false;
3091
3258
  edit._error_msg = msg;
3259
+ edit._any_sub_failure = true;
3092
3260
  const parent = edit._parent_edit_ref;
3093
3261
  if (parent) {
3262
+ parent._any_sub_failure = true;
3094
3263
  if (!parent._applied_status) {
3095
3264
  parent._applied_status = false;
3096
3265
  parent._error_msg = msg;
@@ -3099,7 +3268,25 @@ export class RedlineEngine {
3099
3268
  }
3100
3269
  }
3101
3270
 
3102
- return [applied, skipped];
3271
+ // Return LOGICAL edit counts over the caller's input list: one
3272
+ // match_mode="all" edit over N occurrences is one applied edit (its
3273
+ // occurrence count lives in _occurrences_modified / the report), never N
3274
+ // (QA 2026-07-19 F-21). An edit with any failed or skipped sub-edit
3275
+ // counts as skipped so the all-or-nothing batch contract is unchanged.
3276
+ let applied_logical = 0;
3277
+ let skipped_logical = 0;
3278
+ for (const input_edit of edits) {
3279
+ if (typeof input_edit !== "object" || input_edit === null) {
3280
+ skipped_logical++;
3281
+ continue;
3282
+ }
3283
+ if (input_edit._applied_status && !input_edit._any_sub_failure) {
3284
+ applied_logical++;
3285
+ } else {
3286
+ skipped_logical++;
3287
+ }
3288
+ }
3289
+ return [applied_logical, skipped_logical];
3103
3290
  }
3104
3291
 
3105
3292
  public apply_review_actions(actions: any[]): [number, number] {
@@ -3753,6 +3940,14 @@ export class RedlineEngine {
3753
3940
  }
3754
3941
  }
3755
3942
 
3943
+ // Explicit bold/italic markers in the edit make the markers
3944
+ // authoritative: inserted runs must not additionally inherit the replaced
3945
+ // span's emphasis (QA 2026-07-19 F-02). Keys on THIS resolved edit's
3946
+ // post-trim fields: identical markers on both sides were absorbed into
3947
+ // context (formatting unchanged — keep inheriting), and plain edits
3948
+ // fuzzy-matched onto styled text never receive marker hunks at all.
3949
+ const suppress_emphasis = this._edit_declares_emphasis(edit);
3950
+
3756
3951
  if (op === "STYLE_ONLY" || op === "STYLE_AND_TEXT") {
3757
3952
  const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
3758
3953
  start_idx,
@@ -4013,6 +4208,7 @@ export class RedlineEngine {
4013
4208
  anchor_run,
4014
4209
  ins_id!,
4015
4210
  xmlDoc,
4211
+ suppress_emphasis,
4016
4212
  );
4017
4213
  if (content_ins) new_p.appendChild(content_ins);
4018
4214
  body.insertBefore(new_p, _bug233_target_para);
@@ -4047,6 +4243,11 @@ export class RedlineEngine {
4047
4243
  anchor_run,
4048
4244
  anchor_para,
4049
4245
  ins_id!,
4246
+ null,
4247
+ suppress_emphasis,
4248
+ // Paragraph-start insertions attach BEFORE the anchor (see
4249
+ // before_anchor below): the suffix relocation must know.
4250
+ start_idx === 0,
4050
4251
  );
4051
4252
 
4052
4253
  if (!result.first_node) return false;
@@ -4261,6 +4462,7 @@ export class RedlineEngine {
4261
4462
  // The insertion physically follows the deletion block; the style
4262
4463
  // run was detached when the deletion cloned it into <w:del>.
4263
4464
  last_del,
4465
+ suppress_emphasis,
4264
4466
  );
4265
4467
 
4266
4468
  if (result.first_node) {
@@ -4314,6 +4516,8 @@ export class RedlineEngine {
4314
4516
  anchor,
4315
4517
  first_span.paragraph,
4316
4518
  ins_id!,
4519
+ null,
4520
+ suppress_emphasis,
4317
4521
  );
4318
4522
  if (result.first_node) {
4319
4523
  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
  }