返回 CodeWhale
qr.mjs
1 // QR code encoder — byte mode, versions 1-10, error correction level L.
2 //
3 // Zero-dependency, so the Weixin bridge keeps its no-npm-deps property. The
4 // login URL is ASCII (~70 bytes), which fits version 4-5 at ECC L; versions up
5 // to 10 are supported for headroom.
6 //
7 // Rendered the same way the Rust side does it
8 // (`qrcode::render::unicode::Dense1x2` in crates/tui/src/runtime_api.rs):
9 // two QR rows per text row using half-block glyphs.
10
11 // --- Galois field GF(256) tables for Reed-Solomon -------------------------
12
13 const EXP = new Uint8Array(512);
14 const LOG = new Uint8Array(256);
15 (() => {
16 let x = 1;
17 for (let i = 0; i < 255; i++) {
18 EXP[i] = x;
19 LOG[x] = i;
20 x <<= 1;
21 if (x & 0x100) x ^= 0x11d;
22 }
23 for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
24 })();
25
26 function gfMul(a, b) {
27 if (a === 0 || b === 0) return 0;
28 return EXP[LOG[a] + LOG[b]];
29 }
30
31 /** Reed-Solomon generator polynomial of the given degree. */
32 function rsGenerator(degree) {
33 let poly = [1];
34 for (let i = 0; i < degree; i++) {
35 const next = new Array(poly.length + 1).fill(0);
36 for (let j = 0; j < poly.length; j++) {
37 next[j] ^= gfMul(poly[j], 1);
38 next[j + 1] ^= gfMul(poly[j], EXP[i]);
39 }
40 poly = next;
41 }
42 return poly;
43 }
44
45 /** Compute `ecLength` Reed-Solomon error correction bytes for `data`. */
46 function rsEncode(data, ecLength) {
47 const gen = rsGenerator(ecLength);
48 const result = new Array(ecLength).fill(0);
49 for (const byte of data) {
50 const factor = byte ^ result[0];
51 result.shift();
52 result.push(0);
53 for (let i = 0; i < ecLength; i++) {
54 result[i] ^= gfMul(gen[i + 1], factor);
55 }
56 }
57 return result;
58 }
59
60 // --- Version tables --------------------------------------------------------
61
62 // [ecCodewordsPerBlock, [[blockCount, dataCodewordsPerBlock], ...]]
63 // Error correction level L only.
64 const VERSION_TABLE = {
65 1: [7, [[1, 19]]],
66 2: [10, [[1, 34]]],
67 3: [15, [[1, 55]]],
68 4: [20, [[1, 80]]],
69 5: [26, [[1, 108]]],
70 6: [18, [[2, 68]]],
71 7: [20, [[2, 78]]],
72 8: [24, [[2, 97]]],
73 9: [30, [[2, 116]]],
74 10: [18, [[2, 68], [2, 69]]],
75 };
76
77 const ALIGNMENT_POSITIONS = {
78 1: [],
79 2: [6, 18],
80 3: [6, 22],
81 4: [6, 26],
82 5: [6, 30],
83 6: [6, 34],
84 7: [6, 22, 38],
85 8: [6, 24, 42],
86 9: [6, 26, 46],
87 10: [6, 28, 50],
88 };
89
90 /** Total data codewords available at level L for a version. */
91 function dataCapacity(version) {
92 const [, blocks] = VERSION_TABLE[version];
93 return blocks.reduce((sum, [count, size]) => sum + count * size, 0);
94 }
95
96 // --- Bit buffer ------------------------------------------------------------
97
98 class BitBuffer {
99 constructor() {
100 this.bits = [];
101 }
102 put(value, length) {
103 for (let i = length - 1; i >= 0; i--) {
104 this.bits.push((value >>> i) & 1);
105 }
106 }
107 get length() {
108 return this.bits.length;
109 }
110 }
111
112 // --- Encoding --------------------------------------------------------------
113
114 function chooseVersion(byteLength) {
115 for (const version of Object.keys(VERSION_TABLE).map(Number).sort((a, b) => a - b)) {
116 // 4 bits mode + 8 or 16 bits length + payload, then terminator headroom.
117 const lengthBits = version < 10 ? 8 : 16;
118 const needed = 4 + lengthBits + byteLength * 8;
119 if (needed <= dataCapacity(version) * 8) return version;
120 }
121 throw new Error(`qr: payload too long for supported versions (${byteLength} bytes)`);
122 }
123
124 function buildCodewords(bytes, version) {
125 const capacity = dataCapacity(version);
126 const buf = new BitBuffer();
127 buf.put(0b0100, 4); // byte mode
128 buf.put(bytes.length, version < 10 ? 8 : 16);
129 for (const byte of bytes) buf.put(byte, 8);
130
131 // Terminator, then pad to a byte boundary.
132 const maxBits = capacity * 8;
133 buf.put(0, Math.min(4, maxBits - buf.length));
134 while (buf.length % 8 !== 0) buf.bits.push(0);
135
136 const data = [];
137 for (let i = 0; i < buf.length; i += 8) {
138 let byte = 0;
139 for (let j = 0; j < 8; j++) byte = (byte << 1) | buf.bits[i + j];
140 data.push(byte);
141 }
142 // Alternating pad bytes.
143 const pads = [0xec, 0x11];
144 for (let i = 0; data.length < capacity; i++) data.push(pads[i % 2]);
145
146 // Split into blocks, add EC, then interleave.
147 const [ecPerBlock, blockSpec] = VERSION_TABLE[version];
148 const dataBlocks = [];
149 const ecBlocks = [];
150 let offset = 0;
151 for (const [count, size] of blockSpec) {
152 for (let b = 0; b < count; b++) {
153 const block = data.slice(offset, offset + size);
154 offset += size;
155 dataBlocks.push(block);
156 ecBlocks.push(rsEncode(block, ecPerBlock));
157 }
158 }
159
160 const out = [];
161 const maxData = Math.max(...dataBlocks.map((b) => b.length));
162 for (let i = 0; i < maxData; i++) {
163 for (const block of dataBlocks) if (i < block.length) out.push(block[i]);
164 }
165 for (let i = 0; i < ecPerBlock; i++) {
166 for (const block of ecBlocks) out.push(block[i]);
167 }
168 return out;
169 }
170
171 // --- Matrix construction ---------------------------------------------------
172
173 function makeMatrix(version) {
174 const size = version * 4 + 17;
175 const modules = Array.from({ length: size }, () => new Array(size).fill(null));
176 const reserved = Array.from({ length: size }, () => new Array(size).fill(false));
177
178 const setFinder = (row, col) => {
179 for (let r = -1; r <= 7; r++) {
180 for (let c = -1; c <= 7; c++) {
181 const rr = row + r;
182 const cc = col + c;
183 if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue;
184 const inRing = r >= 0 && r <= 6 && c >= 0 && c <= 6;
185 const dark =
186 inRing && (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4));
187 modules[rr][cc] = dark ? 1 : 0;
188 reserved[rr][cc] = true;
189 }
190 }
191 };
192
193 setFinder(0, 0);
194 setFinder(0, size - 7);
195 setFinder(size - 7, 0);
196
197 // Alignment patterns.
198 const positions = ALIGNMENT_POSITIONS[version];
199 for (const row of positions) {
200 for (const col of positions) {
201 // Skip the three finder corners.
202 const nearFinder =
203 (row <= 8 && col <= 8) ||
204 (row <= 8 && col >= size - 9) ||
205 (row >= size - 9 && col <= 8);
206 if (nearFinder) continue;
207 for (let r = -2; r <= 2; r++) {
208 for (let c = -2; c <= 2; c++) {
209 const dark = Math.max(Math.abs(r), Math.abs(c)) !== 1;
210 modules[row + r][col + c] = dark ? 1 : 0;
211 reserved[row + r][col + c] = true;
212 }
213 }
214 }
215 }
216
217 // Timing patterns.
218 for (let i = 8; i < size - 8; i++) {
219 if (!reserved[6][i]) {
220 modules[6][i] = i % 2 === 0 ? 1 : 0;
221 reserved[6][i] = true;
222 }
223 if (!reserved[i][6]) {
224 modules[i][6] = i % 2 === 0 ? 1 : 0;
225 reserved[i][6] = true;
226 }
227 }
228
229 // Dark module + reserve format areas.
230 modules[size - 8][8] = 1;
231 reserved[size - 8][8] = true;
232 for (let i = 0; i < 9; i++) {
233 if (!reserved[8][i]) reserved[8][i] = true;
234 if (!reserved[i][8]) reserved[i][8] = true;
235 }
236 for (let i = 0; i < 8; i++) {
237 reserved[8][size - 1 - i] = true;
238 reserved[size - 1 - i][8] = true;
239 }
240
241 // Reserve version info for version >= 7.
242 if (version >= 7) {
243 for (let i = 0; i < 6; i++) {
244 for (let j = 0; j < 3; j++) {
245 reserved[size - 11 + j][i] = true;
246 reserved[i][size - 11 + j] = true;
247 }
248 }
249 }
250
251 return { size, modules, reserved };
252 }
253
254 function placeData(matrix, codewords) {
255 const { size, modules, reserved } = matrix;
256 let bitIndex = 0;
257 const totalBits = codewords.length * 8;
258 const nextBit = () => {
259 if (bitIndex >= totalBits) return 0;
260 const byte = codewords[bitIndex >> 3];
261 const bit = (byte >>> (7 - (bitIndex & 7))) & 1;
262 bitIndex++;
263 return bit;
264 };
265
266 // Two-module-wide columns, right to left, alternating up/down. The vertical
267 // timing column (6) is skipped by shifting the whole pair left by one, which
268 // is why the guard is `<= 6` rather than `=== 6`: once the pair straddles the
269 // timing column every subsequent column is offset.
270 let upward = true;
271 for (let col = size - 1; col > 0; col -= 2) {
272 if (col <= 6) col -= 1;
273 for (let i = 0; i < size; i++) {
274 const row = upward ? size - 1 - i : i;
275 for (const c of [col, col - 1]) {
276 if (reserved[row][c]) continue;
277 modules[row][c] = nextBit();
278 }
279 }
280 upward = !upward;
281 }
282 }
283
284 function applyMask(modules, reserved, maskId) {
285 const size = modules.length;
286 return modules.map((row, r) =>
287 row.map((value, c) => {
288 if (reserved[r][c]) return value;
289 let invert;
290 switch (maskId) {
291 case 0: invert = (r + c) % 2 === 0; break;
292 case 1: invert = r % 2 === 0; break;
293 case 2: invert = c % 3 === 0; break;
294 case 3: invert = (r + c) % 3 === 0; break;
295 case 4: invert = (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0; break;
296 case 5: invert = ((r * c) % 2) + ((r * c) % 3) === 0; break;
297 case 6: invert = (((r * c) % 2) + ((r * c) % 3)) % 2 === 0; break;
298 default: invert = (((r + c) % 2) + ((r * c) % 3)) % 2 === 0; break;
299 }
300 return invert ? value ^ 1 : value;
301 })
302 );
303 }
304
305 function penalty(modules) {
306 const size = modules.length;
307 let score = 0;
308 // Rule 1: runs of 5+ same-colour modules.
309 for (let r = 0; r < size; r++) {
310 for (const dir of ["row", "col"]) {
311 let run = 1;
312 for (let i = 1; i < size; i++) {
313 const prev = dir === "row" ? modules[r][i - 1] : modules[i - 1][r];
314 const cur = dir === "row" ? modules[r][i] : modules[i][r];
315 if (cur === prev) {
316 run++;
317 } else {
318 if (run >= 5) score += 3 + (run - 5);
319 run = 1;
320 }
321 }
322 if (run >= 5) score += 3 + (run - 5);
323 }
324 }
325 // Rule 2: 2x2 blocks.
326 for (let r = 0; r < size - 1; r++) {
327 for (let c = 0; c < size - 1; c++) {
328 const v = modules[r][c];
329 if (v === modules[r][c + 1] && v === modules[r + 1][c] && v === modules[r + 1][c + 1]) {
330 score += 3;
331 }
332 }
333 }
334 // Rule 3: the two ISO/IEC 18004 finder-like patterns, each 11 modules long:
335 // pattern1: 10111010000
336 // pattern2: 00001011101
337 // Matching these exactly matters — a looser check selects a different mask
338 // than reference encoders, producing a valid-looking but different matrix.
339 const PATTERN1 = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0];
340 const PATTERN2 = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1];
341 const scanForFinderLike = (get) => {
342 let hits = 0;
343 for (let start = 0; start + 11 <= size; start++) {
344 let p1 = true;
345 let p2 = true;
346 for (let i = 0; i < 11; i++) {
347 const v = get(start + i);
348 if (v !== PATTERN1[i]) p1 = false;
349 if (v !== PATTERN2[i]) p2 = false;
350 if (!p1 && !p2) break;
351 }
352 if (p1 || p2) hits++;
353 }
354 return hits;
355 };
356 for (let r = 0; r < size; r++) {
357 score += 40 * scanForFinderLike((i) => modules[r][i]);
358 }
359 for (let c = 0; c < size; c++) {
360 score += 40 * scanForFinderLike((i) => modules[i][c]);
361 }
362 // Rule 4: dark-module balance.
363 let dark = 0;
364 for (const row of modules) for (const v of row) dark += v;
365 const percent = (dark * 100) / (size * size);
366 score += Math.floor(Math.abs(percent - 50) / 5) * 10;
367 return score;
368 }
369
370 function formatBits(maskId) {
371 // ECC level L (0b01) + mask, BCH(15,5) with the standard generator.
372 let data = (0b01 << 3) | maskId;
373 let rem = data << 10;
374 for (let i = 14; i >= 10; i--) {
375 if ((rem >>> i) & 1) rem ^= 0b10100110111 << (i - 10);
376 }
377 return ((data << 10) | rem) ^ 0b101010000010010;
378 }
379
380 function placeFormat(matrix, maskId) {
381 const { size, modules } = matrix;
382 const bits = formatBits(maskId);
383 for (let i = 0; i < 15; i++) {
384 const bit = (bits >>> i) & 1;
385 // First copy: bits 0-5 down the left of the top-left finder (column 8),
386 // bit 6 at (8,7), bits 7-8 at (8,5)-(8,6)... then along row 8 to the right.
387 if (i < 6) {
388 modules[i][8] = bit;
389 } else if (i < 8) {
390 modules[i + 1][8] = bit;
391 } else if (i === 8) {
392 modules[8][7] = bit;
393 } else {
394 modules[8][14 - i] = bit;
395 }
396 // Second copy: bits 0-7 along the bottom of the top-right finder,
397 // bits 8-14 down the right of the bottom-left finder.
398 if (i < 8) {
399 modules[8][size - 1 - i] = bit;
400 } else {
401 modules[size - 15 + i][8] = bit;
402 }
403 }
404 modules[size - 8][8] = 1;
405 }
406
407 function versionBits(version) {
408 let rem = version << 12;
409 for (let i = 17; i >= 12; i--) {
410 if ((rem >>> i) & 1) rem ^= 0b1111100100101 << (i - 12);
411 }
412 return (version << 12) | rem;
413 }
414
415 function placeVersion(matrix, version) {
416 if (version < 7) return;
417 const { size, modules } = matrix;
418 const bits = versionBits(version);
419 for (let i = 0; i < 18; i++) {
420 const bit = (bits >>> i) & 1;
421 const row = Math.floor(i / 3);
422 const col = i % 3;
423 modules[size - 11 + col][row] = bit;
424 modules[row][size - 11 + col] = bit;
425 }
426 }
427
428 // --- Public API ------------------------------------------------------------
429
430 /**
431 * Encode `text` as a QR module matrix (1 = dark).
432 * @param {string} text
433 * @returns {number[][]}
434 */
435 export function encodeQr(text) {
436 const bytes = [...Buffer.from(text, "utf8")];
437 const version = chooseVersion(bytes.length);
438 const codewords = buildCodewords(bytes, version);
439 const matrix = makeMatrix(version);
440 placeData(matrix, codewords);
441
442 let best = null;
443 for (let maskId = 0; maskId < 8; maskId++) {
444 const masked = applyMask(matrix.modules, matrix.reserved, maskId);
445 const score = penalty(masked);
446 if (!best || score < best.score) best = { score, masked, maskId };
447 }
448 placeFormat({ size: matrix.size, modules: best.masked }, best.maskId);
449 placeVersion({ size: matrix.size, modules: best.masked }, version);
450 return best.masked;
451 }
452
453 /**
454 * Render `text` as a terminal QR code using half-block glyphs, matching the
455 * Rust side's `Dense1x2` renderer.
456 *
457 * @param {string} text
458 * @param {{invert?: boolean, quietZone?: number}} [options]
459 * @returns {string}
460 */
461 export function renderQrToText(text, options = {}) {
462 const { invert = false, quietZone = 2 } = options;
463 const matrix = encodeQr(text);
464 const size = matrix.length;
465 const padded = size + quietZone * 2;
466
467 // Pad so the matrix has an even number of rows for half-block pairing.
468 const totalRows = padded % 2 === 0 ? padded : padded + 1;
469
470 const dark = (r, c) => {
471 const rr = r - quietZone;
472 const cc = c - quietZone;
473 if (rr < 0 || rr >= size || cc < 0 || cc >= size) return false;
474 return matrix[rr][cc] === 1;
475 };
476
477 const lines = [];
478 for (let r = 0; r < totalRows; r += 2) {
479 let line = "";
480 for (let c = 0; c < padded; c++) {
481 const top = dark(r, c);
482 const bottom = dark(r + 1, c);
483 // Half-block glyphs: each text cell covers two module rows.
484 if (top && bottom) line += "\u2588"; // █
485 else if (top) line += "\u2580"; // ▀
486 else if (bottom) line += "\u2584"; // ▄
487 else line += " ";
488 }
489 // Dark modules must be dark ink; when the terminal draws light-on-dark this
490 // is already correct, but allow the caller to flip for dark-on-light.
491 lines.push(invert ? line.replace(/[\u2580\u2584\u2588 ]/g, (ch) =>
492 ch === " " ? "\u2588" : " ") : line);
493 }
494 return lines.join("\n");
495 }
496
496 lines Plain Text