generated from VLADIMIR/template
272 lines
7.5 KiB
Go
272 lines
7.5 KiB
Go
package team_service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
"image/png"
|
|
"strings"
|
|
"time"
|
|
|
|
"evening_detective_server/internal/modules/team_qr_pdf"
|
|
"evening_detective_server/internal/repos"
|
|
"evening_detective_server/internal/repos/games_repo"
|
|
"evening_detective_server/internal/repos/teams_repo"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/skip2/go-qrcode"
|
|
"golang.org/x/image/font"
|
|
"golang.org/x/image/font/opentype"
|
|
"golang.org/x/image/math/fixed"
|
|
)
|
|
|
|
const (
|
|
receiptWidth = 384
|
|
receiptRowBytes = receiptWidth / 8
|
|
receiptMaxHeight = 1024
|
|
// ReceiptTimeout fits within the venue proxy's 120-second read timeout.
|
|
ReceiptTimeout = 110 * time.Second
|
|
)
|
|
|
|
// IReceiptPrinter sends a monochrome 384-pixel-wide raster to the venue printer.
|
|
type IReceiptPrinter interface {
|
|
Print(context.Context, []byte) error
|
|
}
|
|
|
|
// PreviewTeamReceipt renders a team receipt without contacting the printer.
|
|
func (s *TeamService) PreviewTeamReceipt(ctx context.Context, gameID int, teamID string) ([]byte, error) {
|
|
img, err := s.teamReceipt(ctx, gameID, teamID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var data bytes.Buffer
|
|
if err := png.Encode(&data, img); err != nil {
|
|
return nil, fmt.Errorf("encode receipt: %w", err)
|
|
}
|
|
return data.Bytes(), nil
|
|
}
|
|
|
|
// PrintTeamReceipt loads current team data and prints one receipt without retrying.
|
|
func (s *TeamService) PrintTeamReceipt(ctx context.Context, gameID int, teamID string) error {
|
|
img, err := s.teamReceipt(ctx, gameID, teamID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.receiptPrinter == nil {
|
|
return ErrPrinterNotConfigured
|
|
}
|
|
return s.receiptPrinter.Print(ctx, packReceipt(img))
|
|
}
|
|
|
|
func (s *TeamService) teamReceipt(ctx context.Context, gameID int, teamID string) (*image.Gray, error) {
|
|
if !s.venueMode {
|
|
return nil, ErrReceiptVenueOnly
|
|
}
|
|
if gameID <= 0 {
|
|
return nil, ErrInvalidReceiptRequest
|
|
}
|
|
if _, err := uuid.Parse(teamID); err != nil {
|
|
return nil, ErrInvalidReceiptRequest
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
game, err := s.gamesRepo.GetGameByID(ctx, gameID)
|
|
if errors.Is(err, games_repo.ErrGameNotFound) {
|
|
return nil, ErrGameNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
team, err := s.teamsRepo.GetTeamByID(ctx, teamID)
|
|
if errors.Is(err, teams_repo.ErrTeamNotFound) {
|
|
return nil, ErrTeamNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if team.GameID != gameID {
|
|
return nil, ErrTeamNotFound
|
|
}
|
|
if team.ParticipationStatus == "cancelled" {
|
|
return nil, ErrReceiptTeamCancelled
|
|
}
|
|
cards, _, err := teamQRData(s.appBaseURL, game, []*repos.Team{team})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("receipt card: %w: %w", ErrInvalidReceipt, err)
|
|
}
|
|
return renderReceipt(cards[0])
|
|
}
|
|
|
|
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
|
|
}
|
|
ttf, err := opentype.Parse(team_qr_pdf.FontData())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse receipt font: %w", err)
|
|
}
|
|
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 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
|
|
}
|
|
bitmap := qr.Bitmap()
|
|
scale := (receiptWidth - 32) / len(bitmap)
|
|
if scale < 3 {
|
|
return nil, ErrInvalidReceipt
|
|
}
|
|
qrSize := len(bitmap) * scale
|
|
qrY := 16 + len(instructions)*26 + 16
|
|
height := qrY + qrSize + 24 + len(lines)*nameLineHeight
|
|
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)
|
|
height = qrY + qrSize + 24 + len(lines)*nameLineHeight
|
|
}
|
|
if height > receiptMaxHeight {
|
|
return nil, ErrInvalidReceipt
|
|
}
|
|
img := image.NewGray(image.Rect(0, 0, receiptWidth, height))
|
|
draw.Draw(img, img.Bounds(), image.White, image.Point{}, draw.Src)
|
|
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, qrY+qrSize+face.Metrics().Ascent.Ceil()+i*nameLineHeight)
|
|
drawer.DrawString(line)
|
|
}
|
|
x := (receiptWidth - qrSize) / 2
|
|
for row, cells := range bitmap {
|
|
for col, black := range cells {
|
|
if black {
|
|
draw.Draw(img, image.Rect(x+col*scale, qrY+row*scale, x+(col+1)*scale, qrY+(row+1)*scale), image.Black, image.Point{}, draw.Src)
|
|
}
|
|
}
|
|
}
|
|
drawer.Face = instructionFace
|
|
for i, line := range instructions {
|
|
drawer.Dot = fixed.P(16, 16+instructionFace.Metrics().Ascent.Ceil()+i*26)
|
|
drawer.DrawString(line)
|
|
}
|
|
for i, pixel := range img.Pix {
|
|
if pixel < 128 {
|
|
img.Pix[i] = 0
|
|
} else {
|
|
img.Pix[i] = 255
|
|
}
|
|
}
|
|
return img, nil
|
|
}
|
|
|
|
func wrapReceiptName(name string, face font.Face) []string {
|
|
var lines []string
|
|
line := ""
|
|
for _, word := range strings.Fields(name) {
|
|
candidate := word
|
|
if line != "" {
|
|
candidate = line + " " + word
|
|
}
|
|
if font.MeasureString(face, candidate).Ceil() <= receiptWidth-32 {
|
|
line = candidate
|
|
continue
|
|
}
|
|
if line != "" {
|
|
lines = append(lines, line)
|
|
}
|
|
line = ""
|
|
for _, r := range word {
|
|
if line != "" && font.MeasureString(face, line+string(r)).Ceil() > receiptWidth-32 {
|
|
lines = append(lines, line)
|
|
line = ""
|
|
}
|
|
line += string(r)
|
|
}
|
|
}
|
|
if line != "" {
|
|
lines = append(lines, line)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func packReceipt(img *image.Gray) []byte {
|
|
data := make([]byte, img.Rect.Dy()*receiptRowBytes)
|
|
for y := range img.Rect.Dy() {
|
|
for x := range receiptWidth {
|
|
if img.GrayAt(x, y) == (color.Gray{Y: 0}) {
|
|
data[y*receiptRowBytes+x/8] |= 1 << (x % 8)
|
|
}
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
|
|
func validateReceiptRaster(data []byte) error {
|
|
if len(data) == 0 || len(data)%receiptRowBytes != 0 || len(data) > receiptMaxHeight*receiptRowBytes {
|
|
return ErrInvalidReceipt
|
|
}
|
|
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
|
|
}
|