// Self-contained QR Code (model 2) encoder — byte mode, versions 1-40, all four // ECC levels. We render our own QR locally so a join/admin link's secret // #fragment never has to leave the browser for some third-party QR service. // // qrMatrix is pure (no DOM, no globals) so it can be unit-tested in Node; the // only browser-only piece is qrCanvas. Tables and the placement/mask algorithm // follow ISO/IEC 18004. const ECL_INDEX = { L: 0, M: 1, Q: 2, H: 3 }; // The 2-bit EC indicator written into the format information is NOT in L/M/Q/H // numeric order: L=01, M=00, Q=11, H=10 per the spec. const ECL_FORMAT_BITS = { L: 1, M: 0, Q: 3, H: 2 }; // EC codewords per block, indexed [eclIndex][version]. Index 0 of each row is a // placeholder so version can index directly. (Nayuki's tables — the part most // worth copying verbatim rather than re-deriving.) const ECC_CODEWORDS_PER_BLOCK = [ [-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], [-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], [-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], [-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], ]; // Number of EC blocks, indexed [eclIndex][version]. const NUM_ERROR_CORRECTION_BLOCKS = [ [-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25], [-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], [-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68], [-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81], ]; const MIN_VERSION = 1; const MAX_VERSION = 40; const PENALTY_N1 = 3; const PENALTY_N2 = 3; const PENALTY_N3 = 40; const PENALTY_N4 = 10; // Number of data+EC modules available in a version, before subtracting function // patterns' overhead — i.e. the bits the codeword stream is laid into. function numRawDataModules(version) { let result = (16 * version + 128) * version + 64; if (version >= 2) { const numAlign = Math.floor(version / 7) + 2; result -= (25 * numAlign - 10) * numAlign - 55; if (version >= 7) result -= 36; // two version-information blocks } return result; } function numDataCodewords(version, eclIndex) { return ( Math.floor(numRawDataModules(version) / 8) - ECC_CODEWORDS_PER_BLOCK[eclIndex][version] * NUM_ERROR_CORRECTION_BLOCKS[eclIndex][version] ); } // Byte-mode character-count indicator width grows with version. function charCountBits(version) { return version <= 9 ? 8 : 16; } // GF(256) multiply with primitive polynomial 0x11D, via Russian-peasant so we // avoid log/antilog tables. function gfMul(x, y) { let z = 0; for (let i = 7; i >= 0; i--) { z = (z << 1) ^ ((z >>> 7) * 0x11d); z ^= ((y >>> i) & 1) * x; } return z & 0xff; } // Generator polynomial coefficients (degree `ecLen`) for Reed–Solomon. function rsDivisor(ecLen) { const result = new Uint8Array(ecLen); result[ecLen - 1] = 1; // start with the monomial x^0 let root = 1; for (let i = 0; i < ecLen; i++) { // multiply the current product by (x - r^i) for (let j = 0; j < result.length; j++) { result[j] = gfMul(result[j], root); if (j + 1 < result.length) result[j] ^= result[j + 1]; } root = gfMul(root, 0x02); } return result; } function rsRemainder(data, divisor) { const result = new Uint8Array(divisor.length); for (const b of data) { const factor = b ^ result[0]; result.copyWithin(0, 1); result[result.length - 1] = 0; for (let i = 0; i < result.length; i++) { result[i] ^= gfMul(divisor[i], factor); } } return result; } // Build the bit-padded data codeword stream for the chosen version: mode, // count, payload, terminator, byte-align, then the alternating EC/11 pad bytes. function dataCodewords(bytes, version, eclIndex) { const capacityBits = numDataCodewords(version, eclIndex) * 8; const bits = []; const push = (value, len) => { for (let i = len - 1; i >= 0; i--) bits.push((value >>> i) & 1); }; push(0b0100, 4); // byte mode push(bytes.length, charCountBits(version)); for (const b of bytes) push(b, 8); push(0, Math.min(4, capacityBits - bits.length)); // terminator push(0, (8 - (bits.length % 8)) % 8); // align to byte boundary for (let pad = 0xec; bits.length < capacityBits; pad ^= 0xec ^ 0x11) { push(pad, 8); } const out = new Uint8Array(bits.length / 8); for (let i = 0; i < bits.length; i++) { out[i >> 3] |= bits[i] << (7 - (i & 7)); } return out; } // Split data into blocks, append RS error correction to each, then interleave // data-then-EC across blocks as the spec requires. function addEccAndInterleave(data, version, eclIndex) { const numBlocks = NUM_ERROR_CORRECTION_BLOCKS[eclIndex][version]; const eccLen = ECC_CODEWORDS_PER_BLOCK[eclIndex][version]; const rawCodewords = Math.floor(numRawDataModules(version) / 8); const numShort = numBlocks - (rawCodewords % numBlocks); const shortLen = Math.floor(rawCodewords / numBlocks); const divisor = rsDivisor(eccLen); const blocks = []; for (let i = 0, k = 0; i < numBlocks; i++) { const datLen = shortLen - eccLen + (i < numShort ? 0 : 1); const dat = data.slice(k, k + datLen); k += datLen; blocks.push({ dat, ecc: rsRemainder(dat, divisor) }); } const result = new Uint8Array(rawCodewords); let idx = 0; const maxDatLen = shortLen - eccLen + 1; for (let i = 0; i < maxDatLen; i++) { for (const block of blocks) { // short blocks omit their final data codeword from the last column if (i < block.dat.length) result[idx++] = block.dat[i]; } } for (let i = 0; i < eccLen; i++) { for (const block of blocks) result[idx++] = block.ecc[i]; } return result; } // Alignment-pattern centre coordinates for a version (empty for version 1). function alignmentPositions(version) { if (version === 1) return []; const size = version * 4 + 17; const numAlign = Math.floor(version / 7) + 2; const step = version === 32 ? 26 : Math.ceil((version * 4 + 4) / (numAlign * 2 - 2)) * 2; const result = [6]; for (let pos = size - 7; result.length < numAlign; pos -= step) { result.splice(1, 0, pos); } return result; } // A QR symbol under construction: holds the module grid plus a parallel mask of // which cells are immutable function patterns. class QrSymbol { constructor(version, eclIndex) { this.version = version; this.eclIndex = eclIndex; this.ecl = ["L", "M", "Q", "H"][eclIndex]; this.size = version * 4 + 17; this.modules = Array.from({ length: this.size }, () => new Array(this.size).fill(false) ); this.isFunction = Array.from({ length: this.size }, () => new Array(this.size).fill(false) ); } set(x, y, dark) { this.modules[y][x] = dark; this.isFunction[y][x] = true; } drawFinder(cx, cy) { for (let dy = -4; dy <= 4; dy++) { for (let dx = -4; dx <= 4; dx++) { const x = cx + dx; const y = cy + dy; if (x < 0 || x >= this.size || y < 0 || y >= this.size) continue; const dist = Math.max(Math.abs(dx), Math.abs(dy)); this.set(x, y, dist !== 2 && dist !== 4); // ring + centre, with separator } } } drawAlignment(cx, cy) { for (let dy = -2; dy <= 2; dy++) { for (let dx = -2; dx <= 2; dx++) { this.set(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1); } } } drawFunctionPatterns() { for (let i = 0; i < this.size; i++) { this.set(6, i, i % 2 === 0); // vertical timing this.set(i, 6, i % 2 === 0); // horizontal timing } this.drawFinder(3, 3); this.drawFinder(this.size - 4, 3); this.drawFinder(3, this.size - 4); const pos = alignmentPositions(this.version); const n = pos.length; for (let i = 0; i < n; i++) { for (let j = 0; j < n; j++) { // skip the three that collide with the finder patterns if ( (i === 0 && j === 0) || (i === 0 && j === n - 1) || (i === n - 1 && j === 0) ) { continue; } this.drawAlignment(pos[i], pos[j]); } } // Reserve format/version areas (real bits drawn later); dummy mask is fine. this.drawFormatBits(0); this.drawVersion(); } drawFormatBits(mask) { const data = (ECL_FORMAT_BITS[this.ecl] << 3) | mask; // 5 bits let rem = data; for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >> 9) * 0x537); const bits = ((data << 10) | rem) ^ 0x5412; // 15 bits, masked per spec const bit = (i) => ((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); // always-dark module } drawVersion() { if (this.version < 7) return; let rem = this.version; for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >> 11) * 0x1f25); const bits = (this.version << 12) | rem; // 18 bits 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); } } // Lay the interleaved codeword stream into the data region, zig-zagging up and // down pairs of columns from the right, skipping the vertical timing column. drawCodewords(data) { 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 upward = ((right + 1) & 2) === 0; const y = upward ? this.size - 1 - vert : vert; if (!this.isFunction[y][x] && i < data.length * 8) { this.modules[y][x] = ((data[i >> 3] >> (7 - (i & 7))) & 1) !== 0; i++; } } } } } maskBit(mask, x, y) { 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(x / 3) + Math.floor(y / 2)) % 2 === 0; case 5: return ((x * y) % 2) + ((x * y) % 3) === 0; case 6: return (((x * y) % 2) + ((x * y) % 3)) % 2 === 0; case 7: return (((x + y) % 2) + ((x * y) % 3)) % 2 === 0; default: throw new Error("bad mask"); } } applyMask(mask) { for (let y = 0; y < this.size; y++) { for (let x = 0; x < this.size; x++) { if (!this.isFunction[y][x] && this.maskBit(mask, x, y)) { this.modules[y][x] = !this.modules[y][x]; } } } } penaltyScore() { let result = 0; const size = this.size; const addHistory = (runLen, history) => { if (history[0] === 0) runLen += size; // light border before a leading run history.pop(); history.unshift(runLen); }; // A 1:1:3:1:1 dark/light pattern flanked by 4+ light modules (the finder // look-alike) costs N3 each. const countPatterns = (h) => { const n = h[1]; const core = n > 0 && h[2] === n && h[3] === n * 3 && h[4] === n && h[5] === n; return ( (core && h[0] >= n * 4 && h[6] >= n ? 1 : 0) + (core && h[6] >= n * 4 && h[0] >= n ? 1 : 0) ); }; const terminateAndCount = (runColor, runLen, history) => { if (runColor) { addHistory(runLen, history); runLen = 0; } runLen += size; // trailing light border addHistory(runLen, history); return countPatterns(history); }; // Rule 1 + Rule 3, scanned along rows then columns. for (let y = 0; y < size; y++) { let runColor = false; let runLen = 0; const history = [0, 0, 0, 0, 0, 0, 0]; for (let x = 0; x < size; x++) { if (this.modules[y][x] === runColor) { runLen++; if (runLen === 5) result += PENALTY_N1; else if (runLen > 5) result++; } else { addHistory(runLen, history); if (!runColor) result += countPatterns(history) * PENALTY_N3; runColor = this.modules[y][x]; runLen = 1; } } result += terminateAndCount(runColor, runLen, history) * PENALTY_N3; } for (let x = 0; x < size; x++) { let runColor = false; let runLen = 0; const history = [0, 0, 0, 0, 0, 0, 0]; for (let y = 0; y < size; y++) { if (this.modules[y][x] === runColor) { runLen++; if (runLen === 5) result += PENALTY_N1; else if (runLen > 5) result++; } else { addHistory(runLen, history); if (!runColor) result += countPatterns(history) * PENALTY_N3; runColor = this.modules[y][x]; runLen = 1; } } result += terminateAndCount(runColor, runLen, history) * PENALTY_N3; } // Rule 2: every 2x2 block of one colour costs N2. for (let y = 0; y < size - 1; y++) { for (let x = 0; x < size - 1; x++) { const c = this.modules[y][x]; if ( c === this.modules[y][x + 1] && c === this.modules[y + 1][x] && c === this.modules[y + 1][x + 1] ) { result += PENALTY_N2; } } } // Rule 4: deviation of dark-module proportion from 50%. let dark = 0; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) if (this.modules[y][x]) dark++; } const total = size * size; const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; result += k * PENALTY_N4; return result; } } // Pick the smallest version whose data capacity fits the byte payload at the // requested ECC level. function chooseVersion(byteLen, eclIndex) { for (let version = MIN_VERSION; version <= MAX_VERSION; version++) { const capacityBits = numDataCodewords(version, eclIndex) * 8; const neededBits = 4 + charCountBits(version) + 8 * byteLen; if (neededBits <= capacityBits) return version; } return -1; } // Returns { size, modules } where modules[y][x] is true for a dark module. // Throws if the text is too long for version 40 at the given ECC level. export function qrMatrix(text, ecl = "M") { const eclIndex = ECL_INDEX[ecl]; if (eclIndex === undefined) throw new Error(`unknown ECC level: ${ecl}`); const bytes = new TextEncoder().encode(text); const version = chooseVersion(bytes.length, eclIndex); if (version < 0) { throw new Error(`data too long: ${bytes.length} bytes exceeds version 40 at level ${ecl}`); } const sym = new QrSymbol(version, eclIndex); sym.drawFunctionPatterns(); sym.drawCodewords( addEccAndInterleave(dataCodewords(bytes, version, eclIndex), version, eclIndex) ); // Try every mask, keep the lowest-penalty one. We apply, score, then undo by // re-applying (XOR masks are self-inverse). let bestMask = 0; let bestPenalty = Infinity; for (let mask = 0; mask < 8; mask++) { sym.applyMask(mask); sym.drawFormatBits(mask); const penalty = sym.penaltyScore(); if (penalty < bestPenalty) { bestPenalty = penalty; bestMask = mask; } sym.applyMask(mask); } sym.applyMask(bestMask); sym.drawFormatBits(bestMask); return { size: sym.size, modules: sym.modules }; } // Browser-only: render the matrix to a fresh and return it. export function qrCanvas( text, { ecl = "M", scale = 6, margin = 4, dark = "#000", light = "#fff" } = {} ) { const { size, modules } = qrMatrix(text, ecl); const dim = (size + margin * 2) * scale; const canvas = document.createElement("canvas"); canvas.width = dim; canvas.height = dim; const ctx = canvas.getContext("2d"); ctx.fillStyle = light; ctx.fillRect(0, 0, dim, dim); ctx.fillStyle = dark; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { if (modules[y][x]) { ctx.fillRect((x + margin) * scale, (y + margin) * scale, scale, scale); } } } return canvas; }