pdfx im Terminal und in der CI: vom ersten Befehl zum verlässlichen Skript
Mit pdfx auf der Kommandozeile PDFs zusammenführen, komprimieren und mit Wasserzeichen versehen, viele Dateien auf einmal verarbeiten und Werkzeuge per Pipeline in einer Anfrage verketten. Für Skripte und CI: worauf du dich verlassen kannst, nämlich Exit-Codes, der Bericht mit --json, Standardeingabe und -ausgabe, und warum ein bedingtes Filterwerkzeug ohne Treffer trotzdem mit 0 endet.

Bevor du pdfx in ein Skript oder in die kontinuierliche Integration (CI) einbaust, merke dir zwei Dinge. Exit-Code 0 heißt nicht immer, dass eine Datei geschrieben wurde: Auch ein bedingtes Filterwerkzeug ohne Treffer endet mit 0. Und --json hat für eine Eingabe eine andere Form als für mehrere, sodass ein Skript, das nur das Array files auswertet, nichts findet, wenn nur eine Datei übrig blieb. pdfx ist ein HTTP-Client: Dateien werden zur Verarbeitung auf den Server hochgeladen, es gibt keine lokale Engine und keinen Offline-Modus. Die vollständige Befehlsreferenz steht im CLI-Leitfaden auf der Entwicklerseite.
Installieren, dann zwei Dateien zusammenführen
Du brauchst Node 20.3 oder neuer, oder Bun:
npm install -g @pdf123/cli
pdfx --version
Willst du nicht global installieren, setze npx @pdf123/cli vor den Befehl. Mit zwei PDFs im aktuellen Verzeichnis:
pdfx merge a.pdf b.pdf -o merged.pdf
Bei Erfolg gibt die Standardausgabe merged.pdf aus. Das war PDF zusammenführen. Bevor du das Werkzeug wechselst, frage nach den Feldern, statt Parameternamen zu raten:
pdfx describe watermark
Add Watermark (watermark) - Add text or image watermarks to PDF files
Input: 1 file (.pdf)
Result: file
Fields:
--watermarkText <value> Watermark text [default: PDF123]
--fontSize <number> Font size [default: 30]
min 6, max 200
(Ein Auszug; zu den Feldern gehören auch --rotation, --customColor und weitere.) Ein Feld ist einfach eine Kommandozeilenoption:
pdfx watermark in.pdf --watermarkText DRAFT -o marked.pdf
pdfx list listet alle 95 Werkzeuge auf, --category security nur eine Kategorie, und --query watermark sucht nach einem Wort.
Mehrere Dateien landen in einem Verzeichnis, und vorhandene Dateien werden nicht überschrieben
Gibst du einem Einzeldatei-Werkzeug mehrere Eingaben, wird jede Datei in das mit -o genannte Verzeichnis geschrieben, sobald sie fertig ist, und nicht bis zum Ende zurückgehalten. Scheitert eine Datei, laufen die anderen weiter, und der Exit-Code am Ende des Stapels ist 1.
pdfx compress a.pdf b.pdf -o small/
Die Standardausgabe eines Laufs sah so aus, und die Reihenfolge kann jedes Mal anders sein:
a.pdf -> small/a.pdf
b.pdf -> small/b.pdf
Ein vorhandenes Verzeichnis funktioniert so, wie es ist; existiert das Verzeichnis noch nicht, braucht -o einen abschließenden /, sonst wird small als Dateiname behandelt. Ohne -o landen die Ergebnisse im aktuellen Verzeichnis unter dem Namen, den der Server vergibt; eine vorhandene Datei wird nicht überschrieben, und die neue heißt a-1.pdf, a-2.pdf. Ein bestimmter Dateiname (-o same.pdf) ersetzt vorhandenen Inhalt, wie du es angegeben hast. Standardmäßig werden zwei Dateien gleichzeitig verarbeitet; das änderst du mit --concurrency. Drückst du mitten in einem Stapel Strg-C, bleiben die bereits geschriebenen Dateien erhalten, und der Exit-Code ist 130.
Eine verschlüsselte Datei öffnest du mit --input-password PASSWORT. Mischt ein Stapel gesperrte und ungesperrte Dateien, nimm --password-for DATEI=PASSWORT, das du wiederholen kannst.
Werkzeuge einzeln aufrufen für Zwischendateien, sonst eine Pipeline
Um zusammenzuführen, dann ein Wasserzeichen zu setzen und dann zu komprimieren, fasst du alles mit pipeline zu einer Anfrage zusammen, wenn du die Zwischenergebnisse nicht auf der Platte brauchst; die Zwischendateien werden nicht erneut herunter- und hochgeladen. Willst du die Datei jedes Schritts, rufe die Werkzeuge einzeln auf. Eine Pipeline erzeugt genau eine Datei.
pdfx pipeline a.pdf b.pdf --step merge --step compress -o merged-small.pdf
# When a step needs parameters, describe the steps in JSON
pdfx pipeline a.pdf b.pdf \
--steps '[{"tool":"merge"},{"tool":"watermark","params":{"watermarkText":"DRAFT"}},{"tool":"compress"}]' \
-o out.pdf
Eine Pipeline hat höchstens 8 Schritte; ein 9. Schritt führt zu HTTP 400: at most 8 pipeline steps allowed. Ein Werkzeug, das eine zweite Datei braucht (zum Beispiel overlay-pdfs, das eine weitere PDF überlagert), kann kein Pipeline-Schritt sein. pdfx weist es vor dem Hochladen ab, mit Exit-Code 2 und der Meldung Tool "overlay-pdfs" needs a second file and cannot run as a pipeline step.
Nutze die Standardeingabe nur, wenn du den Befehl an eine Pipe hängen willst. Ein Dateiargument - liest von der Standardeingabe, und -o - schreibt das Ergebnis auf die Standardausgabe:
cat report.pdf | pdfx compress - -o - > report-small.pdf
In diesem Modus trägt die Standardausgabe nur die Bytes der Datei; Meldungen und Fehler gehen an die Standardfehlerausgabe, das Umleiten ist also sicher. Wir haben das mit einer PDF von etwa 1 KB geprüft: Die Standardausgabe enthielt eine PDF von 1040 Bytes, genauso groß wie die mit -o geschriebene Datei, und die Standardfehlerausgabe war leer. Liest du von der Standardeingabe und gibst kein -o an, heißt das Ergebnis stdin.pdf, wird ins aktuelle Verzeichnis geschrieben und mit einem Hinweis auf der Standardfehlerausgabe begleitet.
Skripte sollten auf den Grund prüfen, nicht auf die Meldung
| Exit-Code | Bedeutung | Beispiele |
|---|---|---|
| 0 | Erfolg, oder ein bedingtes Filterwerkzeug ohne Treffer | Eine Zusammenführung gelang; die Bedingung von filter-page-count war falsch |
| 1 | Die Anfrage wurde gesendet, scheiterte aber; in einem Stapel scheiterte mindestens eine Datei | Falsches Passwort, keine PDF, Zeitüberschreitung, nichts zurückzugeben |
| 2 | Bedienfehler, nichts wurde hochgeladen | Falsch geschriebener Werkzeugname, ein Pipeline-Schritt, der eine zweite Datei braucht, ein Ergebnistyp, der der Endung der Ausgabedatei widerspricht |
| 130 | Du hast abgebrochen | Strg-C während eines Stapels |
Bei einem Fehler trägt die Standardfehlerausgabe neben der Meldungszeile drei weitere Zeilen: reason:, code: und hint:. Eine verschlüsselte Datei mit falschem Passwort ergab lokal Folgendes:
pdfx: HTTP 400: The password is incorrect.
reason: wrong_password
code: bad_request
hint: Check the password and try again.
Wie du das Passwort übergibst, ändert die erste Zeile der Meldung: unlock --password ergibt die obige Zeile. Mit --input-password bei einem anderen Werkzeug behandelt der Server das Entsperren als internen Schritt, und die erste Zeile lautet HTTP 400: Pipeline step 0 (security/remove-password) failed: The password is incorrect., während die Zeile reason: in beiden Fällen wrong_password ist. Ganz ohne Passwort lautet die Meldung This PDF is password-protected. Enter its password. und reason ist password_required. Ein Skript sollte auf die Zeile reason: prüfen. code ist gröber, und ein häufiger Wert ist bad_request.
Ein falsch geschriebener Werkzeugname endet mit 2, und die Meldung schlägt ähnliche Namen vor:
pdfx: Unknown tool "compres". Did you mean: compress, decompress-pdf? Run `pdfx list` to see all tools.
Ein Ergebnistyp, der dem Dateinamen widerspricht, endet ebenfalls mit 2, und zwar bevor etwas geschrieben wird. Hier das ZIP, das PDF teilen erzeugt, nach x.pdf geschrieben:
pdfx: The result is application/zip but "x.pdf" has a .pdf extension; name it *.zip or write into a directory
code: output_mismatch
Eine Zeitüberschreitung endet ebenfalls mit 1, mit code gleich timeout. Die Einheit von --timeout sind Millisekunden, der Standardwert ist 300000, also 5 Minuten; --timeout 60 bedeutet daher 60 Millisekunden, nicht 60 Sekunden.
Ist die Bedingung falsch, bleibt der Exit-Code trotzdem 0
Eine Klasse von Werkzeugen beantwortet eine Ja-Nein-Frage: Ist die Seitenzahl größer als N, enthält die Datei einen bestimmten Text, ist die Datei größer als eine bestimmte Größe. Bei Ja geben sie die Eingabedatei unverändert zurück; bei Nein geben sie nichts zurück, und pdfx gibt die Zeile no match aus und endet mit 0. Diese Filterwerkzeuge gibt es nur im SDK, auf der Kommandozeile und in MCP; die Website hat keine Seite für sie.
Das unterscheidet sich von einem anderen leeren Ergebnis. Findet PDF in CSV keine Tabelle in der PDF, liefert der Server 204, und pdfx endet mit 1 und meldet no_content: Das Werkzeug sollte Inhalt erzeugen und hat es nicht getan, den Grund erklärt Leerer Export von Tabellen (204): Deine PDF hat vermutlich keine Spalten. Für ein Filterwerkzeug ist „kein Treffer“ die Antwort, die es geben sollte.
Zum Auslesen im Skript nimmst du --json. Ein Treffer gibt die geschriebene Datei aus; ohne Treffer erscheint { "matched": false }:
# Match: the file is written, and its info is printed
$ pdfx filter-page-count three.pdf --pageCount 2 --comparator Greater --json -o big/
{ "path": "big/three.pdf", "contentType": "application/pdf", "bytes": 2594 }
# No match: no file is written
$ pdfx filter-page-count three.pdf --pageCount 5 --comparator Greater --json
{ "matched": false }
Um nur die Dateien mit mehr als 2 Seiten zu behalten, kannst du es so schreiben. Wir haben es lokal mit a.pdf (1 Seite) und three.pdf (3 Seiten) ausgeführt, und nur die zweite blieb erhalten:
mkdir -p big
for f in *.pdf; do
if pdfx filter-page-count "$f" --pageCount 2 --comparator Greater --json -o big/ \
| jq -e '.matched == false' >/dev/null; then
echo "$f: übersprungen"
fi
done
jq -e '.matched == false' endet mit 0, wenn es keinen Treffer gibt, und mit 1, wenn es einen gibt. Bei einem Treffer hat pdfx die Datei bereits nach big/ geschrieben; das if entscheidet nur, ob „übersprungen“ ausgegeben wird, nicht, ob geschrieben wird.
Bei einer einzigen Eingabe hat --json kein files-Array
Bei mehreren Eingaben ist die Standardausgabe unter --json ein vollständiger Bericht. input ist ein absoluter Pfad. processed zählt die Dateien, deren Anfrage erfolgreich war, einschließlich derer ohne Treffer. unmatched gibt an, wie viele davon keinen Treffer hatten, und ihre Einträge sind { "ok": true, "matched": false } ohne path. Haben in einem Stapel alle Dateien keinen Treffer, ist der Exit-Code trotzdem 0. Übergibst du --idempotency-key an einen Stapel, lautet der pro Datei gesendete Schlüssel <key>:<index>; der Mechanismus steht in Idempotency-Key: sichere Wiederholungen für PDF-Aufgaben.
{
"processed": 2,
"unmatched": 0,
"failed": 1,
"files": [
{ "input": "/work/a.pdf", "ok": true, "path": "out/a.pdf", "contentType": "application/pdf", "bytes": 1040 },
{ "input": "/work/broken.pdf", "ok": false, "error": "HTTP 400: The file is not a valid PDF or it is damaged.", "reason": "invalid_pdf" },
{ "input": "/work/b.pdf", "ok": true, "path": "out/b.pdf", "contentType": "application/pdf", "bytes": 1027 }
]
}
Bei einer einzelnen Eingabe wird der Einzeldatei-Pfad genommen: --json gibt { "path": ..., "contentType": ..., "bytes": ... } aus, und es gibt kein Array files. Bei einem Fehler ist die Standardausgabe leer und alles steht auf der Standardfehlerausgabe. Wenn du Dateien per Glob expandierst, muss das Skript beide Formate verarbeiten, ob nun eine Datei passte oder mehrere.
Die obigen Regeln in einem CI-Schritt zusammenfassen
Dieses Skript komprimiert nur und verwendet kein Filterwerkzeug. Ein Fehler bei der Kompression endet mit 1, und der Schritt schlägt damit fehl. Ein Filterwerkzeug ohne Treffer endet mit 0, sodass die CI nicht allein deshalb fehlschlägt, weil es keinen Treffer gab; ob ein fehlender Treffer ein Problem ist, entscheidest du selbst, indem du --json liest, wie im Abschnitt über Filter weiter oben.
Wird irgendeine Datei abgelehnt, schlägt dieser Schritt fehl und listet Dateinamen und Gründe im Log auf. Die Logik ist dieselbe wie zuvor: zuerst den Exit-Code lesen, bei einem Fehler die Standardfehlerausgabe ausgeben, dann mit jq den reason aus dem Stapelbericht ziehen. Ohne Verzeichnisargument bricht es sofort ab, damit "$1"/*.pdf nie zu /*.pdf expandiert wird.
#!/usr/bin/env bash
# Aufruf: ./ci-step.sh docs
docs="${1:?Aufruf: ./ci-step.sh <Verzeichnis>}"
mkdir -p out
pdfx compress "$docs"/*.pdf -o out/ --json > report.json 2> errors.log
status=$?
if [ "$status" -ne 0 ]; then
cat errors.log >&2
jq -r '.files[]? | select(.ok | not) | "\(.input | split("/") | last)\t\(.reason)"' report.json >&2
fi
exit "$status"
Wir haben es lokal mit vier Arten von Eingaben ausgeführt:
| Dateien im Verzeichnis | Exit-Code | Log |
|---|---|---|
| Zwei gute PDFs | 0 | keins |
| Zwei gute PDFs plus eine beschädigte | 1 | pdfx: broken.pdf: HTTP 400: ..., dann eine Zeile broken.pdf invalid_pdf |
| Eine einzelne gute PDF | 0 | keins; report.json hat das Einzeldatei-Format |
| Eine einzelne beschädigte PDF | 1 | reason: invalid_pdf, code: invalid_document und eine hint-Zeile; report.json ist leer |
Das Fragezeichen am Ende von .files[]? in jq verhindert, dass der Einzeldatei-Bericht (der kein files hat) einen Fehler auslöst. Eine einzelne beschädigte Datei hat keinen Stapelbericht, und der Grund erscheint nur in errors.log; behalte also beide Ausgaben.
Drei weitere Dinge solltest du prüfen, bevor du das in die CI übernimmst. pdfx braucht das Netz: Dateien werden nach PDFX_API_BASE hochgeladen, standardmäßig https://pdf123.xyz. Für Dokumente, die in deinem Netz bleiben müssen, betreibe einen eigenen Dienst und richte diese Variable darauf; siehe Was Selbst-Hosting wirklich bringt (und was es kostet). Brauchst du eine Identität, lege den Schlüssel in PDFX_API_KEY und nicht in Kommandozeilenargumente, wo er in der Prozessliste und in den Logs stehen bliebe. Die aktuell veröffentlichte Version ist 0.1.0; in der CI legst du sie mit npx @pdf123/[email protected] ... fest, damit sich Ausgabeformat und Exit-Codes nicht mit neuen Versionen ändern und ein Upgrade eine bewusste Änderung von dir wird.
Die Seite des Pakets auf npm ist @pdf123/cli.