QQR

QR code encoder and decoder in pure Elixir. Zero dependencies — no NIFs, no ports, no C.

Installation

def deps do
  [{:qqr, "~> 0.2.0"}]
end

Encoding

{:ok, matrix} = QQR.encode("Hello World")
{:ok, matrix} = QQR.encode("12345", ec_level: :high, mode: :numeric)

Options: :ec_level (:low, :medium, :quartile, :high), :mode (:numeric, :alphanumeric, :byte, :auto), :version (1–40), :mask (0–7). All default to auto.

SVG

svg = QQR.to_svg("https://example.com")
svg = QQR.to_svg("Hello", dot_shape: :rounded, color: "#336699")

Styling: :dot_shape (:square, :rounded, :dots, :diamond), :finder_shape (:square, :rounded, :dots), :dot_size, :module_size, :quiet_zone, :color, :background, :logo. See QQR.SVG for details.

Phoenix LiveView

<div class="qr"><%= raw(QQR.to_svg_iodata(@url, dot_shape: :rounded)) %></div>

to_svg_iodata/2 returns iodata — no extra binary copy, sent directly to the socket.

PNG with stb_image

{:ok, matrix} = QQR.encode("Hello World")
dim = matrix.width
scale = 10
quiet = 4
img_dim = (dim + quiet * 2) * scale

rgb =
  for y <- 0..(img_dim - 1), x <- 0..(img_dim - 1), into: <<>> do
    qr_x = div(x, scale) - quiet
    qr_y = div(y, scale) - quiet

    if QQR.BitMatrix.get(matrix, qr_x, qr_y),
      do: <<0, 0, 0>>,
      else: <<255, 255, 255>>
  end

%StbImage{data: rgb, shape: {img_dim, img_dim, 3}, type: {:u, 8}}
|> StbImage.write_file!("qr.png")

Decoding

From RGBA pixels

case QQR.decode(rgba_binary, width, height) do
  {:ok, result} ->
    result.text     #=> "https://example.com"
    result.version  #=> 3
    result.bytes    #=> [104, 116, 116, 112, ...]
    result.chunks   #=> [%QQR.Chunk{mode: :byte, text: "https://example.com", bytes: [...]}]
    result.location #=> %QQR.Location{top_left_corner: {10.5, 10.5}, ...}

  :error ->
    # no QR code found
end

rgba_binary is a binary of RGBA pixels — 4 bytes per pixel, same format as ImageData in browsers.

From a file with stb_image

{:ok, img} = StbImage.read_file("photo.png")
{h, w, c} = img.shape

rgba =
  case c do
    4 -> img.data
    3 -> for <<r, g, b <- img.data>>, into: <<>>, do: <<r, g, b, 255>>
  end

case QQR.decode(rgba, w, h) do
  {:ok, result} -> result.text
  :error -> "no QR code found"
end

From a module grid

Skip image processing when you already have a binarized grid:

QQR.decode_matrix(bit_matrix)

Inversion

By default both normal and inverted (light-on-dark) images are tried. Pass inversion: :dont_invert for ~2× speedup when you know the background is white.

Features

Benchmarks

Compared against qrex (Rust NIF, PNG input). Run with elixir bench/decode.exs.

Input QQR.decode_matrix QRex (Rust NIF) QQR.decode (RGBA)
Version 1, "Hello" 30 µs 51 µs 1.5 ms
Version 2, URL 55 µs 70 µs 2.1 ms
Version 6, 100 chars 251 µs 146 µs 5.5 ms

Grid-only decode (decode_matrix) is 1.3–1.7× faster than Rust for small and medium QR codes. The full RGBA pipeline is slower due to image processing overhead in the binarizer and locator.

How it works

Encode: text → data bits → RS error correction → matrix → mask → QR
Decode: RGBA → binarize → locate → extract → unmask → RS correct → text

GF(256) exp/log tables are compiled into pattern-matched function heads. The BitMatrix uses a flat tuple with :erlang.element/2 for constant-time access. No mutable state — zigzag traversal, Bresenham walks, and polynomial arithmetic are purely functional.

Encoder ported from etiket. Decoder ported from jsQR with algorithm verification against quirc.

License

MIT