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
+13 -1
View File
@@ -37,6 +37,7 @@ import { friendlyError } from "../lib/errors.js";
import { logger } from "../lib/logger.js";
import { jobDuration, jobsTotal } from "../lib/metrics.js";
import { getObjectBuffer, putObject } from "../lib/object-storage.js";
import { SCRUB_PDF_PRODUCER_TOOLS, scrubPdfProducer } from "../lib/pdf-producer.js";
import { publishEphemeral, updateSingleFileProgress } from "../routes/progress.js";
import {
getToolConfig,
@@ -253,6 +254,13 @@ async function processToolJob(job: Job<ToolJobData>): Promise<ToolJobResult> {
resultContentType,
);
// Generated PDFs carry the conversion engine's name as Producer/Creator
// (LibreOffice, Ghostscript, pdfcpu, ...); stamp SnapOtter instead.
// Best effort: a failed scrub keeps the original bytes.
if (SCRUB_PDF_PRODUCER_TOOLS.has(data.toolId) && outName.toLowerCase().endsWith(".pdf")) {
resultBuffer = await scrubPdfProducer(resultBuffer);
}
// Write primary output to object storage
const primaryKey = `outputs/${jobId}/${outName}`;
await putObject(primaryKey, resultBuffer);
@@ -262,7 +270,11 @@ async function processToolJob(job: Job<ToolJobData>): Promise<ToolJobResult> {
if (extraOutputs) {
for (const extra of extraOutputs) {
const extraKey = `outputs/${jobId}/${extra.name}`;
await putObject(extraKey, extra.buffer);
const body =
SCRUB_PDF_PRODUCER_TOOLS.has(data.toolId) && extra.name.toLowerCase().endsWith(".pdf")
? await scrubPdfProducer(extra.buffer)
: extra.buffer;
await putObject(extraKey, body);
outputRefs.push(extraKey);
}
}
+75
View File
@@ -0,0 +1,75 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pdfScrubProducerPy } from "@snapotter/doc-engine";
/**
* Tools whose PDF output is GENERATED by a conversion engine that stamps its
* own name into the Producer/Creator metadata (LibreOffice, Ghostscript,
* pdfcpu, WeasyPrint, PDFKit, the OCR pipeline). Their outputs get SnapOtter
* stamped instead; see scrubPdfProducer.
*
* Deliberately excluded: tools that edit the user's own PDF and preserve its
* metadata (merge/split/rotate/protect/unlock/repair/linearize via qpdf,
* sign-pdf and flatten-pdf via PyMuPDF incremental edits) and pdfa-convert,
* where rewriting metadata after conversion risks PDF/A conformance.
*/
export const SCRUB_PDF_PRODUCER_TOOLS: ReadonlySet<string> = new Set([
// LibreOffice conversions
"convert-document",
"convert-presentation",
"convert-spreadsheet",
"word-to-pdf",
"excel-to-pdf",
"powerpoint-to-pdf",
// WeasyPrint
"html-to-pdf",
"markdown-to-pdf",
// pandoc/WeasyPrint chains
"epub-convert",
// Ghostscript rewrites (these clobber the user's Producer anyway)
"compress-pdf",
"grayscale-pdf",
// pdfcpu layout tools (pdfcpu stamps itself on every write)
"crop-pdf",
"nup-pdf",
"booklet-pdf",
"watermark-pdf",
"pdf-page-numbers",
// OCR produces a new searchable PDF
"ocr-pdf",
// PDFKit image-to-PDF family
"image-to-pdf",
"jpg-to-pdf",
"png-to-pdf",
"heic-to-pdf",
"tiff-to-pdf",
"webp-to-pdf",
"gif-to-pdf",
"eps-to-pdf",
]);
/**
* Replace the conversion engine's Producer/Creator metadata with SnapOtter.
* Best effort by design: any failure (missing PyMuPDF, malformed PDF,
* dispatcher down) returns the original buffer unchanged so a cosmetic
* metadata pass can never fail a job.
*/
export async function scrubPdfProducer(buffer: Buffer): Promise<Buffer> {
let dir: string | null = null;
try {
dir = await mkdtemp(join(tmpdir(), "pdf-scrub-"));
const inPath = join(dir, "in.pdf");
const outPath = join(dir, "out.pdf");
await writeFile(inPath, buffer);
await pdfScrubProducerPy(inPath, outPath);
return await readFile(outPath);
} catch (err) {
console.warn(
`[pdf-scrub] keeping original metadata: ${err instanceof Error ? err.message : String(err)}`,
);
return buffer;
} finally {
if (dir) await rm(dir, { recursive: true, force: true }).catch(() => {});
}
}