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

TipoCómo detectarCómo procesar
Con textopage.get_text() devuelve texto legibleExtracción directa con PyMuPDF
Escaneadopage.get_text() vacío o mínimoOCR o Vision API sobre imagen
MixtoAlgunas páginas con texto, otras sinDetección página por página

PyMuPDF vs pdf2image

CaracterísticaPyMuPDFpdf2image
Extracción de textoSí, nativoNo
Imágenes de páginasSí (pixmap)Sí (PIL)
Imágenes embebidasSí (get_images)No
TablasSí (find_tables)No
MetadatosSí (metadata, get_toc)No
DependenciasNinguna externaPoppler
VelocidadMuy rápidaRápida
Formato de salidabytes PNG/JPEGPIL Image
Uso típicoTexto + metadatos + análisisImá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

DPIUsoTamaño aprox. (A4)Calidad
72Vista previa rápida~100 KBBaja
150Balance general~400 KBMedia
200Vision APIs~700 KBBuena
300OCR de alta calidad~1.5 MBAlta

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

  1. PyMuPDF Documentation
  2. PyMuPDF — Extracting Tables
  3. pdf2image GitHub
  4. Poppler — Motor de renderizado para pdf2image
  5. PIL/Pillow — Procesamiento de imágenes en Python