Порядок чтения и XY-cut — извлечение многоколоночных PDF в естественном порядке
Многоколоночные PDF — научные статьи, учебники, журнальные материалы, политические документы — ставят большинство инструментов извлечения в тупик. При наивном чтении сверху вниз слова из первой и второй колонок перемежаются, давая на выходе мусор вроде accompaally ("accompa" из первой колонки соединяется с "ally" из второй).
PDF Oxide применяет алгоритм XY-cut для обнаружения колонок и автоматического восстановления естественного порядка чтения. Начиная с v0.3.34 добавлена защита от ложных срабатываний на разреженных страницах (страницы с авторскими правами, титульные страницы), а также корректная обработка смешанных макетов, где таблица расположена внутри основного текста.
Быстрый пример
Извлечение учитывает колонки по умолчанию — никаких флагов не нужно:
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)
Как работает XY-cut
Алгоритм XY-cut рекурсивно разбивает страницу на прямоугольные области, поочерёдно проводя вертикальные и горизонтальные разрезы вдоль пустых промежутков:
- Все символы проецируются на ось X. Если обнаруживается высокий широкий вертикальный пропуск (межколоночный отступ), страница делится на две области по этой X-координате.
- Внутри каждой области символы проецируются на ось Y, и область делится по горизонтальным промежуткам (отступы между абзацами, границы разделов).
- Рекурсия продолжается до тех пор, пока в каждой листовой области не останется явных промежутков — это и есть атомарные блоки.
- Блоки сериализуются в порядке сверху вниз, слева направо.
Это точно соответствует тому, как читает человек: сначала первая колонка сверху вниз, затем вторая сверху вниз, и наконец полностраничный нижний колонтитул.
Когда активируется XY-cut
XY-cut запускается автоматически, когда extract_text обнаруживает многоколоночный макет. Алгоритм пропускается в следующих случаях:
- Одноколоночные страницы (вертикальный промежуток не найден, используется стандартная построчная сортировка)
- Разреженные страницы, где на каждую предполагаемую колонку приходится менее ~10 текстовых фрагментов — как правило, это титульные страницы или страницы с авторскими правами, где два пика X-центров являются артефактом, а не настоящими колонками (исправлено в v0.3.34)
Для типичных сценариев настройка не требуется. Если нужно принудительно включить определённый режим, см. раздел «Отказ от XY-cut» ниже.
Что исправлено в v0.3.34
Чередование колонок в неразмеченных PDF
В неразмеченных многоколоночных PDF (академические учебники, справочники по генетике) extract_text ранее применял XY-cut внутри extract_spans(), а затем пересортировывал результат построчной сортировкой в extract_text_with_options, разрушая структуру колонок. Итогом были перемешанные фрагменты вроде accompaally.
Исправление: построчная пересортировка теперь пропускается для страниц, которые действительно являются многоколоночными. Проверено на учебниках Hartwell Genetics, Murphy ML и Kandel Neural Science.
Страницы с таблицами внутри текста
На страницах со смешанным макетом (таблица, встроенная в текст) развёрнутые табуляцией строки таблицы заполняли межколоночный промежуток и вводили детектор колонок в заблуждение. Исправления:
- Широкие фрагменты (больше 55% ширины области) исключены из плотности проекции — табулированные строки больше не перекрывают промежуток.
- Односимвольные фрагменты (значения ячеек таблицы вроде
G,T) исключены из проекции, чтобы не рассеиваться по всему промежутку. - Покрытие рассчитывается на основе подсчёта символов, а не исходной ширины ограничивающего прямоугольника, поэтому табулированные строки больше не маскируются под плотный основной текст.
Ложные срабатывания на разреженных страницах
Страницы с авторскими правами, титульные страницы и выходные данные могут давать два пика X-центров при всего 7–10 фрагментах на «колонку». Теперь они не считаются многоколоночными, и XY-cut не разрывает предложения, половины которых расположены в разных X-позициях на одной строке.
Структурированный доступ по колонкам
Для более низкоуровневой работы, чем extract_text, можно получить слова или данные на уровне символов с сохранением порядка колонок:
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)
Каждое слово и каждая строка содержат ограничивающий прямоугольник, поэтому вы можете группировать их по колонкам и применять собственную политику сортировки (например, читать правую колонку первой для арабского макета).
Ручное определение многоколоночных страниц
Если нужно проверить, является ли страница многоколоночной, перед извлечением:
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
В производственных проектах лучше использовать extract_text и доверить принятие решений встроенному XY-cut в сочетании с защитой от разреженных страниц.
Отказ от XY-cut и пользовательская сортировка
Если нужны исходные фрагменты в порядке расположения (например, для собственного движка вёрстки), используйте extract_chars или extract_words — они возвращают записи с ограничивающими прямоугольниками, к которым можно применить любую сортировку:
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)
Связанные страницы
- Извлечение текста — полный API извлечения
- Профили извлечения — настройка обнаружения пробелов для разных типов документов
- Извлечение таблиц из PDF — структурированный вывод таблиц
- История изменений — исправления многоколоночного и смешанного макета в v0.3.34