Skip to content

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

Los PDFs con múltiples columnas — artículos académicos, libros de texto, artículos de revistas, documentos de política — confunden a la mayoría de las herramientas de extracción. Una lectura ingenua de arriba a abajo toma una palabra de la columna 1, luego una de la columna 2, luego vuelve a la columna 1, produciendo salidas revueltas como accompaally ("accompa" de la columna 1 pegado a "ally" de la columna 2).

PDF Oxide usa un algoritmo XY-cut para detectar columnas y producir el orden de lectura natural de forma automática. Desde v0.3.34 también protege contra falsos positivos en páginas dispersas (páginas de copyright, portadas) y maneja correctamente los diseños mixtos donde una tabla está dentro del cuerpo del texto.

Ejemplo rápido

La extracción reconoce columnas por defecto — no hace falta ningún parámetro extra:

Python

from pdf_oxide import PdfDocument

doc = PdfDocument("academic-paper.pdf")
text = doc.extract_text(0)
# Columns are read top-to-bottom within each column, not interleaved.

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));

Java

import fyi.oxide.pdf.PdfDocument;
import java.nio.file.Path;

try (PdfDocument doc = PdfDocument.open(Path.of("academic-paper.pdf"))) {
    String text = doc.extractText(0);
}

Kotlin

import fyi.oxide.pdf.PdfDocument
import java.nio.file.Path

PdfDocument.open(Path.of("academic-paper.pdf")).use { doc ->
    val text = doc.extractText(0)
}

Scala

import fyi.oxide.pdf.PdfDocument
import scala.util.Using

Using.resource(PdfDocument.open("academic-paper.pdf")) { doc =>
  val text = doc.extractText(0)
}

Clojure

(require '[pdf-oxide.core :as pdf])

(with-open [doc (pdf/open "academic-paper.pdf")]
  (pdf/extract-text doc 0))

Ruby

require 'pdf_oxide'

PdfOxide::PdfDocument.open('academic-paper.pdf') do |doc|
  text = doc.extract_text(0)
end

PHP

use PdfOxide\PdfDocument;

$doc  = PdfDocument::open('academic-paper.pdf');
$text = $doc->extractText(0);
$doc->close();

C++

#include <pdf_oxide/pdf_oxide.hpp>

auto doc  = pdf_oxide::Document::open("academic-paper.pdf");
auto text = doc.extract_text(0);

Swift

import PdfOxide

let doc  = try Document.open("academic-paper.pdf")
let text = try doc.extractText(0)

Dart

import 'package:pdf_oxide/pdf_oxide.dart';

final doc  = PdfDocument.open('academic-paper.pdf');
final text = doc.extractText(0);
doc.close();

R

library(pdfoxide)

doc  <- pdf_open("academic-paper.pdf")
text <- pdf_extract_text(doc, 0)

Julia

using PdfOxide

doc  = open_document("academic-paper.pdf")
text = extract_text(doc, 0)

Zig

const pdf_oxide = @import("pdf_oxide");
const a = std.heap.page_allocator;

var doc  = try pdf_oxide.Document.open("academic-paper.pdf");
const text = try doc.extractText(a, 0);

Objective-C

#import "POXPdfOxide.h"
NSError *err = nil;

POXDocument *doc = [POXDocument openPath:@"academic-paper.pdf" error:&err];
NSString *text = [doc extractText:0 error:&err];

Elixir

{:ok, doc}  = PdfOxide.open("academic-paper.pdf")
{:ok, text} = PdfOxide.extract_text(doc, 0)

Cómo funciona el XY-cut

El algoritmo XY-cut divide una página de forma recursiva en regiones rectangulares aplicando cortes verticales y horizontales alternos a lo largo de los espacios en blanco entre columnas:

  1. Proyecta todos los caracteres en el eje X. Si aparece un hueco vertical alto y ancho (el medianil entre columnas), divide la página en dos regiones en esa coordenada X.
  2. Dentro de cada región, proyecta en el eje Y y divide en los huecos horizontales (separaciones de párrafo, límites de sección).
  3. Repite de forma recursiva hasta que cada región hoja no tenga más huecos pronunciados — esos son los bloques atómicos.
  4. Serializa los bloques de arriba a abajo y de izquierda a derecha.

Esto coincide con cómo 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 el XY-cut

El XY-cut se ejecuta automáticamente cuando extract_text detecta un diseño multicolumna. Se omite en:

  • Páginas de una sola columna (no se encuentra medianil vertical, se usa la ordenación por filas por defecto)
  • Páginas dispersas con menos de ~10 tramos de texto por columna aparente — suelen ser portadas o páginas de copyright donde dos picos de centro-X son un artefacto, no columnas reales (corregido en v0.3.34)

Para el caso habitual no se necesita ninguna configuración. Si quieres forzar un modo concreto, consulta “Cómo desactivarlo” más abajo.

Qué corrigió v0.3.34

Salida intercalada en PDFs multicolumna sin etiquetar

En PDFs multicolumna sin etiquetar (libros de texto académicos, referencias de genética), extract_text aplicaba antes el XY-cut dentro de extract_spans() y luego reordenaba el resultado con una ordenación por filas en extract_text_with_options, deshaciendo la estructura de columnas. Resultado: fragmentos mezclados como accompaally.

Corrección: la reordenación por filas ahora se omite en páginas genuinamente multicolumna. Verificado con los libros Hartwell Genetics, Murphy ML y Kandel Neural Science.

Páginas con tabla dentro del texto

Las páginas de diseño mixto (una tabla incrustada en el flujo del texto) podían engañar al detector de columnas porque las filas de tabla expandidas con tabulaciones rellenaban el medianil entre columnas. Correcciones:

  • Los tramos anchos (>55 % del ancho de la región) se excluyen de la densidad de proyección — las filas rellenas con tabulaciones ya no enmascaran el medianil.
  • Los tramos de un solo carácter (valores de celda de tabla como G, T) se excluyen de la proyección para que no se dispersen por el medianil.
  • La cobertura usa una estimación del recuento de caracteres en lugar del ancho bruto del bounding box, de modo que las filas con tabulaciones ya no se hacen pasar por texto denso.

Falsos positivos en diseños dispersos

Las páginas de copyright, portadas y colofones pueden generar dos picos de centro-X con solo 7–10 tramos por «columna». Estas páginas ya no se tratan como multicolumna, lo que evita que el XY-cut parta frases cuyos fragmentos están en posiciones X distintas en la misma línea.

Acceso estructurado por columna

A un nivel más bajo que 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# return rows of (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})");

Java

try (PdfDocument doc = PdfDocument.open(Path.of("paper.pdf"))) {
    for (TextWord w : doc.page(0).words()) {
        System.out.printf("%s  (%.0f,%.0f)%n", w.text(), w.bbox().x0(), w.bbox().y0());
    }
}

Kotlin

PdfDocument.open(Path.of("paper.pdf")).use { doc ->
    for (w in doc.page(0).words()) {
        println("${w.text()}  (${w.bbox().x0()},${w.bbox().y0()})")
    }
}

Scala

Using.resource(PdfDocument.open("paper.pdf")) { doc =>
  doc.page(0).wordsSeq.foreach { w =>
    println(f"${w.text}  (${w.bbox.x0}%.0f,${w.bbox.y0}%.0f)")
  }
}

Clojure

(with-open [doc (pdf/open "paper.pdf")]
  (doseq [w (pdf/words (pdf/page doc 0))]
    (printf "%s  (%.0f,%.0f)%n" (.text w) (.. w bbox x0) (.. w bbox y0))))

C++

auto doc = pdf_oxide::Document::open("paper.pdf");
for (const auto& w : doc.extract_words(0)) {
    std::printf("%s  (%.0f,%.0f)\n", w.text.c_str(), w.bbox.x, w.bbox.y);
}

Swift

let doc = try Document.open("paper.pdf")
for w in try doc.extractWords(0) {
    print("\(w.text)  (\(w.bbox.x),\(w.bbox.y))")
}

Dart

final doc = PdfDocument.open('paper.pdf');
for (final w in doc.extractWords(0)) {
  print('${w.text}  (${w.bbox.x},${w.bbox.y})');
}
doc.close();

R

doc   <- pdf_open("paper.pdf")
words <- pdf_extract_words(doc, 0)
for (w in words) {
  cat(sprintf("%s  (%.0f,%.0f)\n", w$text, w$bbox$x, w$bbox$y))
}

Julia

doc = open_document("paper.pdf")
for w in extract_words(doc, 0)
    println("$(w.text)  ($(w.bbox.x),$(w.bbox.y))")
end

Zig

var doc = try pdf_oxide.Document.open("paper.pdf");
const words = try doc.extractWords(a, 0);
defer pdf_oxide.Document.freeWords(a, words);
for (words) |w| {
    std.debug.print("{s}  ({d:.0},{d:.0})\n", .{ w.text, w.bbox.x, w.bbox.y });
}

Objective-C

POXDocument *doc = [POXDocument openPath:@"paper.pdf" error:&err];
for (POXWord *w in [doc extractWords:0 error:&err]) {
    NSLog(@"%@  (%.0f,%.0f)", w.text, w.bbox.x, w.bbox.y);
}

Elixir

{:ok, doc}   = PdfOxide.open("paper.pdf")
{:ok, words} = PdfOxide.extract_words(doc, 0)
Enum.each(words, fn w ->
  IO.puts("#{w.text}  (#{w.bbox.x},#{w.bbox.y})")
end)

Cada palabra y línea lleva su bounding box, de modo que puedes agrupar por columna y reordenar según tu propia política (por ejemplo, leer primero la columna de la derecha para diseños en árabe).

Detección manual de páginas multicolumna

Si quieres comprobar si una 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)
    # Heuristic: distinct X-center clusters
    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)")

Java

try (PdfDocument doc = PdfDocument.open(Path.of("mixed.pdf"))) {
    for (int i = 0; i < doc.pageCount(); i++) {
        Set<Long> xCenters = new HashSet<>();
        for (TextWord w : doc.page(i).words()) {
            double cx = w.bbox().x0() + w.bbox().width() / 2;
            xCenters.add(Math.round(cx / 50) * 50L);
        }
        if (xCenters.size() >= 2)
            System.out.printf("Page %d: likely multi-column (%d X-centers)%n", i, xCenters.size());
    }
}

Kotlin

PdfDocument.open(Path.of("mixed.pdf")).use { doc ->
    for (i in 0 until doc.pageCount()) {
        val xCenters = doc.page(i).words().map {
            (Math.round((it.bbox().x0() + it.bbox().width() / 2) / 50) * 50)
        }.toSet()
        if (xCenters.size >= 2)
            println("Page $i: likely multi-column (${xCenters.size} X-centers)")
    }
}

Scala

Using.resource(PdfDocument.open("mixed.pdf")) { doc =>
  for (i <- 0 until doc.pageCount()) {
    val xCenters = doc.page(i).wordsSeq.map { w =>
      math.round((w.bbox.x0 + w.bbox.width / 2) / 50) * 50
    }.toSet
    if (xCenters.size >= 2)
      println(s"Page $i: likely multi-column (${xCenters.size} X-centers)")
  }
}

Clojure

(with-open [doc (pdf/open "mixed.pdf")]
  (doseq [i (range (pdf/page-count doc))]
    (let [xs (set (map #(* 50 (Math/round (/ (+ (.. % bbox x0) (/ (.. % bbox width) 2)) 50.0)))
                       (pdf/words (pdf/page doc i))))]
      (when (>= (count xs) 2)
        (printf "Page %d: likely multi-column (%d X-centers)%n" i (count xs))))))

C++

auto doc = pdf_oxide::Document::open("mixed.pdf");
for (int i = 0; i < doc.page_count(); ++i) {
    std::set<long> x_centers;
    for (const auto& w : doc.extract_words(i))
        x_centers.insert(std::lround((w.bbox.x + w.bbox.width / 2) / 50) * 50);
    if (x_centers.size() >= 2)
        std::printf("Page %d: likely multi-column (%zu X-centers)\n", i, x_centers.size());
}

Swift

let doc = try Document.open("mixed.pdf")
for i in 0..<(try doc.pageCount()) {
    let xCenters = Set(try doc.extractWords(i).map {
        (($0.bbox.x + $0.bbox.width / 2) / 50).rounded() * 50
    })
    if xCenters.count >= 2 {
        print("Page \(i): likely multi-column (\(xCenters.count) X-centers)")
    }
}

Dart

final doc = PdfDocument.open('mixed.pdf');
for (var i = 0; i < doc.pageCount; i++) {
  final xCenters = doc.extractWords(i)
      .map((w) => ((w.bbox.x + w.bbox.width / 2) / 50).round() * 50)
      .toSet();
  if (xCenters.length >= 2) {
    print('Page $i: likely multi-column (${xCenters.length} X-centers)');
  }
}
doc.close();

R

doc <- pdf_open("mixed.pdf")
for (i in 0:(pdf_page_count(doc) - 1)) {
  words <- pdf_extract_words(doc, i)
  x_centers <- unique(sapply(words, function(w)
    round((w$bbox$x + w$bbox$width / 2) / 50) * 50))
  if (length(x_centers) >= 2)
    cat(sprintf("Page %d: likely multi-column (%d X-centers)\n", i, length(x_centers)))
}

Julia

doc = open_document("mixed.pdf")
for i in 0:(page_count(doc) - 1)
    x_centers = Set(round(Int, (w.bbox.x + w.bbox.width / 2) / 50) * 50
                    for w in extract_words(doc, i))
    if length(x_centers) >= 2
        println("Page $i: likely multi-column ($(length(x_centers)) X-centers)")
    end
end

Zig

var doc = try pdf_oxide.Document.open("mixed.pdf");
const n = try doc.pageCount();
var i: i32 = 0;
while (i < n) : (i += 1) {
    const words = try doc.extractWords(a, i);
    defer pdf_oxide.Document.freeWords(a, words);
    var centers = std.AutoHashMap(i64, void).init(a);
    defer centers.deinit();
    for (words) |w| {
        const c: i64 = @intFromFloat(@round((w.bbox.x + w.bbox.width / 2) / 50) * 50);
        try centers.put(c, {});
    }
    if (centers.count() >= 2)
        std.debug.print("Page {d}: likely multi-column ({d} X-centers)\n", .{ i, centers.count() });
}

Objective-C

POXDocument *doc = [POXDocument openPath:@"mixed.pdf" error:&err];
for (NSInteger i = 0; i < [doc pageCountError:&err]; i++) {
    NSMutableSet<NSNumber*> *xCenters = [NSMutableSet set];
    for (POXWord *w in [doc extractWords:i error:&err]) {
        long c = lround((w.bbox.x + w.bbox.width / 2) / 50) * 50;
        [xCenters addObject:@(c)];
    }
    if (xCenters.count >= 2)
        NSLog(@"Page %ld: likely multi-column (%lu X-centers)", (long)i, (unsigned long)xCenters.count);
}

Elixir

{:ok, doc} = PdfOxide.open("mixed.pdf")
{:ok, n}   = PdfOxide.page_count(doc)
for i <- 0..(n - 1) do
  {:ok, words} = PdfOxide.extract_words(doc, i)
  x_centers = words
    |> Enum.map(fn w -> round((w.bbox.x + w.bbox.width / 2) / 50) * 50 end)
    |> Enum.uniq()
  if length(x_centers) >= 2 do
    IO.puts("Page #{i}: likely multi-column (#{length(x_centers)} X-centers)")
  end
end

En producción, lo mejor es usar extract_text y dejar que el XY-cut de la biblioteca junto con la protección frente a páginas dispersas tomen la decisión.

Cómo desactivarlo o usar un orden personalizado

Si necesitas tramos ordenados por posición en bruto (por ejemplo, para un motor de diseño propio), usa extract_chars o extract_words — devuelven registros con bounding boxes y permiten aplicar tu propia ordenación:

Python

chars = doc.extract_chars(0)
# Top-to-bottom, then left-to-right — ignores columns
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()));

Java

List<TextChar> chars = new ArrayList<>(doc.page(0).chars());
// Top-to-bottom, then left-to-right — ignores columns
chars.sort(Comparator
    .comparingDouble((TextChar c) -> c.bbox().y0()).reversed()
    .thenComparingDouble(c -> c.bbox().x0()));

Kotlin

val chars = doc.page(0).chars()
    .sortedWith(compareByDescending<TextChar> { it.bbox().y0() }
        .thenBy { it.bbox().x0() })

Scala

val chars = doc.page(0).charsSeq
  .sortBy(c => (-c.bbox.y0, c.bbox.x0))

Clojure

(def chars
  (sort-by (juxt #(- (.. % bbox y0)) #(.. % bbox x0))
           (pdf/chars (pdf/page doc 0))))

C++

auto chars = doc.extract_chars(0);
// Top-to-bottom, then left-to-right — ignores columns
std::sort(chars.begin(), chars.end(), [](const auto& a, const auto& b) {
    return a.bbox.y != b.bbox.y ? a.bbox.y > b.bbox.y : a.bbox.x < b.bbox.x;
});

Swift

let chars = try doc.extractChars(0).sorted {
    $0.bbox.y != $1.bbox.y ? $0.bbox.y > $1.bbox.y : $0.bbox.x < $1.bbox.x
}

Dart

final chars = doc.extractChars(0)
  ..sort((a, b) => a.bbox.y != b.bbox.y
      ? b.bbox.y.compareTo(a.bbox.y)
      : a.bbox.x.compareTo(b.bbox.x));

R

chars <- pdf_extract_chars(doc, 0)
# Top-to-bottom, then left-to-right — ignores columns
chars <- chars[order(-sapply(chars, function(c) c$bbox$y),
                      sapply(chars, function(c) c$bbox$x))]

Julia

chars = extract_chars(doc, 0)
# Top-to-bottom, then left-to-right — ignores columns
sort!(chars, by = c -> (-c.bbox.y, c.bbox.x))

Zig

const chars = try doc.extractChars(a, 0);
defer pdf_oxide.Document.freeChars(a, chars);
std.mem.sort(pdf_oxide.Char, chars, {}, struct {
    fn lt(_: void, x: pdf_oxide.Char, y: pdf_oxide.Char) bool {
        return if (x.bbox.y != y.bbox.y) x.bbox.y > y.bbox.y else x.bbox.x < y.bbox.x;
    }
}.lt);

Objective-C

NSArray<POXChar*> *chars = [doc extractChars:0 error:&err];
// Top-to-bottom, then left-to-right — ignores columns
chars = [chars sortedArrayUsingComparator:^NSComparisonResult(POXChar *a, POXChar *b) {
    if (a.bbox.y != b.bbox.y) return a.bbox.y > b.bbox.y ? NSOrderedAscending : NSOrderedDescending;
    return a.bbox.x < b.bbox.x ? NSOrderedAscending : NSOrderedDescending;
}];

Elixir

{:ok, chars} = PdfOxide.extract_chars(doc, 0)
# Top-to-bottom, then left-to-right — ignores columns
chars = Enum.sort_by(chars, fn c -> {-c.bbox.y, c.bbox.x} end)

Páginas relacionadas