@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.
@@ -417,6 +417,35 @@ export function normalize_change_dates(doc: DocumentObject): string[] {
417
417
  return count ? [`Track change timestamps: ${count} normalized`] : [];
418
418
  }
419
419
 
420
+ /**
421
+ * Normalize timestamps on RETAINED comments to the fixed sanitize date.
422
+ * Keep-markup sanitize normalized core and tracked-change timestamps but
423
+ * left the original comment timestamps in word/comments.xml (w:date) and
424
+ * word/commentsExtensible.xml (w16cex:dateUtc) — visible through any
425
+ * extraction and carrying exactly the when-did-they-work signal the other
426
+ * normalizations remove (QA 2026-07-19 F-09).
427
+ */
428
+ export function normalize_comment_dates(doc: DocumentObject): string[] {
429
+ let count = 0;
430
+ const fixed = "2025-01-01T00:00:00Z";
431
+ for (const part of doc.pkg.parts) {
432
+ if (!part.contentType.includes('comments')) continue;
433
+ for (const el of findAllDescendants(part._element, 'w:comment')) {
434
+ if (el.hasAttribute('w:date')) {
435
+ el.setAttribute('w:date', fixed);
436
+ count++;
437
+ }
438
+ }
439
+ for (const el of findAllDescendants(part._element, 'w16cex:commentExtensible')) {
440
+ if (el.hasAttribute('w16cex:dateUtc')) {
441
+ el.setAttribute('w16cex:dateUtc', fixed);
442
+ count++;
443
+ }
444
+ }
445
+ }
446
+ return count ? [`Comment timestamps: ${count} normalized`] : [];
447
+ }
448
+
420
449
  export function scrub_doc_properties(doc: DocumentObject): string[] {
421
450
  const lines: string[] = [];
422
451
  const corePart = doc.pkg.getPartByPath('docProps/core.xml');
@@ -435,12 +464,17 @@ export function scrub_doc_properties(doc: DocumentObject): string[] {
435
464
  // Classification-style properties are textbook leak vectors ("Project
436
465
  // Falcon", "confidential,merger,..."). Unlike title they carry no
437
466
  // legitimate outbound formatting value, so strip them and say so.
467
+ // dc:identifier, dc:language and cp:version are scrubbed with them:
468
+ // identifiers are DMS/matter-ID carriers.
438
469
  const leakFields: Array<[string, string]> = [
439
470
  ['category', 'Category'],
440
471
  ['keywords', 'Keywords'],
441
472
  ['subject', 'Subject'],
442
473
  ['contentStatus', 'Content status'],
443
474
  ['description', 'Description/comments'],
475
+ ['identifier', 'Identifier'],
476
+ ['language', 'Language'],
477
+ ['version', 'Version'],
444
478
  ];
445
479
  for (const [local, label] of leakFields) {
446
480
  findDescendantsByLocalName(corePart._element, local).forEach(c => {
@@ -498,33 +532,106 @@ export function scrub_timestamps(doc: DocumentObject): string[] {
498
532
  return modified ? ["Timestamps normalized to epoch"] : [];
499
533
  }
500
534
 
501
- export function strip_custom_xml(doc: DocumentObject): string[] {
502
- const customParts = doc.pkg.parts.filter(p => p.partname.includes('/customXml'));
503
- if (customParts.length === 0) return [];
504
-
505
- const partnames = new Set(customParts.map(p => p.partname));
506
- doc.pkg.parts = doc.pkg.parts.filter(p => !partnames.has(p.partname));
535
+ /**
536
+ * Fully ejects package members whose path matches `matcher`: the parsed part
537
+ * list, the raw `pkg.unzipped` bytes (save() re-zips EVERY unzipped
538
+ * member, so skipping this ships the original bytes in the output), the
539
+ * [Content_Types].xml overrides, and every .rels Relationship whose
540
+ * target matches `relTargetMatcher`.
541
+ */
542
+ function ejectPackageMembers(
543
+ doc: DocumentObject,
544
+ matcher: (path: string) => boolean,
545
+ relTargetMatcher: (target: string) => boolean,
546
+ ) {
547
+ const pkg = doc.pkg;
548
+ const normalized = (p: string) => (p.startsWith('/') ? p.substring(1) : p);
507
549
 
508
- const removeRelationsTo = (relsPart: Part) => {
550
+ // 1. Sever relationships from every .rels part (and the in-memory rels maps)
551
+ for (const part of pkg.parts) {
552
+ if (!part.partname.endsWith('.rels')) continue;
509
553
  const toRemove: Element[] = [];
510
- for (const rel of findAllDescendants(relsPart._element, 'Relationship')) {
511
- const target = rel.getAttribute('Target');
512
- if (target && target.includes('customXml')) toRemove.push(rel);
554
+ for (const rel of findAllDescendants(part._element, 'Relationship')) {
555
+ const target = rel.getAttribute('Target') || '';
556
+ if (relTargetMatcher(target)) {
557
+ toRemove.push(rel);
558
+ const sourcePath = part.partname.replace('/_rels/', '/').replace('.rels', '');
559
+ const sourcePart = pkg.getPartByPath(sourcePath);
560
+ if (sourcePart) {
561
+ const relId = rel.getAttribute('Id');
562
+ if (relId) sourcePart.rels.delete(relId);
563
+ }
564
+ }
513
565
  }
514
566
  toRemove.forEach(r => r.parentNode?.removeChild(r));
515
- };
567
+ }
516
568
 
517
- const rootRels = doc.pkg.getPartByPath('_rels/.rels');
518
- if (rootRels) removeRelationsTo(rootRels);
569
+ // 2. Remove [Content_Types].xml overrides
570
+ const ctPart = pkg.getPartByPath('[Content_Types].xml');
571
+ if (ctPart) {
572
+ const toRemove: Element[] = [];
573
+ for (const override of findAllDescendants(ctPart._element, 'Override')) {
574
+ const partName = override.getAttribute('PartName') || '';
575
+ if (matcher(normalized(partName))) toRemove.push(override);
576
+ }
577
+ toRemove.forEach(o => o.parentNode?.removeChild(o));
578
+ }
519
579
 
520
- const docRels = doc.pkg.getOrCreateRelsPart(doc.part.partname);
521
- if (docRels) removeRelationsTo(docRels);
580
+ // 3. Drop the parts from the serialization list AND the raw zip map
581
+ pkg.parts = pkg.parts.filter(p => !matcher(normalized(p.partname)));
582
+ for (const key of Object.keys(pkg.unzipped)) {
583
+ if (matcher(normalized(key))) delete pkg.unzipped[key];
584
+ }
585
+ }
586
+
587
+ export function strip_custom_xml(doc: DocumentObject): string[] {
588
+ const isCustomXml = (path: string) => path.startsWith('customXml/');
589
+ const customParts = doc.pkg.parts.filter(p =>
590
+ isCustomXml(p.partname.startsWith('/') ? p.partname.substring(1) : p.partname),
591
+ );
592
+ const leakedMembers = Object.keys(doc.pkg.unzipped).filter(isCustomXml);
593
+ if (customParts.length === 0 && leakedMembers.length === 0) return [];
594
+
595
+ ejectPackageMembers(doc, isCustomXml, target => target.includes('customXml'));
522
596
 
523
597
  for (const sdtPr of findAllDescendants(doc.element, 'w:sdtPr')) {
524
598
  findChildren(sdtPr, 'w:dataBinding').forEach(b => sdtPr.removeChild(b));
525
599
  }
526
600
 
527
- return [`Custom XML parts: ${customParts.length} removed`];
601
+ return [`Custom XML parts: ${Math.max(customParts.length, leakedMembers.length)} removed`];
602
+ }
603
+
604
+ const CUSTOM_PROPS_PATH = 'docProps/custom.xml';
605
+
606
+ /**
607
+ * Remove the custom document properties part (docProps/custom.xml) and its
608
+ * package relationship. Custom properties are a standard home for matter
609
+ * numbers, client names, DMS identifiers and workflow secrets; a package
610
+ * that keeps them can never be reported CLEAN.
611
+ */
612
+ export function strip_custom_properties(doc: DocumentObject): string[] {
613
+ const part = doc.pkg.getPartByPath(CUSTOM_PROPS_PATH);
614
+ const leaked = Object.keys(doc.pkg.unzipped).includes(CUSTOM_PROPS_PATH);
615
+ if (!part && !leaked) return [];
616
+
617
+ // Enumerate what is being removed so the report discloses it.
618
+ const lines: string[] = [];
619
+ if (part) {
620
+ for (const prop of findDescendantsByLocalName(part._element, 'property')) {
621
+ const name = prop.getAttribute('name') || '(unnamed)';
622
+ const value = (prop.textContent || '').trim();
623
+ lines.push(` Custom property removed: ${name} = "${_truncate(value, 60)}"`);
624
+ }
625
+ }
626
+
627
+ ejectPackageMembers(
628
+ doc,
629
+ path => path === CUSTOM_PROPS_PATH,
630
+ target => target.includes('docProps/custom.xml'),
631
+ );
632
+
633
+ const count = Math.max(lines.length, 1);
634
+ return [`Custom document properties: ${count} removed (docProps/custom.xml)`].concat(lines);
528
635
  }
529
636
 
530
637
  export function strip_image_alt_text(doc: DocumentObject): string[] {
package/src/utils/docx.ts CHANGED
@@ -560,6 +560,27 @@ export function get_run_style_markers(
560
560
  return [prefix, suffix];
561
561
  }
562
562
 
563
+ /**
564
+ * Splits `text` into [leading_ws, core, trailing_ws]. Emphasis markers must
565
+ * wrap only the core: `**The Supplier **` (a bold run with a trailing space)
566
+ * is malformed Markdown — CommonMark requires the closing delimiter to hug
567
+ * non-whitespace — and it poisons every downstream CriticMarkup consumer
568
+ * (QA 2026-07-19 F-03/F-10). Fully-whitespace text yields ["", "", text] so
569
+ * callers skip the markers entirely.
570
+ */
571
+ export function split_boundary_whitespace(
572
+ text: string,
573
+ ): [string, string, string] {
574
+ const core = text.trim();
575
+ if (!core) return ["", "", text];
576
+ const lead_len = text.length - text.trimStart().length;
577
+ return [
578
+ text.substring(0, lead_len),
579
+ text.substring(lead_len, lead_len + core.length),
580
+ text.substring(lead_len + core.length),
581
+ ];
582
+ }
583
+
563
584
  export function apply_formatting_to_segments(
564
585
  text: string,
565
586
  prefix: string,
@@ -567,10 +588,17 @@ export function apply_formatting_to_segments(
567
588
  ): string {
568
589
  if (!prefix && !suffix) return text;
569
590
  if (!text) return "";
570
- if (!text.includes("\n")) return `${prefix}${text}${suffix}`;
591
+
592
+ const wrap = (segment: string): string => {
593
+ const [lead, core, trail] = split_boundary_whitespace(segment);
594
+ if (!core) return segment;
595
+ return `${lead}${prefix}${core}${suffix}${trail}`;
596
+ };
597
+
598
+ if (!text.includes("\n")) return wrap(text);
571
599
 
572
600
  const parts = text.split("\n");
573
- return parts.map((p) => (p ? `${prefix}${p}${suffix}` : "")).join("\n");
601
+ return parts.map((p) => (p ? wrap(p) : "")).join("\n");
574
602
  }
575
603
 
576
604
  export function get_run_text(run: Run): string {