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
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pdfroute-0.1.0.tar.gz.
File metadata
- Download URL: pdfroute-0.1.0.tar.gz
- Upload date:
- Size: 28.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d3455245117f328712c074e8a8cd0fccc7cadf9e9b78b1eabf7747190cbf0e96
|
|
| MD5 |
a6fa0b8a7981d3921b73afc69c1f4041
|
|
| BLAKE2b-256 |
48ec3c91a4dc98459ac30143f6a8284da9186d4ab21f1f39b0789c9a766d2d6f
|
Provenance
The following attestation bundles were made for pdfroute-0.1.0.tar.gz:
Publisher:
publish.yml on yagebin79386/pdfroute
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pdfroute-0.1.0.tar.gz -
Subject digest:
d3455245117f328712c074e8a8cd0fccc7cadf9e9b78b1eabf7747190cbf0e96 - Sigstore transparency entry: 2568530829
- Sigstore integration time:
-
Permalink:
yagebin79386/pdfroute@fd09787c2ca886b34e0d9c065bc47d5371c7d962 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/yagebin79386
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@fd09787c2ca886b34e0d9c065bc47d5371c7d962 -
Trigger Event:
release
-
Statement type:
File details
Details for the file pdfroute-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pdfroute-0.1.0-py3-none-any.whl
- Upload date:
- Size: 21.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e1da0fe0edd0a2fe1e9a058d7d0fee4f272e9b56fc38b3b19dd5d313cdee387f
|
|
| MD5 |
cc4e0f61e1564d7c5661a0c03e77ef8e
|
|
| BLAKE2b-256 |
2534735c8248731facd31211627e8b297008cd1849731878ff1c43937bdde19e
|
Provenance
The following attestation bundles were made for pdfroute-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on yagebin79386/pdfroute
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pdfroute-0.1.0-py3-none-any.whl -
Subject digest:
e1da0fe0edd0a2fe1e9a058d7d0fee4f272e9b56fc38b3b19dd5d313cdee387f - Sigstore transparency entry: 2568530838
- Sigstore integration time:
-
Permalink:
yagebin79386/pdfroute@fd09787c2ca886b34e0d9c065bc47d5371c7d962 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/yagebin79386
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@fd09787c2ca886b34e0d9c065bc47d5371c7d962 -
Trigger Event:
release
-
Statement type: