在終端機與 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。
只有想把指令接到管道(pipe)上時才用標準輸入。檔案引數 - 從標準輸入讀取,-o - 把結果寫到標準輸出:
cat report.pdf | pdfx compress - -o - > report-small.pdf
這個模式下,標準輸出只含檔案的位元組;訊息和錯誤走標準錯誤,所以重新導向是安全的。我們用一份約 1 KB 的 PDF 驗證過:標準輸出是一份 1040 位元組的 PDF,與用 -o 寫出的檔案大小相同,標準錯誤為空。從標準輸入讀取又沒給 -o 時,結果會命名為 stdin.pdf,寫進目前目錄,並在標準錯誤留下一行說明。
腳本應比對原因,而不是訊息
| 結束碼 | 意義 | 範例 |
|---|---|---|
| 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 是其中沒有符合的數量,它們的項目是 { "ok": true, "matched": false },沒有 path。批次中每個檔案都沒有符合時,結束碼仍是 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 <目錄>}"
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 之前還有三件事要檢查。pdfx 需要網路:檔案會上傳到 PDFX_API_BASE,預設為 https://pdf123.xyz。對必須留在內網的文件,自己架一個服務並讓這個變數指向它;見自架實際買到什麼(以及要付出什麼)。需要身分時,把金鑰放在 PDFX_API_KEY,不要放在命令列引數裡,那樣它會留在行程清單和日誌中。目前發布的版本是 0.1.0;在 CI 中用 npx @pdf123/[email protected] ... 固定版本,輸出格式與結束碼就不會隨新版本改變,升級也成為你有意識去做的變更。
這個套件在 npm 上的頁面是 @pdf123/cli。