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
+64 -5
View File
@@ -103,6 +103,9 @@ func (s *TeamService) teamReceipt(ctx context.Context, gameID int, teamID string
}
func renderReceipt(card team_qr_pdf.Card) (*image.Gray, error) {
if err := team_qr_pdf.ValidateWiFi(card.WiFiName, card.WiFiPassword); err != nil {
return nil, fmt.Errorf("%w: %w", ErrInvalidReceipt, err)
}
name := strings.Join(strings.Fields(card.Name), " ")
if name == "" || len([]rune(name)) > 255 {
return nil, ErrInvalidReceipt
@@ -111,17 +114,34 @@ func renderReceipt(card team_qr_pdf.Card) (*image.Gray, error) {
if err != nil {
return nil, fmt.Errorf("parse receipt font: %w", err)
}
face, err := opentype.NewFace(ttf, &opentype.FaceOptions{Size: 26, DPI: 72, Hinting: font.HintingFull})
nameSize, nameLineHeight := 26.0, 34
if card.WiFiName != "" {
nameSize, nameLineHeight = 20, 26
}
face, err := opentype.NewFace(ttf, &opentype.FaceOptions{Size: nameSize, DPI: 72, Hinting: font.HintingFull})
if err != nil {
return nil, fmt.Errorf("open receipt font: %w", err)
}
defer face.Close()
defer func() {
if face != nil {
_ = face.Close()
}
}()
for _, r := range name {
if _, ok := face.GlyphAdvance(r); !ok {
return nil, ErrInvalidReceipt
}
}
lines := wrapReceiptName(name, face)
instructionFace, err := opentype.NewFace(ttf, &opentype.FaceOptions{Size: 20, DPI: 72, Hinting: font.HintingFull})
if err != nil {
return nil, fmt.Errorf("open Wi-Fi receipt font: %w", err)
}
defer instructionFace.Close()
var instructions []string
for _, paragraph := range team_qr_pdf.WiFiInstructions(card.WiFiName, card.WiFiPassword) {
instructions = append(instructions, wrapReceiptText(paragraph, instructionFace)...)
}
qr, err := qrcode.New(card.URL, qrcode.Medium)
if err != nil {
return nil, ErrInvalidReceipt
@@ -132,8 +152,20 @@ func renderReceipt(card team_qr_pdf.Card) (*image.Gray, error) {
return nil, ErrInvalidReceipt
}
qrSize := len(bitmap) * scale
qrY := 16 + len(lines)*34 + 16
height := qrY + qrSize + 24
qrY := 16 + len(lines)*nameLineHeight + 16
height := qrY + qrSize + 24 + len(instructions)*26
for card.WiFiName != "" && height > receiptMaxHeight && nameSize > 16 {
_ = face.Close()
nameSize -= 2
nameLineHeight = int(nameSize) + 6
face, err = opentype.NewFace(ttf, &opentype.FaceOptions{Size: nameSize, DPI: 72, Hinting: font.HintingFull})
if err != nil {
return nil, fmt.Errorf("resize receipt font: %w", err)
}
lines = wrapReceiptName(name, face)
qrY = 16 + len(lines)*nameLineHeight + 16
height = qrY + qrSize + 24 + len(instructions)*26
}
if height > receiptMaxHeight {
return nil, ErrInvalidReceipt
}
@@ -142,7 +174,7 @@ func renderReceipt(card team_qr_pdf.Card) (*image.Gray, error) {
drawer := font.Drawer{Dst: img, Src: image.Black, Face: face}
for i, line := range lines {
width := drawer.MeasureString(line).Ceil()
drawer.Dot = fixed.P((receiptWidth-width)/2, 16+face.Metrics().Ascent.Ceil()+i*34)
drawer.Dot = fixed.P((receiptWidth-width)/2, 16+face.Metrics().Ascent.Ceil()+i*nameLineHeight)
drawer.DrawString(line)
}
x := (receiptWidth - qrSize) / 2
@@ -153,6 +185,11 @@ func renderReceipt(card team_qr_pdf.Card) (*image.Gray, error) {
}
}
}
drawer.Face = instructionFace
for i, line := range instructions {
drawer.Dot = fixed.P(16, qrY+qrSize+instructionFace.Metrics().Ascent.Ceil()+i*26)
drawer.DrawString(line)
}
for i, pixel := range img.Pix {
if pixel < 128 {
img.Pix[i] = 0
@@ -211,3 +248,25 @@ func validateReceiptRaster(data []byte) error {
}
return nil
}
// Preserve spaces in credentials when wrapping the printed instructions.
func wrapReceiptText(text string, face font.Face) []string {
var lines []string
line := ""
for _, r := range text {
for line != "" && font.MeasureString(face, line+string(r)).Ceil() > receiptWidth-32 {
if space := strings.LastIndexByte(line, ' '); space >= 0 {
lines = append(lines, line[:space+1])
line = line[space+1:]
} else {
lines = append(lines, line)
line = ""
}
}
line += string(r)
}
if line != "" {
lines = append(lines, line)
}
return lines
}
+53 -4
View File
@@ -14,18 +14,21 @@ import (
"evening_detective_server/internal/repos"
"evening_detective_server/internal/repos/games_repo"
"evening_detective_server/internal/repos/teams_repo"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
)
const receiptTestTeamID = "019a1234-5678-7abc-8def-000000000007"
type receiptReposStub struct {
ITeamsRepository
team *repos.Team
gameErr, teamErr error
team *repos.Team
gameErr, teamErr error
wifiName, wifiPassword string
}
func (s *receiptReposStub) GetGameByID(context.Context, int) (*repos.Game, error) {
return &repos.Game{ID: 1, Name: "Проверка чеков"}, s.gameErr
return &repos.Game{ID: 1, Name: "Проверка чеков", WiFiName: s.wifiName, WiFiPassword: s.wifiPassword}, s.gameErr
}
func (s *receiptReposStub) GetTeamByID(context.Context, string) (*repos.Team, error) {
@@ -81,7 +84,7 @@ func TestTeamReceiptValidation(t *testing.T) {
func TestTeamReceiptPreviewMatchesPrintedRaster(t *testing.T) {
for _, name := range []string{"Вечерние сыщики", "Очень длинное название команды БезПробеловОченьДлинноеНазваниеКоманды"} {
t.Run(name, func(t *testing.T) {
repos := &receiptReposStub{team: &repos.Team{ID: receiptTestTeamID, GameID: 1, Name: name, Password: "a+b&c?", ParticipationStatus: "confirmed"}}
repos := &receiptReposStub{wifiName: "Детектив", wifiPassword: " secret ", team: &repos.Team{ID: receiptTestTeamID, GameID: 1, Name: name, Password: "a+b&c?", ParticipationStatus: "confirmed"}}
printer := &receiptPrinterStub{}
s := NewTeamService(repos, repos, nil, "https://game.test/app", nil, true, printer)
data, err := s.PreviewTeamReceipt(context.Background(), 1, receiptTestTeamID)
@@ -149,3 +152,49 @@ func TestReceiptPrinterErrorsRoundTrip(t *testing.T) {
})
}
}
func TestReceiptWiFiFitsAndPreservesCredentials(t *testing.T) {
for _, name := range []string{"Команда", strings.Repeat("Ш", 255), strings.Repeat("W", 255)} {
for _, password := range []string{"", " secret ", strings.Repeat("W", 64)} {
card := team_qr_pdf.Card{
Name: name,
URL: "https://example.org/team-story/019a1234-5678-7abc-8def-000000000007?password=abc123",
WiFiName: strings.Repeat("W", 32),
WiFiPassword: password,
}
img, err := renderReceipt(card)
if err != nil {
t.Fatalf("name runes=%d, password bytes=%d: %v", len([]rune(name)), len(password), err)
}
if img.Bounds().Dy() > receiptMaxHeight {
t.Fatal("receipt exceeds printer limit")
}
if err := validateReceiptRaster(packReceipt(img)); err != nil {
t.Fatal(err)
}
}
}
}
func TestWrapReceiptTextPreservesCredentials(t *testing.T) {
ttf, err := opentype.Parse(team_qr_pdf.FontData())
if err != nil {
t.Fatal(err)
}
face, err := opentype.NewFace(ttf, &opentype.FaceOptions{Size: 20, DPI: 72, Hinting: font.HintingFull})
if err != nil {
t.Fatal(err)
}
defer face.Close()
for _, text := range []string{" secret password ", " " + strings.Repeat("W", 64), "Пароль: «" + strings.Repeat("W", 64) + "»", "Подключитесь к Wi-Fi организатора."} {
lines := wrapReceiptText(text, face)
if strings.Join(lines, "") != text {
t.Fatal("wrapping changed credentials")
}
for _, line := range lines {
if font.MeasureString(face, line).Ceil() > receiptWidth-32 {
t.Fatal("text clipped at receipt edge")
}
}
}
}
+6 -1
View File
@@ -29,7 +29,12 @@ func teamQRData(baseURL string, game *repos.Game, teams []*repos.Team) ([]team_q
link.Path = strings.TrimRight(base.Path, "/") + "/team-story/" + team.ID
link.RawPath = ""
link.RawQuery = url.Values{"password": {team.Password}}.Encode()
cards = append(cards, team_qr_pdf.Card{Name: team.Name, URL: link.String()})
cards = append(cards, team_qr_pdf.Card{
Name: team.Name,
URL: link.String(),
WiFiName: game.WiFiName,
WiFiPassword: game.WiFiPassword,
})
}
name := strings.Map(func(r rune) rune {
if unicode.IsControl(r) || strings.ContainsRune(`\/:*?"<>|`, r) {
@@ -9,7 +9,7 @@ import (
)
func TestTeamQRData(t *testing.T) {
game := &repos.Game{Name: "Дело / №1\r\n", StartAt: time.Date(2026, 9, 8, 14, 0, 0, 0, time.UTC)}
game := &repos.Game{WiFiName: "Детектив", WiFiPassword: " secret ", Name: "Дело / №1\r\n", StartAt: time.Date(2026, 9, 8, 14, 0, 0, 0, time.UTC)}
teams := []*repos.Team{{ID: "019a1234-5678-7abc-8def-000000000007", Name: "Знатоки", Password: "a+b&c?"}}
cards, filename, err := teamQRData("https://example.org/app/", game, teams)
if err != nil {
@@ -18,6 +18,9 @@ func TestTeamQRData(t *testing.T) {
if len(cards) != 1 || cards[0].Name != "Знатоки" {
t.Fatalf("cards: %+v", cards)
}
if cards[0].WiFiName != game.WiFiName || cards[0].WiFiPassword != game.WiFiPassword {
t.Fatal("Wi-Fi settings missing from print card")
}
u, err := url.Parse(cards[0].URL)
if err != nil || u.Path != "/app/team-story/019a1234-5678-7abc-8def-000000000007" || u.Query().Get("password") != teams[0].Password {
t.Fatalf("wrong team link: %s", cards[0].URL)