// @ts-check /** * A QR code of a text, so that the invitation can be read by the camera * of a phone or a tablet (Andy, 2026-10-10: there is no good way to send * a link from a Linux computer to an iPad). Written for Kokkoja since * the client uses no packages: byte mode, any version from 1 to 40, * error correction level M, the mask chosen by the standard's penalty * rules (ISO/IEC 18004). `tests/test_qr_code.py` compares every version * and level with Nayuki's reference encoder, mask choice included. */ /** Error correction levels, as the format bits name them. */ const LEVELS = { L: 1, M: 0, Q: 3, H: 2 }; /** @typedef {keyof typeof LEVELS} Level */ /** Error correction codewords per block, by level then version (index 0 unused). */ const ECC_PER_BLOCK = { L: [0, 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], M: [0, 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], Q: [0, 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], H: [0, 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 blocks, by level then version. */ const BLOCKS = { L: [0, 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], M: [0, 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], Q: [0, 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], H: [0, 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], }; /** @param {number} version @returns {number} Modules not taken by function patterns. */ function rawDataModules(version) { let result = (16 * version + 128) * version + 64; if (version >= 2) { const aligns = Math.floor(version / 7) + 2; result -= (25 * aligns - 10) * aligns - 55; if (version >= 7) result -= 36; } return result; } /** @param {number} version @param {Level} level @returns {number} Data codewords. */ function dataCodewords(version, level) { return Math.floor(rawDataModules(version) / 8) - ECC_PER_BLOCK[level][version] * BLOCKS[level][version]; } /** @param {number} version @returns {number} Bits of the byte count in byte mode. */ function countBits(version) { return version < 10 ? 8 : 16; } /** * @param {number} byteCount * @param {Level} level * @returns {number} The smallest version holding that many bytes. */ function versionFor(byteCount, level) { for (let version = 1; version <= 40; version++) { if (4 + countBits(version) + byteCount * 8 <= dataCodewords(version, level) * 8) return version; } throw new Error('Too long for a QR code'); } /** @param {number} version @returns {number[]} Centres of the alignment patterns, one axis. */ function alignmentPositions(version) { if (version === 1) return []; const count = Math.floor(version / 7) + 2; const size = version * 4 + 17; const step = Math.floor((version * 8 + count * 3 + 5) / (count * 4 - 4)) * 2; const result = [6]; for (let position = size - 7; result.length < count; position -= step) result.splice(1, 0, position); return result; } /* Reed-Solomon over GF(2^8) with the standard's polynomial. */ /** @param {number} x @param {number} y @returns {number} */ function multiply(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; } /** @param {number} degree @returns {number[]} The generator polynomial's coefficients, highest first after the leading 1. */ function generator(degree) { let result = Array(degree - 1).fill(0).concat([1]); let root = 1; for (let i = 0; i < degree; i++) { result = result.map((coefficient, j) => multiply(coefficient, root) ^ (j + 1 < result.length ? result[j + 1] : 0)); root = multiply(root, 2); } return result; } /** @param {number[]} data @param {number[]} divisor @returns {number[]} The remainder. */ function remainder(data, divisor) { const result = Array(divisor.length).fill(0); for (const byte of data) { const factor = byte ^ /** @type {number} */ (result.shift()); result.push(0); divisor.forEach((coefficient, i) => { result[i] ^= multiply(coefficient, factor); }); } return result; } /** * @param {Uint8Array} bytes * @param {number} version * @param {Level} level * @returns {number[]} All codewords, data and error correction interleaved. */ function codewords(bytes, version, level) { /** @type {number[]} */ const bits = []; /** @param {number} value @param {number} count */ const append = (value, count) => { for (let i = count - 1; i >= 0; i--) bits.push((value >>> i) & 1); }; append(4, 4); append(bytes.length, countBits(version)); for (const byte of bytes) append(byte, 8); const capacity = dataCodewords(version, level) * 8; append(0, Math.min(4, capacity - bits.length)); append(0, (8 - bits.length % 8) % 8); for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11) append(pad, 8); /** @type {number[]} */ const data = []; for (let i = 0; i < bits.length; i += 8) data.push(parseInt(bits.slice(i, i + 8).join(''), 2)); const blockCount = BLOCKS[level][version]; const eccCount = ECC_PER_BLOCK[level][version]; const total = Math.floor(rawDataModules(version) / 8); const shortBlocks = blockCount - total % blockCount; const shortLength = Math.floor(total / blockCount); const divisor = generator(eccCount); /** @type {number[][]} */ const blocks = []; for (let i = 0, start = 0; i < blockCount; i++) { const length = shortLength - eccCount + (i < shortBlocks ? 0 : 1); const block = data.slice(start, start + length); start += length; const ecc = remainder(block, divisor); if (i < shortBlocks) block.push(0); // A placeholder, skipped below. blocks.push(block.concat(ecc)); } /** @type {number[]} */ const result = []; for (let i = 0; i < blocks[0].length; i++) { blocks.forEach((block, j) => { if (i !== shortLength - eccCount || j >= shortBlocks) result.push(block[i]); }); } return result; } /** * The modules of a QR code: `modules[y][x]` is true where dark. * @typedef {boolean[][]} Modules */ /** * @param {Uint8Array} bytes * @param {Level} level * @returns {Modules} */ function encodeBytes(bytes, level) { const version = versionFor(bytes.length, level); const size = version * 4 + 17; /** @type {Modules} */ const modules = Array.from({ length: size }, () => Array(size).fill(false)); /** @type {boolean[][]} Where function patterns are, not to be masked. */ const reserved = Array.from({ length: size }, () => Array(size).fill(false)); /** @param {number} x @param {number} y @param {boolean} dark */ const setFunction = (x, y, dark) => { modules[y][x] = dark; reserved[y][x] = true; }; // Timing patterns, finders with their separators, alignment patterns. for (let i = 0; i < size; i++) { setFunction(6, i, i % 2 === 0); setFunction(i, 6, i % 2 === 0); } /** @param {number} cx @param {number} cy */ const finder = (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 || y < 0 || x >= size || y >= size) continue; const distance = Math.max(Math.abs(dx), Math.abs(dy)); setFunction(x, y, distance !== 2 && distance !== 4); } } }; finder(3, 3); finder(size - 4, 3); finder(3, size - 4); const aligns = alignmentPositions(version); aligns.forEach((cx, i) => { aligns.forEach((cy, j) => { const onFinder = (i === 0 && j === 0) || (i === 0 && j === aligns.length - 1) || (i === aligns.length - 1 && j === 0); if (onFinder) return; for (let dy = -2; dy <= 2; dy++) { for (let dx = -2; dx <= 2; dx++) { setFunction(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1); } } }); }); /** @param {number} mask */ const drawFormat = (mask) => { const data = (LEVELS[level] << 3) | mask; let rem = data; for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537); const bits = ((data << 10) | rem) ^ 0x5412; /** @param {number} i @returns {boolean} */ const bit = (i) => ((bits >>> i) & 1) !== 0; for (let i = 0; i <= 5; i++) setFunction(8, i, bit(i)); setFunction(8, 7, bit(6)); setFunction(8, 8, bit(7)); setFunction(7, 8, bit(8)); for (let i = 9; i < 15; i++) setFunction(14 - i, 8, bit(i)); for (let i = 0; i < 8; i++) setFunction(size - 1 - i, 8, bit(i)); for (let i = 8; i < 15; i++) setFunction(8, size - 15 + i, bit(i)); setFunction(8, size - 8, true); }; drawFormat(0); // Reserves the places; redrawn with the chosen mask. 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 dark = ((bits >>> i) & 1) !== 0; const a = size - 11 + (i % 3); const b = Math.floor(i / 3); setFunction(a, b, dark); setFunction(b, a, dark); } } // The codewords, in the zigzag the standard prescribes. const words = codewords(bytes, version, level); let i = 0; for (let right = size - 1; right >= 1; right -= 2) { if (right === 6) right = 5; for (let vertical = 0; vertical < size; vertical++) { for (let j = 0; j < 2; j++) { const x = right - j; const upward = ((right + 1) & 2) === 0; const y = upward ? size - 1 - vertical : vertical; if (reserved[y][x] || i >= words.length * 8) continue; modules[y][x] = ((words[i >>> 3] >>> (7 - (i & 7))) & 1) !== 0; i++; } } } // The mask with the least penalty. /** @type {((x: number, y: number) => boolean)[]} */ const masks = [ (x, y) => (x + y) % 2 === 0, (x, y) => y % 2 === 0, (x, y) => 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, ]; /** @param {number} mask */ const applyMask = (mask) => { for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { if (!reserved[y][x] && masks[mask](x, y)) modules[y][x] = !modules[y][x]; } } }; let best = 0; let least = Infinity; for (let mask = 0; mask < 8; mask++) { applyMask(mask); drawFormat(mask); const score = penalty(modules); if (score < least) { least = score; best = mask; } applyMask(mask); } applyMask(best); drawFormat(best); return modules; } /** * The standard's four penalty rules, as Nayuki's reference encoder reads * them: a finder-like pattern is a run of dark, light, dark, light, dark * in the ratio 1:1:3:1:1 at any scale, with light four times its unit * or more on a side (the edge counting as light), scored once a side. * The tests compare the mask chosen with that encoder's. * @param {Modules} modules * @returns {number} */ function penalty(modules) { const size = modules.length; let score = 0; /** @param {boolean[]} line */ const scoreLine = (line) => { /** The lengths of the latest runs, the latest first. */ const runs = [0, 0, 0, 0, 0, 0, 0]; /** @param {number} length */ const addRun = (length) => { if (runs[0] === 0) length += size; // The edge is light without end. runs.pop(); runs.unshift(length); }; /** Finder-like patterns ending with the light run just added. */ const patterns = () => { const n = runs[1]; const core = n > 0 && runs[2] === n && runs[3] === n * 3 && runs[4] === n && runs[5] === n; return (core && runs[0] >= n * 4 && runs[6] >= n ? 1 : 0) + (core && runs[6] >= n * 4 && runs[0] >= n ? 1 : 0); }; let color = false; let run = 0; for (const dark of line) { if (dark === color) { run++; if (run === 5) score += 3; else if (run > 5) score += 1; continue; } addRun(run); if (!color) score += patterns() * 40; color = dark; run = 1; } if (color) { addRun(run); run = 0; } addRun(run + size); score += patterns() * 40; }; for (let y = 0; y < size; y++) scoreLine(modules[y]); for (let x = 0; x < size; x++) scoreLine(modules.map((row) => row[x])); let dark = 0; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { if (modules[y][x]) dark++; if (x + 1 < size && y + 1 < size) { const a = modules[y][x]; if (modules[y][x + 1] === a && modules[y + 1][x] === a && modules[y + 1][x + 1] === a) score += 3; } } } const total = size * size; const steps = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; return score + steps * 10; } /** * @param {string} text * @param {Level} [level] * @returns {Modules} */ export function qrModules(text, level = 'M') { return encodeBytes(new TextEncoder().encode(text), level); } /** The light border the standard requires, in modules. */ export const QUIET_ZONE = 4; /** * @param {Modules} modules * @returns {string} An SVG path of the dark modules, one unit a module, * for a viewBox of `-QUIET_ZONE -QUIET_ZONE size+2*QUIET_ZONE * size+2*QUIET_ZONE`. */ export function qrPath(modules) { /** @type {string[]} */ const parts = []; modules.forEach((row, y) => { row.forEach((dark, x) => { if (dark) parts.push(`M${x} ${y}h1v1h-1z`); }); }); return parts.join(''); }