An encoder and decoder for QR codes, written in MoonBit and compiled to plain
JavaScript. Zero runtime dependencies, runs entirely in the browser — try it below.
Everything below runs entirely in your browser — no server, no external requests. Open
the console or the network tab if you don't believe it.
1. Generate — text → QR
Waiting for input…
2. Read — image file
Uses QrScanner's multiscale decode (scanImage). Works on screenshots and
downscaled images — even photos of a QR code shown on a monitor — by trying a
downscale pyramid from the smallest scale first.
Drag & drop a QR image here, or click to choose a file
3. Read — live camera
navigator.mediaDevices.getUserMedia only works over HTTPS or localhost
(secure-context restriction). On an insecure origin, without a camera, or if
permission is denied, this shows a clear error instead of failing silently.
MoonBit is a modern, statically-typed language that targets Wasm, JS, and native. Two
backends matter for the browser — js and wasm-gc — so before committing to one, we
measured both on the real decode workload, boundary costs included.
The result: the js backend was 1.99x faster than wasm-gc (geometric mean across Node and
Chrome). wasm-gc has no bulk-transfer path yet for moving a Uint8Array into a
FixedArray[Byte] — every pixel crosses the JS↔wasm boundary one element at a time. So
this project ships the js backend.
A photo of a QR code shown on a monitor picks up the screen's sub-pixel grid as
high-frequency noise. Naive block-based binarization mistakes it for near-50% speckle
and can't find the finder patterns — jsQR fails on these too, so this isn't a bug in our
binarizer.
A single-step downscale makes it worse — it aliases the grid instead of filtering it.
The fix is a small-scale-first multiscale pyramid: box-averaged (2×2 mean, a real
low-pass filter) downscales, tried smallest first. On a real monitor-photo test case
this cut decode time from ~5.4s to ~62ms, because the small scale that actually works is
reached immediately instead of after every larger scale has already failed.
Both packages are on npm, zero runtime dependencies. The MoonBit core is also on
mooncakes.io as naoto24kawa/moonqr — add it with moon add naoto24kawa/moonqr.
import { decode } from "@elchika-inc/moonqr/decode";
// data is an RGBA pixel buffer (Uint8Array or Uint8ClampedArray),
// e.g. from CanvasRenderingContext2D#getImageData
const result = decode(imageData.data, imageData.width, imageData.height);
if (result) {
console.log(result.text, result.version, result.ecLevel);
}
Live camera scan
import { QrScanner } from "@elchika-inc/moonqr-scanner";
const video = document.querySelector("video");
const scanner = new QrScanner(
video,
(result) => console.log("scanned:", result.text),
{ preferredCamera: "environment" },
);
await scanner.start();
// scanner.stop() when you're done
Limitations
Kanji-mode encoding is not supported — Japanese (and other non-ASCII) text is always
encoded as byte-mode UTF-8, which is valid but not maximally compact. Decoding
Kanji-mode QR codes made by other encoders is fully supported.
No ECI, Structured Append, or Micro QR — only standard (Model 2) QR codes with default
byte-mode handling are supported.