pdfx в терминале и в CI: от первой команды до надёжного скрипта
Командой 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-for ФАЙЛ=ПАРОЛЬ; опцию можно повторять.
Нужны промежуточные файлы — вызывайте инструменты по отдельности, иначе берите конвейер
Чтобы объединить, затем поставить водяной знак и затем сжать, когда промежуточные результаты на диске не нужны, соберите всё в один запрос через 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. Инструмент, которому нужен второй файл (например, overlay-pdfs, накладывающий другой PDF), не может быть шагом конвейера. 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
В этом режиме стандартный вывод несёт только байты файла; сообщения и ошибки идут в стандартный поток ошибок, так что перенаправление безопасно. Мы проверили это на PDF примерно в 1 КБ: стандартный вывод содержал PDF размером 1040 байт, столько же, сколько и файл, записанный через -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, причём до того, как что-либо записано. Запись в x.pdf ZIP-архива, который выдаёт Разделить 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 нет. При сбое стандартный вывод пуст, а всё находится в стандартном потоке ошибок. Когда вы раскрываете файлы по шаблону, скрипт должен обрабатывать оба формата, совпал один файл или несколько.
Соберите все эти правила в один шаг 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 пуст |
Вопросительный знак в конце .files[]? в jq не даёт отчёту для одного файла (в нём нет 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.