This commit is contained in:
2026-10-13 13:50:13 +07:00
parent e77cc34b4d
commit 57fee0cc06
26 changed files with 2202 additions and 181 deletions
+213
View File
@@ -0,0 +1,213 @@
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) {
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)
}
face, err := opentype.NewFace(ttf, &opentype.FaceOptions{Size: 26, DPI: 72, Hinting: font.HintingFull})
if err != nil {
return nil, fmt.Errorf("open receipt font: %w", err)
}
defer face.Close()
for _, r := range name {
if _, ok := face.GlyphAdvance(r); !ok {
return nil, ErrInvalidReceipt
}
}
lines := wrapReceiptName(name, face)
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(lines)*34 + 16
height := qrY + qrSize + 24
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, 16+face.Metrics().Ascent.Ceil()+i*34)
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)
}
}
}
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
}