← Files Career Evidence ToolkitARCHIVED FILE

scripts/sanitize_docx.py

3.04 KB · Oct 2, 2026 · 00:34 UTC

↓ Download file

#!/usr/bin/env python3
"""Create a publication-safe copy of a DOCX template.

Removes custom XML, comments, author metadata, and relationships to removed parts.
It deliberately does not replace visible personal content; the calling workflow must
replace visible text with placeholders and render the result for review.
"""

from __future__ import annotations

import argparse
import re
import shutil
import tempfile
import zipfile
from pathlib import Path


REMOVED_PREFIXES = (
    "customXml/",
    "customXML/",
    "word/comments",
    "word/people",
)


def sanitize_xml(name: str, data: bytes) -> bytes:
    text = data.decode("utf-8")

    if name.endswith(".rels"):
        text = re.sub(
            r'<Relationship\b[^>]*Target="[^"]*(?:customXml|customXML|comments|people)[^"]*"[^>]*/>',
            "",
            text,
            flags=re.IGNORECASE,
        )

    if name == "[Content_Types].xml":
        text = re.sub(
            r'<Override\b[^>]*PartName="/(?:customXml|customXML|word/comments|word/people)[^"]*"[^>]*/>',
            "",
            text,
            flags=re.IGNORECASE,
        )

    if name == "docProps/core.xml":
        text = re.sub(r"<dc:creator>.*?</dc:creator>", "<dc:creator></dc:creator>", text)
        text = re.sub(
            r"<cp:lastModifiedBy>.*?</cp:lastModifiedBy>",
            "<cp:lastModifiedBy></cp:lastModifiedBy>",
            text,
        )
        text = re.sub(r"<cp:keywords>.*?</cp:keywords>", "", text)
        text = re.sub(r"<cp:category>.*?</cp:category>", "", text)

    return text.encode("utf-8")


def sanitize(source: Path, destination: Path) -> None:
    if source.resolve() == destination.resolve():
        raise ValueError("Destination must differ from source; originals are immutable.")
    if not zipfile.is_zipfile(source):
        raise ValueError(f"Not a valid DOCX/ZIP file: {source}")

    destination.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as handle:
        temporary = Path(handle.name)

    try:
        with zipfile.ZipFile(source, "r") as incoming, zipfile.ZipFile(
            temporary, "w", zipfile.ZIP_DEFLATED
        ) as outgoing:
            for item in incoming.infolist():
                if item.filename.startswith(REMOVED_PREFIXES):
                    continue
                data = incoming.read(item.filename)
                if item.filename.endswith((".xml", ".rels")):
                    data = sanitize_xml(item.filename, data)
                outgoing.writestr(item, data)
        shutil.move(temporary, destination)
    finally:
        temporary.unlink(missing_ok=True)


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("source", type=Path)
    parser.add_argument("destination", type=Path)
    args = parser.parse_args()
    sanitize(args.source, args.destination)
    print(f"Sanitized template: {args.destination}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 5e2f560c007c890ff461f931d419838a742230b8af110a0f2411451f89bb4e1a