1 package main
2
3 // Minimal QR code encoder for npub strings.
4 // Generates version-4-M (33x33 modules, medium EC) alphanumeric QR codes
5 // and renders them as an SVG string with a center logo cutout.
6 //
7 // npub1... is 63 chars. Version 4-M alphanumeric capacity = 78 chars. Plenty.
8 // Medium EC (~15% correction) tolerates the center logo area.
9
10 // qrSVG generates a QR code SVG string for the given data.
11 // The SVG has a white background, black modules, and a centered logo placeholder.
12 func qrSVG(data string, size int, logoSVG string) string {
13 mods := qrEncode(data)
14 if mods == nil {
15 return ""
16 }
17 n := len(mods)
18 // Module size in SVG units.
19 quiet := 2 // quiet zone modules
20 total := n + quiet*2
21 modSize := size / total
22 if modSize < 1 {
23 modSize = 1
24 }
25 svgSize := total * modSize
26
27 svg := "<svg xmlns='http://www.w3.org/2000/svg' width='" + itoa(svgSize) + "' height='" + itoa(svgSize) + "' viewBox='0 0 " + itoa(svgSize) + " " + itoa(svgSize) + "'>"
28 svg += "<rect width='100%' height='100%' fill='white'/>"
29
30 // Center logo area (mask out modules).
31 logoMods := 9 // 9x9 module area in center
32 logoStart := (n - logoMods) / 2
33 logoEnd := logoStart + logoMods
34
35 for y := 0; y < n; y++ {
36 for x := 0; x < n; x++ {
37 if mods[y][x]&1 == 1 {
38 // Skip modules in logo area.
39 if x >= logoStart && x < logoEnd && y >= logoStart && y < logoEnd {
40 continue
41 }
42 px := (x + quiet) * modSize
43 py := (y + quiet) * modSize
44 svg += "<rect x='" + itoa(px) + "' y='" + itoa(py) + "' width='" + itoa(modSize) + "' height='" + itoa(modSize) + "' fill='black'/>"
45 }
46 }
47 }
48
49 // Center logo.
50 if logoSVG != "" {
51 logoPixStart := (logoStart + quiet) * modSize
52 logoPixSize := logoMods * modSize
53 svg += "<g transform='translate(" + itoa(logoPixStart) + "," + itoa(logoPixStart) + ")'>"
54 svg += "<rect width='" + itoa(logoPixSize) + "' height='" + itoa(logoPixSize) + "' fill='white' rx='4'/>"
55 pad := logoPixSize / 6
56 inner := logoPixSize - pad*2
57 svg += "<g transform='translate(" + itoa(pad) + "," + itoa(pad) + ")'>"
58 // Inject logo SVG, rewriting width/height.
59 svg += "<svg width='" + itoa(inner) + "' height='" + itoa(inner) + "' viewBox='0 0 100 100'>"
60 // Strip outer <svg> tags from logoSVG and inject content.
61 svg += extractSVGContent(logoSVG)
62 svg += "</svg></g></g>"
63 }
64
65 svg += "</svg>"
66 return svg
67 }
68
69 // extractSVGContent strips the outer <svg ...> and </svg> tags, returning inner content.
70 func extractSVGContent(s string) string {
71 // Find end of opening <svg ...> tag.
72 i := 0
73 for i < len(s) && s[i] != '<' {
74 i++
75 }
76 if i >= len(s) {
77 return s
78 }
79 // Find closing > of opening tag.
80 j := i + 1
81 for j < len(s) && s[j] != '>' {
82 j++
83 }
84 if j >= len(s) {
85 return s
86 }
87 start := j + 1
88 // Find </svg>.
89 end := len(s)
90 for k := len(s) - 1; k >= 6; k-- {
91 if s[k] == '>' && k >= 5 && s[k-5:k+1] == "</svg>" {
92 end = k - 5
93 break
94 }
95 }
96 return s[start:end]
97 }
98
99 // --- QR encoder: Version 4-M, alphanumeric mode ---
100
101 const qrSize = 33 // Version 4 = 33x33
102
103 // qrEncode produces the module matrix for a version-4-M alphanumeric QR code.
104 func qrEncode(data string) [][]byte {
105 gfInit()
106 // Encode data bits.
107 bits := qrAlphanumericBits(data)
108 if bits == nil {
109 return nil
110 }
111 // Add terminator + padding.
112 bits = qrPadBits(bits)
113 // Compute error correction.
114 codewords := bitsToBytes(bits)
115 ecWords := rsEncode(codewords)
116 // Interleave (single block for v4-M).
117 var allBits []byte
118 for _, b := range codewords {
119 allBits = append(allBits, byteToBits(b)...)
120 }
121 for _, b := range ecWords {
122 allBits = append(allBits, byteToBits(b)...)
123 }
124 // Remainder bits for version 4: 7.
125 for i := 0; i < 7; i++ {
126 allBits = append(allBits, 0)
127 }
128
129 // Place modules.
130 mods := make([][]byte, qrSize)
131 for i := range mods {
132 mods[i] = make([]byte, qrSize)
133 }
134 // reserved = 2 means "function pattern, don't overwrite"
135 qrPlaceFinderPatterns(mods)
136 qrPlaceAlignmentPattern(mods)
137 qrPlaceTimingPatterns(mods)
138 qrPlaceDarkModule(mods)
139 qrReserveFormatArea(mods)
140 qrReserveVersionArea(mods) // no-op for v4
141 qrPlaceData(mods, allBits)
142 qrApplyBestMask(mods)
143 return mods
144 }
145
146 // Alphanumeric character set.
147 const alphaChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"
148
149 func alphaVal(c byte) int {
150 for i := 0; i < len(alphaChars); i++ {
151 if alphaChars[i] == c {
152 return i
153 }
154 }
155 return -1
156 }
157
158 func qrAlphanumericBits(data string) []byte {
159 udata := toUpper(data)
160 // Mode indicator: 0010 (alphanumeric).
161 bits := []byte{0, 0, 1, 0}
162 // Character count: 9 bits for version 4.
163 count := len(udata)
164 for i := 8; i >= 0; i-- {
165 bits = append(bits, byte((count>>uint(i))&1))
166 }
167 // Encode pairs.
168 for i := 0; i+1 < len(udata); i += 2 {
169 v1 := alphaVal(udata[i])
170 v2 := alphaVal(udata[i+1])
171 if v1 < 0 || v2 < 0 {
172 return nil
173 }
174 val := v1*45 + v2
175 for b := 10; b >= 0; b-- {
176 bits = append(bits, byte((val>>uint(b))&1))
177 }
178 }
179 if len(udata)%2 == 1 {
180 v := alphaVal(udata[len(udata)-1])
181 if v < 0 {
182 return nil
183 }
184 for b := 5; b >= 0; b-- {
185 bits = append(bits, byte((v>>uint(b))&1))
186 }
187 }
188 return bits
189 }
190
191 func toUpper(s string) string {
192 b := make([]byte, len(s))
193 for i := 0; i < len(s); i++ {
194 c := s[i]
195 if c >= 'a' && c <= 'z' {
196 c -= 32
197 }
198 b[i] = c
199 }
200 return string(b)
201 }
202
203 // v4-M: 80 data codewords total.
204 const v4MDataCodewords = 80
205
206 func qrPadBits(bits []byte) []byte {
207 totalBits := v4MDataCodewords * 8
208 // Add terminator (up to 4 zeros).
209 term := 4
210 if totalBits-len(bits) < 4 {
211 term = totalBits - len(bits)
212 }
213 for i := 0; i < term; i++ {
214 bits = append(bits, 0)
215 }
216 // Pad to byte boundary.
217 for len(bits)%8 != 0 {
218 bits = append(bits, 0)
219 }
220 // Pad bytes.
221 padBytes := []byte{0xEC, 0x11}
222 idx := 0
223 for len(bits) < totalBits {
224 bits = append(bits, byteToBits(padBytes[idx%2])...)
225 idx++
226 }
227 return bits[:totalBits]
228 }
229
230 func bitsToBytes(bits []byte) []byte {
231 n := len(bits) / 8
232 out := make([]byte, n)
233 for i := 0; i < n; i++ {
234 var v byte
235 for j := 0; j < 8; j++ {
236 v = v<<1 | bits[i*8+j]
237 }
238 out[i] = v
239 }
240 return out
241 }
242
243 func byteToBits(b byte) []byte {
244 bits := make([]byte, 8)
245 for i := 7; i >= 0; i-- {
246 bits[7-i] = (b >> uint(i)) & 1
247 }
248 return bits
249 }
250
251 // --- Reed-Solomon for v4-M: 1 block, 80 data codewords, 18 EC codewords ---
252
253 const v4MECCodewords = 18
254
255 // GF(256) with primitive polynomial 0x11d.
256 var gfExp [512]byte
257 var gfLog [256]byte
258 var gfReady bool
259
260 func gfInit() {
261 if gfReady {
262 return
263 }
264 gfReady = true
265 v := 1
266 for i := 0; i < 255; i++ {
267 gfExp[i] = byte(v)
268 gfLog[v] = byte(i)
269 v <<= 1
270 if v >= 256 {
271 v ^= 0x11d
272 }
273 }
274 for i := 255; i < 512; i++ {
275 gfExp[i] = gfExp[i-255]
276 }
277 }
278
279 func gfMul(a, b byte) byte {
280 if a == 0 || b == 0 {
281 return 0
282 }
283 return gfExp[int(gfLog[a])+int(gfLog[b])]
284 }
285
286 func rsEncode(data []byte) []byte {
287 // Generator polynomial for 18 EC codewords.
288 gen := rsGeneratorPoly(v4MECCodewords)
289 result := make([]byte, v4MECCodewords)
290 for _, d := range data {
291 factor := d ^ result[0]
292 copy(result, result[1:])
293 result[v4MECCodewords-1] = 0
294 for i := 0; i < v4MECCodewords; i++ {
295 result[i] ^= gfMul(gen[i], factor)
296 }
297 }
298 return result
299 }
300
301 func rsGeneratorPoly(n int) []byte {
302 poly := []byte{1}
303 for i := 0; i < n; i++ {
304 newPoly := make([]byte, len(poly)+1)
305 for j := 0; j < len(poly); j++ {
306 newPoly[j] ^= poly[j]
307 newPoly[j+1] ^= gfMul(poly[j], gfExp[i])
308 }
309 poly = newPoly
310 }
311 return poly[1:] // drop leading 1
312 }
313
314 // --- Module placement ---
315
316 func setMod(mods [][]byte, x, y int, val byte) {
317 if x >= 0 && x < qrSize && y >= 0 && y < qrSize {
318 mods[y][x] = val
319 }
320 }
321
322 func qrPlaceFinderPatterns(mods [][]byte) {
323 place := func(cx, cy int) {
324 for dy := -4; dy <= 4; dy++ {
325 for dx := -4; dx <= 4; dx++ {
326 x, y := cx+dx, cy+dy
327 if x < 0 || x >= qrSize || y < 0 || y >= qrSize {
328 continue
329 }
330 adx, ady := dx, dy
331 if adx < 0 {
332 adx = -adx
333 }
334 if ady < 0 {
335 ady = -ady
336 }
337 mx := adx
338 if ady > mx {
339 mx = ady
340 }
341 var v byte
342 switch {
343 case mx == 4:
344 v = 2 // separator (white, reserved)
345 case mx == 0 || mx == 2:
346 v = 3 // black, reserved
347 case mx == 1:
348 v = 2 // white, reserved
349 case mx == 3:
350 v = 3 // black, reserved
351 }
352 mods[y][x] = v
353 }
354 }
355 }
356 place(3, 3) // top-left
357 place(qrSize-4, 3) // top-right
358 place(3, qrSize-4) // bottom-left
359 }
360
361 func qrPlaceAlignmentPattern(mods [][]byte) {
362 // Version 4: alignment pattern at (26, 26).
363 cx, cy := 26, 26
364 for dy := -2; dy <= 2; dy++ {
365 for dx := -2; dx <= 2; dx++ {
366 adx, ady := dx, dy
367 if adx < 0 {
368 adx = -adx
369 }
370 if ady < 0 {
371 ady = -ady
372 }
373 mx := adx
374 if ady > mx {
375 mx = ady
376 }
377 var v byte
378 if mx == 1 {
379 v = 2 // white, reserved
380 } else {
381 v = 3 // black, reserved
382 }
383 mods[cy+dy][cx+dx] = v
384 }
385 }
386 }
387
388 func qrPlaceTimingPatterns(mods [][]byte) {
389 for i := 8; i < qrSize-8; i++ {
390 v := byte(3) // black reserved
391 if i%2 == 1 {
392 v = 2 // white reserved
393 }
394 if mods[6][i] == 0 {
395 mods[6][i] = v
396 }
397 if mods[i][6] == 0 {
398 mods[i][6] = v
399 }
400 }
401 }
402
403 func qrPlaceDarkModule(mods [][]byte) {
404 mods[qrSize-8][8] = 3 // always dark
405 }
406
407 func qrReserveFormatArea(mods [][]byte) {
408 // Around top-left finder.
409 for i := 0; i < 9; i++ {
410 if mods[8][i] == 0 {
411 mods[8][i] = 2
412 }
413 if mods[i][8] == 0 {
414 mods[i][8] = 2
415 }
416 }
417 // Below top-right finder.
418 for i := 0; i < 8; i++ {
419 if mods[8][qrSize-1-i] == 0 {
420 mods[8][qrSize-1-i] = 2
421 }
422 }
423 // Right of bottom-left finder.
424 for i := 0; i < 7; i++ {
425 if mods[qrSize-1-i][8] == 0 {
426 mods[qrSize-1-i][8] = 2
427 }
428 }
429 }
430
431 func qrReserveVersionArea(mods [][]byte) {
432 // Version info blocks only for version >= 7. No-op for v4.
433 }
434
435 func qrPlaceData(mods [][]byte, bits []byte) {
436 idx := 0
437 // Data is placed in 2-column strips, right to left, bottom to top / top to bottom alternating.
438 x := qrSize - 1
439 upward := true
440 for x > 0 {
441 if x == 6 {
442 x-- // skip timing column
443 }
444 for row := 0; row < qrSize; row++ {
445 y := row
446 if upward {
447 y = qrSize - 1 - row
448 }
449 for dx := 0; dx <= 1; dx++ {
450 cx := x - dx
451 if mods[y][cx] != 0 {
452 continue // reserved
453 }
454 if idx < len(bits) {
455 mods[y][cx] = bits[idx] // 0 or 1
456 }
457 idx++
458 }
459 }
460 upward = !upward
461 x -= 2
462 }
463 }
464
465 // --- Masking ---
466
467 // v4-M format info strings (pre-computed for masks 0-7).
468 // Format: error correction level M (01) + mask pattern + BCH error correction.
469 var formatBits = [8][15]byte{
470 {1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0}, // mask 0
471 {1, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0, 1, 1, 0, 0}, // mask 1
472 {1, 0, 1, 1, 1, 1, 0, 1, 1, 0, 0, 0, 1, 1, 1}, // mask 2
473 {1, 0, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 0, 1}, // mask 3
474 {1, 0, 0, 0, 1, 0, 1, 1, 0, 1, 0, 1, 1, 1, 0}, // mask 4
475 {1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0, 0}, // mask 5
476 {1, 0, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1}, // mask 6
477 {1, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1}, // mask 7
478 }
479
480 func maskFunc(mask int, x, y int) bool {
481 switch mask {
482 case 0:
483 return (x+y)%2 == 0
484 case 1:
485 return y%2 == 0
486 case 2:
487 return x%3 == 0
488 case 3:
489 return (x+y)%3 == 0
490 case 4:
491 return (y/2+x/3)%2 == 0
492 case 5:
493 return (x*y)%2+(x*y)%3 == 0
494 case 6:
495 return ((x*y)%2+(x*y)%3)%2 == 0
496 case 7:
497 return ((x+y)%2+(x*y)%3)%2 == 0
498 }
499 return false
500 }
501
502 func isData(v byte) bool {
503 return v <= 1 // 0 or 1 are data modules
504 }
505
506 func qrApplyBestMask(mods [][]byte) {
507 bestMask := 0
508 bestScore := 1<<31 - 1
509 for m := 0; m < 8; m++ {
510 // Apply mask.
511 for y := 0; y < qrSize; y++ {
512 for x := 0; x < qrSize; x++ {
513 if isData(mods[y][x]) && maskFunc(m, x, y) {
514 mods[y][x] ^= 1
515 }
516 }
517 }
518 score := qrPenaltyScore(mods)
519 // Undo mask.
520 for y := 0; y < qrSize; y++ {
521 for x := 0; x < qrSize; x++ {
522 if isData(mods[y][x]) && maskFunc(m, x, y) {
523 mods[y][x] ^= 1
524 }
525 }
526 }
527 if score < bestScore {
528 bestScore = score
529 bestMask = m
530 }
531 }
532 // Apply best mask permanently.
533 for y := 0; y < qrSize; y++ {
534 for x := 0; x < qrSize; x++ {
535 if isData(mods[y][x]) && maskFunc(bestMask, x, y) {
536 mods[y][x] ^= 1
537 }
538 }
539 }
540 // Write format info.
541 qrWriteFormatInfo(mods, bestMask)
542 // Convert reserved modules: 2→0 (white), 3→1 (black).
543 for y := 0; y < qrSize; y++ {
544 for x := 0; x < qrSize; x++ {
545 if mods[y][x] == 2 {
546 mods[y][x] = 0
547 } else if mods[y][x] == 3 {
548 mods[y][x] = 1
549 }
550 }
551 }
552 }
553
554 func qrWriteFormatInfo(mods [][]byte, mask int) {
555 fb := formatBits[mask]
556 // Horizontal: left of top-left finder.
557 positions := [15][2]int{
558 {0, 8}, {1, 8}, {2, 8}, {3, 8}, {4, 8}, {5, 8}, {7, 8}, {8, 8},
559 {8, 7}, {8, 5}, {8, 4}, {8, 3}, {8, 2}, {8, 1}, {8, 0},
560 }
561 for i, pos := range positions {
562 v := byte(2)
563 if fb[i] == 1 {
564 v = 3
565 }
566 mods[pos[1]][pos[0]] = v
567 }
568 // Vertical: below top-right + right of bottom-left.
569 positions2 := [15][2]int{
570 {8, qrSize - 1}, {8, qrSize - 2}, {8, qrSize - 3}, {8, qrSize - 4},
571 {8, qrSize - 5}, {8, qrSize - 6}, {8, qrSize - 7},
572 {qrSize - 8, 8}, {qrSize - 7, 8}, {qrSize - 6, 8}, {qrSize - 5, 8},
573 {qrSize - 4, 8}, {qrSize - 3, 8}, {qrSize - 2, 8}, {qrSize - 1, 8},
574 }
575 for i, pos := range positions2 {
576 v := byte(2)
577 if fb[i] == 1 {
578 v = 3
579 }
580 mods[pos[1]][pos[0]] = v
581 }
582 }
583
584 func qrPenaltyScore(mods [][]byte) int {
585 score := 0
586 // Rule 1: 5+ same-color in a row/column.
587 for y := 0; y < qrSize; y++ {
588 run := 1
589 for x := 1; x < qrSize; x++ {
590 if (mods[y][x]&1) == (mods[y][x-1]&1) {
591 run++
592 } else {
593 if run >= 5 {
594 score += run - 2
595 }
596 run = 1
597 }
598 }
599 if run >= 5 {
600 score += run - 2
601 }
602 }
603 for x := 0; x < qrSize; x++ {
604 run := 1
605 for y := 1; y < qrSize; y++ {
606 if (mods[y][x]&1) == (mods[y-1][x]&1) {
607 run++
608 } else {
609 if run >= 5 {
610 score += run - 2
611 }
612 run = 1
613 }
614 }
615 if run >= 5 {
616 score += run - 2
617 }
618 }
619 // Rule 2: 2x2 same-color blocks.
620 for y := 0; y < qrSize-1; y++ {
621 for x := 0; x < qrSize-1; x++ {
622 c := mods[y][x] & 1
623 if (mods[y][x+1]&1) == c && (mods[y+1][x]&1) == c && (mods[y+1][x+1]&1) == c {
624 score += 3
625 }
626 }
627 }
628 // Rule 4: proportion of dark modules.
629 dark := 0
630 total := qrSize * qrSize
631 for y := 0; y < qrSize; y++ {
632 for x := 0; x < qrSize; x++ {
633 if mods[y][x]&1 == 1 {
634 dark++
635 }
636 }
637 }
638 pct := dark * 100 / total
639 dev := pct - 50
640 if dev < 0 {
641 dev = -dev
642 }
643 score += (dev / 5) * 10
644 return score
645 }
646