Skip to content

Repository files navigation

render-first-ocr

Offline, CPU-only PDF OCR pipeline. Rasterize every page, OCR the pixels, treat embedded PDF text as a hint, never as truth.

No network, no GPU, no API keys, no model downloads at runtime. MIT licensed.

Why render-first

A PDF has two stories: what the pixels show and what the embedded text layer claims. They often disagree, and only one is trustworthy. Selectable text is trivially spoofable: invisible glyphs, off-page runs, glyph maps that render one character and copy another. If your extraction trusts it, anyone who writes your input PDFs controls your output.

This pipeline renders every page to pixels and OCRs the pixels. The text layer is still captured, span by span with positions, but tagged authoritative: False and flagged when it sits outside the visible crop box. Use it to decide where to look. Never let it decide what a document says.

The approach held up under adversarial fire: the same engine scored 137.23/150 with zero catastrophic false approvals on the 8090 MIB Document Challenge, a benchmark built around hostile documents with poisoned text layers. That domain-specific solution (field extraction, adjudication, confidence calibration) lives in bmdhodl/mib-doc-solution; this repository is the general-purpose engine underneath it.

What you get

  • Rendering (DocumentRenderer): pypdfium2 rasterization at a bounded pixel budget, automatic small-angle deskew, per-page PNG bytes, and the non-authoritative text layer with span positions.
  • Two OCR engines (TesseractOcrEngine, RapidOcrEngine): Tesseract as a timeout-bounded subprocess reading word-level TSV; RapidOCR in-process over its wheel-bundled ONNX models. Same token interface, swap freely.
  • Line assembly (group_tokens_into_lines, visual_reading_order): word tokens to lines with length-weighted confidence, then row-clustered visual reading order so a table value never precedes its label.
  • Bounded retries (preprocess): weak pages retry through preprocessing variants (grayscale; the grayscale + 2x + MaxFilter(3) pass that recovers thin-stroke, low-ink scans). A retry is kept only when it strictly improves the read, so preprocessing can never make a page worse.
  • Batch orchestration (BatchRunner): every document processed independently; one corrupt or hostile PDF is one recorded failure, never a lost batch. Deterministic, atomic, sorted JSONL output.

Install

pip install git+https://github.com/bmdhodl/render-first-ocr

Tesseract is a system dependency:

# Debian/Ubuntu
sudo apt-get install tesseract-ocr tesseract-ocr-eng
# macOS
brew install tesseract
# Windows: https://github.com/UB-Mannheim/tesseract/wiki

For the RapidOCR engine (pure Python wheels, no system dependency):

pip install "render-first-ocr[rapidocr] @ git+https://github.com/bmdhodl/render-first-ocr"

Use

Command line, directory in, JSONL out:

render-first-ocr ./pdfs ./out/results.jsonl --engine tesseract --workers 4

Add --txt-dir ./out/txt to also write one plain-text file per document.

Python:

from render_first_ocr import DocumentOcrPipeline, TesseractOcrEngine

pipeline = DocumentOcrPipeline(engine=TesseractOcrEngine())
result = pipeline.process_document("statement.pdf")

print(result.text)                      # full text in visual reading order
for page in result.pages:
    for line in page.lines:
        print(line.text, line.confidence, line.box.to_list())

for span in result.text_layer:          # embedded text: hints only
    if span.off_crop:
        print("hidden text detected:", span.text)

One JSONL row per document:

{"doc_id": "statement", "source": "statement.pdf", "source_sha256": "...",
 "page_count": 2, "text": "...",
 "pages": [{"index": 0, "width_px": 1700, "height_px": 2200, "dpi": 200,
            "mean_confidence": 0.93, "retry_variant": null,
            "lines": [{"text": "ACCOUNT SUMMARY", "confidence": 0.97,
                       "box": [212.0, 148.0, 655.0, 189.0]}]}]}

Docker

Fully offline image with both engines:

docker build -t render-first-ocr .
docker run --rm --network none \
  -v "$(pwd)/pdfs:/input:ro" -v "$(pwd)/out:/output" \
  render-first-ocr /input /output/results.jsonl

Test

pip install -e ".[dev]"
python -m pytest

Unit tests run without any OCR engine installed (engines are faked at the seam). End-to-end tests run automatically when a tesseract binary is on the PATH.

Design rules

  1. Pixels are the record. Embedded text may nominate pages and regions; it never decides content on its own.
  2. Fail closed, fail small. Hard timeouts on OCR subprocesses, a pixel budget on rendering, per-document isolation in the batch runner, atomic output publication.
  3. Retries must strictly improve. A preprocessing retry that recovers less text, or the same text at lower confidence, is discarded.
  4. Determinism. Same input directory, same output bytes: stable discovery order, sorted rows, canonical JSON separators.

Provenance

This package generalizes the document-extraction engine of an MIT-licensed challenge solution that itself builds on other MIT-licensed public entries. ATTRIBUTION.md names every upstream author and pins the exact commits. Third-party license texts are retained under third_party_licenses/.

License

MIT. See LICENSE.

About

Offline, CPU-only PDF OCR pipeline: rasterize pages, OCR the pixels, treat embedded text as a hint, never as truth. Tesseract + RapidOCR, bounded retries, atomic JSONL batch output. MIT.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages