dsh-plugin-mobile-link 0.2.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 +111 -0
- package/cordis.patch.yml +37 -0
- package/lib/cli.js +191 -0
- package/lib/dlp.js +296 -0
- package/lib/dsh-client.js +280 -0
- package/lib/enroll.js +142 -0
- package/lib/files.js +127 -0
- package/lib/hello.js +65 -0
- package/lib/index.js +128 -0
- package/lib/link.js +439 -0
- package/lib/pairing.js +41 -0
- package/lib/qr.js +576 -0
- package/lib/router.js +379 -0
- package/lib/routes.js +181 -0
- package/lib/state.js +148 -0
- package/lib/ws.js +183 -0
- package/package.json +54 -0
package/lib/qr.js
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small QR encoder, and the SVG the pairing screen shows.
|
|
3
|
+
*
|
|
4
|
+
* Why hand-rolled instead of an npm dependency: this connector is loaded into
|
|
5
|
+
* the DSH process, and `lib/` is deliberately self-contained (see index.js).
|
|
6
|
+
* Adding a package here would mean the plugin only works when someone has run
|
|
7
|
+
* an install step, which is precisely the failure mode a first-run enrollment
|
|
8
|
+
* flow cannot afford. The subset of ISO/IEC 18004 needed for one short URL is
|
|
9
|
+
* small enough to own, and `test/qr.test.js` decodes every symbol it builds
|
|
10
|
+
* with an independent implementation, so a table or bit-order mistake cannot
|
|
11
|
+
* ship quietly.
|
|
12
|
+
*
|
|
13
|
+
* Supported: byte mode, error-correction level L, versions 1–10 (up to 271
|
|
14
|
+
* data bytes). That covers `dsh://pair?relay=…&code=…` with the longest relay
|
|
15
|
+
* URL we would plausibly issue while keeping the symbol small enough to scan
|
|
16
|
+
* off a laptop screen at arm's length.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Error-correction level L: 7% recovery, smallest symbol per payload. */
|
|
20
|
+
const EC_FORMAT_BITS = 0b01
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Block layout for level L, by version:
|
|
24
|
+
* `[ecPerBlock, group1Blocks, group1DataPerBlock, group2Blocks, group2DataPerBlock]`.
|
|
25
|
+
*
|
|
26
|
+
* Straight from the standard's Table 9, written out in full rather than
|
|
27
|
+
* derived: a derived shortcut that is wrong for one version produces a symbol
|
|
28
|
+
* that looks perfect and decodes to nothing.
|
|
29
|
+
*/
|
|
30
|
+
const L_BLOCKS = {
|
|
31
|
+
1: [7, 1, 19, 0, 0],
|
|
32
|
+
2: [10, 1, 34, 0, 0],
|
|
33
|
+
3: [15, 1, 55, 0, 0],
|
|
34
|
+
4: [20, 1, 80, 0, 0],
|
|
35
|
+
5: [26, 1, 108, 0, 0],
|
|
36
|
+
6: [18, 2, 68, 0, 0],
|
|
37
|
+
7: [20, 2, 78, 0, 0],
|
|
38
|
+
8: [24, 2, 97, 0, 0],
|
|
39
|
+
9: [30, 2, 116, 0, 0],
|
|
40
|
+
10: [18, 2, 68, 2, 69],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Alignment-pattern centre coordinates per version (ISO/IEC 18004 Annex E). */
|
|
44
|
+
const ALIGNMENT_CENTRES = {
|
|
45
|
+
1: [],
|
|
46
|
+
2: [6, 18],
|
|
47
|
+
3: [6, 22],
|
|
48
|
+
4: [6, 26],
|
|
49
|
+
5: [6, 30],
|
|
50
|
+
6: [6, 34],
|
|
51
|
+
7: [6, 22, 38],
|
|
52
|
+
8: [6, 24, 42],
|
|
53
|
+
9: [6, 26, 46],
|
|
54
|
+
10: [6, 28, 50],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Total codewords the symbol holds, at any level (capacity table). */
|
|
58
|
+
const TOTAL_CODEWORDS = {
|
|
59
|
+
1: 26, 2: 44, 3: 70, 4: 100, 5: 134, 6: 172, 7: 196, 8: 242, 9: 292, 10: 346,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const MIN_VERSION = 1
|
|
63
|
+
const MAX_VERSION = 10
|
|
64
|
+
|
|
65
|
+
// ── GF(256) arithmetic ──────────────────────────────────────────────────────
|
|
66
|
+
//
|
|
67
|
+
// QR's field: polynomial 0x11D with generator 2. Built once, lazily, so the
|
|
68
|
+
// module has no top-level side effects.
|
|
69
|
+
|
|
70
|
+
let EXP
|
|
71
|
+
let LOG
|
|
72
|
+
|
|
73
|
+
function field() {
|
|
74
|
+
if (EXP) return { exp: EXP, log: LOG }
|
|
75
|
+
EXP = new Uint8Array(512)
|
|
76
|
+
LOG = new Uint8Array(256)
|
|
77
|
+
let value = 1
|
|
78
|
+
for (let index = 0; index < 255; index += 1) {
|
|
79
|
+
EXP[index] = value
|
|
80
|
+
LOG[value] = index
|
|
81
|
+
value <<= 1
|
|
82
|
+
if (value & 0x100) value ^= 0x11d
|
|
83
|
+
}
|
|
84
|
+
for (let index = 255; index < 512; index += 1) EXP[index] = EXP[index - 255]
|
|
85
|
+
return { exp: EXP, log: LOG }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function gfMultiply(a, b) {
|
|
89
|
+
if (a === 0 || b === 0) return 0
|
|
90
|
+
const { exp, log } = field()
|
|
91
|
+
return exp[log[a] + log[b]]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The generator polynomial for `degree` error-correction codewords. */
|
|
95
|
+
function generatorPolynomial(degree) {
|
|
96
|
+
let polynomial = [1]
|
|
97
|
+
for (let index = 0; index < degree; index += 1) {
|
|
98
|
+
const next = new Array(polynomial.length + 1).fill(0)
|
|
99
|
+
for (let term = 0; term < polynomial.length; term += 1) {
|
|
100
|
+
next[term] ^= polynomial[term]
|
|
101
|
+
next[term + 1] ^= gfMultiply(polynomial[term], field().exp[index])
|
|
102
|
+
}
|
|
103
|
+
polynomial = next
|
|
104
|
+
}
|
|
105
|
+
return polynomial
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The `degree` error-correction codewords for one data block. */
|
|
109
|
+
function reedSolomon(data, degree) {
|
|
110
|
+
const generator = generatorPolynomial(degree)
|
|
111
|
+
const remainder = new Array(degree).fill(0)
|
|
112
|
+
for (const byte of data) {
|
|
113
|
+
const factor = byte ^ remainder[0]
|
|
114
|
+
remainder.shift()
|
|
115
|
+
remainder.push(0)
|
|
116
|
+
for (let index = 0; index < degree; index += 1) {
|
|
117
|
+
remainder[index] ^= gfMultiply(generator[index + 1], factor)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return remainder
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── symbol geometry ─────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
function sizeOf(version) {
|
|
126
|
+
return 17 + 4 * version
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Data codewords available at level L. */
|
|
130
|
+
function dataCodewords(version) {
|
|
131
|
+
const [, firstBlocks, firstData, secondBlocks, secondData] = L_BLOCKS[version]
|
|
132
|
+
return firstBlocks * firstData + secondBlocks * secondData
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The block groups as `{blocks, dataPerBlock}` records. */
|
|
136
|
+
function blockGroups(version) {
|
|
137
|
+
const [ecPerBlock, firstBlocks, firstData, secondBlocks, secondData] = L_BLOCKS[version]
|
|
138
|
+
const groups = [{ blocks: firstBlocks, dataPerBlock: firstData, ecPerBlock }]
|
|
139
|
+
if (secondBlocks > 0) groups.push({ blocks: secondBlocks, dataPerBlock: secondData, ecPerBlock })
|
|
140
|
+
return groups
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Character-count indicator width for byte mode. */
|
|
144
|
+
function countBits(version) {
|
|
145
|
+
return version <= 9 ? 8 : 16
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** BCH check bits for the format (15,5) and version (18,6) fields. */
|
|
149
|
+
function bch(value, generator, shift) {
|
|
150
|
+
const generatorBits = 32 - Math.clz32(generator)
|
|
151
|
+
let remainder = value << shift
|
|
152
|
+
while (remainder !== 0 && 32 - Math.clz32(remainder) >= generatorBits) {
|
|
153
|
+
remainder ^= generator << ((32 - Math.clz32(remainder)) - generatorBits)
|
|
154
|
+
}
|
|
155
|
+
return (value << shift) | remainder
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The 15 format bits: BCH(15,5) over the level and mask, then XOR-masked. */
|
|
159
|
+
export function formatBits(ecBits, mask) {
|
|
160
|
+
return bch((ecBits << 3) | mask, 0x537, 10) ^ 0x5412
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The 18 version-information bits: BCH(18,6), unmasked. */
|
|
164
|
+
export function versionInfoBits(version) {
|
|
165
|
+
return bch(version, 0x1f25, 12)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── encoding ────────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
/** Choose the smallest version at level L that holds `text` in byte mode. */
|
|
171
|
+
export function versionFor(text) {
|
|
172
|
+
const payloadBytes = Buffer.byteLength(text, 'utf8')
|
|
173
|
+
for (let version = MIN_VERSION; version <= MAX_VERSION; version += 1) {
|
|
174
|
+
// Everything is counted in bits: mode indicator + character count + payload.
|
|
175
|
+
if (dataCodewords(version) * 8 >= 4 + countBits(version) + payloadBytes * 8) return version
|
|
176
|
+
}
|
|
177
|
+
throw new Error(
|
|
178
|
+
`the pairing payload is too long for a QR code (${payloadBytes} bytes;`
|
|
179
|
+
+ ` version ${MAX_VERSION} holds ${dataCodewords(MAX_VERSION) - 3} in byte mode)`,
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The data codewords for `text`: mode, length, payload, terminator, padding. */
|
|
184
|
+
function dataCodewordsFor(text, version) {
|
|
185
|
+
const bytes = [...Buffer.from(text, 'utf8')]
|
|
186
|
+
const capacityBits = dataCodewords(version) * 8
|
|
187
|
+
const bits = []
|
|
188
|
+
const push = (value, width) => {
|
|
189
|
+
for (let index = width - 1; index >= 0; index -= 1) bits.push((value >> index) & 1)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
push(0b0100, 4) // byte mode
|
|
193
|
+
push(bytes.length, countBits(version))
|
|
194
|
+
for (const byte of bytes) push(byte, 8)
|
|
195
|
+
|
|
196
|
+
push(0, Math.min(4, capacityBits - bits.length)) // terminator
|
|
197
|
+
while (bits.length % 8 !== 0) bits.push(0)
|
|
198
|
+
const pads = [0xec, 0x11]
|
|
199
|
+
for (let index = 0; bits.length < capacityBits; index += 1) push(pads[index % 2], 8)
|
|
200
|
+
|
|
201
|
+
const codewords = []
|
|
202
|
+
for (let index = 0; index < bits.length; index += 8) {
|
|
203
|
+
let byte = 0
|
|
204
|
+
for (let offset = 0; offset < 8; offset += 1) byte = (byte << 1) | bits[index + offset]
|
|
205
|
+
codewords.push(byte)
|
|
206
|
+
}
|
|
207
|
+
return codewords
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Split into blocks, add EC to each, and interleave as the standard requires. */
|
|
211
|
+
function interleave(codewords, version) {
|
|
212
|
+
const ecPerBlock = L_BLOCKS[version][0]
|
|
213
|
+
const dataBlocks = []
|
|
214
|
+
const ecBlocks = []
|
|
215
|
+
let cursor = 0
|
|
216
|
+
for (const group of blockGroups(version)) {
|
|
217
|
+
for (let block = 0; block < group.blocks; block += 1) {
|
|
218
|
+
const slice = codewords.slice(cursor, cursor + group.dataPerBlock)
|
|
219
|
+
cursor += group.dataPerBlock
|
|
220
|
+
dataBlocks.push(slice)
|
|
221
|
+
ecBlocks.push(reedSolomon(slice, ecPerBlock))
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const out = []
|
|
226
|
+
const longest = Math.max(...dataBlocks.map((block) => block.length))
|
|
227
|
+
for (let index = 0; index < longest; index += 1) {
|
|
228
|
+
for (const block of dataBlocks) if (index < block.length) out.push(block[index])
|
|
229
|
+
}
|
|
230
|
+
for (let index = 0; index < ecPerBlock; index += 1) {
|
|
231
|
+
for (const block of ecBlocks) out.push(block[index])
|
|
232
|
+
}
|
|
233
|
+
return out
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── symbol assembly ─────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
/** Which modules are function patterns (and therefore never carry data). */
|
|
239
|
+
function reservedMap(version) {
|
|
240
|
+
const size = sizeOf(version)
|
|
241
|
+
const reserved = Array.from({ length: size }, () => new Array(size).fill(false))
|
|
242
|
+
const mark = (row, column) => {
|
|
243
|
+
if (row >= 0 && column >= 0 && row < size && column < size) reserved[row][column] = true
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Finder patterns with their separators: an 8x8 block at each corner.
|
|
247
|
+
for (const [top, left] of [[0, 0], [0, size - 7], [size - 7, 0]]) {
|
|
248
|
+
for (let row = -1; row <= 7; row += 1) {
|
|
249
|
+
for (let column = -1; column <= 7; column += 1) mark(top + row, left + column)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// Timing patterns.
|
|
253
|
+
for (let index = 0; index < size; index += 1) {
|
|
254
|
+
mark(6, index)
|
|
255
|
+
mark(index, 6)
|
|
256
|
+
}
|
|
257
|
+
// Alignment patterns, except the three that would overlap a finder.
|
|
258
|
+
const centres = ALIGNMENT_CENTRES[version] ?? []
|
|
259
|
+
for (const row of centres) {
|
|
260
|
+
for (const column of centres) {
|
|
261
|
+
const overlapsFinder = (row <= 8 && column <= 8)
|
|
262
|
+
|| (row <= 8 && column >= size - 9)
|
|
263
|
+
|| (row >= size - 9 && column <= 8)
|
|
264
|
+
if (overlapsFinder) continue
|
|
265
|
+
for (let dr = -2; dr <= 2; dr += 1) {
|
|
266
|
+
for (let dc = -2; dc <= 2; dc += 1) mark(row + dr, column + dc)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Format field (both copies) and the always-dark module.
|
|
271
|
+
for (let index = 0; index <= 8; index += 1) {
|
|
272
|
+
mark(8, index)
|
|
273
|
+
mark(index, 8)
|
|
274
|
+
}
|
|
275
|
+
for (let index = 0; index < 8; index += 1) {
|
|
276
|
+
mark(8, size - 1 - index)
|
|
277
|
+
mark(size - 1 - index, 8)
|
|
278
|
+
}
|
|
279
|
+
mark(size - 8, 8)
|
|
280
|
+
// Version information.
|
|
281
|
+
if (version >= 7) {
|
|
282
|
+
for (let index = 0; index < 18; index += 1) {
|
|
283
|
+
mark(Math.floor(index / 3), size - 11 + (index % 3))
|
|
284
|
+
mark(size - 11 + (index % 3), Math.floor(index / 3))
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return reserved
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Finder, separator and alignment patterns — the parts that never change. */
|
|
291
|
+
function drawFunctionPatterns(reserved, version) {
|
|
292
|
+
const size = sizeOf(version)
|
|
293
|
+
const matrix = Array.from({ length: size }, () => new Array(size).fill(false))
|
|
294
|
+
|
|
295
|
+
const drawFinder = (top, left) => {
|
|
296
|
+
for (let row = -1; row <= 7; row += 1) {
|
|
297
|
+
for (let column = -1; column <= 7; column += 1) {
|
|
298
|
+
const r = top + row
|
|
299
|
+
const c = left + column
|
|
300
|
+
if (r < 0 || c < 0 || r >= size || c >= size) continue
|
|
301
|
+
const insideFinder = row >= 0 && row <= 6 && column >= 0 && column <= 6
|
|
302
|
+
// Separator ring (the 8x8 border) is light; inside, a ring of width 1
|
|
303
|
+
// around a 3x3 core is dark.
|
|
304
|
+
const ring = Math.max(Math.abs(row - 3), Math.abs(column - 3))
|
|
305
|
+
matrix[r][c] = insideFinder && ring !== 2
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
drawFinder(0, 0)
|
|
310
|
+
drawFinder(0, size - 7)
|
|
311
|
+
drawFinder(size - 7, 0)
|
|
312
|
+
|
|
313
|
+
for (let index = 8; index < size - 8; index += 1) {
|
|
314
|
+
matrix[6][index] = index % 2 === 0
|
|
315
|
+
matrix[index][6] = index % 2 === 0
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const centres = ALIGNMENT_CENTRES[version] ?? []
|
|
319
|
+
for (const row of centres) {
|
|
320
|
+
for (const column of centres) {
|
|
321
|
+
// The three centres that sit on a finder (top-left, top-right,
|
|
322
|
+
// bottom-left) are skipped: their alignment pattern is already drawn.
|
|
323
|
+
const overlapsFinder = (row === 6 && column === 6)
|
|
324
|
+
|| (row === 6 && column === size - 7)
|
|
325
|
+
|| (row === size - 7 && column === 6)
|
|
326
|
+
if (overlapsFinder) continue
|
|
327
|
+
for (let dr = -2; dr <= 2; dr += 1) {
|
|
328
|
+
for (let dc = -2; dc <= 2; dc += 1) {
|
|
329
|
+
const r = row + dr
|
|
330
|
+
const c = column + dc
|
|
331
|
+
if (r < 0 || c < 0 || r >= size || c >= size) continue
|
|
332
|
+
matrix[r][c] = Math.max(Math.abs(dr), Math.abs(dc)) !== 1
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return matrix
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** XOR one mask into every module that is not a function pattern. */
|
|
341
|
+
function applyMask(matrix, reserved, pattern) {
|
|
342
|
+
const masked = matrix.map((row) => row.slice())
|
|
343
|
+
for (let row = 0; row < matrix.length; row += 1) {
|
|
344
|
+
for (let column = 0; column < matrix.length; column += 1) {
|
|
345
|
+
if (reserved[row][column]) continue
|
|
346
|
+
if (maskAt(pattern, row, column)) masked[row][column] = !masked[row][column]
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return masked
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function maskAt(pattern, row, column) {
|
|
353
|
+
switch (pattern) {
|
|
354
|
+
case 0: return (row + column) % 2 === 0
|
|
355
|
+
case 1: return row % 2 === 0
|
|
356
|
+
case 2: return column % 3 === 0
|
|
357
|
+
case 3: return (row + column) % 3 === 0
|
|
358
|
+
case 4: return (Math.floor(row / 2) + Math.floor(column / 3)) % 2 === 0
|
|
359
|
+
case 5: return ((row * column) % 2) + ((row * column) % 3) === 0
|
|
360
|
+
case 6: return (((row * column) % 2) + ((row * column) % 3)) % 2 === 0
|
|
361
|
+
default: return (((row + column) % 2) + ((row * column) % 3)) % 2 === 0
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Write the data codewords in the standard's two-module-wide zigzag.
|
|
367
|
+
*
|
|
368
|
+
* Column 6 is the vertical timing pattern and is not part of any pair, so the
|
|
369
|
+
* pair *starts* one column earlier: 20/19, 18/17, … 8/7, then 5/4 (skipping 6),
|
|
370
|
+
* then 3/2, 1/0. Advancing the loop counter over that skip is what keeps the
|
|
371
|
+
* pair sequence aligned.
|
|
372
|
+
*/
|
|
373
|
+
function drawData(matrix, reserved, codewords, version) {
|
|
374
|
+
const size = sizeOf(version)
|
|
375
|
+
const bits = []
|
|
376
|
+
for (const codeword of codewords) {
|
|
377
|
+
for (let index = 7; index >= 0; index -= 1) bits.push((codeword >> index) & 1)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
let index = 0
|
|
381
|
+
let upward = true
|
|
382
|
+
for (let column = size - 1; column > 0; column -= 2) {
|
|
383
|
+
if (column === 6) column -= 1
|
|
384
|
+
for (let step = 0; step < size; step += 1) {
|
|
385
|
+
const row = upward ? size - 1 - step : step
|
|
386
|
+
for (const current of [column, column - 1]) {
|
|
387
|
+
if (reserved[row][current]) continue
|
|
388
|
+
matrix[row][current] = index < bits.length ? bits[index] === 1 : false
|
|
389
|
+
index += 1
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
upward = !upward
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Write both copies of the format field, in the order the reference encoders
|
|
398
|
+
* use: the column-8 copy first, the row-8 copy second.
|
|
399
|
+
*
|
|
400
|
+
* The order matters at the three modules where the two copies meet — (8,7),
|
|
401
|
+
* (8,8) and (7,8). The copy written last wins, and every reference
|
|
402
|
+
* implementation writes the row copy last, so the modules at (8,0) to (8,5)
|
|
403
|
+
* keep bits 0-5 from the column copy. Writing them the other way round still
|
|
404
|
+
* scans, but it is a different pattern; `test/qr.test.js` pins the whole matrix
|
|
405
|
+
* against `qrcode` (npm) so this cannot drift.
|
|
406
|
+
*
|
|
407
|
+
* column copy: 0..5 -> rows 0..5; 6 -> (7,8); 7 -> (8,8);
|
|
408
|
+
* 8..14 -> rows size-15+i .. size-1
|
|
409
|
+
* row copy: 0..7 -> columns size-1 .. size-8;
|
|
410
|
+
* 8 -> (8,7); 9..14 -> columns 6 .. 1
|
|
411
|
+
*/
|
|
412
|
+
function drawFormat(matrix, version, bits) {
|
|
413
|
+
const size = sizeOf(version)
|
|
414
|
+
const at = (index) => ((bits >> index) & 1) === 1
|
|
415
|
+
|
|
416
|
+
for (let index = 0; index < 15; index += 1) {
|
|
417
|
+
const dark = at(index)
|
|
418
|
+
if (index < 6) matrix[index][8] = dark
|
|
419
|
+
else if (index < 8) matrix[index + 1][8] = dark
|
|
420
|
+
else matrix[size - 15 + index][8] = dark
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
for (let index = 0; index < 15; index += 1) {
|
|
424
|
+
const dark = at(index)
|
|
425
|
+
if (index < 8) matrix[8][size - 1 - index] = dark
|
|
426
|
+
else if (index < 9) matrix[8][15 - index] = dark
|
|
427
|
+
else matrix[8][15 - index - 1] = dark
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
matrix[size - 8][8] = true
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Write both copies of the version information (versions 7+). */
|
|
434
|
+
function drawVersionInfo(matrix, version) {
|
|
435
|
+
if (version < 7) return
|
|
436
|
+
const size = sizeOf(version)
|
|
437
|
+
const bits = versionInfoBits(version)
|
|
438
|
+
for (let index = 0; index < 18; index += 1) {
|
|
439
|
+
const dark = ((bits >> index) & 1) === 1
|
|
440
|
+
matrix[Math.floor(index / 3)][size - 11 + (index % 3)] = dark
|
|
441
|
+
matrix[size - 11 + (index % 3)][Math.floor(index / 3)] = dark
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const FINDER_RUN = [true, false, true, true, true, false, true]
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The standard's four penalty rules; the lowest total picks the mask.
|
|
449
|
+
*
|
|
450
|
+
* Rule 3 is implemented the way the standard words it: a 1:1:3:1:1 run of dark
|
|
451
|
+
* and light is penalised when it is preceded or followed by four light modules
|
|
452
|
+
* (the symbol's light border counts as light), which is what makes a pattern
|
|
453
|
+
* that looks like a finder pattern expensive.
|
|
454
|
+
*/
|
|
455
|
+
function penalty(matrix) {
|
|
456
|
+
const size = matrix.length
|
|
457
|
+
let score = 0
|
|
458
|
+
|
|
459
|
+
const runs = (line) => {
|
|
460
|
+
let total = 0
|
|
461
|
+
let run = 1
|
|
462
|
+
for (let index = 1; index < line.length; index += 1) {
|
|
463
|
+
if (line[index] === line[index - 1]) {
|
|
464
|
+
run += 1
|
|
465
|
+
} else {
|
|
466
|
+
if (run >= 5) total += 3 + (run - 5)
|
|
467
|
+
run = 1
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (run >= 5) total += 3 + (run - 5)
|
|
471
|
+
return total
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const columns = []
|
|
475
|
+
for (let index = 0; index < size; index += 1) columns.push(matrix.map((row) => row[index]))
|
|
476
|
+
const lines = [...matrix, ...columns]
|
|
477
|
+
for (const line of lines) score += runs(line)
|
|
478
|
+
|
|
479
|
+
for (let row = 0; row < size - 1; row += 1) {
|
|
480
|
+
for (let column = 0; column < size - 1; column += 1) {
|
|
481
|
+
const dark = matrix[row][column]
|
|
482
|
+
if (dark === matrix[row][column + 1]
|
|
483
|
+
&& dark === matrix[row + 1][column]
|
|
484
|
+
&& dark === matrix[row + 1][column + 1]) score += 3
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Rule 3: a 1:1:3:1:1 dark/light run with four light modules on one side —
|
|
489
|
+
// the pattern that looks like a finder pattern. Matched as an 11-bit window
|
|
490
|
+
// per line: 10111010000 or its mirror 00001011101.
|
|
491
|
+
const finderLike = (line) => {
|
|
492
|
+
let total = 0
|
|
493
|
+
let window = 0
|
|
494
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
495
|
+
window = ((window << 1) & 0x7ff) | (line[index] ? 1 : 0)
|
|
496
|
+
if (index >= 10 && (window === 0x5d0 || window === 0x05d)) total += 40
|
|
497
|
+
}
|
|
498
|
+
return total
|
|
499
|
+
}
|
|
500
|
+
for (const line of lines) score += finderLike(line)
|
|
501
|
+
|
|
502
|
+
let dark = 0
|
|
503
|
+
for (const row of matrix) for (const module of row) if (module) dark += 1
|
|
504
|
+
// The standard rounds the dark proportion *up* to the next 5% step before
|
|
505
|
+
// comparing with 50%; flooring instead is a 10-point difference that shows up
|
|
506
|
+
// as a different mask choice than other encoders.
|
|
507
|
+
const steps = Math.abs(Math.ceil(((dark * 100) / (size * size)) / 5) - 10)
|
|
508
|
+
score += steps * 10
|
|
509
|
+
return score
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* The QR matrix for `text`: `true` means a dark module.
|
|
514
|
+
*
|
|
515
|
+
* `mask` pins the mask pattern instead of choosing the lowest-penalty one. The
|
|
516
|
+
* default is what ships; the override lets the tests compare the encoder
|
|
517
|
+
* module-for-module with an independent implementation.
|
|
518
|
+
*/
|
|
519
|
+
export function qrMatrix(text, { mask } = {}) {
|
|
520
|
+
const version = versionFor(text)
|
|
521
|
+
const reserved = reservedMap(version)
|
|
522
|
+
const matrix = drawFunctionPatterns(reserved, version)
|
|
523
|
+
drawVersionInfo(matrix, version)
|
|
524
|
+
drawData(matrix, reserved, interleave(dataCodewordsFor(text, version), version), version)
|
|
525
|
+
|
|
526
|
+
let chosen = Number.isInteger(mask) ? mask : 0
|
|
527
|
+
if (!Number.isInteger(mask)) {
|
|
528
|
+
let best = Infinity
|
|
529
|
+
for (let candidate = 0; candidate < 8; candidate += 1) {
|
|
530
|
+
// Score the *finished* symbol: the format field is part of the pattern a
|
|
531
|
+
// scanner sees, so it has to be in place before the penalty is measured.
|
|
532
|
+
// Scoring the bare masked matrix picks a different mask.
|
|
533
|
+
const scored = applyMask(matrix, reserved, candidate)
|
|
534
|
+
drawFormat(scored, version, formatBits(EC_FORMAT_BITS, candidate))
|
|
535
|
+
const score = penalty(scored)
|
|
536
|
+
if (score < best) {
|
|
537
|
+
best = score
|
|
538
|
+
chosen = candidate
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const out = applyMask(matrix, reserved, chosen)
|
|
544
|
+
drawFormat(out, version, formatBits(EC_FORMAT_BITS, chosen))
|
|
545
|
+
return out
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* The QR code for `text` as a standalone SVG.
|
|
550
|
+
*
|
|
551
|
+
* One path element rather than thousands of rects: small enough to print in a
|
|
552
|
+
* terminal response, and it renders identically. The four-module quiet zone is
|
|
553
|
+
* not decoration — scanners need it to find the symbol at all.
|
|
554
|
+
*/
|
|
555
|
+
export function qrSvg(text, { scale = 8, quietZone = 4, dark = '#111111', light = '#ffffff', mask } = {}) {
|
|
556
|
+
const matrix = qrMatrix(text, mask === undefined ? {} : { mask })
|
|
557
|
+
const size = matrix.length
|
|
558
|
+
const extent = (size + quietZone * 2) * scale
|
|
559
|
+
const segments = []
|
|
560
|
+
for (let row = 0; row < size; row += 1) {
|
|
561
|
+
for (let column = 0; column < size; column += 1) {
|
|
562
|
+
if (!matrix[row][column]) continue
|
|
563
|
+
const x = (column + quietZone) * scale
|
|
564
|
+
const y = (row + quietZone) * scale
|
|
565
|
+
segments.push(`M${x} ${y}h${scale}v${scale}h-${scale}z`)
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${extent}" height="${extent}"`
|
|
569
|
+
+ ` viewBox="0 0 ${extent} ${extent}" shape-rendering="crispEdges" role="img"`
|
|
570
|
+
+ ` aria-label="DSH 配对二维码">`
|
|
571
|
+
+ `<rect width="${extent}" height="${extent}" fill="${light}"/>`
|
|
572
|
+
+ `<path d="${segments.join('')}" fill="${dark}"/>`
|
|
573
|
+
+ '</svg>'
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export { TOTAL_CODEWORDS }
|