mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -37,6 +37,7 @@ import { friendlyError } from "../lib/errors.js";
|
|||||||
import { logger } from "../lib/logger.js";
|
import { logger } from "../lib/logger.js";
|
||||||
import { jobDuration, jobsTotal } from "../lib/metrics.js";
|
import { jobDuration, jobsTotal } from "../lib/metrics.js";
|
||||||
import { getObjectBuffer, putObject } from "../lib/object-storage.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 { publishEphemeral, updateSingleFileProgress } from "../routes/progress.js";
|
||||||
import {
|
import {
|
||||||
getToolConfig,
|
getToolConfig,
|
||||||
@@ -253,6 +254,13 @@ async function processToolJob(job: Job<ToolJobData>): Promise<ToolJobResult> {
|
|||||||
resultContentType,
|
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
|
// Write primary output to object storage
|
||||||
const primaryKey = `outputs/${jobId}/${outName}`;
|
const primaryKey = `outputs/${jobId}/${outName}`;
|
||||||
await putObject(primaryKey, resultBuffer);
|
await putObject(primaryKey, resultBuffer);
|
||||||
@@ -262,7 +270,11 @@ async function processToolJob(job: Job<ToolJobData>): Promise<ToolJobResult> {
|
|||||||
if (extraOutputs) {
|
if (extraOutputs) {
|
||||||
for (const extra of extraOutputs) {
|
for (const extra of extraOutputs) {
|
||||||
const extraKey = `outputs/${jobId}/${extra.name}`;
|
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);
|
outputRefs.push(extraKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,6 +98,7 @@ DOCS_SCRIPTS = {
|
|||||||
"doc_metadata",
|
"doc_metadata",
|
||||||
"doc_html_pdf",
|
"doc_html_pdf",
|
||||||
"doc_sign",
|
"doc_sign",
|
||||||
|
"doc_scrub_meta",
|
||||||
}
|
}
|
||||||
|
|
||||||
if DISPATCHER_PROFILE == "docs":
|
if DISPATCHER_PROFILE == "docs":
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -45,6 +45,7 @@ export {
|
|||||||
pdfMetadataSetPy,
|
pdfMetadataSetPy,
|
||||||
pdfPageCountPy,
|
pdfPageCountPy,
|
||||||
pdfRedactPy,
|
pdfRedactPy,
|
||||||
|
pdfScrubProducerPy,
|
||||||
pdfSignPy,
|
pdfSignPy,
|
||||||
pdfTextPy,
|
pdfTextPy,
|
||||||
pdfToWordPy,
|
pdfToWordPy,
|
||||||
|
|||||||
@@ -20,6 +20,19 @@ export async function pdfFlattenPy(inPath: string, outPath: string): Promise<voi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stamp SnapOtter as Producer/Creator on a GENERATED PDF (PyMuPDF), replacing
|
||||||
|
* the conversion engine's self-promotion (LibreOffice, Ghostscript, pdfcpu,
|
||||||
|
* WeasyPrint, PDFKit). Encrypted files are copied through untouched.
|
||||||
|
*/
|
||||||
|
export async function pdfScrubProducerPy(inPath: string, outPath: string): Promise<void> {
|
||||||
|
const stdout = await runDocsScript("doc_scrub_meta", { path: inPath, out: outPath });
|
||||||
|
const parsed = JSON.parse(stdout.trim()) as { ok?: boolean; error?: string };
|
||||||
|
if (parsed.error) {
|
||||||
|
throw new Error(`doc_scrub_meta failed: ${parsed.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** True redaction with verification pass (PyMuPDF search + apply_redactions). */
|
/** True redaction with verification pass (PyMuPDF search + apply_redactions). */
|
||||||
export async function pdfRedactPy(
|
export async function pdfRedactPy(
|
||||||
inPath: string,
|
inPath: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user