PDFライン抽出で失敗しない!レイアウト崩れを防止する5つのコツ

PDFからテキスト・ラインを抽出する時に起きやすい失敗とその防止策

PDFは「文書をそのまま表示したまま配布したい」という目的で作られたフォーマットです。そのため、文書の見た目を正確に再現するのに大きな労力が掛かります。結果として、PDFを機械的に解析しようとすると「改行位置がずれる」「列が入れ替わる」「文字が結合される」など、想定外のレイアウト崩れを招くことが頻繁にあります。特に、テキストを正しく行ごとに分割したいときは、PDFの構造を深く理解して対策を講じる必要があります。

このブログでは、PDFライン抽出で失敗しないための基本的な5つのコツを紹介します。実際の開発で直面しやすいケースと、どのように対処すれば再現性の高い抽出が実現できるかを、具体的な手順やサンプルコードとともに解説します。


1. PDF構造を理解し、適切なライブラリを選択する

1‑1. PDFは「ページレイアウトの記述」である

PDFは、テキストが「位置×座標」によって配置される「ページレイアウトの図形言語」です。単純な文字列ではなく、座標ベースで文字の位置やフォントが定義されます。これにより、同じテキストでも異なる位置に配置されると、全く別の意味合いになる可能性があります。

1‑2. ライブラリの特徴を把握する

ライブラリ 特徴 失敗しやすいケース
pdftotext (Poppler) C++で書かれた高速実装。文字の座標情報を簡潔に返す。 スパイラルレイアウトや複数列。
pdfminer.six Python実装で、レイアウト解析機能あり。 大量PDFのメモリ使用量。
PyMuPDF (fitz) 文字列、画像、図形を同時取得可能。 版権で保護されたPDF(文字が不可読)。
Tika Apache TikaでJava実装。多言語対応。 日本語での正確な改行位置が難しい。

ポイント
失敗率を下げるには、ライブラリ選定を事前に行い、実際に試しに抽出結果を比較する手順を設けること。特に日本語のPDFは「文字コード」や「フォント埋め込み」の問題が多いため、複数ライブラリでテストを行うと安心です。


2. 事前にPDFを「テキストのみ」形式に変換しておく

2‑1. なぜ「変換が必要か」

PDFの内部構造は「ページ=座標」なので、座標が異なる列や図形に囲まれたテキストを単に文字列に変換すると、改行位置が崩れやすくなります。事前に「テキストのみ」に揃えることで、文字位置情報を取り込みやすくなります。

2‑2. pdftotextでの変換例

# 標準出力にテキストを出力
pdftotext -layout input.pdf - | tee clean.txt

# ライブラリで直接読み込む場合
import subprocess, sys

def pdftotext_to_str(pdf_path):
    result = subprocess.run(
        ['pdftotext', '-layout', pdf_path, '-'],
        stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True
    )
    return result.stdout.decode('utf-8')

-layout オプションは「元のレイアウトを保持してテキストを出力」するため、列や改行位置をなるべく保った形で取得できます。

2‑3. 変換後の注意点

  • 改行コードがCRLF/ LF 混在:Python では textwrap.dedent で統一しましょう。
  • フォント埋め込みがないPDF:文字コードが 0xE4 などに置き換わる場合があります。-enc UTF-8 オプションを併用すると解決しやすいです。

3. 改行位置を「座標情報」から推論する

3‑1. 行ごとの座標を計算する

座標解析の手法として、Y座標の近似値を使って行ごとにグルーピングします。Python + PyMuPDF でのサンプルを示します。

import fitz

def extract_lines(pdf_path):
    doc = fitz.open(pdf_path)
    blocks = doc.get_text("dict")["blocks"]
    # 各文字の Y 座標を取得
    lines = {}
    for blk in blocks:
        if blk["type"] != 0:  # テキストブロック以外はスキップ
            continue
        for line in blk["lines"]:
            # 1 行の文字列全体を結合
            text = "".join([span["text"] for span in line["spans"]])
            y0 = line["bbox"][1]  # y 上端
            # Y 座標が近いものを同じ行としてまとめる
            key = round(y0 / 1.5)  # スケール調整
            lines.setdefault(key, []).append(text)
    # 行ごとのテキストを連結
    return ["".join(lines[key]) for key in sorted(lines.keys())]

ポイント
round(y0 / 1.5) の分母は「行間の高さ」を調整する係数です。PDF の DPI が高いほど、行間も細かくなるため、テストで適切な値に調整してください。

3‑2. 列を識別する

列がある場合は、X 座標で分離します。列が横に並ぶ場合は x0 (左上角 X 座標)が異なる値となります。

def extract_columns(lines, bin_threshold=200):
    # 各行の X 座標を取得
    cols = {}
    for line in lines:
        # 1 文字ずつのベアリング情報を取得
        doc = fitz.open()
        page = doc.load_page(0)
        words = page.get_text("words")
        # 文字ごとの X0 を取得
        xs = [w[0] for w in words if w[4] >= line[1]]  # y の範囲で文字を抽出
        key = round(min(xs) / bin_threshold)  # 列番号
        cols.setdefault(key, []).append(line)
    # 列ごとに連結
    return ["".join(cols[key]) for key in sorted(cols.keys())]

注意
列数を自動判定する際は bin_threshold をドキュメントのレイアウトに合わせて調整してください。広い列間隔を持つレポートでは、大きめの値に設定すると列崩れが減少します。


4. エンジニアリングで「文字列重複」を排除する

4‑1. 文字が統合されるケース

特に日本語 PDF では 合字(全角文字の組み合わせ)や、フォントのサブセット化により文字コードが合併されることがあります。これらは単に文字列を連結すると 文字化け または 重複文字列 を生成します。

4‑2. 文字の重複を検知し修正

以下に簡易的な重複検知ロジックを示します。

def deduplicate_text(text):
    result = []
    for i, char in enumerate(text):
        # 文字が前文字と同一で、スペースが無い場合はスキップ
        if i > 0 and char == text[i-1] and char not in " \t":
            continue
        result.append(char)
    return "".join(result)
  • 重複文字列を発見:文字が連続し、前文字と同じでスペースが無いと判定。
  • スキップ対象:多重に同じ文字が連結されるケースはほとんど重複のみで意味を持たないため除去。

4‑3. フォントに依存しない文字列処理

pdfminer.sixPyMuPDF は、文字列に元のフォント情報を保持します。これを利用して「文字コードの正規化」を行うと重複や混乱を減らせます。

def normalize_from_pdfminer(text, font_name):
    # フォントごとにマッピングを作る(例)
    if font_name.startswith("MSYELG"):
        # 例として、全角スペースを半角スペースに置換
        text = text.replace(" ", " ")
    return text

5. 抽出後に「レイアウト検証」を行う

抽出が終わったら、**「レイアウト崩れが起きていないか」**を確認する仕組みを組み込みましょう。単に文字列が得られただけでは、行や列の位置情報が失われているかどうかは判定できません。

5‑1. 画像との比較で自動チェック

  1. まず、PDF を ページごとに PNG に変換します。
  2. 抽出したテキストを 文字列ボックス に描画し、元の PNG と重ねます。
from PIL import Image, ImageDraw, ImageFont

def compare_layout(pdf_path, extracted_text):
    # PDF を PNG に変換 (例: pdf2image)
    # 省略...
    # 文字列を描画(Pillow)
    img = Image.new('RGB', (1024, 1440), color='white')
    draw = ImageDraw.Draw(img)
    font = ImageFont.truetype("NotoSansJP-Regular.otf", 24)
    y = 20
    for line in extracted_text:
        draw.text((20, y), line, fill="black", font=font)
        y += 30
    img.show()

ポイント
画像差分検出ツール(diffimg など)で「文字の位置ずれ」や「欠落している文字列」を自動検知できます。テストケースを作る際に必ずこの比較を入れてください。

5‑2. スキーマ化された「行情報」保存

行ごとの 座標とテキスト を JSON 等に保存しておくと、あとから確認や再処理が容易です。

{
  "page": 1,
  "lines": [
    {"y": 123.4, "text": "本日の売上報告"},
    {"y": 156.8, "text": "売上高:¥1,234,567"},
    {"y": 189.9, "text": "対象期間:2025年1月1日〜2025年1月31日"}
  ]
}
  • Y 座標 に基づいて行を並び替えることで、抜けたり重複した行の検出が容易に。

まとめ:5つのコツの要点

# コツ 実装時のポイント
1 PDF構造を理解し、適切なライブラリを選択 文字列のみ抽出 vs. 位置情報付与
2 事前に「テキストのみ」形式に変換 pdftotext -layout を活用
3 改行位置と列を座標情報から推論 Y座標のクラスタリング、X座標で列分割
4 文字列重複や合字を排除 重複検出ロジック、フォント別正規化
5 抽出後にレイアウト検証 画像差分、座標付きJSON保存

もし、**「PDFからテキストを正しく行ごとに取得したい」**という課題に直面しているなら、今回紹介した5つのコツを実装に取り入れてみてください。順序立てて検証を行うことで、失敗率を大幅に低減し、見た目を崩すことなく抽出が可能となります。

次回予告
今回のポイントをさらに踏まえる形で、Python + PyMuPDF を使った完全自動ライン抽出ワークフローを実装していきます。興味がある方はぜひお楽しみに!

コメント