clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

main
puzzletea / export / pdf / render_title.go
render_title.go 5.8K
1package pdfexport23import (4 "fmt"5 "sort"6 "strings"7 "time"89 "codeberg.org/go-pdf/fpdf"10)1112func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg RenderConfig) {13 pageW, pageH := pdf.GetPageSize()14 margin := 12.015 contentWidth := pageW - 2*margin16 categoryTotals := summarizeCategoryTotals(puzzles)1718 pdf.SetTextColor(20, 20, 20)19 pdf.SetFont(sansFontFamily, "B", 22+pdfFontSizeDelta)20 pdf.SetXY(0, 24)21 pdf.CellFormat(pageW, 10, fmt.Sprintf("PuzzleTea Volume %02d", cfg.VolumeNumber), "", 0, "C", false, 0, "")2223 pdf.SetTextColor(50, 50, 50)24 pdf.SetFont(coverFontFamily, "", 16+pdfFontSizeDelta)25 pdf.SetXY(0, 35)26 pdf.CellFormat(pageW, 8, cfg.CoverSubtitle, "", 0, "C", false, 0, "")2728 bodyY := 49.029 if header := strings.TrimSpace(cfg.HeaderText); header != "" {30 pdf.SetFont(sansFontFamily, "", 9.2+pdfFontSizeDelta)31 pdf.SetTextColor(74, 74, 74)32 wrappedHeader := pdf.SplitLines([]byte(header), contentWidth-20)33 if len(wrappedHeader) == 0 {34 wrappedHeader = [][]byte{[]byte(header)}35 }36 for _, line := range wrappedHeader {37 pdf.SetXY(margin, bodyY)38 pdf.CellFormat(contentWidth, 4.6, string(line), "", 0, "C", false, 0, "")39 bodyY += 4.640 }41 bodyY += 2.042 }4344 introLines := splitCoverTextLines(pdf, cfg.AdvertText, contentWidth)45 if len(introLines) > 0 {46 pdf.SetTextColor(58, 58, 58)47 pdf.SetFont(sansFontFamily, "", 9.3+pdfFontSizeDelta)48 for _, line := range introLines {49 pdf.SetXY(margin, bodyY)50 pdf.CellFormat(contentWidth, 4.8, line, "", 0, "C", false, 0, "")51 bodyY += 4.852 }53 bodyY += 3.854 }5556 pdf.SetTextColor(40, 40, 40)57 pdf.SetFont(sansFontFamily, "B", 9.6+pdfFontSizeDelta)58 pdf.SetXY(margin, bodyY)59 pdf.CellFormat(contentWidth, 5.4, fmt.Sprintf("%d puzzles across %d categories", len(puzzles), len(categoryTotals)), "", 0, "C", false, 0, "")60 bodyY += 7.66162 pdf.SetFont(sansFontFamily, "B", 10+pdfFontSizeDelta)63 pdf.SetTextColor(45, 45, 45)64 pdf.SetXY(margin, bodyY)65 pdf.CellFormat(contentWidth, 6, "Inside This Volume", "", 0, "C", false, 0, "")66 bodyY += 7.06768 renderCategoryOverview(pdf, categoryTotals, margin, bodyY, contentWidth, pageH-32)6970 pdf.SetTextColor(50, 50, 50)71 pdf.SetFont(sansFontFamily, "B", 12+pdfFontSizeDelta)72 footerTitleY := pageH - 31.073 pdf.SetXY(margin, footerTitleY)74 pdf.CellFormat(contentWidth, 7, "Made with PuzzleTea", "", 0, "C", false, 0, "")7576 colophon := titlePageColophon(docs, cfg.GeneratedAt)77 if colophon != "" {78 pdf.SetFont(sansFontFamily, "", 7.8+pdfFontSizeDelta)79 pdf.SetTextColor(112, 112, 112)80 pdf.SetXY(margin, footerTitleY+8.0)81 pdf.CellFormat(contentWidth, 4.0, colophon, "", 0, "C", false, 0, "")82 }83}8485func summarizeVersions(docs []PackDocument) []string {86 set := map[string]struct{}{}87 for _, doc := range docs {88 version := strings.TrimSpace(doc.Metadata.Version)89 if version == "" {90 continue91 }92 set[version] = struct{}{}93 }9495 versions := make([]string, 0, len(set))96 for version := range set {97 versions = append(versions, version)98 }99 sort.Strings(versions)100 if len(versions) == 0 {101 return []string{"Unknown"}102 }103 return versions104}105106func defaultTitle(docs []PackDocument) string {107 if len(docs) == 1 {108 category := strings.TrimSpace(docs[0].Metadata.Category)109 if category != "" {110 return fmt.Sprintf("%s Puzzle Pack", category)111 }112 }113 return "PuzzleTea Mixed Puzzle Pack"114}115116type namedCount struct {117 Name string118 Count int119}120121func summarizeCategoryTotals(puzzles []Puzzle) []namedCount {122 m := make(map[string]*namedCount)123 for _, puzzle := range puzzles {124 category := strings.TrimSpace(puzzle.Category)125 if category == "" {126 category = "Unknown"127 }128 key := normalizeToken(category)129 if entry, ok := m[key]; ok {130 entry.Count++131 continue132 }133 m[key] = &namedCount{Name: category, Count: 1}134 }135 return sortedNamedCounts(m)136}137138func sortedNamedCounts(m map[string]*namedCount) []namedCount {139 counts := make([]namedCount, 0, len(m))140 for _, entry := range m {141 counts = append(counts, *entry)142 }143 sort.SliceStable(counts, func(i, j int) bool {144 return strings.Compare(normalizeToken(counts[i].Name), normalizeToken(counts[j].Name)) < 0145 })146 return counts147}148149func renderCategoryOverview(150 pdf *fpdf.Fpdf,151 categoryTotals []namedCount,152 x, y, width, maxY float64,153) float64 {154 const (155 lineHeight = 4.8156 categoryGap = 1.2157 columnGap = 6.0158 maxWidth = 96.0159 widthScale = 0.82160 )161162 if width <= 0 || y >= maxY || len(categoryTotals) == 0 {163 return y164 }165166 pdf.SetTextColor(ruleTextGray, ruleTextGray, ruleTextGray)167 pdf.SetFont(sansFontFamily, "", 9.0+pdfFontSizeDelta)168169 containerWidth := min(width, min(maxWidth, width*widthScale))170 containerX := x + (width-containerWidth)/2171 colWidth := (containerWidth - columnGap) / 2172 leftCount := (len(categoryTotals) + 1) / 2173 maxRows := max(leftCount, len(categoryTotals)-leftCount)174 curY := y175 for row := 0; row < maxRows; row++ {176 rowY := curY + float64(row)*(lineHeight+categoryGap)177 if rowY+lineHeight > maxY {178 return rowY179 }180 if row < leftCount {181 pdf.SetXY(containerX, rowY)182 pdf.CellFormat(colWidth, lineHeight, formatCountLabel(categoryTotals[row]), "", 0, "R", false, 0, "")183 }184 rightIndex := leftCount + row185 if rightIndex < len(categoryTotals) {186 pdf.SetXY(containerX+colWidth+columnGap, rowY)187 pdf.CellFormat(colWidth, lineHeight, formatCountLabel(categoryTotals[rightIndex]), "", 0, "L", false, 0, "")188 }189 }190191 return curY + float64(maxRows)*(lineHeight+categoryGap)192}193194func formatCountLabel(item namedCount) string {195 return fmt.Sprintf("%s x%d", item.Name, item.Count)196}197198func titlePageColophon(docs []PackDocument, generatedAt time.Time) string {199 parts := []string{}200 versions := summarizeVersions(docs)201 if len(versions) > 0 && versions[0] != "Unknown" {202 parts = append(parts, fmt.Sprintf("PuzzleTea %s", strings.Join(versions, ", ")))203 }204205 if !generatedAt.IsZero() {206 parts = append(parts, generatedAt.Format("January 2, 2006"))207 }208209 if len(parts) == 0 {210 return ""211 }212 return strings.Join(parts, " | ")213}