A while back I put together a small QR code generator — type some text, pick colors and an error-correction level, get a PNG. I assumed the "convert text into a QR code" part was the boring half and figured I'd eventually add the reverse direction, scan a QR code and get the text back. Then I actually read the generation library and realized the two directions aren't remotely symmetric, and that most of what people type into a QR generator doesn't even get encoded the way you'd expect.
The tool wraps qrjs2, a small vanilla-JS port of the classic qr.js, loaded as a plain <script> tag and called as QRCode.generatePNG(text, options). Reading through it to write my own wrapper is what turned up the actual interesting parts.
Three ways to store your text, and why yours probably isn't the cheap one
The QR spec doesn't just dump your string into bytes. It has three usable encoding modes, and the library picks one automatically based on what you typed, before it even thinks about size:
var NUMERIC_REGEXP = /^\d*$/;
var ALPHANUMERIC_OUT_REGEXP = /^[A-Z0-9 $%*+\-./:]*$/;
if (data.match(NUMERIC_REGEXP)) {
mode = MODE_NUMERIC;
} else if (data.match(ALPHANUMERIC_OUT_REGEXP)) {
mode = MODE_ALPHANUMERIC;
} else {
mode = MODE_OCTET;
}
Numeric mode packs three digits into 10 bits — great if you're encoding a phone number or a serial number. Alphanumeric mode packs two characters into 11 bits, but only for a 45-character set: A–Z, 0–9, space, and $ % * + - . / :. Notice what's missing — lowercase letters. That's not a bug in this library, it's the QR standard itself; alphanumeric mode never had lowercase in it.
Which means a completely ordinary URL like https://begoodtool.com/qrcode never qualifies for alphanumeric mode, because of the lowercase https. It falls straight through to octet (byte) mode, at 8 bits per character — worse than double the bits-per-character of alphanumeric. Almost every real-world QR code — URLs, emails, freeform text — ends up in the "expensive" mode, and the "cheap" mode mostly exists for things like tracking numbers typed in all caps.
The version picker is just a for-loop, and error correction eats into your budget
QR codes come in 40 "versions" (bigger version = more modules = more capacity). The library doesn't ask you which one you want; it tries them in order and takes the first one your data fits in:
if (ver < 0) {
for (ver = 1; ver <= 40; ++ver) {
if (data.length <= getmaxdatalen(ver, mode, ecclevel)) {
break;
}
}
if (ver > 40) {
throw "too large data";
}
}
getmaxdatalen depends on ecclevel because error-correction data shares the same fixed pool of bits as your actual content:
var ndatabits = function ndatabits(ver, ecclevel) {
var nbits = /* total bits for this version */;
nbits -= 8 * v[0][ecclevel] * v[1][ecclevel]; // ECC codewords come out of here
return nbits;
};
The four levels in the UI (L 7%, M 15%, Q 25%, H 30%) aren't just a durability dial — every extra percentage point of recovery capacity is data capacity you no longer have at that version. Bump a long URL from L to H and you can push it into needing the next version up, which is a visibly denser, harder-to-scan-from-far-away code, for exactly the same text.
The line I was sure was a typo (it wasn't)
Scrolling through the source I kept seeing things like data[length] and buf[length] instead of data.length — I was ready to write this whole thing up as a library bug. Then I found the actual declaration near the top:
var length = "length";
It's not a typo. foo[length] and foo.length are the exact same lookup once length is a variable holding the string "length" — it's an old micro-optimization/minification trick for aliasing a property name you use dozens of times in the file. Confirmed it by patching every occurrence back to .length and diffing the generated matrices against the original: byte-for-byte identical. A good reminder to actually test a suspicion before writing "bug" in a blog post.
What this tool actually can't do
Despite living in a folder literally called "QR conversion," there's no reverse path here — no camera, no upload-an-image-and-get-the-text-back. Generating a QR code and decoding one are unrelated problems (decoding needs a whole separate finder-pattern-detection and perspective-correction pipeline, something like jsQR or ZXing), and this tool only implements the first half. If you're staring at a QR code someone handed you and need to know what's inside it, this won't help.
A couple of other real gotchas: nothing here checks contrast between your background and foreground colors, so it's entirely possible to generate a QR code that looks fine and just won't scan. And because neither toCreateQR() nor the component that calls QRCode.generatePNG() wraps the call in a try/catch, feeding in something long enough to blow past version 40 (rare, but possible at high error correction with a lot of text) throws an uncaught exception — no friendly "too much text" message, it just quietly fails to render.
I cleaned up the version I built into a small free tool if you want custom colors, an adjustable error-correction level, and an instant PNG download without wiring up qrjs2 yourself: Online QR Code Generator. No sign-up, everything happens in the browser.
Available in other languages
- 線上QR code產生器 — 繁體中文
- 在线QR码生成器 — 简体中文
- Online QR Code Generator — English
- オンラインQRコード作成ツール — 日本語
- 무료 QR 코드 생성기 — 한국어
- Générateur de code QR en ligne — Français
- Генератор QR-кодов онлайн — Русский
- Online-QR-Code-Generator — Deutsch
- Pembuat Kode QR — Bahasa Indonesia
- Generador de códigos QR en línea — Español
- Trình tạo mã QR trực tuyến — Tiếng Việt
- เครื่องมือสร้าง QR Code ออนไลน์ — ไทย
- Generator kodów QR online — Polski
- Online QR Kod Oluşturucu — Türkçe
- Generatore di codici QR online — Italiano
- Gerador de QR Code online — Português
- Online QR-codegenerator — Nederlands
- Онлайн генератор QR-кодів — Українська