@homie-rocks/studio 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -11
- package/bin/homie-studio.mjs +132 -27
- package/lib/build.mjs +100 -23
- package/lib/cloudflare.mjs +146 -28
- package/lib/media.mjs +126 -0
- package/lib/port-check.mjs +651 -0
- package/lib/port.mjs +277 -0
- package/lib/scaffold.mjs +47 -18
- package/lib/studio.mjs +1 -1
- package/media/MEDIA.md +81 -0
- package/netplay/netplay.ts +93 -8
- package/package.json +12 -2
- package/port/audio.ts +96 -0
- package/port/bots.ts +97 -0
- package/port/camera.ts +75 -0
- package/port/controls.ts +48 -0
- package/port/early.ts +14 -0
- package/port/global.ts +9 -0
- package/port/hud.ts +96 -0
- package/port/index.ts +29 -0
- package/port/keys.ts +102 -0
- package/port/probe.ts +126 -0
- package/port/room.ts +356 -0
- package/port/sandbox.ts +112 -0
- package/port/touch.ts +378 -0
- package/starters/gem-rush/src/main.ts +9 -0
- package/worker/index.mjs +113 -11
- package/worker/pages.mjs +155 -13
- package/worker/qr.mjs +501 -0
package/worker/qr.mjs
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A QR encoder with no dependency, for the big-screen view's "join on your
|
|
3
|
+
* phone" square. Byte mode (a URL keeps its case), versions 1–10, error
|
|
4
|
+
* correction L/M/Q/H, the eight masks scored by the standard penalty rules
|
|
5
|
+
* (ISO/IEC 18004). The same encoder the homie.rocks big screen draws, which
|
|
6
|
+
* was checked against Apple's Vision barcode reader.
|
|
7
|
+
*
|
|
8
|
+
* `qrSvg(text)` is what the studio's pages use: one <svg> with crisp modules
|
|
9
|
+
* and the mandatory four-module quiet zone.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/* ========================================================================== */
|
|
13
|
+
/* Tables */
|
|
14
|
+
/* ========================================================================== */
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Per (version, level): EC codewords per block, then one or two block groups as
|
|
18
|
+
* [blockCount, dataCodewordsPerBlock].
|
|
19
|
+
*
|
|
20
|
+
* Transcribed from ISO/IEC 18004 Table 9. This is the single most error-prone
|
|
21
|
+
* body of constants in the file — a wrong row does not throw, it produces a
|
|
22
|
+
* plausible square that no phone can read. The homie.rocks checker exercises every
|
|
23
|
+
* version reachable from a realistic join URL, which is what stands behind it.
|
|
24
|
+
*/
|
|
25
|
+
const EC_TABLE = {
|
|
26
|
+
// L M Q H
|
|
27
|
+
1: { L: [7, [[1, 19]]], M: [10, [[1, 16]]], Q: [13, [[1, 13]]], H: [17, [[1, 9]]] },
|
|
28
|
+
2: { L: [10, [[1, 34]]], M: [16, [[1, 28]]], Q: [22, [[1, 22]]], H: [28, [[1, 16]]] },
|
|
29
|
+
3: { L: [15, [[1, 55]]], M: [26, [[1, 44]]], Q: [18, [[2, 17]]], H: [22, [[2, 13]]] },
|
|
30
|
+
4: { L: [20, [[1, 80]]], M: [18, [[2, 32]]], Q: [26, [[2, 24]]], H: [16, [[4, 9]]] },
|
|
31
|
+
5: { L: [26, [[1, 108]]], M: [24, [[2, 43]]], Q: [18, [[2, 15], [2, 16]]], H: [22, [[2, 11], [2, 12]]] },
|
|
32
|
+
6: { L: [18, [[2, 68]]], M: [16, [[4, 27]]], Q: [24, [[4, 19]]], H: [28, [[4, 15]]] },
|
|
33
|
+
7: { L: [20, [[2, 78]]], M: [18, [[4, 31]]], Q: [18, [[2, 14], [4, 15]]], H: [26, [[4, 13], [1, 14]]] },
|
|
34
|
+
8: { L: [24, [[2, 97]]], M: [22, [[2, 38], [2, 39]]], Q: [22, [[4, 18], [2, 19]]], H: [26, [[4, 14], [2, 15]]] },
|
|
35
|
+
9: { L: [30, [[2, 116]]], M: [22, [[3, 36], [2, 37]]], Q: [20, [[4, 16], [4, 17]]], H: [24, [[4, 12], [4, 13]]] },
|
|
36
|
+
10: { L: [18, [[2, 68], [2, 69]]], M: [26, [[4, 43], [1, 44]]], Q: [24, [[6, 19], [2, 20]]], H: [28, [[6, 15], [2, 16]]] },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Alignment-pattern centre coordinates. Version 1 has none. ISO 18004 Annex E. */
|
|
40
|
+
const ALIGNMENT = {
|
|
41
|
+
1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30],
|
|
42
|
+
6: [6, 34], 7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Two-bit level indicator as it appears in the format information. Not the same order as the names. */
|
|
46
|
+
const ECL_BITS = { L: 1, M: 0, Q: 3, H: 2 };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* How many data bits a version+level holds, minus the byte-mode header.
|
|
50
|
+
*
|
|
51
|
+
* The character-count field is 8 bits for versions 1–9 and 16 bits from version
|
|
52
|
+
* 10 — the boundary is inside our range, so it is computed rather than assumed.
|
|
53
|
+
*/
|
|
54
|
+
function byteCapacity(version, ecl) {
|
|
55
|
+
const [, groups] = EC_TABLE[version][ecl];
|
|
56
|
+
const dataCodewords = groups.reduce((n, [count, size]) => n + count * size, 0);
|
|
57
|
+
const headerBits = 4 + (version >= 10 ? 16 : 8);
|
|
58
|
+
return Math.floor((dataCodewords * 8 - headerBits) / 8);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* ========================================================================== */
|
|
62
|
+
/* GF(256) */
|
|
63
|
+
/* ========================================================================== */
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reed–Solomon over GF(2^8) with the QR primitive polynomial 0x11d.
|
|
67
|
+
*
|
|
68
|
+
* Built once at module load, so a big screen draws its square at once.
|
|
69
|
+
*/
|
|
70
|
+
const GF_EXP = new Uint8Array(512);
|
|
71
|
+
const GF_LOG = new Uint8Array(256);
|
|
72
|
+
(function buildTables() {
|
|
73
|
+
let x = 1;
|
|
74
|
+
for (let i = 0; i < 255; i++) {
|
|
75
|
+
GF_EXP[i] = x;
|
|
76
|
+
GF_LOG[x] = i;
|
|
77
|
+
x <<= 1;
|
|
78
|
+
if (x & 0x100) x ^= 0x11d;
|
|
79
|
+
}
|
|
80
|
+
for (let i = 255; i < 512; i++) GF_EXP[i] = GF_EXP[i - 255];
|
|
81
|
+
})();
|
|
82
|
+
|
|
83
|
+
function gfMul(a, b) {
|
|
84
|
+
if (a === 0 || b === 0) return 0;
|
|
85
|
+
return GF_EXP[GF_LOG[a] + GF_LOG[b]];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The generator polynomial for `degree` EC codewords: product of (x - 2^i). */
|
|
89
|
+
function generatorPoly(degree) {
|
|
90
|
+
let poly = [1];
|
|
91
|
+
for (let i = 0; i < degree; i++) {
|
|
92
|
+
const next = new Array(poly.length + 1).fill(0);
|
|
93
|
+
for (let j = 0; j < poly.length; j++) {
|
|
94
|
+
next[j] ^= gfMul(poly[j], 1);
|
|
95
|
+
next[j + 1] ^= gfMul(poly[j], GF_EXP[i]);
|
|
96
|
+
}
|
|
97
|
+
poly = next;
|
|
98
|
+
}
|
|
99
|
+
return poly;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Polynomial long division; returns the `degree` remainder codewords. */
|
|
103
|
+
function ecCodewords(data, degree) {
|
|
104
|
+
const gen = generatorPoly(degree);
|
|
105
|
+
const rem = new Uint8Array(degree);
|
|
106
|
+
for (const byte of data) {
|
|
107
|
+
const factor = byte ^ rem[0];
|
|
108
|
+
rem.copyWithin(0, 1);
|
|
109
|
+
rem[degree - 1] = 0;
|
|
110
|
+
for (let i = 0; i < degree; i++) rem[i] ^= gfMul(gen[i + 1], factor);
|
|
111
|
+
}
|
|
112
|
+
return rem;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* ========================================================================== */
|
|
116
|
+
/* Bit stream */
|
|
117
|
+
/* ========================================================================== */
|
|
118
|
+
|
|
119
|
+
function bitStream() {
|
|
120
|
+
const bits = [];
|
|
121
|
+
return {
|
|
122
|
+
bits,
|
|
123
|
+
push(value, length) {
|
|
124
|
+
for (let i = length - 1; i >= 0; i--) bits.push((value >>> i) & 1);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* ========================================================================== */
|
|
130
|
+
/* Encoding */
|
|
131
|
+
/* ========================================================================== */
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Choose the smallest version that fits, preferring the strongest correction
|
|
135
|
+
* level that fits *at that version*.
|
|
136
|
+
*
|
|
137
|
+
* The preference order is the interesting decision. Fewer, larger modules are
|
|
138
|
+
* easier for a camera to resolve from a couch; more error correction survives a
|
|
139
|
+
* bad angle, a reflection off a glossy panel, and a hand shaking on the third
|
|
140
|
+
* drink. Growing the version costs module size for the whole square, while
|
|
141
|
+
* raising the level inside a version costs nothing — so: smallest version wins
|
|
142
|
+
* first, and within it we take the most redundancy we can get for free.
|
|
143
|
+
*/
|
|
144
|
+
function chooseFormat(byteLength, minLevel) {
|
|
145
|
+
const order = ['H', 'Q', 'M', 'L'];
|
|
146
|
+
const floorIndex = order.indexOf(minLevel);
|
|
147
|
+
for (let version = 1; version <= 10; version++) {
|
|
148
|
+
for (let i = 0; i <= floorIndex; i++) {
|
|
149
|
+
const ecl = order[i];
|
|
150
|
+
if (byteLength <= byteCapacity(version, ecl)) return { version, ecl };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Mode indicator + length + payload + terminator + padding, to full codewords. */
|
|
157
|
+
function buildDataCodewords(bytes, version, ecl) {
|
|
158
|
+
const [, groups] = EC_TABLE[version][ecl];
|
|
159
|
+
const dataCodewords = groups.reduce((n, [count, size]) => n + count * size, 0);
|
|
160
|
+
const capacityBits = dataCodewords * 8;
|
|
161
|
+
|
|
162
|
+
const bs = bitStream();
|
|
163
|
+
bs.push(0b0100, 4); // byte mode
|
|
164
|
+
bs.push(bytes.length, version >= 10 ? 16 : 8);
|
|
165
|
+
for (const b of bytes) bs.push(b, 8);
|
|
166
|
+
|
|
167
|
+
// Terminator: up to four zero bits, but never past the end.
|
|
168
|
+
bs.push(0, Math.min(4, capacityBits - bs.bits.length));
|
|
169
|
+
while (bs.bits.length % 8 !== 0) bs.bits.push(0);
|
|
170
|
+
|
|
171
|
+
const out = new Uint8Array(dataCodewords);
|
|
172
|
+
for (let i = 0; i < bs.bits.length; i += 8) {
|
|
173
|
+
let byte = 0;
|
|
174
|
+
for (let j = 0; j < 8; j++) byte = (byte << 1) | bs.bits[i + j];
|
|
175
|
+
out[i / 8] = byte;
|
|
176
|
+
}
|
|
177
|
+
// The specified pad bytes, alternating. Not arbitrary filler: a decoder that
|
|
178
|
+
// sees something else here on a damaged symbol has fewer clues to recover it.
|
|
179
|
+
const PAD = [0xec, 0x11];
|
|
180
|
+
for (let i = bs.bits.length / 8, k = 0; i < dataCodewords; i++, k++) out[i] = PAD[k % 2];
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Split into blocks, compute EC, and interleave.
|
|
186
|
+
*
|
|
187
|
+
* The interleave is the whole point of blocking: a coffee stain or a thumb over
|
|
188
|
+
* one corner of the square damages a contiguous run of the *final* stream, and
|
|
189
|
+
* interleaving spreads that run across every block so each one loses only a few
|
|
190
|
+
* codewords and stays inside its own correction budget. Get the order wrong and
|
|
191
|
+
* an undamaged symbol still decodes — every block is intact — but a slightly
|
|
192
|
+
* damaged one fails completely, which is a bug you will only meet in a living
|
|
193
|
+
* room and never at a desk.
|
|
194
|
+
*/
|
|
195
|
+
function interleave(dataCodewords, version, ecl) {
|
|
196
|
+
const [ecPerBlock, groups] = EC_TABLE[version][ecl];
|
|
197
|
+
const dataBlocks = [];
|
|
198
|
+
const ecBlocks = [];
|
|
199
|
+
let offset = 0;
|
|
200
|
+
for (const [count, size] of groups) {
|
|
201
|
+
for (let i = 0; i < count; i++) {
|
|
202
|
+
const block = dataCodewords.slice(offset, offset + size);
|
|
203
|
+
offset += size;
|
|
204
|
+
dataBlocks.push(block);
|
|
205
|
+
ecBlocks.push(ecCodewords(block, ecPerBlock));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const out = [];
|
|
210
|
+
const maxData = Math.max(...dataBlocks.map((b) => b.length));
|
|
211
|
+
for (let i = 0; i < maxData; i++) {
|
|
212
|
+
for (const block of dataBlocks) if (i < block.length) out.push(block[i]);
|
|
213
|
+
}
|
|
214
|
+
for (let i = 0; i < ecPerBlock; i++) {
|
|
215
|
+
for (const block of ecBlocks) out.push(block[i]);
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* ========================================================================== */
|
|
221
|
+
/* The symbol */
|
|
222
|
+
/* ========================================================================== */
|
|
223
|
+
|
|
224
|
+
function blankMatrix(size) {
|
|
225
|
+
const m = [];
|
|
226
|
+
for (let i = 0; i < size; i++) m.push(new Uint8Array(size));
|
|
227
|
+
return m;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function drawFunctionPatterns(modules, reserved, version) {
|
|
231
|
+
const size = modules.length;
|
|
232
|
+
|
|
233
|
+
const set = (x, y, v) => {
|
|
234
|
+
if (x < 0 || y < 0 || x >= size || y >= size) return;
|
|
235
|
+
modules[y][x] = v;
|
|
236
|
+
reserved[y][x] = 1;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// Finder patterns and their separators, at three corners.
|
|
240
|
+
for (const [ox, oy] of [[0, 0], [size - 7, 0], [0, size - 7]]) {
|
|
241
|
+
for (let dy = -1; dy <= 7; dy++) {
|
|
242
|
+
for (let dx = -1; dx <= 7; dx++) {
|
|
243
|
+
const inner = dx >= 0 && dx <= 6 && dy >= 0 && dy <= 6;
|
|
244
|
+
const dark = inner && (dx === 0 || dx === 6 || dy === 0 || dy === 6 ||
|
|
245
|
+
(dx >= 2 && dx <= 4 && dy >= 2 && dy <= 4));
|
|
246
|
+
set(ox + dx, oy + dy, dark ? 1 : 0);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Timing patterns. Row 6 and column 6, alternating from the origin.
|
|
252
|
+
for (let i = 8; i < size - 8; i++) {
|
|
253
|
+
const v = i % 2 === 0 ? 1 : 0;
|
|
254
|
+
set(i, 6, v);
|
|
255
|
+
set(6, i, v);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Alignment patterns, at every pair of centres except the three that would
|
|
259
|
+
// sit on top of a finder.
|
|
260
|
+
const centres = ALIGNMENT[version];
|
|
261
|
+
for (const cy of centres) {
|
|
262
|
+
for (const cx of centres) {
|
|
263
|
+
const onFinder =
|
|
264
|
+
(cx === 6 && cy === 6) ||
|
|
265
|
+
(cx === 6 && cy === size - 7) ||
|
|
266
|
+
(cx === size - 7 && cy === 6);
|
|
267
|
+
if (onFinder) continue;
|
|
268
|
+
for (let dy = -2; dy <= 2; dy++) {
|
|
269
|
+
for (let dx = -2; dx <= 2; dx++) {
|
|
270
|
+
const dark = Math.max(Math.abs(dx), Math.abs(dy)) !== 1;
|
|
271
|
+
set(cx + dx, cy + dy, dark ? 1 : 0);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// The dark module. Always set, always here. Its only job is to exist.
|
|
278
|
+
set(8, size - 8, 1);
|
|
279
|
+
|
|
280
|
+
// Reserve the format-information strips; the values are written after masking
|
|
281
|
+
// because they encode which mask was chosen.
|
|
282
|
+
for (let i = 0; i < 9; i++) {
|
|
283
|
+
if (!reserved[i][8]) set(8, i, 0);
|
|
284
|
+
if (!reserved[8][i]) set(i, 8, 0);
|
|
285
|
+
}
|
|
286
|
+
for (let i = 0; i < 8; i++) {
|
|
287
|
+
if (!reserved[size - 1 - i][8]) set(8, size - 1 - i, 0);
|
|
288
|
+
if (!reserved[8][size - 1 - i]) set(size - 1 - i, 8, 0);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Version information, versions 7 and up.
|
|
292
|
+
if (version >= 7) {
|
|
293
|
+
const bits = versionBits(version);
|
|
294
|
+
for (let i = 0; i < 18; i++) {
|
|
295
|
+
const bit = (bits >>> i) & 1;
|
|
296
|
+
const a = Math.floor(i / 3);
|
|
297
|
+
const b = (i % 3) + size - 11;
|
|
298
|
+
set(a, b, bit);
|
|
299
|
+
set(b, a, bit);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** BCH(18,6) over the version number. Computed, not tabulated — a table of four
|
|
305
|
+
* magic constants is four chances to have typed one wrong. */
|
|
306
|
+
function versionBits(version) {
|
|
307
|
+
let rem = version;
|
|
308
|
+
for (let i = 0; i < 12; i++) rem = (rem * 2) ^ ((rem >>> 11) * 0x1f25); // rem * 2 is rem shifted left one bit (rem stays under 2^13)
|
|
309
|
+
return ((version << 12) | rem) & 0x3ffff;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** BCH(15,5) over level+mask, then XORed with the spec's fixed pattern so that
|
|
313
|
+
* an all-zero format never appears (which would be unreadable as "no data"). */
|
|
314
|
+
function formatBits(ecl, mask) {
|
|
315
|
+
const data = (ECL_BITS[ecl] << 3) | mask;
|
|
316
|
+
let rem = data;
|
|
317
|
+
for (let i = 0; i < 10; i++) rem = (rem * 2) ^ ((rem >>> 9) * 0x537);
|
|
318
|
+
return (((data << 10) | rem) ^ 0x5412) & 0x7fff;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function drawFormat(modules, ecl, mask) {
|
|
322
|
+
const size = modules.length;
|
|
323
|
+
const bits = formatBits(ecl, mask);
|
|
324
|
+
for (let i = 0; i < 15; i++) {
|
|
325
|
+
const bit = (bits >>> i) & 1;
|
|
326
|
+
// First copy, around the top-left finder, skipping the timing row/column.
|
|
327
|
+
if (i < 6) modules[i][8] = bit;
|
|
328
|
+
else if (i < 8) modules[i + 1][8] = bit;
|
|
329
|
+
else if (i === 8) modules[8][7] = bit;
|
|
330
|
+
else modules[8][14 - i] = bit;
|
|
331
|
+
// Second copy, split between the other two finders, so a symbol with one
|
|
332
|
+
// damaged corner still declares its own mask.
|
|
333
|
+
if (i < 8) modules[8][size - 1 - i] = bit;
|
|
334
|
+
else modules[size - 15 + i][8] = bit;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const MASKS = [
|
|
339
|
+
(r, c) => (r + c) % 2 === 0,
|
|
340
|
+
(r) => r % 2 === 0,
|
|
341
|
+
(r, c) => c % 3 === 0,
|
|
342
|
+
(r, c) => (r + c) % 3 === 0,
|
|
343
|
+
(r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
|
|
344
|
+
(r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
|
|
345
|
+
(r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0,
|
|
346
|
+
(r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
|
|
347
|
+
];
|
|
348
|
+
|
|
349
|
+
function placeData(modules, reserved, codewords) {
|
|
350
|
+
const size = modules.length;
|
|
351
|
+
let bit = 0;
|
|
352
|
+
const total = codewords.length * 8;
|
|
353
|
+
for (let right = size - 1; right >= 1; right -= 2) {
|
|
354
|
+
if (right === 6) right = 5; // column 6 is the vertical timing pattern
|
|
355
|
+
for (let v = 0; v < size; v++) {
|
|
356
|
+
for (let j = 0; j < 2; j++) {
|
|
357
|
+
const x = right - j;
|
|
358
|
+
const upward = ((right + 1) & 2) === 0;
|
|
359
|
+
const y = upward ? size - 1 - v : v;
|
|
360
|
+
if (reserved[y][x]) continue;
|
|
361
|
+
// Past the end of the stream the remainder bits stay zero, which is what
|
|
362
|
+
// the spec requires — they are not data and are not padded.
|
|
363
|
+
modules[y][x] = bit < total ? (codewords[bit >> 3] >>> (7 - (bit & 7))) & 1 : 0;
|
|
364
|
+
bit++;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Mask penalty, ISO 18004 §8.8.2.
|
|
372
|
+
*
|
|
373
|
+
* The four rules exist to stop a mask producing something that *looks* like a
|
|
374
|
+
* finder pattern to a camera, or a field so lopsided that thresholding fails
|
|
375
|
+
* under a dim room light. This is scored for all eight masks and the
|
|
376
|
+
* lowest wins.
|
|
377
|
+
*/
|
|
378
|
+
function penalty(modules) {
|
|
379
|
+
const size = modules.length;
|
|
380
|
+
let score = 0;
|
|
381
|
+
|
|
382
|
+
// Rule 1 — runs of five or more identical modules in a row or column.
|
|
383
|
+
for (let i = 0; i < size; i++) {
|
|
384
|
+
for (const read of [(k) => modules[i][k], (k) => modules[k][i]]) {
|
|
385
|
+
let run = 1;
|
|
386
|
+
for (let k = 1; k < size; k++) {
|
|
387
|
+
if (read(k) === read(k - 1)) {
|
|
388
|
+
run++;
|
|
389
|
+
if (run === 5) score += 3;
|
|
390
|
+
else if (run > 5) score += 1;
|
|
391
|
+
} else run = 1;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Rule 2 — every 2x2 block of one colour.
|
|
397
|
+
for (let y = 0; y < size - 1; y++) {
|
|
398
|
+
for (let x = 0; x < size - 1; x++) {
|
|
399
|
+
const v = modules[y][x];
|
|
400
|
+
if (v === modules[y][x + 1] && v === modules[y + 1][x] && v === modules[y + 1][x + 1]) score += 3;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Rule 3 — the finder-lookalike 1:1:3:1:1 with four light modules on a side.
|
|
405
|
+
const A = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0];
|
|
406
|
+
const B = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1];
|
|
407
|
+
for (let i = 0; i < size; i++) {
|
|
408
|
+
for (const read of [(k) => modules[i][k], (k) => modules[k][i]]) {
|
|
409
|
+
for (let k = 0; k + 11 <= size; k++) {
|
|
410
|
+
let mA = true; let mB = true;
|
|
411
|
+
for (let j = 0; j < 11; j++) {
|
|
412
|
+
const v = read(k + j);
|
|
413
|
+
if (v !== A[j]) mA = false;
|
|
414
|
+
if (v !== B[j]) mB = false;
|
|
415
|
+
}
|
|
416
|
+
if (mA) score += 40;
|
|
417
|
+
if (mB) score += 40;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Rule 4 — deviation from a 50/50 light/dark split.
|
|
423
|
+
let dark = 0;
|
|
424
|
+
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) dark += modules[y][x];
|
|
425
|
+
const percent = (dark * 100) / (size * size);
|
|
426
|
+
score += Math.floor(Math.abs(percent - 50) / 5) * 10;
|
|
427
|
+
|
|
428
|
+
return score;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/* ========================================================================== */
|
|
432
|
+
/* Public */
|
|
433
|
+
/* ========================================================================== */
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Encode `text` as a QR symbol.
|
|
437
|
+
*
|
|
438
|
+
* Returns `{ size, version, ecl, mask, get(x, y) }` where `get` is true for a
|
|
439
|
+
* dark module. Throws only for a text too long for version 10 — a caller that
|
|
440
|
+
* can be handed an arbitrary URL should catch and show the address as text.
|
|
441
|
+
*
|
|
442
|
+
* `minLevel` is a floor, not a target: 'M' means "at least M", and a short URL
|
|
443
|
+
* will happily come back as Q or H at the same physical size.
|
|
444
|
+
*/
|
|
445
|
+
export function encodeQr(text, { minLevel = 'M' } = {}) {
|
|
446
|
+
const bytes = new TextEncoder().encode(text);
|
|
447
|
+
const chosen = chooseFormat(bytes.length, minLevel);
|
|
448
|
+
if (!chosen) {
|
|
449
|
+
throw new Error(`QR: ${bytes.length} bytes will not fit in version 1-10 at level ${minLevel} or better`);
|
|
450
|
+
}
|
|
451
|
+
const { version, ecl } = chosen;
|
|
452
|
+
const size = version * 4 + 17;
|
|
453
|
+
|
|
454
|
+
const reserved = blankMatrix(size);
|
|
455
|
+
const base = blankMatrix(size);
|
|
456
|
+
drawFunctionPatterns(base, reserved, version);
|
|
457
|
+
|
|
458
|
+
const codewords = interleave(buildDataCodewords(bytes, version, ecl), version, ecl);
|
|
459
|
+
|
|
460
|
+
let best = null;
|
|
461
|
+
for (let mask = 0; mask < 8; mask++) {
|
|
462
|
+
const modules = base.map((row) => Uint8Array.from(row));
|
|
463
|
+
placeData(modules, reserved, codewords);
|
|
464
|
+
const fn = MASKS[mask];
|
|
465
|
+
for (let y = 0; y < size; y++) {
|
|
466
|
+
for (let x = 0; x < size; x++) {
|
|
467
|
+
if (!reserved[y][x] && fn(y, x)) modules[y][x] ^= 1;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
drawFormat(modules, ecl, mask);
|
|
471
|
+
const score = penalty(modules);
|
|
472
|
+
if (!best || score < best.score) best = { score, mask, modules };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
size,
|
|
477
|
+
version,
|
|
478
|
+
ecl,
|
|
479
|
+
mask: best.mask,
|
|
480
|
+
get: (x, y) => best.modules[y][x] === 1,
|
|
481
|
+
/** Row-major 0/1 bytes. Only the harness and the canvas painter want this. */
|
|
482
|
+
toBytes() {
|
|
483
|
+
const out = new Uint8Array(size * size);
|
|
484
|
+
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) out[y * size + x] = best.modules[y][x];
|
|
485
|
+
return out;
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
/** The symbol as an SVG string: one path, dark modules on a light square with the quiet zone. */
|
|
492
|
+
export function qrSvg(text, { minLevel = 'M', quiet = 4, dark = '#000000', light = '#ffffff', title = '' } = {}) {
|
|
493
|
+
const q = encodeQr(text, { minLevel });
|
|
494
|
+
const total = q.size + quiet * 2;
|
|
495
|
+
let d = '';
|
|
496
|
+
for (let y = 0; y < q.size; y++) {
|
|
497
|
+
for (let x = 0; x < q.size; x++) if (q.get(x, y)) d += `M${x + quiet} ${y + quiet}h1v1h-1z`;
|
|
498
|
+
}
|
|
499
|
+
const t = String(title).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
500
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${total} ${total}" shape-rendering="crispEdges" role="img"${t ? ` aria-label="${t}"` : ''}><rect width="${total}" height="${total}" fill="${light}"/><path d="${d}" fill="${dark}"/></svg>`;
|
|
501
|
+
}
|