How I Built a QR Code Encoder From the Standard, With No Library

When I added a QR code generator to ToolOnTap, the easy route was a library, and there are good ones. I wrote the encoder myself anyway, for two reasons. I wanted to understand what’s inside those black and white squares, and I wanted every line of the page’s code to be something I could explain. The whole encoder is about 400 lines of TypeScript and has no dependencies.

This article walks through it in the order the code runs. Everything here follows ISO/IEC 18004, the QR code standard.

What a QR code is made of

A QR code is a square grid of modules (the small squares). There are 40 sizes, called versions: version 1 is 21 × 21 modules, and each version adds 4 per side, up to 177 × 177 for version 40.

Some of the grid is fixed, the same in every code of that version:

  • Three finder patterns, the big squares in the corners, which let a camera find the code and work out its rotation.
  • Two timing patterns, alternating dark and light lines between the finders, which give the grid’s spacing.
  • Alignment patterns, smaller squares that help with bent or tilted codes, from version 2 up.
  • Format information, 15 bits next to the finders that say which error correction level and mask were used. From version 7, there’s also version information.

Everything else carries data. The job of an encoder is to turn your text into bits, protect them against damage, and place them in those free modules.

Step 1: turn the text into bits

QR codes have several modes. Each packs a different kind of character more tightly:

Mode Characters Bits
Numeric 0–9 10 bits per 3 digits
Alphanumeric 0–9, A–Z, space and $%*+-./: 11 bits per 2 characters
Byte anything 8 bits per byte

So a phone number in numeric mode takes about 3.3 bits per digit instead of 8. My encoder picks the most compact mode that can hold the whole text. A link like https://toolontap.com has lowercase letters, so it goes in byte mode. Arabic text and emoji go in byte mode too, as UTF-8.

Here’s numeric mode, three digits at a time:

if (mode === 'numeric') {
  for (let i = 0; i < text.length; i += 3) {
    const chunk = text.slice(i, i + 3)
    b.put(Number(chunk), chunk.length * 3 + 1)
  }
  return { bits: b.bits, count: text.length }
}

chunk.length * 3 + 1 gives 10 bits for three digits, 7 for two and 4 for one, which is exactly what the standard asks for.

The data starts with a 4-bit mode indicator and the number of characters. The width of that count depends on the version: 8 bits for bytes in versions 1 to 9, 16 bits above that.

Step 2: pick the smallest version

Each version and error correction level has a fixed capacity. Version 1 at level M holds 16 data codewords (bytes). https://toolontap.com is 21 bytes, plus 12 bits of header, which doesn’t fit. Version 2 at level M holds 28, so the encoder uses version 2. The generator shows this under the code: “Version 2 · 25 × 25 · 80% full”.

After the data comes a terminator of up to four zero bits. Then the bits are padded to a whole byte, and the rest of the capacity is filled with two bytes that alternate: 0xEC and 0x11. Those values are chosen in the standard so the padding doesn’t produce long runs of the same color.

Step 3: Reed–Solomon error correction

This is the part that lets you scan a code with a coffee stain on it. Reed–Solomon codes add extra bytes computed from the data. A reader that gets some bytes wrong can find and fix them with the extra ones. There are four levels: L, M, Q and H recover from about 7%, 15%, 25% and 30% damage.

The maths happens in GF(256), a field of 256 numbers where addition is XOR and multiplication wraps around a fixed polynomial. QR codes use x⁸ + x⁴ + x³ + x² + 1, which is 0x11D in hex:

function gfMultiply(x: number, y: number): number {
  let z = 0
  for (let i = 7; i >= 0; i--) {
    z = (z << 1) ^ ((z >>> 7) * 0x11D)
    z ^= ((y >>> i) & 1) * x
  }
  return z
}

It’s ordinary long multiplication in binary: each step shifts left, and if a bit falls off the top, XOR with the polynomial folds it back into 8 bits.

The error correction bytes are the remainder of dividing the data, as a polynomial, by a generator polynomial. It’s the same idea as the remainder in long division, which is why it can be computed one byte at a time.

The standard includes a worked example: “01234567” at version 1, level M. I copied its data bytes and its 10 expected error correction bytes into a unit test. When that test passed, I knew the field arithmetic and the division were right before I’d drawn a single module.

Step 4: split into blocks and interleave

Bigger codes don’t protect all their data as one long message. They split it into blocks, add error correction to each block, then interleave them: first byte of block 1, first byte of block 2, and so on.

The reason is physical damage. A scratch across a code destroys bytes that sit next to each other. After interleaving, those neighbours belong to different blocks, so each block loses only a little and can repair itself. Some versions mix short and long blocks that differ by one byte, which is the fiddliest part of the code to get right.

Step 5: place the bits in a zigzag

The bits go in two-module-wide columns, starting at the bottom right and moving up, then down the next pair of columns, then up again, skipping anything fixed:

for (let right = m.size - 1; right >= 1; right -= 2) {
  if (right === 6) right = 5 // skip the vertical timing pattern
  for (let vert = 0; vert < m.size; vert++) {
    for (let j = 0; j < 2; j++) {
      const x = right - j
      const upward = ((right + 1) & 2) === 0
      const y = upward ? m.size - 1 - vert : vert
      if (!m.fixed[y]![x] && i < data.length * 8) {
        m.modules[y]![x] = bit(data[i >>> 3]!, 7 - (i & 7))
        i++
      }
    }
  }
}

The right === 6 line is a detail that’s easy to miss. Column 6 holds the vertical timing pattern, so the zigzag jumps over it.

Step 6: choose a mask

Raw data can create patterns that confuse a camera: big solid areas, or shapes that look like a finder pattern. So the standard defines 8 masks, simple formulas such as “flip every module where (x + y) is even”. A mask is applied with XOR to the data modules only.

The encoder tries all 8 and scores each result with the standard’s four penalty rules:

  1. Runs of 5 or more same-colored modules in a row or column.
  2. 2 × 2 blocks of one color.
  3. Patterns that look like a finder (dark-light-dark-dark-dark-light-dark with light space around it).
  4. An unbalanced share of dark modules, far from 50%.

The mask with the lowest score wins. Its number goes in the format information, together with the error correction level, protected by its own small error-correcting code and XORed with 0x5412 so it’s never all zeros.

How I know it works

Writing an encoder is one thing; knowing that phones can read it is another. I didn’t want to rely on my eyes or one phone, so the tests decode every code with jsQR, an independent open-source decoder that is only used by the tests and never shipped to the site. The tests cover:

  • every error correction level, with links, Arabic text, emoji and a Wi‑Fi string full of characters that need escaping;
  • all 8 masks, forced one by one;
  • lengths from 10 to 2,200 characters, which reach the large versions where the block layouts get complicated;
  • the capacity tables against the numbers printed in the standard.

I also ran the downloaded PNG and SVG files from the real page through the same decoder in a browser.

What I left out

  • Mixed modes. A text can switch modes partway, for example a long number inside a link, to save a few bits. My encoder uses one mode for the whole text, which is simpler and only costs a little space on long mixed content.
  • Kanji mode, which packs Japanese characters more tightly, isn’t needed for the site’s users.
  • Logos in the middle. They work because of error correction: you deliberately destroy part of the code and let H level repair it. It’s a trade-off I’d rather not make for people printing a menu or a Wi‑Fi sign.

The result is a QR code generator that makes static codes for links, Wi‑Fi, WhatsApp, email and phone numbers, in your browser, with no library and no server.

Tools in this article

  • QR code generator

    Make a QR code for a link, Wi‑Fi network, WhatsApp chat, email or phone number, and download it as PNG or SVG. Static codes that never expire.

More articles