Reading Order & XY-Cut — Extract Multi-Column PDFs in Natural Order
Multi-column PDFs — academic papers, textbooks, magazine articles, policy briefs — trip up most extraction tools. A naïve top-to-bottom read pulls a word from column 1, then a word from column 2, then back to column 1, producing garbled output like accompaally ("accompa" from column 1 joined to "ally" from column 2).
PDF Oxide uses an XY-cut algorithm to detect columns and produce natural reading order automatically. Since v0.3.34 it also guards against sparse-layout false positives (copyright pages, title pages) and correctly handles mixed layouts where a table sits inside body text.
Quick Example
Extraction is column-aware by default — no flag needed:
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)
What XY-cut Does
The XY-cut algorithm recursively splits a page into rectangular regions by alternating vertical and horizontal cuts along whitespace gutters:
- Project all characters onto the X axis. If a tall, wide vertical gap shows up (the column gutter), split the page into two regions at that X coordinate.
- Within each region, project onto the Y axis and split on horizontal gutters (paragraph breaks, section boundaries).
- Recurse until each leaf region has no strong gutter — these are the atomic blocks.
- Serialize blocks in top-to-bottom, left-to-right order.
This matches how a human reads: column 1 top to bottom, then column 2 top to bottom, then any full-width footer.
When XY-cut Activates
XY-cut runs automatically when extract_text detects a multi-column layout. It is skipped for:
- Single-column pages (no vertical gutter is found, so the default row-aware sort is used)
- Sparse pages with fewer than ~10 text spans per apparent column — these are typically title or copyright pages where two X-center peaks are an artefact rather than real columns (fixed in v0.3.34)
No configuration is needed for the common case. If you want to force one mode or the other, see “Opt-out” below.
What v0.3.34 Fixed
Interleaved multi-column output on untagged PDFs
On untagged multi-column PDFs (academic textbooks, genetics references), extract_text previously applied XY-cut inside extract_spans() and then re-sorted the result with a row-aware sort in extract_text_with_options, undoing the column structure. Result: garbled fragments like accompaally.
Fix: the row-aware re-sort is now skipped on pages that are genuinely multi-column. Verified clean on Hartwell Genetics, Murphy ML, and Kandel Neural Science textbooks.
Table-within-text pages
Mixed-layout pages (a table embedded in running body text) could trick the column detector because tab-expanded table rows filled the column gutter. Fix:
- Wide spans (>55 % of region width) are excluded from the projection density — tab-padded rows no longer mask the gutter.
- Single-character spans (table cell values like
G,T) are excluded from the projection so they don’t scatter across the gutter. - Coverage uses a character-count estimate rather than raw bbox width, so tab-padded rows no longer masquerade as dense body text.
Sparse-layout false positives
Copyright pages, title pages, and colophons can produce two X-center peaks with only 7–10 spans per “column”. These are no longer treated as multi-column, preventing XY-cut from splitting sentences whose halves sit at different X positions on the same line.
Structured Access per Column
Going lower-level than extract_text, you can pull words or character-level data with the same column ordering applied:
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)
Each word / line carries its bounding box so you can group by column and re-order yourself if you need a custom policy (e.g. read the right column first for Arabic layouts).
Detecting Multi-Column Pages Manually
If you want to branch on whether a page is multi-column before extracting:
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
For production use, prefer extract_text and let the library’s XY-cut + sparse-layout guard make the call.
Opt-out or Custom Ordering
If you want raw, position-ordered spans (e.g. for a custom layout engine), use extract_chars or extract_words — these return records with bounding boxes, and you can apply your own sort:
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)
Related Pages
- Text Extraction — full extraction API
- Extraction Profiles — tune space detection per document type
- Extract Tables from PDF — structured table output
- Changelog — v0.3.34 multi-column and mixed-layout fixes