😊
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処理ライブラリは、以下のような手順でテキストを抽出します:
- コンテンツストリームからテキストと位置情報を取得
- 座標でソート(上から下、左から右)
- ソート結果を出力
この「座標でソート」する処理が、テキスト順序の乱れを引き起こす主な原因です。
具体的な問題例
- 縦書きと横書きの混在:日本語文書でよく見られる
- 複数カラムレイアウト:新聞や雑誌形式
- 図表の挿入:本文の流れを分断する要素
- ヘッダー・フッター:ページをまたぐ要素
解決策:言語別アプローチ
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()
トラブルシューティング
よくある問題と解決策
-
日本語の文字化け
# UTF-8エンコーディングを明示 text = page.get_text("text").encode('utf-8', errors='ignore').decode('utf-8')
-
縦書きテキストの処理
// 書字方向を判定 function getWritingDirection(item) { // transformマトリックスから判定 return Math.abs(item.transform[1]) > 0.5 ? 'vertical' : 'horizontal'; }
-
メモリ不足
# ストリーミング処理 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の透明テキスト抽出において、順序保持は重要な課題です。主なポイントは:
- 問題の理解:座標ベースのソートが順序を乱す主因
- 適切なライブラリ選択:PDF.js(JavaScript)やPyMuPDF(Python)を使用
- 実装方法:コンテンツストリームの順序を維持する
- ハイブリッドアプローチ:順序と座標情報の両方を活用
これらの知識を活用することで、より正確で信頼性の高いPDFテキスト抽出システムを構築できます。
Discussion