/* A minimal QR encoder, byte mode, error-correction level M. * * Written rather than imported because the arcade has to work on a network * with no internet: a CDN script is a dependency on the outside world, and the * whole point of this box is that it does not need one. * * Scope is deliberately narrow — byte mode, versions 1 through 20, level M — * which covers a Lightning invoice with room to spare and leaves out the * kanji/numeric modes and structured-append machinery that would triple the * size for no benefit here. * * Reference: ISO/IEC 18004. */ /* ---------- Galois field arithmetic over GF(256) ---------- * Reed–Solomon needs multiplication in the field the QR spec defines, with * the primitive polynomial 0x11d. Log tables make that a lookup. */ const EXP = new Uint8Array(512); const LOG = new Uint8Array(256); (function buildTables() { let x = 1; for (let i = 0; i < 255; i++) { EXP[i] = x; LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; } for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255]; })(); function gfMul(a, b) { if (a === 0 || b === 0) return 0; return EXP[LOG[a] + LOG[b]]; } /* The generator polynomial for n error-correction codewords. */ function rsGenerator(n) { let poly = [1]; for (let i = 0; i < n; i++) { const next = new Array(poly.length + 1).fill(0); for (let j = 0; j < poly.length; j++) { next[j] ^= poly[j]; next[j + 1] ^= gfMul(poly[j], EXP[i]); } poly = next; } return poly; } function rsEncode(data, ecCount) { const gen = rsGenerator(ecCount); const res = new Uint8Array(data.length + ecCount); res.set(data); for (let i = 0; i < data.length; i++) { const factor = res[i]; if (factor === 0) continue; for (let j = 0; j < gen.length; j++) { res[i + j] ^= gfMul(gen[j], factor); } } return res.slice(data.length); } /* ---------- capacity tables, level M ---------- * [total codewords, ec codewords per block, block count group1, * data codewords group1, block count group2, data codewords group2] */ const VERSIONS = [ null, [26, 10, 1, 16, 0, 0], // 1 [44, 16, 1, 28, 0, 0], [70, 26, 1, 44, 0, 0], [100, 18, 2, 32, 0, 0], [134, 24, 2, 43, 0, 0], [172, 16, 4, 27, 0, 0], [196, 18, 4, 31, 0, 0], [242, 22, 2, 38, 2, 39], [292, 22, 3, 36, 2, 37], [346, 26, 4, 43, 1, 44], // 10 [404, 30, 1, 50, 4, 51], [466, 22, 6, 36, 2, 37], [532, 22, 8, 37, 1, 38], [581, 24, 4, 40, 5, 41], [655, 24, 5, 41, 5, 42], // 15 [733, 28, 7, 45, 3, 46], [815, 28, 10, 46, 1, 47], [901, 26, 9, 43, 4, 44], [991, 26, 3, 44, 11, 45], [1085, 26, 3, 41, 13, 42], // 20 ]; function versionCapacity(v) { const [, ec, b1, d1, b2, d2] = VERSIONS[v]; return b1 * d1 + b2 * d2; } /* Alignment pattern centres per version. */ const ALIGN = [ [], [], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34], [6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50], [6, 30, 54], [6, 32, 58], [6, 34, 62], [6, 26, 46, 66], [6, 26, 48, 70], [6, 26, 50, 74], [6, 30, 54, 78], [6, 30, 56, 82], [6, 30, 58, 86], [6, 34, 62, 90], ]; /* ---------- bit stream ---------- */ class Bits { constructor() { this.bits = []; } push(value, length) { for (let i = length - 1; i >= 0; i--) this.bits.push((value >> i) & 1); } get length() { return this.bits.length; } toBytes() { const out = new Uint8Array(Math.ceil(this.bits.length / 8)); this.bits.forEach((b, i) => { if (b) out[i >> 3] |= 0x80 >> (i & 7); }); return out; } } /* ---------- encoding ---------- */ function encodeData(text, version) { const bytes = new TextEncoder().encode(text); const bits = new Bits(); bits.push(0b0100, 4); // byte mode bits.push(bytes.length, version < 10 ? 8 : 16); // length field for (const b of bytes) bits.push(b, 8); const capacityBits = versionCapacity(version) * 8; if (bits.length > capacityBits) return null; // does not fit // Terminator, then pad to a byte boundary, then alternating pad bytes. bits.push(0, Math.min(4, capacityBits - bits.length)); while (bits.length % 8 !== 0) bits.push(0, 1); const data = Array.from(bits.toBytes()); const padBytes = [0xec, 0x11]; let i = 0; while (data.length < versionCapacity(version)) data.push(padBytes[i++ % 2]); return interleave(data, version); } /* Split into blocks, compute error correction, then interleave both — the * spec's arrangement, which is what makes a QR survive damage to any one * region rather than losing a contiguous run of data. */ function interleave(data, version) { const [, ecPerBlock, b1, d1, b2, d2] = VERSIONS[version]; const blocks = []; let offset = 0; for (let i = 0; i < b1; i++) { blocks.push(data.slice(offset, offset + d1)); offset += d1; } for (let i = 0; i < b2; i++) { blocks.push(data.slice(offset, offset + d2)); offset += d2; } const ecBlocks = blocks.map((b) => rsEncode(Uint8Array.from(b), ecPerBlock)); const out = []; const maxData = Math.max(...blocks.map((b) => b.length)); for (let i = 0; i < maxData; i++) { for (const b of blocks) if (i < b.length) out.push(b[i]); } for (let i = 0; i < ecPerBlock; i++) { for (const b of ecBlocks) out.push(b[i]); } return out; } /* ---------- matrix ---------- */ function buildMatrix(version, codewords, mask) { const size = version * 4 + 17; const m = Array.from({ length: size }, () => new Array(size).fill(null)); const setFinder = (r, c) => { for (let dr = -1; dr <= 7; dr++) { for (let dc = -1; dc <= 7; dc++) { const rr = r + dr, cc = c + dc; if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue; const inRing = dr >= 0 && dr <= 6 && dc >= 0 && dc <= 6 && (dr === 0 || dr === 6 || dc === 0 || dc === 6 || (dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4)); m[rr][cc] = inRing ? 1 : 0; } } }; setFinder(0, 0); setFinder(0, size - 7); setFinder(size - 7, 0); // Timing patterns. for (let i = 8; i < size - 8; i++) { m[6][i] = i % 2 === 0 ? 1 : 0; m[i][6] = i % 2 === 0 ? 1 : 0; } // Alignment patterns, skipping those that would collide with finders. const centres = ALIGN[version]; for (const r of centres) { for (const c of centres) { if ((r <= 8 && c <= 8) || (r <= 8 && c >= size - 9) || (r >= size - 9 && c <= 8)) continue; for (let dr = -2; dr <= 2; dr++) { for (let dc = -2; dc <= 2; dc++) { m[r + dr][c + dc] = Math.max(Math.abs(dr), Math.abs(dc)) !== 1 ? 1 : 0; } } } } m[size - 8][8] = 1; // dark module // Reserve format areas so data placement skips them. const reserveFormat = () => { for (let i = 0; i < 9; i++) { if (m[8][i] === null) m[8][i] = 0; if (m[i][8] === null) m[i][8] = 0; } for (let i = 0; i < 8; i++) { if (m[8][size - 1 - i] === null) m[8][size - 1 - i] = 0; if (m[size - 1 - i][8] === null) m[size - 1 - i][8] = 0; } }; const formatCells = []; for (let r = 0; r < size; r++) { for (let c = 0; c < size; c++) if (m[r][c] === null) formatCells.push([r, c]); } reserveFormat(); // Version information, for version 7 and above. if (version >= 7) { let rem = version; for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >> 11) * 0x1f25); const bits = (version << 12) | rem; for (let i = 0; i < 18; i++) { const bit = (bits >> i) & 1; m[Math.floor(i / 3)][size - 11 + (i % 3)] = bit; m[size - 11 + (i % 3)][Math.floor(i / 3)] = bit; } } // Data placement: upward/downward in two-column strips, right to left. let bitIndex = 0; const dataBits = []; for (const cw of codewords) { for (let i = 7; i >= 0; i--) dataBits.push((cw >> i) & 1); } let upward = true; for (let col = size - 1; col > 0; col -= 2) { if (col === 6) col--; // skip the timing column for (let i = 0; i < size; i++) { const row = upward ? size - 1 - i : i; for (let c = 0; c < 2; c++) { const cc = col - c; if (m[row][cc] !== null) continue; let bit = bitIndex < dataBits.length ? dataBits[bitIndex++] : 0; if (maskAt(mask, row, cc)) bit ^= 1; m[row][cc] = bit; } } upward = !upward; } writeFormat(m, size, mask); return m; } function maskAt(mask, r, c) { switch (mask) { case 0: return (r + c) % 2 === 0; case 1: return r % 2 === 0; case 2: return c % 3 === 0; case 3: return (r + c) % 3 === 0; case 4: return (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0; case 5: return ((r * c) % 2) + ((r * c) % 3) === 0; case 6: return (((r * c) % 2) + ((r * c) % 3)) % 2 === 0; default: return (((r + c) % 2) + ((r * c) % 3)) % 2 === 0; } } function writeFormat(m, size, mask) { // Level M is 0b00; combine with the mask and append BCH error correction. const data = (0b00 << 3) | mask; let rem = data; for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >> 9) * 0x537); const bits = ((data << 10) | rem) ^ 0x5412; for (let i = 0; i <= 5; i++) m[8][i] = (bits >> i) & 1; m[8][7] = (bits >> 6) & 1; m[8][8] = (bits >> 7) & 1; m[7][8] = (bits >> 8) & 1; for (let i = 9; i < 15; i++) m[14 - i][8] = (bits >> i) & 1; // The second copy is 7 bits down the bottom-left column and 8 bits along // the top-right row. It is 7 and not 8 down the column because the cell // below is the dark module, which is fixed and must not be overwritten. for (let i = 0; i <= 6; i++) m[size - 1 - i][8] = (bits >> i) & 1; for (let i = 7; i < 15; i++) m[8][size - 15 + i] = (bits >> i) & 1; } /* ---------- public API ---------- */ /* encode returns a square matrix of 0/1, or null if the text does not fit. */ export function encode(text) { for (let version = 1; version <= 20; version++) { const codewords = encodeData(text, version); if (codewords) { // Mask 0 is used unconditionally. Choosing the optimal mask by penalty // score improves scan reliability marginally and costs four more passes // over the matrix; at the sizes here, every reader handles mask 0. return buildMatrix(version, codewords, 0); } } return null; } /* render draws a matrix into an SVG element, scaled to its container. */ export function render(matrix, options = {}) { const quiet = options.quiet ?? 4; const size = matrix.length; const total = size + quiet * 2; const NS = 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(NS, 'svg'); svg.setAttribute('viewBox', `0 0 ${total} ${total}`); svg.setAttribute('width', '100%'); svg.setAttribute('height', '100%'); svg.setAttribute('shape-rendering', 'crispEdges'); const bg = document.createElementNS(NS, 'rect'); bg.setAttribute('width', total); bg.setAttribute('height', total); bg.setAttribute('fill', options.background ?? '#000603'); svg.appendChild(bg); // One path for every dark module: far fewer nodes than a rect each, which // matters when a phone is re-rendering this inside a live page. let d = ''; for (let r = 0; r < size; r++) { for (let c = 0; c < size; c++) { if (matrix[r][c]) d += `M${c + quiet} ${r + quiet}h1v1h-1z`; } } const path = document.createElementNS(NS, 'path'); path.setAttribute('d', d); path.setAttribute('fill', options.foreground ?? '#00ff9c'); svg.appendChild(path); return svg; }