generated from VLADIMIR/template
up
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-код команды.",
|
||||
"Если телефон сообщает, что сеть без интернета, оставайтесь подключены.",
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user