| new file mode 100644 |
| index 0000000..d0b2fe9 |
| --- /dev/null |
| +++ b/web/static/qrcode.js |
| @@ -0,0 +1,494 @@ |
| +// 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 <canvas> 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; |
| +} |