Skip to content
yagebin79386Public

About

Decide which PDF pages actually need a vision model — and stop paying for the rest. Measures each page and routes only the dense tables, charts and scanned inserts to the model. Pure Python, one dependency, MIT.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

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'm Ruiqi Tan; at Silicon Awakening 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

About

Decide which PDF pages actually need a vision model — and stop paying for the rest. Measures each page and routes only the dense tables, charts and scanned inserts to the model. Pure Python, one dependency, MIT.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages