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/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 }