← All components
Week 5 · 2026-10-08

Document Scanner

Turn a phone photo of a page into a flat, clean scan: auto-detected corners, perspective correction, shadow-removing B&W filter and multi-page PDF export, all in the browser.

Document scanner

Turn a phone photo of a page into a flat, clean scan. Nothing leaves your device.

Source

Demo.tsx
import DocScanner from "./DocScanner";

export default function Demo() {
  return <DocScanner />;
}
DocScanner.module.css
.card {
  --surface: #fcfcfb;
  --ink: #0b0b0b;
  --ink-2: #52514e;
  --muted: #898781;
  --border: rgba(11, 11, 11, 0.1);
  --field: #f4f3f0;
  --accent: #2a78d6;
  --handle: #4cc9f0;

  width: 100%;
  max-width: 900px;
  padding: 22px;
  border: 1px solid var(--border);
  border-radius: 20px;
  background: var(--surface);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 12px 32px -12px rgba(0, 0, 0, 0.18);
  color: var(--ink);
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
  container-type: inline-size;
}

@media (prefers-color-scheme: dark) {
  .card {
    --surface: #1a1a19;
    --ink: #ffffff;
    --ink-2: #c3c2b7;
    --border: rgba(255, 255, 255, 0.1);
    --field: #232321;
    --accent: #3987e5;
    box-shadow: 0 12px 32px -12px rgba(0, 0, 0, 0.6);
  }
}

.head { margin-bottom: 16px; }
.title { margin: 0; font-size: 17px; font-weight: 650; }
.subtitle { margin: 2px 0 0; color: var(--ink-2); font-size: 13px; }

/* Empty state */
.dropzone {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: 44px 16px;
  border: 1.5px dashed color-mix(in srgb, var(--ink) 22%, transparent);
  border-radius: 14px;
  text-align: center;
  cursor: pointer;
  transition: border-color 0.15s ease, background 0.15s ease;
}
.dropzone:hover,
.dropzone[data-over="true"] { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 6%, transparent); }
.dropzone:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
.fileInput { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.dropIcon { fill: none; stroke: var(--accent); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.dropTitle { font-size: 14px; font-weight: 600; }
.dropNote { color: var(--muted); font-size: 12px; }
.sampleLink {
  margin-top: 6px;
  padding: 0;
  border: 0;
  background: none;
  color: var(--accent);
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}

/* Two panels */
.body { display: grid; grid-template-columns: 1fr 1fr; align-items: start; gap: 22px; }
@container (max-width: 680px) {
  .body { grid-template-columns: 1fr; }
}
.panel { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.panelHead { display: flex; align-items: center; gap: 8px; font-size: 13.5px; font-weight: 600; }
.step {
  display: grid;
  place-items: center;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--ink);
  color: var(--surface);
  font-size: 11px;
}
.meta { margin-left: auto; color: var(--muted); font-size: 12px; font-weight: 400; font-variant-numeric: tabular-nums; }

/* Corner editor */
.editor {
  position: relative;
  width: 100%;
  margin: 0 auto;
  overflow: visible;
  border-radius: 8px;
  touch-action: none;
  user-select: none;
}
.photo { display: block; width: 100%; height: 100%; border-radius: 8px; }
.overlay { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 8px; pointer-events: none; }
.shade { fill: rgba(0, 0, 0, 0.5); }
.outline { fill: rgba(76, 201, 240, 0.08); stroke: var(--handle); stroke-width: 2; stroke-linejoin: round; vector-effect: non-scaling-stroke; }

.handle {
  position: absolute;
  width: 26px;
  height: 26px;
  padding: 0;
  border: 3px solid #fff;
  border-radius: 50%;
  background: var(--handle);
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3), 0 4px 10px rgba(0, 0, 0, 0.35);
  cursor: grab;
  transform: translate(-50%, -50%);
  touch-action: none;
  transition: transform 0.12s ease;
}
.handle:hover,
.handle[data-active="true"] { transform: translate(-50%, -50%) scale(1.15); }
.handle[data-active="true"] { cursor: grabbing; }
.handle:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }

.loupe {
  position: absolute;
  z-index: 3;
  width: 120px;
  height: 120px;
  border: 3px solid #fff;
  border-radius: 50%;
  box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
  pointer-events: none;
  transform: translate(-50%, calc(-100% - 26px));
}
.loupe[data-below="true"] { transform: translate(-50%, 26px); }

.row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.chip {
  padding: 6px 11px;
  border: 1px solid var(--border);
  border-radius: 99px;
  background: none;
  color: var(--ink-2);
  font: inherit;
  font-size: 12.5px;
  cursor: pointer;
}
.chip:hover { background: var(--field); color: var(--ink); }
.chip:focus-visible,
.segmented button:focus-visible,
.actions button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Result */
.resultBox {
  display: grid;
  place-items: center;
  min-height: 220px;
  padding: 12px;
  border-radius: 10px;
  background: repeating-linear-gradient(45deg, var(--field) 0 10px, transparent 10px 20px);
  transition: opacity 0.2s ease;
}
.resultBox[data-busy="true"] { opacity: 0.55; }
.result {
  display: block;
  max-width: 100%;
  max-height: 420px;
  background: #fff;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1), 0 8px 24px -8px rgba(0, 0, 0, 0.35);
}

.segmented { display: inline-flex; flex-wrap: wrap; gap: 2px; padding: 3px; border-radius: 10px; background: var(--field); }
.segmented button {
  padding: 5px 10px;
  border: 0;
  border-radius: 8px;
  background: none;
  color: var(--ink-2);
  font: inherit;
  font-size: 12.5px;
  cursor: pointer;
}
.segmented button[aria-checked="true"] { background: var(--surface); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); color: var(--ink); font-weight: 600; }

.actions { display: flex; flex-wrap: wrap; gap: 6px; }
.actions button { height: 38px; padding: 0 14px; border-radius: 10px; font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer; }
.actions button:disabled { opacity: 0.5; cursor: default; }
.secondary { border: 1px solid var(--border); background: none; color: var(--ink); }
.secondary:hover:not(:disabled) { background: var(--field); }
.primary { flex: 1; border: 0; background: var(--ink); color: var(--surface); }
.primary:hover:not(:disabled) { opacity: 0.88; }

.pages { display: flex; gap: 8px; margin: 0; padding: 2px 0 4px; overflow-x: auto; list-style: none; }
.pages li { position: relative; flex: none; }
.pages img { display: block; height: 76px; border-radius: 4px; background: #fff; box-shadow: 0 0 0 1px var(--border); }
.pages span {
  position: absolute;
  bottom: 4px;
  left: 4px;
  padding: 0 5px;
  border-radius: 4px;
  background: rgba(0, 0, 0, 0.65);
  color: #fff;
  font-size: 10.5px;
}
.pages button {
  position: absolute;
  top: -6px;
  right: -6px;
  width: 20px;
  height: 20px;
  padding: 0;
  border: 2px solid var(--surface);
  border-radius: 50%;
  background: var(--ink);
  color: var(--surface);
  font-size: 12px;
  line-height: 1;
  cursor: pointer;
}
DocScanner.tsx
"use client";

import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
import { applyFilter, type Filter } from "./filters";
import { detectPage, flatten, quadSize, type Quad } from "./geometry";
import { makePdf } from "./pdf";
import { drawSamplePhoto } from "./sample";
import styles from "./DocScanner.module.css";

type Work = { canvas: HTMLCanvasElement; data: ImageData; w: number; h: number; url: string };
type Page = { blob: Blob; url: string; width: number; height: number };
type Shape = "auto" | "a4" | "id";

const MAX_WORK = 2000; // longest side of the working copy
const MAX_OUTPUT = 1600; // longest side of the scan
const LOUPE = 120;
const LOUPE_ZOOM = 3;

const FILTERS: { key: Filter; label: string }[] = [
  { key: "original", label: "Original" },
  { key: "enhanced", label: "Enhanced" },
  { key: "grayscale", label: "Grayscale" },
  { key: "bw", label: "B&W scan" },
];
const SHAPES: { key: Shape; label: string }[] = [
  { key: "auto", label: "Auto" },
  { key: "a4", label: "A4" },
  { key: "id", label: "ID card" },
];

const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));

function insetQuad(w: number, h: number, f = 0.06): Quad {
  return [
    { x: w * f, y: h * f },
    { x: w * (1 - f), y: h * f },
    { x: w * (1 - f), y: h * (1 - f) },
    { x: w * f, y: h * (1 - f) },
  ];
}

function guessCorners(work: Work): Quad {
  const s = Math.min(1, 240 / Math.max(work.w, work.h));
  const small = document.createElement("canvas");
  small.width = Math.round(work.w * s);
  small.height = Math.round(work.h * s);
  const ctx = small.getContext("2d", { willReadFrequently: true })!;
  ctx.drawImage(work.canvas, 0, 0, small.width, small.height);
  const quad = detectPage(ctx.getImageData(0, 0, small.width, small.height));
  return quad ? (quad.map((p) => ({ x: p.x / s, y: p.y / s })) as Quad) : insetQuad(work.w, work.h);
}

function outputSize(quad: Quad, shape: Shape, imageW: number, imageH: number) {
  let { w, h } = quadSize(quad, imageW, imageH);
  const landscape = w >= h;
  if (shape === "a4") {
    const r = Math.SQRT2;
    if (landscape) h = w / r;
    else h = w * r;
  }
  if (shape === "id") {
    const r = 85.6 / 53.98;
    if (landscape) h = w / r;
    else h = w * r;
  }
  const s = Math.min(1, MAX_OUTPUT / Math.max(w, h));
  return { w: Math.max(1, Math.round(w * s)), h: Math.max(1, Math.round(h * s)) };
}

function download(blob: Blob, name: string) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = name;
  a.click();
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}

const toBlob = (canvas: HTMLCanvasElement, type: string, quality?: number) =>
  new Promise<Blob>((resolve) => canvas.toBlob((b) => resolve(b!), type, quality));

export default function DocScanner() {
  const [work, setWork] = useState<Work | null>(null);
  const [corners, setCorners] = useState<Quad | null>(null);
  const [dragging, setDragging] = useState<number | null>(null);
  const [filter, setFilter] = useState<Filter>("bw");
  const [shape, setShape] = useState<Shape>("auto");
  const [rotation, setRotation] = useState(0);
  const [processing, setProcessing] = useState(false);
  const [resultSize, setResultSize] = useState<{ w: number; h: number } | null>(null);
  const [pages, setPages] = useState<Page[]>([]);
  const [dragOver, setDragOver] = useState(false);

  const editor = useRef<HTMLDivElement>(null);
  const result = useRef<HTMLCanvasElement>(null);
  const loupe = useRef<HTMLCanvasElement>(null);

  async function load(file: Blob) {
    const url = URL.createObjectURL(file);
    const img = new Image();
    // onload rather than img.decode(): decode() can stall in background tabs.
    await new Promise<void>((resolve, reject) => {
      img.onload = () => resolve();
      img.onerror = () => reject(new Error("Could not read that image"));
      img.src = url;
    });
    const s = Math.min(1, MAX_WORK / Math.max(img.naturalWidth, img.naturalHeight));
    const canvas = document.createElement("canvas");
    canvas.width = Math.round(img.naturalWidth * s);
    canvas.height = Math.round(img.naturalHeight * s);
    const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
    ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
    const next: Work = { canvas, data: ctx.getImageData(0, 0, canvas.width, canvas.height), w: canvas.width, h: canvas.height, url };
    setWork((prev) => {
      if (prev) URL.revokeObjectURL(prev.url);
      return next;
    });
    setCorners(guessCorners(next));
    setRotation(0);
  }

  // Flatten + filter whenever the inputs settle (not while a corner is being dragged).
  useEffect(() => {
    if (!work || !corners || dragging !== null) return;
    setProcessing(true);
    const timer = setTimeout(() => {
      const { w, h } = outputSize(corners, shape, work.w, work.h);
      const flat = applyFilter(flatten(work.data, corners, w, h), filter);
      const tmp = document.createElement("canvas");
      tmp.width = w;
      tmp.height = h;
      tmp.getContext("2d")!.putImageData(flat, 0, 0);

      const out = result.current;
      if (!out) return;
      const quarter = rotation % 2 === 1;
      out.width = quarter ? h : w;
      out.height = quarter ? w : h;
      const ctx = out.getContext("2d")!;
      ctx.save();
      ctx.translate(out.width / 2, out.height / 2);
      ctx.rotate((rotation * Math.PI) / 2);
      ctx.drawImage(tmp, -w / 2, -h / 2);
      ctx.restore();
      setResultSize({ w: out.width, h: out.height });
      setProcessing(false);
    }, 30);
    return () => clearTimeout(timer);
  }, [work, corners, dragging, filter, shape, rotation]);

  // Magnifier around the corner being dragged.
  useEffect(() => {
    if (dragging === null || !work || !corners || !loupe.current || !editor.current) return;
    const ctx = loupe.current.getContext("2d")!;
    const scale = editor.current.getBoundingClientRect().width / work.w;
    const r = LOUPE / 2 / (scale * LOUPE_ZOOM);
    const { x, y } = corners[dragging];
    ctx.fillStyle = "#000";
    ctx.fillRect(0, 0, LOUPE, LOUPE);
    ctx.drawImage(work.canvas, x - r, y - r, r * 2, r * 2, 0, 0, LOUPE, LOUPE);
    ctx.strokeStyle = "rgba(76, 201, 240, 0.95)";
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.moveTo(LOUPE / 2, 0);
    ctx.lineTo(LOUPE / 2, LOUPE);
    ctx.moveTo(0, LOUPE / 2);
    ctx.lineTo(LOUPE, LOUPE / 2);
    ctx.stroke();
  }, [dragging, corners, work]);

  useEffect(() => () => pages.forEach((p) => URL.revokeObjectURL(p.url)), []); // eslint-disable-line react-hooks/exhaustive-deps

  function pointToImage(e: PointerEvent) {
    const rect = editor.current!.getBoundingClientRect();
    return {
      x: clamp(((e.clientX - rect.left) / rect.width) * work!.w, 0, work!.w),
      y: clamp(((e.clientY - rect.top) / rect.height) * work!.h, 0, work!.h),
    };
  }

  function moveCorner(i: number, p: { x: number; y: number }) {
    setCorners((q) => (q ? (q.map((c, j) => (j === i ? p : c)) as Quad) : q));
  }

  function onHandleKey(i: number, e: KeyboardEvent) {
    const steps: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
    const step = steps[e.key];
    if (!step || !work || !corners || !editor.current) return;
    e.preventDefault();
    const px = (work.w / editor.current.getBoundingClientRect().width) * (e.shiftKey ? 10 : 1);
    moveCorner(i, { x: clamp(corners[i].x + step[0] * px, 0, work.w), y: clamp(corners[i].y + step[1] * px, 0, work.h) });
  }

  async function addPage() {
    if (!result.current || processing) return;
    const blob = await toBlob(result.current, "image/jpeg", 0.9);
    setPages((p) => [...p, { blob, url: URL.createObjectURL(blob), width: result.current!.width, height: result.current!.height }]);
  }

  async function exportPdf() {
    if (!result.current) return;
    const source: Page[] = pages.length
      ? pages
      : [{ blob: await toBlob(result.current, "image/jpeg", 0.9), url: "", width: result.current.width, height: result.current.height }];
    const pdf = makePdf(
      await Promise.all(source.map(async (p) => ({ jpeg: new Uint8Array(await p.blob.arrayBuffer()), width: p.width, height: p.height }))),
    );
    download(pdf, `scan-${new Date().toISOString().slice(0, 10)}.pdf`);
  }

  async function exportPng() {
    if (result.current) download(await toBlob(result.current, "image/png"), "scan.png");
  }

  const quadPath = corners?.map((p, i) => `${i ? "L" : "M"}${p.x},${p.y}`).join(" ") + " Z";
  const active = dragging !== null && corners ? corners[dragging] : null;

  return (
    <div className={styles.card}>
      <header className={styles.head}>
        <h3 className={styles.title}>Document scanner</h3>
        <p className={styles.subtitle}>Turn a phone photo of a page into a flat, clean scan. Nothing leaves your device.</p>
      </header>

      {!work || !corners ? (
        <label
          className={styles.dropzone}
          data-over={dragOver}
          onDragOver={(e) => {
            e.preventDefault();
            setDragOver(true);
          }}
          onDragLeave={() => setDragOver(false)}
          onDrop={(e) => {
            e.preventDefault();
            setDragOver(false);
            const file = e.dataTransfer.files[0];
            if (file?.type.startsWith("image/")) load(file);
          }}
        >
          <input type="file" accept="image/*" className={styles.fileInput} onChange={(e) => e.target.files?.[0] && load(e.target.files[0])} />
          <svg viewBox="0 0 24 24" width="30" height="30" aria-hidden="true" className={styles.dropIcon}>
            <path d="M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3M7 12h10" />
          </svg>
          <span className={styles.dropTitle}>Drop a photo of a document, or click to choose</span>
          <span className={styles.dropNote}>Letters, receipts, certificates, ID cards</span>
          <button
            type="button"
            className={styles.sampleLink}
            onClick={async (e) => {
              e.preventDefault();
              load(await drawSamplePhoto());
            }}
          >
            Try a sample photo
          </button>
        </label>
      ) : (
        <div className={styles.body}>
          <section className={styles.panel} aria-label="Adjust corners">
            <div className={styles.panelHead}>
              <span className={styles.step}>1</span>
              <span>Adjust the corners</span>
            </div>
            <div
              ref={editor}
              className={styles.editor}
              style={{ aspectRatio: `${work.w} / ${work.h}`, maxWidth: `calc(420px * ${work.w / work.h})` }}
            >
              {/* eslint-disable-next-line @next/next/no-img-element */}
              <img src={work.url} alt="Original photo" className={styles.photo} draggable={false} />
              <svg className={styles.overlay} viewBox={`0 0 ${work.w} ${work.h}`} preserveAspectRatio="none" aria-hidden="true">
                <path d={`M0,0 H${work.w} V${work.h} H0 Z ${quadPath}`} fillRule="evenodd" className={styles.shade} />
                <path d={quadPath} className={styles.outline} />
              </svg>
              {corners.map((p, i) => (
                <button
                  key={i}
                  type="button"
                  className={styles.handle}
                  data-active={dragging === i}
                  style={{ left: `${(p.x / work.w) * 100}%`, top: `${(p.y / work.h) * 100}%` }}
                  aria-label={`${["Top-left", "Top-right", "Bottom-right", "Bottom-left"][i]} corner. Use arrow keys to move.`}
                  onPointerDown={(e) => {
                    e.currentTarget.setPointerCapture(e.pointerId);
                    setDragging(i);
                  }}
                  onPointerMove={(e) => dragging === i && moveCorner(i, pointToImage(e))}
                  onPointerUp={() => setDragging(null)}
                  onPointerCancel={() => setDragging(null)}
                  onKeyDown={(e) => onHandleKey(i, e)}
                />
              ))}
              {active && (
                <canvas
                  ref={loupe}
                  width={LOUPE}
                  height={LOUPE}
                  className={styles.loupe}
                  data-below={active.y / work.h < 0.3}
                  style={{ left: `${(active.x / work.w) * 100}%`, top: `${(active.y / work.h) * 100}%` }}
                />
              )}
            </div>
            <div className={styles.row}>
              <button type="button" className={styles.chip} onClick={() => setCorners(guessCorners(work))}>
                Auto-detect
              </button>
              <button type="button" className={styles.chip} onClick={() => setCorners(insetQuad(work.w, work.h, 0))}>
                Whole photo
              </button>
              <button
                type="button"
                className={styles.chip}
                onClick={() => {
                  setWork(null);
                  setCorners(null);
                }}
              >
                New photo
              </button>
            </div>
          </section>

          <section className={styles.panel} aria-label="Scan result">
            <div className={styles.panelHead}>
              <span className={styles.step}>2</span>
              <span>Your scan</span>
              {resultSize && (
                <span className={styles.meta}>
                  {processing ? "Processing…" : `${resultSize.w} × ${resultSize.h} px`}
                </span>
              )}
            </div>
            <div className={styles.resultBox} data-busy={processing}>
              <canvas ref={result} className={styles.result} role="img" aria-label="Flattened scan" />
            </div>

            <div className={styles.segmented} role="radiogroup" aria-label="Filter">
              {FILTERS.map((f) => (
                <button key={f.key} type="button" role="radio" aria-checked={filter === f.key} onClick={() => setFilter(f.key)}>
                  {f.label}
                </button>
              ))}
            </div>
            <div className={styles.row}>
              <div className={styles.segmented} role="radiogroup" aria-label="Page shape">
                {SHAPES.map((s) => (
                  <button key={s.key} type="button" role="radio" aria-checked={shape === s.key} onClick={() => setShape(s.key)}>
                    {s.label}
                  </button>
                ))}
              </div>
              <button type="button" className={styles.chip} onClick={() => setRotation((r) => (r + 1) % 4)} aria-label="Rotate 90 degrees">
                ↻ Rotate
              </button>
            </div>

            <div className={styles.actions}>
              <button type="button" className={styles.secondary} onClick={addPage} disabled={processing}>
                + Add page{pages.length ? ` (${pages.length})` : ""}
              </button>
              <button type="button" className={styles.secondary} onClick={exportPng} disabled={processing}>
                PNG
              </button>
              <button type="button" className={styles.primary} onClick={exportPdf} disabled={processing}>
                Download PDF{pages.length > 1 ? ` · ${pages.length} pages` : ""}
              </button>
            </div>

            {pages.length > 0 && (
              <ol className={styles.pages} aria-label="Pages in the PDF">
                {pages.map((p, i) => (
                  <li key={p.url}>
                    {/* eslint-disable-next-line @next/next/no-img-element */}
                    <img src={p.url} alt={`Page ${i + 1}`} />
                    <span>{i + 1}</span>
                    <button
                      type="button"
                      aria-label={`Remove page ${i + 1}`}
                      onClick={() => {
                        URL.revokeObjectURL(p.url);
                        setPages((all) => all.filter((x) => x !== p));
                      }}
                    >
                      ×
                    </button>
                  </li>
                ))}
              </ol>
            )}
          </section>
        </div>
      )}
    </div>
  );
}
filters.ts
export type Filter = "original" | "enhanced" | "grayscale" | "bw";

const luma = (d: Uint8ClampedArray, i: number) => d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114;

/** Stretches contrast so the darkest 1% become black and the brightest 1% become white. */
function autoLevels(img: ImageData) {
  const d = img.data;
  const hist = new Array<number>(256).fill(0);
  for (let i = 0; i < d.length; i += 4) hist[luma(d, i) | 0]++;
  const total = d.length / 4;
  let lo = 0;
  let hi = 255;
  for (let acc = 0; lo < 255 && (acc += hist[lo]) < total * 0.01; lo++);
  for (let acc = 0; hi > 0 && (acc += hist[hi]) < total * 0.01; hi--);
  const range = Math.max(1, hi - lo);
  for (let i = 0; i < d.length; i += 4) {
    for (let c = 0; c < 3; c++) d[i + c] = ((d[i + c] - lo) * 255) / range;
  }
}

/** Sum of luma over every rectangle in O(1): integral[(y)*(w+1)+x] = sum above-left of (x, y). */
function lumaIntegral(img: ImageData) {
  const { width: w, height: h, data: d } = img;
  const integral = new Float64Array((w + 1) * (h + 1));
  for (let y = 0; y < h; y++) {
    let row = 0;
    for (let x = 0; x < w; x++) {
      row += luma(d, (y * w + x) * 4);
      integral[(y + 1) * (w + 1) + x + 1] = integral[y * (w + 1) + x + 1] + row;
    }
  }
  return integral;
}

function localMean(integral: Float64Array, w: number, h: number, x: number, y: number, half: number) {
  const x0 = Math.max(0, x - half);
  const x1 = Math.min(w, x + half + 1);
  const y0 = Math.max(0, y - half);
  const y1 = Math.min(h, y + half + 1);
  const sum = integral[y1 * (w + 1) + x1] - integral[y0 * (w + 1) + x1] - integral[y1 * (w + 1) + x0] + integral[y0 * (w + 1) + x0];
  return sum / ((x1 - x0) * (y1 - y0));
}

/**
 * "Magic colour": estimate the bare paper colour everywhere (lighting + tint), then divide it out,
 * so the paper becomes evenly white while coloured ink like signatures and stamps keeps its colour.
 * The estimate is a tiny copy of the page, "dilated" with lighten-blending so dark ink drops out,
 * then scaled back up smoothly.
 */
function whitenPaper(img: ImageData) {
  const { width: w, height: h, data: d } = img;
  const full = document.createElement("canvas");
  full.width = w;
  full.height = h;
  full.getContext("2d")!.putImageData(img, 0, 0);

  const sw = Math.max(4, Math.round(w / 20));
  const sh = Math.max(4, Math.round(h / 20));
  const small = document.createElement("canvas");
  small.width = sw;
  small.height = sh;
  const sctx = small.getContext("2d", { willReadFrequently: true })!;
  sctx.drawImage(full, 0, 0, sw, sh);
  // Local maximum (paper is the brightest thing nearby): blend shifted copies with "lighten".
  sctx.globalCompositeOperation = "lighten";
  for (let pass = 0; pass < 2; pass++) {
    const copy = document.createElement("canvas");
    copy.width = sw;
    copy.height = sh;
    copy.getContext("2d")!.drawImage(small, 0, 0);
    for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) sctx.drawImage(copy, dx, dy);
  }

  const bg = document.createElement("canvas");
  bg.width = w;
  bg.height = h;
  const bctx = bg.getContext("2d", { willReadFrequently: true })!;
  bctx.imageSmoothingQuality = "high";
  bctx.filter = "blur(4px)";
  bctx.drawImage(small, 0, 0, w, h);
  const paper = bctx.getImageData(0, 0, w, h).data;

  for (let i = 0; i < d.length; i += 4) {
    for (let c = 0; c < 3; c++) d[i + c] = (d[i + c] / Math.max(30, paper[i + c])) * 255;
  }
}

function grayscale(img: ImageData) {
  const d = img.data;
  for (let i = 0; i < d.length; i += 4) d[i] = d[i + 1] = d[i + 2] = luma(d, i);
}

/**
 * Bradley adaptive threshold: each pixel is compared with the average of its neighbourhood
 * (from an integral image), so uneven lighting and shadows disappear.
 */
function adaptiveThreshold(img: ImageData) {
  const { width: w, height: h, data: d } = img;
  const integral = lumaIntegral(img);
  const half = Math.max(8, Math.round(w / 32));
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const i = (y * w + x) * 4;
      d[i] = d[i + 1] = d[i + 2] = luma(d, i) < localMean(integral, w, h, x, y, half) * 0.86 ? 0 : 255;
    }
  }
}

export function applyFilter(img: ImageData, filter: Filter) {
  if (filter === "enhanced") whitenPaper(img);
  if (filter === "grayscale") {
    grayscale(img);
    autoLevels(img);
  }
  if (filter === "bw") adaptiveThreshold(img);
  return img;
}
geometry.ts
export type Point = { x: number; y: number };
/** Corners in order: top-left, top-right, bottom-right, bottom-left. */
export type Quad = [Point, Point, Point, Point];

/** Solves A·x = b with Gaussian elimination and partial pivoting. */
function solve(A: number[][], b: number[]) {
  const n = b.length;
  const M = A.map((row, i) => [...row, b[i]]);
  for (let col = 0; col < n; col++) {
    let pivot = col;
    for (let r = col + 1; r < n; r++) if (Math.abs(M[r][col]) > Math.abs(M[pivot][col])) pivot = r;
    [M[col], M[pivot]] = [M[pivot], M[col]];
    for (let r = col + 1; r < n; r++) {
      const f = M[r][col] / M[col][col];
      for (let c = col; c <= n; c++) M[r][c] -= f * M[col][c];
    }
  }
  const x = Array<number>(n).fill(0);
  for (let r = n - 1; r >= 0; r--) {
    let sum = M[r][n];
    for (let c = r + 1; c < n; c++) sum -= M[r][c] * x[c];
    x[r] = sum / M[r][r];
  }
  return x;
}

/** 3×3 perspective transform (row-major, h33 = 1) mapping each `from[i]` onto `to[i]`. */
export function homography(from: Point[], to: Point[]) {
  const A: number[][] = [];
  const b: number[] = [];
  for (let i = 0; i < 4; i++) {
    const { x, y } = from[i];
    const { x: u, y: v } = to[i];
    A.push([x, y, 1, 0, 0, 0, -u * x, -u * y]);
    b.push(u);
    A.push([0, 0, 0, x, y, 1, -v * x, -v * y]);
    b.push(v);
  }
  return [...solve(A, b), 1];
}

/**
 * For every pixel in the region of `dst`, looks up where it comes from in `src` through `H`
 * (dst → src) and samples bilinearly. Pixels that map outside `src` are left alone, or painted
 * white when `whiteOutside` is set.
 */
export function warp(
  src: ImageData,
  dst: ImageData,
  H: number[],
  region = { x0: 0, y0: 0, x1: dst.width, y1: dst.height },
  whiteOutside = true,
) {
  const s = src.data;
  const d = dst.data;
  const sw = src.width;
  const sh = src.height;
  for (let y = region.y0; y < region.y1; y++) {
    for (let x = region.x0; x < region.x1; x++) {
      const px = x + 0.5;
      const py = y + 0.5;
      const w = H[6] * px + H[7] * py + H[8];
      const sx = (H[0] * px + H[1] * py + H[2]) / w - 0.5;
      const sy = (H[3] * px + H[4] * py + H[5]) / w - 0.5;
      const o = (y * dst.width + x) * 4;
      if (sx < 0 || sy < 0 || sx >= sw - 1 || sy >= sh - 1) {
        if (whiteOutside) d[o] = d[o + 1] = d[o + 2] = d[o + 3] = 255;
        continue;
      }
      const x0 = sx | 0;
      const y0 = sy | 0;
      const fx = sx - x0;
      const fy = sy - y0;
      const i00 = (y0 * sw + x0) * 4;
      const i10 = i00 + 4;
      const i01 = i00 + sw * 4;
      const i11 = i01 + 4;
      for (let c = 0; c < 3; c++) {
        const top = s[i00 + c] + (s[i10 + c] - s[i00 + c]) * fx;
        const bottom = s[i01 + c] + (s[i11 + c] - s[i01 + c]) * fx;
        d[o + c] = top + (bottom - top) * fy;
      }
      d[o + 3] = 255;
    }
  }
  return dst;
}

const dist = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y);

type V3 = [number, number, number];
const cross = (a: V3, b: V3): V3 => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const dot = (a: V3, b: V3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];

/**
 * Real width ÷ height of the photographed rectangle, recovered from perspective
 * (Zhang & He, "Whiteboard scanning and image enhancement", 2006): estimate the camera's focal
 * length from the quad's vanishing geometry, assuming the optical centre is the image centre.
 * Returns null when the photo is too square-on for the maths to be stable.
 */
function perspectiveAspect([tl, tr, br, bl]: Quad, imageW: number, imageH: number) {
  const cx = imageW / 2;
  const cy = imageH / 2;
  const h = (p: Point): V3 => [p.x - cx, p.y - cy, 1];
  const m1 = h(tl);
  const m2 = h(tr);
  const m3 = h(bl);
  const m4 = h(br);
  const k2 = dot(cross(m1, m4), m3) / dot(cross(m2, m4), m3);
  const k3 = dot(cross(m1, m4), m2) / dot(cross(m3, m4), m2);
  const n2: V3 = [k2 * m2[0] - m1[0], k2 * m2[1] - m1[1], k2 * m2[2] - m1[2]];
  const n3: V3 = [k3 * m3[0] - m1[0], k3 * m3[1] - m1[1], k3 * m3[2] - m1[2]];
  if (Math.abs(n2[2]) < 1e-6 || Math.abs(n3[2]) < 1e-6) return null;
  const f2 = -(n2[0] * n3[0] + n2[1] * n3[1]) / (n2[2] * n3[2]);
  if (!(f2 > 0)) return null;
  const len = (n: V3) => (n[0] * n[0] + n[1] * n[1]) / f2 + n[2] * n[2];
  const ratio = Math.sqrt(len(n2) / len(n3));
  return Number.isFinite(ratio) && ratio > 0.1 && ratio < 10 ? ratio : null;
}

/**
 * Size of the flattened page. Width comes from the longer horizontal edge; height comes from the
 * perspective-recovered aspect ratio when available, otherwise from the longer vertical edge.
 */
export function quadSize(quad: Quad, imageW = 0, imageH = 0) {
  const [tl, tr, br, bl] = quad;
  const w = Math.max(dist(tl, tr), dist(bl, br));
  const h = Math.max(dist(tl, bl), dist(tr, br));
  const ratio = imageW && imageH ? perspectiveAspect(quad, imageW, imageH) : null;
  return ratio ? { w, h: w / ratio } : { w, h };
}

/** Flattens the area inside `quad` into a w×h image. */
export function flatten(src: ImageData, quad: Quad, w: number, h: number) {
  const rect = [
    { x: 0, y: 0 },
    { x: w, y: 0 },
    { x: w, y: h },
    { x: 0, y: h },
  ];
  return warp(src, new ImageData(w, h), homography(rect, quad));
}

/**
 * Guesses the page corners: Otsu-threshold a small greyscale copy, keep the largest bright blob,
 * then take its extreme points along the two diagonals. Returns null if nothing page-like is found.
 */
export function detectPage(img: ImageData): Quad | null {
  const { width: w, height: h, data } = img;
  const gray = new Uint8Array(w * h);
  const hist = new Array<number>(256).fill(0);
  for (let i = 0; i < w * h; i++) {
    const g = (data[i * 4] * 0.299 + data[i * 4 + 1] * 0.587 + data[i * 4 + 2] * 0.114) | 0;
    gray[i] = g;
    hist[g]++;
  }

  // Otsu: the threshold that best separates two brightness classes.
  let sumAll = 0;
  for (let t = 0; t < 256; t++) sumAll += t * hist[t];
  let sumB = 0;
  let wB = 0;
  let best = 0;
  let threshold = 128;
  for (let t = 0; t < 256; t++) {
    wB += hist[t];
    if (!wB) continue;
    const wF = w * h - wB;
    if (!wF) break;
    sumB += t * hist[t];
    const mB = sumB / wB;
    const mF = (sumAll - sumB) / wF;
    const between = wB * wF * (mB - mF) ** 2;
    if (between > best) {
      best = between;
      threshold = t;
    }
  }

  // Largest 4-connected bright component.
  const label = new Int32Array(w * h).fill(-1);
  const queue = new Int32Array(w * h);
  let bestId = -1;
  let bestSize = 0;
  let id = 0;
  for (let start = 0; start < w * h; start++) {
    if (gray[start] <= threshold || label[start] !== -1) continue;
    let head = 0;
    let tail = 0;
    queue[tail++] = start;
    label[start] = id;
    while (head < tail) {
      const p = queue[head++];
      const px = p % w;
      const neighbours = [px > 0 ? p - 1 : -1, px < w - 1 ? p + 1 : -1, p - w, p + w];
      for (const q of neighbours) {
        if (q < 0 || q >= w * h || label[q] !== -1 || gray[q] <= threshold) continue;
        label[q] = id;
        queue[tail++] = q;
      }
    }
    if (tail > bestSize) {
      bestSize = tail;
      bestId = id;
    }
    id++;
  }
  if (bestId < 0 || bestSize < w * h * 0.12) return null;

  let tl = { x: 0, y: 0, s: Infinity };
  let br = { x: 0, y: 0, s: -Infinity };
  let tr = { x: 0, y: 0, s: -Infinity };
  let bl = { x: 0, y: 0, s: Infinity };
  for (let p = 0; p < w * h; p++) {
    if (label[p] !== bestId) continue;
    const x = p % w;
    const y = (p / w) | 0;
    if (x + y < tl.s) tl = { x, y, s: x + y };
    if (x + y > br.s) br = { x, y, s: x + y };
    if (x - y > tr.s) tr = { x, y, s: x - y };
    if (x - y < bl.s) bl = { x, y, s: x - y };
  }
  return [tl, tr, br, bl].map(({ x, y }) => ({ x, y })) as Quad;
}
pdf.ts
export type PdfPage = { jpeg: Uint8Array; width: number; height: number };

const A4 = { w: 595.28, h: 841.89 }; // points

/** Writes a minimal PDF with one JPEG per A4 page (portrait or landscape to match the image). */
export function makePdf(pages: PdfPage[]) {
  const enc = new TextEncoder();
  const chunks: Uint8Array[] = [];
  const offsets: number[] = [];
  let length = 0;
  const push = (part: string | Uint8Array) => {
    const bytes = typeof part === "string" ? enc.encode(part) : part;
    chunks.push(bytes);
    length += bytes.length;
  };
  const object = (id: number, body: () => void) => {
    offsets[id] = length;
    push(`${id} 0 obj\n`);
    body();
    push("\nendobj\n");
  };

  // Object ids: 1 catalog, 2 page tree, then three per page (page, content, image).
  const pageId = (i: number) => 3 + i * 3;
  push("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n");
  object(1, () => push("<< /Type /Catalog /Pages 2 0 R >>"));
  object(2, () =>
    push(`<< /Type /Pages /Count ${pages.length} /Kids [${pages.map((_, i) => `${pageId(i)} 0 R`).join(" ")}] >>`),
  );

  pages.forEach((page, i) => {
    const landscape = page.width > page.height;
    const pw = landscape ? A4.h : A4.w;
    const ph = landscape ? A4.w : A4.h;
    const scale = Math.min(pw / page.width, ph / page.height);
    const w = page.width * scale;
    const h = page.height * scale;
    const content = `q ${w.toFixed(2)} 0 0 ${h.toFixed(2)} ${((pw - w) / 2).toFixed(2)} ${((ph - h) / 2).toFixed(2)} cm /Im0 Do Q`;
    const id = pageId(i);

    object(id, () =>
      push(
        `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pw.toFixed(2)} ${ph.toFixed(2)}] /Resources << /XObject << /Im0 ${id + 2} 0 R >> >> /Contents ${id + 1} 0 R >>`,
      ),
    );
    object(id + 1, () => push(`<< /Length ${content.length} >>\nstream\n${content}\nendstream`));
    object(id + 2, () => {
      push(
        `<< /Type /XObject /Subtype /Image /Width ${page.width} /Height ${page.height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${page.jpeg.length} >>\nstream\n`,
      );
      push(page.jpeg);
      push("\nendstream");
    });
  });

  const count = 3 + pages.length * 3;
  const xref = length;
  push(`xref\n0 ${count}\n0000000000 65535 f \n`);
  for (let id = 1; id < count; id++) push(`${String(offsets[id]).padStart(10, "0")} 00000 n \n`);
  push(`trailer\n<< /Size ${count} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`);

  return new Blob(chunks as BlobPart[], { type: "application/pdf" });
}
sample.ts
import { homography, warp } from "./geometry";

const PAGE = { w: 850, h: 1100 };

function drawLetter() {
  const c = document.createElement("canvas");
  c.width = PAGE.w;
  c.height = PAGE.h;
  const ctx = c.getContext("2d")!;
  ctx.fillStyle = "#fbfaf6";
  ctx.fillRect(0, 0, PAGE.w, PAGE.h);

  ctx.fillStyle = "#1c3557";
  ctx.font = "bold 40px Georgia, serif";
  ctx.fillText("ACME LTD.", 70, 110);
  ctx.font = "18px Georgia, serif";
  ctx.fillStyle = "#555";
  ctx.fillText("House 12, Road 7, Gulshan, Dhaka", 70, 140);
  ctx.fillStyle = "#1c3557";
  ctx.fillRect(70, 160, 710, 3);

  ctx.fillStyle = "#222";
  ctx.font = "bold 30px Georgia, serif";
  ctx.fillText("Salary Certificate", 70, 240);
  ctx.font = "20px Georgia, serif";
  ctx.fillText("Date: 15 October 2026", 70, 285);

  const body = [
    "This is to certify that Ms. Nusrat Jahan has been working",
    "with Acme Ltd. as HR Executive since 1 March 2024.",
    "",
    "Her current monthly salary is as follows:",
  ];
  body.forEach((line, i) => ctx.fillText(line, 70, 350 + i * 36));

  const rows = [
    ["Basic salary", "BDT 30,000"],
    ["House rent", "BDT 15,000"],
    ["Medical allowance", "BDT 3,500"],
    ["Transport", "BDT 3,500"],
    ["Gross monthly salary", "BDT 52,000"],
  ];
  ctx.strokeStyle = "#333";
  ctx.lineWidth = 1.5;
  rows.forEach(([label, value], i) => {
    const y = 520 + i * 48;
    ctx.strokeRect(70, y, 710, 48);
    ctx.font = i === rows.length - 1 ? "bold 20px Georgia, serif" : "20px Georgia, serif";
    ctx.fillText(label, 90, y + 31);
    ctx.fillText(value, 600, y + 31);
  });
  ctx.beginPath();
  ctx.moveTo(560, 520);
  ctx.lineTo(560, 520 + rows.length * 48);
  ctx.stroke();

  ctx.font = "20px Georgia, serif";
  ctx.fillText("This certificate is issued at her request.", 70, 820);

  // Signature and stamp
  ctx.strokeStyle = "#1d3a8a";
  ctx.lineWidth = 3;
  ctx.beginPath();
  ctx.moveTo(80, 940);
  ctx.bezierCurveTo(120, 880, 150, 980, 190, 920);
  ctx.bezierCurveTo(220, 880, 240, 970, 290, 925);
  ctx.stroke();
  ctx.fillStyle = "#222";
  ctx.fillText("Head of People", 80, 985);
  ctx.strokeStyle = "rgba(190, 40, 40, 0.75)";
  ctx.lineWidth = 4;
  ctx.beginPath();
  ctx.arc(640, 930, 70, 0, Math.PI * 2);
  ctx.stroke();
  ctx.fillStyle = "rgba(190, 40, 40, 0.8)";
  ctx.font = "bold 18px Georgia, serif";
  ctx.fillText("ACME LTD.", 590, 937);
  return ctx.getImageData(0, 0, PAGE.w, PAGE.h);
}

/** A phone-style photo of the letter lying at an angle on a desk, with uneven lighting. */
export function drawSamplePhoto() {
  const W = 1400;
  const H = 1050;
  const c = document.createElement("canvas");
  c.width = W;
  c.height = H;
  const ctx = c.getContext("2d")!;

  const desk = ctx.createLinearGradient(0, 0, W, H);
  desk.addColorStop(0, "#6b4a32");
  desk.addColorStop(1, "#3e2a1c");
  ctx.fillStyle = desk;
  ctx.fillRect(0, 0, W, H);
  ctx.globalAlpha = 0.08;
  for (let y = 0; y < H; y += 6) {
    ctx.fillStyle = y % 12 ? "#000" : "#fff";
    ctx.fillRect(0, y + Math.sin(y / 40) * 3, W, 2);
  }
  ctx.globalAlpha = 1;

  // A true pinhole-camera projection of the 850×1100 page (tilted back ~23°, f = 1500px),
  // so the scanner's perspective maths can recover its real proportions.
  const quad = [
    { x: 500, y: 191 },
    { x: 1061, y: 219 },
    { x: 1028, y: 994 },
    { x: 344, y: 912 },
  ];

  // Soft shadow under the paper
  ctx.save();
  ctx.shadowColor = "rgba(0, 0, 0, 0.55)";
  ctx.shadowBlur = 40;
  ctx.shadowOffsetX = 18;
  ctx.shadowOffsetY = 22;
  ctx.fillStyle = "#000";
  ctx.beginPath();
  quad.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
  ctx.closePath();
  ctx.fill();
  ctx.restore();

  // Map the letter into the tilted quad with the same perspective maths the scanner uses.
  const photo = ctx.getImageData(0, 0, W, H);
  const pageRect = [
    { x: 0, y: 0 },
    { x: PAGE.w, y: 0 },
    { x: PAGE.w, y: PAGE.h },
    { x: 0, y: PAGE.h },
  ];
  const xs = quad.map((p) => p.x);
  const ys = quad.map((p) => p.y);
  warp(drawLetter(), photo, homography(quad, pageRect), {
    x0: Math.floor(Math.min(...xs)),
    y0: Math.floor(Math.min(...ys)),
    x1: Math.ceil(Math.max(...xs)),
    y1: Math.ceil(Math.max(...ys)),
  }, false);
  ctx.putImageData(photo, 0, 0);

  // Uneven room lighting: bright top-left, shadowed bottom-right.
  const light = ctx.createRadialGradient(350, 150, 100, 700, 600, 1100);
  light.addColorStop(0, "rgba(255, 245, 225, 0.18)");
  light.addColorStop(1, "rgba(0, 0, 0, 0.38)");
  ctx.fillStyle = light;
  ctx.fillRect(0, 0, W, H);

  return new Promise<Blob>((resolve) => c.toBlob((b) => resolve(b!), "image/jpeg", 0.9));
}