Metadata-Version: 2.5
Name: pdfroute
Version: 0.1.0
Summary: Decide which PDF pages actually need a vision model, and skip paying for the rest.
Project-URL: Homepage, https://github.com/yagebin79386/pdfroute
Project-URL: Source, https://github.com/yagebin79386/pdfroute
Project-URL: Issues, https://github.com/yagebin79386/pdfroute/issues
Project-URL: Changelog, https://github.com/yagebin79386/pdfroute/blob/main/CHANGELOG.md
Author: Ruiqi Tan
License: MIT License
        
        Copyright (c) 2026 Ruiqi Tan
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: cost-optimization,document-ai,extraction,llm,ocr,pdf,pymupdf,vision
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Text Processing :: Markup
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: pymupdf>=1.24.3
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == 'dev'
Description-Content-Type: text/markdown

# 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.

```bash
pip install pdfroute
```

```python
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:

```bash
$ 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:

```python
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:

```python
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:

```python
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:

```python
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:

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

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

```python
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:

```python
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](https://pymupdf.readthedocs.io/) 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](https://wakeworth.app), 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.

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

## License

MIT — see [LICENSE](LICENSE).

---

*Last updated: 2026-08-20 · [Changelog](CHANGELOG.md)*
