用 pdfx 在终端和 CI 里处理 PDF:从第一条命令到可靠的脚本
用 pdfx 在命令行合并、压缩、加水印,一次处理多个文件,用管道把多个工具串成一个请求;写进脚本和 CI 时依赖的退出码、--json 报告、标准输入输出,以及条件过滤工具没有匹配时为什么以 0 退出。

把 pdfx 写进脚本或持续集成(CI,Continuous Integration)之前,先记住两件事。退出码 0 不一定表示写出了文件:条件过滤工具没有匹配时也是 0。--json 在只有一个输入和有多个输入时形状不同。只按 files 数组解析的脚本,在只匹配到一个文件时读不到结果。它是 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。这是合并。换工具之前先问字段,不要猜参数名:
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)则按你的指示替换已有内容。同时处理的文件数默认是 2,用 --concurrency 调整。批量运行中途按 Ctrl-C,已经写好的文件保留,退出码是 130。
加密的文件用 --input-password 密码 打开。一批里有的加锁、有的没有时,用 --password-for 文件名=密码,可以重复写。
要中间文件就分开调用,否则用 pipeline
先合并、再加水印、再压缩,中间结果不用落盘时,用 pipeline 收成一个请求,中间文件不会下载再上传。要每一步的文件,就分开调用。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)不能放在 pipeline 里。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 | 用法错误,什么都没有上传 | 工具名拼错、第二步需要第二个文件的 pipeline、结果类型与输出文件名的扩展名矛盾 |
| 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,而且发生在写出之前。把会产出 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)。过滤工具的没有匹配,是它本来要给出的答案。
脚本里读它,用 --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 时,每个文件发出的键是 <键>:<序号>,机制见幂等键。
{
"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。