@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/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,
|
|
@@ -635,6 +685,7 @@ function _row_ops_for_table(
|
|
|
635
685
|
target_text: o_txt,
|
|
636
686
|
new_text: m_txt,
|
|
637
687
|
comment: "Diff: Table row modified",
|
|
688
|
+
_is_table_edit: true,
|
|
638
689
|
});
|
|
639
690
|
}
|
|
640
691
|
}
|
|
@@ -703,8 +754,12 @@ export function generate_structured_edits(
|
|
|
703
754
|
`${kinds_m.join(" + ") || "none"}); comparing flattened text instead. Header/footer ` +
|
|
704
755
|
"additions or removals cannot be expressed as text edits.",
|
|
705
756
|
);
|
|
706
|
-
const flat =
|
|
707
|
-
|
|
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,
|
|
708
763
|
warnings,
|
|
709
764
|
);
|
|
710
765
|
return { edits: [...flat], warnings };
|
|
@@ -785,14 +840,26 @@ export function generate_structured_edits(
|
|
|
785
840
|
if (row_opcodes !== null) {
|
|
786
841
|
edits.push(..._row_ops_for_table(t_o, t_m, row_opcodes, warnings));
|
|
787
842
|
} else {
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
843
|
+
// Cell-internal changes only (row sets align 1:1). Emit one
|
|
844
|
+
// ROW-LEVEL edit per differing row — the engine splits it into
|
|
845
|
+
// per-cell sub-edits along the " | " boundaries. A word-level diff
|
|
846
|
+
// over the whole table span produces hunks that start or end
|
|
847
|
+
// inside a cell separator, which apply into the wrong cell or
|
|
848
|
+
// write literal pipe text. Unpinned like every other table edit:
|
|
849
|
+
// pinned application bypasses the cell splitter, and the full row
|
|
850
|
+
// text is the anchor contract.
|
|
851
|
+
for (let k = 0; k < t_o.rows.length; k++) {
|
|
852
|
+
const o_txt = t_o.rows[k].cells.join(" | ");
|
|
853
|
+
const m_txt = t_m.rows[k].cells.join(" | ");
|
|
854
|
+
if (o_txt === m_txt) continue;
|
|
855
|
+
edits.push({
|
|
856
|
+
type: "modify",
|
|
857
|
+
target_text: o_txt,
|
|
858
|
+
new_text: m_txt,
|
|
859
|
+
comment: "Diff: Table row modified",
|
|
860
|
+
_is_table_edit: true,
|
|
861
|
+
});
|
|
794
862
|
}
|
|
795
|
-
edits.push(...tbl_edits);
|
|
796
863
|
}
|
|
797
864
|
}
|
|
798
865
|
}
|
|
@@ -817,7 +884,9 @@ export function generate_structured_edits(
|
|
|
817
884
|
let ambiguous_anchor_warned = false;
|
|
818
885
|
for (const e of edits) {
|
|
819
886
|
if (
|
|
820
|
-
(e.type === "insert_row" ||
|
|
887
|
+
(e.type === "insert_row" ||
|
|
888
|
+
e.type === "delete_row" ||
|
|
889
|
+
(e as any)._is_table_edit) &&
|
|
821
890
|
!ambiguous_anchor_warned
|
|
822
891
|
) {
|
|
823
892
|
if (e.target_text && countOccurrences(text_orig, e.target_text) > 1) {
|
|
@@ -833,11 +902,18 @@ export function generate_structured_edits(
|
|
|
833
902
|
}
|
|
834
903
|
|
|
835
904
|
// Our own output must never trip the engine's read-only image-marker
|
|
836
|
-
// 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.
|
|
837
907
|
const modify_edits = edits.filter(
|
|
838
908
|
(e): e is ModifyText => e.type === "modify",
|
|
839
909
|
);
|
|
840
|
-
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
|
+
);
|
|
841
917
|
const final_edits = edits.filter(
|
|
842
918
|
(e) => e.type !== "modify" || kept_modifies.has(e as ModifyText),
|
|
843
919
|
);
|
|
@@ -845,6 +921,70 @@ export function generate_structured_edits(
|
|
|
845
921
|
return { edits: final_edits, warnings };
|
|
846
922
|
}
|
|
847
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
|
+
|
|
848
988
|
export function create_unified_diff(
|
|
849
989
|
original_text: string,
|
|
850
990
|
modified_text: string,
|