Saltar al contenido

PyPIApache-2.0sin nada AGPL ni GPL

pdf-tooling

Todas las tareas comunes de PDF. Una sola CLI segura. Sin copyleft debajo.

Instala pdf-tooling; el comando es pdftooling.

# Instalación (publicado en PyPI)
uv tool install pdf-tooling
# o bien: pip install pdf-tooling

Linux y macOS. macOS 15 o posterior en Apple silicon; las Mac con Intel no son compatibles.

bash · pdftoolingexit 0
pdftooling merge jul.pdf aug.pdf sep.pdf -O q3.pdf --dry-run
input output exit code message bytes before duration ms
------- ------ --------- ----------------- ------------ -----------
jul.pdf q3.pdf 0 12 pages selected 10253 0
aug.pdf q3.pdf 0 9 pages selected 7848 0
sep.pdf q3.pdf 0 11 pages selected 9452 0
ls q3.pdf
ls: cannot access 'q3.pdf': No such file or directory

Una corrida en seco planea la unión completa de tres archivos; después ls es la comprobación, y no se escribió nada.

Publicado en PyPI como pdf-tooling: todavía no hay fórmula de Homebrew, ni paquete del sistema, ni imagen de contenedor. Todo comando que imprime pdftooling --help ya viene incluido; consulta Comandos.

§01 / Seguridad

Qué le pasa a tus archivos

Cuatro garantías, verificadas por la propia suite de pruebas del producto en lugar de prometidas.

--dry-run es puro

Planea y reporta; no escribe nada, en ningún lado. Úsalo primero con cualquier cosa de la que no estés seguro: no queda un estado a medio escribir que limpiar después, porque no se escribió nada.

Nada se sobrescribe

Las salidas nunca sobrescriben. Una corrida destructiva en lote sin --force se rechaza con código 5 en lugar de preguntar, así que un script y una persona reciben la misma garantía: no hay una confirmación interactiva que un llamador automatizado pueda satisfacer por accidente.

Cada escritura es atómica; tus entradas no se tocan

Cada escritura va a un archivo temporal en el mismo sistema de archivos del destino, se sincroniza con fsync y luego se mueve a su lugar con un rename atómico. Las entradas nunca se modifican a menos que pidas --in-place, que primero deja un respaldo .bak junto al archivo.

Una contraseña nunca es un valor de línea de comandos

argv se puede leer desde /proc y queda en el historial de la terminal, así que nunca se acepta una contraseña como valor de línea de comandos: no existe --password, ni --user-password, ni --owner-password. Léela de un archivo o de stdin.

Los bits de permisos son informativos, no una restricción. Son una petición al lector, no un candado. El cifrado protege el contenido; los bits por sí solos no protegen nada.

§02 / Arquitectura

Lo bastante chico como para que lo revises tú mismo

Casi todas las herramientas te piden confiar en una lista larga de dependencias. Esta está hecha para que no tengas que hacerlo. Cada motor de PDF que usa queda detrás de 8 módulos adaptadores, archivos chicos cuyo único trabajo es hablar con un motor. Solo 1 archivo más, adapters/subprocess_util.py, puede lanzar otro programa, y nada más en todo el código tiene permiso de hacerlo. Son 9 archivos en total entre tú y cualquier motor, así que leerlos todos toma una tarde y no un proyecto. Escribir en tu disco está igual de acotado, y tests/test_import_boundaries.py hace fallar la compilación el día que cualquiera de los dos límites deje de cumplirse.

El diagrama de abajo muestra cómo está acomodado. No necesitas nada de esto para usar la herramienta.

  1. Capa L1src/pdf_tooling/cli/

    CLI

    La única capa que puede importar el framework de CLI.

    Interpreta las banderas, construye un solo OperationPlan inmutable y elige un renderizador.

  2. Capa L2src/pdf_tooling/ops/

    Ops

    Funciones simples, sin framework, que trabajan solo a través de los puertos de abajo. Aquí nunca se decide un rechazo del sistema de archivos.

    Sin entrada/salida salvo a través de Safety y Ports.

    Ops llega a Safety y a Ports en paralelo.

  3. Capa L3src/pdf_tooling/safety/

    Safety

    ▣ 1 ARCHIVO

    El único lugar que escribe en disco: un archivo, no un paquete. Las dos listas de permitidos de escritura están vacías.

    Escritura a temporal, fsync y rename atómico. Aquí vive la compuerta de --dry-run.

  4. Capa L4src/pdf_tooling/ports/

    Ports

    6 puertos

    Solo definiciones de typing.Protocol. Importar todos los módulos de puertos no carga ningún motor.

    StructureEngine · RasterEngine · ComposeEngine · TextEngine · OcrEngine · OfficeConverter

  5. Capa L5src/pdf_tooling/adapters/

    Adapters

    El único paquete que puede importar una biblioteca de motor. subprocess_util.py es el único módulo del árbol que puede lanzar un proceso.

    Ocho módulos sirven a los seis puertos de arriba. El noveno archivo es el que tiene permiso de lanzar un programa, no el respaldo de un motor.

    ▣ 8 módulos adaptadores▣ +1 archivo que lanza procesos: subprocess_util.py
  6. Frontera que se hace cumplir

    tests/test_import_boundaries.py

    arriba: sin importar motores, sin lanzar programas, sin escribir fuera del único archivo con permiso · abajo: todos los motores de terceros

  7. Motores

    pypdf · pypdfium2 · pikepdf · reportlab · pdfplumber · Tesseract · LibreOffice

    No son parte de este código. Solo licencias permisivas, verificadas en CI.

  8. Capa L6src/pdf_tooling/output/

    Output

    stdout es la carga útil; stderr es todo lo demás.

    Lo que devuelve Ops, renderizado como table, json o ndjson.

▣ marca un archivo que es el único con permiso para hacer su trabajo. Cada regla de frontera la hace cumplir la prueba que aparece junto a ella, y el riel corre en un solo sentido: hacia abajo.

Ver las pruebas que lo hacen cumplir
Cada capa, su regla y la prueba que la hace cumplir.
CapaRutaLa reglaLa hace cumplir
L1src/pdf_tooling/cli/La única capa que puede importar el framework de CLI.test_no_typer_or_click_import_below_l1
L2src/pdf_tooling/ops/Funciones simples, sin framework, que trabajan solo a través de los puertos de abajo. Aquí nunca se decide un rechazo del sistema de archivos.test_no_ops_module_names_a_filesystem_tier_refusal_class
L3src/pdf_tooling/safety/El único lugar que escribe en disco: un archivo, no un paquete. Las dos listas de permitidos de escritura están vacías.test_write_calls_inside_safety_are_confined_to_the_chokepoint · test_both_allowlists_are_empty
L4src/pdf_tooling/ports/Solo definiciones de typing.Protocol. Importar todos los módulos de puertos no carga ningún motor.test_importing_the_port_layer_loads_no_engine
L5src/pdf_tooling/adapters/El único paquete que puede importar una biblioteca de motor. subprocess_util.py es el único módulo del árbol que puede lanzar un proceso.test_no_engine_library_is_imported_outside_adapters · test_nothing_outside_the_chokepoint_can_spawn
L6src/pdf_tooling/output/stdout es la carga útil; stderr es todo lo demás.README.md ## Output contract

Tus archivos

Si una corrida muere a la mitad, tu archivo queda intacto

Mata el proceso, pierde el disco, desconecta la corriente: en otras herramientas el resultado habitual es un archivo truncado donde antes estaba tu documento. Aquí cada paso ocurre en un archivo de trabajo junto al destino, y tu propio archivo se toca exactamente una vez, al final, con un rename que el sistema operativo no puede dejar a medias. Nada más en toda la corrida puede alcanzarlo. Para eso sirve la única ruta de escritura de abajo: un solo archivo de este código tiene permiso de escribir (src/pdf_tooling/safety/atomic.py), y por eso la garantía aplica a todos los comandos y no nada más a los que alguien se acordó de hacer seguros.

  1. Planear / rechazar

    Rechaza un destino que ya existe si no pasas --force. Código 5.

  2. Compuerta de --dry-run

    Regresa antes de cualquier llamada al sistema de archivos, en ambos modos.

  3. Archivo temporal

    Un archivo temporal junto al destino, en el mismo sistema de archivos: eso es lo que hace atómico el rename.

  4. Escritura

    Escribir, vaciar el búfer, fsync.

  5. Respaldo .bak

    Solo con --in-place.

  6. aquí cambia tu archivo

    os.replace()

    Atómico. El único punto donde las dos vías se tocan.

Si algo falla en los pasos ① a ⑤: el temporal se elimina, tu archivo queda intacto y el proceso termina con un código distinto de cero. Una terminación forzada (SIGKILL) no se puede atender: puede dejar el temporal oculto, y tu archivo sigue intacto.

Todo lo que pasa antes del cruce sucede en un archivo de trabajo, en el mismo sistema de archivos que el destino; solo la última estación toca tu archivo, y lo hace de forma atómica.

§03 / Comandos

26 comandos, una sola gramática

Lo que de otro modo armarías con varias herramientas distintas, con una sola forma de decir qué páginas quieres. Todo comando que direcciona páginas acepta la misma gramática de rangos: 1-3,5,9-, even, odd, first, last, -1, !5. La aprendes una sola vez. Esta lista se verifica contra pdftooling --help en cada versión, así que no puede desviarse de la herramienta que instalaste.

diagnóstico · 3

doctor

Reporta el adaptador resuelto de cada puerto, su versión y una sugerencia de instalación acorde a tu sistema para lo que falte.

info

Número y tamaño de páginas, cifrado y bits de permisos, metadatos, productor, lista de fuentes, presencia de firma y rotación por página.

version

Reporta las versiones de la herramienta, del runtime y de los motores.

estructura · 11

merge

Concatena PDFs; selección de páginas por entrada con ruta:rango; una entrada de índice por archivo de origen.

split

De un PDF a muchos, por tamaño fijo de bloque, por rangos explícitos, página por página o en los marcadores de primer nivel.

extract

Escribe las páginas seleccionadas en un PDF nuevo, en el orden indicado.

delete

Escribe todo excepto las páginas seleccionadas.

rotate

Rota las páginas seleccionadas en múltiplos de 90°, de forma absoluta o relativa.

reorder

Reescribe el orden de las páginas a partir de una secuencia explícita; se permiten duplicados y se respeta el orden dado.

repair

Recuperación estructural de un PDF dañado o mal formado con el analizador de recuperación de libqpdf.

linearize

Reescribe para entrega progresiva por bytes ("vista web rápida").

permissions

Reporta los bits de permisos y el algoritmo de cifrado de un PDF cifrado.

meta get

Lee el diccionario de información del documento y el XMP.

meta set

Escribe o limpia campos de información del documento y el XMP.

rasterizado · 1

rasterize

De PDF a PNG/JPEG/TIFF/WEBP al DPI o ancho en píxeles que elijas.

composición · 2

compose

De imágenes a PDF. Las entradas JPEG se incrustan como flujos DCTDecode (conservan los bytes, sin recodificar).

create

De texto (v1) a PDF. Markdown y HTML detrás del extra [html] (fase 2).

texto · 2

text

Extrae texto: ruta rápida (pdfium) o con reconocimiento de maquetación (pdfplumber), con geometría por bloque.

tables

Detecta y extrae tablas a CSV o JSON.

optimización · 1

compress

Reduce el tamaño: flujos de objetos de libqpdf más recompresión de flujos (sin pérdida) y, opcionalmente, reducción y recompresión de imágenes con Pillow (con pérdida).

criptografía · 2

encrypt

Aplica AES-256 (o RC4-128 con --legacy) con contraseñas de usuario y de propietario, y un conjunto de permisos.

decrypt

Quita el cifrado cuando se da la contraseña correcta.

superposición · 3

watermark

Superpone o subpone texto generado sobre las páginas seleccionadas.

stamp

Superpone o subpone una página de un PDF existente sobre las páginas seleccionadas.

ocr

Agrega una capa de texto de Tesseract sobre los píxeles intactos: se genera un PDF solo de texto por página y se une.

externo · 1

convert

De Office a PDF con LibreOffice en modo headless.

Corre pdftooling --help para ver el árbol completo.

§04 / Ejemplos

Así se ve usarla

Cuatro sesiones reales, capturadas de la herramienta en lugar de escritas para esta página.

Hecha para agentes, no solo para personas

Un agente que maneja una herramienta de línea de comandos tiene que adivinar dos veces: si la salida que acaba de leer significa lo que cree, y si lo que pidió de verdad ocurrió. Aquí se quitan las dos adivinanzas. Toda ruta de lectura emite un schema_version declarado y se convierte en JSON por su cuenta cuando stdout no es una terminal. Toda falla es un objeto estructurado cuyo code es el mismo número con el que termina el proceso, tomado de una tabla congelada como API pública, así que un plan puede ramificar sobre un entero en lugar de reconocer texto en inglés. Y --dry-run reporta exactamente lo que haría una corrida real sin escribir nada, en ningún lado, lo que convierte planear-verificar-actuar en una secuencia real y no en una esperanza.

Averigua qué está instalado de verdad

Los motores son opcionales y se resuelven en tiempo de ejecución. Esto reporta qué encontró, en qué versión y qué falta, para que una máquina pueda decidir si un trabajo siquiera es posible antes de empezarlo.

bash · pdftoolingexit 0
pdftooling doctor -o table
port adapter available version kind
--------------- ---------- --------- -------- --------------
StructureEngine pypdf yes 6.16.2 python-package
RasterEngine pypdfium2 yes 5.13.0 python-package
ComposeEngine reportlab yes 5.0.1 python-package
TextEngine pdfplumber yes 0.11.10 python-package
OcrEngine tesseract yes 5.5.0 system-binary
OfficeConverter soffice yes 26.2.5.2 system-binary

Inspecciona un documento, pensando primero en la máquina

No se pasó ninguna bandera. La salida es JSON porque stdout no es una terminal: el mismo comando es legible para una persona cuando lo corres tú, y analizable cuando lo corre un script.

bash · pdftoolingexit 0
pdftooling info report.pdf | jq .documents[0]
{
"path": "report.pdf",
"size_bytes": 6695,
"page_count": 12,
"pdf_version": "1.3",
"encrypted": false,
"permissions": [],
"linearized": false,
"has_signature": false
}

Haz el trabajo

Un renglón por entrada, cada uno con su propio código de salida y su conteo de bytes. No se escribe nada hasta que se leyeron todas las entradas y se verificó el destino.

bash · pdftoolingexit 0
pdftooling merge report.pdf appendix.pdf -O book.pdf -o table
input output exit code message bytes after
------------ -------- --------- ----------------- -----------
report.pdf book.pdf 0 12 pages selected 8777
appendix.pdf book.pdf 0 4 pages selected 8777

Falla de una forma sobre la que un script puede actuar

Córrelo otra vez y se rehúsa en lugar de sobrescribir. El rechazo es un objeto estructurado con un código estable: no es un mensaje que haya que reconocer con expresiones regulares, ni una sobrescritura silenciosa.

bash · pdftoolingexit 5
pdftooling merge report.pdf appendix.pdf -O book.pdf
{
"schema_version": 1,
"error": {
"code": 5,
"kind": "refused",
"message": "book.pdf exists; pass --force to overwrite it",
"path": "book.pdf"
}
}
echo $?
5

el código de salida coincide con la carga útil

§05 / Códigos de salida

Dos contratos, un solo instrumento

7 códigos de salida que significan lo mismo en todos los comandos, más las formas en las que se renderiza -o table / -o json / -o ndjson. Los dos son API pública, los dos están respaldados por una prueba, y ninguno se escribió a mano en esta página.

El contrato de códigos de salida: 7 códigos, iguales en todos los comandos.
CódigoNombreSignificado
0OKÉxito, incluido un reporte vacío pero válido. Un --dry-run refleja el código que devolvería la corrida real, así que no siempre es 0.
1FAILURELa operación se ejecutó y falló: entrada corrupta, error del motor o un destino donde no se puede escribir.
2USAGEInvocación incorrecta: bandera desconocida, banderas mutuamente excluyentes, rango de páginas mal formado o subcomando desconocido.
3ENGINE_MISSINGFalta un motor o un binario requerido. El mensaje siempre trae una sugerencia de instalación.
4NO_INPUTInvocación válida, pero no hay nada sobre lo cual actuar.
5REFUSEDUna compuerta de seguridad lo rechazó.
6AUTHFalta la contraseña, es incorrecta o es del tipo equivocado.
Agrega un código; nunca renumeres uno. Romper esta tabla es un cambio de versión mayor, no un parche.
Las dos excepciones del código 0
La excepción del código 0: el veredicto de un motor
convert y ocr le entregan el operando a un binario fuera del proceso (soffice o tesseract) y un --dry-run puede no llegar a iniciarlo. Cada elemento en seco de esos comandos lleva engine_verified: false en su detalle, así que la vista previa nunca se lee como un éxito sin reservas.
La excepción del código 0: si una contraseña es correcta
Si una contraseña se puede resolver es algo decidible con solo comprobar que existe, y una vista previa lo predice; si una contraseña ya resuelta es correcta solo se puede decidir intentándola, y una vista previa no debe convertirse en un oráculo. Cada elemento en seco que nombra una fuente de contraseña lleva password_verified: false en su detalle.

El contrato de salida

Los errores son la única asimetría deliberada: con -o table un error es un mensaje de una línea en stderr, pero con -o json o -o ndjson es un objeto en stdout, así que un consumidor automatizado que lee stdout nunca tiene que leer además stderr para enterarse de que una corrida falló.

-o table
Una tabla alineada en texto plano, para una persona leyendo una terminal.
-o json
Un solo objeto, que lleva schema_version.
-o ndjson
Un objeto por elemento, uno por línea; cada línea lleva su propio schema_version.

§06 / Licencias

Licencias

Apache-2.0, y todos los motores que tiene debajo son BSD, MIT, Apache o MPL: nada AGPL ni GPL en el grafo de llamadas.

La garantía es un trabajo de CI y una prueba unitaria, no una promesa en un README: la compuerta de licencias hace fallar la compilación ante cualquier paquete AGPL, GPL o LGPL en la cerradura distribuida, y una prueba a nivel de código recorre todos los archivos bajo src/ buscando importaciones prohibidas y llamadas externas con subprocess: la fuga realista que un escaneo de licencias a nivel de paquete no puede ver por sí solo.

Calculado directo del mismo inventario generado que publica el inventario completo: la cerradura de instalación por omisión son 21 paquetes, el extra opcional [html] agrega 10 más, 31 paquetes en total.

pikepdf incluye libqpdf bajo MPL-2.0: copyleft a nivel de archivo, usado sin modificar como dependencia y compatible con Apache sobre esa base. Está documentado en NOTICE y en el inventario completo THIRD_PARTY_LICENSES.

Nada en la instalación por omisión lleva una licencia de la familia GPL, y el único renglón de todo el inventario que nombra una es opcional y ofrece MPL 1.1 como alternativa: pyphen 0.18.1 entra únicamente a través del extra opcional [html], con licencia triple GPLv2+, LGPLv2+ o MPL 1.1, a elección de quien lo toma.

Los 31 paquetes, con licencias, niveles y funciones →

Inventario completo: THIRD_PARTY_LICENSES.

Postura de producción

Con qué puedes contar

Tres promesas, y cómo comprobar cada una.

Contrato congelado
Las formas de salida estructurada y la tabla de códigos de salida son API pública desde la v1.0.0. Romper cualquiera de las dos exige un cambio de versión mayor y un incremento de schema_version, así que un script que escribas hoy sigue funcionando en todas las versiones menores y de parche.
Cómo te llega
Las versiones las construye y publica CI a partir de una etiqueta, sobre OIDC: el flujo de trabajo no guarda ningún token de subida, así que nada se puede publicar desde la máquina de alguien. El wheel y el sdist llevan procedencia atestiguada en PyPI, así que puedes verificar que el artefacto que instalaste se construyó desde este repositorio.
Cuando algo sale mal
Abre un issue: con el comando, las banderas, la entrada y lo que esperabas es suficiente para actuar. Toda corrección que se publica queda en el changelog, escrita por el commit que la hizo.

Instálala

Un solo comando, y nada escondido debajo

La misma instalación con la que abrió esta página: cópiala, córrela y comprueba lo demás por tu cuenta.

uv tool install pdf-tooling

Apache-2.0. Las formas de salida estructurada y la tabla de códigos de salida son API pública. Los errores y sus correcciones viven en los issues de GitHub y en el changelog. Las contribuciones son bienvenidas, bajo el DCO.