// QR codes made on this server (ISO/IEC 18004, byte mode, error correction M), so the authenticator // secret in an otpauth:// address never goes to another service to be drawn (ACC-7). Pure: the // same text always gives the same SVG. Follows Project Nayuki's reference QR generator (MIT). const ECC_PER_BLOCK_M = [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28]; const BLOCKS_M = [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49]; /** The format bits name the level: M is 0. */ const LEVEL_M = 0; export type QrModules = boolean[][]; function rawDataModules(version: number): number { let result = (16 * version + 128) * version + 64; if (version >= 2) { const align = Math.floor(version / 7) + 2; result -= (25 * align - 10) * align - 55; if (version >= 7) result -= 36; } return result; } const dataCodewords = (version: number) => Math.floor(rawDataModules(version) / 8) - ECC_PER_BLOCK_M[version] * BLOCKS_M[version]; // Reed-Solomon over GF(2^8) with the polynomial 0x11D. 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 & 0xff; } function rsDivisor(degree: number): number[] { const result = new Array(degree).fill(0); result[degree - 1] = 1; let root = 1; for (let i = 0; i < degree; i++) { for (let j = 0; j < degree; j++) { result[j] = gfMultiply(result[j], root); if (j + 1 < degree) result[j] ^= result[j + 1]; } root = gfMultiply(root, 0x02); } return result; } function rsRemainder(data: number[], divisor: number[]): number[] { const result = new Array(divisor.length).fill(0); for (const byte of data) { const factor = byte ^ result.shift()!; result.push(0); divisor.forEach((coefficient, i) => { result[i] ^= gfMultiply(coefficient, factor); }); } return result; } function codewordsFor(bytes: Uint8Array): { version: number; codewords: number[] } { let version = 1; const bitsFor = (v: number) => 4 + (v <= 9 ? 8 : 16) + bytes.length * 8; while (version <= 40 && bitsFor(version) > dataCodewords(version) * 8) version++; if (version > 40) throw new Error('Too long for a QR code'); const bits: number[] = []; const push = (value: number, length: number) => { for (let i = length - 1; i >= 0; i--) bits.push((value >>> i) & 1); }; push(0b0100, 4); push(bytes.length, version <= 9 ? 8 : 16); bytes.forEach((byte) => push(byte, 8)); const capacity = dataCodewords(version) * 8; push(0, Math.min(4, capacity - bits.length)); push(0, (8 - (bits.length % 8)) % 8); for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11) push(pad, 8); const data: number[] = []; for (let i = 0; i < bits.length; i += 8) data.push(bits.slice(i, i + 8).reduce((byte, bit) => (byte << 1) | bit, 0)); // Split into blocks, add each block's error correction, then interleave. const blocks = BLOCKS_M[version]; const eccLength = ECC_PER_BLOCK_M[version]; const raw = Math.floor(rawDataModules(version) / 8); const shortBlocks = blocks - (raw % blocks); const shortLength = Math.floor(raw / blocks); const divisor = rsDivisor(eccLength); const parts: number[][] = []; for (let i = 0, k = 0; i < blocks; i++) { const block = data.slice(k, k + shortLength - eccLength + (i < shortBlocks ? 0 : 1)); k += block.length; const ecc = rsRemainder(block, divisor); if (i < shortBlocks) block.push(0); parts.push([...block, ...ecc]); } const codewords: number[] = []; for (let i = 0; i < parts[0].length; i++) { parts.forEach((part, j) => { if (i !== shortLength - eccLength || j >= shortBlocks) codewords.push(part[i]); }); } return { version, codewords }; } function alignmentPositions(version: number): number[] { if (version === 1) return []; const count = Math.floor(version / 7) + 2; const step = version === 32 ? 26 : Math.ceil((version * 4 + 4) / (count * 2 - 2)) * 2; const result = [6]; for (let pos = version * 4 + 17 - 7; result.length < count; pos -= step) result.splice(1, 0, pos); return result; } const MASKS: Array<(x: number, y: number) => boolean> = [ (x, y) => (x + y) % 2 === 0, (_x, y) => y % 2 === 0, (x) => x % 3 === 0, (x, y) => (x + y) % 3 === 0, (x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0, (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0, (x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0, (x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0, ]; class Grid { readonly size: number; readonly modules: QrModules; readonly fixed: boolean[][]; constructor(readonly version: number) { this.size = version * 4 + 17; this.modules = Array.from({ length: this.size }, () => new Array(this.size).fill(false)); this.fixed = Array.from({ length: this.size }, () => new Array(this.size).fill(false)); } set(x: number, y: number, dark: boolean) { this.modules[y][x] = dark; this.fixed[y][x] = true; } drawFunctionPatterns() { for (let i = 0; i < this.size; i++) { this.set(6, i, i % 2 === 0); this.set(i, 6, i % 2 === 0); } for (const [cx, cy] of [[3, 3], [this.size - 4, 3], [3, this.size - 4]]) { for (let dy = -4; dy <= 4; dy++) for (let dx = -4; dx <= 4; dx++) { const x = cx + dx; const y = cy + dy; const distance = Math.max(Math.abs(dx), Math.abs(dy)); if (x >= 0 && x < this.size && y >= 0 && y < this.size) this.set(x, y, distance !== 2 && distance !== 4); } } const positions = alignmentPositions(this.version); const last = positions.length - 1; positions.forEach((px, i) => positions.forEach((py, j) => { if ((i === 0 && j === 0) || (i === 0 && j === last) || (i === last && j === 0)) return; for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) this.set(px + dx, py + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1); })); this.drawFormat(0); if (this.version >= 7) { let rem = this.version; for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25); const bits = (this.version << 12) | rem; for (let i = 0; i < 18; i++) { const dark = ((bits >>> i) & 1) !== 0; const a = this.size - 11 + (i % 3); const b = Math.floor(i / 3); this.set(a, b, dark); this.set(b, a, dark); } } } drawFormat(mask: number) { const data = (LEVEL_M << 3) | mask; let rem = data; for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537); const bits = ((data << 10) | rem) ^ 0x5412; const bit = (i: number) => ((bits >>> i) & 1) !== 0; for (let i = 0; i <= 5; i++) this.set(8, i, bit(i)); this.set(8, 7, bit(6)); this.set(8, 8, bit(7)); this.set(7, 8, bit(8)); for (let i = 9; i < 15; i++) this.set(14 - i, 8, bit(i)); for (let i = 0; i < 8; i++) this.set(this.size - 1 - i, 8, bit(i)); for (let i = 8; i < 15; i++) this.set(8, this.size - 15 + i, bit(i)); this.set(8, this.size - 8, true); } drawCodewords(codewords: number[]) { let i = 0; for (let right = this.size - 1; right >= 1; right -= 2) { if (right === 6) right = 5; for (let vert = 0; vert < this.size; vert++) for (let j = 0; j < 2; j++) { const x = right - j; const y = ((right + 1) & 2) === 0 ? this.size - 1 - vert : vert; if (!this.fixed[y][x] && i < codewords.length * 8) { this.modules[y][x] = ((codewords[i >>> 3] >>> (7 - (i & 7))) & 1) !== 0; i++; } } } } applyMask(mask: number) { for (let y = 0; y < this.size; y++) for (let x = 0; x < this.size; x++) { if (!this.fixed[y][x] && MASKS[mask](x, y)) this.modules[y][x] = !this.modules[y][x]; } } /** The standard's penalty (runs, finder-like patterns, 2x2 blocks, dark balance): lower is easier to read. */ penalty(): number { let score = 0; const lines = [...this.modules, ...this.modules.map((_row, x) => this.modules.map((row) => row[x]))]; for (const line of lines) { let color = false; let run = 0; const history = [0, 0, 0, 0, 0, 0, 0]; for (const dark of line) { if (dark === color) { run++; if (run === 5) score += 3; else if (run > 5) score++; } else { this.addHistory(run, history); if (!color) score += this.finderLike(history) * 40; color = dark; run = 1; } } if (color) { this.addHistory(run, history); run = 0; } this.addHistory(run + this.size, history); score += this.finderLike(history) * 40; } for (let y = 0; y + 1 < this.size; y++) for (let x = 0; x + 1 < this.size; x++) { const dark = this.modules[y][x]; if (dark === this.modules[y][x + 1] && dark === this.modules[y + 1][x] && dark === this.modules[y + 1][x + 1]) score += 3; } const total = this.size * this.size; const darkCount = this.modules.reduce((sum, row) => sum + row.filter(Boolean).length, 0); score += (Math.ceil(Math.abs(darkCount * 20 - total * 10) / total) - 1) * 10; return score; } /** Run lengths, newest first; the light border counts before the first run and after the last. */ private addHistory(run: number, history: number[]) { const first = history[0] === 0; history.pop(); history.unshift(first ? run + this.size : run); } private finderLike(history: number[]): number { const n = history[1]; const core = n > 0 && history[2] === n && history[3] === n * 3 && history[4] === n && history[5] === n; return (core && history[0] >= n * 4 && history[6] >= n ? 1 : 0) + (core && history[6] >= n * 4 && history[0] >= n ? 1 : 0); } } /** The modules of a QR code for this text, true for dark, without the quiet zone. */ export function qrModules(text: string): QrModules { const { version, codewords } = codewordsFor(new TextEncoder().encode(text)); const grid = new Grid(version); grid.drawFunctionPatterns(); grid.drawCodewords(codewords); let best = 0; let bestScore = Infinity; for (let mask = 0; mask < 8; mask++) { grid.applyMask(mask); grid.drawFormat(mask); const score = grid.penalty(); if (score < bestScore) { best = mask; bestScore = score; } grid.applyMask(mask); } grid.applyMask(best); grid.drawFormat(best); return grid.modules; } /** An SVG of the QR code with a four-module quiet zone, black on white. */ export function qrSvg(text: string): string { const modules = qrModules(text); const size = modules.length + 8; const path = modules.flatMap((row, y) => row.map((dark, x) => (dark ? `M${x + 4},${y + 4}h1v1h-1z` : ''))).join(''); return `` + ``; }