Skip to content

読み取り順序と XY-cut — 段組 PDF を自然な順序で抽出

段組の PDF — 学術論文、教科書、雑誌記事、政策ブリーフ — は、多くの抽出ツールをつまずかせます。素朴に上から下へ読むと、段 1 から 1 単語、続いて段 2 から 1 単語、また段 1 に戻る…という具合に、accompaally(段 1 の "accompa" と段 2 の "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 領域に分割します。
  2. 各領域内で Y 軸 に射影し、水平方向の溝(段落や節の境界)で分割します。
  3. 葉の領域に強い溝が残らなくなるまで再帰します。これが最小ブロックです。
  4. ブロックを上から下、左から右の順にシリアライズします。

これは人間の読み方と一致します。段 1 を上から下、続いて段 2 を上から下、最後にページ幅のフッターがあればそれを読みます。

XY-cut が作動する条件

extract_text が段組レイアウトを検出したとき、XY-cut は自動で走ります。次のケースでは スキップ されます。

  • 一段組のページ(縦の溝が見つからないため、デフォルトの行単位ソートが使われます)
  • 見かけ上の段あたりテキストスパンが約 10 未満の疎なページ — 扉や奥付で、2 つの X 中心ピークが段ではなくアーティファクトである場合(v0.3.34 で修正)

一般的なケースでは設定は不要です。どちらかのモードを強制したい場合は、下の「オプトアウト」を参照してください。

v0.3.34 で修正された点

非タグ付き PDF の段組出力が交互に混ざる問題

非タグ付きの段組 PDF(学術教科書、遺伝学のリファレンス)では、extract_text はまず extract_spans() 内で XY-cut を適用し、その後 extract_text_with_options 内で行単位ソートで再整列していたため、段構造が崩れていました。結果として accompaally のような破片が生じました。

修正: 真に段組のページでは、行単位の再整列をスキップするようになりました。Hartwell GeneticsMurphy MLKandel Neural Science の教科書でクリーンな出力を確認済みです。

本文中にテーブルがあるページ

本文に表が埋め込まれた混在レイアウトでは、タブ展開されたテーブル行が段間の溝を埋めてしまい、段検出器を欺いていました。修正内容:

  • 領域幅の 55 % を超える幅広スパンは射影密度から除外します — タブ埋めの行が溝を覆い隠さなくなりました。
  • GT のような単一文字スパン(テーブルセル値)は射影から除外し、溝をまたいで散乱しないようにしました。
  • カバレッジは bbox の生の幅ではなく文字数推定を使うため、タブ埋めの行が密な本文として誤認されなくなりました。

疎なレイアウトでの誤検出

奥付、扉、コロフォンのページは、「段」あたり 7〜10 スパン程度で 2 つの X 中心ピークが現れることがあります。これらは段組として扱われなくなり、同一行の両端に分かれた半分同士を XY-cut が切り離してしまう問題を防ぎます。

段ごとの構造化アクセス

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 と疎レイアウトガードの組み合わせに判断を任せるのが推奨です。

オプトアウトやカスタム順序

位置順に並んだ生のスパンが欲しい場合(独自レイアウトエンジン向けなど)は、extract_charsextract_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()));

関連ページ