@adeu/core 1.26.0 → 1.28.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.
@@ -31,20 +31,22 @@ export class SanitizeReport {
31
31
  const lower = line.toLowerCase();
32
32
  if (lower.includes("tracked change") || lower.includes("insertion") || lower.includes("deletion") || lower.includes("accepted")) {
33
33
  this.change_lines.push(line);
34
- } else if (lower.includes("comment") || lower.includes("[open]") || lower.includes("[resolved]")) {
35
- if (lower.includes("kept") || lower.includes("visible")) {
36
- this.kept_comment_lines.push(line);
37
- } else {
38
- this.removed_comment_lines.push(line);
39
- }
40
34
  } else if (
41
35
  lower.includes("author") || lower.includes("template") || lower.includes("company") ||
42
36
  lower.includes("manager") || lower.includes("metadata") || lower.includes("timestamp") ||
43
37
  lower.includes("custom xml") || lower.includes("custom propert") || lower.includes("identifier") ||
38
+ lower.includes("document variable") ||
44
39
  lower.includes("language") || lower.includes("version") ||
45
- lower.includes("last modified by") || lower.includes("revision count") || lower.includes("last printed")
40
+ lower.includes("last modified by") || lower.includes("revision count") || lower.includes("last printed") ||
41
+ lower.includes("description/comments")
46
42
  ) {
47
43
  this.metadata_lines.push(line);
44
+ } else if (lower.includes("comment") || lower.includes("[open]") || lower.includes("[resolved]")) {
45
+ if (lower.includes("kept") || lower.includes("visible")) {
46
+ this.kept_comment_lines.push(line);
47
+ } else {
48
+ this.removed_comment_lines.push(line);
49
+ }
48
50
  } else if (lower.includes("hyperlink") || lower.includes("warning")) {
49
51
  this.warnings.push(line);
50
52
  } else {
@@ -634,6 +634,45 @@ export function strip_custom_properties(doc: DocumentObject): string[] {
634
634
  return [`Custom document properties: ${count} removed (docProps/custom.xml)`].concat(lines);
635
635
  }
636
636
 
637
+ /**
638
+ * Remove Word document variables (<w:docVars>/<w:docVar>) from
639
+ * word/settings.xml. Document variables are invisible in Word's UI but are a
640
+ * standard carrier for matter references, DMS identifiers, template state and
641
+ * integration tokens — a package that keeps them can never be reported CLEAN
642
+ * (QA 2026-07-19 ADEU-QA-001). Variables are disclosed by NAME only: their
643
+ * values are exactly the secrets a sanitize report must not echo.
644
+ */
645
+ export function strip_document_variables(doc: DocumentObject): string[] {
646
+ const settingsPart = doc.pkg.getPartByPath('word/settings.xml');
647
+ if (!settingsPart) return [];
648
+
649
+ const names: string[] = [];
650
+ let removedAny = false;
651
+
652
+ for (const container of findDescendantsByLocalName(settingsPart._element, 'docVars')) {
653
+ for (const child of Array.from(container.childNodes)) {
654
+ const el = child as Element;
655
+ const tag = el.tagName || '';
656
+ if (tag === 'docVar' || tag.endsWith(':docVar')) {
657
+ names.push(el.getAttribute('w:name') || el.getAttribute('name') || '(unnamed)');
658
+ }
659
+ }
660
+ container.parentNode?.removeChild(container);
661
+ removedAny = true;
662
+ }
663
+
664
+ // Defensive: stray <w:docVar> elements outside a <w:docVars> container.
665
+ for (const stray of findDescendantsByLocalName(settingsPart._element, 'docVar')) {
666
+ names.push(stray.getAttribute('w:name') || stray.getAttribute('name') || '(unnamed)');
667
+ stray.parentNode?.removeChild(stray);
668
+ removedAny = true;
669
+ }
670
+
671
+ if (!removedAny) return [];
672
+ const shown = Array.from(new Set(names)).sort().join(', ') || '(unnamed)';
673
+ return [`Document variables: ${names.length} removed (${shown})`];
674
+ }
675
+
637
676
  export function strip_image_alt_text(doc: DocumentObject): string[] {
638
677
  let count = 0;
639
678
  for (const docPr of findDescendantsByLocalName(doc.element, 'docPr')) {
package/src/utils/docx.ts CHANGED
@@ -468,6 +468,19 @@ export function get_paragraph_prefix(
468
468
  }
469
469
 
470
470
  if (!style_name || style_name === "Normal") {
471
+ let is_inside_tc = false;
472
+ let curr: Element | null = paragraph._element;
473
+ while (curr) {
474
+ if (curr.tagName === "w:tc") {
475
+ is_inside_tc = true;
476
+ break;
477
+ }
478
+ curr = curr.parentNode as Element | null;
479
+ }
480
+ if (is_inside_tc) {
481
+ return "";
482
+ }
483
+
471
484
  const text = paragraph.text.trim();
472
485
  if (text && text.length < 100 && text === text.toUpperCase()) {
473
486
  let is_bold = false;
@@ -581,6 +594,80 @@ export function split_boundary_whitespace(
581
594
  ];
582
595
  }
583
596
 
597
+ /**
598
+ * Maps each tracked-change id in a merged meta bubble to the OTHER ids of its
599
+ * resolution group, rendered ready for the bubble line suffix
600
+ * (`uid -> "Chg:2"` / `uid -> "Chg:2, Chg:3"`).
601
+ *
602
+ * A replacement is stored as a contiguous same-author <w:del> + <w:ins> pair
603
+ * with two distinct w:id values, but both engines resolve such a group as ONE
604
+ * unit — accepting or rejecting either side decides the whole replacement.
605
+ * Projecting the two ids side by side with no linkage implied they were
606
+ * independently resolvable (QA 2026-07-19 ADEU-QA-004); every meta-block
607
+ * builder (Python/Node ingest + mapper) uses this map to annotate grouped
608
+ * lines as `(pairs with Chg:N)`.
609
+ *
610
+ * Grouping mirrors the engines' `_get_paired_nodes` walk: consecutive ins/del
611
+ * ids in bubble order group while the author stays the same; any state
612
+ * carrying NO active ins/del (a comment- or format-only run between changes —
613
+ * physical text separating the elements) breaks the group. `states_list`
614
+ * entries are [ins_map, del_map, comments_set, fmt_map] snapshots in document
615
+ * order, as accumulated by the ingest/mapper state machines.
616
+ */
617
+ export function compute_change_pair_map(
618
+ states_list: any[],
619
+ ): Record<string, string> {
620
+ const groups: Array<Array<[string, string]>> = [];
621
+ let current: Array<[string, string]> = [];
622
+ const seen_ids = new Set<string>();
623
+
624
+ for (const [ins_map, del_map] of states_list) {
625
+ const ins_entries = Object.entries(ins_map as Record<string, any>);
626
+ const del_entries = Object.entries(del_map as Record<string, any>);
627
+ if (ins_entries.length === 0 && del_entries.length === 0) {
628
+ // A run with only comment/format meta sits between the tracked
629
+ // elements: they are not siblings, the engine will not group them.
630
+ if (current.length > 0) {
631
+ groups.push(current);
632
+ current = [];
633
+ }
634
+ continue;
635
+ }
636
+ const state_new: Array<[string, string]> = [];
637
+ for (const [uid, meta] of ins_entries) {
638
+ if (!seen_ids.has(uid)) {
639
+ state_new.push([uid, (meta && meta.author) || "Unknown"]);
640
+ }
641
+ }
642
+ for (const [uid, meta] of del_entries) {
643
+ if (!seen_ids.has(uid)) {
644
+ state_new.push([uid, (meta && meta.author) || "Unknown"]);
645
+ }
646
+ }
647
+ for (const [uid, author] of state_new) {
648
+ seen_ids.add(uid);
649
+ if (current.length > 0 && current[current.length - 1][1] !== author) {
650
+ groups.push(current);
651
+ current = [];
652
+ }
653
+ current.push([uid, author]);
654
+ }
655
+ }
656
+ if (current.length > 0) groups.push(current);
657
+
658
+ const pair_map: Record<string, string> = {};
659
+ for (const group of groups) {
660
+ if (group.length < 2) continue;
661
+ for (const [uid] of group) {
662
+ pair_map[uid] = group
663
+ .filter(([u]) => u !== uid)
664
+ .map(([u]) => `Chg:${u}`)
665
+ .join(", ");
666
+ }
667
+ }
668
+ return pair_map;
669
+ }
670
+
584
671
  export function apply_formatting_to_segments(
585
672
  text: string,
586
673
  prefix: string,