@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/dist/index.cjs +478 -122
- 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 +477 -122
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/diff.ts +152 -12
- package/src/engine.ts +267 -57
- package/src/index.ts +1 -1
- package/src/ingest.ts +11 -2
- package/src/mapper.ts +99 -47
- package/src/repro_qa_report_v6.test.ts +486 -0
- package/src/repro_qa_report_v7.test.ts +380 -0
- package/src/sanitize/core.ts +60 -1
- package/src/sanitize/report.ts +5 -3
- package/src/sanitize/sanitize.test.ts +5 -4
- package/src/sanitize/transforms.ts +123 -16
- package/src/utils/docx.ts +30 -2
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
|
-
//
|
|
1428
|
-
//
|
|
1429
|
-
//
|
|
1430
|
-
|
|
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
|
|
1581
|
-
//
|
|
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
|
-
//
|
|
1587
|
-
// (
|
|
1588
|
-
//
|
|
1589
|
-
//
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
"w:
|
|
1596
|
-
|
|
1597
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2968
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
3808
|
-
//
|
|
3809
|
-
//
|
|
3810
|
-
//
|
|
3811
|
-
//
|
|
3812
|
-
//
|
|
3813
|
-
//
|
|
3814
|
-
//
|
|
3815
|
-
//
|
|
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
|
-
|
|
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
|
}
|