@adeu/core 1.25.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/dist/index.cjs +298 -74
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +24 -1
- package/dist/index.d.ts +24 -1
- package/dist/index.js +296 -73
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/diff.ts +129 -4
- package/src/engine.ts +135 -18
- package/src/index.ts +1 -1
- package/src/ingest.ts +11 -2
- package/src/mapper.ts +99 -47
- package/src/repro_qa_report_v7.test.ts +380 -0
- package/src/sanitize/core.ts +3 -0
- package/src/sanitize/transforms.ts +29 -0
- package/src/utils/docx.ts +30 -2
package/package.json
CHANGED
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 ``). 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
|
+
"() 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 =
|
|
708
|
-
|
|
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(
|
|
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,10 @@ 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,
|
|
1402
1426
|
): {
|
|
1403
1427
|
first_node: Element | null;
|
|
1404
1428
|
last_p: Element | null;
|
|
@@ -1497,6 +1521,7 @@ export class RedlineEngine {
|
|
|
1497
1521
|
anchor_run,
|
|
1498
1522
|
reuse_id,
|
|
1499
1523
|
xmlDoc,
|
|
1524
|
+
suppress_emphasis,
|
|
1500
1525
|
);
|
|
1501
1526
|
first_node = inline_ins;
|
|
1502
1527
|
// Caller will attach `inline_ins` to the DOM later — keep it for now.
|
|
@@ -1582,6 +1607,7 @@ export class RedlineEngine {
|
|
|
1582
1607
|
anchor_run,
|
|
1583
1608
|
reuse_id,
|
|
1584
1609
|
xmlDoc,
|
|
1610
|
+
suppress_emphasis,
|
|
1585
1611
|
);
|
|
1586
1612
|
if (content_ins) {
|
|
1587
1613
|
new_p.appendChild(content_ins);
|
|
@@ -1621,6 +1647,7 @@ export class RedlineEngine {
|
|
|
1621
1647
|
anchor_run: Run | null,
|
|
1622
1648
|
reuse_id: string,
|
|
1623
1649
|
xmlDoc: Document,
|
|
1650
|
+
suppress_emphasis: boolean = false,
|
|
1624
1651
|
): Element | null {
|
|
1625
1652
|
if (!line_text && line_text !== "") return null;
|
|
1626
1653
|
const ins = this._create_track_change_tag("w:ins", "", reuse_id);
|
|
@@ -1630,25 +1657,26 @@ export class RedlineEngine {
|
|
|
1630
1657
|
}
|
|
1631
1658
|
for (const [segText, segProps] of segments) {
|
|
1632
1659
|
const r = xmlDoc.createElement("w:r");
|
|
1633
|
-
// Inherit run formatting
|
|
1634
|
-
//
|
|
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).
|
|
1635
1664
|
if (anchor_run && anchor_run._element) {
|
|
1636
1665
|
const anchor_rPr = findChild(anchor_run._element, "w:rPr");
|
|
1637
1666
|
if (anchor_rPr) {
|
|
1638
1667
|
const clone = anchor_rPr.cloneNode(true) as Element;
|
|
1639
|
-
//
|
|
1640
|
-
// (
|
|
1641
|
-
//
|
|
1642
|
-
//
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
"w:
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
]) {
|
|
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) {
|
|
1652
1680
|
const found = findChild(clone, tag);
|
|
1653
1681
|
if (found) clone.removeChild(found);
|
|
1654
1682
|
}
|
|
@@ -1695,6 +1723,28 @@ export class RedlineEngine {
|
|
|
1695
1723
|
return [text, null];
|
|
1696
1724
|
}
|
|
1697
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
|
+
|
|
1698
1748
|
private _parse_inline_markdown(
|
|
1699
1749
|
text: string,
|
|
1700
1750
|
baseStyle: any = {},
|
|
@@ -2037,6 +2087,23 @@ export class RedlineEngine {
|
|
|
2037
2087
|
const is_regex = (edit as any).regex || false;
|
|
2038
2088
|
const match_mode = (edit as any).match_mode || "strict";
|
|
2039
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
|
+
|
|
2040
2107
|
let matches = this.mapper.find_all_match_indices(
|
|
2041
2108
|
edit.target_text,
|
|
2042
2109
|
is_regex,
|
|
@@ -2855,6 +2922,14 @@ export class RedlineEngine {
|
|
|
2855
2922
|
actions_skipped: skipped_actions,
|
|
2856
2923
|
edits_applied: applied_edits,
|
|
2857
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
|
+
),
|
|
2858
2933
|
skipped_details: this.skipped_details,
|
|
2859
2934
|
edits: edits_reports,
|
|
2860
2935
|
engine: "node",
|
|
@@ -2882,6 +2957,7 @@ export class RedlineEngine {
|
|
|
2882
2957
|
}
|
|
2883
2958
|
edit._applied_status = false;
|
|
2884
2959
|
edit._error_msg = null;
|
|
2960
|
+
edit._any_sub_failure = false;
|
|
2885
2961
|
}
|
|
2886
2962
|
|
|
2887
2963
|
for (const edit of edits) {
|
|
@@ -3020,18 +3096,25 @@ export class RedlineEngine {
|
|
|
3020
3096
|
this.skipped_details.push(msg);
|
|
3021
3097
|
edit._applied_status = false;
|
|
3022
3098
|
edit._error_msg = msg;
|
|
3099
|
+
edit._any_sub_failure = true;
|
|
3023
3100
|
const parent = edit._parent_edit_ref;
|
|
3024
3101
|
if (parent) {
|
|
3025
3102
|
parent._applied_status = false;
|
|
3026
3103
|
parent._error_msg = msg;
|
|
3104
|
+
parent._any_sub_failure = true;
|
|
3027
3105
|
}
|
|
3028
3106
|
continue;
|
|
3029
3107
|
}
|
|
3030
3108
|
|
|
3031
3109
|
let success = false;
|
|
3032
3110
|
if (edit.type === "modify") {
|
|
3033
|
-
|
|
3034
|
-
|
|
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);
|
|
3035
3118
|
} else if (edit.type === "insert_row" || edit.type === "delete_row") {
|
|
3036
3119
|
success = this._apply_table_edit(edit, false);
|
|
3037
3120
|
}
|
|
@@ -3089,8 +3172,10 @@ export class RedlineEngine {
|
|
|
3089
3172
|
this.skipped_details.push(msg);
|
|
3090
3173
|
edit._applied_status = false;
|
|
3091
3174
|
edit._error_msg = msg;
|
|
3175
|
+
edit._any_sub_failure = true;
|
|
3092
3176
|
const parent = edit._parent_edit_ref;
|
|
3093
3177
|
if (parent) {
|
|
3178
|
+
parent._any_sub_failure = true;
|
|
3094
3179
|
if (!parent._applied_status) {
|
|
3095
3180
|
parent._applied_status = false;
|
|
3096
3181
|
parent._error_msg = msg;
|
|
@@ -3099,7 +3184,25 @@ export class RedlineEngine {
|
|
|
3099
3184
|
}
|
|
3100
3185
|
}
|
|
3101
3186
|
|
|
3102
|
-
|
|
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];
|
|
3103
3206
|
}
|
|
3104
3207
|
|
|
3105
3208
|
public apply_review_actions(actions: any[]): [number, number] {
|
|
@@ -3753,6 +3856,14 @@ export class RedlineEngine {
|
|
|
3753
3856
|
}
|
|
3754
3857
|
}
|
|
3755
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
|
+
|
|
3756
3867
|
if (op === "STYLE_ONLY" || op === "STYLE_AND_TEXT") {
|
|
3757
3868
|
const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
|
|
3758
3869
|
start_idx,
|
|
@@ -4013,6 +4124,7 @@ export class RedlineEngine {
|
|
|
4013
4124
|
anchor_run,
|
|
4014
4125
|
ins_id!,
|
|
4015
4126
|
xmlDoc,
|
|
4127
|
+
suppress_emphasis,
|
|
4016
4128
|
);
|
|
4017
4129
|
if (content_ins) new_p.appendChild(content_ins);
|
|
4018
4130
|
body.insertBefore(new_p, _bug233_target_para);
|
|
@@ -4047,6 +4159,8 @@ export class RedlineEngine {
|
|
|
4047
4159
|
anchor_run,
|
|
4048
4160
|
anchor_para,
|
|
4049
4161
|
ins_id!,
|
|
4162
|
+
null,
|
|
4163
|
+
suppress_emphasis,
|
|
4050
4164
|
);
|
|
4051
4165
|
|
|
4052
4166
|
if (!result.first_node) return false;
|
|
@@ -4261,6 +4375,7 @@ export class RedlineEngine {
|
|
|
4261
4375
|
// The insertion physically follows the deletion block; the style
|
|
4262
4376
|
// run was detached when the deletion cloned it into <w:del>.
|
|
4263
4377
|
last_del,
|
|
4378
|
+
suppress_emphasis,
|
|
4264
4379
|
);
|
|
4265
4380
|
|
|
4266
4381
|
if (result.first_node) {
|
|
@@ -4314,6 +4429,8 @@ export class RedlineEngine {
|
|
|
4314
4429
|
anchor,
|
|
4315
4430
|
first_span.paragraph,
|
|
4316
4431
|
ins_id!,
|
|
4432
|
+
null,
|
|
4433
|
+
suppress_emphasis,
|
|
4317
4434
|
);
|
|
4318
4435
|
if (result.first_node) {
|
|
4319
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
|
-
|
|
363
|
+
lead_match !== null
|
|
356
364
|
) {
|
|
357
365
|
pending_text =
|
|
358
366
|
pending_text.slice(0, -current_style[1].length) +
|
|
359
|
-
|
|
367
|
+
lead_match[1] +
|
|
368
|
+
seg.slice(lead_match[0].length);
|
|
360
369
|
} else {
|
|
361
370
|
pending_text += seg;
|
|
362
371
|
}
|