WIP: feat(ocr): optional local OCR for scanned PDFs and image documents - #61
Draft
massimodeluisa wants to merge 10 commits into
Draft
WIP: feat(ocr): optional local OCR for scanned PDFs and image documents#61massimodeluisa wants to merge 10 commits into
massimodeluisa wants to merge 10 commits into
Conversation
This was referenced Aug 8, 2026
massimodeluisa
marked this pull request as draft
August 8, 2026 16:25
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.
massimodeluisa
force-pushed
the
feat/local-ocr
branch
from
August 12, 2026 07:42
d2bc8bc to
edbfbde
Compare
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.pdffrom 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
ocrcargo feature and a reusableConverteryou 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
renderfeature. 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
ocrfeature needs 1.92, that is hayro's floor and it comes in through the render feature.Tests
cargo test --lockedis 224 tests; with--features ocrit 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 --nocaptureThe 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
Format::Imageis 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.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.