@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/dist/index.cjs +354 -77
- 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 +352 -76
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/diff.ts +129 -4
- package/src/engine.ts +228 -24
- package/src/index.ts +1 -1
- package/src/ingest.ts +11 -2
- package/src/mapper.ts +107 -47
- package/src/repro_qa_report_v7.test.ts +380 -0
- package/src/repro_qa_report_v8.test.ts +265 -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,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
|
|
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
|
-
:
|
|
1442
|
-
|
|
1443
|
-
|
|
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
|
-
|
|
1455
|
-
|
|
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
|
|
1634
|
-
//
|
|
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
|
-
//
|
|
1640
|
-
// (
|
|
1641
|
-
//
|
|
1642
|
-
//
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
"w:
|
|
1649
|
-
|
|
1650
|
-
|
|
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
|
-
|
|
3034
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|