Las herramientas web tradicionales para manipular PDFs son un dolor de cabeza. Tienen límites de tamaño, interfaces lentas y, lo peor de todo, obligan a subir documentos privados a servidores desconocidos.
Para resolver esto, decidí construir PDF Ninja, una herramienta interactiva para la terminal escrita en Python que permite limpiar restricciones de seguridad, optimizar peso, fusionar mediante una cola visual y transformar imágenes en documentos finales.
A continuación te cuento cómo funciona por dentro, el porqué de cada decisión técnica y cómo puedes llevarlo al siguiente nivel. Si prefieres escucharlo, dale al play al principio del artículo.
🤔 ¿Qué vamos a conseguir?
- Una CLI en Python que desbloquea restricciones de copia e impresión de PDFs en segundos.
- Compresión de documentos eliminando objetos redundantes (garbage collection).
- Una interfaz interactiva en la terminal para unir muchos archivos usando una cola visual.
- Agrupar imágenes escaneadas (
.png,.jpg,.webp) en un único PDF limpio. - Un script autoinstalable que aprovisiona sus propias dependencias en tiempo de ejecución.
🧰 Prerrequisitos
- Python 3.9+ instalado en tu sistema (Linux, Windows o macOS).
- Conocimientos básicos de terminal y de
pip. - Tener
pipdisponible, idealmente dentro de un entorno virtual (.venv).
La buena noticia es que PDF Ninja se aprovisona solo: si le falta una librería, la instala automáticamente. Aun así, para un entorno limpio conviene crear un virtualenv y activarlo:
python -m venv .venv
source .venv/bin/activate # Linux / macOS
# .venv\Scripts\activate # Windows (PowerShell)
🛠️ El problema: dependencias cruzadas
Ninguna librería de Python por sí sola hacía todo bien. Las que abrían archivos protegidos no sabían comprimir de forma eficiente, y las que convertían imágenes no manipulaban los metadatos internos del PDF a bajo nivel. La solución pasó por coordinar un ecosistema de herramientas específicas:
pikepdf— basada en la arquitectura C++qpdf: para abrir y reescribir la estructura interna, quitando restricciones de propietario sin romper el archivo.PyMuPDF(fitz) — brutalmente rápida para renderizado y reescritura física: para comprimir con parámetros de garbage collection y deflate.rich— para dibujar la interfaz y las tablas en la terminal.readchar— para leer las teclas en crudo (flechas del cursor).Pillow— para procesar las imágenes antes de convertirlas a PDF.tqdm— barras de progreso en las operaciones masivas.
🚀 Autoinstalación de dependencias
Para que el script funcione en cualquier máquina Linux sin configuraciones manuales previas, uso un cargador automático mediante subprocess que detecta e instala lo que falta:
import subprocess
import sys
def instalar_dependencias():
"""Instala las librerías necesarias antes de importarlas."""
libs = [
("pymupdf", "fitz"),
("rich", "rich"),
("tqdm", "tqdm"),
("readchar", "readchar"),
("pikepdf", "pikepdf"),
("Pillow", "PIL"),
]
for lib_pip, lib_import in libs:
try:
__import__(lib_import)
except ImportError:
print(f"[*] Instalando dependencia faltante: {lib_pip}...")
try:
subprocess.check_call([sys.executable, "-m", "pip", "install", lib_pip])
except Exception as e:
print(f"[!] Error crítico instalando {lib_pip}: {e}")
sys.exit(1)
Por qué esto y no un requirements.txt fijo: evita obligar al usuario (o al pipeline de CI) a ejecutar comandos pesados de instalación a mano. Si una librería falta, el script se aprovisiona de forma autónoma usando el mismo binario que corre el programa (sys.executable), de modo que cae dentro del entorno virtual correcto.
🚀 El doble ataque ninja: desbloquear y comprimir
La función central combina dos pasadas sobre el archivo. Primero pikepdf reescribe el documento quitando las restricciones de propietario; luego fitz lo optimiza físicamente:
import pikepdf
import fitz
def limpiar_pdf(r_in, r_out, password="", comprimir=True):
# INTENTO 1: Abrir con pikepdf (quitando restricciones de dueño)
with pikepdf.open(r_in, password=password) as pdf:
pdf.save(r_out)
# INTENTO 2: Optimizar con fitz (PyMuPDF)
doc = fitz.open(r_out)
doc.save(
r_out,
garbage=4 if comprimir else 3,
deflate=comprimir,
incremental=False,
)
doc.close()
pikepdfreescribe la estructura eliminando los permisos de edición/impresión impuestos por el creador.fitzcongarbage=4ydeflate=Truefuerza la eliminación de objetos duplicados u huérfanos dentro del flujo binario (fuentes no usadas, imágenes repetidas), logrando reducciones de tamaño muy drásticas.
Nota legal: esto solo funciona con restricciones de propietario (que protegen acciones pero no el contenido). Un PDF cifrado con contraseña de usuario para impedir la lectura no debe desbloquearse si no tienes derecho a ello. Respeta siempre los derechos de autor y las licencias.
🛠️ La interfaz interactiva en la terminal
Unir una lista estática de PDFs es fácil; lo difícil es diseñar una UI dentro de la propia terminal que permita filtrar en tiempo real, mover un cursor con las flechas y añadir archivos a una cola (🛒 COLA). Para el refresco uso rich.live.Live con tablas estructuradas:
from rich.console import Console
from rich.live import Live
from rich.panel import Panel
from rich.table import Table
console = Console()
def renderizar_tabla(lista_disponibles, seleccionado, lista_cola, en_cola):
grid = Table.grid(expand=True, padding=1)
grid.add_column(ratio=5)
grid.add_column(ratio=5)
grid.add_row(
Panel(
render_lista(lista_disponibles, seleccionado, not en_cola),
title="📂 DISPONIBLES",
border_style="blue",
),
Panel(
render_lista(lista_cola, seleccionado, en_cola),
title="🛒 COLA",
border_style="green",
),
)
return grid
# En el bucle principal, con readchar lees flechas y refrescas con Live
with Live(console=console, refresh_per_second=15) as live:
# ... procesar teclas y actualizar estado ...
live.update(renderizar_tabla(disponibles, idx, cola, en_cola))
Por qué rich.live.Live y no clear: en lugar de borrar toda la pantalla con comandos del sistema (que eliminan el histórico y el contexto), Live refresca únicamente la región ocupada por la tabla, varias veces por segundo, creando una experiencia fluida y respetando el historial de comandos previos para poder inspeccionar errores anteriores al instante.
🚀 Convertir imágenes a PDF
La parte de digitalización agrupa ráfagas de imágenes escaneadas en un único documento. Con Pillow abrimos cada imagen y la volcamos como página del PDF final:
from PIL import Image
from pathlib import Path
def imagenes_a_pdf(directorio, salida="resultado.pdf"):
lista_imgs = []
for ruta in sorted(Path(directorio).glob("*.png")):
img = Image.open(ruta).convert("RGB")
lista_imgs.append(img)
if lista_imgs:
primera = lista_imgs.pop(0)
primera.save(salida, save_all=True, append_images=lista_imgs)
print(f"✔️ PDF generado: {salida}")
🧪 Comprobar que funciona
- Ejecuta el script contra un PDF protegido y verifica que ya no hay aviso de “protegido” al abrirlo en un visor.
- Compara el tamaño antes y después:
ls -lh original.pdffrente als -lh resultado.pdfdebe mostrar un archivo menor. - En la vista de cola, mueve el cursor con las flechas, pulsa una tecla para añadir a
🛒 COLAy confirma la fusión: el archivo de salida tendrá el número de páginas de los archivos en cola. - Comprueba el código de salida tras un lote:
echo $?debe devolver0.
⚠️ Errores comunes
| Error | Causa | Solución |
|---|---|---|
pikepdf.PasswordError | El PDF está cifrado con contraseña de usuario | Pásale la contraseña al parámetro password o deja el archivo si no tienes permiso |
FileNotFoundError al guardar | El directorio de salida no existe | Crea la carpeta destino con Path(dir_out).mkdir(parents=True, exist_ok=True) |
MemoryError con muchas imágenes | Se cargan todas las imágenes en RAM a la vez | Procesa de forma incremental guardando a un archivo temporal en disco |
| La lista no se actualiza en la cola | Falta refrescar el estado en el bucle | Llama a live.update(...) tras cada cambio de estado |
subprocess instaló la librería en otro Python | El entorno virtual no estaba activado | Activa el .venv antes de ejecutar el script |
| El PDF sale “corrupto” después de comprimir | garbage demasiado agresivo en algunos PDFs | Prueba con garbage=3 y deflate=False antes de dar el salto a 4 |
🪜 Siguiente nivel
- Procesamiento en paralelo — el lote actual es secuencial. Con
concurrent.futures.ThreadPoolExecutorpuedes procesar cientos de PDFs a la vez aprovechando todos los núcleos de la CPU. - Uso de memoria en imágenes — sustituye la lista en RAM por un volcado incremental a disco para no agotar memoria con 500 fotos en alta resolución.
- Automatízalo en un
crono GitHub Action para limpiar un directorio de PDFs cada noche. - Amplía tu arsenal de automatización en Python con la Guía de Python: de 0 a 100 y el caso de generar XML desde Python.
