pdfmux/blog
pdf-extraction

PDF extraction routing in Python: pick the right parser per page

TL;DRHow to route each PDF page to the best extractor in Python using text density, font embedding, and scan detection — so you stop OCR-ing pages that already have clean text.

Direct answer: Don’t pick one PDF parser for a whole document — pick one per page. A single 80-page report often mixes 60 digital-text pages, 12 scanned pages, and 8 table-heavy pages. PyMuPDF reads the digital pages in 0.01s each but returns blank strings on scans; OCR reads the scans but wastes 1-3s per page on text that was already clean. Routing solves this: classify each page with five cheap heuristics (text density, image coverage, font embedding, encoding sanity, character distribution — each under 1ms), then send it to the cheapest parser that can handle it. In Python the fastest way is pip install pdfmux and pdfmux convert mixed.pdf, which routes automatically and scores 0.905 overall accuracy on a 200-PDF benchmark. This guide shows how to build the router yourself and when to reach for a library.


Why one parser per document is the wrong default

Almost every PDF tutorial gives you the same shape: import one library, loop over pages, extract. That works on a uniform document and breaks on a real one.

Real PDFs are heterogeneous. A signed contract has digital body text and scanned signature pages. A financial filing has narrative text and dense tables. A logistics manifest has typed fields and a photographed stamp. According to Adobe’s 2025 digital document survey, 38% of business PDFs contain at least one scanned page — and in legal and healthcare that figure passes 65%. Pick PyMuPDF for the whole file and you silently drop the scanned pages. Pick OCR for the whole file and you turn a 0.5s job into a 90s job.

The cost is asymmetric. OCR-ing everything is slow but complete. Skipping scanned pages is fast but lossy — and lossy in the worst way, because the gaps are invisible. Your RAG pipeline indexes 73 of 80 pages and reports success. Nobody notices until a user asks about the content on page 74.

Routing is the third option: extract each page with the right tool, and only the right tool.


What “routing” means in practice

A router is a two-stage pipeline. Stage one is a classifier that labels each page. Stage two is a dispatcher that sends each label to a handler.

page → classifier → label ──► handler ──► page result

       digital ──────┼──► PyMuPDF (fast text layer read)
       scanned ──────┼──► OCR engine (RapidOCR / Tesseract)
       table-heavy ──┼──► Docling / Camelot (structure-aware)
       mixed ────────┴──► OCR + layout overlay

The classifier is the whole game. Get it right and you pay OCR cost only on the 5-15% of pages that actually need it. Get it wrong in the lossy direction and you drop content; get it wrong in the wasteful direction and your throughput collapses. This is the same insight behind self-healing extraction — extract cheaply, verify, and only escalate where the cheap path failed.


The routing signals (per page)

You don’t need a machine-learning model to classify pages. Five heuristics, computed from the PDF structure itself, get you most of the way. Each costs well under 1ms because it reads metadata, not pixels.

SignalWhat it measuresScanned-page indicatorCost
Text densityCharacters per rendered area< 50 chars on a full page< 0.1ms
Image coverage% of page area covered by images> 80%< 0.5ms
Font embeddingEmbedded fonts in page resourcesZero embedded fonts< 0.1ms
Encoding sanityValid Unicode in the text layerGarbled / replacement chars< 0.2ms
Character distributionRealistic letter frequenciesRandom-looking sequences< 0.3ms

A page that fails two or more signals is routed to OCR. One failing signal is ambiguous (a cover page is legitimately image-heavy but not scanned), so the two-of-five rule keeps false positives low. We break the scan-detection logic down further in detecting scanned vs digital PDFs in Python.


Building a router in Python

Here is a minimal router built on PyMuPDF (pip install pymupdf, currently v1.24+) for classification and digital extraction, with a Tesseract fallback for scanned pages.

import fitz  # PyMuPDF
import pytesseract
from collections import Counter

def classify_page(page) -> str:
    """Return 'digital', 'scanned', or 'mixed' for a single page."""
    text = page.get_text("text")
    char_count = len(text.strip())

    # Signal 1: text density
    low_text = char_count < 50

    # Signal 2: image coverage
    page_area = abs(page.rect.width * page.rect.height) or 1
    img_area = sum(abs(b[2] - b[0]) * abs(b[3] - b[1])
                   for b in (img[1:5] for img in page.get_image_info(xrefs=True)))
    heavy_images = (img_area / page_area) > 0.80

    # Signal 3: font embedding
    no_fonts = len(page.get_fonts()) == 0

    # Signal 4: encoding sanity (replacement chars signal a broken text layer)
    garbled = text.count("�") > 3

    # Signal 5: character distribution (very low alpha ratio looks decorative-mapped)
    alpha = sum(c.isalpha() for c in text)
    weird_dist = char_count > 20 and (alpha / max(char_count, 1)) < 0.3

    failed = sum([low_text, heavy_images, no_fonts, garbled, weird_dist])
    if failed >= 2:
        return "scanned"
    if char_count > 50 and heavy_images:
        return "mixed"
    return "digital"


def route_and_extract(pdf_path: str) -> list[str]:
    doc = fitz.open(pdf_path)
    out = []
    for page in doc:
        label = classify_page(page)
        if label == "digital":
            out.append(page.get_text("text"))          # ~0.01s
        else:
            pix = page.get_pixmap(dpi=300)              # rasterize
            img_bytes = pix.tobytes("png")
            out.append(pytesseract.image_to_string(img_bytes))  # ~1.8s
    return out


pages = route_and_extract("mixed-report.pdf")
print(f"Extracted {len(pages)} pages, {sum(len(p) for p in pages)} chars")

This is deliberately simple — it does not yet handle tables, reading order, or rotation. But it already beats both naive defaults: it never returns a blank scanned page, and it never OCRs a page that has a clean text layer.


Routing decision table

Once a page is classified, the dispatch rule decides the handler. These are the defaults that hold up across the document types we benchmark:

Page typeHandlerWhyApprox cost
Digital textPyMuPDF text layerNative, exact, no model0.01s
Scanned (clean)RapidOCRCPU-only, 3.0% CER0.9s
Scanned (skewed/low DPI)Deskew + OCR high modeRecovers content that fast OCR drops1.5s
Table-heavyDocling / Camelot overlayCell structure, not flat text0.6s
Multi-columnLayout detection firstFixes reading order0.4s

Multi-column pages are their own trap: OCR and naive text extraction both interleave columns into nonsense unless you detect layout before reading. The fix — clustering text regions on x-coordinates and reading each column top to bottom — is covered in multi-column PDF extraction in Python.


The cost of getting routing wrong

Concrete math on an 80-page document with 68 digital pages and 12 scanned pages:

  • OCR everything: 80 × 1.8s = 144s. Complete, but 122s of it was wasted on digital text.
  • PyMuPDF everything: 80 × 0.01s = 0.8s. Fast, but 12 pages return blank — 15% of the document silently lost.
  • Routed: 68 × 0.01s + 12 × 0.9s = 0.68s + 10.8s ≈ 11.5s. Complete and 12× faster than OCR-everything.

Scale that to a 10,000-PDF ingestion job and the difference between routed and OCR-everything is the difference between a several-hour run and a multi-day run — verified at smaller scale in our real-world benchmark across 1,422 pages of SEC filings and legal opinions.


How pdfmux routes automatically

pdfmux ships the classifier and dispatcher as the default path, so you get routing without writing any of the above:

from pdfmux import process

result = process("mixed-report.pdf", quality="standard")
print(f"Confidence: {result.confidence:.0%}")
print(f"Extractors used per page: {result.extractor_used}")

result.extractor_used returns the routing decision for each page, so the pipeline is auditable rather than a black box. In quality="high" mode, pdfmux runs more than one handler on low-confidence pages and keeps the best output, scored by its confidence model. The default OCR engine is RapidOCR (PaddleOCR models via ONNX Runtime), which needs no GPU — see PDF extraction without a GPU for the CPU-only architecture.

For the broader picture of how the major libraries differ before you commit to one, see pdfmux vs PyMuPDF vs Marker vs Docling.


FAQ

Do I need machine learning to classify pages?

No. The five structural heuristics above (text density, image coverage, font embedding, encoding sanity, character distribution) classify pages with no model and no training data, at under 1ms per page. ML layout models like Surya help with reading order on complex pages, but they are not required for the digital-vs-scanned routing decision.

What happens to a page that has both text and a scanned image?

That’s the “mixed” label — common on letterhead pages with a typed body and a photographed signature. Route it to OCR with a layout overlay so the digital text and the OCR’d region both land in the output. pdfmux handles this automatically; in a hand-rolled router, treat any page with meaningful text and >80% image coverage as mixed rather than purely digital.

Is routing slower than just using one parser?

The classification overhead is negligible — under 1ms per page versus the 10-1800ms the extraction itself takes. Routing is almost always net faster than OCR-everything (you skip OCR on most pages) and only marginally slower than PyMuPDF-everything (the classifier cost), while being far more complete.

How do I route straight to Markdown for RAG?

Run pdfmux convert mixed.pdf (Markdown is the default output) or process("mixed.pdf").markdown. Because pdfmux preserves headings through every handler, the Markdown chunks cleanly on heading boundaries, which is exactly what a RAG pipeline needs. For benchmark numbers comparing routed extraction to single-parser baselines, see benchmarking PDF extractors.

Can I override the router for a specific page?

Yes. With pdfmux you can force a handler or target specific pages: process("doc.pdf", pages=[7, 12]). In a custom router, just bypass classify_page() and call the handler you want. Overrides are useful when you know a document’s structure in advance and want to skip classification entirely.

Which extractors should each page type use?

As a starting point: digital text → PyMuPDF, clean scans → RapidOCR, skewed scans → deskew then OCR, tables → Docling or Camelot, multi-column → layout detection first. The full ranked comparison of every Python option is in the best PDF extraction library for Python in 2026 and the 2026 extractor comparison.