Skip to content

Recover a garbled scan layer

Goal: rescue scanned PDFs that ship a present but garbled native text layer — the kind a scanner or an old “Paper Capture” pass leaves behind, where a title block reads 0RlGlt IAt lJn tbe @nitp! btutts instead of “In the United States Court of Federal Claims”. An empty-layer check misses these (there is text), so the garbage flows straight into search and extraction.

kaos-pdf carries a cheap, dependency-free legibility signal — an English dictionary hit-rate scored on the worst line of a page — and parse_pdf(path, ocr="auto") uses it to re-OCR a scanned page whose native layer is garbled, not just the empty ones.

Terminal window
uv run examples/recover-garbled-ocr.py
garbled line legibility: 0.10
clean line legibility: 1.00
page worst-line score: 0.10
worst line: '0RlGlt IAt lJn tbe @nitp! btutts ourt of trs lsims'
garbled layer -> needs re-OCR
clean layer -> kept as-is
examples/recover-garbled-ocr.py
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13"
# dependencies = ["kaos-pdf>=0.1.6,<0.2"]
# ///
"""Detect a garbled OCR layer so `ocr="auto"` re-extracts it.
Scanners and old "Paper Capture" passes often leave a PDF with a *present but
garbled* text layer — the page reads ``0RlGlt IAt lJn tbe @nitp! btutts`` where
it should say "In the United States Court of Federal Claims". An empty-layer
check misses this (there IS text), so the garbage flows downstream.
`kaos-pdf` ships a cheap, dependency-free legibility signal (an English
dictionary hit-rate scored on the *worst* line of a page) that flags these
layers. `parse_pdf(..., ocr="auto")` uses exactly this signal to automatically
re-OCR a scanned page whose native text is garbled — not just empty ones.
This example scores a garbled layer against a clean one — fully offline and
deterministic, no OCR engine required.
Run it:
uv run examples/recover-garbled-ocr.py
"""
from __future__ import annotations
from kaos_pdf import assess_text_quality, is_low_quality_layer, line_legibility
# The garbled string is the real native text layer of a Canon-scanned court
# order; the clean string is what the page actually says.
GARBLED = "0RlGlt IAt lJn tbe @nitp! btutts ourt of trs lsims"
CLEAN = "In the United States Court of Federal Claims"
def main() -> tuple[bool, bool]:
print(f"garbled line legibility: {line_legibility(GARBLED):.2f}") # ~0.10
print(f"clean line legibility: {line_legibility(CLEAN):.2f}\n") # 1.00
# A real page has clean body text plus one garbled title line. The page
# score is the *worst* substantial line, so localized garbage still trips it.
page = f"{CLEAN} filed July 17, 2015\n{GARBLED}\nNot for publication"
quality = assess_text_quality(page)
print(f"page worst-line score: {quality.score:.2f}")
print(f"worst line: {quality.worst_line!r}\n")
garbled_low = is_low_quality_layer(GARBLED)
clean_low = is_low_quality_layer(CLEAN)
print(f"garbled layer -> {'needs re-OCR' if garbled_low else 'kept as-is'}")
print(f"clean layer -> {'needs re-OCR' if clean_low else 'kept as-is'}")
print('\nparse_pdf(path, ocr="auto") applies this automatically: it re-OCRs')
print("scanned pages whose native layer is garbled, and leaves the rest alone.")
return garbled_low, clean_low
if __name__ == "__main__":
garbled_low, clean_low = main()
assert garbled_low is True # garbled layer is flagged for re-OCR
assert clean_low is False # clean text is kept as-is

What to notice

  • ocr="auto" now re-OCRs a structurally-scanned page when its native text is empty OR garbled — the old behavior only caught empty layers. Born-digital text pages are never touched. Tune the cut-off with ocr_quality_threshold (default 0.35; set 0.0 to restore the empty-layer-only behavior).
  • The signal is public, so you can measure layer quality yourself: line_legibility(text), assess_text_quality(text) (worst substantial line), and is_low_quality_layer(text). It needs no OCR engine and no network.

Pick a local OCR engine

ocr="auto"/"always" need an OCR engine. Two local options, both Apache-2.0:

  • Tesseract (default) — pip install 'kaos-pdf[ocr]' plus the system tesseract binary. Zero-config and fast.
  • RapidOCR / PP-OCRv5 on ONNX Runtimepip install 'kaos-pdf[onnx]'. Higher accuracy on degraded and multi-column scans, no system binary, no PyTorch and no Hugging Face transformers runtime. Pass it explicitly:
from kaos_pdf import parse_pdf
from kaos_pdf.ocr import RapidOcrEngine
doc = parse_pdf("scan.pdf", ocr="auto", ocr_engine=RapidOcrEngine())

From the CLI, reach OCR with kaos-pdf extract scan.pdf --ocr auto (or --ocr always, --ocr-dpi 400).

Escalate the hardest pages to a vision model

When a deterministic engine still struggles — handwriting, stamps, mixed scripts — a KAOS agent can escalate just those pages to a vision-language model. kaos-agents runs Tesseract first and sends only the low-confidence or garbled pages (the same legibility signal) to kaos_llm_core.vision.ocr_page, bounded by a per-document budget. Turn it on with KAOS_AGENT_OCR_VLM_ESCALATION=1 (off by default — it makes live, billed calls). The vision programs are also MCP tools: kaos-llm-core-vision-ocr, -describe, and -classify.

  • The whole chain produces the same document AST with provenance — page, bounding box, and confidence — whether a page came from the native text layer, Tesseract, RapidOCR, or a vision model. Downstream search and grounded extraction don’t care which path it took.
  • Pair this with page-level routing to OCR only the pages that need it, then re-OCR only the ones whose layer is garbled.