- name
- ocr-quirurgico-pdf-md
- version
- 1.0.0
- description
- Pipeline completo OCR quirúrgico: PDF → página a página → Markdown/HTML con precisión total. Detecta tablas, firmas, diagramas, imágenes. Si no entiende algo, captura y graba en posición exacta. Vectoriza para búsqueda con ChromaDB + qwen3-embedding.
- tags
- ["ocr","pdf","markdown","html","pipeline","tablas","firmas","diagramas","chromadb","qwen3"]
# OCR Quirúrgico PDF → MD/HTML
## Resumen
Pipeline que recibe un PDF, lo **corta página a página** y produce un Markdown o HTML con **precisión absoluta**:
- Texto real → extraído con bounding boxes
- Tablas → detectadas con pdfplumber + reconstruidas como `<table>`
- Firmas → paths vectoriales capturados como imagen
- Diagramas → render + OCR + imagen incrustada
- Imágenes → extraídas con fitz + metadata
- Si algo falla → **captura de pantalla** (render 300dpi) + imagen en posición exacta
Luego **todo se vectoriza** con qwen3-embedding (4096d) para búsqueda semántica por ChromaDB.
## Cuándo usarlo
- Usuario adjunta PDF técnico y pide "convertir a Markdown/HTML con todo en su sitio"
- Usuario quiere un **documento navegable** donde cada tabla, firma, y diagrama esté en la posición correcta
- Usuario necesita **búsqueda semántica** sobre el contenido (miles de páginas)
## No es para
- OCR de documentos escaneados con fotos (usar `documentos-institucionales`)
- Extracción rápida sin preservación de layout (usar `markitdown` o `pdf-to-dashboard`)
- **Extracción de datos estructurados de PDFs digitales** → usar `pdf-llm-extraction` (nuevo paradigma: font analysis + LLM, 100% confianza vs ~60% con regex)
- Documentos con solo texto sin tablas ni imágenes (usar `pdf-to-artifacts-david-antizar`)
> ⚠️ **Paradigma shift (2026-06):** Para PDFs digitales con texto seleccionable, el pipeline `pdf-llm-extraction` (PyMuPDF + Qwen 3.6) supera cualitativamente a cualquier enfoque regex o OCR. Usar OCR solo para PDFs escaneados/imágenes.
## Stack de herramientas
| Herramienta | Rol | Instalación |
|------------|-----|-------------|
| `fitz` (PyMuPDF) | Extracción principal + render + bounding boxes | `pip install PyMuPDF` |
| `pdfplumber` | Detección de tablas + líneas de cuadrícula | `pip install pdfplumber` |
| `Tesseract` | OCR de imágenes cuando el texto no es extraíble | `apt install tesseract-ocr` |
| `camelot` | Tablas sin líneas (fallback) | `pip install camelot-py[cv]` |
| `Pillow` | Procesado de imágenes + deskew | `pip install Pillow` |
| `qwen3-embedding` | Vectorización 4096d | `api.nan.builders/v1/embeddings` |
| `ChromaDB` | Almacenamiento vectorial + búsqueda | `pip install chromadb` |
| `OpenCV` | Preprocesado de imágenes (threshold, denoise) | `pip install opencv-python` |
## Arquitectura del pipeline
```
PDF
↓
┌───────────────────────────────┐
│ FASE 0 — PREPARACIÓN │
│ - Verificar herramientas │
│ - Crear directorios de salida │
│ - Detectar tipo de PDF │
└───────────────────────────────┘
↓
┌───────────────────────────────┐
│ FASE 1 — CORTE │
│ - fitz: abrir PDF │
│ - Por cada página: │
│ - text = get_text("dict") │
│ - images = get_images() │
│ - paths = get_drawings() │
│ - metadata = page metadata │
└───────────────────────────────┘
↓
┌───────────────────────────────┐
│ FASE 2 — CLASIFICACIÓN │
│ - Por cada elemento: │
│ - ¿Es texto? → extraer │
│ - ¿Es tabla? → pdfplumber │
│ - ¿Es firma? → capturar │
│ - ¿Es diagrama? → render │
│ - ¿Es imagen? → extraer │
│ - ¿No se entiende? → OCR │
│ → Captura de imagen │
└───────────────────────────────┘
↓
┌───────────────────────────────┐
│ FASE 3 — ENSAMBLADO │
│ - Página por página: │
│ - Markdown: texto + tablas │
│ - HTML: con imágenes incrust │
│ - Imagen fallback incrustada │
│ - Metadata por elemento: │
│ - tipo, bbox, confidence │
│ - página, documento │
└───────────────────────────────┘
↓
┌───────────────────────────────┐
│ FASE 4 — VECTORIZACIÓN │
│ - qwen3-embedding por página │
│ - qwen3-embedding por elemento │
│ - ChromaDB: store + metadata │
│ - Búsqueda: consulta semántica│
└───────────────────────────────┘
```
## Fase 0 — Preparación
```bash
# 1. Verificar herramientas
python3 -c "
import sys
try:
import fitz; print('fitz ✅')
import pdfplumber; print('pdfplumber ✅')
import cv2; print('OpenCV ✅')
except ImportError as e:
print(f'Falta: {e}')
sys.exit(1)
"
# 2. Verificar Tesseract
which tesseract || echo "Instalar: apt install tesseract-ocr tesseract-ocr-spa"
# 3. Crear directorio de trabajo
mkdir -p /tmp/ocr-pipeline/{pages,elements,output,images}
```
## Fase 1 — Corte página a página
```python
import fitz
import json
from pathlib import Path
def cortar_pdf(pdf_path: str, output_dir: str = "/tmp/ocr-pipeline") -> dict:
"""
Corta un PDF página a página y devuelve un dict con:
- pages: [{index, text_blocks, images, drawings, metadata}]
- total_pages: int
- metadata: {title, author, producer, ...}
"""
doc = fitz.open(pdf_path)
pages_data = []
for i, page in enumerate(doc):
# Metadata de página
rect = page.rect
rotation = page.rotation
# Extraer texto con bounding boxes (dict mode)
text_dict = page.get_text("dict")
# Extraer imágenes
images = page.get_images(full=True)
# Extraer paths/dibujos (diagramas, firmas)
paths = page.get_drawings()
# Extraer elementos de texto con coordenadas
blocks = []
for block in text_dict.get("blocks", []):
if block.get("type") == 0: # texto
blocks.append({
"type": "text",
"bbox": block.get("bbox"),
"lines": [
{
"spans": [
{"text": s.get("text"), "font": s.get("font"),
"size": s.get("size"), "color": s.get("color")}
for s in span.get("spans", [])
]
}
for line in block.get("lines", [])
]
})
elif block.get("type") == 1: # imagen
blocks.append({
"type": "image",
"bbox": block.get("bbox"),
"image": block.get("image") # bytes
})
pages_data.append({
"page": i + 1,
"rect": [rect.x0, rect.y0, rect.x1, rect.y1],
"rotation": rotation,
"blocks": blocks,
"images": [{"index": j, "xref": img[0], "width": img[2], "height": img[3]}
for j, img in enumerate(images)],
"drawings": [{"path": d} for d in paths]
})
return {
"pages": pages_data,
"total_pages": len(doc),
"metadata": {
"title": doc.metadata.get("title"),
"author": doc.metadata.get("author"),
"producer": doc.metadata.get("producer"),
"format": doc.metadata.get("format")
}
}
# Guardar para la fase 2
json.dump(cortar_pdf("documento.pdf"),
open("/tmp/ocr-pipeline/pages_raw.json", "w"),
ensure_ascii=False)
```
### Fase 1b — Render de página para fallback
```python
def render_page_as_image(pdf_path, page_num, dpi=300):
"""Renderiza una página completa como imagen PNG a 300 DPI"""
doc = fitz.open(pdf_path)
page = doc[page_num - 1]
pix = page.get_pixmap(dpi=dpi)
pix.save(f"/tmp/ocr-pipeline/pages/page_{page_num:04d}.png")
return f"/tmp/ocr-pipeline/pages/page_{page_num:04d}.png"
```
## Fase 2 — Clasificación de elementos
```python
def clasificar_elemento(elemento: dict, page_width: float, page_height: float) -> str:
"""
Clasifica un bloque de texto en:
- "texto" → párrafo normal
- "tabla" → líneas alineadas en columnas
- "firma" → paths vectoriales (curvas bezier)
- "diagrama" → líneas + texto + formas
- "imagen" → imagen embebida
- "no_identificado" → render + OCR
"""
bbox = elemento.get("bbox", [0, 0, 0, 0])
w = bbox[2] - bbox[0]
h = bbox[3] - bbox[1]
tipo = elemento.get("type", "")
# Heurísticas
if tipo == "image":
return "imagen"
if tipo == "text":
# ¿Es tabla? (múltiples líneas con mismo espaciado X)
lines = elemento.get("lines", [])
if len(lines) > 2:
x_coords = set()
for line in lines:
for span in line.get("spans", []):
x_coords.add(round(span["bbox"][0]))
# Si hay más de 3 columnas X distintas → tabla
if len(set(round(x) for x in x_coords)) > 3:
return "tabla"
# ¿Es imagen grande? (ocupa >80% del ancho)
if w > page_width * 0.8:
return "imagen_grande"
# ¿Es texto normal?
return "texto"
# Paths vectoriales → firma o diagrama
if tipo == "drawing":
# Si son curvas bezier → firma
# Si son rectas + texto → diagrama
return "firma" if len(paths) < 5 else "diagrama"
return "no_identificado"
```
## Fase 3 — OCR de fallback (cuando no se entiende)
```python
import subprocess
from PIL import Image
def ocr_fallback(pdf_path, page_num, bbox=None):
"""
Cuando el texto no es extraíble (fuentes embebidas, etc.):
- Renderiza la página completa o solo el bbox
- Ejecuta Tesseract
- Devuelve: {"text": "...", "confidence": float, "image_path": "..."}
"""
doc = fitz.open(pdf_path)
page = doc[page_num - 1]
if bbox:
# Solo renderizar la región del bbox
clip = fitz.Rect(*bbox)
pix = page.get_pixmap(dpi=300, clip=clip)
else:
# Página completa
pix = page.get_pixmap(dpi=300)
img_path = f"/tmp/ocr-pipeline/images/page_{page_num:04d}_bbox.png"
pix.save(img_path)
# Tesseract
result = subprocess.run(
["tesseract", img_path, "stdout", "-l", "spa+eng", "--psm", "6"],
capture_output=True, text=True, timeout=30
)
return {
"text": result.stdout.strip(),
"confidence": 0.8, # placeholder
"image_path": img_path
}
def ocr_con_llm(img_path: str, model="qwen3.6"):
"""
Cuando Tesseract no es suficiente (diagramas, logos, firmas):
Envía la imagen a un modelo multimodal para descripción.
"""
# Aquí iría la llamada a NaN API con el modelo multimodal
# Ej: curl -X POST https://api.nan.builders/v1/chat/completions
# -d '{"model": "qwen3-vl", "messages": [{"role": "user", "content": "Describe esta imagen de un documento"}]}'
return {"description": "...", "type": "diagrama"}
```
## Fase 4 — Ensamblado a Markdown/HTML
```python
def ensamblar_markdown(pages_data, pdf_path, output_dir="/tmp/ocr-pipeline/output"):
"""
Genera un Markdown con:
- Cada página como sección
- Imágenes incrustadas con MEDIA:
- Tablas como markdown tables
- Metadata de cada elemento
"""
md = []
md.append(f"# {pdf_path}\n")
for page_data in pages_data:
md.append(f"\n## Página {page_data['page']}\n")
for block in page_data.get("blocks", []):
tipo = block["type"]
if tipo == "text":
md.append(block["text"])
elif tipo == "table":
md.append(formatear_tabla(block))
elif tipo == "image":
md.append(f"\n![Imagen página {page_data['page']}]({block['path']})\n")
elif tipo == "diagram":
md.append(f"\n```\n{block['description']}\n```\n")
md.append(f"\n")
elif tipo == "signature":
md.append(f"\n---\n### Firma\n\n---\n")
return "\n".join(md)
def ensamblar_html(pages_data, pdf_path, output_dir="/tmp/ocr-pipeline/output"):
"""
Versión HTML con:
- CSS para responsive
- Imágenes en base64 (incrustadas en el HTML)
- Tablas con <table>
- Cada elemento con su metadata
"""
html = ["<!DOCTYPE html><html><head><meta charset='UTF-8'><meta name='viewport' content='width=device-width, initial-scale=1.0'><style>"]
html.append("""
body { font-family: 'Segoe UI', sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; background: #fafafa; }
.page { border: 1px solid #ddd; margin: 20px 0; padding: 20px; background: white; border-radius: 8px; }
table { width: 100%; border-collapse: collapse; margin: 10px 0; }
在 GitHub 查看