ターミナルと CI で pdfx を使う:最初のコマンドから信頼できるスクリプトまで
pdfx をコマンドラインで使い、結合、圧縮、透かしの追加、多数のファイルの一括処理、パイプラインによるツールの連結を行う。スクリプトと CI で何に頼れるかとして、終了コード、--json レポート、標準入出力、そして一致しない条件付きフィルターツールがなぜ 0 で終了するかを扱う。

pdfx をスクリプトや継続的インテグレーション(CI)に入れる前に、二つのことを覚えておいてほしい。終了コード 0 は、必ずしもファイルが書かれたことを意味しない。一致しなかった条件付きフィルターツールも 0 で終了する。また --json は、入力が一つのときと複数のときで形が違うので、files 配列だけを解析するスクリプトは、一致したファイルが一つだけの場合に何も見つけられない。pdfx は HTTP クライアントだ。ファイルは処理のためにサーバーへアップロードされ、ローカルのエンジンは無く、オフラインモードも無い。コマンドの完全なリファレンスは開発者ページの CLI ガイドにある。
インストールして、二つのファイルを結合する
Node 20.3 以降、または Bun が必要だ。
npm install -g @pdf123/cli
pdfx --version
グローバルにインストールしたくなければ、コマンドの前に npx @pdf123/cli を付ける。カレントディレクトリに PDF が二つあるとして次を実行する。
pdfx merge a.pdf b.pdf -o merged.pdf
成功すると標準出力に merged.pdf が表示される。これがPDFの結合だった。別のツールに移る前に、パラメータ名を推測せず、フィールドを尋ねる。
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
(抜粋であり、フィールドにはほかに --rotation、--customColor などもある。)フィールドとは、要するにコマンドラインのオプションだ。
pdfx watermark in.pdf --watermarkText DRAFT -o marked.pdf
pdfx list は 95 個すべてのツールを一覧にし、--category security は一つのカテゴリーだけを、--query watermark は語で検索する。
複数のファイルはディレクトリに入り、既存のファイルは上書きされない
単一ファイル用のツールに複数の入力を渡すと、各ファイルは終わり次第、最後まで待たずに -o で指定したディレクトリへ書き込まれる。一つのファイルが失敗してもほかは続行し、バッチの最後の終了コードは 1 になる。
pdfx compress a.pdf b.pdf -o small/
ある実行の標準出力は次のようになった。順序は毎回変わり得る。
a.pdf -> small/a.pdf
b.pdf -> small/b.pdf
既存のディレクトリならそのまま使える。まだ存在しないときは、-o に末尾の / が必要だ。無いと small がファイル名として扱われる。-o を付けないと、結果はサーバーが付けた名前でカレントディレクトリに置かれる。既存のファイルは上書きされず、新しいものは a-1.pdf、a-2.pdf になる。具体的なファイル名(-o same.pdf)は、指示どおり既存の内容を置き換える。既定では二つのファイルを同時に処理する。変えるには --concurrency を使う。バッチの途中で Ctrl-C を押すと、すでに書いたファイルは残り、終了コードは 130 になる。
暗号化されたファイルは --input-password PASSWORD で開く。ロックされたファイルとされていないファイルが混ざったバッチでは、繰り返し指定できる --password-for FILE=PASSWORD を使う。
途中のファイルが要るなら別々に呼び、要らなければパイプラインを使う
結合してから透かしを入れ、さらに圧縮するとき、途中の結果をディスクに置く必要がなければ、pipeline で一つのリクエストにまとめる。途中のファイルは再びダウンロードやアップロードされない。各ステップのファイルが欲しければ、ツールを別々に呼ぶ。パイプラインが作るファイルは一つだ。
pdfx pipeline a.pdf b.pdf --step merge --step compress -o merged-small.pdf
# ステップにパラメータが要るときは、ステップを JSON で書く
pdfx pipeline a.pdf b.pdf \
--steps '[{"tool":"merge"},{"tool":"watermark","params":{"watermarkText":"DRAFT"}},{"tool":"compress"}]' \
-o out.pdf
パイプラインのステップは最大 8 個で、9 個目のステップは HTTP 400: at most 8 pipeline steps allowed になる。二つ目のファイルを必要とするツール(たとえば別の PDF を重ねる overlay-pdfs)は、パイプラインのステップにできない。pdfx はアップロード前にこれを拒否し、終了コード 2 とメッセージ Tool "overlay-pdfs" needs a second file and cannot run as a pipeline step を返す。
標準入力を使うのは、コマンドをパイプにつなぎたいときだけにする。ファイル引数の - は標準入力から読み、-o - は結果を標準出力に書く。
cat report.pdf | pdfx compress - -o - > report-small.pdf
このモードでは、標準出力にはファイルのバイト列しか流れない。メッセージとエラーは標準エラーへ行くので、リダイレクトしても安全だ。約 1 KB の PDF で確かめたところ、標準出力には 1040 バイトの PDF が出力され、-o で書き出したファイルと同じサイズで、標準エラーは空だった。標準入力から読み、-o を指定しなかった場合、結果は stdin.pdf という名前になり、カレントディレクトリに書かれて、標準エラーに一言添えられる。
スクリプトはメッセージではなく reason に合わせる
| 終了コード | 意味 | 例 |
|---|---|---|
| 0 | 成功、または一致しなかった条件付きフィルターツール | 結合が成功した、filter-page-count の条件が偽だった |
| 1 | リクエストは送られたが失敗した。バッチでは少なくとも一つのファイルが失敗 | パスワードが違う、PDF ではない、タイムアウト、返すものが無い |
| 2 | 使い方の誤りで、何もアップロードされていない | ツール名の綴り間違い、二つ目のファイルが要るパイプラインのステップ、出力ファイルの拡張子と矛盾する結果の種類 |
| 130 | 自分で中断した | バッチ中の Ctrl-C |
失敗時、標準エラーには一行のメッセージのほかに、reason:、code:、hint: の三行が付く。パスワードが違う暗号化ファイルでは、ローカルで次の出力になった。
pdfx: HTTP 400: The password is incorrect.
reason: wrong_password
code: bad_request
hint: Check the password and try again.
パスワードの渡し方によって、メッセージの一行目が変わる。unlock --password なら上の行になる。ほかのツールで --input-password を使うと、サーバーはロック解除を内部のステップとして扱い、一行目は HTTP 400: Pipeline step 0 (security/remove-password) failed: The password is incorrect. になる。ただし reason: の行はどちらも wrong_password だ。パスワードをまったく渡さなければ、メッセージは This PDF is password-protected. Enter its password. で、reason は password_required になる。スクリプトが照合すべきは reason: の行だ。code はもっと粗く、よくある値は bad_request である。
ツール名を間違えると終了コード 2 になり、メッセージに似た名前が示される。
pdfx: Unknown tool "compres". Did you mean: compress, decompress-pdf? Run `pdfx list` to see all tools.
結果の種類がファイル名と矛盾する場合も終了コード 2 で、何かが書かれる前に起きる。PDFの分割が作る ZIP を x.pdf に書こうとすると次のようになる。
pdfx: The result is application/zip but "x.pdf" has a .pdf extension; name it *.zip or write into a directory
code: output_mismatch
タイムアウトも終了コード 1 で、code は timeout になる。--timeout の単位はミリ秒で、既定は 300000、つまり 5 分だ。したがって --timeout 60 は 60 秒ではなく 60 ミリ秒を意味する。
条件が偽でも、終了コードは 0 のまま
あるクラスのツールは、はい・いいえの問いに答える。ページ数は N より大きいか、ファイルにあるテキストが含まれるか、ファイルはあるサイズより大きいか、といったものだ。答えがはいなら入力ファイルをそのまま返し、いいえなら何も返さず、pdfx は no match という一行を表示して 0 で終了する。これらのフィルターツールは SDK、コマンドライン、MCP にしか無く、ウェブサイトにはそのためのページが無い。
これは、別の種類の空の結果とは違う。PDFからCSVが PDF の中に表を見つけられなければ、サーバーは 204 を返し、pdfx は 1 で終了して no_content を報告する。内容を作るはずのツールが作らなかったということで、理由は空の表エクスポート(204):その PDF には列が無い可能性が高いにある。フィルターツールにとっては、「一致なし」こそが返すべき答えだ。
スクリプトで読むには --json を使う。一致すれば書かれたファイルの情報が表示され、一致しなければ { "matched": false } が表示される。
# 一致:ファイルが書かれ、その情報が表示される
$ pdfx filter-page-count three.pdf --pageCount 2 --comparator Greater --json -o big/
{ "path": "big/three.pdf", "contentType": "application/pdf", "bytes": 2594 }
# 一致なし:ファイルは書かれない
$ pdfx filter-page-count three.pdf --pageCount 5 --comparator Greater --json
{ "matched": false }
2 ページを超えるファイルだけを残したいなら、次のように書ける。a.pdf(1 ページ)と three.pdf(3 ページ)でローカルに実行したところ、残ったのは後者だけだった。
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: スキップ"
fi
done
jq -e '.matched == false' は、一致しなければ 0、一致すれば 1 で終了する。一致した場合、ファイルはすでに pdfx によって big/ に書かれている。if が決めるのは「スキップ」を表示するかどうかだけで、書き込むかどうかではない。
入力が一つだけなら、--json に files 配列は無い
入力が複数のとき、--json での標準出力は完全なレポートになる。input は絶対パスだ。processed はリクエストが成功したファイルの数で、一致しなかったものも含む。unmatched はそのうち一致しなかった数で、その項目は path の無い { "ok": true, "matched": false } になる。バッチのすべてのファイルが一致しなくても、終了コードは 0 のままだ。バッチに --idempotency-key を渡すと、各ファイルに送られるキーは <key>:<index> になる。仕組みはIdempotency-Key:PDF ジョブの安全な再試行にある。
{
"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 }
]
}
入力が一つのときは単一ファイルの経路が使われ、--json は { "path": ..., "contentType": ..., "bytes": ... } を表示し、files 配列は無い。失敗時は標準出力が空で、すべてが標準エラーに出る。glob でファイルを展開する場合、一致したのが一つでも複数でも、スクリプトは両方の形式を扱わなければならない。
ここまでの決まりを一つの CI ステップにまとめる
このスクリプトは圧縮するだけで、フィルターツールは使わない。圧縮の失敗は 1 で終了し、ステップもそれで失敗する。一致しなかったフィルターツールは 0 で終了するので、一致が無いというだけで CI が失敗することはない。一致が無いことを問題とみなすかどうかは、前のフィルターの節で示したように --json を読んで自分で決める。
いずれかのファイルが拒否されると、このステップは失敗し、ログにファイル名と理由を並べる。論理は前と同じで、まず終了コードを読み、失敗なら標準エラーを表示し、それから jq でバッチレポートから reason を取り出す。ディレクトリ引数が無いときはすぐに終了するので、"$1"/*.pdf が /*.pdf に展開されることは決してない。
#!/usr/bin/env bash
# 使い方: ./ci-step.sh docs
docs="${1:?使い方: ./ci-step.sh <directory>}"
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"
四種類の入力でローカルに実行した。
| ディレクトリ内のファイル | 終了コード | ログ |
|---|---|---|
| 正常な PDF が二つ | 0 | なし |
| 正常な PDF が二つと、壊れたものが一つ | 1 | pdfx: broken.pdf: HTTP 400: ...、続けて broken.pdf invalid_pdf の行 |
| 正常な PDF が一つだけ | 0 | なし。report.json は単一ファイルの形式 |
| 壊れた PDF が一つだけ | 1 | reason: invalid_pdf、code: invalid_document、hint の行。report.json は空 |
jq の .files[]? の末尾の疑問符は、単一ファイルのレポート(files が無い)でエラーが出るのを防ぐ。壊れたファイルが一つだけの場合はバッチレポートが無く、理由は errors.log にしか現れないので、二つの出力は両方残しておくこと。
これを CI に入れるときは、ほかにも、CI に入れる前に確かめておきたいことが三つある。pdfx はネットワークを必要とする。ファイルは PDFX_API_BASE へアップロードされ、その既定値は https://pdf123.xyz だ。自分のネットワークの外に出してはいけない文書については、サービスを自分で動かし、この変数をそこへ向ける。セルフホストが実際にもたらすもの(と、そのコスト)を参照。身元が必要なときは、キーを PDFX_API_KEY に入れ、コマンドライン引数には入れない。引数に入れるとプロセス一覧やログに残ってしまう。現在公開されているバージョンは 0.1.0 だ。CI では npx @pdf123/[email protected] ... でバージョンを固定すると、出力形式と終了コードが新しいリリースで変わらず、アップグレードは意図して行う変更になる。
npm 上のパッケージのページは @pdf123/cli だ。