Skip to main content

pdfroute

Decide which PDF pages actually need a vision model — and stop paying for the rest.

Free and open source, MIT licensed. One dependency (PyMuPDF). No API keys, no network calls, no service to run.

Sending every page of a PDF to a vision model is the expensive default. Most pages are ordinary prose that extracts perfectly well for free; a minority — dense tables, charts, slide layouts, scanned inserts — genuinely need the model. pdfroute measures each page and tells you which is which, before you spend anything.

pip install pdfroute
from pdfroute import Router

plan = Router().plan("deck.pdf")

plan.vision_pages          # [4, 5, 7]  → send these to your vision model
plan.text_pages            # [1, 2, 3, 6, 8]  → extract these as text

print(plan.estimate(vision_cost_per_page=0.01))
# 3/8 pages to vision; 0.0300 vs 0.0800 all-vision (62% saved)

Or from the shell:

$ pdfroute deck.pdf --vision-cost 0.01
   1  text    simple-page       sparse page: 0 images, 1 text blocks
   2  text    default           no rule claimed this page
   3  text    default           no rule claimed this page
   4  vision  table-quality     table extracts poorly: irregular columns (consistency 0.38), large table (14 rows x 7 cols)
   5  vision  image-text-ratio  ratio 0.75 (6 images, 8 text blocks)
   6  text    default           no rule claimed this page
   7  vision  column-count      5 columns detected
   8  text    default           no rule claimed this page

8 pages: 3 vision, 5 text
vision pages: 4, 5, 7
cost: 0.0300 routed vs 0.0800 all-vision (62% saved)

Add --json to pipe the plan into whatever runs next.

Why decisions, not guesses

Every page comes back with the rule that claimed it and the evidence behind it, so a routing plan can be reviewed instead of trusted:

for decision in plan:
    print(decision)

# page 1: text (simple-page: sparse page: 0 images, 1 text blocks)
# page 5: vision (image-text-ratio: ratio 0.75 (6 images, 8 text blocks))

When a plan looks wrong, plan.by_rule() shows which rule is over-claiming, and every threshold behind it is a documented constructor argument.

How pages are judged

Rules run in order and the first one to claim a page decides it. The default pipeline:

Order Rule Sends a page to vision when
1 VisualComplexityRule more than 20 vector drawing operations (a chart or diagram), or an image over 1000pt on a page with ≤3 text blocks
2 SimplePageRule claims for text — ≤3 images and ≤5 text blocks, so covers and dividers exit early
3 TableQualityRule a detected table shows more than one quality problem: ragged column counts (consistency <0.7), drifting cell alignment (<0.6), or size beyond 10 rows / 5 columns
4 ImageTextRatioRule images ÷ text blocks ≥ 0.3
5 FragmentationRule ≥10 text blocks averaging <50 characters each, alongside at least one image — the signature of a layout the extractor could not follow
6 ColumnCountRule ≥4 text columns detected
7 ImageCountRule ≥4 separate images
— default nothing claimed it, so plain text extraction

A clean grid is deliberately not routed to vision: text extraction reproduces it fine. Only tables that would arrive mangled are worth paying for.

A note on how tables are found

Table detection reads word positions, not text blocks. This matters more than it sounds: PyMuPDF's block extraction merges an entire table row into a single block, and widening the gap between cells does not split it — a row of cells 200pt apart still comes back as one block. Any grid search over blocks therefore finds nothing on most real tables.

Word coordinates are unaffected by that grouping. pdfroute buckets words into rows by their top edge, then into cells wherever the horizontal gap exceeds cell_gap_points (12pt by default). The same gap rule is what keeps prose out: running text has word gaps of a few points, so a prose line collapses into one cell and is discarded for having nothing to align against.

You can use the detector on its own:

from pdfroute.tables import detect_table, table_quality_issues, words_of

table = detect_table(words_of(page))
if table:
    print(table.rows, table.cols, table.column_consistency)
    print(table_quality_issues(table))

Tuning

Every threshold is a constructor argument. Nothing reads the environment, so the same config always produces the same plan:

from pdfroute import Router, RoutingConfig

router = Router(RoutingConfig(
    image_text_ratio=0.5,   # tolerate more imagery before paying for vision
    max_columns=3,          # but be stricter about column layouts
))

Reorder, drop or add rules to change policy rather than just thresholds:

from pdfroute import Router
from pdfroute.rules import KeywordRule, ForcePages, Route, default_rules

router = Router(rules=[
    ForcePages([1], Route.TEXT),      # the cover is never worth a vision call
    KeywordRule.financial(),          # but any page mentioning a balance sheet is
    *default_rules(),
])

KeywordRule.financial() ships a multilingual set of financial-statement terms (English, German, French, Spanish, Chinese). Pass your own list for any other domain:

KeywordRule(["clinical endpoint", "adverse event"], name="trial-data")

A rule is any callable taking a PageContext and returning a Verdict or None:

from dataclasses import dataclass
from pdfroute.rules import Route, Verdict

@dataclass(frozen=True)
class SkipAppendix:
    name: str = "skip-appendix"

    def __call__(self, ctx):
        if "appendix" in ctx.text():
            return Verdict(Route.TEXT, "appendix pages never need vision")
        return None

Estimating the bill

plan.estimate() prices the plan against sending everything to the model. Costs are per page in whatever unit you pass — dollars, tokens, seconds:

savings = plan.estimate(vision_cost_per_page=0.01, text_cost_per_page=0.0)

savings.vision_pages      # 3
savings.cost_routed       # 0.03
savings.cost_all_vision   # 0.14
savings.saved_share       # 0.7857...

The library ships no pricing table: model prices change, and a stale constant in a dependency is worse than no constant. Look up your provider's current per-image rate and pass it in.

How much you save depends entirely on your documents. A text-heavy report routes almost nothing to vision; a slide deck routes most of it. Run pdfroute yourfile.pdf --vision-cost <rate> on a real sample before assuming a number.

What this is not

  • Not an extractor. It decides where each page should go; you still call PyMuPDF, or your vision model, to get the content. That separation is the point — it drops into whatever pipeline you already have.
  • Not OCR, and not a scanned-document detector. A page of scanned text with no embedded text layer reports zero text blocks and routes to vision through the ratio rule, but detecting why is out of scope.
  • Not a layout parser. Table detection here answers one question — would this survive text extraction — and stops there.

Requirements

Python 3.9+ (developed and tested on 3.11) and PyMuPDF 1.23+. PyMuPDF is AGPL-licensed; the same constraint applies to any project already using it for PDF work.

Where this came from

I built the routing logic for Wakeworth, a valuation platform that ingests pitch decks and financial statements, where running every page through a vision model was the single largest processing cost. This package is that idea rebuilt as a standalone library: the domain-specific parts became configurable rules, and the thresholds became arguments.

Contributing

Issues and pull requests are welcome. I maintain this on a best-effort basis alongside other work, so expect considered replies rather than fast ones. Bug reports that include the PDF (or a page of it) that routed wrong are the most useful thing you can send.

git clone https://github.com/yagebin79386/pdfroute
cd pdfroute
pip install -e ".[dev]"
pytest

License

MIT — see LICENSE.


Last updated: 2026-08-20 · Changelog

Metadata

Release files for pdfroute 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pdfroute 0.1.0
File Size Uploaded
pdfroute-0.1.0.tar.gz 28.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pdfroute 0.1.0
File Interpreter ABI Platform
pdfroute-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 49.7 kB

Release files / pdfroute-0.1.0.tar.gz

Download URL pdfroute-0.1.0.tar.gz
Size 28.0 kB
Tags Source
SHA-256 checksum
How to use checksums
d3455245117f328712c074e8a8cd0fccc7cadf9e9b78b1eabf7747190cbf0e96
BLAKE2b-256 checksum
How to use checksums
48ec3c91a4dc98459ac30143f6a8284da9186d4ab21f1f39b0789c9a766d2d6f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 22, 2026.

Transparency log

Release files / pdfroute-0.1.0-py3-none-any.whl

Download URL pdfroute-0.1.0-py3-none-any.whl
Size 21.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e1da0fe0edd0a2fe1e9a058d7d0fee4f272e9b56fc38b3b19dd5d313cdee387f
BLAKE2b-256 checksum
How to use checksums
2534735c8248731facd31211627e8b297008cd1849731878ff1c43937bdde19e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 22, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page