Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

pdfjs-distでテキストレイヤが薄いPDFをスキャン判定しGemini OCRへfallbackする

公開 読了時間 約5分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

pdfjs-distで取ったテキストレイヤの非空白文字数がページ数×20字未満なら、実テキストPDFとスキャンPDFの差は大きいため低い閾値でもスキャンPDFと判定でき、PDFバイトをGeminiにfileパートとして直送してOCRへフォールバックできる。

結論

pdfjs-distで取ったテキストレイヤの非空白文字数が「ページ数×20字」未満なら、そのPDFはスキャン(画像)PDFだと判定し、PDFバイトをGeminiへfileパートとして直送してOCRにフォールバックする。 実テキストPDFは1ページ数百字に達する一方、スキャンPDFのテキストレイヤはほぼ0字で、両者の差がきわめて大きいため、低い閾値でも実テキストPDFを誤判定しにくい。OCRは全文を読み取るsuperset(上位互換)の処理なので、誤判定で多少過剰にOCRへ回っても結果は正しく保たれ、コストが増えるだけで済む。逆にスキャンPDFを取りこぼして空テキストをそのまま返すほうが実害が大きいため、判定は安全側(OCRに倒す側)に設計されている。

  • isLikelyScannedPdf(text, pageCount)はtextから空白を除いた文字数がpageCount * 20未満ならtrueを返す
  • OCRクライアント(OcrClient)は任意注入。未注入なら従来どおりテキストレイヤの抽出結果をそのまま返す(fail-closedにしない)
  • PDFはページを画像化せず、PDFバイトそのものをapplication/pdfのfileパートとしてVertex Geminiに渡す
  • OCRを通したことはmeta.ocrUsed = trueで明示し、監査側がGeminiへの外部送信(egress)が発生したかを判別できるようにする

症状

テキスト抽出のパイプラインはpdfjs-distのgetDocument() → 各ページのgetTextContent()で文字列を取り出し、ページ単位で結合して返す構造になっている。この仕組みは、PDFに文字情報(テキストレイヤ)が埋め込まれている前提で動く。

ところが紙の資料をスキャンして作った画像PDFにはテキストレイヤが存在しない。getTextContent()を呼んでもitemsがほぼ空に近い結果しか返らず、ページを結合した最終的なtextは空文字列に近いものになる。この状態のまま下流(構造化・下書き生成)に渡すと、資料の内容が実質ゼロ文字として扱われてしまう。

原因

pdfjs-distのgetTextContent()は、PDF内部に実際に配置された文字オブジェクト(テキストレイヤ)を読むAPIであり、画像として埋め込まれたページの中身をOCRする機能は持たない。スキャンPDFかどうかはファイル形式としては区別されず、実行してみて初めて「テキストレイヤがほぼ空だった」という結果でしか分かる。

この「ほぼ空」をどこで線引きするかが設計上の論点になる。閾値を高く取りすぎると、文字数の少ない実テキストPDF(表紙だけのページなど)を誤ってスキャンPDF側に倒してしまう。低すぎると、一部にノイズ的な文字オブジェクトを持つスキャンPDFを検出できない。実装では「ページ数×20字」という値が採られており、コード中のコメントには、実テキストPDFが1ページ数百字に達するのに対しスキャンPDFはほぼ0字で、差が大きいために低めの閾値でも誤判定を避けられるという判断が明記されている。

直す

非空白文字数を数えるisLikelyScannedPdf(text, pageCount)を用意し、PdfExtractor.extract()の最後でthis.ocr(任意注入のOCRクライアント)が渡されており、かつこの判定がtrueになったときだけOCRフォールバックを実行する。

OCR本体はcreateGeminiOcrClient()が返すOcrClientで、Vertex AI経由でGeminiのマルチモーダル推論を呼ぶ。渡す中身はメディアタイプで分岐しており、PDFの場合はページを画像化せず、PDFバイトをそのまま{ type: "file", data: bytes, mediaType: "application/pdf" }のfileパートとして渡す。ラスタライズ(PDFをページ画像に変換する処理)はGemini側がネイティブに行うため、Node側でPDFのページを画像化するコードを別途持つ必要がない(画像ファイルの場合は{ type: "image", image: bytes, mediaType }のimageパートを使う)。

OCRを実行するモデルにはgemini-2.5-flashが既定として固定されており、リージョンはVertexのasia-northeast1に固定して呼ぶ。コード中のコメントでは、以前はCloud Visionのリージョナルエンドポイントを使う方針(別のADR決定)だったが、そのエンドポイントはデータレジデンシー(データが特定リージョンの外に出ないことの保証)の前提を満たさなかったため、Vertex Geminiへの直送に切り替えてこの決定を上書きしたと説明されている。

OCRに渡すシステム指示(プロンプト)には「資料に無い情報を補完・要約・推測しない」という制約が明記されている。生成モデルをOCRに使う以上、資料に書かれていない内容を補って書いてしまうリスクがあるため、役割を「読み取り=転写」に絞ることでこれを抑える設計になっている。表はセルをタブ区切りで行ごとに書き起こす指示もあり、構造を保ったまま下流に渡すことを意図している。

OCRの実行結果はmeta.ocrUsed = trueというフラグ付きで返る。これにより、監査側は「このPDFは内部のpdfjs-distだけで処理されたのか、Geminiへ外部送信(egress)されたのか」を後から判別できる。Geminiが返すconfidence(信頼度)はCloud Visionと異なり文書単位では提供されないため、その項目は省略されたまま返される。

再発しにくい形

この設計が成立している前提は、OCRが「全文を読み取る上位互換の処理である」という非対称性にある。閾値判定を間違えて実テキストPDFをOCRに回しても結果は変わらず、コストだけが増える。一方、スキャンPDFを取りこぼして空テキストのまま返すと、資料の内容がまるごと失われる。この非対称性があるからこそ、閾値は「厳密な正解」を求めずに安全側(OCRに回す側)へ倒せる。

OCRクライアントが未注入のときに例外を投げず、テキストレイヤの結果をそのまま返す設計(fail-closedにしない)も同じ考え方に基づく。OCRは正規のオプトイン機能として明示的に注入される前提であり、注入されていない呼び出し元まで巻き込んでPDF処理全体を失敗させる必要はない。

またGeminiへPDFバイトを直送する経路は、生のPII(個人情報)を含む資料を外部の生成モデルの文脈に渡すことになるため、meta.ocrUsedと画像ハッシュによる監査、資料アップロード自体の明示的なオプトイン、下流でのPIIマスキングという3段構えのガードが前提になっている。OCR経路を新設するときに監査フラグを一緒に返す設計にしておくことで、「どのリクエストが外部送信を伴ったか」を後から追跡できる状態を保っている。

よくある質問

Q1なぜ20字/ページという低い閾値で判定できるのですか?

実テキストのPDFは1ページあたり数百字に達するのに対し、スキャン(画像)PDFのテキストレイヤはほぼ0字で、両者の差がきわめて大きいためです。差が大きいので低めの閾値を置いても、実テキストPDFを誤ってスキャンPDF側に倒す可能性は低くなります。

Q2閾値判定を誤ってOCRに回しすぎたらどうなりますか?

OCRは画像からもテキストPDFからも全文を読み取れるsuperset(上位互換)の処理なので、実テキストPDFを誤ってOCRへ回しても結果自体は正しいテキストになります。増えるのはGeminiへの送信コストだけです。逆にスキャンPDFを取りこぼして空テキストのまま返すほうが実害が大きいため、判定は過剰にOCRへ倒す側に設計されています。

Q3OCRクライアントを渡さなかった場合はどうなりますか?

例外を投げて失敗させる(fail-closed)のではなく、テキストレイヤが薄いままの結果をそのまま返します(best-effort)。OCRは`OcrClient`として任意で注入する設計で、未注入の呼び出し元は従来どおりpdfjs-distの抽出結果だけを受け取ります。

Q4PDFをGeminiに送るとき、ページを画像化してから送っているのですか?

していません。PDFバイトをそのまま`type: "file", mediaType: "application/pdf"`のfileパートとしてVertex Geminiに直送しています。ラスタライズ(ページを画像に変換する処理)はGemini側がネイティブに行うため、Node側でページ画像化のコードを持つ必要がありません。

Q5OCRで読み取ったテキストはその場でマスキングされますか?

されません。抽出直後のテキストはPII未マスクのまま返り、マスキングは下流(構造化・下書き生成の段階)で行う設計です。OCR経路自体はアップロードの明示同意、`meta.ocrUsed`と画像ハッシュによる監査、下流マスキングの3段構えでガードされています。

確認した環境

  • pdfjs-dist ^6.0.227 / ai ^5.0.52 / @ai-sdk/google-vertex ^3.0.140
  • 2026-06-16、#989 で実装(ADR-038がCloud VisionだったADR-024決定2をsupersede)

この記事の根拠

  • TypeScriptファイル 171〜266行目コミット 1910cd5
  • TypeScriptファイル 1〜83行目コミット 1910cd5

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。