<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[yan wang]]></title><description><![CDATA[yan wang]]></description><link>https://wangyan.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>yan wang</title><link>https://wangyan.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 21 Sep 2026 22:00:00 GMT</lastBuildDate><atom:link href="https://wangyan.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[60 % of our image decode failures were HEIC files named .jpg]]></title><description><![CDATA[For a few days our analytics showed a class of failure that made no sense: images rejected by the decoder in well under a second. Not slow failures, not out-of-memory on a huge photo — instant refusal]]></description><link>https://wangyan.hashnode.dev/60-of-our-image-decode-failures-were-heic-files-named-jpg</link><guid isPermaLink="true">https://wangyan.hashnode.dev/60-of-our-image-decode-failures-were-heic-files-named-jpg</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[debugging]]></category><dc:creator><![CDATA[yan wang]]></dc:creator><pubDate>Sun, 13 Sep 2026 18:28:22 GMT</pubDate><content:encoded><![CDATA[<p>For a few days our analytics showed a class of failure that made no sense: images rejected by the decoder in well under a second. Not slow failures, not out-of-memory on a huge photo — instant refusals. Between 2026-09-06 and 09-10, <strong>60 % of every</strong> <code>decode_failed</code> <strong>event came from the same shape of file: an iPhone photo called</strong> <code>photo.jpg</code><strong>, reporting</strong> <code>image/jpeg</code><strong>, whose actual bytes were HEIC.</strong></p>
<p>We had a HEIC path. It was never tried, because the gate that decides whether to load the HEIC decoder was reading the filename.</p>
<p>Disclosure: this is from <a href="https://lensup.ai/">LensUp</a>, which is ours. The fix is the boring one — read the bytes — but the interesting part is where naive byte-sniffing then went wrong, and the precedence rule we ended up needing.</p>
<h2>How a HEIC ends up called .jpg</h2>
<p>Nobody renames these on purpose. The path is mundane: an iPhone shoots HEIC, you share the photo through a messenger or copy it to a PC, and somewhere along the way the transport relabels it. The extension becomes <code>.jpg</code>, <code>file.type</code> becomes <code>image/jpeg</code>, and the container is still HEIC.</p>
<p>From the browser's side there is no hint of this. <code>File.type</code> is not derived from content — it is whatever the OS or the transport asserted. So:</p>
<pre><code class="language-js">// Cheap, synchronous gate so the page can decide whether to lazy-load the HEIC decoder
// module at all. Byte-level truth lives elsewhere — names and MIME lie often enough
// that this is only a candidate check, never a verdict.
export function isHeicCandidate(file) {
  const mime = file?.type?.toLowerCase();
  if (mime === 'image/heic' || mime === 'image/heif') return true;
  const extension = file?.name?.split('.').pop()?.toLowerCase();
  return extension === 'heic' || extension === 'heif';
}
</code></pre>
<p>That function is fine — as a hint for whether to <em>lazy-load</em> a wasm decoder you do not want to ship to everyone. It is not fine as the thing that decides which decoder runs. That was the bug: a hint had been promoted to a verdict.</p>
<h2>Sniffing: twelve to sixteen bytes, never the file</h2>
<p>The fix reads a small slice. Not the file — a slice:</p>
<pre><code class="language-js">const FTYP_READ_BYTES = 64;
export async function sniffInputSignature(file) {
  let head;
  try {
    head = new Uint8Array(await file.slice(0, FTYP_READ_BYTES).arrayBuffer());
  } catch { return null; }
  if (head.length &lt; 4) return null;
  const ascii = (from, to) =&gt; String.fromCharCode(...head.subarray(from, Math.min(to, head.length)));

  if (head[0] === 0xff &amp;&amp; head[1] === 0xd8 &amp;&amp; head[2] === 0xff) return 'jpeg';
  if (head[0] === 0x89 &amp;&amp; ascii(1, 4) === 'PNG') return 'png';
  if (ascii(0, 4) === 'GIF8') return 'gif';
  if (ascii(0, 4) === 'RIFF' &amp;&amp; head.length &gt;= 12 &amp;&amp; ascii(8, 12) === 'WEBP') return 'webp';
  if (ascii(0, 2) === 'BM') return 'bmp';
  if (ascii(0, 4) === '%PDF') return 'pdf';
  // … TIFF (II*\0 / MM\0*), then the ISO-BMFF family below
}
</code></pre>
<p>Sixteen bytes covers every format the importer accepts, and <code>file.slice()</code> means the read is one small range request against the blob rather than a load of a 12-megapixel photo.</p>
<h2>Where "just read the bytes" is not enough</h2>
<p>ISO base media files — HEIC, HEIF, AVIF, MP4 and friends — all start the same way: bytes 4–8 spell <code>ftyp</code>, bytes 8–12 carry the <strong>major brand</strong>. So the naive version is: read the major brand, map <code>heic</code> → HEIC decoder, <code>avif</code> → native.</p>
<p>Then review found the hole. Two of those brands are generic:</p>
<pre><code class="language-js">const HEIC_BRANDS = new Set(['heic', 'heix', 'hevc', 'hevx', 'heim', 'heis', 'hevm', 'hevs']);
const AVIF_BRANDS = new Set(['avif', 'avis']);
const GENERIC_HEIF_BRANDS = new Set(['mif1', 'msf1']);
</code></pre>
<p><code>mif1</code> and <code>msf1</code> mean "this is a HEIF container" and say <strong>nothing about the codec inside</strong>. An AVIF file may carry <code>mif1</code> as its major brand and declare <code>avif</code> only further down, in the compatible-brands list. Treat <code>mif1</code> as HEIC and you hand a file the browser decodes natively — losslessly, instantly — to a lossy wasm transcode. You have recompressed somebody's photo for no reason.</p>
<p>So a generic major brand gets resolved from the compatible brands, and the <code>ftyp</code> box is walked using its own declared size:</p>
<pre><code class="language-js">if (head.length &gt;= 12 &amp;&amp; ascii(4, 8) === 'ftyp') {
  const major = ascii(8, 12).trim().toLowerCase();
  if (HEIC_BRANDS.has(major)) return 'heic';
  if (AVIF_BRANDS.has(major)) return 'avif';
  if (GENERIC_HEIF_BRANDS.has(major)) {
    // ftyp box: [size:4]['ftyp'][major:4][minor version:4][compatible brands:4 each …]
    const boxSize = ((head[0] &lt;&lt; 24) | (head[1] &lt;&lt; 16) | (head[2] &lt;&lt; 8) | head[3]) &gt;&gt;&gt; 0;
    const end = Math.min(head.length, boxSize || head.length);
    const compatible = [];
    for (let offset = 16; offset + 4 &lt;= end; offset += 4) {
      compatible.push(ascii(offset, offset + 4).trim().toLowerCase());
    }
    if (compatible.some((b) =&gt; AVIF_BRANDS.has(b))) return 'avif';
    if (compatible.some((b) =&gt; HEIC_BRANDS.has(b))) return 'heic';
    return 'heif';   // a HEIF container that names no codec
  }
}
</code></pre>
<p>Two details in there that are easy to skip. The box size is clamped with <code>Math.min(head.length, …)</code> so a bogus length cannot walk past the slice we actually read. And when no compatible brand is specific, the answer is the honest <code>'heif'</code> — "a HEIF container, codec unknown" — rather than a guess.</p>
<h2>The precedence rule: bytes win, the label breaks ties</h2>
<p>"Always trust the bytes" is the slogan, and it is <em>almost</em> right. The generic-HEIF case is where it breaks: a file whose bytes only say <code>heif</code> could be a HEIC the browser cannot open, or something it can. There is no byte that settles it.</p>
<p>So the rule that shipped is narrower and, I think, the actually correct one: <strong>bytes win over the label; the label breaks ties only when the bytes say nothing.</strong></p>
<pre><code class="language-js">export async function classifyInput(file) {
  const signature = await sniffInputSignature(file);
  const labelPdf  = isPdfCandidate(file);
  const labelHeic = isHeicCandidate(file);
  const labelTiff = isTiffCandidate(file);
  const isPdf  = signature === 'pdf'  || (labelPdf  &amp;&amp; signature === null);
  const isHeic = !isPdf &amp;&amp; (signature === 'heic' || (labelHeic &amp;&amp; (signature === null || signature === 'heif')));
  const isTiff = !isPdf &amp;&amp; !isHeic &amp;&amp; (signature === 'tiff' || (labelTiff &amp;&amp; signature === null));
  return { signature, isPdf, isHeic, isTiff, isImage: !isPdf &amp;&amp; !isHeic &amp;&amp; !isTiff,
           mislabelled: labelPdf &amp;&amp; IMAGE_SIGNATURES.has(signature) };
}
</code></pre>
<p>Read the three interesting rows:</p>
<ul>
<li><p><code>scan.pdf</code> <strong>whose bytes are a JPEG is an image</strong>, not a broken PDF. Before this, those went to the PDF parser and produced <code>pdf_invalid</code>: 39 of them between 09-06 and 09-11, most from Windows desktops retrying the same file over and over — the user's file was fine, our routing was not.</p>
</li>
<li><p><code>IMG_0001.jpg</code> <strong>whose bytes start with</strong> <code>%PDF</code> <strong>is a PDF.</strong> Same rule, other direction.</p>
</li>
<li><p><strong>A generic HEIF goes to the HEIC decoder only if the label agrees.</strong> <code>signature === 'heif' &amp;&amp; labelHeic</code> → HEIC. A <code>.jpg</code> with those bytes stays on the native path, because that is the case where the label is the only evidence that exists.</p>
</li>
</ul>
<h2>One more lie: application/octet-stream</h2>
<p>Worth its own line, because it is not a type:</p>
<pre><code class="language-js">// application/octet-stream is what browsers say when they do not know — treat it as
// undeclared so the extension map (and downstream byte sniffing) can decide.
if (declared &amp;&amp; declared !== 'application/octet-stream') return declared;
</code></pre>
<p>Windows file pickers hand over <code>.pdf</code> files with an empty or <code>application/octet-stream</code> type often enough that treating it as a real MIME type poisons every branch downstream. It means "no opinion", and the code should say so.</p>
<p>The same file also needs a small alias table, because the same format arrives under several names: <code>image/pjpeg</code>, <code>image/x-ms-bmp</code>, <code>image/x-png</code>, <code>image/jpg</code>. Normalise first, decide second.</p>
<h2>What I would take from this</h2>
<p><strong>A cheap label check and an expensive byte check are different functions and should be named differently.</strong> Our bug was one function doing both jobs — a lazy-load hint named like a verdict. <code>isHeicCandidate</code> and <code>isHeicFile</code> now sit next to each other with comments saying which is which, and that naming is doing more work than the sniffer is.</p>
<p><strong>"Read the bytes" needs a tie-break rule, not just a preference.</strong> Some formats genuinely do not identify their own contents. Write the precedence down explicitly — bytes, then label, then a documented default — rather than letting it emerge from the order of your <code>if</code>s.</p>
<p><strong>Failures that are too fast are a routing smell.</strong> A decoder that gives up in 200 ms did not struggle with your file; it was handed the wrong file. That timing was the clue that pointed at the label, and it is the one I will look for first next time.</p>
<p>If you want to see the byte path do its thing, drop a HEIC — renamed or not — into <a href="https://lensup.ai/cam-scanner/">a browser-based document scanner</a>. It runs in the tab and files are never uploaded.</p>
]]></content:encoded></item><item><title><![CDATA[A scan is never perfectly square]]></title><description><![CDATA[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 ]]></description><link>https://wangyan.hashnode.dev/a-scan-is-never-perfectly-square</link><guid isPermaLink="true">https://wangyan.hashnode.dev/a-scan-is-never-perfectly-square</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[canvas]]></category><dc:creator><![CDATA[yan wang]]></dc:creator><pubDate>Sun, 13 Sep 2026 18:26:25 GMT</pubDate><content:encoded><![CDATA[<p>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.</p>
<p>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 <a href="https://lensup.ai/">LensUp</a>, 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.</p>
<h2>1. Tilt: never zero, never much, and never random</h2>
<p>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:</p>
<pre><code class="language-js">/** 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;
}
</code></pre>
<p>Three decisions in five lines.</p>
<p><strong>Never zero.</strong> <code>0.25 + |unit| * 0.35</code> 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.</p>
<p><strong>Never much.</strong> Above roughly a degree it stops looking like a scanner and starts looking like a mistake.</p>
<p><strong>Never</strong> <code>Math.random()</code><strong>.</strong> This is the one I would have got wrong. The seed is <code>w * 131 + h * 71</code> — 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.</p>
<h2>2. The bug hiding in the tilt: rotating crops</h2>
<p>Rotate a <code>w × h</code> image by any angle inside a <code>w × h</code> frame and the corners leave the frame. Everybody knows this in the abstract. At 0.6° it is easy to assume it is negligible.</p>
<p>It is not. Review caught it with a measurement: <strong>on a 1560 × 2200 page, a 0.6° tilt moved edge content 2–9 px out of frame.</strong> Nine pixels is a clipped page number, a truncated signature line, the last character of a table cell — on a document, silently.</p>
<p>The fix is the rotated bounding box:</p>
<pre><code class="language-js">/** 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);
}
</code></pre>
<p>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 <em>more</em> realistic, not less: a scan of a page has a sliver of platen around it.</p>
<p>The <code>Math.min(..., 1)</code> 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.</p>
<h2>3. The pixel pass, and the one term that sells it</h2>
<p>The geometry is half the effect. The rest is a single pass over the pixels:</p>
<pre><code class="language-js">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;
</code></pre>
<p>Four of these are the obvious ones. <strong>Lifted blacks</strong> (<code>20 + g * 0.9</code>): a scanner never returns 0, and paper is never 255. <strong>Softened contrast</strong> (×0.94 around mid-grey): optics are not a renderer. <strong>Vignette</strong>, 8 % toward the corners. <strong>Grain</strong>, ±7 levels.</p>
<p>The fifth line is the one that makes it work, and it is the one I would have written wrong:</p>
<pre><code class="language-js">const warmth = Math.max(0, (g - 150) / 105);
</code></pre>
<p>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: <strong>a real scanner warms the paper, not the ink.</strong> The warm cast comes from the platen lamp reflecting off the sheet; black toner reflects almost nothing, so it stays neutral.</p>
<p>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.</p>
<h2>4. Reproducible grain</h2>
<p>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:</p>
<pre><code class="language-js">function scannedRandom(seed) {
  let a = seed &gt;&gt;&gt; 0;
  return () =&gt; {
    a = (a + 0x6D2B79F5) &gt;&gt;&gt; 0;
    let t = a;
    t = Math.imul(t ^ (t &gt;&gt;&gt; 15), t | 1);
    t ^= t + Math.imul(t ^ (t &gt;&gt;&gt; 7), t | 61);
    return ((t ^ (t &gt;&gt;&gt; 14)) &gt;&gt;&gt; 0) / 4294967296;
  };
}
</code></pre>
<p>Seeded per page as <code>w * 31 + h * 17</code>, so the grain pattern is stable across re-renders, and the pixel function is pure: <code>scannedLookPixels(pixels, w, h, seed)</code> 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.</p>
<h2>5. Where this stops</h2>
<p>It is worth being exact about the limit, because this category attracts products that are not exact about it.</p>
<p><strong>Text becomes pixels.</strong> 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.</p>
<p>And the effect changes <strong>how a page looks, nothing else</strong>. 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.</p>
<p>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.</p>
<h2>The part I would take elsewhere</h2>
<p>Two things generalise beyond making pages look scanned.</p>
<p><strong>Deterministic "randomness" is usually what you want in a document pipeline.</strong> Anything the user might re-render should look identical when it does. Seed from the content's own dimensions and the problem disappears.</p>
<p><strong>Small rotations crop.</strong> 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.</p>
<p>If you want to see the effect on a page of your own, it is at <a href="https://lensup.ai/pdf-to-scanned-pdf/">a PDF-to-scanned-PDF converter</a> — it runs in the tab and files are never uploaded, so the Network panel is a fair way to check that.</p>
]]></content:encoded></item><item><title><![CDATA[The adaptive threshold that deleted the thing we were looking for]]></title><description><![CDATA[Finding the four corners of a page in a phone photo is the step that turns a picture of a document into a copy of one. Everything downstream — the perspective warp, the flattening, the PDF — is easy o]]></description><link>https://wangyan.hashnode.dev/the-adaptive-threshold-that-deleted-the-thing-we-were-looking-for</link><guid isPermaLink="true">https://wangyan.hashnode.dev/the-adaptive-threshold-that-deleted-the-thing-we-were-looking-for</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[algorithms]]></category><dc:creator><![CDATA[yan wang]]></dc:creator><pubDate>Sun, 13 Sep 2026 18:20:45 GMT</pubDate><content:encoded><![CDATA[<p>Finding the four corners of a page in a phone photo is the step that turns a picture of a document into a copy of one. Everything downstream — the perspective warp, the flattening, the PDF — is easy once you know where the page is.</p>
<p>We do that client-side, in a browser tab, on a grayscale copy of the photo scaled to a long edge of 640 px. The pipeline is the textbook one:</p>
<pre><code class="language-plaintext">grayscale → blur → Sobel → adaptive edge map → Hough lines → best convex quad
</code></pre>
<p>Two near-horizontal lines, two near-vertical lines, score every candidate quadrilateral, keep the best. Disclosure up front: this is from <a href="https://lensup.ai/">LensUp</a>, which is ours. What follows is the part where the textbook pipeline quietly failed, and the one-line change that nearly doubled how often it works.</p>
<h2>The failure: the page fills the frame and detection gets <em>worse</em></h2>
<p>The adaptive edge threshold is the standard trick. Take the histogram of Sobel magnitudes, pick the 90th percentile, call everything above it an edge. It adapts to lighting, exposure and camera noise for free.</p>
<p>It also has a property nobody mentions: <strong>it is set by whatever has the strongest gradients in the image.</strong> On a photo of a document, that is not the page border. It is the letter strokes. Printed text is black-on-white at high spatial frequency — it produces the sharpest gradients in the frame by a wide margin.</p>
<p>The paper-to-desk step, meanwhile, is weak. Measured on SmartDoc 2015, a page lying on light wood clears the surrounding surface by roughly <strong>14 luma levels</strong>. That is the signal you actually need, and it is an order of magnitude quieter than the text sitting in the middle of it.</p>
<p>So the adaptive threshold, doing exactly what it was designed to do, sets itself by the text and prices the page border out of the edge map. And it does this <em>worst</em> in the case you most want to work: when the user fills the frame with the page, so text dominates the histogram and there is barely any desk left to argue for a lower threshold.</p>
<p>The fix is not clever. Cap it:</p>
<pre><code class="language-js">edgePercentile: 0.9,     // gradient magnitudes above this percentile are edges …
minEdgeMagnitude: 14,    // … but never below this …
maxEdgeThreshold: 30,    // … and never above this: print must not price the page edge out
</code></pre>
<p>Measured on 300 real frames, with every other guard unchanged:</p>
<table>
<thead>
<tr>
<th>threshold cap</th>
<th>pages located</th>
</tr>
</thead>
<tbody><tr>
<td>220</td>
<td>23.3 %</td>
</tr>
<tr>
<td><strong>30</strong></td>
<td><strong>41.0 %</strong></td>
</tr>
</tbody></table>
<p>An adaptive threshold with a hard ceiling is no longer fully adaptive, which felt wrong when we wrote it. It is also the single highest-value line in the file.</p>
<h2>Telling paper from a block of printed text</h2>
<p>Capping the threshold gets the page border back into the edge map. It also lets a lot of rectangles in that are not pages — and the most dangerous one is a dark block of printed text, because it is genuinely a bright-surrounded rectangle with strong edges.</p>
<p>The first instinct is a contrast magnitude: require the inside of the quad to be meaningfully brighter than the outside. We had that set at 14 gray levels, which is the median paper-on-light-wood step from above.</p>
<p>That was the wrong knob. A threshold at the <em>median</em> of true pages rejects about half of the true pages. What actually discriminates is not how big the step is but which way it points:</p>
<pre><code class="language-js">minContrast: 8,   // 40th percentile of (inside − outside) across each side, in gray levels.
                  // A page on light wood measures 14 at the median, so 14 rejected half of them;
                  // the polarity rule is what keeps text blocks out, not this magnitude.
</code></pre>
<p>The polarity rule is the actual test: a quad is a page only if it is <strong>brighter inside than outside, sampled at two depths, on all four sides</strong>. A text block fails that on the sides where more text continues past it. A page on a desk passes it everywhere.</p>
<p>There is still a case that beats local evidence: a large dark region of print can look like "the desk" if you only sample a few pixels out. So before applying a detection automatically — as opposed to just pre-positioning the handles — we probe farther outside, looking for the page continuing past that edge. If the page continues, the edge was not the page edge.</p>
<h2>The guards that are product decisions wearing algorithm clothes</h2>
<p>Three of the constants in that file are not computer vision. They are decisions about what to do when we are unsure, and they are the ones I would port to any similar project:</p>
<pre><code class="language-js">borderMargin: 0.02,        // a side within 2% of the frame edge is "on the border"
applyMinAreaRatio: 0.25,   // a smaller page is only proposed, never applied
minSideSupport: 0.42,      // every side needs this much edge support to be applied
</code></pre>
<p><strong>A quad with a side lying on the image border is rejected.</strong> If the "page edge" is the edge of the photo, the page is not fully inside the frame, and the honest default is the whole frame rather than a crop that silently cuts off whatever was outside it.</p>
<p><strong>The largest well-supported quad wins.</strong> This sounds like a tie-breaker and is actually a correctness rule: it makes an inner rectangle — a photo printed on the page, a bordered table — lose to the page around it.</p>
<p><strong>Below a quarter of the frame, a detection is only a proposal.</strong> It pre-positions the four draggable handles and waits. Auto-applying a small quad is how you produce the single worst outcome in this whole category: a confident crop that removes two thirds of somebody's passport.</p>
<p>That last one generalises. The result of this module is a proposal and never the truth; every handle stays draggable, and the caller decides from a confidence value whether to apply it silently. When detection fails outright it returns <code>null</code>, and <code>null</code> means <strong>keep the entire original image</strong>. A scanner that crops wrong is worse than a scanner that does not crop.</p>
<h2>Two features that ship turned off</h2>
<pre><code class="language-js">weakEdgeRecovery: 0,   // Experimental: desktop cost is not yet justified by coverage gains.
contourRecovery: 0,    // Correct proposals improved, but the measured latency gate failed.
</code></pre>
<p>Both of these work. Both improve the numbers. Both are disabled in the shipped defaults because they did not clear a latency budget on the hardware people actually hold. Leaving them in the file at <code>0</code>, with the reason written next to them, has been more useful than deleting them — the next person to look at this does not have to rediscover that the idea was tried and why it is not on.</p>
<h2>What I would take away from this</h2>
<p>The general shape of the bug is worth more than the specific fix: <strong>an adaptive parameter adapts to the strongest thing in your input, and the strongest thing is often not your signal.</strong> Text beats page borders. Specular highlights beat document edges. The loudest object in the frame sets your threshold, and if your target is quiet, it gets deleted.</p>
<p>When an adaptive method underperforms, it is worth checking whether it is adapting to something you did not intend before reaching for a better algorithm. In our case the better algorithm was a ceiling of 30.</p>
<p>If you want to watch this run on your own photo, it is at <a href="https://lensup.ai/cam-scanner/">a browser-based document scanner</a> — the detection happens in the tab and your files are never uploaded, so the Network panel is a fair way to check that claim.</p>
]]></content:encoded></item><item><title><![CDATA[Three things that bit us moving a document scanner fully into the browser]]></title><description><![CDATA[Photographing a page and turning it into a clean, printable PDF is, on paper, a solved problem: read a file, fix the perspective on a canvas, flatten the shading, write a PDF. Browsers have shipped ev]]></description><link>https://wangyan.hashnode.dev/three-things-that-bit-us-moving-a-document-scanner-fully-into-the-browser</link><guid isPermaLink="true">https://wangyan.hashnode.dev/three-things-that-bit-us-moving-a-document-scanner-fully-into-the-browser</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[canvas]]></category><dc:creator><![CDATA[yan wang]]></dc:creator><pubDate>Sun, 13 Sep 2026 18:04:58 GMT</pubDate><content:encoded><![CDATA[<p>Photographing a page and turning it into a clean, printable PDF is, on paper, a solved problem: read a file, fix the perspective on a canvas, flatten the shading, write a PDF. Browsers have shipped every primitive for that for years, and none of it needs a server.</p>
<p>I work on <a href="https://lensup.ai/">LensUp</a>, which does exactly that — the whole pipeline runs in the tab, and files are never uploaded. Disclosure up front: it's our tool, and this post is about the parts that were harder than the pipeline itself. None of the three below are in the "how to draw an image to a canvas" tutorials, and all three cost us real debugging time.</p>
<h2>1. Web Share can be gone by the time your file is ready</h2>
<p>The Web Share API's file variant is the nicest way to hand a generated PDF to whatever the user actually wants to do with it. The naive version looks fine:</p>
<pre><code class="language-js">shareButton.addEventListener('click', async () =&gt; {
  const bytes = await buildPdf(pages);           // takes a while
  const file = new File([bytes], 'scan.pdf', { type: 'application/pdf' });
  await navigator.share({ files: [file] });      // 💥 NotAllowedError
});
</code></pre>
<p><code>navigator.share()</code> requires <strong>transient user activation</strong>, and transient activation expires. Building a multi-page PDF from full-resolution photos on a mid-range phone takes long enough that by the time you call <code>share()</code>, the activation from the tap is gone. You get a <code>NotAllowedError</code>, the share sheet never opens, and it only reproduces on slow devices — which is the worst possible failure profile.</p>
<p>There is no way to extend the activation. What you can do is decouple "prepare" from "share", and check whether you still have activation before deciding which one you're doing:</p>
<pre><code class="language-js">let preparedShare = null;  // { file, identity }

shareButton.addEventListener('click', async () =&gt; {
  let file;
  if (preparedShare &amp;&amp; sameOutputIdentity(preparedShare.identity, snapshotOutputIdentity())) {
    file = preparedShare.file;                   // second tap: no await before share()
  } else {
    preparedShare = null;
    const { pages, settings } = await prepareCurrentOutputPages('share');
    const bytes = await buildPdf(pages, settings);
    file = new File([bytes], `scan-${Date.now()}.pdf`, { type: 'application/pdf' });
    preparedShare = { file, identity: settings.identity };

    // A long render may outlive transient user activation. The next tap shares
    // the prepared file synchronously, only while its content/settings still match.
    if (navigator.userActivation?.isActive === false) {
      toast('Your file is ready — tap share again');
      return;
    }
  }
  await navigator.share({ files: [file], title, text });
  preparedShare = null;
});
</code></pre>
<p><code>navigator.userActivation.isActive</code> is the part worth knowing about. It lets you tell the difference between "this will work" and "this will throw", so you can degrade to an honest two-tap flow instead of showing an error for something the user did nothing wrong in. On a fast device the second tap never happens; on a slow one the user gets a clear "ready, tap again" instead of a failure.</p>
<p>Two details that go with it.</p>
<p><strong>Feature-detect the file variant, not the API.</strong> <code>'share' in navigator</code> tells you nothing about whether <em>files</em> can be shared — that support is separate, and it varies. The only honest probe is to build a real <code>File</code> of the type you intend to send and ask:</p>
<pre><code class="language-js">const canShareFiles = (() =&gt; {
  try {
    const f = new File([new Uint8Array([37, 80, 68, 70])], 't.pdf', { type: 'application/pdf' });
    return !!(navigator.canShare &amp;&amp; navigator.canShare({ files: [f] }));
  } catch {
    return false;
  }
})();
if (!canShareFiles) shareButton.hidden = true;
</code></pre>
<p>Those four bytes are <code>%PDF</code>. Building the probe file from the real MIME type matters, because <code>canShare</code> can accept one type and refuse another.</p>
<p><code>AbortError</code> <strong>is not an error.</strong> When the user opens the share sheet and dismisses it, <code>share()</code> rejects with <code>AbortError</code>. If you surface that as a toast, you are telling people something failed when they simply changed their mind:</p>
<pre><code class="language-js">} catch (err) {
  if (!err || err.name !== 'AbortError') {
    showShareFailed(err);
  }
}
</code></pre>
<h2>2. One worker per image beat a worker pool</h2>
<p>Finding the page corners in a photo — the geometry that turns a trapezoid back into a rectangle — is the one genuinely CPU-heavy step, and it has no business on the main thread while someone is trying to scroll.</p>
<p>The obvious architecture is a long-lived worker (or a small pool) plus request IDs, so you can match a response to the request that asked for it. We ended up with the opposite: <strong>spawn a worker for one image, then terminate it.</strong></p>
<pre><code class="language-js">export async function detectQuadFromBlob(blob, options = {}) {
  if (!blob || options.signal?.aborted) return null;

  // A worker owns only this image. Completion, cancellation and timeout all release
  // its bitmap/heap; older requests cannot deliver a later request's result.
  if (typeof window !== 'undefined'
      &amp;&amp; typeof Worker === 'function'
      &amp;&amp; typeof OffscreenCanvas === 'function') {
    let worker;
    try {
      worker = new Worker(new URL('../workers/quad-detect-worker.js', import.meta.url),
                          { type: 'module' });
    } catch {
      /* CSP / unsupported module worker: fall through to the local path. */
    }
    if (worker) return new Promise(resolve =&gt; {
      const { signal, ...settings } = options;
      let done = false;
      const finish = value =&gt; {
        if (done) return;
        done = true;
        clearTimeout(timer);
        signal?.removeEventListener('abort', abort);
        worker.terminate();
        resolve(value);
      };
      const abort = () =&gt; finish(null);
      const timer = setTimeout(abort, 10000);
      const fallback = () =&gt; { if (!done) finish(detectLocal(blob, options)); };

      worker.onmessage = e =&gt; finish(e.data?.detection ?? null);
      worker.onerror = fallback;
      worker.onmessageerror = fallback;
      signal?.addEventListener('abort', abort, { once: true });
      if (signal?.aborted) { abort(); return; }
      try { worker.postMessage({ blob, options: settings }); } catch { fallback(); }
    });
  }
  return detectLocal(blob, options);
}
</code></pre>
<p>Three reasons this turned out better for this particular job:</p>
<p><strong>Stale results become structurally impossible.</strong> With a shared worker, a response from the image the user already replaced can arrive after you've moved on, and you are one forgotten ID comparison away from cropping photo B by photo A's corners. Terminating the worker deletes that bug class instead of guarding against it.</p>
<p><strong>Memory releases deterministically.</strong> A decoded <code>ImageBitmap</code> from a 12-megapixel photo is tens of megabytes. <code>terminate()</code> takes the whole worker heap with it, which is a much shorter argument than reasoning about when the bitmap becomes unreachable inside a worker that keeps running. The worker side still closes it explicitly, because the tab may be doing several things at once:</p>
<pre><code class="language-js">self.onmessage = async ({ data }) =&gt; {
  let bitmap;
  try {
    bitmap = await createImageBitmap(data.blob);
    self.postMessage({ detection: detectQuadFromSource(bitmap, data.options) });
  } catch {
    // The importer keeps the full original image on every detection failure.
    self.postMessage({ detection: null });
  } finally {
    bitmap?.close();
  }
};
</code></pre>
<p><strong>Cancellation is just</strong> <code>terminate()</code><strong>.</strong> No cooperative abort checks inside the detection loop, no message protocol for "never mind".</p>
<p>The cost is real — you pay worker startup per image, and on a cold module worker that is not free. For a user importing a handful of pages, that cost is invisible; if you were detecting corners on a video stream at 30fps, you would want the pool and the request IDs.</p>
<p>Note what the failure path does: every error resolves to <code>null</code>, and <code>null</code> means <strong>keep the full original image</strong>. A scanner that crops wrong is worse than a scanner that doesn't crop, so the degraded state is "you get your whole photo" rather than "you get two thirds of your passport".</p>
<h2>3. "Compress to 200 KB" is a search problem, not a setting</h2>
<p>Government portals and university systems love a hard byte ceiling: PDF, under 200 KB, colour, A4. Developers see that requirement and go looking for the quality parameter that produces 200 KB.</p>
<p>There isn't one. The size of an encoded JPEG is a function of the image content as much as the quality setting — a dense page of small text and a mostly-white form at the same quality can differ several-fold. The only thing you can do is encode, measure, and step:</p>
<pre><code class="language-js">async function encodeTowardTarget(canvas, targetBytes) {
  let best = null;
  for (const q of [0.92, 0.85, 0.78, 0.7, 0.6, 0.5, 0.42, 0.35]) {
    const blob = await new Promise(r =&gt; canvas.toBlob(r, 'image/jpeg', q));
    best = blob;
    if (blob.size &lt;= targetBytes) break;   // first one that fits wins
  }
  return best;                              // may still exceed the target
}
</code></pre>
<p>Two things follow from that, and both are product decisions rather than technical ones.</p>
<p>The loop has to terminate somewhere, which means <strong>the result can still be over the ceiling</strong>. You either keep degrading until the page is unreadable, or you stop and hand back something too big. We stop, which makes this a best-effort operation — and if you are building something similar, say that in your UI. Telling a user you will hit an exact byte count is a promise the format does not let you keep.</p>
<p>And if you are the one filling in the form: check the file, don't trust the label. A tool that claims an exact size is either lying or about to destroy your document's legibility.</p>
<h2>The part that is genuinely easier client-side</h2>
<p>For all three of the above, the reward is worth it. The documents people scan are passports, signed contracts, medical forms, payslips — the most sensitive paper most people own. Doing the work in the tab means the server only ever ships HTML, JavaScript and translations; it never receives a pixel of the document.</p>
<p>That claim is also checkable, which is the main thing I'd push for in this category: open DevTools → Network, clear the log, scan something, export it, and watch whether your file's bytes leave. If a "client-side" claim is real, the Network tab shows it. If it isn't, you'll see a POST with your document in it. Worth doing to any tool that touches your paperwork — including ours.</p>
<p>If you want to poke at the implementation described above, it's running at <a href="https://lensup.ai/cam-scanner/">a browser-based document scanner</a>.</p>
]]></content:encoded></item></channel></rss>