Writing
teiqr: the complete guide to generating, styling, validating and scanning QR codes
A working guide to teiqr — optimal encoding, ten module shapes with measured scan rates, logo damage analysis that is per-block rather than per-cent, PDF at a real millimetre size, and a decoder that proves the code you styled can still be read.
12 min read
Most QR libraries do one of three jobs: they encode, or they draw something pretty, or they
decode. teiqr does all three in one package with no runtime dependencies — and the reason
that combination matters is not convenience. It is the only arrangement in which a library
can tell you whether the code it just styled will actually scan.
This guide covers the whole surface: encoding, styling, validation, export, scanning, React, the CLI, and what each entry point costs you in bundle size.
npm install teiqrThe shortest version
qr() builds a symbol once and hands you every output format from it.
import { qr, scan } from 'teiqr';
qr('https://example.com').svg(); // → '<svg …>'
qr('https://example.com').png({ scale: 12 }); // → Uint8Array, no canvas
scan(pngBytes).text; // → 'https://example.com'One options bag covers encoding and styling together:
const code = qr('https://example.com', {
ecc: 'Q', // encoding
moduleShape: 'rounded', // styling
});
code.svg(); // string
code.png({ scale: 12 }); // Uint8Array
code.dataUrl(); // 'data:image/png;base64,…'
code.pixels({ scale: 8 }); // { pixels, width, height, omitted }
code.terminal(); // string, for console.log
code.validate(); // { score, issues, coverage, print, … }
code.verify(); // rasterise and read back — throws if unreadable
code.matrix; // the raw QrMatrixWriting a file in Node is exactly what it looks like:
import { writeFile } from 'node:fs/promises';
await writeFile('qr.png', qr('https://example.com').png({ scale: 10 }));Encoding: two things happen for free
import { encode } from 'teiqr/core';
encode('https://example.com', {
ecc: 'M', // 'L' | 'M' | 'Q' | 'H' (default 'M')
boostEcc: true, // raise the level for free when the version has slack
minVersion: 1,
maxVersion: 40,
mask: undefined, // pin 0–7, or let the penalty score choose
});Segmentation is optimal, not naive. Switching mode inside a symbol costs a mode indicator
plus a character-count field, so the cheapest encoding of a string is rarely one mode all the
way through. teiqr runs a Viterbi pass over numeric, alphanumeric, byte and Kanji modes in
exact sixth-of-a-bit arithmetic. A URL ending in a long order number encodes as byte plus
numeric rather than all-byte, which routinely saves an entire version.
boostEcc is redundancy you were already paying for. The chosen version almost always has
leftover capacity, and spending it on stronger error correction costs no extra modules:
encode('A', { ecc: 'M' }).ecc; // 'H' — same size, more damage tolerance
encode('A', { ecc: 'M', boostEcc: false }).ecc; // 'M'Micro QR and rMQR use the same optimiser
Micro QR (M1–M4) and rMQR (32 rectangular sizes) are priced against their own narrower headers and against the modes each version actually offers — so it is one verified optimiser rather than three approximations of one. Full QR additionally does ECI and Structured Append, which the compact symbologies do not define.
Styling, and the part nobody measures
Ten module shapes, six eye frames, five eye balls, gradients, logos and frames:
qr('https://example.com', {
moduleShape: 'rounded', // square | dot | rounded | extra-rounded | classy
// diamond | star | vertical | horizontal | fluid
eyeFrame: 'rounded', // square | rounded | circle | leaf | cut | dotted
eyeBall: 'rounded', // square | dot | rounded | leaf | diamond
body: {
kind: 'linear',
angle: 45,
stops: [
{ offset: 0, color: '#0b1020' },
{ offset: 1, color: '#123a6b' },
],
},
background: { kind: 'solid', color: '#ffffff' }, // null → transparent
quietZone: 4, // modules of clear space; 4 is the spec minimum
moduleSize: 8,
gap: 0, // 0 flush, 0.1 a hairline gap
logo: {
href: 'data:image/png;base64,…', // data URI only — exports must be self-contained
sizeRatio: 0.22,
excavate: true, // clear modules behind it rather than covering them
},
});Here is the part that makes this package different. Shape choices cost detection margin, and the cost was measured rather than guessed.
Many scanners verify a finder pattern by checking its 1:1:3:1:1 ratio horizontally, vertically and diagonally. A circular eye core measures 3.0 across its diagonal where a square measures 4.24 — so the diagonal check fails. Modern phone cameras use ML detection and tolerate it. Older and cheaper hardware does not.
Every variant was rendered and decoded with an independent decoder across 3 payloads × 4 error correction levels × 6 module scales — 72 decode attempts each:
| Module shape | Pass | Eye frame | Pass | Eye ball | Pass |
|---|---|---|---|---|---|
| square | 72/72 ✅ | square | 72/72 ✅ | square | 72/72 ✅ |
| rounded | 72/72 ✅ | rounded | 72/72 ✅ | rounded | 67/72 ⚠️ |
| extra-rounded | 72/72 ✅ | cut | 72/72 ✅ | dot | 37/72 ❌ |
| vertical | 72/72 ✅ | dotted | 68/72 ⚠️ | leaf | 35/72 ❌ |
| horizontal | 72/72 ✅ | leaf | 49/72 ❌ | diamond | 9/72 ❌ |
| fluid | 72/72 ✅ | circle | 42/72 ❌ | ||
| dot | 61/72 ❌ | ||||
| classy | 54/72 ❌ | ||||
| diamond | 36/72 ❌ | ||||
| star | 36/72 ❌ |
A practical rule falls out of that table: rounded and extra-rounded module shapes with a
square or rounded eye frame cost you nothing. A diamond eye ball at 9/72 is a design
decision to fail.
Proving it scans
Two functions, and they answer different questions.
validate() — static analysis, no rasterisation
Contrast, quiet zone, shape risk from the table above, print size, and exactly how much of the error correction budget a logo consumes. Fast enough for a live editor.
verify() — rasterise and read it back
Renders the styled symbol to pixels and decodes it in-process with the bundled decoder. Throws if it cannot be read. This is the one to run in CI before a code goes to print.
import { validate } from 'teiqr/validate';
const report = validate(matrix, style, { scanDistanceMm: 300, dpi: 300 });
report.score; // 0–100, at-a-glance only
report.issues; // [{ level, code, title, detail }]
report.contrast; // worst-case ratio; gradients judged by their worst stop
report.coverage; // exact logo damage, or null
report.print; // recommended physical sizeThe logo advice everyone repeats is wrong in both directions
The usual rule — keep a logo under 30% at level H — treats error correction as if it applied across the whole symbol. It does not. Reed-Solomon operates per block. Damage concentrated in one block can kill a code covering far less than 30%, while evenly spread damage survives considerably more.
teiqr walks the real module placement order and the real interleave map:
report.coverage;
// {
// coveredModules: 121,
// coveredFraction: 0.11, // for display only — NOT what determines survival
// damagedCodewords: 19,
// worstBlockDamaged: 7, // the number that actually matters
// worstBlockCapacity: 11, // this block's Reed-Solomon budget
// utilisation: 0.64, // 1.0 is exactly at the limit
// recoverable: true,
// breaksFinder: false, // fatal at any ECC level
// }breaksFinder is separate from everything else because no error correction level survives
it: the decoder needs the finder patterns to locate the symbol before correction runs.
Print sizing has a floor, not just a ratio
The industry rule is that a code reads at roughly ten times its own width. That alone is not enough — it has to be checked against a floor on module pitch, because below about 0.4 mm ink spread closes the gaps between modules whatever the overall size says. Codes that fail in print usually fail here.
report.print;
// { span: 41, minSideMm: 30, minModuleMm: 0.73, recommendedSideMm: 30, recommendedPx: 355 }Output: five formats, all synchronous, no canvas
PNG without a canvas anywhere
import { toPng } from 'teiqr/raster';
const bytes = toPng(matrix, { moduleShape: 'rounded' }, {
scale: 12, // device pixels per module
width: 512, // exact width; takes precedence over scale
background: '#ffffff', // null keeps transparency
level: 6, // DEFLATE effort, 0–9
dpi: 300, // writes a pHYs chunk so print tools size it correctly
});The package contains its own DEFLATE compressor, PNG encoder/decoder and anti-aliased
scanline rasteriser. That is why PNG output is synchronous, dependency-free and
byte-identical in every runtime — no node-canvas, no native build step, no
CompressionStream, and it works on Cloudflare Workers.
The DEFLATE implementation is verified against Node's own zlib inflater across empty input,
single bytes, window-edge matches at exactly 32,768 bytes, and random data at all ten
compression levels.
PDF and EPS at a real physical size
import { exportQr } from 'teiqr/export';
const { bytes, extension } = exportQr(matrix, { moduleShape: 'rounded' }, 'pdf', {
sideMm: 40,
title: 'Table 12',
});
await writeFile(`menu.${extension}`, bytes);sideMm is the actual printed size, not a pixel count — a 40 mm PDF opens as 40 mm in
Illustrator, InDesign or a print shop's RIP. Every format serialises from the same scene, so
the geometry cannot drift between your SVG preview and the PDF that goes to press.
Two caveats, reported rather than hidden
Raster output cannot draw frame label text — that needs a font engine — and can only embed
PNG data-URI logos. Both are listed in rasterize().omitted so you can detect them
programmatically. SVG and the vector formats handle both fully.
Payloads: 32 types, with the escaping that decides whether they work
An unescaped ; in a WiFi password silently truncates it. An unfolded 200-character vCard
line is rejected outright by some contact importers. These are the details that make a code
work on a real phone rather than in a demo.
import { serializePayload } from 'teiqr/payload';
qr(serializePayload('wifi', {
ssid: 'Pokhara Cafe',
encryption: 'WPA',
password: 'himalaya2026',
}), { ecc: 'Q' }).png();- url
- vcard
- wifi
- sms
- geo
- event
- upi
- sepa
- bitcoin
- lightning
- github
- …32 in total
Each type declares its own fields, so a form UI can be generated from the data rather than
hand-written:
import { getPayloadType } from 'teiqr/payload';
getPayloadType('wifi').fields;
// [{ name: 'ssid', label: 'Network name', type: 'text', required: true }, …]Ethereum amounts are converted to wei by decimal-string arithmetic with BigInt, because a
double cannot hold 18 significant digits and a wallet handed an off-by-one-wei amount is a bug
worth avoiding entirely.
Scanning: one function, almost any input
import { scan, scanAll, tryScan } from 'teiqr/verify';
scan(await readFile('ticket.png')).text; // Node Buffer
scan(arrayBuffer).text; // ArrayBuffer / Uint8Array
scan('data:image/png;base64,iVBORw0…').text; // data URL or bare base64
scan(ctx.getImageData(0, 0, w, h)).text; // canvas ImageData
scan(canvasElement).text; // <canvas>, <img>, <video>, ImageBitmap
scan(matrix).text; // a QrMatrix, no pixels involvedA camera produces JPEG, and that is a separate opt-in import so the Huffman tables and inverse DCT stay out of the default bundle:
import { scan } from 'teiqr/verify';
import 'teiqr/jpeg'; // +3.4 kB, baseline and progressive
scan(await readFile('photo.jpg')).text;What comes back is diagnostic, not just a string
const result = scan(bytes);
result.text; // decoded payload
result.version; // 1–40
result.ecc; // read from the symbol's own format info
result.corrected; // codewords Reed-Solomon had to repair; 0 means pristine
result.moduleSize; // measured module pitch in pixels
result.origin; // where the symbol sits in the imageCodes are located through a fitted perspective transform, so a symbol photographed at an angle samples correctly across its whole width instead of drifting off the modules partway. All three symbologies also read mirrored — through a shop window or off a mirror — and reflections are only attempted after every upright fit has failed, so an upright symbol pays nothing for the capability.
React
npm install teiqr reactimport { QrCode } from 'teiqr/react';
<QrCode value="https://example.com" size={256} title="Link to example.com" />Three things worth knowing about the component:
Real elements, not injected markup
It builds actual React elements rather than generating an SVG string for
dangerouslySetInnerHTML. So it reconciles normally instead of replacing the subtree on every change, it accepts arefand event handlers, and no interpolated string derived from user input reaches the DOM.Server rendering works, and is asserted by test
Gradient ids are hashes of the fill rather than counters, so SVG output is deterministic — server and client markup match and hydration stays quiet.
Encoding is cached across renders, including inline props
A
useMemokeyed on an inline options object misses every render, because identity changes.useQrCodecaches on a structural digest instead, so<QrCode value={url} moduleShape="rounded" />still hits.
Camera scanning is a hook:
import { useQrScanner } from 'teiqr/react';
const { ref, result, error, scanning, start, stop } = useQrScanner({
onResult: (r) => console.log(r.text),
facingMode: 'environment',
fps: 10,
repeatDelayMs: 1500,
maxSize: 640,
});Command line
No install, no dependencies pulled in — the argument parser is part of the package.
npx teiqr "https://example.com" # prints to the terminal
npx teiqr "https://example.com" -o code.png # writes a file
npx teiqr "text" -o code.pdf --side-mm 40 # print-ready at a real size
npx teiqr "text" --validate # scannability report alongside the code
npx teiqr -t wifi ssid="Pokhara Cafe" password=himalaya2026 -o wifi.png
npx teiqr scan ticket.png --json # decode, full result
npx teiqr batch wifi.csv -t wifi -o codes.zip # one code per rowWhat each part costs
Measured with esbuild, minified and gzipped:
| Entry | Gzipped | What it is |
|---|---|---|
| teiqr | 44.7 kB | everything |
| teiqr/core | 11.6 kB | encoding, all three symbologies |
| teiqr/render | 4.5 kB | scene + SVG |
| teiqr/validate | 4.6 kB | scannability analysis |
| teiqr/payload | 8.3 kB | 32 typed builders + parsers |
| teiqr/export | 22.1 kB | PDF, EPS, ZIP, CSV batch |
| teiqr/raster | 10.4 kB | DEFLATE + PNG + rasteriser |
| teiqr/verify | 17.2 kB | decoder + scanner |
| teiqr/react | 28.4 kB | components + hooks |
Tree-shaking is measured rather than assumed: the same script bundles a single import and
greps the output for markers that appear only in code it should have excluded. encode alone
is 5.5 kB with nothing unrelated pulled in.
Runtime support
Node ≥ 20.9, browsers, Cloudflare Workers, Deno and Bun — generate, SVG, PNG and scan on all
five. This is tested rather than assumed. A dedicated suite makes Buffer, document,
window, HTMLCanvasElement and Image throw on access, then runs the entire pipeline
against that, so anything reaching for a Node-only or DOM-only global fails loudly.
A short checklist for shipping a styled code
Pick shapes from the safe tier
roundedorextra-roundedmodules,squareorroundedeye frames. They cost nothing.Let boostEcc do its work
It is on by default and usually raises the level for free.
Run validate() with your real print numbers
Pass
scanDistanceMmanddpi. Readprint.minModuleMm, not just the overall size.Check worstBlockDamaged, not coveredFraction
The percentage is for display. The block utilisation is what determines survival.
Run verify() in CI before anything goes to print
It rasterises and decodes. A code that passes here has been read, not predicted.
teiqr is MIT licensed and the source is public. Issues, pull requests and forks are all
welcome — a fork that goes its own way is a better outcome than a library nobody could bend.
Start here
Tell us what you are building
Or what is breaking, or what has to go faster. You will get a straight answer from an engineer who would do the work.