Módulo 3: Comprensión de Documentos
2. Procesamiento de PDFs
Descripción
Los PDFs son el formato estándar para documentos empresariales. En esta cápsula aprenderás a extraer texto e imágenes usando PyMuPDF y pdf2image. Dominarás el flujo: abrir PDF → detectar tipo → extraer texto o convertir páginas a imágenes → obtener metadatos. Al final, tendrás un pipeline que decide automáticamente la mejor estrategia de extracción.
Por qué importa: Sin extracción correcta de PDFs, no puedes procesar facturas, contratos ni manuales. PyMuPDF es rápido y sin dependencias externas; pdf2image convierte páginas a imágenes para Vision APIs. Saber cuándo usar cada herramienta es la base de cualquier pipeline de comprensión de documentos.
Conexión con el módulo: La cápsula 03 (OCR + LLM) necesita las imágenes que extraerás aquí. La cápsula 05 (Extracción Estructurada) parte del texto que obtienes de PyMuPDF. El proyecto final combina todo este flujo.
Conceptos Clave
PDFs con texto vs escaneados
| Tipo | Cómo detectar | Cómo procesar |
|---|---|---|
| Con texto | page.get_text() devuelve texto legible | Extracción directa con PyMuPDF |
| Escaneado | page.get_text() vacío o mínimo | OCR o Vision API sobre imagen |
| Mixto | Algunas páginas con texto, otras sin | Detección página por página |
PyMuPDF vs pdf2image
| Característica | PyMuPDF | pdf2image |
|---|---|---|
| Extracción de texto | Sí, nativo | No |
| Imágenes de páginas | Sí (pixmap) | Sí (PIL) |
| Imágenes embebidas | Sí (get_images) | No |
| Tablas | Sí (find_tables) | No |
| Metadatos | Sí (metadata, get_toc) | No |
| Dependencias | Ninguna externa | Poppler |
| Velocidad | Muy rápida | Rápida |
| Formato de salida | bytes PNG/JPEG | PIL Image |
| Uso típico | Texto + metadatos + análisis | Imágenes para Vision API |
Extracción de Texto con PyMuPDF
Abrir un PDF e iterar páginas
import fitz # PyMuPDF
doc = fitz.open("factura.pdf")
print(f"Páginas: {len(doc)}")
for page in doc:
print(f"\n--- Página {page.number + 1} ---")
text = page.get_text()
print(f"Caracteres: {len(text)}")
print(text[:200])
doc.close()
Extraer todo el texto
import fitz
def extract_text_from_pdf(pdf_path: str) -> str:
"""Extrae todo el texto de un PDF concatenando todas las páginas."""
doc = fitz.open(pdf_path)
text_parts = []
for page in doc:
page_text = page.get_text()
if page_text.strip():
text_parts.append(page_text)
doc.close()
return "\n\n".join(text_parts)
text = extract_text_from_pdf("factura.pdf")
print(f"Total caracteres: {len(text):,}")
print(text[:500])
Extracción por página con metadata
import fitz
def extract_text_by_page(pdf_path: str) -> list[dict]:
"""Extrae texto con metadata por cada página."""
doc = fitz.open(pdf_path)
result = []
for page in doc:
text = page.get_text()
rect = page.rect
result.append({
"page_num": page.number + 1,
"text": text,
"char_count": len(text),
"word_count": len(text.split()),
"width": rect.width,
"height": rect.height,
"has_text": len(text.strip()) > 10
})
doc.close()
return result
pages = extract_text_by_page("manual.pdf")
for p in pages:
status = "con texto" if p["has_text"] else "sin texto"
print(f"Página {p['page_num']}: {p['word_count']} palabras ({status})")
Extracción por bloques (layout preservado)
PyMuPDF puede extraer texto respetando la posición visual de cada bloque en la página — útil para identificar encabezados, columnas o secciones. Usa page.get_text("blocks") que retorna tuplas (x0, y0, x1, y1, texto, block_no, block_type) donde block_type == 0 indica texto y 1 indica imagen.
Detectar Tipo de PDF
Un PDF generado desde Word tiene texto seleccionable. Uno escaneado es solo una imagen por página. Algunos son mixtos. Esta función clasifica cada página:
import fitz
def detect_pdf_type(pdf_path: str) -> dict:
"""
Clasifica cada página como 'text' o 'scanned'.
Retorna tipo general y detalle por página.
"""
doc = fitz.open(pdf_path)
page_analysis = []
text_pages = 0
scanned_pages = 0
for page in doc:
char_count = len(page.get_text().strip())
has_text = char_count > 50
if has_text:
text_pages += 1
else:
scanned_pages += 1
page_analysis.append({
"page_num": page.number + 1,
"type": "text" if has_text else "scanned",
"char_count": char_count
})
doc.close()
if scanned_pages == 0:
overall = "text"
elif text_pages == 0:
overall = "scanned"
else:
overall = "mixed"
return {
"overall_type": overall,
"total_pages": len(page_analysis),
"text_pages": text_pages,
"scanned_pages": scanned_pages,
"pages": page_analysis
}
result = detect_pdf_type("documento.pdf")
print(f"Tipo: {result['overall_type']}")
print(f"Páginas con texto: {result['text_pages']}/{result['total_pages']}")
for p in result["pages"]:
print(f" Página {p['page_num']}: {p['type']} ({p['char_count']} chars)")
El umbral de 50 caracteres funciona bien en la práctica: una página escaneada puede tener algunos caracteres residuales, pero rara vez más de 50.
Extracción de Imágenes Embebidas
Los PDFs pueden contener imágenes embebidas (fotos, logos, gráficos). PyMuPDF las extrae directamente:
import fitz
from pathlib import Path
def extract_embedded_images(pdf_path: str, output_dir: str = "images") -> list[dict]:
"""Extrae todas las imágenes embebidas de un PDF."""
doc = fitz.open(pdf_path)
Path(output_dir).mkdir(exist_ok=True)
extracted = []
for page in doc:
for img_index, img_info in enumerate(page.get_images(full=True)):
xref = img_info[0]
base_image = doc.extract_image(xref)
image_bytes = base_image["image"]
image_ext = base_image["ext"]
filename = f"page{page.number + 1}_img{img_index + 1}.{image_ext}"
filepath = Path(output_dir) / filename
with open(filepath, "wb") as f:
f.write(image_bytes)
extracted.append({
"page": page.number + 1,
"filename": filename,
"format": image_ext,
"width": base_image["width"],
"height": base_image["height"],
"size_kb": len(image_bytes) / 1024
})
doc.close()
return extracted
images = extract_embedded_images("catalogo.pdf")
for img in images:
print(f"Página {img['page']}: {img['filename']} ({img['width']}x{img['height']})")
PyMuPDF para Imágenes de Páginas
PyMuPDF convierte páginas completas a imágenes usando pixmaps — sin dependencia de Poppler.
Convertir una página
import fitz
def pdf_page_to_image(pdf_path: str, page_num: int = 0, dpi: int = 150) -> bytes:
"""Convierte una página a imagen PNG usando PyMuPDF pixmap."""
doc = fitz.open(pdf_path)
page = doc[page_num]
zoom = dpi / 72 # 72 DPI es la resolución base de PDF
mat = fitz.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat, alpha=False)
img_bytes = pix.tobytes("png")
doc.close()
return img_bytes
Convertir todas las páginas
import fitz
from pathlib import Path
def pdf_all_pages_to_images(
pdf_path: str, output_dir: str = "pages", dpi: int = 150, fmt: str = "png"
) -> list[str]:
"""Convierte todas las páginas a imágenes individuales."""
doc = fitz.open(pdf_path)
Path(output_dir).mkdir(exist_ok=True)
zoom = dpi / 72
mat = fitz.Matrix(zoom, zoom)
saved_files = []
for page in doc:
pix = page.get_pixmap(matrix=mat, alpha=False)
filename = f"page_{page.number + 1:03d}.{fmt}"
filepath = str(Path(output_dir) / filename)
pix.save(filepath)
saved_files.append(filepath)
doc.close()
return saved_files
files = pdf_all_pages_to_images("reporte.pdf", dpi=200)
print(f"Guardadas {len(files)} páginas")
Comparación de DPI
| DPI | Uso | Tamaño aprox. (A4) | Calidad |
|---|---|---|---|
| 72 | Vista previa rápida | ~100 KB | Baja |
| 150 | Balance general | ~400 KB | Media |
| 200 | Vision APIs | ~700 KB | Buena |
| 300 | OCR de alta calidad | ~1.5 MB | Alta |
Para Vision APIs, 150–200 DPI es suficiente. Más alto no mejora resultados y aumenta el costo por tokens de imagen.
Conversión con pdf2image
pdf2image usa Poppler como motor de renderizado y produce objetos PIL Image.
Conversión básica
from pdf2image import convert_from_path
images = convert_from_path("documento.pdf", dpi=150)
print(f"Páginas convertidas: {len(images)}")
for i, img in enumerate(images):
print(f"Página {i + 1}: {img.size[0]}x{img.size[1]} px")
Convertir páginas específicas y guardar
from pdf2image import convert_from_path
from pathlib import Path
def convert_and_save_pages(
pdf_path: str, first_page: int = 1, last_page: int = 1,
dpi: int = 150, output_dir: str = "pages"
) -> list[str]:
"""Convierte un rango de páginas (1-indexed) y las guarda como PNG."""
Path(output_dir).mkdir(exist_ok=True)
images = convert_from_path(
pdf_path, dpi=dpi, first_page=first_page, last_page=last_page, fmt="png"
)
saved = []
for i, img in enumerate(images):
filepath = str(Path(output_dir) / f"page_{first_page + i:03d}.png")
img.save(filepath, "PNG")
saved.append(filepath)
return saved
files = convert_and_save_pages("manual.pdf", first_page=1, last_page=5, dpi=200)
for f in files:
print(f"Guardada: {f}")
PDFs grandes: conversión por lotes
Con PDFs de muchas páginas, convertir todo de una vez consume demasiada memoria. Usa first_page y last_page para procesar en lotes, y thread_count=4 para paralelizar. Obtén el total de páginas con pdfinfo_from_path(pdf_path)["Pages"] e itera en rangos de batch_size.
Extracción de Tablas
PyMuPDF incluye detección de tablas (a partir de v1.23.0), lo que permite extraer datos tabulares sin librerías adicionales.
import fitz
def extract_tables_from_pdf(pdf_path: str) -> list[dict]:
"""Extrae tablas de todas las páginas de un PDF."""
doc = fitz.open(pdf_path)
all_tables = []
for page in doc:
tables = page.find_tables()
for table_idx, table in enumerate(tables):
data = table.extract()
if not data:
continue
headers = data[0] if len(data) > 1 else [f"col_{i}" for i in range(len(data[0]))]
rows = data[1:] if len(data) > 1 else data
all_tables.append({
"page": page.number + 1,
"table_index": table_idx + 1,
"headers": headers,
"rows": rows,
"row_count": len(rows),
"col_count": len(data[0])
})
doc.close()
return all_tables
tables = extract_tables_from_pdf("reporte_financiero.pdf")
for t in tables:
print(f"\nPágina {t['page']}, Tabla {t['table_index']}:")
print(f" Columnas: {t['headers']}")
print(f" Filas: {t['row_count']}")
for row in t["rows"][:3]:
print(f" {row}")
Para convertir las tablas a diccionarios, usa la primera fila como headers y mapea cada celda: {headers[i]: cell for i, cell in enumerate(row)} sobre data[1:].
Metadata y Estructura
Metadatos del documento
import fitz
from pathlib import Path
def get_pdf_metadata(pdf_path: str) -> dict:
"""Extrae metadatos completos del PDF."""
doc = fitz.open(pdf_path)
meta = doc.metadata
result = {
"title": meta.get("title", ""),
"author": meta.get("author", ""),
"subject": meta.get("subject", ""),
"creator": meta.get("creator", ""),
"producer": meta.get("producer", ""),
"creation_date": meta.get("creationDate", ""),
"modification_date": meta.get("modDate", ""),
"page_count": len(doc),
"file_size_kb": Path(pdf_path).stat().st_size / 1024
}
doc.close()
return result
Tabla de contenido y dimensiones de páginas
import fitz
def get_pdf_structure(pdf_path: str) -> dict:
"""Extrae tabla de contenido y dimensiones de páginas."""
doc = fitz.open(pdf_path)
toc = [{"level": l, "title": t, "page": p} for l, t, p in doc.get_toc()]
pages = []
for page in doc:
rect = page.rect
pages.append({
"page": page.number + 1,
"width_pt": rect.width,
"height_pt": rect.height,
"width_cm": round(rect.width * 2.54 / 72, 1),
"height_cm": round(rect.height * 2.54 / 72, 1),
"orientation": "landscape" if rect.width > rect.height else "portrait"
})
doc.close()
return {"table_of_contents": toc, "pages": pages}
Pipeline: PDF → Texto o Imágenes
Función central que combina detección de tipo, extracción de texto y conversión a imágenes:
import fitz
from pathlib import Path
def process_pdf(pdf_path: str, dpi: int = 150) -> dict:
"""
Pipeline completo: detecta tipo por página, extrae texto donde hay,
genera imágenes donde no, e incluye metadatos.
"""
doc = fitz.open(pdf_path)
zoom = dpi / 72
mat = fitz.Matrix(zoom, zoom)
pages = []
text_count = 0
scan_count = 0
full_text_parts = []
for page in doc:
text = page.get_text()
has_text = len(text.strip()) > 50
page_data = {"page_num": page.number + 1, "has_text": has_text}
if has_text:
text_count += 1
page_data["text"] = text
full_text_parts.append(f"--- Página {page.number + 1} ---\n{text}")
else:
scan_count += 1
pix = page.get_pixmap(matrix=mat, alpha=False)
page_data["image_bytes"] = pix.tobytes("png")
pages.append(page_data)
meta = doc.metadata
metadata = {
"title": meta.get("title", ""),
"author": meta.get("author", ""),
"page_count": len(doc),
"file_size_kb": Path(pdf_path).stat().st_size / 1024
}
doc.close()
if scan_count == 0:
overall = "text"
elif text_count == 0:
overall = "scanned"
else:
overall = "mixed"
return {
"type": overall,
"text_pages": text_count,
"scanned_pages": scan_count,
"full_text": "\n\n".join(full_text_parts),
"pages": pages,
"metadata": metadata
}
result = process_pdf("documento.pdf")
print(f"Tipo: {result['type']}")
print(f"Páginas texto: {result['text_pages']}, escaneadas: {result['scanned_pages']}")
Troubleshooting
Problema: "No module named 'fitz'"
Causa: PyMuPDF se instala como pymupdf pero se importa como fitz.
Solución: pip install pymupdf y luego import fitz.
Problema: pdf2image falla con "Unable to get page count"
Causa: Poppler no instalado o no en PATH.
Solución:
# macOS
brew install poppler
# Linux (Debian/Ubuntu)
apt-get install poppler-utils
Problema: PDF protegido con contraseña
Causa: El PDF requiere contraseña para abrirse o copiar texto.
Solución:
import fitz
doc = fitz.open("protegido.pdf")
if doc.is_encrypted:
if not doc.authenticate("mi_contraseña"):
raise PermissionError("Contraseña incorrecta")
Problema: PDF muy grande consume demasiada memoria
Causa: Convertir todas las páginas a imágenes de alta resolución agota la RAM.
Solución: Procesar en lotes y liberar memoria entre iteraciones.
import fitz
import gc
def process_large_pdf_pages(pdf_path: str, dpi: int = 150, batch_size: int = 20):
"""Procesa un PDF grande en lotes liberando memoria."""
doc = fitz.open(pdf_path)
zoom = dpi / 72
mat = fitz.Matrix(zoom, zoom)
for start in range(0, len(doc), batch_size):
end = min(start + batch_size, len(doc))
batch = [doc[i].get_pixmap(matrix=mat, alpha=False).tobytes("png")
for i in range(start, end)]
yield start, batch
gc.collect()
doc.close()
Problema: Texto extraído con caracteres extraños o mojibake
Causa: El PDF usa fuentes embebidas con encoding personalizado que PyMuPDF no puede mapear.
Solución: Si page.get_text("text") contiene � o basura, hacer fallback: convertir la página a imagen con page.get_pixmap() y enviar a OCR o Vision API.
Ejercicios
Ejercicio 1: Analizador completo de PDF
Crea una función analyze_pdf que reciba la ruta de un PDF y retorne: número de páginas, tipo (text/scanned/mixed), cantidad de imágenes embebidas, si tiene tabla de contenido, y metadatos de autor y título.
Ver solución
import fitz
from pathlib import Path
def analyze_pdf(pdf_path: str) -> dict:
doc = fitz.open(pdf_path)
text_pages = scan_pages = total_images = 0
for page in doc:
if len(page.get_text().strip()) > 50:
text_pages += 1
else:
scan_pages += 1
total_images += len(page.get_images(full=True))
toc = doc.get_toc()
meta = doc.metadata
if scan_pages == 0:
pdf_type = "text"
elif text_pages == 0:
pdf_type = "scanned"
else:
pdf_type = "mixed"
result = {
"file": Path(pdf_path).name,
"pages": len(doc),
"type": pdf_type,
"text_pages": text_pages,
"scanned_pages": scan_pages,
"embedded_images": total_images,
"has_toc": len(toc) > 0,
"title": meta.get("title", ""),
"author": meta.get("author", "")
}
doc.close()
return result
Ejercicio 2: Detector de tipo con reporte
Crea una función que analice un PDF y genere un reporte en texto: cuántas páginas tienen texto, cuántas son escaneadas, y una recomendación de procesamiento.
Ver solución
import fitz
def generate_pdf_report(pdf_path: str) -> str:
doc = fitz.open(pdf_path)
lines = [f"=== Reporte: {pdf_path} ===", f"Total páginas: {len(doc)}", ""]
text_count = scan_count = 0
for page in doc:
chars = len(page.get_text().strip())
pn = page.number + 1
if chars > 50:
text_count += 1
lines.append(f"Página {pn}: TEXTO ({chars} chars) → extracción directa")
else:
scan_count += 1
lines.append(f"Página {pn}: ESCANEADA ({chars} chars) → necesita OCR/Vision")
lines.append("")
lines.append(f"Resumen: {text_count} con texto, {scan_count} escaneadas")
if scan_count == 0:
lines.append("Recomendación: extracción de texto directa con PyMuPDF")
elif text_count == 0:
lines.append("Recomendación: convertir a imágenes y usar Vision API u OCR")
else:
lines.append("Recomendación: procesamiento mixto (texto + Vision API)")
doc.close()
return "\n".join(lines)
Ejercicio 3: Conversor de páginas con redimensionado
Crea una función que convierta páginas de un PDF a imágenes PNG, con parámetros de rango, DPI y tamaño máximo en píxeles (reescalar si supera el límite).
Ver solución
import fitz
from PIL import Image
import io
from pathlib import Path
def convert_pages_to_images(
pdf_path: str, pages: list[int] | None = None,
dpi: int = 150, max_pixels: int = 2048, output_dir: str = "output"
) -> list[str]:
doc = fitz.open(pdf_path)
Path(output_dir).mkdir(exist_ok=True)
if pages is None:
pages = list(range(len(doc)))
zoom = dpi / 72
mat = fitz.Matrix(zoom, zoom)
saved = []
for pn in pages:
if pn >= len(doc):
continue
pix = doc[pn].get_pixmap(matrix=mat, alpha=False)
img = Image.open(io.BytesIO(pix.tobytes("png")))
w, h = img.size
if max(w, h) > max_pixels:
ratio = max_pixels / max(w, h)
img = img.resize((int(w * ratio), int(h * ratio)), Image.Resampling.LANCZOS)
filepath = str(Path(output_dir) / f"page_{pn + 1:03d}.png")
img.save(filepath, "PNG")
saved.append(filepath)
doc.close()
return saved
Ejercicio 4: Extractor de metadatos y estructura
Crea una función que retorne un diccionario con: metadatos del documento, tabla de contenido, dimensiones por página, y cantidad de tablas e imágenes detectadas por página.
Ver solución
import fitz
from pathlib import Path
def extract_full_structure(pdf_path: str) -> dict:
doc = fitz.open(pdf_path)
meta = doc.metadata
metadata = {
"title": meta.get("title", ""),
"author": meta.get("author", ""),
"creator": meta.get("creator", ""),
"pages": len(doc),
"size_kb": Path(pdf_path).stat().st_size / 1024
}
toc = [{"level": l, "title": t, "page": p} for l, t, p in doc.get_toc()]
page_info = []
for page in doc:
rect = page.rect
page_info.append({
"page": page.number + 1,
"width_pt": round(rect.width, 1),
"height_pt": round(rect.height, 1),
"orientation": "landscape" if rect.width > rect.height else "portrait",
"tables_found": len(page.find_tables()),
"images_found": len(page.get_images(full=True))
})
doc.close()
return {"metadata": metadata, "table_of_contents": toc, "pages": page_info}
Recursos adicionales
- PyMuPDF Documentation
- PyMuPDF — Extracting Tables
- pdf2image GitHub
- Poppler — Motor de renderizado para pdf2image
- PIL/Pillow — Procesamiento de imágenes en Python