やってみた 2026年8月3日

Rust製pdf-inspectorを使い捨てコンテナで動かし、「テキストPDFなのに抽出結果が空」を踏んだ話

XECIN RustPDFテキスト抽出品質保証

PDFからテキストを取り出す処理は、地味なわりに現場でよく事故ります。私が何度か見てきたのは「テキストベースのPDFだから当然抽出できるだろう」と信じて組んだパイプラインが、特定の帳票だけ本文が空っぽになる、というやつです。動いているように見えて、実は取れていない。QAの立場からすると一番いやなパターンなんですよね。

GitHubのトレンドで firecrawl/pdf-inspector を見かけたとき、説明文の「Detects whether a PDF is text-based or scanned」という一文が引っかかりました。OCRにかける前に「そもそもこのPDFはテキストとして抽出できるのか」を先に判定する、という発想です。抽出そのものより、抽出前の見極めを道具にしている点が気になったので、実際に動かしてみました。

試した環境

いつも通り、ホストを汚さないように使い捨てのDockerコンテナの中だけで検証しています。

  • ベースイメージ: rust:1-bookworm(rustc 1.97.1 / Debian 12.15)
  • 対象コミット: a15ec2d(2026-08-01時点のデフォルトブランチ)
  • 依存: PDFパースは lopdf 0.41.0 の単一依存、MLモデルなし

先に一点、動かして気づいた小ネタを。cargo build のログに出てくるクレート版は pdf-inspector v0.1.7 でしたが、Gitのタグは v0.7.0 まで進んでいます。Cargo.tomlversion はまだ 0.1.7 のまま。crates.io/npm/PyPI にバッジは並んでいますが、ソースの内部版とタグ表記がずれているので、「どの版を試したか」を書くときはコミットハッシュで押さえておくのが無難だと考えています。

ビルドは軽く、リリースビルドで2つのCLI(pdf2mddetect-pdf)が 56秒 で通りました。ソースチェックアウトから直接叩けます。

cargo build --release --bin pdf2md --bin detect-pdf
# Finished `release` profile [optimized] target(s) in 56.04s

# 分類だけ(抽出しない)
./target/release/detect-pdf tests/fixtures/firecrawl_docs_tagged.pdf --json
# Markdown 変換
./target/release/pdf2md tests/fixtures/firecrawl_docs_tagged.pdf --raw

リポジトリの tests/fixtures/ に22個のPDFが同梱されていたので、自前でサンプルを作らずこれを検証データに使いました。帳票・論文・財務表・二段組み・CIDフォント・暗号化と、素性の違うものが揃っていて、テストデータとしてよくできています。

まず分類器を全ファイルに当ててみる

detect-pdf --json を22件に回した結果、面白かったのは分類そのものより、付随して返ってくるメタ情報でした。素直な帳票はこう返ります。

{"pdf_type":"text_based","page_count":7,"pages_with_text":7,
 "confidence":1.00,"title":"Firecrawl Documentation - API Reference",
 "ocr_recommended":false,"detection_time_ms":1}

confidence はテキストのあるページの割合で素直に動いていて、8ページ中7ページにしか本文がないPDFは 0.88 に落ちました。全ページに本文があれば 1.00。この「自信度」が二値ではなく連続値で返るのは、後段のしきい値設計に使えて助かります。

速度面では、430ページある大きめのPDFでも検出は 78ミリ秒。中を見ると page_count:430 に対して pages_sampled:8 とあり、全ページを舐めずにサンプリングして早期終了しているのが分かります。大きい入力でも検出コストが跳ねない設計です。

想定外だったのはここ——「テキストPDF」なのに中身が空

一番ハッとしたのが、CIDフォント(Identity-H)を使った1ページのPDF(shinagawa_identity_h.pdf)でした。分類結果はこうです。

{"pdf_type":"text_based","has_text":true,"markdown_length":0,
 "has_encoding_issues":true,"pages_needing_ocr":[1],
 "ocr_reasons_by_page":[{"page":1,"reasons":["suspected_garbled_text"]}],
 "markdown":""}

pdf_typetext_basedconfidence1.00has_texttrue。つまり「テキスト操作子は確かに入っている」。ところが markdown_length0 で、実際 pdf2md に通しても出力は 0バイトでした。テキストはあるのに、フォントのエンコーディング(ToUnicode CMap)が引けなくて、意味のある文字に戻せない。

ここで効いてくるのが has_encoding_issues: truepages_needing_ocr: [1]、そして理由の suspected_garbled_text です。この道具は「テキストPDFだから抽出成功」とは言い切らず、「テキストはあるが化けている疑いがあるのでOCRに回せ」と教えてくれます。冒頭に書いた事故——テキスト判定を鵜呑みにして本文が空になるやつ——を、抽出の前に検出できるわけです。

QAの言い方をすれば、これは「動いた=品質OK ではない」を仕組みで担保している例です。text_based はあくまで入口の判定で、抽出可否は別の指標(has_encoding_issues / pages_needing_ocr)で見る。この分離があるから、パイプラインの受け入れ基準を「テキスト判定が出たか」ではなく「抽出できると判定されたか」で書けます。

表のMarkdown化は速くて構造的、ただし詰めどころもある

pdf2md に財務系の表PDF(forecast_table_chart.pdf)を通すと、スプレッドシート的な表がちゃんとMarkdownの表に復元されました。列A〜E・15行、予測値と信頼区間の上下限まで拾えています。--analyze を付けると is_complex:true / pages_with_tables:[1] / pages_with_columns:[1] とレイアウトの複雑さも返してくれる。

一方で、正直に書いておくと出力にはクセもありました。ヘッダ行の文字が字間の広い箇所で分断され、L ower C onfiden ce Boun d のように単語の途中に空白が入ります。おそらくPDF側のletter-spacingを語間スペースと解釈してしまうケースです。ほかにも、表の先頭に空のヘッダセルが1つ増える、区切り行のダッシュが列数と1つずれる、といった小さな崩れが出ました。

||A|B|C|D|E|
|---|---|---|---|---|---|
|1|time|observed|Forecast(observed)|L ower C onfiden ce Boun d(obser ved)|...

このあたりは「取り込んだ後に自分で正規化する前提で使う」のが現実的だと感じています。逆に、素直なテキストPDF(firecrawl_docs_tagged.pdf)は # 見出し から段落まで綺麗にMarkdown化され、タイトルもメタから拾えていました。入力の素性で結果の質がはっきり変わるので、ここでも「入力を先に分類してから処理を分岐する」という設計思想が活きてきます。

速いのは「検出」であって「全抽出」ではない

READMEには「text-based PDFs locally in under 200ms」とあり、ベンチマークも「200 docs in 0.470s」と速さを推しています。ここは実測すると解像度が上がりました。

先ほどの430ページのPDFを フルでMarkdown抽出 すると、3回とも約 26.1秒(26142 / 26110 / 26205 ms)。同じPDFの 検出だけ なら78ミリ秒、1ページの素直なPDFのフル抽出は3ミリ秒でした。つまり「200ミリ秒」は検出(とサンプリングが効く場面)の話で、全ページの本文を実際に取り出すコストはページ数に比例して積み上がります。

操作対象実測
detect-pdf(検出のみ)430ページ(8ページをサンプリング)78 ms
pdf2md(全抽出)430ページ全文約26.1 s(3回平均)
pdf2md(全抽出)1ページ帳票3 ms

運用に落とすと、この非対称性はむしろ好都合です。まず全件を検出だけで安く仕分けし(テキストか/化けていないか/OCRが要るか)、本当に抽出が必要なものだけフル抽出に回す。大量PDFのバッチで、無駄な全抽出とOCR課金を先に削れる形になります。

暗号化PDF(encrypted-secret123.pdf)の扱いも素直でした。検出は {"error":"PDF is encrypted","page_count_hint":8} を返し、pdf2mdError: PDF is encrypted を出して 終了コード1。パスワード付きを黙って空で通さず、ちゃんと失敗として立ててくれるので、CIの受け入れ判定に組み込みやすいです。

PDF取り込みの受け入れ基準に落とすなら

今回触ってみて、私ならPDF取り込みパイプラインの受け入れ基準をこう書くだろう、というのを具体例で残しておきます。テストは目的ではなく手段なので、道具の出力をそのまま合否条件に翻訳できるかが肝です。

  • detect-pdfpdf_type:text_based かつ has_encoding_issues:false かつ pages_needing_ocr が空 → テキスト抽出パスへ(抽出結果が空でないことも別途アサート)
  • pages_needing_ocr が非空、または pdf_typescanned/image_based → OCRパスへ振り分け(テキスト抽出は成功扱いにしない)
  • error を返した(暗号化・破損など)→ 取り込み失敗として隔離。空文字を「成功・本文なし」と混同しない

「テキストと判定された」ではなく「抽出できると判定された」を境界にする。これができるだけでも、冒頭の事故はだいぶ防げると思います。

試してみての所感

pdf-inspectorは、OCRなし・MLモデルなしで「まずこのPDFはどう扱うべきか」を数十ミリ秒で仕分けてくれる道具として、素直に使い勝手が良かったです。表のMarkdown化の細かい崩れや、全抽出がページ数なりに重い点はあるので、抽出結果を最終成果物としてそのまま信じるより、「分類・ルーティングの一次判定」に軸足を置くと噛み合う印象です。

個人的に一番の収穫は、text_basedhas_encoding_issues / pages_needing_ocr が分かれていたことでした。抽出の前に「抽出できるか」を測れる、というのは、動いたつもりで取れていない事故を仕組みで潰せるということ。私はまず大量PDFの仕分け役として手元のバッチに組み込んで、OCR送りの線引きを詰めていきたいと考えています。