😊

PDFの透明テキスト抽出における順序保持の課題と解決策

に公開

はじめに

PDFファイルから透明テキストレイヤーを抽出する際、「テキストの順序が元のPDFと異なってしまう」という問題に直面しました。本記事では、この問題の原因と、JavaScriptとPythonそれぞれでの解決策について解説します。誤っている点もあるかもしれませんが、参考になりましたら幸いです。

PDFの透明テキストとは

PDFの透明テキストレイヤーは、PDFファイル内に埋め込まれた検索可能なテキスト情報です。OCR処理されたPDFや、デジタル生成されたPDFには、この透明テキストレイヤーが含まれており、以下のような機能を実現しています:

  • テキスト検索
  • コピー&ペースト
  • スクリーンリーダーによる読み上げ
  • 機械翻訳

問題:テキストの順序が乱れる理由

PDFの内部構造

PDFファイルは、テキストを「コンテンツストリーム」という形式で保存しています。このストリームには、テキストとその位置情報が含まれていますが、必ずしも読む順序で格納されているわけではありません。

例:PDFコンテンツストリームの概念図
[位置: x=100, y=200, テキスト="見出し"]
[位置: x=300, y=400, テキスト="脚注"]
[位置: x=100, y=300, テキスト="本文"]

一般的な抽出方法の問題点

多くのPDF処理ライブラリは、以下のような手順でテキストを抽出します:

  1. コンテンツストリームからテキストと位置情報を取得
  2. 座標でソート(上から下、左から右)
  3. ソート結果を出力

この「座標でソート」する処理が、テキスト順序の乱れを引き起こす主な原因です。

具体的な問題例

  • 縦書きと横書きの混在:日本語文書でよく見られる
  • 複数カラムレイアウト:新聞や雑誌形式
  • 図表の挿入:本文の流れを分断する要素
  • ヘッダー・フッター:ページをまたぐ要素

解決策:言語別アプローチ

JavaScript (PDF.js) での解決策

PDF.jsは、Mozillaが開発したJavaScriptベースのPDFレンダリングライブラリです。

順序を保持する実装

// PDF.jsを使用した順序保持テキスト抽出
async function extractTextWithOrder(page) {
  // getTextContent()はコンテンツストリームの順序を維持
  const textContent = await page.getTextContent();
  
  // itemsは元の順序を保持した配列
  const orderedText = textContent.items.map(item => {
    return {
      text: item.str,
      x: item.transform[4],
      y: item.transform[5],
      width: item.width,
      height: item.height
    };
  });
  
  // 配列の順序をそのまま使用(座標ソートしない)
  return orderedText;
}

ポイント

  • getTextContent()メソッドは、PDFの内部構造に忠実な順序でテキストを返す
  • 配列のインデックスが元の順序を表現
  • 座標による再ソートを行わない

Python (PyMuPDF) での解決策

PyMuPDF(fitz)は、MuPDFライブラリのPythonバインディングです。

順序を保持する実装

import fitz  # PyMuPDF

def extract_text_with_order(pdf_path):
    doc = fitz.open(pdf_path)
    
    for page_idx, page in enumerate(doc):
        # 方法1: rawテキスト抽出(コンテンツストリーム順)
        raw_text = page.get_text("text")
        
        # 方法2: 詳細な構造を保持した抽出
        if not raw_text.strip():
            text_dict = page.get_text("dict")
            page_texts = []
            
            # ブロックを元の順序で処理
            for block in text_dict.get("blocks", []):
                if block.get("type") == 0:  # テキストブロック
                    for line in block.get("lines", []):
                        line_text = ""
                        for span in line.get("spans", []):
                            line_text += span.get("text", "")
                        if line_text.strip():
                            page_texts.append(line_text)
            
            text = '\n'.join(page_texts)
        else:
            text = raw_text
        
        yield page_idx, text
    
    doc.close()

ポイント

  • get_text("text")は、PDFのコンテンツストリーム順序を保持
  • get_text("dict")で詳細な構造情報を取得可能
  • 座標ベースのソートを避ける

Python (pdfplumber) の問題点

pdfplumberは人気のあるPythonライブラリですが、デフォルトで座標ベースの処理を行います:

# pdfplumberの例(問題のあるアプローチ)
import pdfplumber

with pdfplumber.open(pdf_path) as pdf:
    for page in pdf.pages:
        # extract_text()は内部で座標ソートを行う
        text = page.extract_text()  # 順序が乱れる可能性

実装比較表

特徴 PDF.js (JavaScript) PyMuPDF (Python) pdfplumber (Python)
コンテンツストリーム順序保持
座標情報の取得
処理速度
メモリ使用量
日本語対応
ブラウザ対応

実践例:ハイブリッドアプローチ

順序保持と座標情報の両方を活用する実装例:

JavaScript実装

class PDFTextExtractor {
  constructor() {
    this.textItems = [];
  }
  
  async extractWithMetadata(page) {
    const textContent = await page.getTextContent();
    
    // 元の順序を保持しつつ、位置情報も記録
    this.textItems = textContent.items.map((item, index) => ({
      originalIndex: index,  // 元の順序
      text: item.str,
      x: item.transform[4],
      y: item.transform[5],
      width: item.width,
      height: item.height
    }));
    
    return this.textItems;
  }
  
  // 必要に応じて座標でソート
  getTextByPosition() {
    return [...this.textItems].sort((a, b) => {
      if (Math.abs(a.y - b.y) < 5) {  // 同じ行とみなす
        return a.x - b.x;  // 左から右
      }
      return b.y - a.y;  // 上から下
    });
  }
  
  // 元の順序で取得
  getTextByOriginalOrder() {
    return this.textItems;  // 既に元の順序
  }
}

Python実装

class PDFTextExtractor:
    def __init__(self):
        self.text_items = []
    
    def extract_with_metadata(self, pdf_path):
        doc = fitz.open(pdf_path)
        
        for page_num, page in enumerate(doc):
            # 辞書形式で詳細情報を取得
            text_dict = page.get_text("dict")
            
            item_index = 0
            for block in text_dict.get("blocks", []):
                if block.get("type") == 0:  # テキストブロック
                    for line in block.get("lines", []):
                        for span in line.get("spans", []):
                            self.text_items.append({
                                'original_index': item_index,
                                'page': page_num,
                                'text': span.get("text", ""),
                                'bbox': span.get("bbox", []),  # [x0, y0, x1, y1]
                                'font': span.get("font", ""),
                                'size': span.get("size", 0)
                            })
                            item_index += 1
        
        doc.close()
        return self.text_items
    
    def get_text_by_position(self):
        # 必要に応じて座標でソート
        return sorted(self.text_items, 
                     key=lambda x: (-x['bbox'][1], x['bbox'][0]))
    
    def get_text_by_original_order(self):
        # 元の順序を維持
        return self.text_items

ベストプラクティス

1. 用途に応じた選択

def choose_extraction_method(use_case):
    if use_case == "full_text_search":
        # 全文検索:元の順序を重視
        return "original_order"
    elif use_case == "layout_analysis":
        # レイアウト解析:座標情報を重視
        return "position_based"
    elif use_case == "content_extraction":
        # コンテンツ抽出:ハイブリッド
        return "hybrid"

2. エラーハンドリング

async function safeExtractText(page) {
  try {
    const textContent = await page.getTextContent();
    
    // 空のテキストをチェック
    if (!textContent.items || textContent.items.length === 0) {
      console.warn('No text found in page');
      return [];
    }
    
    // 文字化けチェック
    const items = textContent.items.filter(item => {
      // CID文字の除去
      return !item.str.includes('(cid:');
    });
    
    return items;
  } catch (error) {
    console.error('Text extraction failed:', error);
    return [];
  }
}

3. パフォーマンス最適化

# 大規模PDFの処理
def extract_large_pdf(pdf_path, batch_size=10):
    doc = fitz.open(pdf_path)
    total_pages = len(doc)
    
    for start_idx in range(0, total_pages, batch_size):
        end_idx = min(start_idx + batch_size, total_pages)
        batch_texts = []
        
        for page_idx in range(start_idx, end_idx):
            page = doc[page_idx]
            # メモリ効率を考慮した処理
            text = page.get_text("text")
            batch_texts.append(text)
            # ページオブジェクトの解放
            page = None
        
        yield batch_texts
    
    doc.close()

トラブルシューティング

よくある問題と解決策

  1. 日本語の文字化け

    # UTF-8エンコーディングを明示
    text = page.get_text("text").encode('utf-8', errors='ignore').decode('utf-8')
    
  2. 縦書きテキストの処理

    // 書字方向を判定
    function getWritingDirection(item) {
      // transformマトリックスから判定
      return Math.abs(item.transform[1]) > 0.5 ? 'vertical' : 'horizontal';
    }
    
  3. メモリ不足

    # ストリーミング処理
    def stream_pdf_text(pdf_path):
        doc = fitz.open(pdf_path)
        for page in doc:
            yield page.get_text("text")
            page = None  # 明示的な解放
        doc.close()
    

まとめ

PDFの透明テキスト抽出において、順序保持は重要な課題です。主なポイントは:

  1. 問題の理解:座標ベースのソートが順序を乱す主因
  2. 適切なライブラリ選択:PDF.js(JavaScript)やPyMuPDF(Python)を使用
  3. 実装方法:コンテンツストリームの順序を維持する
  4. ハイブリッドアプローチ:順序と座標情報の両方を活用

これらの知識を活用することで、より正確で信頼性の高いPDFテキスト抽出システムを構築できます。

参考資料

Discussion