diff --git a/bin/evening_detective_server b/bin/evening_detective_server index 8e165d1..7a22491 100755 Binary files a/bin/evening_detective_server and b/bin/evening_detective_server differ diff --git a/internal/services/game_print_service/assets/a_OldTyper.ttf b/internal/services/game_print_service/assets/a_OldTyper.ttf new file mode 100644 index 0000000..5247583 Binary files /dev/null and b/internal/services/game_print_service/assets/a_OldTyper.ttf differ diff --git a/internal/services/game_print_service/assets/logo.jpg b/internal/services/game_print_service/assets/logo.jpg new file mode 100644 index 0000000..2a41945 Binary files /dev/null and b/internal/services/game_print_service/assets/logo.jpg differ diff --git a/internal/services/game_print_service/pdf.go b/internal/services/game_print_service/pdf.go index d3e1e1c..9d3dfed 100644 --- a/internal/services/game_print_service/pdf.go +++ b/internal/services/game_print_service/pdf.go @@ -2,6 +2,7 @@ package game_print_service import ( "bytes" + _ "embed" "fmt" "strings" "unicode/utf8" @@ -14,17 +15,40 @@ import ( "github.com/go-pdf/fpdf" ) -func newPDF(orientation string) *fpdf.Fpdf { +//go:embed assets/a_OldTyper.ttf +var typewriterFont []byte + +//go:embed assets/logo.jpg +var printLogo []byte + +func newPDF(orientation string, isPlayer bool, scenarioName string) *fpdf.Fpdf { pdf := fpdf.New(orientation, "mm", "A4", "") pdf.SetMargins(15, 15, 15) pdf.SetAutoPageBreak(true, 15) - team_qr_pdf.RegisterFont(pdf) - pdf.SetFont("teams", "", 11) - pdf.SetFooterFunc(func() { - pdf.SetY(-12) - pdf.SetFont("teams", "", 8) - pdf.CellFormat(0, 5, fmt.Sprint(pdf.PageNo()), "", 0, "C", false, 0, "") - }) + if isPlayer { + pdf.SetTopMargin(43) + pdf.AddUTF8FontFromBytes("typewriter", "", typewriterFont) + pdf.SetFont("typewriter", "", 11) + pdf.RegisterImageOptionsReader("game-logo", fpdf.ImageOptions{ImageType: "JPG"}, bytes.NewReader(printLogo)) + pdf.SetHeaderFuncMode(func() { + pdf.ImageOptions("game-logo", 15, 15, 18, 0, false, fpdf.ImageOptions{ImageType: "JPG"}, 0, "") + pdf.SetXY(40, 19) + pdf.SetFont("typewriter", "", 16) + pdf.CellFormat(0, 8, "Вечерний детектив", "", 0, "L", false, 0, "") + pdf.SetXY(40, 28) + pdf.SetFont("typewriter", "", 18) + name := printText(pdf, strings.Join(strings.Fields(scenarioName), " ")) + if width := pdf.GetStringWidth(name); width > 153 { + pdf.SetFont("typewriter", "", 18*153/width) + } + pdf.CellFormat(155, 6, name, "", 0, "L", false, 0, "") + pdf.Line(15, 38, 195, 38) + pdf.SetFont("typewriter", "", 11) + }, true) + } else { + team_qr_pdf.RegisterFont(pdf) + pdf.SetFont("teams", "", 11) + } pdf.AddPage() return pdf } @@ -45,10 +69,10 @@ func printText(pdf *fpdf.Fpdf, text string) string { } func heading(pdf *fpdf.Fpdf, text string) { - pdf.SetFont("teams", "", 15) + pdf.SetFont("", "", 15) pdf.MultiCell(0, 7, printText(pdf, text), "", "L", false) pdf.Ln(3) - pdf.SetFont("teams", "", 11) + pdf.SetFont("", "", 11) } func body(pdf *fpdf.Fpdf, text string) { @@ -64,57 +88,126 @@ func pdfBytes(pdf *fpdf.Fpdf) ([]byte, error) { return output.Bytes(), nil } -func textPDF(game, title, text string) ([]byte, error) { - pdf := newPDF("P") +func textPDF(scenarioName, title, text string, isPlayer bool) ([]byte, error) { + pdf := newPDF("P", isPlayer, scenarioName) heading(pdf, title) - body(pdf, game) + if !isPlayer { + body(pdf, scenarioName) + } body(pdf, text) return pdfBytes(pdf) } func rulesPDF(rules string) ([]byte, error) { - pdf := newPDF("P") - for _, paragraph := range strings.Split(rules, "\n\n") { - if strings.HasPrefix(paragraph, "#") { - if pdf.GetY() > 245 { - pdf.AddPage() - } - heading(pdf, strings.TrimSpace(strings.TrimLeft(paragraph, "#"))) - } else { - body(pdf, paragraph) + pdf := newPDF("P", true, "") + pdf.SetAutoPageBreak(false, 0) + type paragraph struct { + text string + isHeading bool + } + var paragraphs []paragraph + for _, raw := range strings.Split(rules, "\n\n") { + isHeading := strings.HasPrefix(raw, "#") + text := raw + if isHeading { + text = strings.TrimSpace(strings.TrimLeft(raw, "#")) } + paragraphs = append(paragraphs, paragraph{ + text: printText(pdf, text), + isHeading: isHeading, + }) + } + if err := pdf.Error(); err != nil { + return nil, err + } + heightAt := func(size float64) float64 { + scale := size / 11 + height := 0.0 + for _, paragraph := range paragraphs { + fontSize, lineHeight := size, 6*scale + if paragraph.isHeading { + fontSize, lineHeight = 15*scale, 7*scale + } + pdf.SetFont("", "", fontSize) + height += float64(len(pdf.SplitText(paragraph.text, 180)))*lineHeight + 3*scale + } + return height + } + size := fitFontSize(heightAt, 277-pdf.GetY()) + scale := size / 11 + for _, paragraph := range paragraphs { + fontSize, lineHeight := size, 6*scale + if paragraph.isHeading { + fontSize, lineHeight = 15*scale, 7*scale + } + pdf.SetFont("", "", fontSize) + for _, line := range pdf.SplitText(paragraph.text, 180) { + pdf.CellFormat(180, lineHeight, line, "", 1, "L", false, 0, "") + } + pdf.Ln(3 * scale) } return pdfBytes(pdf) } -func questionsPDF(game string, questions []*storytelling.Question) ([]byte, error) { - pdf := newPDF("P") +func questionsPDF(scenarioName string, questions []*storytelling.Question) ([]byte, error) { + pdf := newPDF("P", true, scenarioName) + pdf.SetAutoPageBreak(false, 0) heading(pdf, "Вопросы дела") - body(pdf, game) + pdf.Ln(3) body(pdf, "Команда: __________________________________________________") textCleaner := cleaner.NewCleaner() + texts := make([]string, len(questions)) for i, question := range questions { - if pdf.GetY() > 235 { - pdf.AddPage() + texts[i] = printText(pdf, fmt.Sprintf("%d. %s", i+1, textCleaner.ClearText(question.Text))) + } + if err := pdf.Error(); err != nil { + return nil, err + } + size := fitFontSize(func(size float64) float64 { + pdf.SetFont("", "", size) + height := 0.0 + for _, text := range texts { + height += (float64(len(pdf.SplitText(text, 180)))*6 + 37) * size / 11 } - body(pdf, fmt.Sprintf("%d. %s", i+1, textCleaner.ClearText(question.Text))) - if pdf.GetY() > 247 { - pdf.AddPage() + return height + }, 277-pdf.GetY()) + pdf.SetFont("", "", size) + scale := size / 11 + for _, text := range texts { + for _, line := range pdf.SplitText(text, 180) { + pdf.CellFormat(180, 6*scale, line, "", 1, "L", false, 0, "") } + pdf.Ln(3 * scale) for range 4 { - y := pdf.GetY() + 7 + y := pdf.GetY() + 7*scale pdf.Line(15, y, 195, y) pdf.SetY(y) } - pdf.Ln(6) + pdf.Ln(6 * scale) } return pdfBytes(pdf) } -func draftPDF(game, title string) ([]byte, error) { - pdf := newPDF("P") +func fitFontSize(heightAt func(float64) float64, availableHeight float64) float64 { + if heightAt(11) <= availableHeight { + return 11 + } + low, high := 0.0, 11.0 + for range 30 { + middle := (low + high) / 2 + if heightAt(middle) <= availableHeight { + low = middle + } else { + high = middle + } + } + return low +} + +func draftPDF(title string) ([]byte, error) { + pdf := newPDF("P", true, "") heading(pdf, title) - body(pdf, game) + pdf.Ln(3) body(pdf, "Команда: __________________________________________________") pdf.SetDrawColor(180, 180, 180) for y := pdf.GetY() + 8; y < 277; y += 8 { @@ -123,27 +216,25 @@ func draftPDF(game, title string) ([]byte, error) { return pdfBytes(pdf) } -func answersPDF(game string, story *storytelling.Story) ([]byte, error) { - pdf := newPDF("P") - heading(pdf, "Для ведущего: ответы и развязка") - body(pdf, game) +func answersPDF(scenarioName string, questions []*storytelling.Question) ([]byte, error) { + pdf := newPDF("P", false, scenarioName) + heading(pdf, "Для ведущего: ответы") + body(pdf, scenarioName) textCleaner := cleaner.NewCleaner() - for i, question := range story.Questions { + for i, question := range questions { body(pdf, fmt.Sprintf("%d. %s", i+1, textCleaner.ClearText(question.Text))) body(pdf, "Ответ: "+question.Answer) } - if story.Conclusion != nil { - heading(pdf, "Развязка") - body(pdf, story.Conclusion.Text) - } return pdfBytes(pdf) } -func issuancePDF(game string, teams []*repos.Team, clues []*storytelling.Application) ([]byte, error) { - pdf := newPDF("L") +func issuancePDF(scenarioName string, teams []*repos.Team, clues []*storytelling.Application) ([]byte, error) { + pdf := newPDF("L", false, scenarioName) pdf.SetAutoPageBreak(false, 0) const columnsPerPage = 8 - const teamWidth, clueWidth, lineHeight = 51.0, 27.0, 4.0 + const teamWidth, lineHeight = 51.0, 4.0 + pageWidth, _ := pdf.GetPageSize() + textCleaner := cleaner.NewCleaner() if len(clues) == 0 { clues = []*storytelling.Application{{Name: "Улика"}} } @@ -154,16 +245,17 @@ func issuancePDF(game string, teams []*repos.Team, clues []*storytelling.Applica } for start := 0; start < len(clues); start += columnsPerPage { end := min(start+columnsPerPage, len(clues)) + clueWidth := (pageWidth - 30 - teamWidth) / float64(end-start) labels := []string{"Команда / улика"} for _, clue := range clues[start:end] { - labels = append(labels, clue.Name) + labels = append(labels, textCleaner.ClearText(clue.Name)) } for row := 0; row < len(teams); { if start > 0 || row > 0 { pdf.AddPage() } heading(pdf, "Лист выдачи улик") - body(pdf, game) + body(pdf, scenarioName) pdf.SetFont("teams", "", 9) widths := []float64{teamWidth} for range labels[1:] { diff --git a/internal/services/game_print_service/pdf_test.go b/internal/services/game_print_service/pdf_test.go new file mode 100644 index 0000000..86f2c22 --- /dev/null +++ b/internal/services/game_print_service/pdf_test.go @@ -0,0 +1,369 @@ +package game_print_service + +import ( + "bytes" + "compress/zlib" + "errors" + "fmt" + "io" + "os/exec" + "regexp" + "strconv" + "strings" + "testing" + + "evening_detective_server/docs" + "evening_detective_server/internal/modules/storytelling" +) + +func TestRulesFitOnePage(t *testing.T) { + for _, tc := range []struct { + name string + rules string + wantError bool + }{ + {"offline", docs.GameRules("offline"), false}, + {"online", docs.GameRules("online"), false}, + {"long rules", "# Правила\n\n" + strings.Repeat("## Раздел\n\nПроверьте показания свидетелей и сопоставьте время событий.\n\n", 80), false}, + {"empty", "", false}, + {"unsupported text", "# Правила 🕵", true}, + } { + t.Run(tc.name, func(t *testing.T) { + data, err := rulesPDF(tc.rules) + if tc.wantError { + if !errors.Is(err, ErrInvalidPrintText) { + t.Fatalf("error = %v", err) + } + return + } + if err != nil { + t.Fatal(err) + } + if pages := bytes.Count(data, []byte("/Type /Page\n")); pages != 1 { + t.Fatalf("page count = %d", pages) + } + 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 := strings.Join(strings.Fields(extractedPDFText(t, data)), " ") + want := strings.Join(strings.Fields(strings.ReplaceAll(tc.rules, "#", "")), " ") + if !strings.Contains(text, want) { + t.Fatal("rules text or headings were lost") + } + }) + }) + } +} + +func TestScenarioTitleStartsAtEighteenPoints(t *testing.T) { + for _, title := range []string{"Короткий сценарий"} { + t.Run(title, func(t *testing.T) { + pdf := newPDF("P", true, title) + pdf.SetCompression(false) + data, err := pdfBytes(pdf) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(data, []byte("18.00 Tf")) { + t.Fatal("short scenario title did not use eighteen points") + } + }) + } +} + +func TestPDFDoesNotPrintPageNumbers(t *testing.T) { + for _, isPlayer := range []bool{true, false} { + name := "organizer" + if isPlayer { + name = "player" + } + t.Run(name, func(t *testing.T) { + pdf := newPDF("P", isPlayer, "Сценарий") + pdf.SetCompression(false) + body(pdf, "Текст без номеров") + pdf.AddPage() + body(pdf, "Продолжение текста") + data, err := pdfBytes(pdf) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(data, []byte("(\x001) Tj")) || bytes.Contains(data, []byte("(\x002) Tj")) { + t.Fatal("page number printed") + } + 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") + } + if regexp.MustCompile(`(?m)^\s*[12]\s*$`).MatchString(extractedPDFText(t, data)) { + t.Fatal("page number visible in extracted text") + } + }) + }) + } +} + +func decodedPDFStreams(t *testing.T, data []byte) [][]byte { + t.Helper() + streams := regexp.MustCompile(`(?s)\nstream\n(.*?)\nendstream`).FindAllSubmatch(data, -1) + var contents [][]byte + for _, stream := range streams { + reader, err := zlib.NewReader(bytes.NewReader(stream[1])) + if err != nil { + continue + } + content, err := io.ReadAll(reader) + reader.Close() + if err != nil { + t.Fatal(err) + } + contents = append(contents, content) + } + return contents +} + +func drawingLines(t *testing.T, data []byte) [][][]byte { + t.Helper() + pattern := regexp.MustCompile(`([\d.]+) ([\d.]+) m ([\d.]+) ([\d.]+) l S`) + var lines [][][]byte + for _, content := range decodedPDFStreams(t, data) { + lines = append(lines, pattern.FindAllSubmatch(content, -1)...) + } + return lines +} + +func TestIssuanceTableUsesFullWidth(t *testing.T) { + for _, count := range []int{0, 1, 8, 9, 17} { + t.Run(fmt.Sprint(count), func(t *testing.T) { + var clues []*storytelling.Application + for i := range count { + clues = append(clues, &storytelling.Application{Name: fmt.Sprintf("Улика %d", i+1)}) + } + data, err := issuancePDF("Сценарий", nil, clues) + if err != nil { + t.Fatal(err) + } + pattern := regexp.MustCompile(`([\d.]+) ([\d.]+) ([\d.]+) -[\d.]+ re S`) + pages := 0 + for _, content := range decodedPDFStreams(t, data) { + rectangles := pattern.FindAllSubmatch(content, -1) + if len(rectangles) == 0 { + continue + } + pages++ + right := 0.0 + for _, rectangle := range rectangles { + x, err := strconv.ParseFloat(string(rectangle[1]), 64) + if err != nil { + t.Fatal(err) + } + width, err := strconv.ParseFloat(string(rectangle[3]), 64) + if err != nil { + t.Fatal(err) + } + right = max(right, x+width) + } + if right < 799.3 || right > 799.45 { + t.Fatalf("table right edge = %f points, want A4 landscape minus 15 mm", right) + } + } + if pages != max(1, (count+7)/8) { + t.Fatalf("table pages = %d", pages) + } + }) + } +} + +func TestIssuanceClueLabelsMatchPlayers(t *testing.T) { + if _, err := exec.LookPath("pdftotext"); err != nil { + t.Skip("pdftotext is needed only for optional rendered-content checks") + } + for _, name := range []string{"Письмо ([код-1])", " Письмо "} { + t.Run(name, func(t *testing.T) { + clue := &storytelling.Application{Name: name} + data, err := issuancePDF("Сценарий", nil, []*storytelling.Application{clue}) + if err != nil { + t.Fatal(err) + } + text := extractedPDFText(t, data) + if !strings.Contains(text, "Письмо") || strings.Contains(text, "([") || strings.Contains(text, "код-1") { + t.Fatal("clue label was not cleaned") + } + if clue.Name != name { + t.Fatal("original clue name was changed") + } + }) + } +} + +func TestQuestionsAlwaysUseOnePage(t *testing.T) { + for _, tc := range []struct { + name string + count int + text string + }{ + {"empty", 0, ""}, + {"ordinary", 3, "Кто виновен и какие улики это подтверждают?"}, + {"many", 40, "Кто виновен и какие улики это подтверждают?"}, + {"very many", 200, "Проверьте показания свидетелей."}, + {"long paragraphs", 4, strings.Repeat("Сопоставьте показания свидетелей и время событий. ", 200)}, + {"explicit line breaks", 2, strings.Repeat("Проверьте показания.\n", 80)}, + } { + t.Run(tc.name, func(t *testing.T) { + questions := make([]*storytelling.Question, tc.count) + for i := range tc.count { + questions[i] = &storytelling.Question{Text: tc.text} + } + data, err := questionsPDF("Название сценария", questions) + if err != nil { + t.Fatal(err) + } + if pages := bytes.Count(data, []byte("/Type /Page\n")); pages != 1 { + t.Fatalf("page count = %d", pages) + } + lines := drawingLines(t, data) + if len(lines) != 1+4*tc.count { + t.Fatalf("drawing lines = %d, expected header and four lines per question", len(lines)) + } + for _, line := range lines { + y, err := strconv.ParseFloat(string(line[2]), 64) + if err != nil || y < 56.68 { + t.Fatalf("answer line crosses bottom margin: %s", line[0]) + } + } + }) + } +} + +func TestLongScenarioTitleFitsHeader(t *testing.T) { + if _, err := exec.LookPath("pdftotext"); err != nil { + t.Skip("pdftotext is needed only for optional rendered-content checks") + } + for _, title := range []string{"Дело №17 «Тайна поезда»", strings.Repeat("Очень длинное название сценария ", 10)} { + t.Run(title, func(t *testing.T) { + data, err := textPDF(title, "Введение", strings.Repeat("История расследования.\n", 100), true) + if err != nil { + t.Fatal(err) + } + text := extractedPDFText(t, data) + pages := bytes.Count(data, []byte("/Type /Page\n")) + if strings.Count(text, strings.TrimSpace(title)) != pages { + t.Fatalf("title is missing, truncated or duplicated in %d pages", pages) + } + }) + } +} + +func TestPlayerPDFUsesBrandAssets(t *testing.T) { + for _, tc := range []struct { + name string + generate func() ([]byte, error) + }{ + {"rules", func() ([]byte, error) { return rulesPDF(docs.GameRules("offline")) }}, + {"introduction", func() ([]byte, error) { + return textPDF("Дело №1", "Введение", "История расследования", true) + }}, + {"questions", func() ([]byte, error) { + return questionsPDF("Дело №1", []*storytelling.Question{{Text: "Кто виновен?"}}) + }}, + {"draft", func() ([]byte, error) { + return draftPDF("Черновик расследования") + }}, + {"list draft", func() ([]byte, error) { return draftPDF("Список пидарасов") }}, + } { + t.Run(tc.name, func(t *testing.T) { + data, err := tc.generate() + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(data, []byte("/BaseFont /utf8typewriter")) || bytes.Contains(data, []byte("/BaseFont /utf8teams")) { + t.Fatal("player document does not use the frontend font") + } + if !bytes.Contains(data, printLogo) || bytes.Count(data, []byte("/Subtype /Image")) != 1 { + t.Fatal("original logo was not embedded once") + } + }) + } +} + +func TestPlayerHeaderOnEveryPage(t *testing.T) { + for _, count := range []int{1, 3} { + t.Run(strings.Repeat("page ", count), func(t *testing.T) { + pdf := newPDF("P", true, "Дело №1") + pdf.SetCompression(false) + for page := range count { + if page > 0 { + pdf.AddPage() + } + if pdf.GetY() != 43 { + t.Fatalf("content starts at %f mm", pdf.GetY()) + } + body(pdf, "Текст под фирменной шапкой") + } + data, err := pdfBytes(pdf) + if err != nil { + t.Fatal(err) + } + if bytes.Count(data, []byte(" Do Q\n")) != count { + t.Fatal("logo not drawn on every page") + } + }) + } +} + +func TestOrganizerPDFKeepsExistingStyle(t *testing.T) { + for _, tc := range []struct { + name string + generate func() ([]byte, error) + }{ + {"answers", func() ([]byte, error) { return answersPDF("Дело №1", nil) }}, + {"issuance", func() ([]byte, error) { return issuancePDF("Дело №1", nil, nil) }}, + {"external files", func() ([]byte, error) { + return textPDF("Дело №1", "Материалы для ручного скачивания", "https://example.test", false) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + data, err := tc.generate() + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(data, []byte("/BaseFont /utf8teams")) || bytes.Contains(data, []byte("/BaseFont /utf8typewriter")) || bytes.Contains(data, printLogo) { + t.Fatal("organizer document style changed") + } + }) + } +} + +func TestPlayerLongTextKeepsHeadersAndContents(t *testing.T) { + if _, err := exec.LookPath("pdftotext"); err != nil { + t.Skip("pdftotext is needed only for optional rendered-content checks") + } + for _, tc := range []struct { + name string + generate func() ([]byte, error) + end string + isSinglePage bool + }{ + {"introduction", func() ([]byte, error) { + return textPDF("Дело №1", "Введение", strings.Repeat("Следователь изучает улики и проверяет показания свидетелей.\n", 100)+"Конец введения", true) + }, "Конец введения", false}, + {"questions", func() ([]byte, error) { + return questionsPDF("Дело №1", []*storytelling.Question{ + {Text: strings.Repeat("Сопоставьте показания свидетелей и время событий. ", 200)}, + {Text: "Последний вопрос"}, + }) + }, "Последний вопрос", true}, + } { + t.Run(tc.name, func(t *testing.T) { + data, err := tc.generate() + if err != nil { + t.Fatal(err) + } + text := extractedPDFText(t, data) + pages := bytes.Count(data, []byte("/Type /Page\n")) + if (tc.isSinglePage && pages != 1) || (!tc.isSinglePage && pages < 2) || strings.Count(text, "Вечерний детектив") != pages || strings.Count(text, "Дело №1") != pages || !strings.Contains(text, tc.end) { + t.Fatalf("missing headers or content in %d pages", pages) + } + }) + } +} diff --git a/internal/services/game_print_service/service.go b/internal/services/game_print_service/service.go index d5dc9fc..e957fad 100644 --- a/internal/services/game_print_service/service.go +++ b/internal/services/game_print_service/service.go @@ -128,12 +128,12 @@ func (s *GamePrintService) Download(ctx context.Context, gameID int) ([]byte, st generate func() ([]byte, error) }{ {playerFolder + "Правила.pdf", func() ([]byte, error) { return rulesPDF(docs.GameRules(game.Format)) }}, - {playerFolder + "Введение.pdf", func() ([]byte, error) { return textPDF(game.Name, "Введение", intro) }}, - {playerFolder + "Вопросы.pdf", func() ([]byte, error) { return questionsPDF(game.Name, story.Questions) }}, - {playerFolder + "Список пидарасов.pdf", func() ([]byte, error) { return draftPDF(game.Name, "Список пидарасов") }}, - {playerFolder + "Черновик расследования.pdf", func() ([]byte, error) { return draftPDF(game.Name, "Черновик расследования") }}, - {hostFolder + "Ответы и развязка.pdf", func() ([]byte, error) { return answersPDF(game.Name, story) }}, - {hostFolder + "Лист выдачи улик.pdf", func() ([]byte, error) { return issuancePDF(game.Name, teams, clues) }}, + {playerFolder + "Введение.pdf", func() ([]byte, error) { return textPDF(scenario.Name, "Введение", intro, true) }}, + {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("Черновик расследования") }}, + {hostFolder + "Ответы.pdf", func() ([]byte, error) { return answersPDF(scenario.Name, story.Questions) }}, + {hostFolder + "Лист выдачи улик.pdf", func() ([]byte, error) { return issuancePDF(scenario.Name, teams, clues) }}, } for _, document := range documents { if err := ctx.Err(); err != nil { @@ -183,7 +183,7 @@ func (s *GamePrintService) Download(ctx context.Context, gameID int) ([]byte, st } } if len(external) > 0 { - data, err := textPDF(game.Name, "Материалы для ручного скачивания", strings.Join(external, "\n\n")) + data, err := textPDF(scenario.Name, "Материалы для ручного скачивания", strings.Join(external, "\n\n"), false) if err != nil { return nil, "", err } diff --git a/internal/services/game_print_service/service_test.go b/internal/services/game_print_service/service_test.go index 705aeee..bcd618b 100644 --- a/internal/services/game_print_service/service_test.go +++ b/internal/services/game_print_service/service_test.go @@ -22,15 +22,16 @@ import ( type printFixture struct { file_storage.IFileStorage - game *repos.Game - story *storytelling.Story - teams []*repos.Team - files map[string][]byte - gameErr error - scenarioErr error - teamsErr error - fileErr error - qrCalls int + game *repos.Game + scenarioName string + story *storytelling.Story + teams []*repos.Team + files map[string][]byte + gameErr error + scenarioErr error + teamsErr error + fileErr error + qrCalls int } func (f *printFixture) GetGameByID(context.Context, int) (*repos.Game, error) { @@ -38,7 +39,7 @@ func (f *printFixture) GetGameByID(context.Context, int) (*repos.Game, error) { } func (f *printFixture) GetFullScenarioByID(context.Context, int) (*scenarios_service.Scenario, error) { - return &scenarios_service.Scenario{Story: f.story}, f.scenarioErr + return &scenarios_service.Scenario{Name: f.scenarioName, Story: f.story}, f.scenarioErr } func (f *printFixture) GetActiveTeamsByGameID(context.Context, int) ([]*repos.Team, error) { @@ -73,7 +74,8 @@ func (f *printFixture) Get(ctx context.Context, name string) (*file_storage.File func newPrintFixture() *printFixture { clue := &storytelling.Application{Name: "Письмо / улика", Image: "https://files.test/clue.pdf"} return &printFixture{ - game: &repos.Game{ID: 1, Name: "Дело №1", Format: "offline", ScenarioID: 2}, + game: &repos.Game{ID: 1, Name: "Дело №1", Format: "offline", ScenarioID: 2}, + scenarioName: "Сценарий №2", story: &storytelling.Story{ Introduction: &storytelling.Introduction{Text: "Начало расследования"}, Questions: []*storytelling.Question{{Text: "Кто виновен?", Answer: "СЕКРЕТНЫЙ ОТВЕТ"}}, @@ -148,7 +150,7 @@ func TestDownloadPrintMaterials(t *testing.T) { for _, name := range []string{ playerFolder + "Правила.pdf", playerFolder + "Введение.pdf", playerFolder + "Вопросы.pdf", playerFolder + "Список пидарасов.pdf", playerFolder + "Черновик расследования.pdf", - hostFolder + "Ответы и развязка.pdf", hostFolder + "Лист выдачи улик.pdf", + hostFolder + "Ответы.pdf", hostFolder + "Лист выдачи улик.pdf", } { if !bytes.HasPrefix(entries[name], []byte("%PDF-")) { t.Errorf("missing PDF %s", name) @@ -190,7 +192,7 @@ func TestDownloadPrintErrors(t *testing.T) { {"team read error", func(f *printFixture) { f.teamsErr = broken }, broken}, {"missing clue", func(f *printFixture) { delete(f.files, "clue.pdf") }, file_storage.ErrFileNotFound}, {"file read error", func(f *printFixture) { f.fileErr = broken }, broken}, - {"unsupported text", func(f *printFixture) { f.game.Name = "Дело 🕵️" }, ErrInvalidPrintText}, + {"unsupported text", func(f *printFixture) { f.scenarioName = "Дело 🕵️" }, ErrInvalidPrintText}, } { t.Run(tc.name, func(t *testing.T) { f := newPrintFixture() @@ -261,8 +263,11 @@ func TestPrintedContents(t *testing.T) { if strings.HasPrefix(name, playerFolder) && strings.Contains(text, "СЕКРЕТ") { t.Fatalf("organizer secrets in %s", name) } - if strings.Contains(name, "Ответы и развязка") && (!strings.Contains(text, "СЕКРЕТНЫЙ ОТВЕТ") || !strings.Contains(text, "СЕКРЕТНАЯ РАЗВЯЗКА")) { - t.Fatal("missing organizer secrets") + if strings.Contains(text, "СЕКРЕТНАЯ РАЗВЯЗКА") { + t.Fatalf("conclusion included in %s", name) + } + if name == hostFolder+"Ответы.pdf" && !strings.Contains(text, "СЕКРЕТНЫЙ ОТВЕТ") { + t.Fatal("missing organizer answers") } if strings.Contains(name, "QR команд") || strings.Contains(name, "Лист выдачи") { if strings.Contains(text, "Отменённая") || !strings.Contains(text, "Детективы") || !strings.Contains(text, "Ожидающая") { @@ -283,6 +288,49 @@ func TestPrintedContents(t *testing.T) { } } +func TestPrintArchiveUsesScenarioTitle(t *testing.T) { + if _, err := exec.LookPath("pdftotext"); err != nil { + t.Skip("pdftotext is needed only for optional rendered-content checks") + } + for _, gameName := range []string{"Название игры не для печати", "Игра с эмодзи 🕵"} { + t.Run(gameName, func(t *testing.T) { + f := newPrintFixture() + f.game.Name = gameName + f.scenarioName = "Настоящий сценарий" + f.story.Places[0].Applications = append(f.story.Places[0].Applications, + &storytelling.Application{Name: "Внешняя улика", Image: "https://external.test/clue.pdf"}) + data, _, err := f.service().Download(context.Background(), 1) + if err != nil { + t.Fatal(err) + } + for name, data := range readPrintArchive(t, data) { + if !strings.HasSuffix(name, ".pdf") || strings.Contains(name, "Улики/") { + continue + } + text := extractedPDFText(t, data) + if strings.Contains(text, gameName) { + t.Fatalf("game title printed in %s", name) + } + if strings.Contains(name, "QR команд") { + continue + } + if name == playerFolder+"Правила.pdf" || name == playerFolder+"Черновик расследования.pdf" || name == playerFolder+"Список пидарасов.pdf" { + if strings.Contains(text, f.scenarioName) { + t.Fatalf("scenario title printed in %s", name) + } + continue + } + if !strings.Contains(text, f.scenarioName) { + t.Fatalf("scenario title missing in %s", name) + } + if strings.HasPrefix(name, playerFolder) && strings.Count(text, f.scenarioName) != bytes.Count(data, []byte("/Type /Page\n")) { + t.Fatalf("scenario title not repeated in header or duplicated in body in %s", name) + } + } + }) + } +} + func TestPrintCluesPreserveOriginalFiles(t *testing.T) { for _, extension := range []string{"pdf", "png", "webp", "mp3"} { t.Run(extension, func(t *testing.T) {