This commit is contained in:
2026-10-13 02:34:16 +07:00
parent 8101d21d7f
commit e77cc34b4d
6 changed files with 177 additions and 1 deletions
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

@@ -0,0 +1,98 @@
package game_print_service
import (
"bytes"
_ "embed"
"errors"
"github.com/go-pdf/fpdf"
)
//go:embed assets/paper.jpg
var diplomaPaper []byte
var errInvalidDiplomaPlace = errors.New("некорректное место диплома")
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.SetMargins(20, 20, 20)
pdf.SetAutoPageBreak(false, 0)
pdf.AddUTF8FontFromBytes("typewriter", "", typewriterFont)
pdf.SetFont("typewriter", "", 12)
pdf.AddPage()
options := fpdf.ImageOptions{ImageType: "JPG"}
paper := pdf.RegisterImageOptionsReader("diploma-paper", options, bytes.NewReader(diplomaPaper))
if err := pdf.Error(); err != nil {
return nil, err
}
height := 210 * paper.Height() / paper.Width()
pdf.ClipRect(0, 0, 210, 297, false)
pdf.ImageOptions("diploma-paper", 0, (297-height)/2, 210, 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)
}
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, "")
}
@@ -0,0 +1,62 @@
package game_print_service
import (
"bytes"
"errors"
"os/exec"
"regexp"
"strconv"
"strings"
"testing"
)
func TestDiplomasUsePortraitA4AndBrandAssets(t *testing.T) {
for _, tc := range []struct {
place int
rank string
}{{1, "I"}, {2, "II"}, {3, "III"}} {
t.Run(tc.rank, func(t *testing.T) {
data, err := diplomaPDF(tc.place)
if err != nil {
t.Fatal(err)
}
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")
}
if !bytes.Contains(data, diplomaPaper) || !bytes.Contains(data, printLogo) || bytes.Count(data, []byte("/Subtype /Image")) != 2 {
t.Fatal("original paper and logo not embedded")
}
if !bytes.Contains(data, []byte("/BaseFont /utf8typewriter")) {
t.Fatal("typewriter font missing")
}
t.Run("contents", func(t *testing.T) {
if _, err := exec.LookPath("pdftotext"); err != nil {
t.Skip("pdftotext is needed only for optional rendered-content checks")
}
text := extractedPDFText(t, data)
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)
}
})
}
}
@@ -147,6 +147,15 @@ 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,6 +151,7 @@ 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",
} { } {
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)
@@ -168,7 +169,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 := 9 wantEntries := 12
if tc.wantQR { if tc.wantQR {
wantEntries++ wantEntries++
} }
@@ -314,6 +315,12 @@ func TestPrintArchiveUsesScenarioTitle(t *testing.T) {
if strings.Contains(name, "QR команд") { if strings.Contains(name, "QR команд") {
continue continue
} }
if strings.HasPrefix(name, hostFolder+"Дипломы/") {
if strings.Contains(text, f.scenarioName) {
t.Fatal("scenario title printed in universal diploma")
}
continue
}
if name == playerFolder+"Правила.pdf" || name == playerFolder+"Черновик расследования.pdf" || name == playerFolder+"Список пидарасов.pdf" { if name == playerFolder+"Правила.pdf" || name == playerFolder+"Черновик расследования.pdf" || name == playerFolder+"Список пидарасов.pdf" {
if strings.Contains(text, f.scenarioName) { if strings.Contains(text, f.scenarioName) {
t.Fatalf("scenario title printed in %s", name) t.Fatalf("scenario title printed in %s", name)