319 lines
11 KiB
JavaScript
319 lines
11 KiB
JavaScript
|
|
/**
|
||
|
|
* Minimal QR Code generator — no dependencies
|
||
|
|
* Encodes a URL into a canvas element
|
||
|
|
* Based on QR Code Model 2, Version 3 (29x29), Error Correction Level L
|
||
|
|
*/
|
||
|
|
(function () {
|
||
|
|
'use strict';
|
||
|
|
|
||
|
|
// Generate QR code on a canvas for a given URL
|
||
|
|
function generateQR(canvas, url) {
|
||
|
|
// Use the Google Charts API as a simple fallback-free approach:
|
||
|
|
// Draw QR as an image loaded from a data URL generated client-side.
|
||
|
|
// For a truly zero-dependency approach, we use a proven minimal encoder.
|
||
|
|
const size = canvas.width;
|
||
|
|
const ctx = canvas.getContext('2d');
|
||
|
|
|
||
|
|
// Try using the minimal encoder
|
||
|
|
try {
|
||
|
|
const modules = encode(url);
|
||
|
|
const moduleCount = modules.length;
|
||
|
|
const cellSize = Math.floor((size - 16) / moduleCount); // 8px padding each side
|
||
|
|
const offset = Math.floor((size - cellSize * moduleCount) / 2);
|
||
|
|
|
||
|
|
ctx.fillStyle = '#FFFFFF';
|
||
|
|
ctx.fillRect(0, 0, size, size);
|
||
|
|
|
||
|
|
ctx.fillStyle = '#051229'; // Navy from brand
|
||
|
|
for (let r = 0; r < moduleCount; r++) {
|
||
|
|
for (let c = 0; c < moduleCount; c++) {
|
||
|
|
if (modules[r][c]) {
|
||
|
|
ctx.fillRect(
|
||
|
|
offset + c * cellSize,
|
||
|
|
offset + r * cellSize,
|
||
|
|
cellSize,
|
||
|
|
cellSize
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
// Fallback: show URL text
|
||
|
|
ctx.fillStyle = '#FFFFFF';
|
||
|
|
ctx.fillRect(0, 0, size, size);
|
||
|
|
ctx.fillStyle = '#051229';
|
||
|
|
ctx.font = '10px monospace';
|
||
|
|
ctx.textAlign = 'center';
|
||
|
|
ctx.fillText(url, size / 2, size / 2);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Minimal QR Encoder (byte mode, ECC L) ----
|
||
|
|
|
||
|
|
// GF(256) math
|
||
|
|
const EXP = new Uint8Array(512);
|
||
|
|
const LOG = new Uint8Array(256);
|
||
|
|
(function initGF() {
|
||
|
|
let x = 1;
|
||
|
|
for (let i = 0; i < 255; i++) {
|
||
|
|
EXP[i] = x;
|
||
|
|
LOG[x] = i;
|
||
|
|
x = (x << 1) ^ (x & 128 ? 0x11d : 0);
|
||
|
|
}
|
||
|
|
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
|
||
|
|
})();
|
||
|
|
|
||
|
|
function gfMul(a, b) {
|
||
|
|
return a === 0 || b === 0 ? 0 : EXP[LOG[a] + LOG[b]];
|
||
|
|
}
|
||
|
|
|
||
|
|
function polyMul(a, b) {
|
||
|
|
const r = new Uint8Array(a.length + b.length - 1);
|
||
|
|
for (let i = 0; i < a.length; i++)
|
||
|
|
for (let j = 0; j < b.length; j++)
|
||
|
|
r[i + j] ^= gfMul(a[i], b[j]);
|
||
|
|
return r;
|
||
|
|
}
|
||
|
|
|
||
|
|
function polyRemainder(data, gen) {
|
||
|
|
const r = new Uint8Array(data.length + gen.length - 1);
|
||
|
|
r.set(data);
|
||
|
|
for (let i = 0; i < data.length; i++) {
|
||
|
|
if (r[i] === 0) continue;
|
||
|
|
for (let j = 0; j < gen.length; j++)
|
||
|
|
r[i + j] ^= gfMul(gen[j], r[i]);
|
||
|
|
}
|
||
|
|
return r.slice(data.length);
|
||
|
|
}
|
||
|
|
|
||
|
|
function generatorPoly(n) {
|
||
|
|
let g = new Uint8Array([1]);
|
||
|
|
for (let i = 0; i < n; i++)
|
||
|
|
g = polyMul(g, new Uint8Array([1, EXP[i]]));
|
||
|
|
return g;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Version info: [version, size, dataBytes, eccPerBlock, numBlocks]
|
||
|
|
// We pick the smallest version that fits
|
||
|
|
const VERSIONS = [
|
||
|
|
[1, 21, 19, 7, 1],
|
||
|
|
[2, 25, 34, 10, 1],
|
||
|
|
[3, 29, 55, 15, 1],
|
||
|
|
[4, 33, 80, 20, 1],
|
||
|
|
[5, 37, 108, 26, 1],
|
||
|
|
[6, 41, 136, 18, 2],
|
||
|
|
[7, 45, 156, 20, 2],
|
||
|
|
[8, 49, 194, 24, 2],
|
||
|
|
[9, 53, 232, 30, 2],
|
||
|
|
[10, 57, 274, 18, 4],
|
||
|
|
];
|
||
|
|
|
||
|
|
function pickVersion(dataLen) {
|
||
|
|
// Byte mode: 4 bits mode + char count bits + data + terminator
|
||
|
|
for (const v of VERSIONS) {
|
||
|
|
const charCountBits = v[0] <= 9 ? 8 : 16;
|
||
|
|
const totalBits = 4 + charCountBits + dataLen * 8;
|
||
|
|
const capacity = v[2] * 8;
|
||
|
|
if (totalBits <= capacity) return v;
|
||
|
|
}
|
||
|
|
return VERSIONS[VERSIONS.length - 1]; // fallback to largest
|
||
|
|
}
|
||
|
|
|
||
|
|
function encode(text) {
|
||
|
|
const data = new TextEncoder().encode(text);
|
||
|
|
const [version, size, dataBytes, eccPerBlock, numBlocks] = pickVersion(data.length);
|
||
|
|
const charCountBits = version <= 9 ? 8 : 16;
|
||
|
|
|
||
|
|
// Build data bitstream
|
||
|
|
const bits = [];
|
||
|
|
function pushBits(val, len) {
|
||
|
|
for (let i = len - 1; i >= 0; i--)
|
||
|
|
bits.push((val >> i) & 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
pushBits(0b0100, 4); // Byte mode
|
||
|
|
pushBits(data.length, charCountBits);
|
||
|
|
for (const b of data) pushBits(b, 8);
|
||
|
|
pushBits(0, Math.min(4, dataBytes * 8 - bits.length)); // terminator
|
||
|
|
|
||
|
|
// Pad to byte boundary
|
||
|
|
while (bits.length % 8 !== 0) bits.push(0);
|
||
|
|
|
||
|
|
// Pad bytes
|
||
|
|
const padBytes = [0xEC, 0x11];
|
||
|
|
let padIdx = 0;
|
||
|
|
while (bits.length < dataBytes * 8) {
|
||
|
|
pushBits(padBytes[padIdx % 2], 8);
|
||
|
|
padIdx++;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Convert to bytes
|
||
|
|
const dataArr = new Uint8Array(dataBytes);
|
||
|
|
for (let i = 0; i < dataBytes; i++) {
|
||
|
|
let byte = 0;
|
||
|
|
for (let b = 0; b < 8; b++) byte = (byte << 1) | (bits[i * 8 + b] || 0);
|
||
|
|
dataArr[i] = byte;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Split into blocks and generate ECC
|
||
|
|
const blockSize = Math.floor(dataBytes / numBlocks);
|
||
|
|
const gen = generatorPoly(eccPerBlock);
|
||
|
|
const dataBlocks = [];
|
||
|
|
const eccBlocks = [];
|
||
|
|
|
||
|
|
for (let b = 0; b < numBlocks; b++) {
|
||
|
|
const start = b * blockSize;
|
||
|
|
const end = b === numBlocks - 1 ? dataBytes : start + blockSize;
|
||
|
|
const block = dataArr.slice(start, end);
|
||
|
|
dataBlocks.push(block);
|
||
|
|
eccBlocks.push(polyRemainder(block, gen));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Interleave
|
||
|
|
const codewords = [];
|
||
|
|
const maxDataLen = Math.max(...dataBlocks.map(b => b.length));
|
||
|
|
for (let i = 0; i < maxDataLen; i++)
|
||
|
|
for (const block of dataBlocks)
|
||
|
|
if (i < block.length) codewords.push(block[i]);
|
||
|
|
for (let i = 0; i < eccPerBlock; i++)
|
||
|
|
for (const block of eccBlocks)
|
||
|
|
if (i < block.length) codewords.push(block[i]);
|
||
|
|
|
||
|
|
// Create module matrix
|
||
|
|
const modules = Array.from({ length: size }, () => new Uint8Array(size));
|
||
|
|
const reserved = Array.from({ length: size }, () => new Uint8Array(size));
|
||
|
|
|
||
|
|
// Finder patterns
|
||
|
|
function drawFinder(row, col) {
|
||
|
|
for (let r = -1; r <= 7; r++) {
|
||
|
|
for (let c = -1; c <= 7; c++) {
|
||
|
|
const rr = row + r, cc = col + c;
|
||
|
|
if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue;
|
||
|
|
reserved[rr][cc] = 1;
|
||
|
|
if (r >= 0 && r <= 6 && c >= 0 && c <= 6) {
|
||
|
|
modules[rr][cc] =
|
||
|
|
(r === 0 || r === 6 || c === 0 || c === 6 ||
|
||
|
|
(r >= 2 && r <= 4 && c >= 2 && c <= 4)) ? 1 : 0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
drawFinder(0, 0);
|
||
|
|
drawFinder(0, size - 7);
|
||
|
|
drawFinder(size - 7, 0);
|
||
|
|
|
||
|
|
// Alignment pattern (version >= 2)
|
||
|
|
if (version >= 2) {
|
||
|
|
const pos = alignmentPositions(version);
|
||
|
|
for (const r of pos) {
|
||
|
|
for (const c of pos) {
|
||
|
|
if (reserved[r][c]) continue;
|
||
|
|
for (let dr = -2; dr <= 2; dr++) {
|
||
|
|
for (let dc = -2; dc <= 2; dc++) {
|
||
|
|
const rr = r + dr, cc = c + dc;
|
||
|
|
reserved[rr][cc] = 1;
|
||
|
|
modules[rr][cc] =
|
||
|
|
(Math.abs(dr) === 2 || Math.abs(dc) === 2 || (dr === 0 && dc === 0)) ? 1 : 0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Timing patterns
|
||
|
|
for (let i = 8; i < size - 8; i++) {
|
||
|
|
reserved[6][i] = 1;
|
||
|
|
modules[6][i] = i % 2 === 0 ? 1 : 0;
|
||
|
|
reserved[i][6] = 1;
|
||
|
|
modules[i][6] = i % 2 === 0 ? 1 : 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Dark module
|
||
|
|
reserved[size - 8][8] = 1;
|
||
|
|
modules[size - 8][8] = 1;
|
||
|
|
|
||
|
|
// Reserve format info areas
|
||
|
|
for (let i = 0; i < 8; i++) {
|
||
|
|
reserved[8][i] = 1;
|
||
|
|
reserved[8][size - 1 - i] = 1;
|
||
|
|
reserved[i][8] = 1;
|
||
|
|
reserved[size - 1 - i][8] = 1;
|
||
|
|
}
|
||
|
|
reserved[8][8] = 1;
|
||
|
|
|
||
|
|
// Reserve version info (version >= 7) — not needed for our range
|
||
|
|
|
||
|
|
// Place data
|
||
|
|
let bitIdx = 0;
|
||
|
|
const totalBitsToPlace = codewords.length * 8;
|
||
|
|
let col = size - 1;
|
||
|
|
|
||
|
|
while (col >= 0) {
|
||
|
|
if (col === 6) col--; // skip timing column
|
||
|
|
for (let dir = 0; dir < 2; dir++) {
|
||
|
|
const cc = col - dir;
|
||
|
|
const upward = ((size - 1 - col + (col > 6 ? 1 : 0)) / 2) % 2 === 0;
|
||
|
|
for (let i = 0; i < size; i++) {
|
||
|
|
const r = upward ? i : size - 1 - i;
|
||
|
|
if (reserved[r][cc]) continue;
|
||
|
|
if (bitIdx < totalBitsToPlace) {
|
||
|
|
const byteIdx = Math.floor(bitIdx / 8);
|
||
|
|
const bitOff = 7 - (bitIdx % 8);
|
||
|
|
modules[r][cc] = (codewords[byteIdx] >> bitOff) & 1;
|
||
|
|
bitIdx++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
col -= 2;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Apply mask (mask 0: (row + col) % 2 === 0)
|
||
|
|
for (let r = 0; r < size; r++)
|
||
|
|
for (let c = 0; c < size; c++)
|
||
|
|
if (!reserved[r][c] && (r + c) % 2 === 0)
|
||
|
|
modules[r][c] ^= 1;
|
||
|
|
|
||
|
|
// Write format info (mask 0, ECC L)
|
||
|
|
const formatBits = getFormatBits(0); // ECC L, mask 0
|
||
|
|
// Horizontal
|
||
|
|
for (let i = 0; i < 6; i++) modules[8][i] = (formatBits >> (14 - i)) & 1;
|
||
|
|
modules[8][7] = (formatBits >> 8) & 1;
|
||
|
|
modules[8][8] = (formatBits >> 7) & 1;
|
||
|
|
modules[7][8] = (formatBits >> 6) & 1;
|
||
|
|
for (let i = 0; i < 6; i++) modules[5 - i][8] = (formatBits >> (5 - i)) & 1;
|
||
|
|
// Vertical
|
||
|
|
for (let i = 0; i < 7; i++) modules[size - 1 - i][8] = (formatBits >> (14 - i)) & 1;
|
||
|
|
for (let i = 0; i < 8; i++) modules[8][size - 8 + i] = (formatBits >> (7 - i)) & 1;
|
||
|
|
|
||
|
|
return modules;
|
||
|
|
}
|
||
|
|
|
||
|
|
function alignmentPositions(version) {
|
||
|
|
if (version === 1) return [];
|
||
|
|
const positions = [6];
|
||
|
|
const last = version * 4 + 10;
|
||
|
|
const count = Math.floor(version / 7) + 2;
|
||
|
|
const step = Math.ceil((last - 6) / (count - 1));
|
||
|
|
for (let i = 1; i < count; i++) positions.push(6 + i * step > last ? last : 6 + i * step);
|
||
|
|
// Ensure last is exact
|
||
|
|
positions[positions.length - 1] = last;
|
||
|
|
return positions;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getFormatBits(mask) {
|
||
|
|
// Pre-computed format info for ECC Level L (01) with masks 0-7
|
||
|
|
const FORMAT_INFO = [
|
||
|
|
0x77C4, 0x72F3, 0x7DAA, 0x789D, 0x662F, 0x6318, 0x6C41, 0x6976,
|
||
|
|
];
|
||
|
|
return FORMAT_INFO[mask];
|
||
|
|
}
|
||
|
|
|
||
|
|
// Auto-init on load
|
||
|
|
window.addEventListener('DOMContentLoaded', () => {
|
||
|
|
const canvas = document.getElementById('qr-canvas');
|
||
|
|
if (canvas) {
|
||
|
|
generateQR(canvas, 'https://monitor.proactys.swiss/opusbike/');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
})();
|