スキャンPDFかどうかを非空白文字数/ページで判定してGeminiに直送する
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
PDF のテキストレイヤが1ページあたり非空白20字未満なら、pdfjs-dist の抽出結果を捨てずに PDF バイトそのものを Gemini へ file パートとして直送し OCR へフォールバックすると、スキャン PDF だけを安全に拾い直せる。
結論
pdfjs-dist でPDFのテキストレイヤを抽出する処理は、スキャン(画像)PDFに対してもエラーにならず、ほぼ空のテキストを返します。 1ページあたりの非空白文字数が閾値(20字)を下回ったときだけ、抽出済みのテキストを捨ててPDFバイトそのものを Gemini へ file パートとして直送し、OCR結果に差し替えるフォールバックを入れて解決しています。
症状
PDFからテキストを抽出する経路を「テキストPDFならローカルでパースする」という前提だけで作ると、テキストレイヤを持たないPDF(スキャンして取り込んだだけの資料)を渡したときに、処理自体は最後まで正常終了するのに中身がほぼ空文字という結果が返ります。例外が飛ばないぶん気づきにくく、下流の処理が「PDFの中身が薄い」のか「読み取り自体に失敗している」のかを区別できません。
原因
pdfjs-dist の getTextContent() はページのテキストレイヤ(PDF内部に埋め込まれた文字情報)を取り出すだけの処理です。スキャンして取り込んだPDFはページ全体が1枚の画像で、テキストレイヤ自体が存在しないか、あってもごくわずかです。テキストレイヤが無いことはPDFの仕様上まったく異常な状態ではないため、パーサ側はエラーにする理由が無く、素直に空に近い文字列を返して正常終了します。
直し方
ページ数と抽出済みテキストの非空白文字数から、1ページあたりの密度を計算します。
const SCANNED_PDF_MAX_NONWS_CHARS_PER_PAGE = 20;
function isLikelyScannedPdf(text: string, pageCount: number): boolean {
if (pageCount <= 0) return false;
const nonWhitespace = text.replace(/\s/g, "").length;
return nonWhitespace < pageCount * SCANNED_PDF_MAX_NONWS_CHARS_PER_PAGE;
}
この判定が真で、かつ OCR クライアントが注入されている場合だけ、抽出済みのテキストではなく OCR の結果を返します。
if (this.ocr && isLikelyScannedPdf(text, pageCount)) {
const ocrResult = await this.ocr.recognize(source.bytes, PDF_MEDIA_TYPE);
return {
text: ocrResult.text,
format: "pdf",
meta: { pageCount, ocrUsed: true, confidence: ocrResult.confidence },
};
}
return { text, format: "pdf", meta: { pageCount } };
OCR側(Gemini)に渡す部分は、画像とPDFでパートの型を分けています。
const sourcePart =
mediaType === "application/pdf"
? ({ type: "file", data: bytes, mediaType: "application/pdf" } as const)
: ({ type: "image", image: bytes, mediaType } as const);
PDFを file パートで渡すと、Gemini 側がPDFをネイティブにラスタライズしてOCRするため、Node側でページを1枚ずつ画像に変換する前処理が不要になります。画像は従来どおり image パートです。
再発防止
閾値は1ページあたり20字と低めに置いてあります。実テキストPDFは1ページ数百字に達する一方でスキャンPDFはほぼ0字なので、その間は大きく空いており、20はその帯の下端寄りにあたります。この帯が広いことが、20を安全に選べている前提です。
OCRはテキストレイヤ抽出の上位互換(全文を読み取る処理なので、テキストPDFに対して実行しても結果は正しいままになる)なので、この設定で実際に起きうる誤判定 ──「もともと文字数の少ないPDFが20字を下回り、余分にOCRへ回る」── は、結果が変わらず外部送信のコストだけが増えるほうに倒れます。避けたいのは逆方向で、**閾値をさらに下げると、本来スキャンPDFであるものが閾値を上回ってOCRされず、希薄なテキストのまま返ります。**この取りこぼしは呼び出し側から見えません。
OCRクライアントが注入されていない場合は、判定自体は行われても実際のフォールバックはできないため、希薄なテキストのままフェイルクローズせずに返します。抽出処理そのものを止めない代わりに、meta.ocrUsed の有無で「OCR経路を通ったかどうか」を呼び出し側が判別できるようにしてあります。
よくある質問
Q1閾値を1ページあたり20字という低い値にしているのはなぜですか?
実テキストPDFは1ページ数百字に達する一方、スキャンPDFはほぼ0字なので、その間は大きく空いています。20はその帯の下端寄りに置いた値で、ここで実際に起きうる誤判定は「もともと文字数の少ないPDFが20字を下回り、余分にOCRへ回る」ほうです。OCRは全文を読み取る処理でテキストレイヤ抽出のsupersetにあたるため、この場合も結果は正しいままで、増えるのは外部送信のコストだけになります。
Q2OCRクライアントを注入しなかった場合はどうなりますか?
例外を投げずに、pdfjs-dist が抽出した希薄なテキストレイヤをそのまま返します。スキャンPDFかどうかの判定自体は行われますが、OCRの実体が無ければフォールバックのしようがないため、フェイルクローズはせずbest-effortで動作を継続します。
Q3画像のOCRとPDFのOCRで、Geminiへの送り方が違うのはなぜですか?
Gemini に渡すパートの型が違うためです。画像は ImagePart(バイト列をそのまま画像として渡す)、PDF は FilePart(mediaType=application/pdf でバイト列を渡す)を使います。FilePart で渡すと Gemini 側がネイティブにラスタライズしてOCRするため、Node側でPDFをページごとの画像に変換する処理が不要になります。
Q4OCRにフォールバックしたことは呼び出し側からどうやって分かりますか?
抽出結果の meta.ocrUsed が true になります。テキストレイヤをそのまま返した場合は ocrUsed が付かないため、この値を見るだけで、その抽出がOCR経路(Gemini への外部送信)を通ったかどうかを呼び出し側で判別できます。
この記事の根拠
- TypeScriptファイル 169〜266行目コミット 1910cd5
- TypeScriptファイル 33〜83行目コミット 1910cd5
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。