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:
- 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.
- Dentro de cada região, projeta no eixo Y e divide nas calhas horizontais (quebras de parágrafo, limites de seção).
- Repete recursivamente até que cada região folha não tenha mais calhas evidentes — esses são os blocos atômicos.
- 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
- Extração de texto — API completa de extração
- Perfis de extração — ajuste da detecção de espaços por tipo de documento
- Extrair tabelas de PDF — saída estruturada de tabelas
- Changelog — correções de layout multicolunas e misto no v0.3.34