Photograph a document, get a real scan: straightened, cleaned, and searchable.
opendocscan.com — the scanner and everything about it, one page
Point a phone at a page. OpenDocScan finds the edges of the sheet, corrects the perspective, cleans the lighting, reads the text, and writes a multi-page PDF you can search and select from. No sign-up, no watermark, no page limit, and nothing to install. There is an optional account, and nothing is behind it.
Everything runs on your own device. Unlike a native scanner app, that is a claim you can check yourself — see Verifying it.
Three ways to run it, and they are not equally finished. Read the state before picking one.
This is the complete product, and it is running on the front page — not a screenshot of it. Every feature listed below works there, on a phone, a tablet or a laptop, in any modern browser.
On a phone it is worth adding to your home screen — Share → Add to Home Screen on iOS, ⋮ → Add to Home screen on Android. It then opens without browser chrome, works offline, and uses the rear camera like any other app. Nothing to install, nothing to update.
A native build, published for testing. It is early: it takes a photograph, from the camera or your photo library, as far as the scanning engine and back — and no further. Straightening, cleaning, text recognition and PDF export are not yet wired into it. If you want a scanner that finishes the job today, use the browser.
To install it:
- Open the releases page and take
the
.apkfrom the release at the top — every build so far is a release candidate, so/releases/latestdeliberately does not point at one. - Open it. Android will warn you it came from outside the Play Store, because it did — allow installs from your browser or file manager when asked.
- The build is signed with a debug key, so that warning is expected. Check the
download against the
.sha256published beside it if you want to be certain of what you have.
Needs Android 7.0 (API 24) or newer. About 48 MB, because it carries the scanning engine compiled for three processor architectures.
No download yet. The build runs on both, is laid out properly for the iPad including Split View, and passes its tests on the simulator — but handing out an iOS build needs an Apple signing certificate, so there is no TestFlight link to give you.
You can run it on your own device today by building it from source. Needs iOS or iPadOS 15 or newer.
| Camera capture | Live preview with the detected page outlined as you move |
| Import | One image opens the corner editor; a batch is detected and added without stopping to ask |
| Edge detection | Automatic, with four draggable corners for when it gets it wrong |
| Perspective correction | Sized to the page's near edge, so nothing sharp is thrown away |
| Filters | Photo, Enhance, Black and white, plus brightness and rotation |
| Multi-page | Reorder, delete, add — no limit |
| Text recognition | English, on-device, producing an invisible selectable layer |
| Library | Stored in this browser, full-text searchable across every scan |
| Export | PDF to the share sheet, or a download |
| Offline | Works with no connection once loaded |
| Account | Optional, and nothing is behind it — it carries credits between our apps |
![]() |
![]() |
![]() |
| Pages, with the words read from each | The library, searchable by the text inside your scans | Where your scans live, and how to check |
Most scanner apps say your documents stay private. In a native app that is a promise you have no way to audit. Here it is something you can watch happen:
- Open opendocscan.com and let it load.
- Open your browser's developer tools and switch to the Network panel.
- Clear the list, then scan or import a page and export a PDF.
- Read the list. After the app itself has loaded, there is nothing in it.
Three mechanisms make that true, in order of how hard each is to subvert:
- The core cannot reach the network.
docscan-wasmhas no HTTP client and no networking dependency. Read itsCargo.toml. - The text recognition is vendored. tesseract.js fetches its WASM core and
language model from a CDN by default, which would mean a third party learning
each time you scanned something. Every byte is served from the app's own
origin instead — see
apps/web/vendor/tesseract/. - The service worker refuses cross-origin requests outright. Every request
the page makes passes through
apps/web/sw.js, and anything bound for another origin is answered with a 403 rather than forwarded. A future dependency that tried to phone home would fail loudly instead of succeeding quietly.
The end-to-end suite asserts the same thing on every run: it records every request the app makes while driving a complete scan and fails if one leaves the origin.
The phone builds used to carry a stronger version of this claim, and no
longer do. Until accounts existed the Android manifest shipped without the
INTERNET permission, so the process could not open a socket at all — a
guarantee enforced by the operating system rather than by us. An optional
account needs one, so that permission is now present and the guarantee is
enforced in software instead: every request the app makes passes through
AccountApi, which refuses any host but the account server, and
app/test/account_api_test.dart proves a request anywhere else throws rather
than leaving the device. That is weaker than the old arrangement and is written
here plainly rather than quietly dropped. The scanning path itself opens no
connection at all — detection, rectification, filtering, recognition and PDF
assembly are Rust running on the device — and nothing about a document is ever
sent anywhere, signed in or not.
One core, written once in Rust, compiled three ways.
crates/
docscan-core shared types and image IO
docscan-detect finding the page in a photograph
docscan-transform perspective correction
docscan-filters histograms and lookup tables
docscan-ocr text recognition glue
docscan-pdf PDF assembly, with an invisible text layer
docscan-wasm the browser boundary (wasm32-unknown-unknown)
docscan-ffi the Android/iOS boundary (flutter_rust_bridge)
apps/web the browser app — no build step but the Rust one
app the Android and iOS app (Flutter)
No image processing is written twice, and none of it lives in Dart or JavaScript — those layers handle the camera, the file picker and the screen, and nothing else. The browser receives the core as WebAssembly, 139 KB over the wire; the phones get the same crates cross-compiled natively.
cd apps/web
npm install
npm run build # compiles the Rust core to WebAssembly
npm run serve # http://127.0.0.1:8765There is no bundler. The HTML, CSS and JavaScript are served as written.
Testing a phone camera needs HTTPS, because getUserMedia is gated on a secure
context — npm run serve:lan generates a certificate for this machine's LAN
address and prints the steps to trust it. apps/web/README.md has the detail,
including two failure modes that look like app bugs and are not.
Needs the Flutter SDK, the Rust toolchain, and Xcode or the Android SDK.
cd app
flutter pub get
flutter run # a connected device or a running simulator
flutter build apk --release # Android
flutter build ios --release # iOS — needs your own signing certificateThe Rust core is cross-compiled automatically as part of the Gradle and Xcode
builds; there is no separate step. app/README.md covers the three traps that
cost real time when this was first stood up.
cargo test --workspace # 80, no browser
cd apps/web && npm test # 4, real Chromium
cd app && flutter test # 59, no device
cd app && flutter test integration_test/ -d <device-id> # 6, on a deviceFour suites, proving different things. Running one of them is false confidence in a specific direction.
- Rust covers the arithmetic: detection, correction, the histogram filters, PDF assembly, the FFI boundary.
- The browser suite drives a real Chromium through the real UI. It draws a photographed page, imports it, checks the rectified result has the sheet's aspect rather than the photo's, exports a PDF, and parses it back for its page count, title, invisible-text marker and the words recognition read.
- The Flutter host suite covers the phone screens — every permission state, a cancelled pick, one bad file among good ones, the iPad layouts driven by resizing the window, and the whole account flow including the sign-in return trip, which is the half that fails without an error message. It proves nothing about the bridge: a library built for a phone cannot load on the Dart VM.
- The integration suite loads the real library and calls the real core. It is the only thing that catches a broken cross-compile, a missing ABI, or generated bindings that drifted from the Rust.
apps/web/BENCHMARKS.md covers performance, including two optimisations that
measurement rejected.
- Text recognition is English only. The bundled model is
eng. - The invisible text layer is Latin-script only. It uses unembedded Helvetica with WinAnsi encoding, so it cannot carry CJK, Cyrillic or Greek. Words it cannot encode are dropped from the searchable layer rather than mangled; the page image is unaffected.
- The library lives in one browser. Not in an account, not on a server, not on your other devices. Clearing site data clears it. The app asks for persistent storage and tells you whether the browser granted it.
- HEIC cannot be decoded by the phone builds. Capture arrives as JPEG, but a file picked from an iPhone's photo library may not; it surfaces as a message on the card, not a crash.
- A photographed slide from a presentation deck sometimes rectifies to the wrong quadrilateral. The rule that would fix it starts rejecting real receipts, so this is a known limit rather than a pending fix.
- The phone apps have never taken a photograph on real hardware. An emulator's camera is not worth much and the iOS Simulator has none. That is what the release candidate is for.
- The phone apps' sign-in uses a custom URL scheme,
opendocscan://auth, because a verified https deep link needs a signing identity the project does not have yet — an Apple Team ID on iOS, and a release keystore on Android rather than the debug key these builds carry. Until then another app on the same device could register that scheme and intercept the one-time code from a sign-in in progress. It cannot reach anything already stored, and it cannot affect a device that has no such app on it, but it is a real weakness of a custom scheme and the reason the fix is an App Link and a Universal Link rather than more code.
Dual-licensed under either MIT or Apache-2.0, at your option.




