Beyond text
Scanned PDFs and OCR
pdfplumber reads the text layer of a PDF; it does not recognise text in images. A scanned page has no text layer, so pdfplumber sees an image and zero characters. Add a text layer with OCR first, and pdfplumber can read the result. We did that to a scanned invoice and recovered 38 of its 40 table cells exactly.
Short answer
No, pdfplumber does not do OCR. Check whether a page has a text layer, and if it does not, run OCR before using pdfplumber:
python -m pip install ocrmypdf # also needs the Tesseract program
ocrmypdf --deskew --oversample 300 -l eng scanned.pdf searchable.pdfThen pdfplumber.open("searchable.pdf") works as usual. The rest of this page shows how to detect scanned pages, what changes after OCR, and how to get tables out.
Detect pages that need OCR
A scanned page has no characters and one large image. Two numbers tell you:
import pdfplumber
def page_needs_ocr(page, min_chars=20, min_image_share=0.5):
"""True when a page is (mostly) an image with little or no text layer."""
if len(page.chars) >= min_chars:
return False
area = page.width * page.height
image_share = max(((im["x1"] - im["x0"]) * (im["bottom"] - im["top"]) / area
for im in page.images), default=0)
return image_share >= min_image_share
for name in ["invoice", "scanned-invoice", "form"]:
with pdfplumber.open(f"samples/{name}.pdf") as pdf:
page = pdf.pages[0]
print(f"{name:16} chars={len(page.chars):4} images={len(page.images)} needs OCR: {page_needs_ocr(page)}")invoice chars= 851 images=0 needs OCR: False
scanned-invoice chars= 0 images=1 needs OCR: True
form chars= 159 images=0 needs OCR: FalseRun this before OCR so you only process pages that need it; OCR is slow (seconds per page) and can slightly change pages that already had good text. Mixed documents, such as a typed report with a scanned signature page, are common. The PDF Doctor runs the same check on your own file.
Add a text layer with OCRmyPDF
OCRmyPDF runs Tesseract on every page and writes an invisible text layer on top of the original image, so the PDF looks the same but now contains characters. It needs the Tesseract program installed: apt install tesseract-ocr on Debian/Ubuntu, brew install tesseract on macOS, or the installer from the Tesseract project on Windows. Our sister site tesseractocr.org covers installation and languages in depth.
import time
import ocrmypdf
import pdfplumber
t0 = time.perf_counter()
ocrmypdf.ocr("samples/scanned-invoice.pdf", "invoice-ocr.pdf",
language="eng", deskew=True, oversample=300, progress_bar=False)
print(f"OCR took {time.perf_counter() - t0:.1f} s")
with pdfplumber.open("invoice-ocr.pdf") as pdf:
page = pdf.pages[0]
print(len(page.chars), "chars now, font:", {c["fontname"] for c in page.chars})
print("\n".join(page.extract_text().splitlines()[:7]))OCR took 3.0 s
931 chars now, font: {'MPDFAA+NotoSans'}
Hollow Pine Supply Co. INVOICE
418 Quarry Road, Unit 6
Millbrook, OR 97000 Invoice no. INV-2026-0417
billing@hollowpine.example Issue date 2026-10-03
Due date 2026-11-02
PO number PO-88213
BILL TOThe 931 characters are Tesseract’s guesses, placed where the words were found, in a placeholder font. pdfplumber treats them like any other text: extract_text(), extract_words(), search() and cropping all work.
OCR options change what pdfplumber reads
We OCR’d the same scanned invoice three ways and compared pdfplumber’s extract_text() with the text of the born-digital original:
- no options95 % 2 s
- --deskew94.1 % 2.1 s
- --deskew --oversample 30096.5 % 3.3 s
Source: our measurement, 2026-11-04: tesseract 5.3.4, OCRmyPDF 17.13.0, scanned-invoice.pdf; script lab/bench/ocr_options.py
--deskew alone made things slightly worse on this page: it straightened the slight tilt, but two small grey address lines were then read as one interleaved line. Adding --oversample 300 (upsampling the image before recognition) fixed that and gave the best result. Measure on a few of your own pages; the right options depend on scan quality.
Errors that remain are OCR errors, not pdfplumber errors: here, Feldstraße came out as FeldstraRe without oversampling. For non-English documents, install the language data and pass it with -l (for example -l deu+eng).
Tables from scanned PDFs
After OCR there is still a catch: the table’s ruling lines are pixels in the image, not drawn lines, so the default table finder finds nothing. And Tesseract often reads a vertical rule as a | character. Remove those, then rebuild the columns from the gaps between words, the same recipe used for borderless tables:
import pdfplumber
with pdfplumber.open("invoice-ocr.pdf") as pdf: # made by the previous example
page = pdf.pages[0]
print("default table:", page.extract_table()) # the rules are pixels, not lines
# OCR often reads ruling lines as "|"; drop those characters first
page = page.filter(lambda o: o["object_type"] != "char" or o["text"] not in "|[]")
words = page.extract_words()
top = next(w for w in words if w["text"] == "Item")["top"] - 6
bottom = next(w for w in words if w["text"].startswith("Subtotal"))["top"] - 6
region = page.crop((40, top, 572, bottom))
# Same gutter recipe as for borderless tables
spans = sorted((w["x0"], w["x1"]) for w in region.extract_words())
xs, right = [region.bbox[0]], spans[0][1]
for x0, x1 in spans[1:]:
if x0 > right + 6:
xs.append((right + x0) / 2)
right = max(right, x1)
xs.append(region.bbox[2])
lines = region.extract_text_lines()
ys = [region.bbox[1]] + [(a["bottom"] + b["top"]) / 2 for a, b in zip(lines, lines[1:])] + [region.bbox[3]]
table = region.extract_table({"vertical_strategy": "explicit", "explicit_vertical_lines": xs,
"horizontal_strategy": "explicit", "explicit_horizontal_lines": ys})
for row in [r for r in table if any(c.strip() for c in r)][:5]:
print(row)default table: None
['Item', 'Description', 'Qty', 'Unit price', 'Amount']
['HP-1042', 'Stainless hex bolts M8 x 40 (box of 100)', '12', '18.40', '220.80']
['HP-2210', 'Aluminium extrusion 2020, 1 m', '40', '6.95', '278.00']
['HP-3307', 'Linear bearing LM8UU', '64', '1.85', '118.40']
['HP-4120', 'Timing belt GT2, 6 mm (5 m roll)', '6', '11.20', '67.20']On this invoice, 38 of the 40 cells match the original exactly; the two misses are a dropped space (1.5A for 1.5 A, 1m for 1 m). Every number is right. That is typical: OCR’d tables need a cleaning pass, but geometry-based extraction on top of a good OCR layer works.
OCR only the part you need
For one field on many scanned forms, such as a total or an ID, you do not need to OCR whole pages. Render the page with pdfplumber, cut out the region in pixels and pass it to Tesseract directly:
import pdfplumber
import pytesseract
with pdfplumber.open("samples/scanned-invoice.pdf") as pdf:
page = pdf.pages[0]
dpi = 300
image = page.to_image(resolution=dpi).original # a Pillow image of the page
# The total sits roughly here, in points: (x0, top, x1, bottom)
box = (400, 465, 560, 490)
scale = dpi / 72
crop = image.crop(tuple(round(v * scale) for v in box))
print(repr(pytesseract.image_to_string(crop).strip()))'[Total due (USD) 1,118.23'The box is in pdfplumber’s points; multiply by dpi / 72 to get pixels. The stray [ is the edge of a table rule caught in the crop: tighten the box, or strip punctuation you do not expect. to_image() needs pypdfium2, which pdfplumber installs; pytesseract needs the Tesseract program.
When to use something else
| Situation | Better tool |
|---|---|
| Mostly scanned documents, plain text needed | OCRmyPDF + pdfplumber (this page) |
| Scanned tables with complex structure, merged cells | A layout model such as docling, or a vision LLM (comparison) |
| Handwriting | Handwriting-capable OCR services; Tesseract is weak at it |
| Already OCR’d but the text layer is poor | Re-run OCR with --redo-ocr, or --force-ocr to rasterise and replace it |
Check your file
The PDF Doctor below reports, page by page, whether there is a text layer, whether pages are image-only, and other problems ((cid:x) fonts, duplicated characters). It runs pdfplumber in your browser; your file is not uploaded.
PDF Doctor
Drop a PDF here or choose a sample above.
Text-based PDFs work best. Scanned pages have nothing for pdfplumber to read until OCR adds a text layer.
Text and objects
Run pdfplumber to see extract_text() for this page.
Table Settings Lab
Explicit lines: none
table_settings = # defaults
Run pdfplumber to detect tables.
Crop and coordinates
Drag a rectangle on the page. pdfplumber's coordinates are in points from the top-left corner: (x0, top, x1, bottom).
Drag a box on the page to measure it.
PDF Doctor
Checks why extraction might fail: missing text layer, (cid:x) fonts, duplicated characters, rotation and ruling lines.