moonqr

QR ENCODER + DECODER · MOONBIT → JS

QR codes, compiled from MoonBit.

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.

  • 214/214 decoder parity with jsQR
  • faster than jsQR
  • 7.3 KB gzip encoder

Try it now

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.

By the numbers

Why MoonBit

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.

Full methodology →

War story: QR codes on a monitor

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.

Full write-up →

Install

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.

npm install @elchika-inc/moonqr @elchika-inc/moonqr-scanner

Encode → SVG

import { encode, toSvgString } from "@elchika-inc/moonqr/encode";

const matrix = encode("HELLO", { ecLevel: "M" });
if (matrix) {
  const svg = toSvgString(matrix, { margin: 4, cell: 8 });
  document.body.innerHTML = svg;
}

Decode → from ImageData

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