Rebounder Tech Blog

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

スキャンPDFかどうかを非空白文字数/ページで判定してGeminiに直送する

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

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

結論

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-distgetTextContent() はページのテキストレイヤ(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 パートです。

再発防止

閾値は高くではなく低く倒してあります。OCRはテキストレイヤ抽出の上位互換(全文を読み取る処理なので、テキストPDFに対して実行しても結果は正しいままになる)にあたるため、閾値を低くして誤判定した場合の実害は「テキストPDFなのに余分にOCRへ回ってコストが増える」だけで済みます。逆に閾値を高くしすぎると「スキャンPDFなのに閾値内におさまってOCRされず、希薄なテキストのまま返る」取りこぼしが起き、こちらは呼び出し側が気づけません。取りこぼす方向の誤判定を避けるために、低めの閾値を選んでいます。

OCRクライアントが注入されていない場合は、判定自体は行われても実際のフォールバックはできないため、希薄なテキストのままフェイルクローズせずに返します。抽出処理そのものを止めない代わりに、meta.ocrUsed の有無で「OCR経路を通ったかどうか」を呼び出し側が判別できるようにしてあります。

よくある質問

Q1閾値を1ページあたり20字という低い値にしているのはなぜですか?

OCR は全文を読み取る処理なのでテキストPDFに対して実行しても結果は正しいままです(OCRがテキストレイヤ抽出のsupersetになる関係)。閾値を高くしすぎるとテキストPDFまで不要にOCRへ回ってコストが増えるだけですが、低くしすぎると本来スキャンPDFなのに閾値を超えてしまいOCRされずに希薄なテキストのまま返る取りこぼしが起きます。取りこぼしのほうが実害が大きいため、低めの閾値で誤判定を避ける設計にしています。

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

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