Skip to content

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.

bash
npm install teiqr

The shortest version

qr() builds a symbol once and hands you every output format from it.

ts
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:

ts
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 QrMatrix

Writing a file in Node is exactly what it looks like:

ts
import { writeFile } from 'node:fs/promises';
 
await writeFile('qr.png', qr('https://example.com').png({ scale: 10 }));

Encoding: two things happen for free

ts
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:

ts
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:

ts
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 shapePassEye framePassEye ballPass
square72/72 ✅square72/72 ✅square72/72 ✅
rounded72/72 ✅rounded72/72 ✅rounded67/72 ⚠️
extra-rounded72/72 ✅cut72/72 ✅dot37/72 ❌
vertical72/72 ✅dotted68/72 ⚠️leaf35/72 ❌
horizontal72/72 ✅leaf49/72 ❌diamond9/72 ❌
fluid72/72 ✅circle42/72 ❌
dot61/72 ❌
classy54/72 ❌
diamond36/72 ❌
star36/72 ❌
Scan pass rates by shape, out of 72 decode attempts. Available at runtime as SAFETY_EVIDENCE, and turned into warnings automatically by validate().

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.

  1. 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.

  2. 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.

ts
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 size

The 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:

ts
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.

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.

ts
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

ts
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

ts
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.

ts
import { serializePayload } from 'teiqr/payload';
 
qr(serializePayload('wifi', {
  ssid: 'Pokhara Cafe',
  encryption: 'WPA',
  password: 'himalaya2026',
}), { ecc: 'Q' }).png();
  • url
  • vcard
  • wifi
  • email
  • sms
  • geo
  • event
  • upi
  • sepa
  • bitcoin
  • lightning
  • github
  • linkedin
  • …32 in total

Each type declares its own fields, so a form UI can be generated from the data rather than hand-written:

ts
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

ts
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 involved

A camera produces JPEG, and that is a separate opt-in import so the Huffman tables and inverse DCT stay out of the default bundle:

ts
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

ts
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 image

Codes 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

bash
npm install teiqr react
tsx
import { QrCode } from 'teiqr/react';
 
<QrCode value="https://example.com" size={256} title="Link to example.com" />

Three things worth knowing about the component:

  1. 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 a ref and event handlers, and no interpolated string derived from user input reaches the DOM.

  2. 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.

  3. Encoding is cached across renders, including inline props

    A useMemo keyed on an inline options object misses every render, because identity changes. useQrCode caches on a structural digest instead, so <QrCode value={url} moduleShape="rounded" /> still hits.

Camera scanning is a hook:

tsx
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.

bash
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 row

What each part costs

Measured with esbuild, minified and gzipped:

EntryGzippedWhat it is
teiqr44.7 kBeverything
teiqr/core11.6 kBencoding, all three symbologies
teiqr/render4.5 kBscene + SVG
teiqr/validate4.6 kBscannability analysis
teiqr/payload8.3 kB32 typed builders + parsers
teiqr/export22.1 kBPDF, EPS, ZIP, CSV batch
teiqr/raster10.4 kBDEFLATE + PNG + rasteriser
teiqr/verify17.2 kBdecoder + scanner
teiqr/react28.4 kBcomponents + hooks
Entry points and their gzipped size. Individual functions tree-shake further — importing toTerminal costs 0.4 kB out of a 44.7 kB whole.

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

  1. Pick shapes from the safe tier

    rounded or extra-rounded modules, square or rounded eye frames. They cost nothing.

  2. Let boostEcc do its work

    It is on by default and usually raises the level for free.

  3. Run validate() with your real print numbers

    Pass scanDistanceMm and dpi. Read print.minModuleMm, not just the overall size.

  4. Check worstBlockDamaged, not coveredFraction

    The percentage is for display. The block utilisation is what determines survival.

  5. 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.