OCR a scanned PDF and get the text out, page by page
Parse first to find out whether OCR is needed at all, then OCR with a language hint. The confidence field that tells you when not to trust the result.
Half the PDFs people call “scanned” already contain a text layer, and running OCR on those wastes money and produces a worse result than simply reading them. So the right first call on Infrai is POST /v1/pdf/parse, which extracts existing text; only if that comes back empty do you reach for POST /v1/pdf/ocr.
Parse is cheaper, faster and exact. OCR is a guess with a confidence score attached.
Try parse first
curl -sS -X POST "https://api.infrai.cc/v1/pdf/parse" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"pdf": "https://files.example.com/invoices/4821.pdf"}'
{
"ok": true,
"data": {
"pdf_id": "pdf_2fVc8nRqLmT4xBzY",
"text_per_page": ["Invoice INV-4821\nNorthwind Ltd\nDue 2026-10-15", ""],
"outline": [{"title": "Invoice", "page": 1}],
"metadata": {"title": "Invoice INV-4821", "producer": "wkhtmltopdf"},
"language": "en"
}
}
text_per_page is an array, one entry per page, and that shape matters: it lets you tell “this document has no text layer” apart from “page 4 is a scanned insert in an otherwise digital document”. The second case is common in anything assembled from multiple sources, and a whole-document check would miss it.
metadata.producer is a useful hint too. A producer like wkhtmltopdf means the document was generated and almost certainly has text; a scanner model name means it probably doesn’t.
OCR only what needs it
curl -sS -X POST "https://api.infrai.cc/v1/pdf/ocr" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"pdf": "https://files.example.com/scans/contract-2019.pdf",
"lang": "en",
"quality": "balanced"
}'
{
"ok": true,
"data": {
"pdf_id": "pdf_9wQ1zV6pLkS3dHyB",
"text_per_page": ["AGREEMENT\nThis agreement is made on 4 March 2019 between..."],
"confidence_avg": 0.94,
"lang_detected": "en",
"duration_ms": 8421,
"engine": "tesseract"
}
}
Three fields deserve attention. confidence_avg is how much to trust what came back — high nineties is clean, and anything below the mid-eighties means the scan quality is poor enough that a human should look before the text is used for anything consequential. lang_detected lets you catch a wrong lang hint. And quality accepts fast, balanced or quality, trading time for accuracy.
Pass lang when you know it. Detection works, but a hint improves the result on documents with unusual layouts or mixed scripts.
The routine
import os
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
MIN_CHARS_PER_PAGE = 40
LOW_CONFIDENCE = 0.85
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
def parse(pdf: str) -> dict:
resp = SESSION.post(f"{API}/v1/pdf/parse", json={"pdf": pdf}, timeout=120)
body = resp.json()
if not body.get("ok"):
raise RuntimeError(body["error"]["code"])
return body["data"]
def ocr(pdf: str, lang: str = "en", quality: str = "balanced") -> dict:
resp = SESSION.post(f"{API}/v1/pdf/ocr",
json={"pdf": pdf, "lang": lang, "quality": quality}, timeout=600)
body = resp.json()
if not body.get("ok"):
raise RuntimeError(body["error"]["code"])
return body["data"]
def text_of(pdf: str, lang: str = "en") -> dict:
"""Parse first, OCR only if the text layer is thin. On a digital document this
skips the expensive call entirely — which on a mixed corpus is most of them."""
parsed = parse(pdf)
pages = parsed.get("text_per_page") or []
thin = [i for i, page in enumerate(pages) if len((page or "").strip()) < MIN_CHARS_PER_PAGE]
if not pages or len(thin) == len(pages):
result = ocr(pdf, lang=lang)
return {"method": "ocr", "pages": result["text_per_page"],
"confidence": result.get("confidence_avg"),
"needs_review": (result.get("confidence_avg") or 1) < LOW_CONFIDENCE,
"lang": result.get("lang_detected")}
if thin:
# Mixed document: real text on most pages, scanned inserts on a few. OCR
# the whole thing and merge, because per-page OCR is not separable here.
result = ocr(pdf, lang=lang)
merged = [
result["text_per_page"][i] if i in thin and i < len(result["text_per_page"]) else pages[i]
for i in range(len(pages))
]
return {"method": "mixed", "pages": merged,
"confidence": result.get("confidence_avg"),
"needs_review": (result.get("confidence_avg") or 1) < LOW_CONFIDENCE,
"ocr_pages": thin}
return {"method": "parse", "pages": pages, "confidence": None,
"needs_review": False, "lang": parsed.get("language")}
if __name__ == "__main__":
out = text_of("https://files.example.com/scans/contract-2019.pdf")
print({"method": out["method"], "confidence": out["confidence"],
"review": out["needs_review"], "chars": sum(len(p or "") for p in out["pages"])})
The needs_review flag is what turns OCR from a black box into a pipeline you can trust: low-confidence documents go to a human queue instead of silently into your index.
What to do with the text
| Destination | Call |
|---|---|
| Search index for RAG | POST /v1/embeddings then POST /v1/vector/upsert |
| Structured extraction | POST /v1/chat/completions with the page text |
| Full-text search | POST /v1/logs/ingest or your own store |
| Human review queue | POST /v1/queue/publish |
Feeding OCR output straight into a language model for field extraction is the common pattern, and it works better when you pass confidence_avg along with the text — a model told the source is low-confidence hedges appropriately instead of inventing a value for a smudged number.
All of those destinations are on the same key as the OCR, which is the practical argument: a document pipeline is never one call, and here it’s never more than one credential either.
Limitations
text_per_page gives you text, not layout: no bounding boxes, no table structure, no reading order for a multi-column page. If you need to know where on the page a value appeared — for form extraction from unstructured scans, or for redaction by coordinates — that’s a document-AI problem and this isn’t a good fit.
OCR is also whole-document: you can’t OCR page four alone, so a mixed document costs a full OCR pass even when most pages had text. And handwriting is not what this engine is for.
ILovePDF’s extraction suite and dedicated document-AI services go considerably further on layout, tables and invoice-specific field extraction, so if scanned-document understanding is your product rather than an occasional need, buy one of those. Parse and OCR bill per call at rates live in GET /v1/discovery/pdf.ocr (verified 2026-09-21), with parse the cheaper of the two — and platform rates drift downward as vendor contracts improve.