Pipeline para convertir PDFs (y docx/pptx) a Markdown limpio, con imágenes extraídas y enlazadas con rutas relativas. Se usa de dos formas:
- CLI —
python -m pipeline ...sobre una carpeta de documentos propia (que no vive en este repo; ver.gitignore). - Web — subir documentos por el navegador y descargar el Markdown en un ZIP. Pensada para desplegarse en un servidor (ver Docker).
Dos motores para PDF, con trade-offs distintos:
-
pymupdf4llm: rápido, sin GPU, sin dependencias pesadas. Buen resultado en PDFs de una columna con tablas simples. Falla en layouts multicolumna complejos: mezcla el texto de columnas paralelas en un solo párrafo. No hace OCR: sobre un PDF escaneado devuelve un documento vacío. -
Marker(Datalab): el único de los dos que hace OCR real. Resuelve multicolumna y genera mejor jerarquía de encabezados en documentos densos (manuales, doctrina, informes largos). Mucho más lento y con más setup.Marker tiene dos modos y elige solo según el dispositivo:
Modo Qué hace Dónde corre fastDetectores CPU ligeros para layout/tablas; OCR solo de bloques ilegibles Default en CPU/MPS balancedModelo de layout VLM + OCR de página completa; mejor calidad Default en GPU. Requiere el binario llama-serverEsto importa para la decisión de hardware: en un servidor sin GPU no solo vas más lento, corres un modo distinto y de menor calidad.
Trampa con GPU: Marker elige
balancedal ver una GPU, pero ese modo necesita el binariollama-server, que la imagen Docker solo trae si se construyó conWITH_LLAMA_SERVER=true. Esa combinación —GPU + imagen sin el binario— haría fallar todas las conversiones. El pipeline lo detecta y cae afastcon un aviso en el log, así que no revienta; para usarbalancedde verdad hay que reconstruir la imagen con ese build-arg.
El modo auto (por defecto) hace la elección por documento: clasifica cada
PDF con el inventario y manda a Marker solo los escaneados. Es lo que conviene
en casi todos los casos.
pipeline/ núcleo: rutas, inventario, conversores, captions, QC
paths.py resolución de rutas de entrada/salida
inventory.py fase 1 — clasificar nativo vs escaneado
converters.py pymupdf4llm / Marker / pandoc / python-pptx
captions.py descripción de imágenes (Ollama local o API, reanudable)
qc.py fase 4 — validación e inventario final
cli.py CLI unificada (python -m pipeline)
webapp/ interfaz web (FastAPI) + cola de trabajos (SQLite)
patches/ parches a bugs upstream de marker-pdf y surya-ocr
*.py, *.sh wrappers de compatibilidad de los comandos originales
La CLI y la web comparten exactamente el mismo código de conversión; no hay dos implementaciones que puedan desincronizarse.
Requiere Python 3.10+ (marker-pdf y FastAPI no soportan 3.9; el python3
del sistema en macOS suele ser 3.9 — usar brew install python@3.12).
python3.12 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt # núcleo (incluye Marker)
pip install -r requirements-web.txt # solo si vas a usar la interfaz web
# Alternativa sin Marker (sin torch, sin OCR — mucho más liviana):
# pip install -r requirements-light.txt
# Necesario para el modo `balanced` de Marker (el de mayor calidad), en
# cualquier plataforma — no solo en Mac:
brew install llama.cpp
# Solo si vas a convertir .docx:
brew install pandoc
# Aplicar SIEMPRE después de instalar/actualizar marker-pdf, antes de usar
# --mode balanced (ver detalle de los bugs en patches/*.py). Ahora SALEN CON
# ERROR si el patrón esperado no está — no fallan en silencio:
python patches/fix_surya_grammar.py
python patches/fix_marker_empty_image.pyLas versiones de
requirements.txtestán pineadas a propósito. Los parches reescriben líneas literales demarker-pdf 2.0.0ysurya-ocr 0.22.1. Al subir cualquiera de las dos, revisarpatches/antes de desplegar.
# Fase 1 — inventario y diagnóstico
python -m pipeline inventario /ruta/a/documentos
# Fase 2 — piloto sobre unos pocos documentos representativos
python -m pipeline convertir "/ruta/doc1.pdf" "/ruta/doc2.pdf" --engine auto
# Fase 3 — lote completo (acepta archivos o directorios, rutas absolutas)
python -m pipeline convertir /ruta/a/documentos --engine auto --skip-existing
# Descripción de imágenes (opcional; por defecto con Ollama local)
python -m pipeline captions --dry-run # cuenta, no llama al modelo
python -m pipeline captions --limit 3 # smoke test
python -m pipeline captions # todo
# Fase 4 — control de calidad
python -m pipeline qc
# Todo de una
python -m pipeline todo /ruta/a/documentos --engine auto --captionsLos scripts originales siguen funcionando como wrappers: inventario.py,
convertir_pymupdf.py, convertir_marker.sh, convertir_docx.sh,
convertir_pptx.py, caption_imagenes.py, audit_dedup_captions.py,
qc_inventario.py.
Todo queda en output/<estructura de carpetas saneada>/<documento>/, con
imágenes en assets/ (o media/ para pandoc; Marker las deja planas junto al
.md) y enlaces relativos al propio .md — la carpeta de cada documento es
autocontenida y portable.
La estructura relativa se calcula contra el ancestro común del lote. Si
mezclas documentos de árboles muy distintos (p. ej. /Users/... y /mnt/...),
el ancestro común es / y las rutas de salida salen profundas; usa
--input-root /ruta/base para fijarla explícitamente.
| Flag | Para qué |
|---|---|
--engine auto|pymupdf|marker |
Motor de PDF; auto decide por documento |
--skip-existing |
No reconvertir lo que ya tiene un .md no vacío — reanuda un lote interrumpido |
--out DIR |
Directorio de salida (default output) |
--input-root DIR |
Raíz para las rutas relativas de salida |
pip install -r requirements-web.txt
uvicorn webapp.main:app --host 0.0.0.0 --port 8000Abrir http://localhost:8000. Permite subir varios documentos a la vez, elegir
motor, activar el captioning, seguir el progreso en vivo y descargar el
resultado como ZIP.
Puedes dejar varios lotes encolados y desentenderte:
- Cada envío crea un trabajo, que se encola y se procesa en segundo plano. La respuesta es inmediata: no hay que esperar con el navegador abierto.
- Con
WORKERS=1(el default, y lo correcto con Marker) los trabajos corren estrictamente de uno en uno: el siguiente arranca cuando el anterior termina, sin solaparse. Un documento con Marker ya satura la GPU. - Cada trabajo va
queued→running→done/error, con su propio log y su ZIP al terminar. - Un trabajo que falla no detiene la cola: queda en
errory el siguiente arranca igual. Dentro de un trabajo, un documento que falla tampoco aborta los demás. - Sobrevive a reinicios: al arrancar, los trabajos que quedaron
runningoqueuedse reencolan y continúan. Como la conversión salta lo ya hecho, se reanuda en vez de rehacer. En el log verásreanudados=N.
Al arrancar, el servicio deja en el log el diagnóstico de lo que va a usar — útil porque una GPU que no llega al contenedor degrada la conversión en silencio:
[webapp] listo (sin autenticación). workers=1 reanudados=0 purgados=0
[webapp] conversión: GPU NVIDIA L4 · torch 2.13.0+cu130 · marker: auto por dispositivo
[webapp] captions: Ollama local (qwen3-vl:32b)
| Variable | Default | Para qué |
|---|---|---|
DATA_DIR |
data |
Dónde viven subidas, resultados y la base de trabajos |
WORKERS |
1 |
Trabajos simultáneos. Con Marker dejar en 1: uno ya satura la máquina |
MAX_UPLOAD_MB |
500 |
Tope por archivo |
RETENTION_DAYS |
14 |
Borra trabajos terminados más antiguos (0 = nunca) |
MARKER_MODE |
(vacío) | Vacío = Marker elige por dispositivo, pero se fuerza fast si falta llama-server |
MARKER_TIMEOUT_S |
14400 |
Corta un documento colgado en vez de bloquear el lote |
CAPTION_BACKEND |
ollama |
ollama (local, nada sale) o anthropic (API externa) |
OLLAMA_HOST |
http://localhost:11434 |
En Docker: http://ollama:11434 (contenedor) o http://host.docker.internal:11434 (host) |
CAPTION_MODEL |
qwen3-vl:32b |
Modelo de visión (o claude-sonnet-5 con backend anthropic) |
CAPTION_WORKERS |
1 (ollama) |
Ollama atiende de a una; subirlo no acelera |
ANTHROPIC_API_KEY |
— | Solo con CAPTION_BACKEND=anthropic |
OLLAMA_TIMEOUT_S |
300 |
Un 30B tarda decenas de segundos por imagen |
La app no tiene autenticación. Cualquiera que alcance el puerto puede subir documentos, ver los de los demás y borrarlos. Por eso el
docker-compose.ymlla publica solo en127.0.0.1. Para exponerla en la red o a internet, poner delante un proxy inverso que resuelva el acceso (nginx/Caddy con autenticación y TLS, o una VPN) — no basta con abrir el puerto.
# Imagen completa (con Marker/OCR). Tarda: arrastra torch + CUDA (~9.7 GB
# de imagen final, medido en arm64).
docker compose up -d --build
# Imagen ligera, sin Marker ni torch (~1.3 GB): PDFs nativos, docx y pptx.
# Sin OCR: un PDF escaneado sale vacío.
docker build --build-arg WITH_MARKER=false -t doc2md-light .Configuración por .env junto al docker-compose.yml:
PORT=8000
WORKERS=1
# ANTHROPIC_API_KEY=sk-ant-... # solo si quieres captioning de imágenesEl servicio se publica en 127.0.0.1:8000 del host. Para llegar desde otra
máquina, ponle delante un proxy inverso (que además es donde toca resolver
autenticación y TLS) en vez de abrir el puerto directamente.
Todo el estado (subidas, resultados, base de trabajos y la caché de pesos del
modelo) vive en el volumen doc2md-data montado en /data. El contenedor es
desechable; el volumen no. Sin ese volumen, Marker vuelve a descargar 2-4 GB de
pesos en cada recreación.
Los wheels de torch que instala marker-pdf en linux/amd64 ya traen CUDA, así
que la misma imagen sirve para CPU y GPU — lo que cambia es el runtime:
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
# Verificar que el contenedor ve la GPU:
docker compose exec app python -c "import torch; print(torch.cuda.is_available())"Requiere el NVIDIA Container Toolkit en el host.
Para aprovechar la GPU de verdad hace falta el modo balanced, y ese modo
necesita el binario llama-server, que la imagen por defecto no trae:
docker build --build-arg WITH_MARKER=true --build-arg WITH_LLAMA_SERVER=true -t doc2md .Compila llama.cpp en una etapa aparte (solo el binario llega a la imagen
final). Sin él, Marker corre en modo fast incluso con GPU presente, y forzar
MARKER_MODE=balanced falla con llama-server binary not found.
docker compose run --rm app cli inventario /data/entrada
docker compose run --rm app cli convertir /data/entrada --engine auto --out /data/salidaSolo una etapa del pipeline es pesada, así que la decisión depende de cuántas páginas caen en la ruta Marker — no de cuántos documentos hay:
| Etapa | Recurso | ¿Gana con GPU NVIDIA? |
|---|---|---|
| inventario / QC | CPU, I/O | No |
| pymupdf4llm, pandoc, pptx | CPU | No — segundos por documento |
Marker --mode balanced |
VLM | Sí, mucho |
| captioning | red / API de Claude | No — es latencia de red |
Con ~15 s/página de media en Apple Silicon (Metal), un MacBook Pro M3 Pro hace ~240 páginas/hora en Marker:
- < 2.000 páginas Marker → local, de noche. No hace falta servidor.
- 2.000–10.000 → local es viable en un fin de semana; una GPU rentada se paga sola en horas ahorradas.
- > 10.000, o proceso recurrente → servidor. En una L4/A10/4090 el camino
torch hace batching real, que en Apple Silicon vía
llama-serverno existe (es un solo stream): entre 5x y 15x.
A la velocidad se suma la calidad: en CPU, Marker corre en modo fast
(detectores ligeros); el modo balanced — layout VLM + OCR de página completa,
que es lo que resuelve bien la multicolumna — es el default en GPU. Un
servidor con GPU no solo termina antes: produce mejor Markdown.
Corre python -m pipeline inventario sobre el corpus real antes de decidir.
Si sale mayormente nativo, todo esto es discutir sobre nada: pymupdf4llm lo
resuelve en minutos en cualquier portátil.
Y antes de la velocidad, mira la sensibilidad de los documentos. Si no pueden salir de la organización, eso descarta tanto la GPU rentada en la nube como el captioning vía API — y entonces "esperar toda la noche en el portátil" es la opción correcta, no la lenta.
Nota de memoria: 18 GB unificados bastan para un stream de Marker (pesos 2-4 GB
- torch + llama-server), pero van justos con Docker, IDEs y navegador abiertos. La presión de memoria manda a swap y la degradación es brutal — cierra lo demás antes de un lote largo.
- pymupdf4llm 1.28.x: con
write_images=True, si la ruta de salida tiene espacios o paréntesis, el guardado de imágenes falla (code=2: cannot open file) por una inconsistencia entre la ruta saneada para el enlace Markdown y la real usada enpix.save(). Workaround aplicado enpipeline/paths.py(slugify): se sanean los nombres antes de pasarlos a la librería. - surya-ocr 0.22.x (dependencia de marker-pdf, modo
balanced): bug de gramática GBNF con\dque rompe el layout inference en Mac/CPU/MPS. Ver datalab-to/surya#542 ypatches/fix_surya_grammar.py. - marker-pdf 2.0.0: si una caja de layout se recorta a área cero (bbox
degenerado), Pillow revienta con
ValueError: cannot write empty image as JPEGy aborta la conversión COMPLETA del documento, aunque llevara 20+ minutos. Verpatches/fix_marker_empty_image.py(salta esa imagen puntual con un aviso en vez de abortar). Sin fix upstream conocido a la fecha. marker_singleanida su propio output: crea--output_dir/<nombre original>/<archivo>.mden vez de--output_dir/<archivo>.md, y con el nombre sin sanear.pipeline/converters.pylo aplana automáticamente.- Multicolumna: incluso con Marker, revisar manualmente documentos con layouts muy irregulares (outlines multinivel, tablas anidadas) — es el punto débil común a ambas herramientas.
- PPTX con
.emf/.wmf: esos formatos (típicos de diagramas pegados desde Office) se extraen igual, pero se enlazan como adjunto y no como imagen — ningún visor de Markdown los renderiza.
Las imágenes se extraen como archivos separados con enlaces relativos
(assets/imagen.png), nunca embebidas en base64 dentro del .md. Motivos:
- Base64 inline infla el archivo ~33% y vuelve los diffs de git ilegibles — malo para versionado y para indexar en un pipeline de RAG (los chunks de texto quedan contaminados con blobs enormes).
- El costo en tokens de un modelo de visión depende de la RESOLUCIÓN de la imagen, no del encoding. Claude tokeniza en parches: una imagen de 1000×1000px cuesta lo mismo venga como base64 o como archivo. Base64 no ahorra ni cuesta tokens — es puramente un formato de transporte.
- Por lo tanto el archivo separado es estrictamente mejor para almacenamiento/versionado, y convertir a base64 (si cierta API solo acepta eso) es responsabilidad del consumidor en el momento de la llamada.
Para que el contenido de diagramas/mapas/tablas-como-imagen sea buscable por texto (un RAG de solo texto no puede "ver" una imagen), se genera una vez una descripción corta de cada imagen y se inserta justo debajo.
Dos backends con la misma interfaz, vía CAPTION_BACKEND:
| Backend | Modelo por defecto | Privacidad | Velocidad |
|---|---|---|---|
ollama (default) |
qwen3-vl:32b |
Las imágenes no salen de la máquina | Lento (decenas de s/imagen) |
anthropic |
claude-sonnet-5 |
Cada imagen viaja a un tercero | Rápido (minutos por lote) |
python -m pipeline captions --limit 3 # smoke test
python -m pipeline captions # todo
python -m pipeline captions-audit # verificar 1 caption por imagen
# Forzar backend o modelo puntualmente
python -m pipeline captions --backend ollama --model qwen3-vl:30b
python -m pipeline captions --ollama-host http://otra-maquina:11434Antes de procesar el lote se hace un preflight: si el servidor no responde o el modelo no está descargado, falla de inmediato con un mensaje accionable en vez de acumular un error por imagen a mitad de un lote de horas.
Desde dentro del contenedor, localhost es el propio contenedor, nunca el
servidor. La configuración depende de dónde viva Ollama, y son dos escenarios
bien distintos:
A) Ollama en su propio contenedor (lo habitual si ya usas open-webui). Ambos servicios tienen que compartir una red Docker, y se le habla por nombre de servicio y puerto interno (11434), no por el puerto publicado en el host:
# Averigua la red del compose de Ollama:
docker network ls | grep -i ollama
docker inspect <contenedor-de-ollama> --format '{{json .NetworkSettings.Networks}}'# docker-compose.yml — ya viene configurado así
environment:
OLLAMA_HOST: "http://ollama:11434"
networks:
- default # explícito: al declarar redes, Compose deja de añadirla sola
- ollama-net
networks:
ollama-net:
external: true
name: ollama-models_ollama-net # ajusta al nombre real de tu redB) Ollama en el host (instalado con systemd). Escucha solo en 127.0.0.1,
así que hay que abrirlo antes:
sudo systemctl edit ollama
# [Service]
# Environment="OLLAMA_HOST=0.0.0.0:11434"
sudo systemctl restart ollamaY entonces OLLAMA_HOST=http://host.docker.internal:11434 (el
extra_hosts: host.docker.internal:host-gateway del compose lo hace resoluble
en Linux; en Mac y Windows ya existe).
Verificación, en cualquiera de los dos casos — debe listar tus modelos:
docker compose exec app sh -c 'curl -s "$OLLAMA_HOST/api/tags"'Si falla, el preflight del captioning te lo dirá al arrancar la app, en el log:
captions=no se pudo contactar Ollama en ....
- Es reanudable: las imágenes que ya tienen caption se detectan y se saltan
sin llamar al modelo. Reejecutar tras un fallo a mitad de lote no repite
trabajo ni duplica descripciones. (
captions-dedupsigue disponible para limpiar duplicados heredados de corridas anteriores.) - Local (
ollama, por defecto): sin costo, sin clave y sin que las imágenes salgan de la máquina.qwen3-vl:32b(21 GB) o:30b(20 GB) si la GPU tiene 24 GB;qwen3-vl:8b(6.1 GB) o:4b(3.3 GB) con menos memoria — un equipo de 18 GB no puede con los de 30B+. A cambio es lento: decenas de segundos por imagen, frente a minutos por lote completo vía API. - API (
anthropic): minutos y ~$1-2 para varios cientos de imágenes, conANTHROPIC_API_KEYyCAPTION_MODEL=claude-sonnet-5. Mejor calidad y mucho más rápido, pero cada imagen viaja a un tercero — no usar con documentos sensibles. - Cuidado con la concurrencia: si varios hilos escriben el mismo
.mdsin lock hay lost updates (captions que desaparecen sin error visible).pipeline/captions.pyusa un lock por archivo — no quitarlo.
Sources: