Turn photos of paper documents into clean, flatbed-scan-quality multi-page PDFs — entirely in your browser. No accounts, no uploads, no server-side processing: your documents never leave your machine.
Live demo: alxcreate.github.io/scanpdf
- Automatic corner detection — a multi-strategy OpenCV.js pipeline (Canny edges at two sensitivities, brightness and saturation masks, adaptive threshold, Hough-line intersections) finds the sheet in the photo and scores candidate quadrilaterals geometrically.
- Precise manual adjustment — drag corner handles with a magnifier loupe; live preview of the corrected result.
- Perspective and scale correction — pages come out flat and straight, sized to A4, Letter, or auto-fit at ~220 DPI.
- Scan-style filters — Color, Grayscale, and a Black & White mode that uses background division (not hard thresholding), so shadows are flattened, paper turns white, and text stays anti-aliased.
- Multi-page PDF export — reorder and delete pages, then save a single PDF assembled locally with pdf-lib.
- EXIF-aware — phone photo orientation is handled automatically.
- Zero build system — plain HTML, CSS, and vanilla ES modules.
- Drop photos of documents onto the page (or click Add photos).
- Corners of each sheet are detected automatically; pages where detection failed are marked adjust corners.
- In the editor, drag the corner handles (a loupe appears for precision), rotate pages, pick a filter, and reorder or delete pages in the left strip.
- Choose the page size (A4 / Letter / Auto) and click Save PDF.
Pull the published image from GitHub Container Registry — no build, no internet needed at run time:
docker run -d --rm -p 8080:80 ghcr.io/alxcreate/scanpdf # then open http://localhost:8080The image ships the strict Content-Security-Policy from nginx.conf, so the
browser blocks every external request — this is the CSP-enforced offline path.
Tags follow semver (:1.0.0, :1.0, :latest) and cover linux/amd64 and
linux/arm64.
Prefer plain static files? Each release
also attaches scanpdf-<version>.zip — the full app with the pinned vendor
libraries baked in — for serving on any static file server (without the strict
CSP guarantee). Verify it with the accompanying .sha256.
make run # build the image and start it
# open http://localhost:8080
make stopThe running container is fully offline: a strict Content-Security-Policy
served by nginx makes the browser block any request to an external origin.
Internet access is needed only once, during make build, to fetch the two
pinned libraries (SHA-256 verified).
make serve # downloads the vendor libraries once, then serves with python3| Target | What it does |
|---|---|
make build |
Build the Docker image (the only step that needs internet) |
make run |
Build + run at http://localhost:8080 (override with PORT=…) |
make test |
Run + smoke-check all endpoints and the CSP header |
make stop |
Stop the container |
make logs |
Follow nginx logs |
make serve |
Dev loop without Docker (python http.server; vendors libs once) |
make vendor |
Download libraries into site/vendor/ for make serve |
All image processing (OpenCV.js WASM) and PDF assembly (pdf-lib) happen in your browser. The app makes no network requests with your data — there is no backend at all.
- Self-hosted container: the offline guarantee is enforced by the
browser itself via the nginx-served CSP (
default-src 'self'). - GitHub Pages demo: the same code served from GitHub's CDN. Processing is still fully client-side, but headers are controlled by GitHub Pages rather than the project's nginx config — self-host the container if you want the strict CSP enforcement.
- Detection runs on a downscaled (≤1000 px) copy of the image; export warps the full-resolution original (capped at 3500 px on the long side, output at ≤2600 px ≈ 220 DPI on A4).
- Multiple detection strategies each propose sheet candidates; every quadrilateral is scored on area, convexity, and angle regularity, with an inward-refinement pass that handles curled paper edges.
- Export renders pages sequentially, embeds them as JPEG into the PDF, and sizes pages to the selected format.
- Dependencies are pinned by URL and SHA-256 in vendor-checksums.txt: OpenCV.js 4.9.0 and pdf-lib 1.17.1. They are fetched at build/deploy time and never committed.
site/ The whole app (static files)
├── index.html
├── css/app.css
├── js/
│ ├── main.js Bootstrap, file intake, detection queue
│ ├── detect.js Multi-strategy corner detection
│ ├── editor.js Corner editor with loupe and live preview
│ ├── warp.js Perspective warp and page sizing
│ ├── filters.js Color / grayscale / B&W filters, rotation
│ ├── export.js Multi-page PDF assembly
│ ├── pages-ui.js Thumbnail strip
│ ├── state.js App state and pub/sub
│ └── cv-loader.js Lazy OpenCV.js loader
└── vendor/ OpenCV.js + pdf-lib (fetched at build, not committed)
Dockerfile Two-stage build: fetch+verify libs, then nginx
nginx.conf Static serving, gzip, strict CSP
tools/vendor.sh Fetch libs locally for no-Docker development
.github/workflows/ GitHub Pages deploy + container smoke-test CI
Every push to main triggers the
Pages workflow: it downloads the pinned
vendor libraries, verifies their checksums, and publishes site/ to GitHub
Pages. The CI workflow builds the container and
smoke-tests every endpoint plus the CSP header.
Pushing a v* tag triggers the
Release workflow: it attaches the offline
scanpdf-<version>.zip bundle to a GitHub Release and pushes a multi-arch
image to ghcr.io/alxcreate/scanpdf (tags :x.y.z, :x.y, :latest).
Contributions are welcome — see CONTRIBUTING.md. Please report security issues privately as described in SECURITY.md.