How to OCR a PDF with an AI model: pages to images, then one API call

Scans without a text layer can't be parsed the usual way. Render pages to images, send them to a model, and work out what a document will cost.

6 min · · development · автоматизация

Signed contracts, supplier invoices, old reports — a PDF without a text layer can't be read by a regular parser. If you need to OCR a PDF with an AI model, the most universal approach is to render each page to an image and send it to a model that accepts images as input. Below: working Python code, how to pick a model, and a cost estimate with a formula.

First, check whether you need vision at all

PDFs come in two kinds: those with a text layer (exported from Word, accounting software, a browser) and scans or photos. In the first case you get the text for free, no model required:

bash
import pymupdf  # pip install pymupdf

with pymupdf.open("invoice.pdf") as doc:
    for page in doc:
        text = page.get_text()
        print(page.number, len(text.strip()))

If get_text() returns meaningful text, send the model text rather than an image: text input is usually cheaper and more predictable. You need an image when there's no text layer, or when layout matters: tables with merged cells, stamps, handwritten notes in the margins.

Step 1. PDF pages to images

Option A: PyMuPDF

PyMuPDF installs with one command and needs no external programs. Page.get_pixmap(dpi=...) renders a page to a raster image, and Pixmap.tobytes("png") returns PNG bytes. Both are documented in the official PyMuPDF docs.

bash
import base64
import pymupdf


def pdf_pages_to_png_b64(path: str, dpi: int = 150) -> list[str]:
    pages = []
    with pymupdf.open(path) as doc:
        for page in doc:
            pix = page.get_pixmap(dpi=dpi)
            png_bytes = pix.tobytes("png")
            pages.append(base64.b64encode(png_bytes).decode("ascii"))
    return pages

Older examples use import fitz — that's the module's former name. The PyMuPDF docs recommend import pymupdf.

Option B: pdf2image

pdf2image is a wrapper around the poppler utilities. Install poppler first: brew install poppler on macOS, the poppler-utils package on Linux. convert_from_path returns a list of PIL images.

bash
import base64
import io

from pdf2image import convert_from_path  # pip install pdf2image


def pdf_pages_to_png_b64(path: str, dpi: int = 150) -> list[str]:
    result = []
    for image in convert_from_path(path, dpi=dpi):
        buf = io.BytesIO()
        image.save(buf, format="PNG")
        result.append(base64.b64encode(buf.getvalue()).decode("ascii"))
    return result

For long files, first_page and last_page let you process the document in chunks instead of holding hundreds of images in memory.

Which resolution to use

150 dpi is a sensible starting point for printed text: an A4 page comes out at roughly 1240×1754 pixels. Small fonts and footnotes may need 200 dpi. The larger the image, the more tokens the model provider will count for it, and vendors usually downscale very large images themselves — so raising dpi indefinitely is pointless. Tune it on 3–5 typical pages. Size and format limits are covered in the article on image upload errors.

Step 2. Which model to choose

Not every model accepts images. These are the models in the Zukko catalog marked "image input: yes" (prices as of October 6, 2026, $ per 1M tokens):

ModelInputOutput
gpt-6-sol0.130.63
gpt-5.6-terra0.130.63
deepseek-v4-pro0.0880.25
qwen3.8-max0.10.263
claude-sonnet-50.21.013
gpt-5.6-sol0.221.07
gpt-6.1-sol0.271.32
claude-opus-5-50.42
gpt-6-astra0.623.05

claude-sonnet-4-6, claude-opus-5 and claude-fable-5-1 accept images too. gemini-3.8-flash, deepseek-v4.1-flash, gpt-6-luna, glm-5.3 and the other models marked "no" don't — they can only take text from the text layer.

Nobody can tell you in advance which model reads your particular documents best. Take 10 pages with known correct text, run them through 2–3 models and compare line by line. An example of sending an image to the cheapest of these models is in the article DeepSeek V4 Pro via API. For the current list and prices, see GET https://api.zukko.pro/v1/models or the Models page in your dashboard.

Step 3. Sending a page to the model

We use the OpenAI-compatible format: the image goes in as a data URL in a message part of type image_url. The gateway converts the request, so the same code works with claude-sonnet-5 and gpt-6.1-sol — only the model name changes. More on the client in the Python docs.

bash
import os

from openai import OpenAI

client = OpenAI(
    base_url="https://api.zukko.pro/v1",
    api_key=os.environ["ZUKKO_API_KEY"],  # a fetch_... key
)

OCR_PROMPT = (
    "This is a page of a scanned document. Transcribe all text verbatim, "
    "keeping paragraph order. Format tables as Markdown. "
    "If a fragment is illegible, write [illegible]. Do not guess."
)

One page, one request. That makes it easy to retry a failed page, keeps you within request size limits, and ties every result to its page number.

Tables and fields as JSON

For invoices you usually want specific fields rather than running text. Ask the model for JSON and validate it in code:

bash
import json

FIELDS_PROMPT = (
    "Extract these invoice fields and return JSON only, no commentary: "
    '{"number": str, "date": "YYYY-MM-DD", "seller_tax_id": str, '
    '"total": number, "items": [{"name": str, "qty": number, "price": number}]}. '
    "If a field is not on the page, use null."
)


FENCE = "`" * 3


def parse_json(text: str) -> dict:

Programmatic checks — totals, date format, tax ID length — catch recognition errors better than asking the same model again.

Step 4. What it costs to OCR a PDF

The formula for one document:

cost = input_tokens × input_price / 1,000,000 + output_tokens × output_price / 1,000,000

An important caveat: how many tokens an image "weighs" is decided by the model provider under its own rules, and the same page yields different counts with different vendors. So don't guess: run 5–10 pages and take prompt_tokens and completion_tokens from usage — the code above sums them up.

An example with illustrative numbers: say your scans average 1,500 input tokens (image plus prompt) and 700 output tokens per page. A 100-page document is then 150,000 input and 70,000 output tokens.

ModelInputOutputTotal
gpt-6-sol150,000 × 0.13 / 1M = $0.019570,000 × 0.63 / 1M = $0.0441$0.0636
claude-sonnet-5150,000 × 0.2 / 1M = $0.0370,000 × 1.013 / 1M = $0.07091$0.10091
gpt-6.1-sol150,000 × 0.27 / 1M = $0.040570,000 × 1.32 / 1M = $0.0924$0.1329
claude-opus-5-5150,000 × 0.4 / 1M = $0.0670,000 × 2 / 1M = $0.14$0.20

For comparison, at the vendor's official price ($2 / $10) the same volume on GPT-6.1 Sol would cost 150,000 × 2 / 1M + 70,000 × 10 / 1M = $0.30 + $0.70 = $1.00. Plug your own averages from usage into the formula to get the real price of your documents.

Practical tips

  • A table spanning two pages. Send both images in one request (two image_url parts in one message) and ask for a single merged table.
  • A dedicated key for the pipeline. In the dashboard you can give a key a daily and monthly dollar limit and a list of allowed models — if a script loops, it won't drain your whole balance. See key limits.
  • Errors. 413 means the request is too large: lower the dpi or send pages one at a time. 429 means the key's daily or monthly limit is used up (key_daily_limit_reached / key_monthly_limit_reached) — raise it or wait for the reset — or a limit on the model provider's side: add a pause between calls. Zukko has no general rate limit of its own.

You can create a key on the API keys page of the dashboard; the balance can be topped up from $1, including via SBP and Russian bank cards.

FAQ

Can I send the whole PDF without converting it?

Some vendors accept PDF files directly in their own APIs, but that path is not described in the Zukko docs. The image route works with any model marked "image input: yes" in the catalog, and the code is the same for Claude and GPT.

What dpi should I use?

Start with 150 dpi for printed text and 200 dpi for small fonts. Test on a few pages: if quality doesn't improve but input tokens go up, go back to the lower value.

Why do Claude and GPT count a different number of tokens for the same page?

Each provider counts image tokens under its own rules. Compare models by the actual cost per page from usage, not by the price per million tokens.

Can the model read handwriting?

That depends on the model and the scan quality. Collect 10–20 samples of your own documents, run them through several models and compare against the correct text — it's the only reliable way to know.

zukko

Pay 25×
less

for Claude Code & ChatGPT

One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.

Get API keyDocumentation

api.zukko.pro · fetch_*

OCR a PDF with an AI model: Python code and cost · Zukko