This commit is contained in:
2026-10-27 13:12:42 +07:00
parent 0341bfac25
commit 8733c328fa
5 changed files with 35 additions and 134 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

@@ -3,96 +3,24 @@ package game_print_service
import ( import (
"bytes" "bytes"
_ "embed" _ "embed"
"errors"
"github.com/go-pdf/fpdf" "github.com/go-pdf/fpdf"
) )
//go:embed assets/paper.jpg //go:embed assets/diploma.png
var diplomaPaper []byte var diplomaImage []byte
var errInvalidDiplomaPlace = errors.New("некорректное место диплома") func diplomaPDF() ([]byte, error) {
func diplomaPDF(place int) ([]byte, error) {
ranks := [...]string{"I", "II", "III"}
if place < 1 || place > len(ranks) {
return nil, errInvalidDiplomaPlace
}
pdf := fpdf.New("P", "mm", "A4", "") pdf := fpdf.New("P", "mm", "A4", "")
pdf.SetMargins(20, 20, 20)
pdf.SetAutoPageBreak(false, 0) pdf.SetAutoPageBreak(false, 0)
pdf.AddUTF8FontFromBytes("typewriter", "", typewriterFont)
pdf.SetFont("typewriter", "", 12)
pdf.AddPage() pdf.AddPage()
options := fpdf.ImageOptions{ImageType: "JPG"} options := fpdf.ImageOptions{ImageType: "PNG"}
paper := pdf.RegisterImageOptionsReader("diploma-paper", options, bytes.NewReader(diplomaPaper)) image := pdf.RegisterImageOptionsReader("diploma", options, bytes.NewReader(diplomaImage))
if err := pdf.Error(); err != nil { if err := pdf.Error(); err != nil {
return nil, err return nil, err
} }
height := 210 * paper.Height() / paper.Width() width := min(210.0, 297*image.Width()/image.Height())
pdf.ClipRect(0, 0, 210, 297, false) height := width * image.Height() / image.Width()
pdf.ImageOptions("diploma-paper", 0, (297-height)/2, 210, height, false, options, 0, "") pdf.ImageOptions("diploma", (210-width)/2, (297-height)/2, width, height, false, options, 0, "")
pdf.ClipEnd()
pdf.SetDrawColor(99, 75, 51)
pdf.SetTextColor(61, 45, 34)
pdf.SetFillColor(99, 75, 51)
pdf.SetLineWidth(0.45)
pdf.Rect(12, 12, 186, 273, "D")
pdf.SetLineWidth(0.16)
pdf.Rect(15, 15, 180, 267, "D")
for _, corner := range []fpdf.PointType{{21, 21}, {189, 21}, {21, 276}, {189, 276}} {
x, y := corner.X, corner.Y
pdf.Polygon([]fpdf.PointType{{x, y - 2}, {x + 2, y}, {x, y + 2}, {x - 2, y}}, "F")
pdf.Line(x-4, y, x-2.5, y)
pdf.Line(x+2.5, y, x+4, y)
pdf.Line(x, y-4, x, y-2.5)
pdf.Line(x, y+2.5, x, y+4)
}
pdf.SetFillColor(255, 253, 247)
pdf.Circle(105, 38, 17, "FD")
logo := pdf.RegisterImageOptionsReader("diploma-logo", options, bytes.NewReader(printLogo))
if err := pdf.Error(); err != nil {
return nil, err
}
logoHeight := 28 * logo.Height() / logo.Width()
pdf.ClipCircle(105, 38, max(28, logoHeight)/2+0.1, false)
pdf.ImageOptions("diploma-logo", 91, 38-logoHeight/2, 28, 0, false, options, 0, "")
pdf.ClipEnd()
diplomaText(pdf, 61, 15, "Вечерний детектив")
pdf.Line(75, 73, 135, 73)
diplomaText(pdf, 82, 42, "ДИПЛОМ")
diplomaText(pdf, 116, 13, "Награждается команда")
pdf.SetLineWidth(0.3)
pdf.Line(32, 141, 178, 141)
pdf.TransformBegin()
pdf.TransformRotate(-5, 105, 181)
pdf.SetDrawColor(133, 53, 43)
pdf.SetTextColor(133, 53, 43)
pdf.SetLineWidth(0.55)
pdf.Rect(62, 156, 86, 51, "D")
pdf.SetLineWidth(0.18)
pdf.Rect(64, 158, 82, 47, "D")
diplomaText(pdf, 160, 11, "ЗА")
diplomaText(pdf, 168, 44, ranks[place-1])
diplomaText(pdf, 193, 15, "МЕСТО")
pdf.TransformEnd()
pdf.SetDrawColor(99, 75, 51)
pdf.SetTextColor(61, 45, 34)
diplomaText(pdf, 222, 11, "За проницательность, командную работу")
diplomaText(pdf, 230, 11, "и мастерство расследования")
pdf.SetLineWidth(0.25)
pdf.Line(32, 260, 77, 260)
pdf.Line(112, 260, 178, 260)
pdf.SetFont("typewriter", "", 9)
pdf.SetXY(32, 264)
pdf.CellFormat(45, 5, "Дата", "", 0, "C", false, 0, "")
pdf.SetXY(112, 264)
pdf.CellFormat(66, 5, "Организатор / подпись", "", 0, "C", false, 0, "")
return pdfBytes(pdf) return pdfBytes(pdf)
} }
func diplomaText(pdf *fpdf.Fpdf, y, size float64, text string) {
pdf.SetFont("typewriter", "", size)
pdf.SetXY(20, y)
pdf.CellFormat(170, size*0.3528*1.2, text, "", 0, "C", false, 0, "")
}
@@ -2,60 +2,32 @@ package game_print_service
import ( import (
"bytes" "bytes"
"errors" "image/png"
"os/exec"
"regexp"
"strconv"
"strings"
"testing" "testing"
) )
func TestDiplomasUsePortraitA4AndBrandAssets(t *testing.T) { func TestDiplomaUsesPortraitA4AndBlankArtwork(t *testing.T) {
for _, tc := range []struct { for _, name := range []string{"blank diploma"} {
place int t.Run(name, func(t *testing.T) {
rank string data, err := diplomaPDF()
}{{1, "I"}, {2, "II"}, {3, "III"}} {
t.Run(tc.rank, func(t *testing.T) {
data, err := diplomaPDF(tc.place)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if bytes.Count(data, []byte("/Type /Page\n")) != 1 || !bytes.Contains(data, []byte("/MediaBox [0 0 595.28 841.89]")) { if bytes.Count(data, []byte("/Type /Page\n")) != 1 || !bytes.Contains(data, []byte("/MediaBox [0 0 595.28 841.89]")) {
t.Fatal("diploma is not one portrait A4 page") t.Fatal("diploma is not one portrait A4 page")
} }
if !bytes.Contains(data, diplomaPaper) || !bytes.Contains(data, printLogo) || bytes.Count(data, []byte("/Subtype /Image")) != 2 { if bytes.Count(data, []byte("/Subtype /Image")) != 1 || !bytes.Contains(data, []byte("/Width 1054")) || !bytes.Contains(data, []byte("/Height 1492")) {
t.Fatal("original paper and logo not embedded") t.Fatal("diploma artwork missing")
} }
if !bytes.Contains(data, []byte("/BaseFont /utf8typewriter")) { artwork, err := png.Decode(bytes.NewReader(diplomaImage))
t.Fatal("typewriter font missing") if err != nil {
t.Fatal(err)
} }
t.Run("contents", func(t *testing.T) { if artwork.Bounds().Dx() != 1054 || artwork.Bounds().Dy() != 1492 {
if _, err := exec.LookPath("pdftotext"); err != nil { t.Fatal("unexpected artwork dimensions")
t.Skip("pdftotext is needed only for optional rendered-content checks") }
} if bytes.Contains(data, []byte("/Type /Font")) {
text := extractedPDFText(t, data) t.Fatal("diploma contains added text font")
for _, want := range []string{"ДИПЛОМ", "Вечерний детектив", "Награждается команда", "МЕСТО", "Дата", "Организатор / подпись", "мастерство расследования"} {
if !strings.Contains(text, want) {
t.Errorf("missing text %s", want)
}
}
if !regexp.MustCompile(`(?m)^\s*` + tc.rank + `\s*$`).MatchString(text) {
t.Fatalf("wrong award place: %s", text)
}
if regexp.MustCompile(`(?m)^\s*[123]\s*$`).MatchString(text) {
t.Fatal("page number printed")
}
})
})
}
}
func TestDiplomaRejectsUnknownPlace(t *testing.T) {
for _, place := range []int{-1, 0, 4} {
t.Run(strconv.Itoa(place), func(t *testing.T) {
data, err := diplomaPDF(place)
if !errors.Is(err, errInvalidDiplomaPlace) || len(data) != 0 {
t.Fatalf("place %d: data = %d bytes, error = %v", place, len(data), err)
} }
}) })
} }
@@ -132,6 +132,7 @@ func (s *GamePrintService) Download(ctx context.Context, gameID int) ([]byte, st
{playerFolder + "Вопросы.pdf", func() ([]byte, error) { return questionsPDF(scenario.Name, story.Questions) }}, {playerFolder + "Вопросы.pdf", func() ([]byte, error) { return questionsPDF(scenario.Name, story.Questions) }},
{playerFolder + "Список пидарасов.pdf", func() ([]byte, error) { return draftPDF("Список пидарасов") }}, {playerFolder + "Список пидарасов.pdf", func() ([]byte, error) { return draftPDF("Список пидарасов") }},
{playerFolder + "Черновик расследования.pdf", func() ([]byte, error) { return draftPDF("Черновик расследования") }}, {playerFolder + "Черновик расследования.pdf", func() ([]byte, error) { return draftPDF("Черновик расследования") }},
{hostFolder + "Дипломы/Диплом.pdf", diplomaPDF},
{hostFolder + "Ответы.pdf", func() ([]byte, error) { return answersPDF(scenario.Name, story.Questions) }}, {hostFolder + "Ответы.pdf", func() ([]byte, error) { return answersPDF(scenario.Name, story.Questions) }},
{hostFolder + "Лист выдачи улик.pdf", func() ([]byte, error) { return issuancePDF(scenario.Name, teams, clues) }}, {hostFolder + "Лист выдачи улик.pdf", func() ([]byte, error) { return issuancePDF(scenario.Name, teams, clues) }},
} }
@@ -147,15 +148,6 @@ func (s *GamePrintService) Download(ctx context.Context, gameID int) ([]byte, st
return nil, "", err return nil, "", err
} }
} }
for place := 1; place <= 3; place++ {
data, err := diplomaPDF(place)
if err != nil {
return nil, "", fmt.Errorf("generate diploma %d: %w", place, err)
}
if err := add(fmt.Sprintf("%sДипломы/%d место.pdf", hostFolder, place), data); err != nil {
return nil, "", err
}
}
if len(teams) > 0 { if len(teams) > 0 {
data, _, err := s.qr.GenerateTeamQR(ctx, game, teams) data, _, err := s.qr.GenerateTeamQR(ctx, game, teams)
if err != nil { if err != nil {
@@ -151,12 +151,21 @@ func TestDownloadPrintMaterials(t *testing.T) {
playerFolder + "Правила.pdf", playerFolder + "Введение.pdf", playerFolder + "Вопросы.pdf", playerFolder + "Правила.pdf", playerFolder + "Введение.pdf", playerFolder + "Вопросы.pdf",
playerFolder + "Список пидарасов.pdf", playerFolder + "Черновик расследования.pdf", playerFolder + "Список пидарасов.pdf", playerFolder + "Черновик расследования.pdf",
hostFolder + "Ответы.pdf", hostFolder + "Лист выдачи улик.pdf", hostFolder + "Ответы.pdf", hostFolder + "Лист выдачи улик.pdf",
hostFolder + "Дипломы/1 место.pdf", hostFolder + "Дипломы/2 место.pdf", hostFolder + "Дипломы/3 место.pdf", hostFolder + "Дипломы/Диплом.pdf",
} { } {
if !bytes.HasPrefix(entries[name], []byte("%PDF-")) { if !bytes.HasPrefix(entries[name], []byte("%PDF-")) {
t.Errorf("missing PDF %s", name) t.Errorf("missing PDF %s", name)
} }
} }
diplomas := 0
for name := range entries {
if strings.HasPrefix(name, hostFolder+"Дипломы/") {
diplomas++
}
}
if diplomas != 1 {
t.Fatalf("diplomas = %d, want one blank diploma", diplomas)
}
_, hasQR := entries[hostFolder+"QR команд.pdf"] _, hasQR := entries[hostFolder+"QR команд.pdf"]
_, hasExternal := entries[hostFolder+"Внешние материалы.pdf"] _, hasExternal := entries[hostFolder+"Внешние материалы.pdf"]
if hasQR != tc.wantQR || hasExternal != tc.wantExternal { if hasQR != tc.wantQR || hasExternal != tc.wantExternal {
@@ -169,7 +178,7 @@ func TestDownloadPrintMaterials(t *testing.T) {
if !bytes.Equal(entries[playerFolder+"Улики/001 - Письмо _ улика.pdf"], f.files["clue.pdf"]) { if !bytes.Equal(entries[playerFolder+"Улики/001 - Письмо _ улика.pdf"], f.files["clue.pdf"]) {
t.Fatal("original clue was changed or missing") t.Fatal("original clue was changed or missing")
} }
wantEntries := 12 wantEntries := 10
if tc.wantQR { if tc.wantQR {
wantEntries++ wantEntries++
} }