Los PDF son para personas, no para datos

O: cómo un lenguaje de impresión de los 90 sigue troleando tu script de "solo analízalo".

G
Greg T · Engineering· 10 min de lectura·Sep 20, 2025
Read in English

Nos encantan los PDF. Se ven igual en todos los dispositivos, se imprimen maravillosamente en cualquier tamaño y son lo más parecido que tenemos al papel digital. Pero cada vez que alguien de nuestro equipo dice "simplemente extraigamos los datos del PDF", sentimos que un antiguo demonio PostScript se despierta y susurra: "Nací para pintar píxeles, no para estructurar tus filas".

En este artículo, explicamos por qué los PDF son excelentes para la presentación y terribles para los datos. Echaremos un vistazo a las entrañas de un PDF, construiremos un pequeño ejemplo e intentaremos (y fallaremos, y volveremos a intentar) extraer un estado de cuenta bancario ficticio. Al final, esperamos que dejes de esperar que los PDF se comporten como CSV con gabardina.


La ascendencia equivocada

PDF no creció queriendo ser una API. Creció queriendo ser un controlador de impresora que nunca tuviera que conocer a tu impresora. El modelo PDF es esencialmente: una secuencia de instrucciones de dibujo.

Eso significa "dibuja texto aquí, en esta fuente, a este tamaño, luego desplázate un poco, luego dibuja más texto". No significa "esta es una tabla con 5 columnas y un encabezado". No existe un concepto nativo de filas, columnas o incluso palabras. Solo hay glifos colocados en coordenadas.

Si tienes eso en cuenta, todo lo que sigue tiene... bueno, no sentido, pero al menos se siente menos malicioso.


Un recorrido de 30 segundos por un PDF

Dentro de un PDF encontrarás:

  • Objects: bloques numerados (diccionarios, arrays, streams) que definen páginas, fuentes, imágenes, etc.

  • Content streams: flujos de bytes comprimidos que contienen los comandos de dibujo para cada página.

  • Text operators: cosas como BT (begin text), Tj/TJ (show text), Td/Tm (move the text matrix), etc.

  • Cross‑reference (xref):

La guía telefónica que le dice a un lector dónde vive cada objeto en el archivo.

Puedes abrir un PDF simple en un editor de texto y entrecerrar los ojos. Algunas partes serán legibles; otras parecerán estática porque son flujos comprimidos. Eso es normal.

Un flujo de contenido de juguete

Aquí hay un fragmento drásticamente simplificado (y saneado) como el que podrías ver después de descomprimir el flujo de contenido de una página:

Ejemplo de Estado de Cuenta PDF

BT                      % Inicio objeto de texto
/F1 12 Tf               % Seleccionar fuente F1 a 12 puntos
72 720 Td               % Mover a posición (72, 720)
(2024-05-01) Tj         % Dibujar texto simple "2024-05-01"
0 -20 Td                % Mover abajo 20 unidades
(VEGA PARKING - REF: 827492) Tj        % Dibujar texto simple "VEGA PARKING - REF: 827492"
0 -20 Td                % Mover abajo 20 unidades
[(1) -50 (2) -50 (3) -50 (4)] TJ    % Dibujar "1234" con espaciado
0 -20 Td                % Mover abajo 20 unidades
[(Amount:) -100 ($) -20 (1) -30 (2) -30 (3) -30 (.) -20 (4) -30 (5)] TJ
                        % TJ complejo: "Amount: $123.45" con kerning
0 -20 Td                % Mover abajo 20 unidades
(Regular text here) Tj  % Texto simple de nuevo
ET                      % Fin objeto de texto

Acaban de suceder dos cosas importantes:

  1. Tj y TJ dibujan glifos, no "caracteres en una palabra". A veces la fuente codifica glifos de formas extrañas (codificaciones personalizadas, subconjuntos). No puedes asumir que los bytes se mapean a Unicode.
  2. TJ toma un array de cadenas y números. Los números ajustan el espaciado (kerning). La gente lo usa para que los números se vean bonitos. Tu extractor ahora tiene que calcular las posiciones visuales para reconstruir dónde aterrizan realmente "1 2 3 4".

Por qué tu extractor te engaña

Repasemos tres trampas comunes que convierten un "PDF fácil" en un "fin de semana arruinado".

1) El texto no es texto

Si un archivo incrusta una fuente subconjunto (ej. /F1+ABCDEE+Inter), la letra visible A podría ser en realidad el ID de glifo 37 que esta fuente mapea a A, pero el glifo 37 de otro PDF podría mapear a Ω. Si la fuente olvida incluir un mapa /ToUnicode (o miente), la extracción cruda produce basura. El OCR no te salvará si el texto ya son glifos vectoriales.

Síntoma: Ves cadenas Tj como \x12\x7F\x03 y tu herramienta devuelve felizmente .

Solución-ish: Heurística + decodificación de fuentes + rezar para que exista el mapa /ToUnicode. De lo contrario, estás en el territorio de "visión por computadora".

2) Las palabras son una ilusión

No hay espacios a menos que un glifo sea literalmente un espacio. Muchos PDF dibujan "CHAMPAGNE" como nueve glifos independientes con huecos arbitrarios. Si algo es una palabra o dos columnas coincidentemente cercanas es tu trabajo inferirlo agrupando coordenadas.

Síntoma: Tu "división por espacios" devuelve ['CH', 'AMP', 'AGNE'] o peor, fusiona dos columnas en una palabra monstruosa.

Solución-ish: Reconstruir el orden de lectura usando la matriz de texto y umbrales de tolerancia para huecos X/Y. Espera tener que ajustar por documento.

3) Los diseños son copos de nieve hechos a medida

El "Monto" del Banco A puede estar alineado a la derecha en x=480; el Banco B usa una tabla; el Banco C renderiza cada dígito individualmente con TJ para alinear decimales. Lo único consistente sobre "Descripción / Fecha / Monto" es que es inconsistente.

Síntoma: Tu analizador basado en reglas funciona en 9 PDF y luego silenciosamente pierde decimales en el décimo.

Solución-ish: Separar renderizado de semántica. Construir un modelo visual (cajas, líneas, corridas de texto) y luego un mapeo aprendido de visuales → campos. O mantener plantillas por emisor si tu plan a largo plazo incluye tristeza.


Un pequeño experimento: crear, luego extraer

Primero, generaremos un estado de cuenta bancario de juguete. Cualquier biblioteca PDF funciona; la clave es: renderizaremos lo que ven los humanos, no "datos estructurados".

# Generar un estado de cuenta de una página como lo ven los humanos
open_page(width=letter, height=letter)
draw_text("Sample Bank Statement", at=(x_left, y_top))
draw_text("Date", at=(x_date, y_head))
draw_text("Description", at=(x_desc, y_head))
draw_text_right("Amount", right_edge=x_amount_right, y=y_head)

for (date, desc, amount) in transactions:
  draw_text(date, at=(x_date, y))
  draw_text(desc, at=(x_desc, y))
  # crítico: renderizar monto con kerning dígito por dígito (TJ)
  draw_digits_with_spacing(amount, right_edge=x_amount_right, y=y)
  y = y - line_height

save_pdf("sample.pdf")

Ahora intentemos extraer. Demostraremos tres estrategias en pseudocódigo primero (ver Apéndice para scripts ejecutables): "martillo de cadenas", "caminata de texto ingenua" y "consciente del diseño".

# Método 1: YOLO (caza de cadenas)
bytes = read_file("sample.pdf")
visible = grep_text(bytes, patterns=["%PDF", "xref", "/Type", "/Page"])
print(visible)  # puede encontrar tokens de contenedor/meta; los flujos de página están comprimidos
# Método 2: Caminata de texto ingenua
lines = []
for page in pdf_pages("sample.pdf"):
  buf = ""
  prev_y = None
  for op in page.text_ops():  # produce caracteres en orden de operador
    if op.type == "char":
      if prev_y is not None and (prev_y - op.y) > y_tol:
        lines.append(buf.strip()); buf = ""
      buf += op.text
      prev_y = op.y
    elif op.type in {"space", "newline"}:
      buf += " "
  if buf: lines.append(buf.strip())
print(lines)  # palabras aplastadas, columnas fusionadas, texto confuso si falta ToUnicode
# Método 3: Grafo consciente del diseño
runs = []
for page in pdf_pages("sample.pdf"):
  glyphs = [ (g.x, g.y, g.w, g.text) for g in page.glyphs() ]
  lines = cluster_by_y(glyphs, tol=y_tol)
  words = [ cluster_by_x(line, gap=x_tol) for line in lines ]
  right_guide = infer_right_edge([w for line in words for w in line if looks_numeric(w)])
  for line in words:
    amount = snap_right_aligned(line, right_guide, tol=snap_tol)
    date = first_token_like("YYYY-MM-DD", line)
    desc = tokens_between(date, amount, line)
    if date and amount:
      runs.append({
        "date": date.text,
        "description": join(desc),
        "amount": parse_locale_number(amount.text)
      })
print({ "transactions": runs })
{"transactions": [
  {"date": "2024-01-03", "description": "VEGA PARKING - REF: 827492", "amount": -3.45},
  {"date": "2024-01-05", "description": "LUMINA PAYMENTS - Transfer ID: 14x8Nqm7", "amount": 796.60},
  {"date": "2024-01-08", "description": "STELLAR TRANSPORT - Invoice F887", "amount": -63.36}
]}

Spoiler: El Método 1 no extrae casi nada. El Método 2 devuelve texto confuso por esa línea /ToUnicode faltante y fusiona columnas. El Método 3 es... finalmente utilizable, hasta que pruebas la plantilla de un segundo banco.


La batalla final del estado de cuenta bancario

Finjamos que un cliente sube tres estados de cuenta bancarios diferentes para KYC. Se ven igual para una persona:

  • Fecha, Descripción, Monto.
  • Los montos negativos tienen un signo menos; los decimales usan una coma porque... Europa.

Bajo el capó, son monstruos completamente diferentes:

  1. Emisor A usa ToUnicode y Tj simple. Modo fácil.
  2. Emisor B dibuja dígitos con TJ como [ (1) -30 (2) -30 (3) -30 (,) -20 (4) ] para que los decimales se alineen. Alineado a la derecha en x≈480.
  3. Emisor C incrustó una fuente Type 3 con glifos personalizados. Sin ToUnicode. La coma es en realidad el ID de glifo 17, que tu biblioteca decodifica como \x11.

Una tubería (pipeline) debe:

  • Decodificar fuentes cuando sea posible; recurrir a OCR solo donde sea necesario.
  • Reconstruir el orden de lectura por geometría (no por orden de texto).
  • Normalizar configuraciones regionales (, vs .; espacios inseparables; paréntesis para negativos).
  • Detectar y arreglar la "ensalada de dígitos" de TJ calculando las posiciones reales de los glifos.

Este es el tipo de post-procesamiento que terminas escribiendo (pseudocódigo):

# (marcador de posición) postprocess.py
for run in text_runs:
    if looks_like_right_aligned_amount(run):
        amount = stitch_digits_by_x(run.glyphs, tolerance=1.5)
        amount = normalize_decimal_separators(amount)
        yield {"amount": parse_money(amount), "x": run.right_x}

Y no, no es una sola función. Es un jardín de heurísticas, cada una adoptada después de un reporte de error.


"¡Pero el mío funciona en el 95% de los archivos!"

Igual. El PDF mediano está bien. La cola es donde viven los equipos de cumplimiento. La larga cola incluye:

  • Escaneos (mapas de bits) intercalados con texto vectorial.
  • PDF linealizados donde los objetos se barajan para la transmisión (streaming).
  • Xrefs extraños y flujos de objetos comprimidos.
  • PDF etiquetados que pretenden tener estructura (¡a veces útiles! ¡a veces mentiras!).

No notas estos hasta que un cliente crítico usa exactamente esa exportación de exactamente ese sistema bancario central.


Pensamiento final → y cómo hacemos esto en Holofin

Los PDF son maravillosos en lo que fueron diseñados para hacer: mostrar confiablemente a los humanos la misma página. Si necesitas datos, pide datos, CSV, JSON, XLSX. Y cuando la realidad dice "el regulador quiere PDF", necesitas una tubería que trate a los PDF como los pequeños programas de gráficos que son.

En Holofin, esa es literalmente nuestra descripción de trabajo: convertir PDF caóticos del mundo real en datos estructurados, validados y listos para producción, desde exportaciones limpias hasta escaneos manchados de café de los años 90.

Nuestros principios

  • Estructura antes que semántica. Reconstruimos la geometría primero (glifos → palabras → líneas → bloques → tablas) y solo entonces asignamos significado. Esto evita errores de "números correctos, encabezado incorrecto".
  • Anclas en todas partes. Cada valor lleva número de página, cuadro delimitador y linaje de pila de encabezados para que puedas hacer clic para volver a la fuente en revisión/depuración.
  • Salidas deterministas. Entregamos valores consistentes y auditables derivados del contenido del documento; las unidades y monedas se preservan tal como las proporciona la fuente.
  • Restricciones > vibras. Los totales deben sumar a los subtotales; los balances generales deben cuadrar; las fechas deben ser plausibles. Cuando las reglas fallan, probamos estrategias y heurísticas alternativas.

Cómo se ve esto en un estado de cuenta bancario

¿Recuerdas nuestros tres emisores (Tj simple, kerning con TJ, fuentes subconjunto sin ToUnicode)? Holofin los maneja mediante:

  • Extracción de texto resiliente: Combinamos decodificación de texto nativa con respaldo de OCR donde es apropiado para asegurar resultados consistentes a través de fuentes, codificaciones y escaneos.
  • Reconstrucción de diseño primero geometría: Reconstruimos el orden de lectura, líneas y columnas a partir de la geometría en la página para que las diferencias de formato no rompan el análisis.
  • Interpretación consciente del dominio: Asignamos semántica con validaciones financieras (ej. balances y subtotales deben conciliar) para prevenir valores plausibles pero incorrectos.
  • Salida auditable y revisable: Devolvemos datos estructurados con procedencia para apoyar la revisión humana y la trazabilidad.

Por qué esto importa

En finanzas, un error de extracción del uno por ciento no se siente como un error tipográfico, se siente como un cambio de valoración. Diseñamos para la precisión y consistencia a través de controles en capas, conciliaciones y pistas de auditoría integrales, entregando confiabilidad en la que los sistemas posteriores y los revisores pueden confiar.

Lo que obtienes listo para usar

  • Precisión zero-shot del 97%+ en documentos financieros comunes (y herramientas para revisar el último porcentaje).
  • Procesamiento multi-documento (ej. un año de estados de cuenta) en una sola llamada API con salida consolidada y normalizada.
  • Modo de depuración con verdad terreno (ground truth) de fuente y pistas de auditoría para cada valor.
  • Listo para empresas API REST y UI web, construidas para escala y seguridad, con retención de datos configurable (compatible con GDPR por defecto).

Si esto resuena con las cicatrices en tu propio analizador de PDF, hablemos.

Artículos relacionados

Holofin