Skip to content

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

PDFs multicoluna — artigos acadêmicos, livros-texto, matérias de revista, policy briefs — tropeçam a maioria das ferramentas de extração. Uma leitura ingênua de cima para baixo puxa uma palavra da coluna 1, depois uma palavra da coluna 2, volta à coluna 1, e produz saída embaralhada como accompaally ("accompa" da coluna 1 colado em "ally" da coluna 2).

O PDF Oxide usa o algoritmo XY-cut para detectar colunas e gerar a ordem de leitura natural automaticamente. Desde a v0.3.34 ele também se protege contra falsos positivos em layouts esparsos (páginas de copyright, capas) e trata corretamente layouts mistos em que uma tabela aparece dentro do texto corrido.

Exemplo rápido

A extração é ciente de colunas por padrão — sem flag nenhuma:

Python

from pdf_oxide import PdfDocument

doc = PdfDocument("academic-paper.pdf")
text = doc.extract_text(0)
# As colunas são lidas de cima para baixo dentro de cada coluna, sem 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));

O que o XY-cut faz

O algoritmo XY-cut divide a página recursivamente em regiões retangulares, alternando cortes verticais e horizontais ao longo das calhas de espaço em branco:

  1. Projeta todos os caracteres sobre o eixo X. Se aparecer uma lacuna vertical alta e larga (a calha entre colunas), divide a página em duas regiões naquela coordenada X.
  2. Dentro de cada região, projeta sobre o eixo Y e corta nas calhas horizontais (quebras de parágrafo, limites de seção).
  3. Recorre até cada região folha não ter mais nenhuma calha forte — esses são os blocos atômicos.
  4. Serializa os blocos na ordem de cima para baixo, da esquerda para a direita.

Isso espelha como uma pessoa lê: coluna 1 de cima para baixo, depois coluna 2 de cima para baixo, e por fim qualquer rodapé em largura total.

Quando o XY-cut entra em cena

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

  • Páginas de coluna única (nenhuma calha vertical encontrada, então a ordenação padrão por linha é usada)
  • Páginas esparsas com menos de ~10 spans de texto por coluna aparente — normalmente capas ou páginas de copyright onde dois picos de centro X são artefato e não colunas reais (corrigido na v0.3.34)

No caso comum não é preciso configurar nada. Se quiser forçar um modo ou outro, veja “Opt-out” abaixo.

O que a v0.3.34 corrigiu

Saída multicoluna intercalada em PDFs sem tags

Em PDFs multicoluna sem tags (livros-texto acadêmicos, referências de genética), o extract_text antes aplicava o XY-cut dentro de extract_spans() e depois reordenava o resultado com uma ordenação por linha em extract_text_with_options, desfazendo a estrutura de colunas. Resultado: fragmentos embaralhados como accompaally.

Correção: a reordenação por linha agora é pulada em páginas genuinamente multicoluna. Verificado limpo em Hartwell Genetics, Murphy ML e Kandel Neural Science.

Páginas com tabela dentro do texto

Layouts mistos (uma tabela embutida em texto corrido) podiam enganar o detector de colunas porque linhas de tabela expandidas com tabs 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 preenchidas com tabs não mascaram mais a calha.
  • Spans de caractere único (valores de célula como G, T) são excluídos da projeção para não se espalharem pela calha.
  • A cobertura usa uma estimativa por contagem de caracteres em vez da largura bruta do bbox, então linhas preenchidas com tabs não se passam mais por texto denso.

Falsos positivos em layouts esparsos

Páginas de copyright, capas e colofões podem produzir dois picos de centro X com apenas 7–10 spans por “coluna”. Elas deixaram de ser tratadas como multicoluna, evitando que o XY-cut separe frases cujas metades estão em posições X diferentes da mesma linha.

Acesso estruturado por coluna

Em um nível mais baixo que o extract_text, você pode obter palavras ou dados em nível de caractere com a mesma ordenação por coluna aplicada:

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# retornam linhas de (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 palavra / linha carrega sua bounding box, então você pode agrupar por coluna e reordenar por conta própria se precisar de uma política personalizada (por exemplo, ler a coluna da direita primeiro em layouts em árabe).

Detectar páginas multicoluna manualmente

Se quiser ramificar com base em a página ser multicoluna 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)
    # 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)")

Em produção, prefira extract_text e deixe a dupla XY-cut + proteção de layout esparso decidir.

Opt-out ou ordenação personalizada

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

Python

chars = doc.extract_chars(0)
# De cima para baixo, depois da esquerda para a direita — ignora colunas
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