fix: stamp SnapOtter as Producer on generated PDFs (#416)

Conversion engines wrote their own names into PDF metadata: LibreOffice,
Ghostscript, pdfcpu, WeasyPrint, and PDFKit all stamped Producer/Creator
on generated files. A new doc_scrub_meta docs-profile script (PyMuPDF)
rewrites both fields to SnapOtter and drops the stale XMP copy; the
worker applies it to the 25 PDF-generating tools before outputs reach
object storage. Best effort by design: any failure keeps the original
bytes and only logs a warning.

Deliberately untouched: tools that edit the user's own PDF and preserve
its metadata (qpdf edits, sign, flatten), encrypted outputs (copied
through), and pdfa-convert, where a metadata rewrite risks PDF/A
conformance.

Claude-Session: https://claude.ai/code/session_01XGB4pGvTvb7sUX4JN745U7
This commit is contained in:
SnapOtter
2026-07-04 03:05:32 +00:00
committed by GitHub
parent 4d37092bbe
commit 8b3f1e6884
6 changed files with 152 additions and 1 deletions
+1
View File
@@ -98,6 +98,7 @@ DOCS_SCRIPTS = {
"doc_metadata",
"doc_html_pdf",
"doc_sign",
"doc_scrub_meta",
}
if DISPATCHER_PROFILE == "docs":
+49
View File
@@ -0,0 +1,49 @@
"""Set the PDF Producer/Creator to SnapOtter on generated documents.
Conversion engines stamp themselves (LibreOffice, Ghostscript, pdfcpu,
WeasyPrint, PDFKit); from the user's point of view SnapOtter produced the
file. Only runs for tools that GENERATE PDFs; pass-through edits keep the
user's original metadata untouched.
Args: {"path": in, "out": out}. Prints {"ok": true}."""
import json
import sys
def main():
args = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}
path, out = args.get("path"), args.get("out")
if not path or not out:
print(json.dumps({"error": "missing path/out"}))
sys.exit(1)
try:
import fitz
except ImportError:
print(json.dumps({"error": "PyMuPDF not installed"}))
sys.exit(1)
try:
doc = fitz.open(path)
if doc.needs_pass:
# Encrypted output: pass it through untouched rather than
# re-encrypting. `out` must always exist for the caller.
doc.close()
import shutil
shutil.copyfile(path, out)
print(json.dumps({"ok": True, "skipped": "encrypted"}))
return
meta = doc.metadata or {}
meta["producer"] = "SnapOtter"
meta["creator"] = "SnapOtter"
doc.set_metadata(meta)
# XMP often carries the engine's own pdf:Producer; DocInfo is the
# canonical source for these files, so drop the stale copy.
doc.del_xml_metadata()
doc.save(out)
doc.close()
print(json.dumps({"ok": True}))
except Exception as exc: # noqa: BLE001
print(json.dumps({"error": str(exc)}))
sys.exit(1)
if __name__ == "__main__":
main()