Turn scanned, image-only PDFs into clean, born-digital PDFs — real embedded text you can select, search and copy, with the full-page scan image thrown away and only true figures re-embedded.
This is the aggressive "reconstruct from scratch" path: the output contains no page photo, just positioned text + cropped illustrations. Files come out small and editable. The trade-off is honesty about OCR — recognition mistakes appear as real text, and layout is approximated rather than pixel-perfect.
- Extract the embedded full-page scan at native resolution.
- Clean — deskew, whiten the grey paper background, denoise/despeckle,
sharpen text edges (
pdfclean/clean.py). - OCR with layout — Tesseract gives every word a bounding box + confidence
(
pdfclean/ocr.py). - Detect figures — ink the OCR didn't claim as text becomes a figure
candidate; slivers and speckle are filtered out. A text block that a figure
cuts through is split into y-bands so the reflowed text wraps around the
illustration instead of running over it (
pdfclean/figures.py). - Reconstruct — a brand-new page. Each OCR block (a column of text) is
re-flowed as real, wrapped, justified paragraphs in that block's rectangle.
The font is grown to the largest size that fills the block (Times is
narrower than the scan's face, so without this the column would be left
half-empty), and line spacing is taken from the scan — so text size, density
and the heading/body hierarchy track the original. Heavier-stroked blocks are
rendered bold (
pdfclean/style.py). Figures are denoised (non-local-means + white-point) and re-embedded as small JPEGs (pdfclean/reconstruct.py).
On the bundled sample (assets/…silent-language….pdf): 10 pages,
2.1 MB image-only scan → 0.31 MB fully selectable PDF, ~6,900 words,
mean OCR confidence 95.
The big picture: read a page of pixels, understand it, and rebuild it as real text. The optional hosted vision model only makes the "understand" step more accurate — the rest is unchanged.
flowchart LR
IN["Scanned PDF<br/>(image-only pages,<br/>no real text)"] --> READ["Clean & understand<br/>each page<br/>(image cleanup + OCR)"]
READ --> BUILD["Rebuild the page<br/>with real positioned text<br/>+ cropped figures"]
BUILD --> OUT["Born-digital PDF<br/>(selectable · searchable<br/>· tiny · editable)"]
BOOST["Optional hosted vision model<br/>--engine vision:<br/>fixes OCR errors + italics"] -. enhances .-> READ
Every step, with the module/function that does it and the data it passes along. The only fork is the OCR engine; everything after it is identical.
flowchart TD
A["Scanned PDF"] --> CLI["cli.main<br/>load .env.local · gather PDFs · batch loop"]
CLI --> PP["pipeline.process_pdf — per page:"]
PP --> B["_page_to_bgr<br/>extract embedded full-page scan"]
B --> C["clean.clean_image<br/>deskew · whiten bg · denoise → CleanedPage"]
C --> D["ocr.run_ocr<br/>Tesseract → OCRResult tree"]
D --> DH["ocr.dehyphenate<br/>rejoin line-break hyphens (inte- grated → integrated)"]
DH --> F["figures.detect_figures<br/>ink OCR didn't claim → figure boxes"]
F --> SP["figures.split_blocks_around_figures<br/>y-band slicing so text wraps, not overlaps"]
SP --> BO["style.mark_bold_blocks<br/>stroke-width per block"]
BO --> ENG{"--engine ?"}
ENG -->|"vision"| VIS["vision.refine_blocks<br/>upload page + blocks to Mistral / OpenRouter<br/>→ block.override_text + italic"]
ENG -->|"tesseract (default)"| KEEP["keep Tesseract text"]
VIS --> RC
KEEP --> RC["reconstruct.add_page — per block:"]
RC --> EF["embed denoised figure crops<br/>clean_figure_crop → JPEG"]
EF --> NP["_normalize_punct<br/>smart quotes / dashes → ASCII"]
NP --> FF["_fill_fontsize<br/>binary-search largest font that _fits the rect"]
FF --> IT["page.insert_textbox<br/>real Times / Bold / Italic, justified"]
IT --> SAVE["out.save · garbage-collect + deflate"]
SAVE --> Z["Born-digital PDF"]
D -. "builds" .-> DM["Data model<br/>OCRResult → Block → Paragraph → Line → Word<br/>Block.bold / italic / override_text"]
DM -. "read by" .-> RC
| module | responsibility |
|---|---|
pdfclean/cli.py |
argument parsing, .env.local loading, batch loop over files |
pdfclean/pipeline.py |
per-document orchestration: pulls each page's scan, runs the steps above, assembles the output PDF |
pdfclean/clean.py |
image cleanup — deskew, background whitening, denoise; also cleans figure crops |
pdfclean/ocr.py |
runs Tesseract, builds the Block → Paragraph → Line → Word tree with bounding boxes |
pdfclean/figures.py |
detects illustration regions and splits text blocks that a figure cuts through |
pdfclean/style.py |
per-block bold detection from stroke thickness |
pdfclean/vision.py |
optional hosted-model text correction + italic detection (--engine vision) |
pdfclean/reconstruct.py |
builds the new page: reflows each block's text to fill its rectangle, embeds figures |
OCR produces a tree that every later step reads from. A page is a list of blocks (≈ a column or a heading); each block holds paragraphs → lines → words, and every word carries its pixel bounding box and confidence:
OCRResult
└── blocks: list[Block] # one per column / heading, in reading order
├── bold / italic / override_text # set by style.py / vision.py
└── paragraphs: list[Paragraph]
└── lines: list[Line]
└── words: list[Word] # text + (x, y, w, h) + confidence
reconstruct.py walks the blocks, takes each block's rectangle (the bounding box
of its words), and re-flows the block's text into it at the largest font that
fits — so the geometry comes from the scan but the glyphs are real, embedded
font text.
conda env create -f environment.yml
conda activate pdf-ocr# batch a folder (recurses, mirrors structure into output/)
python -m pdfclean input/ -o output/
# a single file
python -m pdfclean scan.pdf -o output/ # -> output/scan.clean.pdf| flag | default | meaning |
|---|---|---|
-o, --output |
(required) | output folder |
-l, --lang |
eng |
Tesseract language(s), e.g. eng+fra |
--min-conf |
40 |
drop OCR words below this confidence |
--psm |
3 |
Tesseract page-segmentation mode |
--no-deskew |
off | skip skew correction |
--no-figures |
off | text only, don't re-embed figures |
--overwrite |
off | overwrite existing outputs |
--max-pages |
0 (all) |
only process the first N pages (cheap vision test) |
--engine |
tesseract |
tesseract (local) or vision (hosted, see below) |
--provider |
mistral |
vision provider: mistral or openrouter |
--model |
provider default | override the vision model |
--api-key |
env var | API key (else from MISTRAL_API_KEY / OPENROUTER_API_KEY) |
Tesseract is local and free but makes mistakes (a script drop-cap The → junk,
an italic in → mm). --engine vision keeps the Tesseract layout (block
rectangles, font size, line pitch) but sends each page image to a hosted
multimodal model to correct the text and detect italics. No self-hosting.
Default provider is Mistral (free tier):
# 1. get a free key at https://console.mistral.ai -> API Keys
export MISTRAL_API_KEY=sk-...
# ...or drop it in a gitignored .env.local (KEY=VALUE), auto-loaded by the CLI:
# echo 'MISTRAL_API_KEY=sk-...' > .env.local
# 2. test on one page first (one API call), then run the whole thing
python -m pdfclean assets/ -o output/ --engine vision --max-pages 1
python -m pdfclean assets/ -o output/ --engine visionSmart quotes / dashes the model returns are folded to ASCII so they render in the
base-14 font (no ? boxes, files stay tiny).
Or use OpenRouter's free vision models:
export OPENROUTER_API_KEY=sk-or-...
python -m pdfclean assets/ -o output/ --engine vision --provider openrouter
# pick a specific free model (the default may get rate-limited):
python -m pdfclean assets/ -o output/ --engine vision --provider openrouter \
--model nvidia/nemotron-nano-12b-v2-vl:freeOpenRouter's free vision tier works but is slow (~30–60 s/page) and heavily
rate-limited (shared free pools return 429 / 504). The tool retries transient
errors and falls back to Tesseract per page if they persist, so a batch always
finishes. Check the current free list at
https://openrouter.ai/api/v1/models (filter for :free + image input) and
pass it with --model. For reliable throughput, Mistral's free tier is steadier.
If a page's API call fails (rate limit, network), that page silently falls back to the Tesseract text so the batch still completes.
Privacy: with
--engine vision, each page image is uploaded to the chosen provider. Don't use it on confidential scans unless that's acceptable. The default--engine tesseractis fully local.
- OCR errors are visible with the default local engine (e.g. an italic in
read as
mm, a decorative drop-cap The read as junk). Use--engine vision(above) to fix most of these and recover italics. - Layout is approximated, not pixel-perfect — text is re-flowed per column block, so line breaks and vertical positions differ slightly from the original. This is deliberate: readability is prioritised over exact placement.
- Italics: detected by the
visionengine; with the localtesseractengine italic type is rendered upright (bold is still detected via stroke weight). Local slant detection was too noisy to ship safely. - Figures are grayscale. Fine for line art; a
--color-figuresflag could preserve colour photos.