DEV Community

Yan Wang
Yan Wang

Posted on AI-assisted

A scan is never perfectly square

The previous problem I wrote about was finding the four corners of a page in a photo. This one is the inverse, and it turned out to be more interesting: take a page that is already perfect — a vector PDF, crisp text, exact margins — and make it look like it came off an office scanner.

No corner detection. No straightening. No cropping. The output canvas keeps the exact width and height of the input, and the geometry contract is that nothing moves out of the frame. Disclosure: this is from LensUp, ours, and this piece is about the five small effects that do the work and the one bug that hid inside the first of them.

1. Tilt: never zero, never much, and never random

The single strongest cue that something was scanned is that it is slightly crooked. A page laid on a flatbed by a human hand is never perfectly square with the sensor. So:

/** The tilt a page gets: never zero (a scan is never perfectly square), never over 0.6°, and the
 *  same every time the same page renders so a re-render does not shuffle the result. */
export function scannedTilt(w, h) {
  const unit = scannedRandom(w * 131 + h * 71)() * 2 - 1;       // -1 … 1
  const degrees = Math.sign(unit || 1) * (0.25 + Math.abs(unit) * 0.35); // 0.25° … 0.6°
  return degrees * Math.PI / 180;
}
Enter fullscreen mode Exit fullscreen mode

Three decisions in five lines.

Never zero. 0.25 + |unit| * 0.35 has a floor, so the tilt lands in 0.25°–0.6° and never in the middle. A page at exactly 0.00° reads as generated, because nothing physical is ever exactly anything.

Never much. Above roughly a degree it stops looking like a scanner and starts looking like a mistake.

Never Math.random(). This is the one I would have got wrong. The seed is w * 131 + h * 71 — the page's own dimensions — so the same page renders with the same tilt every single time. In a document tool, re-rendering is constant: the user toggles a setting, changes paper size, scrolls back. If the tilt reshuffles on every render, the preview shivers and the exported file does not match what was on screen. Determinism here is not a purity preference, it is the difference between a tool that feels solid and one that feels haunted.

2. The bug hiding in the tilt: rotating crops

Rotate a w × h image by any angle inside a w × h frame and the corners leave the frame. Everybody knows this in the abstract. At 0.6° it is easy to assume it is negligible.

It is not. Review caught it with a measurement: on a 1560 × 2200 page, a 0.6° tilt moved edge content 2–9 px out of frame. Nine pixels is a clipped page number, a truncated signature line, the last character of a table cell — on a document, silently.

The fix is the rotated bounding box:

/** The uniform scale that keeps a w×h page rotated by `angle` entirely inside a w×h frame: the
 *  rotated bounding box is w·cos+h·sin by w·sin+h·cos, and the page shrinks by the tighter ratio.
 *  For the tilts in use (≤ 0.6°) that is 0.98–0.995 — a hair of paper margin, never a crop. */
export function scannedFit(w, h, angle) {
  const c = Math.abs(Math.cos(angle)), s = Math.abs(Math.sin(angle));
  return Math.min(w / (w * c + h * s), h / (w * s + h * c), 1);
}
Enter fullscreen mode Exit fullscreen mode

Scale down by 0.98–0.995 before drawing, and the page corners land on paper-white margin instead of outside the canvas. That is also more realistic, not less: a scan of a page has a sliver of platen around it.

The Math.min(..., 1) clamp matters too — it guarantees the function can only ever shrink, so a future angle of 0 cannot accidentally enlarge and re-introduce the crop.

3. The pixel pass, and the one term that sells it

The geometry is half the effect. The rest is a single pass over the pixels:

const vignette = 1 - 0.08 * (dx * dx + dy * dy);
let g = 0.299 * pixels[i] + 0.587 * pixels[i+1] + 0.114 * pixels[i+2];
g = 20 + g * 0.9;                             // lifted blacks, paper not quite white
g = 128 + (g - 128) * 0.94;                   // softened contrast
g = g * vignette + (random() - 0.5) * 14;     // vignette + grain
const warmth = Math.max(0, (g - 150) / 105);  // paper takes the tint, ink does not
const r = g + 2 * warmth, gr = g, b = g - 7 * warmth;
Enter fullscreen mode Exit fullscreen mode

Four of these are the obvious ones. Lifted blacks (20 + g * 0.9): a scanner never returns 0, and paper is never 255. Softened contrast (×0.94 around mid-grey): optics are not a renderer. Vignette, 8 % toward the corners. Grain, ±7 levels.

The fifth line is the one that makes it work, and it is the one I would have written wrong:

const warmth = Math.max(0, (g - 150) / 105);
Enter fullscreen mode Exit fullscreen mode

The naive way to warm a scan is a global sepia tint — add red, remove blue, everywhere. It looks instantly fake, and it took me a while to articulate why: a real scanner warms the paper, not the ink. The warm cast comes from the platen lamp reflecting off the sheet; black toner reflects almost nothing, so it stays neutral.

So the tint is gated on brightness. Below 150 there is no warmth at all; from 150 to 255 it ramps to full. Ink keeps its neutral grey, the paper goes cream, and the result stops looking like a filter.

4. Reproducible grain

Grain needs randomness, and the determinism requirement from section 1 applies to it too. So the noise comes from a seeded PRNG — mulberry32, nine lines, no dependency:

function scannedRandom(seed) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6D2B79F5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
Enter fullscreen mode Exit fullscreen mode

Seeded per page as w * 31 + h * 17, so the grain pattern is stable across re-renders, and the pixel function is pure: scannedLookPixels(pixels, w, h, seed) takes a buffer and a seed and returns the same buffer every time. That makes it directly unit-testable, which a canvas effect otherwise is not.

5. Where this stops

It is worth being exact about the limit, because this category attracts products that are not exact about it.

Text becomes pixels. That is the entire point of the effect — a flattened page is what a scan is — and it is also its cost: the output is an image-based PDF, and text in it is no longer selectable. That belongs in the limitations, not in the fine print.

And the effect changes how a page looks, nothing else. It does not sign anything, date anything, certify anything, or make a file into an original. If a form asks for a signed and scanned copy, the requirement is about the signature: sign the page, then scan or photograph it. Presentation and content are different layers, and the honest version of this tool is the one that only touches the first.

The legitimate reasons to want it are mundane and real: upload portals that accept only image PDFs and reject a text PDF with identical content; an archive of real scans where one crisp vector page looks out of place; a page that should not be quietly editable afterwards. In all of those the content is unchanged and true, and only the presentation matches what the receiver expects.

The part I would take elsewhere

Two things generalise beyond making pages look scanned.

Deterministic "randomness" is usually what you want in a document pipeline. Anything the user might re-render should look identical when it does. Seed from the content's own dimensions and the problem disappears.

Small rotations crop. If you rotate any raster inside a fixed frame and do not compute the rotated bounding box, you are losing edge pixels — a handful at a fraction of a degree, which is exactly the amount nobody checks for and a document cannot afford.

If you want to see the effect on a page of your own, it is at a PDF-to-scanned-PDF converter — it runs in the tab and files are never uploaded, so the Network panel is a fair way to check that.

Top comments (0)