Kokkoja as of 2026-10-10: the teacher's requests, the Session window, notices, the QR code, the license

This commit is contained in:
ack committed 2026-10-10 19:01:20 +02:00
1 parent 95c80d04c9
commit 1d087af633
32 files changed
+4917 -890

No files matched your search

+388
View File
@@ -0,0 +1,388 @@
// @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('');
}