Skip to content

Recipe: OCR a Scanned PDF

Goal

Get the text out of a PDF whose pages are pictures, such as a scan, a fax or a photographed contract: tell that it is scanned, OCR only the pages you need with ocr_pdf, and read a long document in parts.

Prerequisites

  • The docs and ocr extras (or all):

    pip install "doc-processing-std-tools[docs,ocr] @ git+https://github.com/rzoeb/doc-processing-std-tools.git@v0.6.1"
    
  • LLMWHISPERER_API_KEY in the environment of the process that runs the tools. LLMWhisperer bills per page, so ask for the pages you need rather than "all".

Steps

Tool calls are written as Python calls; the model sends the same arguments as JSON.

1. Check whether the PDF has text

get_file_overview is cheap and needs no key. A scanned PDF has an empty text preview on every page and reports images:

get_file_overview(path="scans/contract.pdf")
{
  "type": "pdf",
  "page_count": 3,
  "text_preview_per_page": ["", "", ""],
  "has_tables": false,
  "has_images": true
}

read_pdf on the same file says so too, and names the tool to use:

--- Page 1 ---
(no extractable text on this page)
--- Page 2 ---
(no extractable text on this page)
--- Page 3 ---
(no extractable text on this page)

[No extractable text found — this looks like a scanned/image PDF. Use ocr_pdf for OCR extraction.]

A PDF with a text layer on some pages and scans on others shows text for some pages only. OCR just the empty ones.

2. OCR the pages you need

ocr_pdf(path="scans/contract.pdf", pages="1-2")
--- Page 1 ---
SUPPLY AGREEMENT - PAGE 1

Clause 1.1: The supplier delivers item 100 monthly.
...
--- Page 2 ---
SUPPLY AGREEMENT - PAGE 2
...

pages takes "all", a page ("3"), a range ("1-5"), a list ("1-3,7") or an open-ended range ("21-", page 21 to the end). Pages come back in document order, whatever order you list them in, and the --- Page N --- headers are their page numbers in the document.

Page labels in 0.6.0

In 0.6.0 the labels could be wrong: an open-ended range was numbered from 1 (pages="21-" came back as --- Page 1 --- onwards), and a list out of order ("3,1") was labelled in the order given. The note in step 3 could also name pages you never asked for. This is fixed in 0.6.1 (see the Changelog). On 0.6.0, ask for one closed range at a time, such as "21-40".

3. Read a long document in parts

OCR output longer than DPTTOOLS_OUTPUT_TRUNCATION_LIMIT (50,000 characters by default) is cut on a page boundary, and the result ends with exactly the pages left out, as a pages value:

ocr_pdf(path="scans/contract.pdf", pages="2-")
--- Page 2 ---
SUPPLY AGREEMENT - PAGE 2
...

[Output truncated on a page boundary. Page 3 was not included. Re-call ocr_pdf with pages='3' to read it.]

With several pages left out, the note lists them, for example pages='5-9,12'.

The model follows the note with another ocr_pdf call. A dense 200-page document is better read in ranges of about 20 pages from the start: each call is quicker, and a timeout loses less.

4. Give an agent the tools

Attach only the tools this job needs, and say when to use OCR:

from doc_processing_toolkit import Agent
from doc_processing_std_tools import document_processing_server

agent = Agent(
    model="claude-sonnet-5",
    instructions=(
        "Read PDFs with read_pdf. When it reports no extractable text, use ocr_pdf, "
        "asking for at most 20 pages per call."
    ),
    tools=[{"type": "fastmcp", "server": document_processing_server,
            "allowed_tools": ["get_file_overview", "read_pdf", "ocr_pdf"]}],
)
result = agent.run_sync("What does clause 2.4 of scans/contract.pdf say?")

In-process, the tools read LLMWHISPERER_API_KEY from your process's environment. An OCR call takes seconds to minutes, and with toolkit 0.5.2 or earlier an in-process tool holds up the agent's event loop until it returns. To avoid that, run the tools out of process, as in Using with the Toolkit, and pass LLMWHISPERER_API_KEY in the session's env.

Without an OCR key

read_document_pages_as_images(path="scans/contract.pdf", pages="1-2") returns the pages as images, and a model that reads images can read the text itself. It costs image tokens instead of OCR pages, and it suits a few pages or a page with a layout that matters, such as a form or a table.

What to validate

  • read_pdf on the scan ends with the note naming ocr_pdf.
  • ocr_pdf returns a --- Page N --- block for each requested page, in document order and numbered as in the document, including for an open-ended range such as "2-" and a list out of order such as "3,1".
  • A result that was cut ends with the pages left out, and asking for exactly those returns the rest.
  • Without LLMWHISPERER_API_KEY, ocr_pdf returns an Error: naming the variable and uploads nothing.
  • An empty file, or a file that is not a PDF, is refused before anything is uploaded.

Test sources

  • tests/unit/test_document_processing.py: TestReadPdf::test_scanned_pdf_routes_to_ocr, TestOcrExtraction (page headers, page ranges, cuts on a page boundary), TestOcrLabels, TestPagesSpec, TestOcrInputValidation and TestOcrErrorClassification. The OCR client is mocked.
  • tests/integration/test_ocr_live.py: real LLMWhisperer calls. They are paid, so they are left out of routine runs.