1// The QR encoder behind the relay card. A symbol is read back: format2// bits, unmasked data, de-interleaved blocks, Reed-Solomon syndromes, and3// the bytes themselves; then jsQR, which shares nothing with the encoder.4import { describe, it, expect } from "vitest";5import { EC_M, MASKS, dataCodewords, dataOrder, encode, formatBits, gfMul, gfPow, layout, capacity } from "../../src/qr.ts";6import { scan } from "../helpers/qr.ts";78// readBack decodes a symbol without any error correction: it must be exact.9function readBack(sym: ReturnType<typeof encode>): { mask: number; level: number; bytes: Uint8Array } {10 const { size, modules, version } = sym;11 const at = (x: number, y: number) => modules[y * size + x];12 // Format bits around the top-left finder, and the second copy split13 // between the other two; both must agree.14 const fbits: number[] = [];15 for (let i = 0; i < 6; i++) fbits.push(at(8, i));16 fbits.push(at(8, 7), at(8, 8), at(7, 8));17 for (let i = 9; i < 15; i++) fbits.push(at(14 - i, 8));18 const second: number[] = [];19 for (let i = 0; i < 8; i++) second.push(at(size - 1 - i, 8));20 for (let i = 8; i < 15; i++) second.push(at(8, size - 15 + i));21 expect(second).toEqual(fbits);22 let f = 0;23 fbits.forEach((b, i) => (f |= b << i));24 const mask = (f ^ 0x5412) >> 10 & 7;25 const level = (f ^ 0x5412) >> 13 & 3;26 expect(f).toBe(formatBits(mask));27 // Data modules in placement order, unmasked.28 const l = layout(version);29 const order = dataOrder(l);30 const bits = order.map(([x, y]) => at(x, y) ^ (MASKS[mask](x, y) ? 1 : 0));31 const [ec, g1, d1, g2, d2] = EC_M[version];32 const nblocks = g1 + g2;33 const total = dataCodewords(version) + ec * nblocks;34 const cw: number[] = [];35 for (let i = 0; i < total; i++) cw.push(parseInt(bits.slice(i * 8, i * 8 + 8).join(""), 2));36 // De-interleave.37 const lens = [...Array(g1).fill(d1), ...Array(g2).fill(d2)] as number[];38 const data: number[][] = lens.map(() => []);39 const ecs: number[][] = lens.map(() => []);40 let k = 0;41 for (let i = 0; i < Math.max(d1, d2); i++) for (let b = 0; b < nblocks; b++) if (i < lens[b]) data[b].push(cw[k++]);42 for (let i = 0; i < ec; i++) for (let b = 0; b < nblocks; b++) ecs[b].push(cw[k++]);43 expect(k).toBe(total);44 // Syndromes: the codeword polynomial vanishes at alpha^0 .. alpha^(ec-1).45 for (let b = 0; b < nblocks; b++) {46 const poly = [...data[b], ...ecs[b]];47 for (let i = 0; i < ec; i++) {48 let v = 0;49 const a = gfPow(2, i);50 for (const c of poly) v = gfMul(v, a) ^ c;51 expect(v, `block ${b} syndrome ${i}`).toBe(0);52 }53 }54 // Byte mode header and payload.55 const dbits: number[] = [];56 for (const block of data) for (const c of block) for (let i = 7; i >= 0; i--) dbits.push((c >> i) & 1);57 const mode = parseInt(dbits.slice(0, 4).join(""), 2);58 expect(mode).toBe(4);59 const cl = version < 10 ? 8 : 16;60 const len = parseInt(dbits.slice(4, 4 + cl).join(""), 2);61 const bytes = new Uint8Array(len);62 for (let i = 0; i < len; i++) bytes[i] = parseInt(dbits.slice(4 + cl + i * 8, 4 + cl + i * 8 + 8).join(""), 2);63 return { mask, level, bytes };64}6566describe("qr encoder", () => {67 it("round-trips through every version class with valid format bits and zero syndromes", () => {68 const cases = [5, 14, 40, 100, 130, 213, 260, 400, 500, 666].map((n) => "x".repeat(n).replace(/x/g, () => String.fromCharCode(33 + Math.floor(Math.random() * 90))));69 cases.push("HELLO WORLD", "nostrconnect://" + "ab".repeat(32) + "?relay=wss%3A%2F%2Fearly-trout.bind.ws&secret=deadbeef&perms=sign_event%3A27235");70 const seen = new Set<number>();71 for (const text of cases) {72 const sym = encode(text);73 seen.add(sym.version);74 expect(sym.size).toBe(sym.version * 4 + 17);75 // Finder patterns are dark at their corners and cores.76 for (const [cx, cy] of [[0, 0], [sym.size - 7, 0], [0, sym.size - 7]]) {77 expect(sym.modules[cy * sym.size + cx]).toBe(1);78 expect(sym.modules[(cy + 3) * sym.size + cx + 3]).toBe(1);79 expect(sym.modules[(cy + 1) * sym.size + cx + 1]).toBe(0);80 }81 const back = readBack(sym);82 expect(back.level).toBe(0); // M83 expect(back.mask).toBe(sym.mask);84 expect(new TextDecoder().decode(back.bytes)).toBe(text);85 }86 expect(seen.size).toBeGreaterThan(6);87 expect(capacity(1)).toBe(14);88 expect(capacity(10)).toBe(213);89 expect(capacity(20)).toBe(666);90 expect(() => encode("y".repeat(667))).toThrow(/too long/);91 });9293 it("scans with an independent decoder, which the read-back above cannot prove", () => {94 // The read-back shares the encoder's assumptions: transposed format bits95 // passed it for months while no phone could scan the result, and a96 // misplaced alignment pattern in version 10 hid behind error correction97 // at short lengths. jsQR is a different code base; every version at98 // full capacity goes through it.99 const uri = "nostrconnect://" + "ab".repeat(32) + "?relay=wss%3A%2F%2Fdami.bind.ws&secret=0123456789abcdef&perms=sign_event%3A27235%2Csign_event%3A9734&name=dami.bind.ws&url=https%3A%2F%2Fdami.bind.ws";100 const full = Array.from({ length: 20 }, (_, i) => Array.from({ length: capacity(i + 1) }, (_, j) => String.fromCharCode(48 + ((j * 7 + i) % 74))).join(""));101 for (const text of ["nostr:npub1abc", uri, ...full]) {102 const sym = encode(text);103 expect(scan(sym.size, (x, y) => sym.modules[y * sym.size + x] === 1), `version ${sym.version}`).toBe(text);104 }105 });106});