Порядок чтения и XY-cut — извлечение многоколоночных PDF в естественном порядке
Многоколоночные PDF — научные статьи, учебники, журнальные материалы, policy briefs — сбивают большинство инструментов извлечения. Наивный проход сверху вниз подхватывает слово из первой колонки, затем из второй, потом снова из первой — и на выходе получается каша вроде 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)
# Колонки читаются сверху вниз внутри каждой колонки, без чередования.
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));
Что делает XY-cut
Алгоритм XY-cut рекурсивно делит страницу на прямоугольные регионы, чередуя вертикальные и горизонтальные разрезы по жёлобам пустого пространства:
- Проецирует все символы на ось X. Если обнаружен высокий широкий вертикальный зазор (жёлоб между колонками), страница делится на две области по этой координате X.
- Внутри каждой области проецирует на ось Y и делит по горизонтальным жёлобам (межабзацные промежутки, границы разделов).
- Рекурсия продолжается, пока в листовой области не останется заметных жёлобов — так получаются атомарные блоки.
- Блоки сериализуются в порядке сверху вниз, слева направо.
Это совпадает с тем, как читает человек: первая колонка сверху вниз, затем вторая колонка сверху вниз, затем общий «подвал» на всю ширину.
Когда срабатывает XY-cut
XY-cut включается автоматически, как только extract_text видит многоколоночный макет. Он пропускается в следующих случаях:
- Одноколоночные страницы (вертикальный жёлоб не найден, используется стандартная сортировка по строкам)
- Разреженные страницы, где на предполагаемую колонку приходится меньше примерно 10 текстовых span’ов — обычно это титульные листы или страницы с копирайтом, где два пика по X — артефакт, а не настоящие колонки (исправлено в v0.3.34)
Для обычных случаев настройка не нужна. Если нужно явно включить или отключить режим, см. раздел «Отключение» ниже.
Что исправлено в v0.3.34
Перемешанный вывод на многоколоночных PDF без тегов
На многоколоночных PDF без тегов (научных учебниках, справочниках по генетике) extract_text раньше применял XY-cut внутри extract_spans(), а затем пересортировывал результат построчной сортировкой в extract_text_with_options, стирая структуру колонок. В итоге получались обрывки вида accompaally.
Исправление: построчная пересортировка теперь пропускается на страницах, которые действительно являются многоколоночными. Корректный вывод подтверждён на учебниках Hartwell Genetics, Murphy ML и Kandel Neural Science.
Страницы с таблицей внутри текста
Смешанные макеты (таблица внутри основного текста) могли ввести детектор колонок в заблуждение: строки таблицы, растянутые табами, заполняли жёлоб между колонками. Исправления:
- Широкие span’ы (более 55 % ширины региона) исключаются из плотности проекции — строки с табами больше не маскируют жёлоб.
- Односимвольные span’ы (значения ячеек вида
G,T) исключаются из проекции, чтобы не «размазываться» поперёк жёлоба. - Покрытие считается через оценку количества символов, а не по сырой ширине bbox, так что строки с табами больше не выдают себя за плотный основной текст.
Ложные срабатывания на разреженных макетах
Страницы с копирайтом, титульные листы и колофоны могут давать два пика по X при всего 7–10 span’ах на «колонку». Теперь они не считаются многоколоночными — 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# возвращают строки формата (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})");
Каждое слово / строка несёт свой ограничивающий прямоугольник, поэтому их можно сгруппировать по колонкам и переупорядочить самостоятельно, если нужна собственная политика (например, сначала правую колонку — для арабских макетов).
Определение многоколоночных страниц вручную
Если перед извлечением нужно ветвление по признаку многоколоночности:
Python
from pdf_oxide import PdfDocument
doc = PdfDocument("mixed.pdf")
for i in range(doc.page_count()):
words = doc.extract_words(i)
# Эвристика: различимые кластеры 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)")
В продакшн-сценариях предпочтительнее extract_text: пусть решение принимает связка «XY-cut + защита от разреженных макетов».
Отключение или своя сортировка
Если нужны сырые span’ы в порядке позиций (например, для собственного движка вёрстки), используйте extract_chars или extract_words — они возвращают записи с ограничивающими рамками, по которым можно применить свою сортировку:
Python
chars = doc.extract_chars(0)
# Сверху вниз, затем слева направо — колонки игнорируются
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()));
Связанные страницы
- Извлечение текста — полная API извлечения
- Профили извлечения — настройка распознавания пробелов под тип документа
- Извлечение таблиц из PDF — структурированный вывод таблиц
- Changelog — исправления многоколоночной вёрстки и смешанных макетов в v0.3.34