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:
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.
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 pagesOlder 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.
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 resultFor 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):
| Model | Input | Output |
|---|---|---|
gpt-6-sol | 0.13 | 0.63 |
gpt-5.6-terra | 0.13 | 0.63 |
deepseek-v4-pro | 0.088 | 0.25 |
qwen3.8-max | 0.1 | 0.263 |
claude-sonnet-5 | 0.2 | 1.013 |
gpt-5.6-sol | 0.22 | 1.07 |
gpt-6.1-sol | 0.27 | 1.32 |
claude-opus-5-5 | 0.4 | 2 |
gpt-6-astra | 0.62 | 3.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.
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:
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.
| Model | Input | Output | Total |
|---|---|---|---|
gpt-6-sol | 150,000 × 0.13 / 1M = $0.0195 | 70,000 × 0.63 / 1M = $0.0441 | $0.0636 |
claude-sonnet-5 | 150,000 × 0.2 / 1M = $0.03 | 70,000 × 1.013 / 1M = $0.07091 | $0.10091 |
gpt-6.1-sol | 150,000 × 0.27 / 1M = $0.0405 | 70,000 × 1.32 / 1M = $0.0924 | $0.1329 |
claude-opus-5-5 | 150,000 × 0.4 / 1M = $0.06 | 70,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_urlparts 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.
Related articles
- Translating subtitles with a neural network: SRT without broken timecodesThe main risk when translating SRT with a neural network is a broken file structure. A scheme where timecodes never reach the model, and a Python script with validation.
- Editing texts with a neural network: proofreading, style and cuttingDifferent kinds of editing need different prompts. Wording for proofreading, style and cutting, how to get edits with explanations and how not to lose the author's voice.
- A neural network for writing scripts: Bash, PowerShell and PythonA model writes scripts for routine fast, but running them blindly is risky. How to state the task, demand a safe dry-run mode and check the result before launch.
- Dify in Docker: installation, first-run setup and connecting modelsInstall Dify with Docker Compose, create the admin account and connect Claude, GPT and DeepSeek models on one key. Plus what to do when containers will not start.