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 }