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
Assisted-by: GLM 5.3
465 lines
12 KiB
Go
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
|
|
}
|