Skip to content

Ordem de leitura e XY-cut — extraia PDFs multicolunas na ordem natural

PDFs com múltiplas colunas — artigos acadêmicos, livros didáticos, revistas, documentos de política pública — confundem a maioria das ferramentas de extração. Uma leitura simples de cima para baixo mistura palavras da coluna 1 com palavras da coluna 2, gerando saídas como accompaally ("accompa" da coluna 1 unido a "ally" da coluna 2).

PDF Oxide usa o algoritmo XY-cut para detectar colunas e produzir automaticamente a ordem natural de leitura. A partir do v0.3.34, o algoritmo também protege contra falsos positivos em páginas esparsas (páginas de copyright, folhas de rosto) e lida corretamente com layouts mistos onde uma tabela aparece dentro do corpo do texto.

Exemplo rápido

A extração reconhece colunas por padrão — sem necessidade de flags:

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)

Como o XY-cut funciona

O algoritmo XY-cut divide a página recursivamente em regiões retangulares, alternando cortes verticais e horizontais ao longo dos espaços em branco entre colunas:

  1. Projeta todos os caracteres no eixo X. Se aparecer um espaço vertical alto e largo (a calha entre colunas), a página é dividida em duas regiões nessa coordenada X.
  2. Dentro de cada região, projeta no eixo Y e divide nas calhas horizontais (quebras de parágrafo, limites de seção).
  3. Repete recursivamente até que cada região folha não tenha mais calhas evidentes — esses são os blocos atômicos.
  4. Serializa os blocos de cima para baixo, da esquerda para a direita.

Isso corresponde exatamente à forma como um humano lê: coluna 1 de cima a baixo, depois coluna 2 de cima a baixo, depois qualquer rodapé de largura total.

Quando o XY-cut é ativado

O XY-cut roda automaticamente quando extract_text detecta um layout multicolunas. Ele é ignorado em:

  • Páginas de coluna única (nenhuma calha vertical é encontrada, usa-se a ordenação padrão por linhas)
  • Páginas esparsas com menos de ~10 spans de texto por coluna aparente — geralmente são páginas de título ou de copyright onde dois picos de centro-X são um artefato, não colunas reais (corrigido no v0.3.34)

Para o caso comum, nenhuma configuração é necessária. Se precisar forçar um modo específico, veja “Como desativar” abaixo.

O que o v0.3.34 corrigiu

Saída intercalada em PDFs multicolunas sem tags

Em PDFs multicolunas sem tags (livros didáticos acadêmicos, referências de genética), o extract_text anteriormente aplicava XY-cut dentro de extract_spans() e depois re-ordenava o resultado com uma ordenação por linhas em extract_text_with_options, desfazendo a estrutura de colunas. Resultado: fragmentos embaralhados como accompaally.

Correção: a re-ordenação por linhas agora é ignorada em páginas genuinamente multicolunas. Verificado com os livros Hartwell Genetics, Murphy ML e Kandel Neural Science.

Páginas com tabela dentro do texto

Páginas de layout misto (uma tabela embutida em texto corrido) podiam enganar o detector de colunas porque linhas de tabela expandidas por tabulação preenchiam a calha entre colunas. Correções:

  • Spans largos (>55% da largura da região) são excluídos da densidade de projeção — linhas com tabulação não mascaram mais a calha.
  • Spans de caractere único (valores de célula de tabela como G, T) são excluídos da projeção para não se espalharem pela calha.
  • A cobertura usa uma estimativa de contagem de caracteres em vez da largura bruta da bounding box, então linhas com tabulação não se disfarçam de texto denso.

Falsos positivos em layouts esparsos

Páginas de copyright, folhas de rosto e colofões podem gerar dois picos de centro-X com apenas 7–10 spans por “coluna”. Essas páginas não são mais tratadas como multicolunas, evitando que o XY-cut divida frases cujas metades estão em posições X diferentes na mesma linha.

Acesso estruturado por coluna

Num nível mais baixo que extract_text, você pode buscar palavras ou dados no nível de caracteres mantendo a mesma ordenação por colunas:

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 palavra e linha traz sua bounding box, permitindo agrupar por coluna e reordenar com uma política personalizada (por exemplo, ler a coluna da direita primeiro em layouts em árabe).

Detecção manual de páginas multicolunas

Se quiser verificar se uma página é multicolunas antes de extrair:

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

Para uso em produção, prefira extract_text e deixe o XY-cut da biblioteca junto com a proteção contra layouts esparsos tomarem a decisão.

Como desativar ou usar ordenação personalizada

Se você precisa de spans ordenados por posição bruta (por exemplo, para um motor de layout personalizado), use extract_chars ou extract_words — eles retornam registros com bounding boxes e permitem aplicar sua própria ordenação:

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