Prerequisites
You need:
- Python 3.8 or newer.
pypdffor digital PDFs;pypdfium2andrequestsfor the OCR route (all pure pip installs, no system packages).- For OCR only: a free API Ninjas API key — issued instantly, no credit card; existing accounts find it on the profile page.
pip install pypdf pdfplumber pypdfium2 requestsStep 1: Identify your PDF type
Every PDF-to-text problem is one of two very different problems, and picking wrong is how you end up staring at empty output:
| PDF type | How to tell | Right tool |
|---|---|---|
| Digital | Text can be selected in a viewer | pypdf / pdftotext — instant, free |
| Scanned | Pages are images; nothing selects | OCR via the Image to Text API |
| Mixed | Some pages select, some don't | Extract first, OCR pages that come back empty |
In code, the test is how much text extraction returns:
from pypdf import PdfReader
reader = PdfReader('document.pdf')
sample = ''.join((page.extract_text() or '') for page in reader.pages[:3])
if len(sample.strip()) < 50:
print('Likely a scanned PDF — go to Step 3 (OCR)')
else:
print('Digital PDF — Step 2 will work')Step 2: Extract the text layer (digital PDFs)
For digital PDFs, pypdf — the maintained successor to PyPDF2 — reads the text layer directly:
from pathlib import Path
from pypdf import PdfReader
def pdf_to_text(path: str) -> str:
reader = PdfReader(path)
pages = [page.extract_text() or '' for page in reader.pages]
return '\n\n'.join(pages)
text = pdf_to_text('document.pdf')
Path('document.txt').write_text(text, encoding='utf-8')
print(f'Extracted {len(text)} characters')The or '' guard matters: extract_text() can return None for unusual pages, and one of those shouldn't crash the join. If the output scrambles multi-column layouts, switch to pdfplumber's layout mode, which spaces text according to its position on the page:
import pdfplumber
with pdfplumber.open('document.pdf') as pdf:
for page in pdf.pages:
print(page.extract_text(layout=True))Step 3: OCR scanned PDFs
A scan has no text layer, so the pipeline becomes render-then-recognize: pypdfium2 (Chrome's PDF engine) renders each page to a 300 DPI image in memory, and the Image to Text API returns the words it finds:
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')
def scanned_pdf_to_text(path: str) -> str:
"""Render each page at 300 DPI and OCR it with the Image to Text API."""
pdf = pdfium.PdfDocument(path)
pages = []
for index in range(len(pdf)):
image = pdf[index].render(scale=300 / 72).to_pil()
buffer = io.BytesIO()
image.save(buffer, format='PNG')
buffer.seek(0)
response = session.post(
'https://api.api-ninjas.com/v1/imagetotext',
files={'image': ('page.png', buffer, 'image/png')},
timeout=30,
)
response.raise_for_status()
words = response.json()
pages.append(' '.join(w['text'] for w in words))
return '\n\n'.join(pages)
print(scanned_pdf_to_text('scanned.pdf'))Each page request comes back as a word list with pixel coordinates:
[
{
"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
}
}
]300 DPI (scale=300/72) is the accuracy sweet spot. For the full scanned-PDF treatment — reading order, rotation, accuracy tuning — see How to OCR a PDF.
Step 4: One-off jobs — the command line
When it's a single digital file and you just want the text, skip Python entirely:
# macOS: brew install poppler | Ubuntu: apt install poppler-utils
pdftotext document.pdf document.txt
# Preserve the original layout
pdftotext -layout document.pdf document.txtWhere to go from here
If the part of the PDF you care about is a table, extract structured rows instead of prose — that's How to Convert PDF to CSV. The download below bundles detection, extraction, and OCR into one script that picks the right path per file.