- commit
- 7f79b8b491524b99ce65349f6ad0b7756a5b5f8b
- parent
- 3d19ba14c6aa8d163cc7490cbc2a24a269cf70a4
- author
- Dami <dami.etoile@gmail.com>
| M | package-lock.json | 8 | ++++++ |
| M | package.json | 1 | + |
| M | src/ | 20 | ++++++++------- |
| M | test/ | 40 | +++++++++++++++++++++++++++--- |
@@ -14,6 +14,7 @@
1414 "@cloudflare/vitest-pool-workers": "^0.22.0",1515 "@cloudflare/workers-types": "^5.20260902.1",1616 "@types/ws": "^8.18.0",17+ "jsqr": "^1.4.0",1718 "typescript": "^7.0.2",1819 "vitest": "^4.1.0",1920 "wrangler": "^4.128.0",@@ -2374,6 +2375,13 @@
23742375 "node": "^8.16.0 || ^10.6.0 || >=11.0.0"23752376 }23762377 },2378+ "node_modules/jsqr": {2379+ "version": "1.4.0",2380+ "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",2381+ "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",2382+ "dev": true,2383+ "license": "Apache-2.0"2384+ },23772385 "node_modules/kleur": {23782386 "version": "4.1.5",23792387 "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",@@ -15,7 +15,7 @@ export const EC_M: [number, number, number, number, number][] = [
1515 [28, 7, 45, 3, 46], [28, 10, 46, 1, 47], [26, 9, 43, 4, 44], [26, 3, 44, 11, 45], [26, 3, 41, 13, 42],1616 ];1717 const ALIGN: number[][] = [18- [], [], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34], [6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 52],18+ [], [], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34], [6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50],1919 [6, 30, 54], [6, 32, 58], [6, 34, 62], [6, 26, 46, 66], [6, 26, 48, 70], [6, 26, 50, 74], [6, 30, 54, 78], [6, 30, 56, 82], [6, 30, 58, 86], [6, 34, 62, 90],2020 ];2121 export const MAX_VERSION = 20;@@ -324,15 +324,17 @@ export function encode(text: string | Uint8Array): Symbol {
324324 for (const [x, y] of order) if (MASKS[mask](x, y)) m[y * size + x] ^= 1;325325 const fb = formatBits(mask);326326 const bit = (i: number) => (fb >> i) & 1;327- // Around the top-left finder.328- for (let i = 0; i < 6; i++) m[8 * size + i] = bit(i);329- m[8 * size + 7] = bit(6);327+ // Around the top-left finder: bits 0 to 7 run down column 8, bits 8 to328+ // 14 run leftwards along row 8 (ISO 18004 figure 25).329+ for (let i = 0; i < 6; i++) m[i * size + 8] = bit(i);330+ m[7 * size + 8] = bit(6);330331 m[8 * size + 8] = bit(7);331- m[7 * size + 8] = bit(8);332- for (let i = 9; i < 15; i++) m[(14 - i) * size + 8] = bit(i);333- // Split between the other two finders.334- for (let i = 0; i < 8; i++) m[(size - 1 - i) * size + 8] = bit(i);335- for (let i = 8; i < 15; i++) m[8 * size + (size - 15 + i)] = bit(i);332+ m[8 * size + 7] = bit(8);333+ for (let i = 9; i < 15; i++) m[8 * size + (14 - i)] = bit(i);334+ // Split between the other two finders: bits 0 to 7 leftwards along row335+ // 8 from the right edge, bits 8 to 14 down column 8 to the bottom.336+ for (let i = 0; i < 8; i++) m[8 * size + (size - 1 - i)] = bit(i);337+ for (let i = 8; i < 15; i++) m[(size - 15 + i) * size + 8] = bit(i);336338 const s = penalty(m, size);337339 if (s < bestScore) {338340 bestScore = s;@@ -6,6 +6,7 @@ import { describe, it, expect } from "vitest";
66 import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools/pure";77 import { decode } from "nostr-tools/nip19";88 import { getToken } from "nostr-tools/nip98";9+import jsQR from "jsqr";910 import { EC_M, MASKS, dataCodewords, dataOrder, encode, formatBits, gfMul, gfPow, layout, capacity } from "../src/qr.ts";1011 1112 async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {@@ -21,11 +22,16 @@ const get = (host: string, path: string) => SELF.fetch(`http://${host}${path}`);
2122 function readBack(sym: ReturnType<typeof encode>): { mask: number; level: number; bytes: Uint8Array } {2223 const { size, modules, version } = sym;2324 const at = (x: number, y: number) => modules[y * size + x];24- // Format bits around the top-left finder.25+ // Format bits around the top-left finder, and the second copy split26+ // between the other two; both must agree.2527 const fbits: number[] = [];26- for (let i = 0; i < 6; i++) fbits.push(at(i, 8));27- fbits.push(at(7, 8), at(8, 8), at(8, 7));28- for (let i = 9; i < 15; i++) fbits.push(at(8, 14 - i));28+ for (let i = 0; i < 6; i++) fbits.push(at(8, i));29+ fbits.push(at(8, 7), at(8, 8), at(7, 8));30+ for (let i = 9; i < 15; i++) fbits.push(at(14 - i, 8));31+ const second: number[] = [];32+ for (let i = 0; i < 8; i++) second.push(at(size - 1 - i, 8));33+ for (let i = 8; i < 15; i++) second.push(at(8, size - 15 + i));34+ expect(second).toEqual(fbits);2935 let f = 0;3036 fbits.forEach((b, i) => (f |= b << i));3137 const mask = (f ^ 0x5412) >> 10 & 7;@@ -96,6 +102,32 @@ describe("qr encoder", () => {
96102 expect(capacity(20)).toBe(666);97103 expect(() => encode("y".repeat(667))).toThrow(/too long/);98104 });105+106+ it("scans with an independent decoder, which the read-back above cannot prove", () => {107+ // The read-back shares the encoder's assumptions: transposed format bits108+ // passed it for months while no phone could scan the result, and a109+ // misplaced alignment pattern in version 10 hid behind error correction110+ // at short lengths. jsQR is a different code base; every version at111+ // full capacity goes through it.112+ 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";113+ const full = Array.from({ length: 20 }, (_, i) => Array.from({ length: capacity(i + 1) }, (_, j) => String.fromCharCode(48 + ((j * 7 + i) % 74))).join(""));114+ for (const text of ["nostr:npub1abc", uri, ...full]) {115+ const sym = encode(text);116+ const px = 4, margin = 4, n = sym.size + margin * 2, w = n * px;117+ const img = new Uint8ClampedArray(w * w * 4).fill(255);118+ for (let y = 0; y < sym.size; y++) {119+ for (let x = 0; x < sym.size; x++) {120+ if (!sym.modules[y * sym.size + x]) continue;121+ for (let dy = 0; dy < px; dy++) for (let dx = 0; dx < px; dx++) {122+ const i = (((y + margin) * px + dy) * w + (x + margin) * px + dx) * 4;123+ img[i] = img[i + 1] = img[i + 2] = 0;124+ }125+ }126+ }127+ const r = jsQR(img, w, w);128+ expect(r?.data, `version ${sym.version}`).toBe(text);129+ }130+ });99131 });100132 101133 describe("relay card", () => {