Rebounder Tech Blog

Written by the people who actually run these systems in production.

Routing Scanned PDFs to Gemini by Chars per Page

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

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

結論

If a PDF's text layer holds under 20 non-whitespace characters per page, discarding the pdfjs-dist result and sending the PDF bytes to Gemini as a file part picks up scanned PDFs safely.

The short version

Extracting a PDF’s text layer with pdfjs-dist does not error on a scanned (image) PDF; it returns near-empty text. We solved it with a fallback that, only when the non-whitespace character count per page falls below a threshold (20), discards the extracted text and sends the PDF bytes themselves to Gemini as a file part, substituting the OCR result.

What it looks like

Build a PDF text-extraction path on the sole premise “a text PDF is parsed locally”, and handing it a PDF with no text layer (material that was merely scanned in) makes the routine run all the way to a normal finish with near-empty contents. With no exception thrown it is hard to notice, and downstream cannot distinguish “the PDF’s contents are thin” from “the reading itself failed”.

Why

pdfjs-dist’s getTextContent() only pulls out a page’s text layer (the character information embedded inside the PDF). A scanned PDF has each page as a single image, and the text layer either does not exist or is minimal. Having no text layer is not an abnormal state under the PDF spec at all, so the parser has no reason to error and simply returns a near-empty string and finishes normally.

Fixing it

Compute the per-page density from the page count and the extracted text’s non-whitespace character count.

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;
}

Only when this is true and an OCR client is injected does it return the OCR result rather than the extracted text.

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 } };

The part handed to OCR (Gemini) differs in type between images and PDFs.

const sourcePart =
  mediaType === "application/pdf"
    ? ({ type: "file", data: bytes, mediaType: "application/pdf" } as const)
    : ({ type: "image", image: bytes, mediaType } as const);

Passing a PDF as a file part makes Gemini rasterise and OCR it natively, so no Node-side preprocessing converting pages into images one at a time is needed. Images stay on the image part as before.

Preventing a repeat

The threshold is set low, at 20 characters per page. A real text PDF reaches hundreds of characters per page while a scanned one is near zero, so the gap between them is wide, and 20 sits near the bottom of it. That wide gap is the premise on which 20 can safely be chosen.

OCR is a superset of text-layer extraction (it reads the whole document, so running it on a text PDF still gives a correct result), so the misjudgement this setting can actually produce — a genuinely short PDF falling under 20 and going to OCR unnecessarily — tips toward the result being unchanged and only the egress cost rising. What we want to avoid is the other direction: lower the threshold further and a genuinely scanned PDF clears it, is never OCR’d, and comes back thin. That miss is invisible to the caller.

When no OCR client is injected, the decision still runs but no actual fallback is possible, so the thin text is returned rather than failing closed. In exchange for not stopping the extraction itself, the presence of meta.ocrUsed lets the caller tell whether the OCR path was taken.

よくある質問

Q1Why is the threshold as low as 20 characters per page?

A real text PDF reaches hundreds of characters per page while a scanned one is near zero, so the gap is wide and 20 sits near its bottom. The misjudgement it can actually produce is a short PDF falling under 20 and going to OCR needlessly — OCR is a superset, so only the cost rises.

Q2What happens if no OCR client is injected?

It returns the thin text layer pdfjs-dist extracted, without throwing. The scanned-PDF decision still runs, but with no OCR implementation there is nothing to fall back to, so it continues best-effort rather than failing closed.

Q3Why do image OCR and PDF OCR reach Gemini differently?

The part type differs. Images use an ImagePart (bytes passed as an image); PDFs use a FilePart (bytes with mediaType=application/pdf). Passing a FilePart makes Gemini rasterise and OCR natively, so no Node-side conversion of the PDF into per-page images is needed.

Q4How does the caller know OCR was used?

meta.ocrUsed on the extraction result becomes true. When the text layer is returned as-is, ocrUsed is absent, so that value alone tells the caller whether the extraction went through the OCR path (an external send to Gemini).

この記事の根拠

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

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