pdfx en la terminal y en CI: del primer comando a un script fiable
Usa pdfx en la línea de comandos para unir, comprimir y poner marcas de agua, procesar muchos archivos a la vez y encadenar herramientas en una sola petición con un pipeline. Para scripts y CI, aprende en qué puedes confiar: los códigos de salida, el informe de --json, la entrada y la salida estándar, y por qué una herramienta de filtro condicional sin coincidencia sigue saliendo con 0.

Antes de poner pdfx en un script o en integración continua (CI), recuerda dos cosas. Que el código de salida sea 0 no siempre significa que se escribió un archivo: una herramienta de filtro condicional sin coincidencia también sale con 0. Y --json tiene una forma distinta para una entrada que para varias, así que un script que analiza solo el array files no encuentra nada cuando solo coincidió un archivo. pdfx es un cliente HTTP: los archivos se suben al servidor para procesarse, no hay motor local ni modo sin conexión. La referencia completa de comandos está en la guía de la CLI en la página para desarrolladores.
Instálalo y luego une dos archivos
Necesitas Node 20.3 o superior, o Bun:
npm install -g @pdf123/cli
pdfx --version
Si prefieres no instalar globalmente, pon npx @pdf123/cli delante del comando. Con dos PDF en el directorio actual:
pdfx merge a.pdf b.pdf -o merged.pdf
Si todo va bien, la salida estándar imprime merged.pdf. Eso fue Unir PDF. Antes de cambiar de herramienta, pide los campos en lugar de adivinar los nombres de los parámetros:
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
(Es un extracto; los campos incluyen también --rotation, --customColor y otros.) Un campo es simplemente una opción de la línea de comandos:
pdfx watermark in.pdf --watermarkText DRAFT -o marked.pdf
pdfx list muestra las 95 herramientas, --category security muestra solo una categoría y --query watermark busca por palabra.
Varios archivos van a un directorio, y los archivos existentes no se sobrescriben
Si das varias entradas a una herramienta de un solo archivo, cada archivo se escribe en el directorio indicado con -o en cuanto termina, sin esperar al final. Si un archivo falla, los demás continúan, y el código de salida al terminar el lote es 1.
pdfx compress a.pdf b.pdf -o small/
La salida estándar de una ejecución fue esta, y el orden puede cambiar cada vez:
a.pdf -> small/a.pdf
b.pdf -> small/b.pdf
Un directorio existente funciona tal cual; si el directorio aún no existe, -o necesita una / final; de lo contrario small se trata como nombre de archivo. Sin -o, los resultados quedan en el directorio actual con el nombre que da el servidor; un archivo existente no se sobrescribe y el nuevo pasa a ser a-1.pdf, a-2.pdf. Un nombre de archivo concreto (-o same.pdf) reemplaza el contenido existente, como indicaste. Por defecto se procesan dos archivos a la vez; cámbialo con --concurrency. Si pulsas Ctrl-C a mitad de un lote, los archivos ya escritos se conservan y el código de salida es 130.
Abre un archivo cifrado con --input-password CONTRASEÑA. Cuando un lote mezcla archivos bloqueados y no bloqueados, usa --password-for ARCHIVO=CONTRASEÑA, que puedes repetir.
Llama a las herramientas por separado para obtener archivos intermedios; si no, usa un pipeline
Para unir, luego poner marca de agua y luego comprimir, cuando no necesitas los resultados intermedios en disco, reúnelo todo en una sola petición con pipeline; los archivos intermedios no se descargan ni se vuelven a subir. Si quieres el archivo de cada paso, llama a las herramientas por separado. Un pipeline produce un único archivo.
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
Un pipeline tiene como máximo 8 pasos; un noveno paso recibe HTTP 400: at most 8 pipeline steps allowed. Una herramienta que necesita un segundo archivo (por ejemplo overlay-pdfs, que superpone otro PDF) no puede ser un paso de pipeline. pdfx la rechaza antes de subir nada, con código de salida 2 y el mensaje Tool "overlay-pdfs" needs a second file and cannot run as a pipeline step.
Usa la entrada estándar solo cuando quieras conectar el comando a una tubería. Un argumento de archivo - lee de la entrada estándar, y -o - escribe el resultado en la salida estándar:
cat report.pdf | pdfx compress - -o - > report-small.pdf
En este modo la salida estándar lleva únicamente los bytes del archivo; los mensajes y errores van al error estándar, así que redirigir es seguro. Lo comprobamos con un PDF de unos 1 KB: la salida estándar contenía un PDF de 1040 bytes, del mismo tamaño que el archivo escrito con -o, y el error estándar estaba vacío. Cuando lees de la entrada estándar y no indicas -o, el resultado se llama stdin.pdf, se escribe en el directorio actual y se avisa con una nota en el error estándar.
Los scripts deben comparar el motivo, no el mensaje
| Código de salida | Significado | Ejemplos |
|---|---|---|
| 0 | Éxito, o una herramienta de filtro condicional sin coincidencia | Una unión tuvo éxito; la condición de filter-page-count fue falsa |
| 1 | La petición se envió pero falló; en un lote, al menos un archivo falló | Contraseña incorrecta, no es un PDF, tiempo de espera agotado, nada que devolver |
| 2 | Error de uso, no se subió nada | Nombre de herramienta mal escrito, un paso de pipeline que necesita un segundo archivo, un tipo de resultado que contradice la extensión del archivo de salida |
| 130 | Lo interrumpiste | Ctrl-C durante un lote |
Cuando algo falla, además de una línea de mensaje, el error estándar lleva tres líneas: reason:, code: y hint:. Un archivo cifrado con la contraseña incorrecta produjo esto en local:
pdfx: HTTP 400: The password is incorrect.
reason: wrong_password
code: bad_request
hint: Check the password and try again.
La forma de pasar la contraseña cambia la primera línea del mensaje: unlock --password da la línea de arriba. Con --input-password en otra herramienta, el servidor trata el desbloqueo como un paso interno y la primera línea es HTTP 400: Pipeline step 0 (security/remove-password) failed: The password is incorrect., mientras que la línea reason: es wrong_password en ambos casos. Sin ninguna contraseña, el mensaje es This PDF is password-protected. Enter its password. y reason es password_required. Un script debe comparar la línea reason:. code es más grueso, y un valor habitual es bad_request.
Un nombre de herramienta mal escrito sale con 2, y el mensaje sugiere nombres parecidos:
pdfx: Unknown tool "compres". Did you mean: compress, decompress-pdf? Run `pdfx list` to see all tools.
Un tipo de resultado que contradice el nombre del archivo también sale con 2, y ocurre antes de escribir nada. Aquí, escribir en x.pdf el ZIP que produce Dividir 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
Un tiempo de espera agotado también sale con 1, con code igual a timeout. La unidad de --timeout son los milisegundos y el valor por defecto es 300000, es decir, 5 minutos, así que --timeout 60 significa 60 milisegundos, no 60 segundos.
Cuando la condición es falsa, el código de salida sigue siendo 0
Una clase de herramientas responde una pregunta de sí o no: si el número de páginas es mayor que N, si el archivo contiene cierto texto, si el archivo pesa más que cierto tamaño. Cuando la respuesta es sí, devuelven el archivo de entrada sin cambios; cuando es no, no devuelven nada, y pdfx imprime una línea no match y sale con 0. Estas herramientas de filtro existen solo en el SDK, la línea de comandos y MCP; el sitio web no tiene página para ellas.
Esto difiere de otro tipo de resultado vacío. Cuando PDF a CSV no encuentra ninguna tabla en el PDF, el servidor devuelve 204 y pdfx sale con 1 e informa no_content: la herramienta debía producir contenido y no lo hizo, y el motivo está en Exportación de tabla vacía (204): tu PDF probablemente no tiene columnas. Para una herramienta de filtro, «sin coincidencia» es la respuesta que debía dar.
Para leerlo en un script, usa --json. Una coincidencia imprime el archivo que se escribió; sin coincidencia se imprime { "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 }
Para quedarte solo con los archivos de más de 2 páginas, puedes escribirlo así. Lo ejecutamos en local con a.pdf (1 página) y three.pdf (3 páginas), y solo se conservó el segundo:
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: omitido"
fi
done
jq -e '.matched == false' sale con 0 cuando no hay coincidencia y con 1 cuando la hay. Cuando hay coincidencia, pdfx ya ha escrito el archivo en big/; el if solo decide si se imprime «omitido», no si se escribe.
Con una sola entrada, --json no trae el array files
Con varias entradas, la salida estándar bajo --json es un informe completo. input es una ruta absoluta. processed cuenta los archivos cuya petición tuvo éxito, incluidos los que no tuvieron coincidencia. unmatched es cuántos de ellos no tuvieron coincidencia, y sus entradas son { "ok": true, "matched": false } sin path. Cuando ningún archivo de un lote tiene coincidencia, el código de salida sigue siendo 0. Cuando pasas --idempotency-key a un lote, la clave enviada para cada archivo es <key>:<index>; el mecanismo está en Idempotency-Key: reintentos seguros para trabajos 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 }
]
}
Con una sola entrada se usa la ruta de un solo archivo: --json imprime { "path": ..., "contentType": ..., "bytes": ... } y no hay array files. Si algo falla, la salida estándar queda vacía y todo va al error estándar. Cuando expandes archivos con un glob, el script tiene que manejar ambos formatos, tanto si coincidió un archivo como si coincidieron varios.
Reúne las reglas anteriores en un único paso de CI
Este script solo comprime y no usa ninguna herramienta de filtro. Un fallo de compresión sale con 1, y el paso falla con él. Una herramienta de filtro sin coincidencia sale con 0, así que la CI no falla solo porque no hubo coincidencia; si la falta de coincidencia es un problema o no, lo decides tú leyendo --json, como en la sección de filtros de arriba.
Si se rechaza algún archivo, este paso falla y lista en el log los nombres de archivo y los motivos. La lógica es la de antes: leer primero el código de salida, imprimir el error estándar si hay fallo y luego usar jq para extraer el reason del informe del lote. Sin argumento de directorio sale de inmediato, para que "$1"/*.pdf nunca se expanda a /*.pdf.
#!/usr/bin/env bash
# Uso: ./ci-step.sh docs
docs="${1:?uso: ./ci-step.sh <directorio>}"
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"
Lo ejecutamos en local con cuatro tipos de entrada:
| Archivos en el directorio | Código de salida | Log |
|---|---|---|
| Dos PDF buenos | 0 | ninguno |
| Dos PDF buenos más uno dañado | 1 | pdfx: broken.pdf: HTTP 400: ..., luego una línea broken.pdf invalid_pdf |
| Un solo PDF bueno | 0 | ninguno; report.json está en el formato de un solo archivo |
| Un solo PDF dañado | 1 | reason: invalid_pdf, code: invalid_document y una línea hint; report.json está vacío |
El signo de interrogación al final de .files[]? en jq evita que el informe de un solo archivo (que no tiene files) provoque un error. Un único archivo dañado no tiene informe de lote y el motivo aparece solo en errors.log, así que conserva ambas salidas.
Hay tres cosas más que conviene comprobar antes de llevar esto a CI. pdfx necesita red: los archivos se suben a PDFX_API_BASE, que por defecto es https://pdf123.xyz. Para documentos que deben permanecer dentro de tu red, ejecuta un servicio propio y apunta esta variable a él; consulta Qué te compra realmente el self-hosting (y qué te cuesta). Cuando necesites una identidad, pon la clave en PDFX_API_KEY y no en los argumentos de la línea de comandos, donde quedaría en la lista de procesos y en los logs. La versión publicada actualmente es la 0.1.0; en CI usa npx @pdf123/[email protected] ... para fijarla, de modo que el formato de salida y los códigos de salida no cambien con las nuevas versiones y una actualización sea un cambio que haces a propósito.
La página del paquete en npm es @pdf123/cli.