A local, open, routed alternative to hosted Document AI.
Status: early but working. Contract, registry, router, evaluation harness and two OCR engines run end to end on CPU. The VLM tier is not wired yet. Every performance number below was measured on a synthetic development set and is a proof of mechanism, not a claim about your documents. See Honest limitations before trusting anything here.
Most of what Google Document AI sells, open components already do well — and often better, locally, for free. On OmniDocBench v1.6 a 0.9B open parser scores 96.34 while a 235B general VLM scores 89.78. A 7.7M-parameter PP-OCRv6 reads printed text better than pipelines six times its size.
So the gap is not models. It is three specific things:
- No open orchestrator reads handwriting. Docling — MIT, Linux Foundation, the best of them — has no cursive-capable OCR backend as of 2026. The community workaround is to hand pages to a 7B VLM.
- Everyone picks one engine per pipeline, not per page. Engines are not equally good at the same things, and the spread within a document type is larger than the spread between engines overall.
- There is no honest way to choose. Published comparisons routinely give the home team post-processing the competitors did not get, drop hard pages without penalty, and report word-level intervals on page-clustered data.
paperstack is those three things and nothing else. Layout, tables, reading
order, chunking and export are Docling's job and it does them better than a
reimplementation would.
Measured on a 300-page development set, classifier and routing policy fit under 5-fold cross-validation so the router pays for its own mistakes:
| spotH | |
|---|---|
| best single engine (PP-OCRv6) | 0.869 |
| routed | 0.898 |
| oracle: route on true class | 0.900 |
| oracle: best engine per page | 0.938 (ceiling) |
+0.029, paired page-bootstrap 95% CI [+0.016, +0.045].
The classifier is essentially free — routing on its predictions costs 0.002 against routing on ground truth — because the binary handwriting/print gate hits 0.963 trained, 0.917 zero-shot with no training at all. And ~+0.040 remains available to a router that predicts per page rather than per class.
Engines are complementary, which is the whole point:
| cursive-tuned | PP-OCRv6 | docTR | |
|---|---|---|---|
| handwriting (97 pp) | 0.847 | 0.728 | 0.674 |
| printed (203 pp) | 0.814 | 0.922 | 0.886 |
The detector ships, MIT-licensed. It was trained on 100% synthetic pages
rendered from google/fonts (OFL 1.1 / Apache-2.0 / UFL) — no IAM, no IMGUR5K,
no archival or scraped material — so it is redistributable without restriction.
paperstack fetches it on first use and verifies its sha256.
The fastest useful thing here needs no local checkpoint at all:
from paperstack import analyze, engines
rec = analyze("scan.png", ocr=engines.build("hybrid")) # downloads on first callhybrid = our MIT detector + PP-OCRv6's Apache-2.0 recognizer. Every weight is
redistributable, and it is exactly the configuration that produced the strongest
result in this project: +0.241 cursive spotH [+0.133, +0.343] over PP-OCRv6's
own detector, with nothing of ours doing the reading. That claim survives even
though our recognizer, measured honestly, does not beat PP-OCRv6's.
Route to it, don't default to it — on printed prose it is −0.064 against us.
The recognizer is now released too, retrained on permissive data only —
so engines.build("cursive") needs no local checkpoint either:
rec = analyze("scan.png", ocr=engines.build("cursive")) # both halves fetchedThat cost +0.030 CER on held-out handwriting versus a checkpoint trained with IAM/IMGUR5K/archival data (0.061 vs 0.031). The number is published, the restricted checkpoint is not, and the deficit is concentrated entirely in 1–2 character fragments (0.150 vs 0.012). If your material is dense with initials, ticks and single letters, route those crops to a larger model.
Restoring short-fragment coverage synthetically did not work — a second model with generated fragments at 9% of batches scored 0.155 on that band, paired CI [−0.0014, +0.0030]. The constraint is domain, not coverage. Recorded so nobody spends the GPU-hours again.
pip install -e ".[rapidocr,gate]" # printed-text engine + CLIP router
pip install -e ".[all]" # everything, including the heavy tiersThe core depends on numpy, pillow and scipy only. import paperstack works on a
machine with none of the engines installed — you can always inspect the registry
even when you cannot run it.
paperstack engines # what exists, and what runs here
paperstack analyze scans/ --ocr rapidocr --gate clip --out pages.jsonl
paperstack score pages.jsonl truth.jsonl # the acceptance gate
# the flagship: a cheap gate picks the engine per page
paperstack analyze scans/ --ocr router \
--cursive-det det.pt --cursive-rec rec.pt --out pages.jsonlVerified end to end on 20 mixed pages (2,669 words), CPU only:
| pipeline | detH | spotH | mIoU | matched CER |
|---|---|---|---|---|
--ocr rapidocr |
0.820 | 0.755 | 0.847 | 0.039 |
--ocr cursive |
0.981 | 0.757 | 0.829 | 0.060 |
--ocr router |
0.855 | 0.798 | 0.859 | 0.036 |
The gate sent 6 pages to cursive and 14 to rapidocr. Twenty pages is far too
few for the interval to exclude zero — it is a working demonstration of the
mechanism, not a result.
from paperstack import analyze, engines, Policy, Router
from paperstack.gate import ClipGate
router = Router(
policy=Policy(by_class={"handwriting": "cursive", "printed": "rapidocr"},
default="rapidocr",
escalate_to="qianfan", escalate_below=0.70),
engines={"rapidocr": engines.build("rapidocr")},
gate=ClipGate())
rec = analyze("scan.png", ocr=router)
print(rec.doc_class, rec.stages, rec.mean_conf)
print(rec.text)Everything speaks PageRecord. Adding a field is a breaking change; adding an
engine is not. Coordinates are always [x0, y0, x1, y1] in original page
pixels — conversion happens once, at the engine boundary, and never again.
| slot | what it does | built in | candidates |
|---|---|---|---|
preprocess |
polarity, orientation | ✅ | ScanTailor, unpaper |
gate |
document class → routing | ✅ CLIP 0.963 / ink baseline | DINOv2 |
ocr |
words + boxes + confidence | ✅ PP-OCRv6, ✅ cursive | docTR, Qianfan-OCR 4B |
layout |
regions + reading order | — | RT-DocLayout (33M), Docling |
tables |
structure + content | — | TableSeq, DELTA, TableFormer |
kie |
key–value pairs | — | LayTextLLM, Qwen3-VL-4B |
summarize |
page/doc summary | — | Gemma 4 E4B, Qwen3 |
Partly. The honest split:
| claim | evidence | verdict |
|---|---|---|
| cursive detection | detH 0.984 vs docTR 0.732, PP-OCRv6 0.548 | strong, CI excludes 0 |
| our boxes improve another recognizer on cursive | +0.241 [+0.133, +0.343] | strongest result |
| cursive recognition at equal boxes | 0.811 vs 0.746, CI [−0.017, +0.139] | not significant |
The detection rows describe the released MIT weights. The recognition row
was produced by a restricted checkpoint that cannot ship; the permissive
replacement is a different, measurably weaker model. MODEL_CARD.md keeps them
apart.
The detector is the contribution. The recognizer is not distinguishable from PP-OCRv6's once both get the same boxes.
And the baselines were weak: docTR, PP-OCRv6 and Tesseract are all print-oriented, so "beats them at cursive" is a low bar. No comparison has been run against a handwriting-specialised system — TrOCR-handwritten, HTR-ConvText (3.45% CER on IAM), Qwen3-VL, or Nemotron Parse 2.0. Until that exists, treat the detection margin as a promising hypothesis.
Read this part.
- All numbers above come from a synthetic generator, on a development set that shares its fonts, word list, transforms and box conventions with the training data. No font, writer, vocabulary or domain is held out. Treat them as evidence that the mechanism works, not that it will work on your corpus. Re-fit the policy on your own labelled pages.
matched CERexcludes misses and hallucinations. It is conditional on a box matching at IoU ≥ 0.5. It is a recognizer diagnostic and is not an end-to-end error rate.- Handwriting is not one problem. On modern handwriting (IAM), general VLMs now reach ~1.7% CER and beat specialized HTR (~3.45%). The open gap is degraded archival handwriting under scan damage — that is the claim this project makes, and it is narrower than "handwriting".
- Orientation detection here separates 0/180 from 90/270 and nothing more. Upside-down pages need a trained head. Stated rather than hidden.
- The router's escalation threshold is a default, not a tuned value. Tune it
on your own data with
paperstack score.
Code: MIT — chosen to compose with Docling (MIT) and to stay usable by anyone.
Weights are a separate question and are not covered by this licence. Any
cursive checkpoint trained on IAM or IMGUR5K is non-commercial and cannot be
redistributed under MIT. A releasable checkpoint has to be retrained on
permissively-licensed corpora only (CC0 / CC-BY / MIT) — a genuine retrain, not
a filter applied afterwards. Until that exists, no weights ship here, and
.gitignore blocks *.pt / *.onnx / *.gguf so one cannot be added by
accident.
THIRD_PARTY.md— every dependency licence, verified against installed distributions. All permissive, no copyleft.MODEL_CARD.md— what the reference checkpoints were trained on, why they cannot be released, and how to build one that can.
Engines wrapping third-party models carry that model's licence, and
paperstack engines prints it. No weights are downloaded at import.
- Wire the cursive OCR engine + publish a permissively-trained checkpoint.
to_docling()— emit aDoclingDocumentso layout/tables/chunking come free.- VLM tier (Qianfan-OCR 4B, Apache-2.0) behind the cascade.
- Per-page router to claim the remaining +0.040.
- Taxonomized benchmark: modality × degradation × structure × condition, on free public sets (OmniDocBench, PureDocBench, DocLayNet, FUNSD/CORD/SROIE).