This commit is contained in:
2026-10-23 00:45:30 +07:00
parent 60119a07e8
commit 146700cf4c
34 changed files with 622 additions and 124 deletions
+36 -12
View File
@@ -18,8 +18,10 @@ var ErrNoCards = errors.New("нет карточек")
// Card contains the data rendered on a team QR-code card.
type Card struct {
Name string
URL string
Name string
URL string
WiFiName string
WiFiPassword string
}
//go:embed fonts/DejaVuSansCondensed.ttf
@@ -45,7 +47,18 @@ func Generate(cards []Card) ([]byte, error) {
pdf.SetCellMargin(0)
RegisterFont(pdf)
pdf.SetFillColor(0, 0, 0)
const margin, width, height, qrSize = 10.0, 190.0 / 3, 277.0 / 4, 36.0
const margin, qrSize = 10.0, 36.0
columns, rows := 3, 4
for _, card := range cards {
if err := ValidateWiFi(card.WiFiName, card.WiFiPassword); err != nil {
return nil, err
}
if card.WiFiName != "" {
columns, rows = 2, 3
}
}
width, height := 190.0/float64(columns), 277.0/float64(rows)
perPage := columns * rows
for i, card := range cards {
u, err := url.Parse(card.URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
@@ -55,22 +68,22 @@ func Generate(cards []Card) ([]byte, error) {
if err != nil {
return nil, fmt.Errorf("qr команды %d: %w", i+1, err)
}
if i%12 == 0 {
if i%perPage == 0 {
pdf.AddPage()
pdf.SetDrawColor(100, 100, 100)
pdf.SetLineWidth(0.2)
pdf.SetDashPattern([]float64{1, 1}, 0)
for col := 0; col <= 3; col++ {
for col := 0; col <= columns; col++ {
x := margin + float64(col)*width
pdf.Line(x, margin, x, margin+4*height)
pdf.Line(x, margin, x, margin+float64(rows)*height)
}
for row := 0; row <= 4; row++ {
for row := 0; row <= rows; row++ {
y := margin + float64(row)*height
pdf.Line(margin, y, margin+3*width, y)
pdf.Line(margin, y, margin+float64(columns)*width, y)
}
}
x := margin + float64(i%3)*width
y := margin + float64(i%12/3)*height
x := margin + float64(i%columns)*width
y := margin + float64(i%perPage/columns)*height
bitmap := qr.Bitmap()
pixel := qrSize / float64(len(bitmap))
for row, cells := range bitmap {
@@ -86,23 +99,34 @@ func Generate(cards []Card) ([]byte, error) {
return nil, fmt.Errorf("название команды %d содержит неподдерживаемый символ %q", i+1, r)
}
}
var instructions []string
pdf.SetFont("teams", "", 8)
for _, paragraph := range WiFiInstructions(card.WiFiName, card.WiFiPassword) {
instructions = append(instructions, pdf.SplitText(paragraph, width-6)...)
}
instructionHeight := float64(len(instructions)) * 3.3
var lines []string
var lineHeight float64
for size := 12.0; size >= 6; size-- {
pdf.SetFont("teams", "", size)
lineHeight = size * 0.3528 * 1.15
lines = pdf.SplitText(name, width-6)
if float64(len(lines))*lineHeight <= height-qrSize-8 {
if float64(len(lines))*lineHeight <= height-qrSize-8-instructionHeight {
break
}
}
if float64(len(lines))*lineHeight > height-qrSize-8 {
if float64(len(lines))*lineHeight > height-qrSize-8-instructionHeight {
return nil, fmt.Errorf("название команды %d не помещается на карточке (%d строк, %f мм)", i+1, len(lines), lineHeight)
}
for line, text := range lines {
pdf.SetXY(x+3, y+qrSize+5+float64(line)*lineHeight)
pdf.CellFormat(width-6, lineHeight, text, "", 0, "C", false, 0, "")
}
pdf.SetFont("teams", "", 8)
for line, text := range instructions {
pdf.SetXY(x+3, y+qrSize+5+float64(len(lines))*lineHeight+float64(line)*3.3)
pdf.CellFormat(width-6, 3.3, text, "", 0, "L", false, 0, "")
}
}
var result bytes.Buffer
if err := pdf.Output(&result); err != nil {
@@ -52,3 +52,28 @@ func TestGenerate(t *testing.T) {
}
}
}
func TestGenerateWiFiCards(t *testing.T) {
for _, password := range []string{"", strings.Repeat("W", 64), " secret "} {
cards := make([]Card, 7)
for i := range cards {
cards[i] = Card{
Name: strings.Repeat("Ш", 255),
URL: "https://example.org/team-story/019a1234-5678-7abc-8def-000000000007?password=abc123",
WiFiName: strings.Repeat("W", 32),
WiFiPassword: password,
}
}
data, err := Generate(cards)
if err != nil {
t.Fatal(err)
}
if got := len(regexp.MustCompile(`/Type /Page\b`).FindAll(data, -1)); got != 2 {
t.Fatalf("got %d pages, want 2", got)
}
}
_, err := Generate([]Card{{Name: "Team", URL: "https://example.org", WiFiName: "bad\n"}})
if !errors.Is(err, ErrInvalidWiFi) {
t.Fatal("invalid Wi-Fi accepted")
}
}
+59
View File
@@ -0,0 +1,59 @@
package team_qr_pdf
import (
"errors"
"fmt"
"unicode"
"unicode/utf8"
"golang.org/x/image/font/sfnt"
)
// ErrInvalidWiFi indicates Wi-Fi settings that cannot be printed reliably.
var ErrInvalidWiFi = errors.New("некорректные параметры Wi-Fi")
// ValidateWiFi checks lengths, printable characters and the embedded font.
func ValidateWiFi(name, password string) error {
if len(name) > 32 || !utf8.ValidString(name) {
return fmt.Errorf("%w: название сети — до 32 байт UTF-8", ErrInvalidWiFi)
}
if len(password) > 64 {
return fmt.Errorf("%w: пароль — до 64 печатных ASCII-символов", ErrInvalidWiFi)
}
for _, r := range password {
if r < 32 || r > 126 {
return fmt.Errorf("%w: пароль — только печатные ASCII-символы", ErrInvalidWiFi)
}
}
if name == "" {
return nil
}
ttf, err := sfnt.Parse(font)
if err != nil {
return fmt.Errorf("parse Wi-Fi font: %w", err)
}
for _, r := range name {
glyph, err := ttf.GlyphIndex(nil, r)
if err != nil || glyph == 0 || r > 0xffff || unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
return fmt.Errorf("%w: название сети содержит непечатаемый символ", ErrInvalidWiFi)
}
}
return nil
}
// WiFiInstructions returns print paragraphs, or none when no network is configured.
func WiFiInstructions(name, password string) []string {
if name == "" {
return nil
}
access := "Без пароля"
if password != "" {
access = "Пароль: «" + password + "»"
}
return []string{
"1. Подключитесь к Wi-Fi «" + name + "».",
access,
"2. Отсканируйте QR-код команды.",
"Если телефон сообщает, что сеть без интернета, оставайтесь подключены.",
}
}
+57
View File
@@ -0,0 +1,57 @@
package team_qr_pdf
import (
"errors"
"reflect"
"strings"
"testing"
)
func TestValidateWiFi(t *testing.T) {
for _, tc := range []struct {
name, password string
invalid bool
}{
{},
{name: "Детектив", password: " secret password "},
{name: strings.Repeat("Ш", 16), password: strings.Repeat("~", 64)},
{name: strings.Repeat("x", 33), invalid: true},
{name: strings.Repeat("Ш", 17), invalid: true},
{name: "сеть\n", invalid: true},
{name: "сеть\u2028", invalid: true},
{name: "сеть\u200b", invalid: true},
{name: "сеть🕵", invalid: true},
{name: string([]byte{0xff}), invalid: true},
{password: strings.Repeat("x", 65), invalid: true},
{password: "пароль", invalid: true},
{password: "secret\t", invalid: true},
{password: "secret\x7f", invalid: true},
} {
err := ValidateWiFi(tc.name, tc.password)
if tc.invalid && !errors.Is(err, ErrInvalidWiFi) {
t.Fatal("invalid Wi-Fi accepted")
}
if !tc.invalid && err != nil {
t.Fatal(err)
}
}
}
func TestWiFiInstructions(t *testing.T) {
if got := WiFiInstructions("", "secret"); len(got) != 0 {
t.Fatal("instructions without a network")
}
got := WiFiInstructions(" Venue ", " secret ")
want := []string{
"1. Подключитесь к Wi-Fi « Venue ».",
"Пароль: « secret »",
"2. Отсканируйте QR-код команды.",
"Если телефон сообщает, что сеть без интернета, оставайтесь подключены.",
}
if !reflect.DeepEqual(got, want) {
t.Fatal("instructions changed credentials")
}
if got := WiFiInstructions("Venue", ""); got[1] != "Без пароля" {
t.Fatal("open network requires a password")
}
}