Skip to content

Порядок чтения и 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 рекурсивно делит страницу на прямоугольные регионы, чередуя вертикальные и горизонтальные разрезы по жёлобам пустого пространства:

  1. Проецирует все символы на ось X. Если обнаружен высокий широкий вертикальный зазор (жёлоб между колонками), страница делится на две области по этой координате X.
  2. Внутри каждой области проецирует на ось Y и делит по горизонтальным жёлобам (межабзацные промежутки, границы разделов).
  3. Рекурсия продолжается, пока в листовой области не останется заметных жёлобов — так получаются атомарные блоки.
  4. Блоки сериализуются в порядке сверху вниз, слева направо.

Это совпадает с тем, как читает человек: первая колонка сверху вниз, затем вторая колонка сверху вниз, затем общий «подвал» на всю ширину.

Когда срабатывает 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()));

Связанные страницы