Files
volumen/internal/qrcode/qrcode.go
T
petrbalvin f8ed33df83
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Initial commit
Assisted-by: GLM 5.3
2026-09-29 10:03:32 +02:00

465 lines
12 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package qrcode writes QR symbols, byte mode on error level M, by
// hand: no dependency, no encoder table beyond the block structure the
// ISO/IEC 18004 annex defines. The caller gets a complete symbol as an
// inline SVG, which is what the admin interface needs to hand an
// otpauth URI to a phone camera.
//
// The supported range is versions 1 to 16, which carries up to 560
// bytes, twice the longest otpauth URI a volumen account can produce.
// Longer input is refused rather than silently downgraded: a truncated
// URI scans as a symbol that opens nothing.
package qrcode
import (
"errors"
"fmt"
"strings"
)
// block is one Reed-Solomon block: the codeword total and the data
// codewords inside it.
type block struct {
total, data int
}
// versionBlocks is one version's structure: the block shapes it mixes
// and how many of each.
type versionBlocks struct {
shapes []block
counts []int
}
// blocks lists the level M block structure per version, index version-1.
// Versions from 8 mix two block sizes.
var blocks = []versionBlocks{
{blockList(block{26, 16}), []int{1}},
{blockList(block{44, 28}), []int{1}},
{blockList(block{70, 44}), []int{1}},
{blockList(block{50, 32}), []int{2}},
{blockList(block{67, 43}), []int{2}},
{blockList(block{43, 27}), []int{4}},
{blockList(block{49, 31}), []int{4}},
{blockList(block{60, 38}, block{61, 39}), []int{2, 2}},
{blockList(block{58, 36}, block{59, 37}), []int{3, 2}},
{blockList(block{69, 43}, block{70, 44}), []int{4, 1}},
{blockList(block{80, 50}, block{81, 51}), []int{1, 4}},
{blockList(block{58, 36}, block{59, 37}), []int{6, 2}},
{blockList(block{59, 37}, block{60, 38}), []int{8, 1}},
{blockList(block{64, 40}, block{65, 41}), []int{4, 5}},
{blockList(block{65, 41}, block{66, 42}), []int{5, 5}},
{blockList(block{73, 45}, block{74, 46}), []int{7, 3}},
}
// blockList exists only because Go cannot spell a slice literal with a
// fixed array type on one line readably.
func blockList(bs ...block) []block { return bs }
// alignment lists the alignment pattern centre coordinates per version;
// version 1 carries none.
var alignment = [][]int{
{}, {6, 18}, {6, 22}, {6, 26}, {6, 30}, {6, 34}, {6, 22, 38},
{6, 24, 42}, {6, 26, 46}, {6, 28, 50}, {6, 30, 54}, {6, 32, 58},
{6, 34, 62}, {6, 26, 46, 66}, {6, 26, 48, 70}, {6, 26, 50, 74},
}
// capacity returns the byte-mode capacity of a version.
func capacity(version int) int {
shapes, counts := blocks[version-1].shapes, blocks[version-1].counts
dataCodewords := 0
for i, shape := range shapes {
dataCodewords += shape.data * counts[i]
}
headerBits := 12 // mode + 8-bit count
if version >= 10 {
headerBits = 20 // the count grows to 16 bits
}
return (dataCodewords*8 - headerBits) / 8
}
// ErrTooLong names the refusal of input beyond the supported range.
var ErrTooLong = errors.New("qrcode: input exceeds version 16 capacity")
// SVG renders text as a complete QR symbol in inline SVG: a white card,
// black modules, and the four-module quiet zone the spec demands. The
// colours are fixed on purpose: a symbol that follows the page's scheme
// can end up light-on-dark, which cameras refuse.
func SVG(text string) (string, error) {
m, err := encode([]byte(text))
if err != nil {
return "", err
}
n := len(m)
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" role="img" aria-hidden="true" shape-rendering="crispEdges">`, n+8, n+8)
b.WriteString(`<rect width="100%" height="100%" fill="#fff"/>`)
b.WriteString(`<path fill="#000" d="`)
for y, row := range m {
for x, dark := range row {
if dark {
fmt.Fprintf(&b, "M%d %dh1v1h-1z", x+4, y+4)
}
}
}
b.WriteString(`"/></svg>`)
return b.String(), nil
}
// encode builds the final module matrix: codewords, mask, format and
// version information all placed.
func encode(text []byte) ([][]bool, error) {
return encodeMasked(text, -1)
}
// encodeMasked builds the symbol with a forced mask, or the best one
// when mask is negative. The test suite uses the forced form to prove
// each mask's placement against a reference implementation.
func encodeMasked(text []byte, mask int) ([][]bool, error) {
version := 0
for v := 1; v <= len(blocks); v++ {
if len(text) <= capacity(v) {
version = v
break
}
}
if version == 0 {
return nil, fmt.Errorf("%w (%d bytes)", ErrTooLong, len(text))
}
codewords := codewords(text, version)
n := 4*version + 17
m := newMatrix(n)
reserveFunction(m, version)
place(m, codewords)
best, bestScore := 0, -1
if mask >= 0 {
best = mask
} else {
for candidate := range 8 {
trial := cloneMatrix(m.data)
applyMask(trial, m.function, candidate)
if score := penalty(trial); bestScore < 0 || score < bestScore {
best, bestScore = candidate, score
}
}
}
applyMask(m.data, m.function, best)
placeFormat(m, best)
if version >= 7 {
placeVersion(m, version)
}
return m.data, nil
}
// matrix couples the module grid with the map of cells reserved for
// function patterns and information fields.
type matrix struct {
data [][]bool
function [][]bool
}
func newMatrix(n int) *matrix {
return &matrix{data: blank(n), function: blank(n)}
}
func blank(n int) [][]bool {
m := make([][]bool, n)
for i := range m {
m[i] = make([]bool, n)
}
return m
}
func cloneMatrix(src [][]bool) [][]bool {
dst := make([][]bool, len(src))
for i, row := range src {
dst[i] = append([]bool(nil), row...)
}
return dst
}
// reserveFunction draws every fixed pattern: finders with separators,
// timing lines, alignment patterns, the dark module, and blanks the
// format and version areas so data placement skips them.
func reserveFunction(m *matrix, version int) {
n := len(m.data)
set := func(y, x int, dark bool) {
m.function[y][x] = true
m.data[y][x] = dark
}
finder := func(top, left int) {
for dy := -1; dy <= 7; dy++ {
for dx := -1; dx <= 7; dx++ {
y, x := top+dy, left+dx
if y < 0 || x < 0 || y >= n || x >= n {
continue
}
ring := max(abs(dy-3), abs(dx-3))
set(y, x, ring != 2 && ring <= 3)
}
}
}
finder(0, 0)
finder(0, n-7)
finder(n-7, 0)
for i := 8; i < n-8; i++ {
set(6, i, i%2 == 0)
set(i, 6, i%2 == 0)
}
// Only the three centres that coincide with a finder's corner are
// skipped: an alignment pattern may cross the timing line, and one
// centred on it must still be drawn.
finderCorner := func(cy, cx int) bool {
return (cy == 6 && cx == 6) || (cy == 6 && cx == n-7) || (cy == n-7 && cx == 6)
}
for _, cy := range alignment[version-1] {
for _, cx := range alignment[version-1] {
if finderCorner(cy, cx) {
continue
}
for dy := -2; dy <= 2; dy++ {
for dx := -2; dx <= 2; dx++ {
ring := max(abs(dy), abs(dx))
set(cy+dy, cx+dx, ring != 1)
}
}
}
}
set(n-8, 8, true) // the dark module
// Reserve both format areas and, from version 7, the version cells.
for i := range 9 {
m.function[8][i] = true
m.function[i][8] = true
}
for i := range 8 {
m.function[8][n-1-i] = true
m.function[n-1-i][8] = true
}
if version >= 7 {
for i := range 6 {
for j := range 3 {
m.function[n-11+j][i] = true
m.function[i][n-11+j] = true
}
}
}
}
// place lays the codeword bits into the data region, two columns at a
// time from the bottom right, skipping the timing column.
func place(m *matrix, codewords []byte) {
n := len(m.data)
bit := 0
total := len(codewords) * 8
upward := true
for col := n - 1; col > 0 && bit < total; col -= 2 {
if col == 6 {
col--
}
for i := 0; i < n && bit < total; i++ {
row := n - 1 - i
if !upward {
row = i
}
for _, x := range []int{col, col - 1} {
if bit < total && !m.function[row][x] {
m.data[row][x] = codewords[bit/8]>>(7-uint(bit%8))&1 == 1
bit++
}
}
}
upward = !upward
}
}
// applyMask XORs the mask pattern over the data region.
func applyMask(data, function [][]bool, mask int) {
n := len(data)
for y := range n {
for x := range n {
if !function[y][x] && maskBit(mask, y, x) {
data[y][x] = !data[y][x]
}
}
}
}
func maskBit(mask, y, x int) bool {
switch mask {
case 0:
return (y+x)%2 == 0
case 1:
return y%2 == 0
case 2:
return x%3 == 0
case 3:
return (y+x)%3 == 0
case 4:
return (y/2+x/3)%2 == 0
case 5:
return y*x%2+y*x%3 == 0
case 6:
return (y*x%2+y*x%3)%2 == 0
default:
return ((y+x)%2+y*x%3)%2 == 0
}
}
// placeFormat writes the BCH-protected format information, level M and
// the chosen mask, into its two copies. bit(k) below is the k-th bit
// from the most significant one. The first copy runs b14 to b9 along
// row 8, b8 to b6 around the finder corner, and b5 to b0 up column 8;
// the second carries b7 to b0 rightward along row 8 and b14 to b8
// upward along column 8, with the dark module between the segments.
func placeFormat(m *matrix, mask int) {
const levelM = 0
bits := formatBits(levelM, mask)
n := len(m.data)
bit := func(k int) bool { return bits>>(14-uint(k))&1 == 1 }
// The copy around the top-left finder, skipping the timing line.
for i := range 6 {
m.data[8][i] = bit(i)
}
m.data[8][7] = bit(6)
m.data[8][8] = bit(7)
m.data[7][8] = bit(8)
for i := range 6 {
m.data[5-i][8] = bit(9 + i)
}
// The split copy.
for i := range 8 {
m.data[8][n-8+i] = bit(7 + i)
}
for i := range 7 {
m.data[n-1-i][8] = bit(i)
}
}
// formatBits builds the 15-bit format word: five data bits, ten BCH
// check bits from the 0x537 generator, masked with the XOR pattern that
// keeps an all-zero word impossible.
func formatBits(level, mask int) uint {
data := uint(level<<3 | mask)
rem := data << 10
for i := 14; i >= 10; i-- {
if rem&(1<<uint(i)) != 0 {
rem ^= 0x537 << uint(i-10)
}
}
return (data<<10 | rem) ^ 0x5412
}
// placeVersion writes the 18-bit version word of versions 7 and up
// into its two strips, least significant bit first, each strip read
// top to bottom in columns left to right.
func placeVersion(m *matrix, version int) {
bits := versionBits(version)
n := len(m.data)
for i := range 18 {
dark := bits>>uint(i)&1 == 1
a, b := i/3, i%3
m.data[n-11+b][a] = dark
m.data[a][n-11+b] = dark
}
}
func versionBits(version int) uint {
rem := uint(version) << 12
for i := 17; i >= 12; i-- {
if rem&(1<<uint(i)) != 0 {
rem ^= 0x1F25 << uint(i-12)
}
}
return uint(version)<<12 | rem
}
// penalty scores a masked matrix by the four rules of the spec: long
// runs, large blocks, the finder lookalike pattern, and a colour
// balance far from half.
func penalty(data [][]bool) int {
n := len(data)
score := 0
dark := 0
runScore := func(get func(a, b int) bool) int {
total := 0
for a := range n {
run, prev := 0, get(a, 0)
for b := range n {
v := get(a, b)
if v == prev {
run++
} else {
if run >= 5 {
total += run - 2
}
run, prev = 1, v
}
}
if run >= 5 {
total += run - 2
}
}
return total
}
score += runScore(func(y, x int) bool { return data[y][x] })
score += runScore(func(x, y int) bool { return data[y][x] })
for y := 0; y < n-1; y++ {
for x := 0; x < n-1; x++ {
if data[y][x] == data[y][x+1] && data[y][x] == data[y+1][x] && data[y][x] == data[y+1][x+1] {
score += 3
}
}
}
lookalike := func(get func(a, b int) bool) int {
total := 0
pattern := []bool{true, false, true, true, true, false, true, false, false, false, false}
for a := range n {
for b := 0; b+11 <= n; b++ {
match, matchRev := true, true
for k := range 11 {
v := get(a, b+k)
if v != pattern[k] {
match = false
}
if v != pattern[10-k] {
matchRev = false
}
}
if match {
total += 40
}
if matchRev {
total += 40
}
}
}
return total
}
score += lookalike(func(y, x int) bool { return data[y][x] })
score += lookalike(func(x, y int) bool { return data[y][x] })
for _, row := range data {
for _, v := range row {
if v {
dark++
}
}
}
total := n * n
percent := dark * 100 / total
score += 10 * (abs(percent-50) / 5)
return score
}
func abs(v int) int {
if v < 0 {
return -v
}
return v
}