Turn any photo into a two-ink risograph-style print, right in your browser.
Live: https://overprint-1iy.pages.dev
overprint splits a photo into two spot-ink plates, dithers each plate into dots, knocks the second plate slightly out of register, adds ink grain, and overprints the two inks on paper, multiplying them the way real transparent ink does. All the image processing is Rust compiled to WebAssembly. Your photo never leaves the device.
- Open the page. A sample poster is already on the press: big flat shapes, a few tints and a headline, so the grain, the overprint and the mis-registration show at a glance. It is drawn in code as two plates (no photograph, no copyright) and repainted in whichever inks you pick.
- Drop, paste or choose your own photo.
- Pick an ink pair: fluorescent pink + blue, teal + yellow, or red + black.
- Pick a screen: Floyd–Steinberg, Atkinson or blue-noise dithering, at 400 (the default, coarse like a real stencil print), 600 or 900 dots on the long edge. Where there is room, the preview draws every dot as a whole number of screen pixels, so the grain stays crisp.
- Slide plate B out of register (the registration marks on the sheet drift apart too), add ink grain, change each plate's density, or proof one plate on its own.
- Export PNG: each dot becomes a crisp square of whole pixels, so the long edge is at least 2400 px: exactly 2400 px at 400 or 600 dots, and 2700 px at 900. The page shows the exact size next to the button.
- Tick Race plain JavaScript to time the same job in both engines on your own machine. You get the median of 7 alternating runs, plus a byte-for-byte check that both outputs are identical.
| Step | What happens (Rust, src/) |
|---|---|
Separate (separate.rs) |
Each pixel and each ink becomes an "absorption" vector (255 − value per channel). A 2×2 least-squares solve finds the ink A / ink B coverage that best explains the pixel. If the answer falls outside what the inks can print, one coverage is clamped and the other re-solved. |
Grain (grain.rs) |
Plate density is scaled first, then two layers of seeded noise are added: fine per-dot hash noise and coarse 16-dot value noise. The change is proportional to the coverage, so bare paper stays bare. |
Dither (dither.rs) |
Floyd–Steinberg (serpentine scan, 7/3/5/1 split that conserves error), Atkinson (6 taps of ⅛, drops ¼ by design), or an ordered threshold against a 64×64 blue-noise map generated in code with Ulichney's void-and-cluster method. The job seed offsets each plate's blue-noise tile. |
Register and overprint (compose.rs) |
Plate B is shifted by whole dots (clamped to ±16). Anything that slides in from outside the sheet is bare paper. Where both inks print, the colour is paper × (A/paper) × (B/paper). |
Deterministic by construction. Every step uses integer maths only (wrapping u32 hashes, arithmetic shifts, truncating i64 division) and every random choice is seeded. The same resized pixels and settings give the same bytes everywhere. The tests pin golden fingerprints that native Rust, the wasm32 build and the JavaScript reference all have to hit.
web/reference.js is a line-for-line plain-JavaScript port. It exists for the speed race, and as a clearly labelled fallback if WebAssembly fails to load. web/app.js handles the page: file loading, canvas drawing, layout and export. web/intake.js checks a file before the browser decodes it (see Large or unusual images below).
- The language does the real work. Per-pixel least squares, serpentine error diffusion, void-and-cluster generation, seeded grain and compositing all live in Rust. The JavaScript glue only moves pixels in and out.
- Bit-exact results. Rust's explicit integer types make it straightforward to specify the maths exactly, then hold a JS port to the same answer. WebAssembly runs that specification identically on every device.
- Predictable speed, small payload. No garbage-collection pauses during a render, and the whole engine is a ~36 KiB
.wasmfile. - Honest about the margin. Modern JavaScript engines are very good at typed-array loops, so the gap is real but not dramatic, and it varies a lot by device and browser. That is why the page measures it live instead of quoting a number.
Requirements:
- Rust 1.88 or newer (declared as
rust-versioninCargo.toml) with thewasm32-unknown-unknowntarget wasm-bindgen-cli0.2.129 (it must match the version pinned inCargo.toml)- Node.js 20 or newer
- Optional:
wasm-opt(binaryen) for an extra size pass. The build skips it if it is not installed.
rustup target add wasm32-unknown-unknown
cargo install --locked wasm-bindgen-cli --version 0.2.129
npm run build # compiles Rust -> wasm, generates bindings, copies web/ -> dist/
npm run serve # preview dist/ at http://127.0.0.1:8080 (serves the production _headers too)There are no npm dependencies. dist/ is plain static files. The build also writes dist/THIRD-PARTY-NOTICES.txt (see Credits and third-party notices).
npm test # cargo test, then npm run build, then node --test tests/*.test.mjs-
Rust unit and integration tests (
cargo test, 35 tests) cover:- separation: pure inks map to their own plate, white needs no ink, black and tints, degenerate ink pairs
- known-answer cases for every dither kernel (1×4, 2×2 and 4×4, cross-checked against an independent scalar model)
- the blue-noise map: a permutation of 0–4095, exact 50% dot count, minimum spacing
- grain range and seeding
- mis-registration shifts, clamping and bounds
- parameter validation
- seeded determinism and golden fingerprints
-
JS vs Rust equality (
tests/equality.test.mjs) runs in Node against the built WASM:- more than 1,000 byte-for-byte comparisons on small synthetic images
- every preset × dither × proof view
- sweeps of grain, density, registration and seed
- out-of-range parameters
- the golden fingerprints
-
Smoke (
tests/smoke.test.mjs) checks thatdist/index.htmlloadsapp.js, which imports the wasm-bindgen module, that every local asset exists, that the.wasmvalidates and renders, and that the CSP allows WebAssembly. -
Browser (
tests/browser.test.mjs) runs real headless Chrome through the DevTools protocol againstdist/, served with the production headers. It checks:- the page renders with the WebAssembly engine, starting on the sample poster at 400 dots, 2 screen pixels per dot at 1280 × 800
- the in-page race finds identical output
- a text file, a corrupt PNG, an SVG, a 200-megapixel PNG header, a GIF with a 1 × 1 screen and a 108-megapixel first frame, and an AVIF that states no size all produce clear messages, and the print stays
- a real JPEG (encoded by the browser) and a real GIF still load
- the Archivo and IBM Plex Mono faces load from the site's own
fonts/folder, and no request goes to any other origin - there are no console errors, CSP violations or uncaught exceptions
- there is no horizontal scroll at 400 px, in both themes
The test skips if Chrome is not found; set
CHROME_PATHto point at one. In CI, setREQUIRE_BROWSER=1: then a missing Chrome fails the run instead of skipping (tests/browser-flag.test.mjschecks both behaviours). -
Page logic runs in Node without a browser:
tests/intake.test.mjs: format and size sniffing, including files built to hide their size (a GIF whose first frame is bigger than its screen, a JPEG with its frame header behind 5 × 64 KB of metadata, AVIF and HEIC sizes from theirispeboxes), the read budget, the SVG message, the 100-megapixel cap and the working-copy sizetests/sizes.test.mjs: the export sizes quoted above, and whole-pixel preview scalingtests/contrast.test.mjs: text colours reach WCAG AA (4.5:1) in both rooms, read straight fromstyles.csstests/fonts.test.mjs:dist/names no Google Fonts host, the CSP allows styles and fonts from the site only, and every@font-facepoints at a WOFF2 file that ships indist/fonts/
-
Third-party notices (
tests/notices.test.mjs):dist/THIRD-PARTY-NOTICES.txtexists, the colophon links to it, it names the exactwasm-bindgenversion fromCargo.lock, every crate whose code is inside the shipped.wasm(read from its name section) has an entry, and every font file has one too, with its copyright line and the OFL text
dist/ is 15 static files, including one ~36 KiB .wasm and three WOFF2 font files (about 125 KB together). No Worker, KV, D1 or server code, and no API calls of any kind. That sits well inside Cloudflare Pages' free static-asset limits (unlimited requests, 20,000 files per site, 25 MiB per file).
To deploy your own copy, build locally, then upload dist/ directly:
npm run build
npx wrangler pages deploy dist --project-name overprintThere is deliberately no npm run deploy script and no account_id in wrangler.toml: the plain command above uses whatever Cloudflare login is active, which is fine for your own copy. The owner deploys this project through a separate, guarded script that checks which account it is about to use.
Direct upload is the intended path, because Pages' own build image is not assumed to have Rust and wasm-bindgen installed. dist/_headers sets:
- a strict Content-Security-Policy (
script-src 'self' 'wasm-unsafe-eval',style-src 'self',font-src 'self': no inline scripts or styles, and no third-party hosts) nosniffno-referrer- a locked-down Permissions-Policy
No long cache lifetime is set: app.js, pkg/overprint.js and pkg/overprint_bg.wasm keep fixed names (so do the font files), so a max-age could pair a fresh app.js with a stale engine after a redeploy. Pages' default (revalidate every time) avoids that.
- No uploads, no analytics, no cookies.
- No third-party requests at all. The Archivo and IBM Plex Mono typefaces ship with the site (
web/fonts/), and the Content-Security-Policy only allows scripts, styles and fonts from the site itself. localStorageholds a single value: your Light room / Darkroom choice.
- Ink colours are on-screen sRGB approximations chosen by eye, not measured ink data. Real spot inks, paper and presses will look different, and no screen can show fluorescent ink.
- The separation is a linear least-squares fit in absorption space: a good-looking approximation, not a colour-managed separation. Colours two inks cannot make are clamped to the nearest printable mix. A saturated green, for example, cannot come out of pink + blue.
- "Same result on every machine" starts after resizing. Photos are first resized with the browser's canvas (once to a 900-px working copy, then to 400/600/900 dots), and that resampling can differ slightly between browsers. From those pixels onward, the engine is bit-exact.
- Mis-registration is a whole-dot shift of plate B only. There is no rotation, skew or paper stretch.
- Timings come from
performance.now(), which browsers deliberately coarsen (often to about 0.1 ms). Each race uses one warm-up run and 7 alternating timed runs per engine. The single-render time shown under Output is one run. - Rendering runs on the main thread. It is quick at these sizes (the page shows the measured time for each print), but larger outputs would want a Web Worker.
- Whole-pixel preview needs room. On a phone with 2 screen pixels per CSS pixel, 400 dots cannot fit at a whole number of pixels per dot without shrinking the print to about half the screen, so the preview fits the space instead and the dots are drawn 1–2 pixels wide.
- Large or unusual images:
- Before decoding, the page reads the file in small pieces to find its real format and its size in pixels. Files over 80 MB or 100 megapixels are refused with a message that gives the numbers. Where the size comes from:
- PNG, WebP and BMP: the header.
- GIF: the larger of the logical screen and the first frame. Browsers decode the first frame at that size, so a GIF can claim 1 × 1 and hold a 23,000 × 23,000 frame.
- JPEG: the frame header, found by walking the metadata segments before it, however far into the file they reach.
- AVIF and HEIC: the
ispe(image size) boxes in the file's metadata. The largest one counts.
- That reading stops after 1,024 reads or 2 MB in all, so a crafted file cannot keep the page reading. If the page recognises the format but cannot find the size within that budget, or the file does not state one, it refuses the file instead of decoding it blind.
- Files in other formats go to the browser's decoder and are checked against the same cap straight after decoding.
- The check reads what each file declares in the places above, so it does not catch a picture that is bigger than those say. Examples: an AVIF whose image data is bigger than its
ispebox, an AVIF grid whose tiles cover more than the grid's ownispe, or an animated AVIF whose frames are bigger than its still image. The browser starts decoding such a file at its real size. - Each photo is decoded once, drawn into a working copy no bigger than 900 px on the long edge (the largest dot count), and the full-size decode is released straight away.
- SVG drawings are not supported; the page says so and asks for a photo (JPEG, PNG or WebP).
- Some browsers cannot open HEIC; if the file really is HEIC, the page says so and suggests saving it as JPEG.
- Before decoding, the page reads the file in small pieces to find its real format and its size in pixels. Files over 80 MB or 100 megapixels are refused with a message that gives the numbers. Where the size comes from:
- SVG export (each plate as vector dots or paths)
- More ink presets, a custom ink picker and paper colours
- Halftone screens (angled AM dots) alongside the dithers
- Per-plate rotation and skew in the registration model
- Render in a Web Worker; run
wasm-optin the build - Export each plate separately as a greyscale file, ready for a real press
Built by Ramen Protocol with AI assistance (Claude). overprint is independent and not affiliated with any printer or ink maker. "Risograph-style" only describes the look.
The .wasm file contains third-party code and the site ships two typefaces, so npm run build writes dist/THIRD-PARTY-NOTICES.txt, linked from the page's colophon. scripts/notices.mjs generates it from the exact versions that were compiled: licence texts come from each crate's own source in the Cargo registry, and from the Rust toolchain's own licence files. It covers:
- the Rust standard library (
core,alloc,std; MIT OR Apache-2.0, with Unicode-3.0 data tables incore) anddlmalloc, its allocator on wasm32 (MIT OR Apache-2.0) wasm-bindgen(MIT OR Apache-2.0), plus thepkg/overprint.jsglue thatwasm-bindgen-cligenerates- the crates
wasm-bindgenpulls in for wasm32:wasm-bindgen-shared,cfg-if,once_cell(all MIT OR Apache-2.0) andunicode-ident((MIT OR Apache-2.0) AND Unicode-3.0) - the typefaces in
dist/fonts/, both under the SIL Open Font License 1.1: Archivo 2.001 (one variable Latin-subset WOFF2, as served by Google Fonts) and IBM Plex Mono 2.005 Regular and Medium (IBM's own Latin-1 WOFF2 files from@ibm/plex-mono2.5.0). Their copyright lines and licence text come fromlicenses/, and the build fails on any font file the notices do not list.
Proc-macro and build-script crates (syn, quote, proc-macro2 and others) only run on the build machine, so nothing from them ships.
