@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adeu/core",
3
- "version": "1.23.0",
3
+ "version": "1.26.0",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/diff.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { unzipSync } from "fflate";
1
2
  import diff_match_patch from "diff-match-patch";
2
3
  import { DeleteTableRow, InsertTableRow, ModifyText } from "./models.js";
3
4
  import type {
@@ -475,6 +476,55 @@ function _drop_image_marker_hunks(
475
476
  return kept;
476
477
  }
477
478
 
479
+ /**
480
+ * Removes generated hunks whose pinned range cuts INTO a read-only image
481
+ * marker without covering it whole — the shape an alt-text-only difference
482
+ * produces (`RED` -> `BLUE` inside `![RED logo](docx-image:1)`). The engine
483
+ * categorically rejects such edits, so emitting them makes diff output
484
+ * unappliable by apply (QA 2026-07-19 F-04/F-14). Hunks that contain
485
+ * complete markers are judged by _drop_image_marker_hunks instead.
486
+ */
487
+ function _drop_marker_interior_hunks(
488
+ edits: ModifyText[],
489
+ text_orig: string,
490
+ warnings: string[],
491
+ ): ModifyText[] {
492
+ const marker_spans: [number, number][] = [];
493
+ for (const m of text_orig.matchAll(_IMAGE_MARKER_RE)) {
494
+ marker_spans.push([m.index!, m.index! + m[0].length]);
495
+ }
496
+ if (marker_spans.length === 0) return edits;
497
+
498
+ const kept: ModifyText[] = [];
499
+ let warned = false;
500
+ for (const e of edits) {
501
+ const idx = e._match_start_index;
502
+ if (idx === undefined || idx === null) {
503
+ kept.push(e);
504
+ continue;
505
+ }
506
+ const end = idx + (e.target_text || "").length;
507
+ const cuts_into_marker = marker_spans.some(
508
+ ([m_start, m_end]) =>
509
+ m_start < end && idx < m_end && !(idx <= m_start && m_end <= end),
510
+ );
511
+ if (!cuts_into_marker) {
512
+ kept.push(e);
513
+ continue;
514
+ }
515
+ if (!warned) {
516
+ warnings.push(
517
+ "An image's alternative text differs between the documents. Image markers " +
518
+ "(![alt](docx-image:N)) are read-only projections, so this difference cannot be " +
519
+ "expressed as a text edit — it was skipped; update the image or its alt text " +
520
+ "manually in Word.",
521
+ );
522
+ warned = true;
523
+ }
524
+ }
525
+ return kept;
526
+ }
527
+
478
528
  /**
479
529
  * True when every projected row reads exactly as its cells joined by " | ".
480
530
  * Rows wrapped in tracked-row CriticMarkup ({++ … ++} / {-- … --}) do not,
@@ -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 = _drop_image_marker_hunks(
707
- generate_edits_from_text(text_orig, text_mod),
757
+ const flat = _drop_marker_interior_hunks(
758
+ _drop_image_marker_hunks(
759
+ generate_edits_from_text(text_orig, text_mod),
760
+ warnings,
761
+ ),
762
+ text_orig,
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
- const tbl_edits = generate_edits_from_text(
789
- text_orig.substring(t_o.start, t_o.end),
790
- text_mod.substring(t_m.start, t_m.end),
791
- );
792
- for (const e of tbl_edits) {
793
- e._match_start_index = (e._match_start_index || 0) + t_o.start;
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" || e.type === "delete_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(_drop_image_marker_hunks(modify_edits, warnings));
910
+ const kept_modifies = new Set(
911
+ _drop_marker_interior_hunks(
912
+ _drop_image_marker_hunks(modify_edits, warnings),
913
+ text_orig,
914
+ warnings,
915
+ ),
916
+ );
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,