Skip to content

WIP: feat(ocr): optional local OCR for scanned PDFs and image documents - #61

Draft
massimodeluisa wants to merge 10 commits into
firecrawl:mainfrom
massimodeluisa:feat/local-ocr
Draft

WIP: feat(ocr): optional local OCR for scanned PDFs and image documents#61
massimodeluisa wants to merge 10 commits into
firecrawl:mainfrom
massimodeluisa:feat/local-ocr

Conversation

@massimodeluisa

@massimodeluisa massimodeluisa commented Aug 7, 2026

Copy link
Copy Markdown

WIP: this needs firecrawl/pdf-inspector#280 merged first, see the note at the bottom.

Hi! This is the follow-up of firecrawl/pdf-inspector#280. Now that the renderer exists over there, anydoc can finally do something with the pages pdf-inspector flags as needing OCR. Same premise as that PR, I'm still quite new to Rust, so if something looks wrong to you please point it out.

Why

Today a scanned PDF just errors with "unsupported input: PDF has no extractable text", and an image file is not a format anydoc accepts at all. But people throw these at a converter every day, and the frustrating part is that anydoc already knows page 2 is a scan, it just had no way to read it. With this PR you give the converter the two ocrs model files and it reads scanned and mixed PDFs, and PNG/JPEG/WebP/TIFF/BMP files become documents too. All local, you bring the models, the library never downloads anything.

For a concrete case I used 018-base64-image/base64image.pdf from the py-pdf corpus, a page that is just a screenshot. On main it errors. With an OCR converter it returns the text of the screenshot, and native and wasm return exactly the same characters.

Ps. It's the most common question on X reguarding this amazing library 😂.

What

An opt-in ocr cargo feature and a reusable Converter you build once from the model bytes and use for every conversion after that. The same converter exists in Rust, in Node (async, off the event loop) and in the browser wasm package (from Uint8Array). The free functions don't change and the default build doesn't even compile the OCR dependencies.

For PDFs I render and recognize only the pages that need it, one page at a time, through the new pdf-inspector render feature. A page with real text keeps the exact extraction output it had before, so a text PDF converts byte for byte the same as today. A mixed PDF today comes back partial (the scanned pages are just missing); now the recognized pages get merged with the extracted ones in source order. The recognized text stays plain text, I don't invent any structure out of it.

I wired the OCR in the demo page as well. Drop a scanned PDF or a photo of a page on it and the text shows up in the same preview a .docx does, in your browser. The models load only when a document actually needs them (12 MB, CC-BY-SA-4.0, fetched pinned by hash when the site deploys), so who converts a Word file never downloads them, and they never touch the crate.

Numbers

All numbers from my Apple M3 Pro, release profile. Engine init is ~21 ms cold and ~7 ms warm, and the 018 conversion above takes ~155 ms end to end. Peak RSS for one Letter page at 200 DPI is ~234 MB (that includes the RTen inference workspace), which fits a browser tab but not with a huge margin... for big documents is better to run the conversion in a Worker, the wasm README shows the pattern.

The published wasm bundle does not change (~2.95 MB gzip); an OCR build is ~5.45 MB gzip. The demo build turns on also wasm SIMD, that alone cuts the 018 conversion in wasm from ~1.8 s to ~0.9 s (I measured on Node, which runs the same V8 of Chrome) and costs ~11 KB of gzip more. The native bindings grow by ~4.75 MB when OCR is compiled in. The base crate keeps Rust 1.88; the ocr feature needs 1.92, that is hayro's floor and it comes in through the render feature.

Tests

cargo test --locked is 224 tests; with --features ocr it is 251. One snapshot changed and honestly it is not OCR: text.pdf converts now through the pinned pdf-inspector revision, which brings the upstream strikeout and footnote fixes, so the expected output moved with it.

For the OCR pipeline I scripted a deterministic fake engine, so I can test the routing, the merge order, which text wins on a page and the failure fallbacks without any model file. Then there are the tests with the real models. They cover the init with the standard models, a synthetic scanned page, and the 018 case above asserting the recognized phrase. I marked them as ignored, they run only when the model paths are set.

To run them (from the repo root):

git clone https://github.com/py-pdf/sample-files.git ../sample-files
git -C ../sample-files checkout 89039b6078fd0c9f98bf3d6fcb5583fac6b0ecaf
curl -LO https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.rten
curl -LO https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.rten

ANYDOC_OCR_DETECTION_MODEL=text-detection.rten ANYDOC_OCR_RECOGNITION_MODEL=text-recognition.rten ANYDOC_PDF_SAMPLE_FILES=../sample-files cargo test --features ocr --test ocr -- --ignored --nocapture

The Node binding runs also its whole suite with the real models (17 passed locally), and the wasm package has Node tests for both builds plus the same 018 case through wasm.

Known limitations

The current ocrs models read Latin script only and the output is plain text, no structure. Probably some structure could be inferred from the line geometry (paragraphs, maybe headings) and I would like to try in a follow-up, but here I preferred to keep the recognized text plain on purpose.

On photos with no text the models can still produce short garbage fragments; in the corpus tests I assert the cases where nothing may be invented, rather than pretending it never happens. Overlapping text (a watermark over a paragraph) can garble the merged line. EXIF orientation is not applied to images yet. The models are CC-BY-SA-4.0 and are never bundled, the caller downloads them and passes the bytes.

Important notes

  • I did not expose OCR in the Python binding on purpose: I personally don't like very much nor use Python, so I preferred to not ship a converter there that I cannot maintain. The only Python changes are the new image format name in the registry and a couple of refreshed docstrings, so the binding keeps compiling. The plumbing is all in the core crate, adding the converter later should be mechanical if you want it.
  • Format::Image is a new variant on an exhaustive enum, so an exhaustive match downstream will need a new arm. I did not mark the enum non_exhaustive because that changes the ergonomics for everyone; your call if you prefer it.
  • The release matrix for the native bindings has never compiled rten and hayro across all 7 napi targets, so please keep an eye on the first release with OCR in.
  • This PR needs feat(render): add optional selected-page rasterization pdf-inspector#280 merged first. Right now pdf-inspector comes from a git pin on my fork's PR branch, so every lane runs from a clean clone, and I will switch it to the released crate version once #280 ships. That is also why this is a WIP.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Adds a `Converter` and its builder, both non-exhaustive, and routes
the crate-level functions through a default converter. Behavior is
unchanged and there are parity tests proving it.
Adds the wasm32-unknown-unknown target to the stable CI toolchain and
a locked cargo check of the root crate for it.
New opt-in `ocr` feature. The caller supplies the detection and the
recognition model as bytes and gets a reusable engine, built once per
converter. Both sizes are checked against a 64 MiB cap before any
parsing happens, init errors have their own type so they don't mix
with document errors, and there is an ignored env-gated test for the
standard models. The registry loads only the RTen operators the two
models actually use.
When a converter carries an engine, PDF pages flagged as needs_ocr
are rendered one at a time and recognized, and the result is merged
back in source order as plain text (with GFM block starters escaped).
TextBased documents keep the exact process_pdf_mem output they had
before. Documents classified Scanned or ImageBased with no
document-level markdown get every page recognized, and per page the
richer text wins between extraction and recognition. A page that
fails falls back to its extracted text or is skipped; when nothing
usable is left the new ConvertError::Ocr is returned. Also adds the
PageOcr seam for tests, synthetic PDF fixtures and an ignored corpus
regression. The text.pdf snapshot changes because the renderer
dependency brings the upstream strikeout and footnote fixes.
Adds Format::Image for PNG, JPEG, WebP, TIFF and BMP. Detection by
signature is always on and the extension mapping too, while the
conversion goes through the shared engine and so it needs the `ocr`
feature. Width and height are checked from the header before any
decode (16384 px per side, 25M pixels total) and EXIF orientation is
not applied. The new format is named in the Node, Python, wasm and
TypeScript surfaces.
Adds a Converter class to the Node binding. A static async create
takes the model bytes, toMarkdownBytes runs off the event loop, and
the engine is shared with an Arc so concurrent calls are fine.
OcrInitError gets the new 'ocrInit' code and 'ocr' joins the
error-code union. Generated bindings are regenerated; the free
functions don't change.
Adds an `ocr` cargo feature to the wasm package, off by default so
the published bundle does not change. With it on, a Converter built
from Uint8Array model bytes exposes toMarkdownBytes like the free
function, and 'ocr'/'ocrInit' join the TypeScript error-code union.
There is an env-gated Node test with the real models.
Documents the optional local OCR in the root README (model files with
their SHA-256, the licensing note, what the current models can and
cannot do), adds the image row to the formats table, fixes the stale
Format::Pdf rustdoc, documents the converter and the size cost in the
node README, adds a dependency-license paragraph and a Web Worker
example for wasm, and refreshes the stale wording left in the binding
docs.
CI runs the ocr feature now. Native tests, wasm32 checks for both
default and ocr, two pinned toolchain jobs (1.88.0 for the default
floor and 1.92.0 for the ocr one), and the wasm-pack suites for both
wasm builds wired through ANYDOC_WASM_OCR_BUILD.

The ocr and compiler-floor lanes run from a clean clone now: the
git-pinned pdf-inspector dependency resolves without a local
sibling checkout. Switching to the released pdf-inspector crate is
the remaining step before merge.
The demo page feature-detects the Converter export, so a module built
without the ocr feature leaves it behaving exactly as it does today.

Where the export is there, a conversion that reports OCR is required,
or a dropped image, sends the file down the OCR path: the two ocrs
models are fetched same-origin from models/ with progress shown in the
preview area, their sizes are checked, and the converter is built once
and kept for the session. The recognized Markdown lands in the same
preview a .docx does.

The Pages build compiles the module with the ocr feature and downloads
the models into the site at deploy time, verifying both SHA-256
digests. They stay out of git.

The demo build compiles the wasm with SIMD enabled, and the wasm-opt
flags now accept SIMD opcodes.

The footer credits the ocrs models and their CC-BY-SA-4.0 license.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant