Skip to content

Orden de lectura y XY-cut — extrae PDFs multicolumna en orden natural

Los PDFs multicolumna — papers académicos, libros de texto, artículos de revista, informes de política — hacen tropezar a casi todas las herramientas de extracción. Una lectura ingenua de arriba a abajo saca una palabra de la columna 1, luego una de la columna 2, después vuelve a la 1 y produce salidas confusas como accompaally ("accompa" de la columna 1 pegado con "ally" de la columna 2).

PDF Oxide usa un algoritmo XY-cut para detectar columnas y generar el orden de lectura natural automáticamente. Desde v0.3.34 también se protege contra falsos positivos en layouts dispersos (páginas de copyright, portadas) y maneja correctamente layouts mixtos donde una tabla queda dentro del cuerpo del texto.

Ejemplo rápido

La extracción es consciente de columnas por defecto — sin flag extra:

Python

from pdf_oxide import PdfDocument

doc = PdfDocument("academic-paper.pdf")
text = doc.extract_text(0)
# Las columnas se leen de arriba a abajo dentro de cada columna, sin intercalar.

Rust

use pdf_oxide::PdfDocument;

let mut doc = PdfDocument::open("academic-paper.pdf")?;
let text = doc.extract_text(0)?;

JavaScript / TypeScript (Node)

const { PdfDocument } = require("pdf-oxide");
const doc = new PdfDocument("academic-paper.pdf");
const text = doc.extractText(0);
doc.close();

JavaScript (WASM)

import { WasmPdfDocument } from "pdf-oxide-wasm";
const doc = new WasmPdfDocument(bytes);
console.log(doc.extractText(0));
doc.free();

Go

doc, _ := pdfoxide.Open("academic-paper.pdf")
defer doc.Close()

text, _ := doc.ExtractText(0)
fmt.Println(text)

C#

using PdfOxide;

using var doc = PdfDocument.Open("academic-paper.pdf");
Console.WriteLine(doc.ExtractText(0));

Qué hace XY-cut

El algoritmo XY-cut divide la página de forma recursiva en regiones rectangulares, alternando cortes verticales y horizontales a lo largo de los canales de blanco:

  1. Proyecta todos los caracteres sobre el eje X. Si aparece un hueco vertical alto y ancho (el canal de columnas), divide la página en dos regiones en esa coordenada X.
  2. Dentro de cada región, proyecta sobre el eje Y y corta en los canales horizontales (saltos de párrafo, límites de sección).
  3. Recursa hasta que cada región hoja no tenga un canal marcado — esas son las unidades atómicas.
  4. Serializa los bloques en orden de arriba a abajo, de izquierda a derecha.

Esto coincide con la manera en que lee una persona: columna 1 de arriba a abajo, luego columna 2 de arriba a abajo, y por último cualquier pie de página a ancho completo.

Cuándo se activa XY-cut

XY-cut corre automáticamente cuando extract_text detecta un layout multicolumna. Se omite en:

  • Páginas de una sola columna (no hay canal vertical, así que se usa el orden por fila por defecto)
  • Páginas dispersas con menos de ~10 spans de texto por columna aparente — suelen ser portadas o páginas de copyright donde dos picos de centro X son un artefacto y no columnas reales (arreglado en v0.3.34)

Para el caso común no hace falta configuración. Si quieres forzar uno u otro modo, mira la sección “Desactivar” abajo.

Qué arregló v0.3.34

Salida multicolumna intercalada en PDFs sin tags

En PDFs multicolumna sin tags (libros académicos, referencias de genética), antes extract_text aplicaba XY-cut dentro de extract_spans() y luego reordenaba el resultado con un orden por fila en extract_text_with_options, deshaciendo la estructura de columnas. Resultado: fragmentos confusos como accompaally.

Fix: el reordenamiento por fila se omite ahora en páginas que son genuinamente multicolumna. Verificado limpio en Hartwell Genetics, Murphy ML y Kandel Neural Science.

Páginas con tabla dentro del texto

Los layouts mixtos (una tabla embebida en cuerpo de texto corrido) podían confundir al detector de columnas porque las filas de tabla expandidas con tabs llenaban el canal entre columnas. Fix:

  • Los spans anchos (>55 % del ancho de la región) quedan excluidos de la densidad de proyección — las filas rellenadas con tabs ya no tapan el canal.
  • Los spans de un solo carácter (valores de celda como G, T) se excluyen de la proyección para que no se esparzan por el canal.
  • La cobertura usa una estimación por conteo de caracteres en vez del ancho crudo del bbox, así las filas con tabs ya no se disfrazan de cuerpo de texto denso.

Falsos positivos en layouts dispersos

Las páginas de copyright, portadas y colofones pueden producir dos picos de centro X con apenas 7–10 spans por “columna”. Ya no se tratan como multicolumna, evitando que XY-cut parta oraciones cuyas mitades quedan en posiciones X distintas de la misma línea.

Acceso estructurado por columna

Un nivel por debajo de extract_text, puedes obtener palabras o datos a nivel de carácter con el mismo orden de columnas aplicado:

Python

from pdf_oxide import PdfDocument

doc = PdfDocument("paper.pdf")
for w in doc.extract_words(0):
    print(f"{w.text}  ({w.x0:.0f},{w.y0:.0f})")

Rust

let mut doc = PdfDocument::open("paper.pdf")?;
for w in doc.extract_words(0)? {
    println!("{}  ({:.0},{:.0})", w.text, w.x0, w.y0);
}

Go

doc, _ := pdfoxide.Open("paper.pdf")
defer doc.Close()

words, _ := doc.ExtractWords(0)
for _, w := range words {
    fmt.Printf("%s  (%.0f,%.0f)\n", w.Text, w.X0, w.Y0)
}

C#

using var doc = PdfDocument.Open("paper.pdf");
// Node/C# devuelven filas del tipo (text, x, y, w, h):
var lines = doc.ExtractTextLines(0);
foreach (var (text, x, y, w, h) in lines)
    Console.WriteLine($"{text}  ({x:F0},{y:F0})");

Cada palabra o línea trae su bounding box, así puedes agrupar por columna y reordenar tú mismo si necesitas una política personalizada (por ejemplo, leer la columna derecha primero para layouts en árabe).

Detectar páginas multicolumna manualmente

Si quieres ramificar según si la página es multicolumna antes de extraer:

Python

from pdf_oxide import PdfDocument

doc = PdfDocument("mixed.pdf")
for i in range(doc.page_count()):
    words = doc.extract_words(i)
    # Heurística: clusters distintos de centros X
    x_centers = {round((w.x0 + w.x1) / 2 / 50) * 50 for w in words}
    if len(x_centers) >= 2:
        print(f"Page {i}: likely multi-column ({len(x_centers)} X-centers)")

Para producción, usa extract_text y deja que la combinación XY-cut + guardia contra layouts dispersos tome la decisión.

Desactivar u orden personalizado

Si quieres spans crudos ordenados por posición (por ejemplo, para un motor de layout propio), usa extract_chars o extract_words — devuelven registros con bounding boxes sobre los cuales puedes aplicar tu propio criterio:

Python

chars = doc.extract_chars(0)
# De arriba a abajo, luego de izquierda a derecha — ignora columnas
chars_sorted = sorted(chars, key=lambda c: (-c.y, c.x))

Rust

let mut chars = doc.extract_chars(0)?;
chars.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap()
    .then(a.x.partial_cmp(&b.x).unwrap()));

Páginas relacionadas