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

DestinationCall
Search index for RAGPOST /v1/embeddings then POST /v1/vector/upsert
Structured extractionPOST /v1/chat/completions with the page text
Full-text searchPOST /v1/logs/ingest or your own store
Human review queuePOST /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.

References

Browse more pdf developer guides