Construyendo PDF Ninja: Cómo vencí las restricciones de PDFs con Python y la Terminal

Construyendo PDF Ninja: Cómo vencí las restricciones de PDFs con Python y la Terminal

La historia y los fragmentos clave de un CLI interactiva en Python para desbloquear, comprimir, unir y dividir PDFs de forma masiva, sin depender de servicios web de terceros. Incluye el porqué de cada decisión técnica.

FECHA:
ACTUALIZADO:
AUTOR:Jorge Beneyto Castelló
LECTURA:7 MINUTOS DE LECTURA

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 pip disponible, 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()
  • pikepdf reescribe la estructura eliminando los permisos de edición/impresión impuestos por el creador.
  • fitz con garbage=4 y deflate=True fuerza 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.pdf frente a ls -lh resultado.pdf debe mostrar un archivo menor.
  • En la vista de cola, mueve el cursor con las flechas, pulsa una tecla para añadir a 🛒 COLA y 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 devolver 0.

⚠️ Errores comunes

ErrorCausaSolución
pikepdf.PasswordErrorEl PDF está cifrado con contraseña de usuarioPásale la contraseña al parámetro password o deja el archivo si no tienes permiso
FileNotFoundError al guardarEl directorio de salida no existeCrea la carpeta destino con Path(dir_out).mkdir(parents=True, exist_ok=True)
MemoryError con muchas imágenesSe cargan todas las imágenes en RAM a la vezProcesa de forma incremental guardando a un archivo temporal en disco
La lista no se actualiza en la colaFalta refrescar el estado en el bucleLlama a live.update(...) tras cada cambio de estado
subprocess instaló la librería en otro PythonEl entorno virtual no estaba activadoActiva el .venv antes de ejecutar el script
El PDF sale “corrupto” después de comprimirgarbage demasiado agresivo en algunos PDFsPrueba con garbage=3 y deflate=False antes de dar el salto a 4

🪜 Siguiente nivel

  1. Procesamiento en paralelo — el lote actual es secuencial. Con concurrent.futures.ThreadPoolExecutor puedes procesar cientos de PDFs a la vez aprovechando todos los núcleos de la CPU.
  2. 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.
  3. Automatízalo en un cron o GitHub Action para limpiar un directorio de PDFs cada noche.
  4. 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.

🔗 Enlaces y recursos

COMPARTIR:
COMENTARIOS:

📋 Contenido