@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
|
@@ -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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
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(
|
|
511
|
-
const target = rel.getAttribute('Target');
|
|
512
|
-
if (target
|
|
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
|
-
|
|
518
|
-
|
|
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
|
-
|
|
521
|
-
|
|
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
|
-
|
|
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 ?
|
|
601
|
+
return parts.map((p) => (p ? wrap(p) : "")).join("\n");
|
|
574
602
|
}
|
|
575
603
|
|
|
576
604
|
export function get_run_text(run: Run): string {
|