generated from VLADIMIR/template
up
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user