carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

1// A QR encoder, alphanumeric mode, versions 1–6.
2//
3// This is here for one artifact: the Emergency Kit. The Account Key card and
4// the Recovery sheet each carry their code as a QR so a phone can read it off
5// paper years later, and that is the moment nothing may be wrong.
6//
7// Deliberately narrow. Alphanumeric mode only, because a CONTRA code is
8// `A1-`/`C1-` plus Crockford Base32 — uppercase letters, digits and hyphens,
9// all inside QR's 45-character alphanumeric set. Versions 1–6 only, because a
10// 38-character code needs version 3 and stopping below 7 means no version
11// information block. Anything outside that throws rather than guessing.
12//
13// It is checked two ways in CI, not one: every symbol it produces is decoded
14// back by ZXing, and every matrix is compared bit-for-bit against Python's
15// `qrcode` at the same version, EC level and mask. Neither of those is ours.
16
17import { remainder } from "./qrgf.js";
18import {
19 ALIGNMENT, ALNUM, BLOCKS, dataCapacity, EC_LEVELS, MASKS, size,
20} from "./qrtables.js";
21
22export { EC_LEVELS };
23
24class Bits {
25 constructor() { this.bits = []; }
26 push(value, length) {
27 for (let i = length - 1; i >= 0; i--) this.bits.push((value >> i) & 1);
28 }
29 get length() { return this.bits.length; }
30}
31
32// encodeAlphanumeric packs characters in pairs — 11 bits for two, 6 for a
33// leftover one — which is the whole reason to use this mode over bytes.
34function encodeAlphanumeric(text, version, ec) {
35 const bits = new Bits();
36 bits.push(0b0010, 4); // mode: alphanumeric
37 bits.push(text.length, 9); // character count, 9 bits for versions 1–9
38 for (let i = 0; i < text.length; i += 2) {
39 const a = ALNUM.indexOf(text[i]);
40 if (a < 0) throw new Error(`qr: ${JSON.stringify(text[i])} is not alphanumeric`);
41 if (i + 1 < text.length) {
42 const b = ALNUM.indexOf(text[i + 1]);
43 if (b < 0) throw new Error(`qr: ${JSON.stringify(text[i + 1])} is not alphanumeric`);
44 bits.push(a * 45 + b, 11);
45 } else {
46 bits.push(a, 6);
47 }
48 }
49
50 const capacity = dataCapacity(version, ec) * 8;
51 if (bits.length > capacity) {
52 throw new Error(`qr: ${text.length} characters do not fit in version ${version}${ec}`);
53 }
54 // Terminator, then to a byte boundary, then the two alternating pad bytes.
55 bits.push(0, Math.min(4, capacity - bits.length));
56 while (bits.length % 8 !== 0) bits.bits.push(0);
57
58 const bytes = [];
59 for (let i = 0; i < bits.length; i += 8) {
60 let b = 0;
61 for (let j = 0; j < 8; j++) b = (b << 1) | bits.bits[i + j];
62 bytes.push(b);
63 }
64 for (let i = 0; bytes.length < capacity / 8; i++) bytes.push(i % 2 ? 0x11 : 0xec);
65 return bytes;
66}
67
68// interleave splits the data into the version's blocks, computes each block's
69// error correction, and reads the whole lot back column-wise — which is what
70// spreads a coffee stain across every block instead of destroying one.
71function interleave(bytes, version, ec) {
72 const [, ecLen, groups] = BLOCKS[version][ec];
73 const data = [];
74 let at = 0;
75 for (const [count, words] of groups) {
76 for (let i = 0; i < count; i++) {
77 data.push(bytes.slice(at, at + words));
78 at += words;
79 }
80 }
81 const ecc = data.map((block) => remainder(block, ecLen));
82
83 const out = [];
84 const longest = Math.max(...data.map((b) => b.length));
85 for (let i = 0; i < longest; i++) {
86 for (const block of data) if (i < block.length) out.push(block[i]);
87 }
88 for (let i = 0; i < ecLen; i++) for (const block of ecc) out.push(block[i]);
89 return out;
90}
91
92// The fixed patterns: finders, separators, timing, alignment, and the dark
93// module. `reserved` marks every cell data must not be written into.
94function skeleton(version) {
95 const n = size(version);
96 const m = Array.from({ length: n }, () => new Array(n).fill(0));
97 const reserved = Array.from({ length: n }, () => new Array(n).fill(false));
98
99 const put = (r, c, v) => {
100 if (r >= 0 && r < n && c >= 0 && c < n) { m[r][c] = v; reserved[r][c] = true; }
101 };
102
103 for (const [top, left] of [[0, 0], [0, n - 7], [n - 7, 0]]) {
104 for (let r = -1; r <= 7; r++) {
105 for (let c = -1; c <= 7; c++) {
106 const onRing = r === 0 || r === 6 || c === 0 || c === 6;
107 const inCore = r >= 2 && r <= 4 && c >= 2 && c <= 4;
108 const inside = r >= 0 && r <= 6 && c >= 0 && c <= 6;
109 put(top + r, left + c, inside && (onRing || inCore) ? 1 : 0);
110 }
111 }
112 }
113
114 for (let i = 8; i < n - 8; i++) {
115 put(6, i, i % 2 === 0 ? 1 : 0);
116 put(i, 6, i % 2 === 0 ? 1 : 0);
117 }
118
119 const centres = ALIGNMENT[version];
120 for (const r of centres) {
121 for (const c of centres) {
122 // Never over a finder.
123 if ((r <= 8 && c <= 8) || (r <= 8 && c >= n - 9) || (r >= n - 9 && c <= 8)) continue;
124 for (let dr = -2; dr <= 2; dr++) {
125 for (let dc = -2; dc <= 2; dc++) {
126 const edge = Math.abs(dr) === 2 || Math.abs(dc) === 2;
127 put(r + dr, c + dc, edge || (dr === 0 && dc === 0) ? 1 : 0);
128 }
129 }
130 }
131 }
132
133 put(n - 8, 8, 1); // the dark module
134
135 // Format information areas, reserved now and written after masking.
136 for (let i = 0; i < 9; i++) {
137 if (!reserved[8][i]) put(8, i, 0);
138 if (!reserved[i][8]) put(i, 8, 0);
139 }
140 for (let i = 0; i < 8; i++) {
141 if (!reserved[8][n - 1 - i]) put(8, n - 1 - i, 0);
142 if (!reserved[n - 1 - i][8]) put(n - 1 - i, 8, 0);
143 }
144 return { m, reserved, n };
145}
146
147
148// Data goes in two-module columns, right to left, snaking up and down, and
149// skipping the vertical timing column.
150function place(grid, codewords) {
151 const { m, reserved, n } = grid;
152 let bit = 0;
153 const total = codewords.length * 8;
154 for (let right = n - 1; right > 0; right -= 2) {
155 if (right === 6) right = 5; // the timing column is not a data column
156 for (let step = 0; step < n; step++) {
157 const up = ((n - 1 - right) & 2) === 0;
158 const row = up ? n - 1 - step : step;
159 for (const col of [right, right - 1]) {
160 if (reserved[row][col] || bit >= total) continue;
161 m[row][col] = (codewords[bit >> 3] >> (7 - (bit & 7))) & 1;
162 bit++;
163 }
164 }
165 }
166}
167
168function penalty(m, n) {
169 let score = 0;
170 const run = (get) => {
171 for (let a = 0; a < n; a++) {
172 let length = 1;
173 for (let b = 1; b < n; b++) {
174 if (get(a, b) === get(a, b - 1)) {
175 length++;
176 } else {
177 if (length >= 5) score += length - 2;
178 length = 1;
179 }
180 }
181 if (length >= 5) score += length - 2;
182 }
183 };
184 run((r, c) => m[r][c]);
185 run((c, r) => m[r][c]);
186
187 for (let r = 0; r < n - 1; r++) {
188 for (let c = 0; c < n - 1; c++) {
189 const v = m[r][c];
190 if (v === m[r][c + 1] && v === m[r + 1][c] && v === m[r + 1][c + 1]) score += 3;
191 }
192 }
193
194 const A = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0];
195 const B = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1];
196 const matches = (get, a, b) =>
197 A.every((v, i) => get(a, b + i) === v) || B.every((v, i) => get(a, b + i) === v);
198 for (let a = 0; a < n; a++) {
199 for (let b = 0; b + 11 <= n; b++) {
200 if (matches((x, y) => m[x][y], a, b)) score += 40;
201 if (matches((x, y) => m[y][x], a, b)) score += 40;
202 }
203 }
204
205 let dark = 0;
206 for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) dark += m[r][c];
207 score += Math.floor(Math.abs((dark * 100) / (n * n) - 50) / 5) * 10;
208 return score;
209}
210
211// BCH(15,5) over the 5 format bits, then the specification's fixed XOR.
212function formatBits(ec, mask) {
213 let value = (EC_LEVELS[ec] << 3) | mask;
214 let rem = value;
215 for (let i = 0; i < 10; i++) {
216 rem <<= 1;
217 if (rem & 0x400) rem ^= 0x537;
218 }
219 return ((value << 10) | rem) ^ 0x5412;
220}
221
222// The 15 format bits go in twice, and the two copies run in opposite
223// directions — which is the detail worth stating rather than deriving from a
224// loop, because getting it backwards produces a symbol that is correct
225// everywhere else and scans nowhere.
226//
227// Bit 14 is the most significant. Copy A starts at the top-left corner and
228// reads MSB-first along row 8, then continues MSB-first *up* column 8. Copy B
229// starts at the bottom of column 8 and runs MSB-first upward, then continues
230// along row 8 towards the right edge. (8,6) and (6,8) are timing and are
231// skipped; (n-8,8) is the dark module and is not a format cell.
232function writeFormat(m, n, ec, mask) {
233 const bits = formatBits(ec, mask);
234 const at = (i) => (bits >> i) & 1;
235
236 // Copy A: bits 14…7 along the top row, then bits 6…0 up the left column.
237 const rowA = [0, 1, 2, 3, 4, 5, 7, 8];
238 rowA.forEach((c, i) => { m[8][c] = at(14 - i); });
239 const colA = [7, 5, 4, 3, 2, 1, 0];
240 colA.forEach((r, i) => { m[r][8] = at(6 - i); });
241
242 // Copy B: bits 14…8 up from the bottom, then bits 7…0 to the right edge.
243 for (let i = 0; i < 7; i++) m[n - 1 - i][8] = at(14 - i);
244 for (let i = 0; i < 8; i++) m[8][n - 8 + i] = at(7 - i);
245
246 m[n - 8][8] = 1; // the dark module, restated
247}
248
249// smallestVersion is the smallest symbol the text fits at this EC level.
250export function smallestVersion(text, ec) {
251 const bits = 4 + 9 + Math.floor(text.length / 2) * 11 + (text.length % 2) * 6;
252 for (let v = 1; v <= 6; v++) {
253 if (bits <= dataCapacity(v, ec) * 8) return v;
254 }
255 throw new Error(`qr: ${text.length} characters need a version above 6`);
256}
257
258// encode returns the module matrix: 1 is dark, 0 is light, no quiet zone.
259//
260// `mask` is normally chosen by penalty score, exactly as the specification
261// says; passing one forces it, which is what lets CI compare our matrix
262// bit-for-bit against another encoder's.
263export function encode(text, { ec = "Q", version = null, mask = null } = {}) {
264 if (!EC_LEVELS.hasOwnProperty(ec)) throw new Error(`qr: unknown EC level ${ec}`);
265 const v = version ?? smallestVersion(text, ec);
266 if (!BLOCKS[v]) throw new Error(`qr: version ${v} is outside 1–6`);
267
268 const codewords = interleave(encodeAlphanumeric(text, v, ec), v, ec);
269
270 const build = (candidate) => {
271 const grid = skeleton(v);
272 place(grid, codewords);
273 for (let r = 0; r < grid.n; r++) {
274 for (let c = 0; c < grid.n; c++) {
275 if (!grid.reserved[r][c] && MASKS[candidate](r, c)) grid.m[r][c] ^= 1;
276 }
277 }
278 writeFormat(grid.m, grid.n, ec, candidate);
279 return grid;
280 };
281
282 if (mask !== null) return { modules: build(mask).m, version: v, ec, mask };
283
284 let best = null;
285 let bestScore = Infinity;
286 let bestMask = 0;
287 for (let candidate = 0; candidate < 8; candidate++) {
288 const grid = build(candidate);
289 const score = penalty(grid.m, grid.n);
290 if (score < bestScore) {
291 bestScore = score;
292 best = grid;
293 bestMask = candidate;
294 }
295 }
296 return { modules: best.m, version: v, ec, mask: bestMask };
297}
298