QR Code Generator — TypeScript source
Turn any text or URL into a downloadable QR code. Full ISO encoder with all error-correction levels, versions 1-40, SVG and PNG export, colors and quiet-zone control — runs entirely in your browser.
This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Pure QR code encoder — ISO/IEC 18004, versions 1-40, EC levels L/M/Q/H,
// numeric + alphanumeric + byte modes, all 8 masks with penalty auto-select.
// No React, no DOM (TextEncoder only). Deterministic; invalid input → null.
//
// Version/EC block structure comes from ./qr-tables (transcribed from the
// published spec tables); format and version information strings are computed
// at runtime via their BCH generators rather than transcribed.
import { EC_BLOCKS, ALIGNMENT_CENTERS, type EcLevelKey } from './qr-tables';
export type EcLevel = EcLevelKey;
export type DataMode = 'numeric' | 'alphanumeric' | 'byte';
export interface QrOptions {
ecLevel?: EcLevel;
minVersion?: number;
maxVersion?: number;
/** 0-7; automatic penalty selection when omitted. */
mask?: number;
}
export interface QrResult {
version: number;
ecLevel: EcLevel;
size: number;
mask: number;
mode: DataMode;
/** Row-major; modules[y][x]; true = dark. */
modules: boolean[][];
}
export interface SvgOptions {
scale?: number;
margin?: number;
fg?: string;
bg?: string;
}
// ---------------------------------------------------------------------------
// Modes and character sets
// ---------------------------------------------------------------------------
const ALNUM_CHARSET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:';
function pickMode(text: string): DataMode {
if (/^[0-9]+$/.test(text)) return 'numeric';
if (text.length > 0 && /^[0-9A-Z $%*+./:-]+$/.test(text)) return 'alphanumeric';
return 'byte';
}
function countBitsFor(mode: DataMode, version: number): number {
if (mode === 'byte') return version <= 9 ? 8 : 16;
return version <= 9 ? 9 : version <= 26 ? 11 : 13;
}
const MODE_INDICATOR: Record<DataMode, number> = {
numeric: 0b0001,
alphanumeric: 0b0010,
byte: 0b0100,
};
// ---------------------------------------------------------------------------
// BitBuffer — MSB-first packing
// ---------------------------------------------------------------------------
class BitBuffer {
private bits: number[] = [];
get length(): number {
return this.bits.length;
}
push(value: number, count: number): void {
for (let i = count - 1; i >= 0; i--) this.bits.push((value >>> i) & 1);
}
toBytes(): number[] {
// The caller pads to a byte boundary before calling, so every group is 8.
const out: number[] = [];
for (let i = 0; i < this.bits.length; i += 8) {
let byte = 0;
for (let b = 0; b < 8; b++) byte = (byte << 1) | this.bits[i + b];
out.push(byte);
}
return out;
}
}
// ---------------------------------------------------------------------------
// Reed-Solomon over GF(256), primitive polynomial 0x11D
// ---------------------------------------------------------------------------
const GF_EXP = new Uint8Array(256);
const GF_LOG = new Uint8Array(256);
{
let x = 1;
for (let i = 0; i < 255; i++) {
GF_EXP[i] = x;
GF_LOG[x] = i;
x <<= 1;
if (x & 0x100) x ^= 0x11d;
}
}
export function gfMul(a: number, b: number): number {
if (a === 0 || b === 0) return 0;
return GF_EXP[(GF_LOG[a] + GF_LOG[b]) % 255];
}
/** Generator polynomial of the given degree, coefficients highest-first, monic. */
function rsGenerator(degree: number): number[] {
let poly = [1];
for (let i = 0; i < degree; i++) {
const next = new Array<number>(poly.length + 1).fill(0);
for (let j = 0; j < poly.length; j++) {
next[j] ^= poly[j];
next[j + 1] ^= gfMul(poly[j], GF_EXP[i]);
}
poly = next;
}
return poly;
}
/** Error-correction codewords for `data` (synthetic division by the generator). */
function rsEncode(data: number[], ecLen: number): number[] {
const gen = rsGenerator(ecLen);
const rem = new Array<number>(ecLen).fill(0);
for (const byte of data) {
const factor = byte ^ rem[0];
rem.shift();
rem.push(0);
if (factor !== 0) {
for (let i = 0; i < ecLen; i++) rem[i] ^= gfMul(gen[i + 1], factor);
}
}
return rem;
}
// ---------------------------------------------------------------------------
// BCH format / version information (computed, not transcribed)
// ---------------------------------------------------------------------------
const EC_FORMAT_BITS: Record<EcLevel, number> = { L: 0b01, M: 0b00, Q: 0b11, H: 0b10 };
/** 15-bit format info string (MSB first) for (ecLevel, mask), incl. the 0x5412 mask. */
export function formatInfoString(ecLevel: EcLevel, mask: number): string {
const data = (EC_FORMAT_BITS[ecLevel] << 3) | mask;
let v = data << 10;
const G = 0x537;
for (let i = 14; i >= 10; i--) {
if (v & (1 << i)) v ^= G << (i - 10);
}
const rem = v & 0x3ff;
const full = ((data << 10) | rem) ^ 0x5412;
return full.toString(2).padStart(15, '0');
}
/** 18-bit version info string (MSB first) for versions >= 7. */
export function versionInfoString(version: number): string {
let v = version << 12;
const G = 0x1f25;
for (let i = 17; i >= 12; i--) {
if (v & (1 << i)) v ^= G << (i - 12);
}
const full = (version << 12) | (v & 0xfff);
return full.toString(2).padStart(18, '0');
}
// ---------------------------------------------------------------------------
// Matrix construction
// ---------------------------------------------------------------------------
class Matrix {
readonly size: number;
readonly modules: boolean[][];
readonly reserved: boolean[][];
constructor(size: number) {
this.size = size;
this.modules = Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
this.reserved = Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
}
set(x: number, y: number, dark: boolean): void {
this.modules[y][x] = dark;
this.reserved[y][x] = true;
}
/** Write a DATA module — marks nothing reserved, so masking still applies. */
write(x: number, y: number, dark: boolean): void {
this.modules[y][x] = dark;
}
/** Format/version cells are reserved now, written after masking. */
reserve(x: number, y: number): void {
this.reserved[y][x] = true;
}
drawFinder(x: number, y: number): void {
for (let dy = -1; dy <= 7; dy++) {
for (let dx = -1; dx <= 7; dx++) {
const X = x + dx;
const Y = y + dy;
if (X < 0 || X >= this.size || Y < 0 || Y >= this.size) continue;
const dark =
(dx >= 0 && dx <= 6 && (dy === 0 || dy === 6)) ||
(dy >= 0 && dy <= 6 && (dx === 0 || dx === 6)) ||
(dx >= 2 && dx <= 4 && dy >= 2 && dy <= 4);
this.set(X, Y, dark);
}
}
}
drawAlignment(cx: number, cy: number): void {
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
const dark = Math.max(Math.abs(dx), Math.abs(dy)) !== 1;
this.set(cx + dx, cy + dy, dark);
}
}
}
drawFunctionPatterns(version: number): void {
const size = this.size;
this.drawFinder(0, 0);
this.drawFinder(size - 7, 0);
this.drawFinder(0, size - 7);
for (let i = 8; i < size - 8; i++) {
const dark = i % 2 === 0;
this.set(i, 6, dark);
this.set(6, i, dark);
}
this.set(8, size - 8, true); // dark module: (col 8, row 4*version+9) = size-8
// Alignment patterns — skip the three that would overlap finders.
const centers = ALIGNMENT_CENTERS[version - 1];
const last = centers[centers.length - 1];
for (const cy of centers) {
for (const cx of centers) {
if ((cx === 6 && cy === 6) || (cx === 6 && cy === last) || (cx === last && cy === 6)) {
continue;
}
this.drawAlignment(cx, cy);
}
}
this.reserveFormatAreas(version);
}
/** Reserve both format copies + the version info areas (v >= 7). */
reserveFormatAreas(version: number): void {
const size = this.size;
for (let i = 0; i <= 8; i++) {
this.reserve(i, 8); // row 8, cols 0-8 (skip col 6 — already timing)
if (i !== 6) this.reserve(8, i); // col 8, rows 0-8
}
for (let i = 0; i < 8; i++) this.reserve(size - 1 - i, 8); // col 8, bottom 8 rows
for (let i = 0; i < 8; i++) this.reserve(8, size - 1 - i); // row 8, right 8 cols
if (version >= 7) {
// 3x6 + 6x3 blocks at bottom-right of the top-right finder and
// right of the bottom-left finder. Both copies mirror each other.
for (let i = 0; i < 18; i++) {
const a = size - 11 + (i % 3);
const b = Math.floor(i / 3);
this.reserve(a, b);
this.reserve(b, a);
}
}
}
drawFormatAndVersion(ecLevel: EcLevel, mask: number, version: number): void {
const size = this.size;
const format = formatInfoString(ecLevel, mask);
const bit = (i: number) => format[i] === '1';
// Copy 1 (top-left): row 8 cols 0-5,7,8 then col 8 rows 7,5..0 — MSB first.
const copy1: Array<[number, number]> = [
[0, 8], [1, 8], [2, 8], [3, 8], [4, 8], [5, 8], [7, 8], [8, 8],
[8, 7], [8, 5], [8, 4], [8, 3], [8, 2], [8, 1], [8, 0],
];
copy1.forEach(([x, y], i) => this.set(x, y, bit(i)));
// Copy 2: col 8 bottom 7 rows, then row 8 right 8 cols — MSB first.
for (let i = 0; i < 7; i++) this.set(8, size - 1 - i, bit(i));
for (let i = 7; i < 15; i++) this.set(size - 15 + i, 8, bit(i));
if (version >= 7) {
const vinfo = versionInfoString(version);
for (let i = 0; i < 18; i++) {
const dark = vinfo[i] === '1';
const a = size - 11 + (i % 3);
const b = Math.floor(i / 3);
this.set(a, b, dark);
this.set(b, a, dark);
}
}
}
}
// ---------------------------------------------------------------------------
// Masking and penalty
// ---------------------------------------------------------------------------
function maskBit(mask: number, x: number, y: number): boolean {
switch (mask) {
case 0:
return (x + y) % 2 === 0;
case 1:
return y % 2 === 0;
case 2:
return x % 3 === 0;
case 3:
return (x + y) % 3 === 0;
case 4:
return (Math.floor(y / 2) + Math.floor(x / 3)) % 2 === 0;
case 5:
return ((x * y) % 2) + ((x * y) % 3) === 0;
case 6:
return (((x * y) % 2) + ((x * y) % 3)) % 2 === 0;
default:
return (((x + y) % 2) + ((x * y) % 3)) % 2 === 0;
}
}
function penalty(matrix: Matrix): number {
const size = matrix.size;
const m = matrix.modules;
let score = 0;
// N1: runs of 5+ same color, rows and columns.
const runScore = (getter: (i: number, j: number) => boolean): number => {
let s = 0;
for (let i = 0; i < size; i++) {
let run = 1;
let color = getter(i, 0);
for (let j = 1; j < size; j++) {
const c = getter(i, j);
if (c === color) {
run++;
if (run === 5) s += 3;
else if (run > 5) s += 1;
} else {
run = 1;
color = c;
}
}
}
return s;
};
score += runScore((y, x) => m[y][x]);
score += runScore((x, y) => m[y][x]);
// N2: 2x2 blocks of one color.
for (let y = 0; y < size - 1; y++) {
for (let x = 0; x < size - 1; x++) {
const c = m[y][x];
if (c === m[y][x + 1] && c === m[y + 1][x] && c === m[y + 1][x + 1]) score += 3;
}
}
// N3: finder-like 1011101 with 4 light modules on one side.
const finderPenalty = (getter: (i: number, j: number) => boolean): number => {
let s = 0;
for (let i = 0; i < size; i++) {
for (let j = 0; j <= size - 11; j++) {
const seq = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0];
const seqRev = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1];
let fwd = true;
let rev = true;
for (let k = 0; k < 11; k++) {
const dark = getter(i, j + k);
if (dark !== (seq[k] === 1)) fwd = false;
if (dark !== (seqRev[k] === 1)) rev = false;
}
if (fwd || rev) s += 40;
}
}
return s;
};
score += finderPenalty((y, x) => m[y][x]);
score += finderPenalty((x, y) => m[y][x]);
// N4: dark-module ratio deviation from 50%.
let dark = 0;
for (const row of m) for (const cell of row) if (cell) dark++;
const total = size * size;
const k = Math.floor(Math.abs((dark * 100) / total - 50) / 5);
score += k * 10;
return score;
}
// ---------------------------------------------------------------------------
// Encoder pipeline
// ---------------------------------------------------------------------------
function dataCapacityCodewords(version: number, ecLevel: EcLevel): number {
const spec = EC_BLOCKS[ecLevel][version - 1];
return spec.blocks.reduce((sum, b) => sum + b.count * b.dataCodewords, 0);
}
function payloadBits(mode: DataMode, text: string, bytes: number[], version: number, buf: BitBuffer): void {
const count = mode === 'byte' ? bytes.length : text.length;
buf.push(MODE_INDICATOR[mode], 4);
buf.push(count, countBitsFor(mode, version));
if (mode === 'numeric') {
for (let i = 0; i < text.length; i += 3) {
const chunk = text.slice(i, i + 3);
const value = Number(chunk);
buf.push(value, chunk.length === 3 ? 10 : chunk.length === 2 ? 7 : 4);
}
} else if (mode === 'alphanumeric') {
for (let i = 0; i < text.length; i += 2) {
if (i + 1 < text.length) {
buf.push(ALNUM_CHARSET.indexOf(text[i]) * 45 + ALNUM_CHARSET.indexOf(text[i + 1]), 11);
} else {
buf.push(ALNUM_CHARSET.indexOf(text[i]), 6);
}
}
} else {
for (const byte of bytes) buf.push(byte, 8);
}
}
export function encode(text: string, opts: QrOptions = {}): QrResult | null {
const ecLevel: EcLevel = opts.ecLevel ?? 'M';
const minVersion = opts.minVersion ?? 1;
const maxVersion = Math.min(opts.maxVersion ?? 40, 40);
if (minVersion < 1 || minVersion > maxVersion) return null;
const mode = pickMode(text);
const bytes = mode === 'byte' ? Array.from(new TextEncoder().encode(text)) : [];
// Smallest version whose data capacity fits the payload.
let version = 0;
for (let v = minVersion; v <= maxVersion; v++) {
const count = mode === 'byte' ? bytes.length : text.length;
const needed = 4 + countBitsFor(mode, v) + estimatePayloadSize(mode, text, bytes);
if (needed <= dataCapacityCodewords(v, ecLevel) * 8) {
version = v;
break;
}
}
if (version === 0) return null;
const capacityBits = dataCapacityCodewords(version, ecLevel) * 8;
const buf = new BitBuffer();
payloadBits(mode, text, bytes, version, buf);
// Terminator (up to 4 bits), byte alignment, pad codewords.
const maxTerminator = Math.min(4, capacityBits - buf.length);
if (maxTerminator > 0) buf.push(0, maxTerminator);
while (buf.length % 8 !== 0) buf.push(0, 1);
const padBytes = [0xec, 0x11];
let padIndex = 0;
while (buf.length < capacityBits) {
buf.push(padBytes[padIndex % 2], 8);
padIndex++;
}
const dataCodewords = buf.toBytes();
// Split into blocks, compute EC, interleave.
const spec = EC_BLOCKS[ecLevel][version - 1];
const dataBlocks: number[][] = [];
for (const group of spec.blocks) {
for (let b = 0; b < group.count; b++) {
const offset = dataBlocks.reduce((sum, blk) => sum + blk.length, 0);
dataBlocks.push(dataCodewords.slice(offset, offset + group.dataCodewords));
}
}
const ecBlocks = dataBlocks.map((blk) => rsEncode(blk, spec.ecPerBlock));
const finalCodewords: number[] = [];
const maxData = Math.max(...dataBlocks.map((b) => b.length));
for (let i = 0; i < maxData; i++) {
for (const blk of dataBlocks) if (i < blk.length) finalCodewords.push(blk[i]);
}
for (let i = 0; i < spec.ecPerBlock; i++) {
for (const blk of ecBlocks) finalCodewords.push(blk[i]);
}
// Matrix: function patterns, zigzag data, mask, format/version info.
const size = version * 4 + 17;
const matrix = new Matrix(size);
matrix.drawFunctionPatterns(version);
const bitStream: boolean[] = [];
for (const cw of finalCodewords) {
for (let i = 7; i >= 0; i--) bitStream.push(((cw >>> i) & 1) === 1);
}
let bitIndex = 0;
let upward = true;
for (let right = size - 1; right >= 1; right -= 2) {
if (right === 6) right -= 1;
for (let vert = 0; vert < size; vert++) {
const y = upward ? size - 1 - vert : vert;
for (const x of [right, right - 1]) {
if (matrix.reserved[y][x]) continue;
matrix.write(x, y, bitIndex < bitStream.length ? bitStream[bitIndex] : false);
bitIndex++;
}
}
upward = !upward;
}
// Mask from a pristine post-placement base — candidates never accumulate.
const base = matrix.modules.map((row) => row.slice());
const applyToModules = (mask: number | null): void => {
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
matrix.modules[y][x] = base[y][x];
if (mask !== null && !matrix.reserved[y][x] && maskBit(mask, x, y)) {
matrix.modules[y][x] = !matrix.modules[y][x];
}
}
}
};
let chosenMask = 0;
if (opts.mask !== undefined) {
if (opts.mask < 0 || opts.mask > 7) return null;
chosenMask = opts.mask;
applyToModules(chosenMask);
} else {
let best = Infinity;
for (let mask = 0; mask < 8; mask++) {
applyToModules(mask);
const p = penalty(matrix);
if (p < best) {
best = p;
chosenMask = mask;
}
}
applyToModules(chosenMask);
}
matrix.drawFormatAndVersion(ecLevel, chosenMask, version);
return { version, ecLevel, size, mask: chosenMask, mode, modules: matrix.modules };
}
/** Payload bit count for the mode, independent of the count-indicator width. */
function estimatePayloadSize(mode: DataMode, text: string, bytes: number[]): number {
if (mode === 'numeric') {
const pairs = Math.floor(text.length / 3);
const rest = text.length - pairs * 3;
return pairs * 10 + (rest === 2 ? 7 : rest === 1 ? 4 : 0);
}
if (mode === 'alphanumeric') {
const pairs = Math.floor(text.length / 2);
const rest = text.length - pairs * 2;
return pairs * 11 + (rest === 1 ? 6 : 0);
}
return bytes.length * 8;
}
// ---------------------------------------------------------------------------
// SVG rendering
// ---------------------------------------------------------------------------
export function toSvg(res: QrResult, o: SvgOptions = {}): string {
const scale = o.scale ?? 4;
const margin = o.margin ?? 4;
const fg = o.fg ?? '#000000';
const bg = o.bg ?? '#ffffff';
const dim = (res.size + margin * 2) * scale;
let path = '';
for (let y = 0; y < res.size; y++) {
for (let x = 0; x < res.size; x++) {
if (!res.modules[y][x]) continue;
const px = (x + margin) * scale;
const py = (y + margin) * scale;
path += `M${px} ${py}h${scale}v${scale}h-${scale}z`;
}
}
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="${dim}" height="${dim}" viewBox="0 0 ${dim} ${dim}" ` +
`shape-rendering="crispEdges" role="img" aria-label="QR code">` +
`<rect width="${dim}" height="${dim}" fill="${bg}"/>` +
`<path d="${path}" fill="${fg}"/>` +
`</svg>`
);
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →