cam2qr
v1.0.0
Published
Zero-dependency TypeScript QR code scanning for the browser — independent from-spec detection and decoding, camera included.
Maintainers
Readme
cam2qr
Zero-dependency TypeScript QR code scanning for the browser, camera included.
The entire pipeline (adaptive binarization, finder-pattern location, perspective correction, Reed-Solomon error correction, segment decoding) is implemented independently from the ISO/IEC 18004 specification. Not a port or a wrapper of an existing scanner. Full docs and a how-it-works deep dive live at cam2qr.com.
- Zero runtime dependencies. One package, nothing else in your lockfile.
13.4 kB brotli for the full camera scanner; 9.5 kB if you only import
decode(). - Layered. Use
decode(imageData)as a pure function (works in Node),QrScannerfor batteries-included camera scanning, or thecam2qr/react,cam2qr/vue, andcam2qr/svelteadapters. - Fast, off the main thread. ~0.6 ms per clean frame; live scanning decodes in a module Web Worker by default with automatic main-thread fallback.
- Robust. Decodes 100% of every distortion sweep (noise, blur, rotation, perspective, low contrast, inversion) in the public benchmark against jsQR and @zxing/library.
- Typed errors, rich results.
CameraError.codetells you why the camera failed; results carry corner points, version, EC level, corrected-codeword counts, raw bytes, segments, and a parsed-content classification (URL / WiFi / vCard / geo / tel / sms / email / GS1 element strings).
Install
npm install cam2qrLive camera scanning
import { QrScanner } from 'cam2qr';
const scanner = new QrScanner(videoElement, {
camera: { facing: 'environment' },
onDecode(result) {
console.log(result.text, result.content, result.cornerPoints);
},
onError(error) {
if (error.code === 'permission-denied') showPermissionHelp();
},
});
await scanner.start(); // asks for permission, begins scanning
scanner.setTorch(true); // resolves false when unsupported, no throw
await scanner.setCamera({ facing: 'user' });
scanner.update({ tryHarder: true });
scanner.stop(); // releases the camera
scanner.destroy(); // full teardown (worker, listeners)Scanner options (all optional)
| Option | Default | Meaning |
| --- | --- | --- |
| camera.facing | 'environment' | 'environment' (rear) or 'user' (front) |
| camera.deviceId | – | exact camera from listCameras() (overrides facing) |
| camera.resolution | 1280×720 | ideal capture resolution |
| maxScansPerSecond | 15 | decode attempts per second (battery/CPU dial) |
| scanRegion | full frame | sub-rectangle (or video => region) to decode; a big CPU saver |
| useWorker | true | decode in a Web Worker when available |
| useNativeDetector | false | use the browser's BarcodeDetector when present, falling back to our engine when missing or failing; native results carry placeholder codec metadata (version: 0, mask: -1, zero ecc, EC level 'M') |
| pauseOnHidden | true | pause while the tab is hidden |
| tryInverted | true | also try light-on-dark codes |
| tryHarder | false | extra passes: more finder triples + a 2× low-pass (blur recovery) |
| maxDownscale | 2 | downscale huge frames by up to this factor before decoding |
| dedupeWindowMs | 1500 | quiet period before the same payload fires again (0 = off; tracked per payload) |
| stopOnDecode | false | stop the camera after the first result |
| multiple | false | decode every code in the frame; each symbol fires its own decode event |
| structuredAppend | 'reassemble' | join multi-symbol sequences into one decode event (parity-checked); 'individual' fires each symbol separately |
| onDecode / onError | – | callbacks; scanner.on('decode' \| 'detect' \| 'error' \| 'start' \| 'stop', …) also works |
| onDetect | – | fires every scanned frame with located symbol candidates (or null); corner points arrive before/regardless of a successful decode, for live outline overlays |
Also on the instance: pause() / resume(), setZoom(level), getCapabilities(),
listCameras() (exported standalone).
Live outline overlays
onDetect reports corner points in video pixel coordinates. A canvas sized to
videoWidth×videoHeight and CSS-stretched exactly like the video can draw them directly
(see demo/); for overlays positioned in CSS pixels, videoToElementCoordinates(points,
video) maps them, accounting for object-fit letterboxing/cropping.
const scanner = new QrScanner(video, {
onDetect(detections) {
clearOverlay();
for (const d of detections ?? []) drawPolygon(d.cornerPoints); // tracks while you aim
},
onDecode(result) {
flashPolygon(result.cornerPoints); // confirmed decode
},
});Camera error codes
start() rejects (and the error event fires) with a CameraError whose code is one of
permission-denied, camera-not-found, camera-in-use, insecure-context (camera needs
HTTPS or localhost), unsupported, stream-failed.
One-shot decoding
import { decode } from 'cam2qr';
const result = decode(imageData, { tryHarder: true }); // QrResult | nullTakes any { data, width, height } RGBA buffer (a canvas ImageData, a decoded PNG in
Node, …). Options: tryInverted, tryHarder, maxDownscale, parseContent. decode()
returns null when no decodable code is present and only throws on genuinely invalid
input.
Multiple codes per frame
import { decodeAll } from 'cam2qr';
const results = decodeAll(imageData); // QrResult[]: every decodable symbol, deduplicateddecodeAll always runs the full pass plan (all scales, plus the inverted pass), so it can
find a dark-on-light and a light-on-dark code in the same frame. On the scanner, set
multiple: true instead.
Locate without decoding
import { detect } from 'cam2qr';
const candidates = detect(imageData); // Detection[]: { cornerPoints, moduleSize }Cheaper than a decode and useful for framing feedback; candidates are plausibility-ranked and may include finder-like decoys that would not survive a decode.
The result
interface QrResult {
text: string; // decoded, charset-aware (ECI/UTF-8/Shift-JIS kanji)
bytes: Uint8Array; // raw payload bytes
content?: ParsedContent; // url | wifi | vcard | geo | tel | sms | email | gs1 | text
cornerPoints: [Point, Point, Point, Point]; // symbol outline in image pixels
moduleSize: number; // measured pixels per module
version: number; // 1–40
errorCorrectionLevel: 'L' | 'M' | 'Q' | 'H';
mask: number;
segments: Segment[]; // per-mode payload breakdown (incl. kanji)
ecc: { blocks: number; codewordsCorrected: number }; // damage signal
structuredAppend?: { index: number; total: number; parity: number };
fnc1?: { position: 'first' } | { position: 'second'; applicationIndicator: string };
}GS1 symbols (FNC1 in first position) get their element strings split into application
identifiers: content becomes { type: 'gs1', elements: [{ ai: '01', value: '0401…' }, …] },
with GS separators and alphanumeric % escapes handled per spec.
Structured-append sequences (one payload split across up to 16 symbols) are reassembled by
the scanner by default: parts are withheld and a single decode event fires with the joined
payload once every symbol has been seen and the parity byte checks out. Show all symbols at
once with multiple: true, or pan across them within 30 s. decode()/decodeAll() stay
pure and return the parts with their structuredAppend headers.
React
import { useQrScanner } from 'cam2qr/react';
function Scanner() {
const { videoRef, result, error, isScanning, scanner } = useQrScanner({
onDecode: (r) => console.log(r.text),
});
return <video ref={videoRef} />;
}The hook starts the camera when the video mounts, releases it on unmount, and re-renders on
results/errors. Pass enabled: false to keep the camera off; use scanner for imperative
control (torch, camera switching, update()).
Vue
<script setup>
import { useQrScanner } from 'cam2qr/vue';
const { videoRef, result, error, isScanning, scanner } = useQrScanner({
onDecode: (r) => console.log(r.text),
});
</script>
<template>
<video ref="videoRef"></video>
<p v-if="result">{{ result.text }}</p>
</template>Same lifecycle as the React hook, with refs instead of state. enabled accepts a Ref to
toggle the camera reactively; everything is torn down when the component's scope disposes.
Svelte
<script>
import { createQrScanner } from 'cam2qr/svelte';
const { video, result, isScanning } = createQrScanner({
onDecode: (r) => console.log(r.text),
});
</script>
<video use:video></video>
{#if $result}<p>{$result.text}</p>{/if}Readable stores plus an action (built on svelte/store only, so Svelte 3, 4, and 5 all
work). The camera starts when the <video> mounts and is released when it unmounts, so
you can gate the element with {#if} to toggle scanning.
Node and other runtimes
decode(), decodeAll(), and detect() are pure functions over RGBA bytes with no DOM
or Node API usage, so they run in browsers, workers, Node, and Hermes. The camera layer is
browser-only by nature. For React Native, docs/react-native.md
sketches the adapter approach over react-native-vision-camera.
Benchmarks
docs/benchmarks.md is regenerated by pnpm compare, which feeds
the same seeded synthetic frames to cam2qr, jsQR, and @zxing/library (both are
devDependencies used as measurement baselines only). Snapshot from the current run:
cam2qr with tryHarder decodes 100% of every distortion sweep (jsQR drops to 60% on
noise and 48% on low contrast; zxing to 20% on noise and 0% on inverted codes), at
~0.6 ms per clean small frame: ~5× faster than jsQR, on par with zxing. The harness
asserts cam2qr's detection-rate floors, so quality regressions fail CI.
There is also a regression corpus in test/fixtures/corpus, where every image must decode, enforced by the main test suite. "Fails to scan" reports become corpus images; see its README.
How it's tested
Every decoder stage has unit tests (GF(256), Reed-Solomon with injected errors, BCH format/version, masks, bitstream). Round-trip tests generate symbols with an external generator and decode them across all 40 versions × 4 EC levels × 8 masks. A pure-TS software renderer produces distorted images (rotation, perspective, noise, blur, lighting gradients, inversion) for detection tests. The scanner loop is integration-tested against fake camera hardware, and Playwright feeds a generated video to Chromium's fake camera to verify the shipped bundle end to end.
Demo
pnpm demo builds the library and serves a scanner page (Vite, port 5183) that runs
against the built artifacts: camera picker, torch, pause, tryHarder toggle, decode
outline overlay, and parsed-content display.
Contributing
Bug reports with a sample image are especially valuable, since every "fails to scan" report can become a permanent regression test. See CONTRIBUTING.md for setup, commands, architecture, and conventions.
