![]() The photograph. Grand Prismatic Spring, Yellowstone: continuous tone, thousands of colours. |
![]() Printed. Three inks (Black, Blue, Orange), each screened at its own angle onto natural stock. |
Overprint does what a print shop does, not what a filter does: it picks a few spot inks, works out how much of each belongs at every point in the picture, screens each ink at its own angle, and prints them one over another onto paper. That is where the riso look comes from: rosettes where screens cross, ink slightly out of register, colours the inks can't quite reach, paper grain in the highlights.
It is one static page with no build step and no server. Your photo is never uploaded — it is separated, screened and printed on your own machine — and your photo and settings stay in your browser. The page's one outbound request is an anonymous hit to GoatCounter, which sets no cookies and records no personal data; see Analytics.
Every picture on this page can be reproduced: drop
docs/images/source.jpgon the sheet and pick the matching preset.
The same photograph again, this time through the screens and tricks of BitCam, the 1-bit Macintosh camera. A press can do all of them: an error-diffusion dither is a screen like any other, and a gradient behind or inside the picture is a flood coat or a split fountain.
The app shows you its own working. The buttons along the top step through the same stages a press goes through.
1. The photo. Whatever you dropped in, with the film look, exposure, contrast and saturation applied. Everything downstream works from this, which is why a look changes the inks and not just the tint.
2. Separation. The photo resolved into one film per ink: how much black, how much blue, how much orange belongs at each point. Continuous tone, no dots yet. Look at what each film ended up carrying: black took the dark mineral structure and the boardwalk, blue took the pool, orange took the bacterial mats with the pool knocked out of it.
3. Screening. Each film broken into dots. Every ink gets its own frequency, angle and dot shape, which is what stops the three screens landing on top of each other and turning into a muddy moiré.
4. Overprint. The screened films printed onto the paper in order, each shifted a fraction against the last. Where dots overlap you get colours that aren't in any single ink; the green rim around the pool is blue and orange landing together.
A library of 122 colour grades in 17 families, named after the shelves a phone editor keeps them on: 自然 Natural, 质感 Texture, 人像 Portrait, 胶片 Film, 轻复古 Retro, 电影 Cinematic, 港风 Hong Kong, 日系 Japanese, 韩系 Korean, 清新 Fresh, 奶油 Cream, 赛博 Cyber, 夜景 Night, 黑白 Mono, 美食 Food, 油画 Oil, 风景 Landscape.
The same library is offered at two points in the press, which are two different jobs:
photo → PRE → separate · screen · overprint → POST → sheet
A pre-press filter colours the picture the press is asked to match. That changes the separation, so Auto-pick fits its inks to the coloured photo and reaches for different drums. One photo, one three-ink press, four filters — and four ink sets nobody chose:
Halation rides alongside a pre-press filter, because it is the one thing a colour table cannot hold: it is not a per-pixel map but the red bleed a negative gets where a highlight scatters back off the film base. It is taken from the photo's own mip chain, which is already a blur of itself. Cinestill 800 sets it to 95%, which is the whole reason that stock exists.
A press flattens a grade, and there is no way around it: a print has two or three inks, a paper white it cannot go above and a maximum ink density it cannot go below. So a post-press filter colours the sheet that came out instead. No press can do this, which is exactly the point — it arrives exactly as picked, paper and all.
The same look, the same photograph, the same three inks. Only the stage differs:
Both can be on at once: colour the photograph to steer the ink choice, then colour the sheet to set the mood. Separations are deliberately left alone — those are plates for a real press, and a stylistic filter has no business on one.
Four of the newer families, shown post-press so they arrive undiluted — the same three-ink print underneath each one — Blue + Crimson + Orange, unchanged:
![]() Neon Alley 港风 |
![]() Terminal 赛博 |
![]() Crème 奶油 |
![]() Hazy Green 日系 |
Not as a stolen table. Each one is a spec — white balance, a pivot contrast curve, a toe
and a shoulder, a black lift, split toning, and gaussian bands on hue in Oklab — and
src/film.js bakes that spec into a 33³ table mapping sRGB in to sRGB out.
The curves hold their endpoints, so nothing clips however hard a look is pushed; the
separator would otherwise be handed flat black. Tables are baked once and cached, at
about 18 ms each, and uploaded as 8-bit 3D textures — which is what a LUT box gives you,
and far more than survives being screened into dots.
Because a look is only ever a table, your own LUTs are first-class. Load a .cube
takes any 3D or 1D .cube file — anything you can export from Lightroom or Resolve, or
a pack you own — and it joins both pickers as Custom. A table with a domain outside
0..1 is resampled onto 0..1 when it loads, so everything downstream sees the one domain.
It is parsed in your browser and kept there.
Eight ways of breaking a tone into marks, shown on the pool's edge in a single black ink. The first four are amplitude screens at 22 lpi: a regular grid where the marks grow and shrink. The other four are frequency screens: same-sized art pixels, scattered more densely in the shadows.
![]() Dot |
![]() Diamond |
![]() Square |
![]() Line |
![]() Grain |
![]() Bayer |
![]() Atkinson |
![]() Floyd–Steinberg |
Grain thresholds each pixel against white noise. Bayer thresholds it against a fixed 8×8 matrix, which is why it makes those crosshatch textures. Atkinson and Floyd–Steinberg are error diffusion: each pixel is rounded to ink or paper and the rounding error is pushed onto the pixels not yet visited, so tone is kept over a neighbourhood rather than inside a cell. Floyd–Steinberg passes all of the error on and reproduces tone exactly. Atkinson, Bill Atkinson's kernel from the original Macintosh, deliberately drops a quarter of it, so highlights clip to clean paper and shadows to solid ink instead of filling with stray specks. That contrast is the whole 1-bit look, and it is why the one-bit preset lifts the exposure.
Frequency screens have no angle or frequency; they have a pixel size, in px at 150 ppi. The three quick sizes are BitCam's: Fat bits (6 px, 175 pixels across a 7-inch print), Standard (3.5 px, 300 across) and Fine (2 px, 525 across).
Two things a press can do with a gradient that a filter can't.
A flood coat is a layer of ink laid over the whole block before the picture: solid, or graded between two inks in a straight line or out from the centre. It behaves like coloured paper, and the separator treats it that way: the films are made against the flooded stock, so a black ink over a red flood carries only the darkness the red can't. Save separations gives you the flood as its own film, printed first.
A split fountain is two inks in one tray, so a single pass prints one colour at one end of the sheet and another at the other. The stencil is still cut for the first ink; the second is just what comes out at the far end. The blend between them is interpolated in ink density rather than in light, which is what keeps a blue-to-yellow fountain green in the middle rather than grey.
Both take a pair from the same eight quick pairs, or any two inks from the library.
The panel is deliberately colourless. The only saturated thing in the interface is each channel's actual ink, so nothing competes with the print you're judging.
| Control | What it does |
|---|---|
| ? | An eight-step walkthrough. A small bubble rings one control at a time and says what it is for, counting 1/8 as it goes, with Skip on every step and ← → Esc on the keyboard. It runs itself once on a first visit and never again; the ? replays it. It rings rather than dims, so the print stays visible, and nothing is blocked while it is up. |
| Open photo (O) | Loads a new photo. You can also drop an image file on the sheet, or paste one from the clipboard. |
| Rotate, Flip | Quarter turns and mirror flips of the photo. Rotating swaps the sheet's orientation with it. |
| Paper | Ten stocks: white, natural, newsprint, grey, kraft, four pastels and black. Dark stock means highlights can only ever be paper. |
| Ink picker | The 78 Riso drum colours in hue order, White included, plus a rainbow swatch that takes any colour from the system picker. |
| Hiding | Per ink, in Customize. How much a solid dot covers the paper rather than filtering it; each ink comes with its own value. |
| Pre-press filter | One of 122 colour grades, laid on the photo before it is separated, so it moves which inks Auto-pick reaches for — at the cost of the press flattening most of it. Strength cross-fades it; Halation adds the red bleed around highlights that a colour table cannot hold. |
| Post-press filter | The same library, laid on the finished sheet instead, after every press stage. It comes through exactly as picked, paper and all, because nothing downstream has to reproduce it. Separations are left unfiltered. Each section carries a small map of the press with its own slot lit, so the pair reads as one choice. |
| Load a .cube | Any 3D or 1D .cube LUT you own, parsed in the browser and kept there. It joins both pickers as Custom. |
| Auto-pick | Searches the 26 common drum colours for a set that suits this photo on this paper. It has two ideas of "suits". Poster (the default) picks what a print shop would reach for: one dark ink to carry the drawing, the rest bright and far apart on the wheel, overprinting to a clean dark — the best-looking set that still reproduces the photo well. Faithful picks whatever reproduces it most closely, however dull; it drifts toward process-like triads. Clicking either chip picks at once. |
| Screen angle dial | Drag it. Hold Shift to snap to 7.5°, or use the arrow keys. |
| Pixel size | For the frequency screens. The Fat bits, Standard and Fine chips are BitCam's three resolutions. |
| Flood coat | Under Paper. None, solid, or a linear or radial gradient between two inks, laid down before the picture. |
| Split fountain | Per ink, in Customize. Blends the channel's ink into a second one across the sheet. |
| Shuffle (R) | Keeps your photo, reprints it with a new look, inks, screen and frame. The inks come from a list of classic riso pairings four times in ten, from a Poster pick three, and from chance the rest; now and then it floods the sheet or splits a fountain. |
| Misregistration | Shifts every ink but the first, the way a second pass through the drum would. |
| Ink fade | The drum running low: density falls away toward the trailing edge, unevenly. |
| Roller streaks | Bands of heavier and lighter ink across the feed, at the drum's rhythm. |
| Highlight knockout | Prints no ink below a chosen coverage, so the lightest tones stay clean paper. Worth having on with dithers. |
| Marks | Crop marks at the corners, and optionally registration targets on each side. They go down with every ink, so with misregistration on you can see the targets split, as on a real proof. Separations carry them too. |
| Hand-cut edge | Roughens the edge of the ink block, like paper torn against a ruler. |
| Surround | Dark, neutral grey or white behind the sheet. The grey is the ISO 3664 viewing grey you'd judge a real proof against. |
| Save separations | One PNG per ink, ready to take to an actual press, plus one for the flood if there is one. |
Your photo and settings are kept in your browser and restored when you come back.
git clone https://github.com/chuwd19/overprint.git
cd overprint
./run.sh # serves on :8123 and opens a browserES modules need a real origin, so opening index.html straight from the Finder won't
work; hence the tiny server. Any browser with WebGL2 will do (Safari 15+, Chrome,
Firefox, and mobile Safari).
To host your own copy, push it anywhere that serves static files. There's no build step; the app itself is 181 KB, plus 66 KB of typeface.
The part worth reading about is the separation, because it's where a print simulator usually goes wrong.
Ink doesn't work like a filter. The obvious model is Beer–Lambert: light passes
through ink and gets attenuated, so density adds up. That's true of a solid film, but a
screened tint isn't a film. It's a scattering of solid dots with bare paper between
them, and what you see is the average. So reflectance is linear in coverage, not
exponential. And a dot isn't a pure filter either: ink has hiding power, so a solid
dot is the base seen through the ink, pulled toward the ink's own colour. With inks laid
down in printing order on paper
for ink colour
Riso inks are glazes, so most sit near that; light inks carry white pigment and hide more, and White is mostly cover, which is the only reason it does anything on black stock.
Getting from a photo to those coverages means inverting the model, a small nonlinear
least-squares problem solved per pixel: a least-squares seed from the model linearised
around the paper, then four Gauss–Newton steps on the relative residual
It's worth doing properly. The first version used plain Beer–Lambert and mid-grey came
out as #c3adb6, visibly washed out, with every photo needing the density cranked by
hand. With the correct model, a single black ink reproduces a grey ramp exactly.
Screening keeps tone honest. A dot's radius is derived from the coverage
Error diffusion can't run in a fragment shader. Every other screen decides each
pixel on its own, so the whole print is one pass of one shader. Atkinson and
Floyd–Steinberg need to know what their neighbours decided, so the renderer takes a
detour: it runs the same shader with one fragment per art pixel and asks only for that
ink's coverage, reads the result back, diffuses it on the CPU (src/dither.js, which has
no browser dependencies), and hands the 1-bit map back to the print pass as a texture.
The art-pixel grid is sized in physical units like everything else, so the preview and a
3× export have the same number of art pixels and the same dither pattern.
Everything is in physical units. Frequency is lines per inch against a 7-inch long edge, so a 3× export has the same physical dot size as what you saw on screen, just with more pixels describing it.
Auto-pick is a search, not a heuristic. It scores every candidate ink set by how closely it reproduces a sample of your photo's pixels, in Oklab. Every pair and every triple is screened on a few of the samples, the survivors re-scored on all of them, and the best triple is refined slot by slot until no swap helps; on the 26-ink pool that lands on the same answer as an exhaustive search. It runs in a worker so the page stays live. The pool is the common drum colours rather than the full library: the tints and greys add little, and White in particular would be chosen on pale stock to brighten highlights beyond the paper, which the model allows and no print does. Shuffle draws from the same pool, and waits for the fit before it changes the print.
Faithful is the wrong target for riso. Minimising reproduction error picks the inks
that best reproduce the photograph, and a riso print is good precisely because it
doesn't: two bold inks far apart on the wheel, one dark to draw with. Left to fidelity
alone, the search drifts toward process-like triads that reproduce well and look like a
mediocre CMYK print. So Poster mode multiplies each candidate's reproduction error
by 1 + 2.5 × taste, where taste is a penalty computed from the inks alone, before
any photo is considered: it rises if no ink is dark enough to carry the structure, if
the chromatic inks sit close on the wheel, if they are pale, if two of them are
neutral, if they are all the same lightness, or if their full overprint is mid-toned
mud rather than a clean dark. Zero for Black + Fluo Pink; 1.7 for Brown + Light Gray.
The weight is set where a pale summer landscape stops being picked as three pastels
and before every third photo comes back as the same Blue + Fluo Pink + Yellow. On the
demo photograph the two modes differ mildly on two inks — Blue + Scarlet against Medium
Blue + Orange — and plainly on three: Faithful takes Blue + Crimson + Orange, which nails the
mats and puts two inks 26° apart; Poster takes Blue + Fluo Pink + Yellow, a hair less
faithful and a great deal more like a print.
The classic pairings Shuffle deals from are listed in CURATED in
src/inks.js: seven single inks, twenty pairs and ten triads a print
shop reaches for without thinking. Poster mode tends to land on them of its own accord,
which is a reasonable check that the penalty is measuring the right thing.
index.html page shell
src/style.css interface
src/fonts/ Archivo, self-hosted (SIL OFL)
src/color.js sRGB ↔ linear, Oklab, small matrix inverse
src/inks.js ink library, paper stocks, classic pairings, presets
src/film.js the 122 looks, the grade that bakes them, and the .cube parser
src/separate.js the separator and Auto-pick; runs standalone in node, too
src/autopick-worker.js Auto-pick off the main thread
src/dither.js error-diffusion screens; also standalone
src/render.js WebGL2 renderer and the shader
src/tour.js the optional first-visit walkthrough
src/app.js state, controls, export, session storage
docs/images/ everything on this page, including the source scan
src/separate.js and src/film.js have no browser dependencies, so you can import them
in node and check the colour maths for yourself — including baking a look's table and
reading values out of it.
Bleed and true-size imposition. Saving your own presets, deliberately: the site keeps no account and records nothing. Metallic inks print as flat colour; there is no sheen.
Filters do not stack within a stage: one pre-press and one post-press is the most you get, and there are no blend modes between them. Film grain is not among them either — a look is a colour table and grain is spatial, so it would have to be its own thing; the paper grain and the ink mottle under Paper and Press are the grain this app has.
The page loads GoatCounter, which records one hit per visit: the path, the referrer, and a coarse browser and country. No cookies, no fingerprint, no identifier that persists between visits, and nothing at all about your photograph — the picture never leaves your machine, so there is nothing about it to send. The counts go to the same place as the rest of chuwd19.github.io.
The script is async and entirely separate from the press. Block it, and the
app works exactly the same; the two lines that load it are the last thing in
index.html, so deleting them is the whole removal.
MIT; see LICENSE. The demo photograph is public domain (see Credits).
Inspired by tone and BitCam. tone does the same job on iOS and is worth your money if you're on a phone. BitCam, the Iconfactory's (now Héliographe's) Macintosh camera, is where the dithers, the pixel sizes and the idea of a gradient behind or inside a 1-bit picture come from. It does one thing and does it perfectly.
The ink colours are the Riso drum list kept by stencil.wiki, as collected in riso-colors by Matt DesLauriers.
The interface is set in Archivo by Omnibus-Type, under the SIL Open Font License, and served with the page rather than from a font CDN.
The demo photograph is Grand Prismatic Spring, Yellowstone National Park, by Jim Peaco for the National Park Service. As a work of the US government it is in the public domain. It was picked because it is an ordinary photograph that happens to run the whole way from deep blue through cyan, green and yellow to rust, which is exactly the kind of thing a three-ink press has to fight.




































