Prerequisites
You need three things:
- Python 3.8 or newer.
pypdfium2(Chrome's PDF engine, pip-installable with no system dependencies) andrequests.- A free API Ninjas API key — issued instantly, no credit card; existing accounts find it on the profile page.
pip install pypdfium2 requestsStep 1: Confirm the PDF actually needs OCR
OCR is the slow, lossy path — only take it when there is no text layer to extract. A practical threshold: fewer than ~50 characters per page means scanned:
from pypdf import PdfReader
reader = PdfReader('document.pdf')
text = ''.join((page.extract_text() or '') for page in reader.pages)
needs_ocr = len(text.strip()) < 50 * len(reader.pages)
print('Needs OCR' if needs_ocr else 'Has a text layer — extract it instead')If a text layer exists, follow How to Convert a PDF to Text instead — extraction is instant and exact.
Step 2: Render pages to images
OCR engines read pixels, so the first job is turning each page into a high-resolution image. PDF points are 1/72 of an inch, so scale=300/72 renders at 300 DPI — the accuracy sweet spot:
import pypdfium2 as pdfium
pdf = pdfium.PdfDocument('scanned.pdf')
print(f'{len(pdf)} pages')
# Render page 1 at 300 DPI (PDF points are 1/72 inch)
image = pdf[0].render(scale=300 / 72).to_pil()
image.save('page1.png')Step 3: Recognize the text
POST each rendered page to /v1/imagetotext as multipart form data. The full pipeline — render, recognize, join, with per-page progress:
import io
import os
import pypdfium2 as pdfium
import requests
session = requests.Session()
session.headers['X-Api-Key'] = os.environ.get('API_NINJAS_KEY', 'YOUR_API_KEY')
OCR_URL = 'https://api.api-ninjas.com/v1/imagetotext'
def ocr_page(image) -> list:
"""OCR one PIL image; returns the API's word list."""
buffer = io.BytesIO()
image.save(buffer, format='PNG')
buffer.seek(0)
response = session.post(
OCR_URL,
files={'image': ('page.png', buffer, 'image/png')},
timeout=30,
)
response.raise_for_status()
return response.json()
def ocr_pdf(path: str) -> str:
pdf = pdfium.PdfDocument(path)
page_texts = []
for index in range(len(pdf)):
image = pdf[index].render(scale=300 / 72).to_pil()
words = ocr_page(image)
page_texts.append(' '.join(w['text'] for w in words))
print(f'Page {index + 1}/{len(pdf)}: {len(words)} words')
return '\n\n'.join(page_texts)
text = ocr_pdf('scanned.pdf')
with open('scanned.txt', 'w', encoding='utf-8') as f:
f.write(text)Each request returns the recognized words with pixel bounding boxes:
[
{
"text": "INVOICE",
"bounding_box": {
"x1": 412,
"y1": 118,
"x2": 604,
"y2": 158
}
},
{
"text": "No.",
"bounding_box": {
"x1": 412,
"y1": 176,
"x2": 452,
"y2": 200
}
},
{
"text": "2041",
"bounding_box": {
"x1": 458,
"y1": 176,
"x2": 512,
"y2": 200
}
}
]Step 4: Rebuild reading order
The bounding boxes are what separate an OCR API from a plain text dump — they let you reconstruct lines, find the value next to a label, or extract tables. Words that share a y position belong to the same line:
def words_to_lines(words: list, tolerance: int = 12) -> list:
"""Group OCR words into reading-order lines by their y position."""
lines = []
for word in sorted(words, key=lambda w: (w['bounding_box']['y1'],
w['bounding_box']['x1'])):
y = word['bounding_box']['y1']
if lines and abs(lines[-1][0] - y) <= tolerance:
lines[-1][1].append(word)
else:
lines.append((y, [word]))
return [
' '.join(w['text'] for w in sorted(ws, key=lambda w: w['bounding_box']['x1']))
for _, ws in lines
]
for line in words_to_lines(ocr_page(image)):
print(line)Step 5: Tune accuracy
| Factor | Recommendation |
|---|---|
| Resolution | 300 DPI (scale=300/72) — the single biggest lever. |
| Format | PNG — lossless, so no JPEG artifacts around character edges. |
| Orientation | Rotate sideways pages upright: image.rotate(90, expand=True). |
| Source quality | Re-scan faint originals — OCR cannot recover what the scanner lost. |
| Borders | Crop dark scanner edges and hole-punch shadows before sending. |
One adjacent goal worth naming: if what you want is the same PDF but searchable — the scan with an invisible text layer on top — that's the open-source ocrmypdf tool in one command. Use the API pipeline when your application needs the text itself:
pip install ocrmypdf
ocrmypdf scanned.pdf searchable.pdfWhere to go from here
Since every page is an independent request, the pipeline parallelizes trivially with a thread pool — no GPUs or native OCR dependencies to manage, and premium plans raise the request limits for production volume. If the scan contains tables, the bounding boxes reconstruct rows and columns — that's How to Convert PDF to CSV. The complete script is below.