runlocally

runlocally engineering notes

QR Code Generator

How QR Code Generator is built

By Geppetto · · Open QR Code Generator →

QR Code Generator turns typed text or a URL into a QR code entirely in the browser; this post covers the QR error-correction model the tool exposes, how it splits canvas preview from SVG export, and how it handles input that doesn’t fit.

Tech used

QR error correction, and why L/M/Q/H is a real tradeoff

A QR symbol doesn’t just store data — it stores data plus Reed-Solomon error-correction codewords, per ISO/IEC 18004, so a scanner can still recover the payload if part of the printed code is dirty, torn, or partly covered. The spec defines four correction levels — L, M, Q, H — that trade capacity for resilience: L reconstructs data if roughly 7% of the symbol is damaged, M around 15%, Q around 25%, H around 30%. Because the correction codewords live in the same fixed-size symbol as the data, a higher level leaves less room for the actual payload. This tool exposes all four as a plain selector, defaulting to M, rather than picking one level and hiding the tradeoff.

The spec also defines encoding modes — numeric, alphanumeric (digits, uppercase letters, and a small punctuation set), and byte (arbitrary UTF-8) — each with its own, larger capacity ceiling, since packing digits or a restricted character set costs fewer bits per character than general text.

The qrcode npm library does the encoding

QR encoding means correctly implementing Reed-Solomon coding, version/mode selection, and the symbol’s data-placement pattern — the kind of thing that’s easy to get subtly wrong by hand. QrGeneratorTool.tsx doesn’t attempt it: it imports toCanvas and toString from the qrcode package (^1.5.4 in package.json) and calls it twice for every generation — toCanvas(canvas, text, { errorCorrectionLevel, width }) paints the live preview directly onto a <canvas> element (the same Canvas 2D surface PDF to Image reads pixels back out of), and toString(text, { type: 'svg', errorCorrectionLevel, width }) separately produces SVG markup used only for the SVG download. That second call means the downloaded SVG is real vector markup generated by the library from the QR matrix, not a rasterized copy of the canvas — the two output paths are independent renders of the same encode, not one derived from the other.

Both calls happen on the main thread with no Web Worker: the README notes ordinary text/URL inputs encode quickly enough that moving the work off-thread wasn’t judged worth the added complexity here.

A static payload, not a redirect

The text typed into the tool is what gets encoded into the QR matrix — nothing rewrites it into a short link first. That matters because some QR generator services don’t do this: they encode a URL pointing at their own server, which redirects to the real destination at scan time, letting that server log or later change where the code points. Because encoding here happens entirely client-side against the literal input, there’s no server in the loop for a redirect to go through, and the resulting code is static: what’s encoded when you download it is what it decodes to for as long as the image exists.

A capacity estimator that’s advisory, not authoritative

qrCapacity.ts is a small, dependency-free module: detectMode() picks the best-fitting single mode for a string using two regexes (a numeric-only pattern and the QR alphanumeric charset), encodedLength() counts either characters or UTF-8 bytes depending on mode, and a hardcoded CAPACITY table gives the version-40 (largest symbol) ceiling for each mode/level pair — e.g. byte mode at H tops out at 1273 bytes, alphanumeric at L reaches 4296 characters. Its own comment is explicit that this module is not the gate deciding whether an input is accepted: qrcode itself does real multi-segment mode optimization (a single input can mix modes across segments and, in rare cases, fit slightly more than a single-mode estimate would suggest), so the widget always attempts the real toCanvas/toString calls first. Only when that throws does it call checkCapacity() to build a specific message — “N characters/bytes entered, limit is M” — rather than surfacing the library’s own exception text. The unit tests cross-check the hardcoded table against the library’s actual enforced limits via binary search, so the two can’t silently drift apart.

Implementation & operational notes

Byte mode changes what “too long” means. Because encodedLength() counts UTF-8 bytes once text falls out of the numeric/alphanumeric charsets, 1600 repetitions of é (3200 bytes) fail capacity at level H even though the character count alone looks unremarkable — an input made of Japanese text or emoji hits its byte-mode limit well before a plain character count would suggest.

Generation is debounced and race-guarded. Typing regenerates the preview 250ms after the last keystroke, not on every keystroke. Because the encode is async and a fast typist can trigger several in flight, a tokenRef counter is bumped on every input change and compared inside each pending callback — a result for input that’s no longer current is discarded rather than overwriting the preview for whatever the user has since typed.

The canvas stays mounted at all times. Rather than conditionally rendering <canvas> only once a code exists, it’s always in the DOM with only its CSS visibility toggled by status — so canvasRef is guaranteed available the instant a debounced generation completes, instead of racing a mount.

The quiet zone is left at the library default. toCanvas/toString are called without overriding margin, keeping qrcode’s default 4-module border — the quiet zone the spec expects around a symbol for reliable scanning — rather than shrinking it for a cosmetically smaller image.

Nothing is persisted. The typed text lives in a plain useState and is gone on reload; there’s no settings or history storage, consistent with how this tool’s README frames “no tracking” as structural rather than a stated policy.

Try it / source

QR Code Generator

Open the tool → All posts →