325 lines
7.9 KiB
Go
325 lines
7.9 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"log/slog"
|
|
"math"
|
|
"math/rand"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
|
|
"github.com/charmbracelet/huh"
|
|
"github.com/disintegration/imaging"
|
|
"github.com/go-pdf/fpdf"
|
|
)
|
|
|
|
const (
|
|
imgDir = "./img"
|
|
cardWidth = 55.0
|
|
cardHeight = 85.0
|
|
margin = 5.0
|
|
dpiScale = 3.779528 // 96 DPI
|
|
outputFileName = "dobble_cards.pdf"
|
|
minScaleFactor = 0.7
|
|
maxScaleFactor = 1.0
|
|
)
|
|
|
|
type CardGenerator struct {
|
|
TotalCards int
|
|
ImagesPerCard int
|
|
ImageFiles []string
|
|
RoundCards bool
|
|
}
|
|
|
|
func main() {
|
|
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
|
slog.SetDefault(logger)
|
|
|
|
cg, err := getInputAndInitialize()
|
|
if err != nil {
|
|
logger.Error("Initialization failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
cards := cg.generateCards()
|
|
logger.Info("Cards generated", "count", len(cards))
|
|
|
|
if err := generatePDF(cards, cg.RoundCards); err != nil {
|
|
logger.Error("PDF generation failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
logger.Info("PDF successfully generated")
|
|
}
|
|
|
|
func getInputAndInitialize() (*CardGenerator, error) {
|
|
var totalCardsStr, imagesPerCardStr string
|
|
var roundCards bool
|
|
|
|
form := huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewInput().Title("Enter the total number of cards:").Value(&totalCardsStr),
|
|
huh.NewInput().Title("Enter the number of images per card:").Value(&imagesPerCardStr),
|
|
huh.NewConfirm().
|
|
Title("Do you want round cards?").
|
|
Value(&roundCards),
|
|
),
|
|
)
|
|
|
|
if err := form.Run(); err != nil {
|
|
return nil, fmt.Errorf("form input failed: %w", err)
|
|
}
|
|
|
|
totalCards, err1 := strconv.Atoi(totalCardsStr)
|
|
imagesPerCard, err2 := strconv.Atoi(imagesPerCardStr)
|
|
if err := errors.Join(err1, err2); err != nil {
|
|
return nil, fmt.Errorf("invalid input: %w", err)
|
|
}
|
|
|
|
cg := &CardGenerator{
|
|
TotalCards: totalCards,
|
|
ImagesPerCard: imagesPerCard,
|
|
RoundCards: roundCards,
|
|
}
|
|
|
|
if err := cg.loadImageFiles(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return cg, nil
|
|
}
|
|
|
|
func (cg *CardGenerator) generateCards() [][]string {
|
|
n := cg.ImagesPerCard - 1
|
|
totalCards := n*n + n + 1
|
|
|
|
if len(cg.ImageFiles) < totalCards {
|
|
slog.Error("Not enough images for the given parameters",
|
|
"required", totalCards,
|
|
"available", len(cg.ImageFiles))
|
|
return nil
|
|
}
|
|
|
|
cards := cg.generateCardIndices(n)
|
|
imageCards := cg.convertToImageCards(cards)
|
|
cg.shuffleCards(imageCards)
|
|
|
|
return cg.limitCards(imageCards)
|
|
}
|
|
|
|
func (cg *CardGenerator) generateCardIndices(n int) [][]int {
|
|
cards := make([][]int, 0, n*n+n+1)
|
|
|
|
for i := 0; i < n+1; i++ {
|
|
card := make([]int, cg.ImagesPerCard)
|
|
card[0] = 1
|
|
for j := 0; j < n; j++ {
|
|
card[j+1] = (j + 1) + (i * n) + 1
|
|
}
|
|
cards = append(cards, card)
|
|
}
|
|
|
|
for i := 0; i < n; i++ {
|
|
for j := 0; j < n; j++ {
|
|
card := make([]int, cg.ImagesPerCard)
|
|
card[0] = i + 2
|
|
for k := 0; k < n; k++ {
|
|
card[k+1] = (n + 1 + n*k + (i*k+j)%n) + 1
|
|
}
|
|
cards = append(cards, card)
|
|
}
|
|
}
|
|
|
|
return cards
|
|
}
|
|
|
|
func (cg *CardGenerator) convertToImageCards(cards [][]int) [][]string {
|
|
imageCards := make([][]string, len(cards))
|
|
for i, card := range cards {
|
|
imageCards[i] = make([]string, len(card))
|
|
for j, symbolIndex := range card {
|
|
imageCards[i][j] = cg.ImageFiles[symbolIndex-1]
|
|
}
|
|
}
|
|
return imageCards
|
|
}
|
|
|
|
func (cg *CardGenerator) shuffleCards(cards [][]string) {
|
|
rand.Shuffle(len(cards), func(i, j int) {
|
|
cards[i], cards[j] = cards[j], cards[i]
|
|
})
|
|
|
|
for i := range cards {
|
|
rand.Shuffle(len(cards[i]), func(j, k int) {
|
|
cards[i][j], cards[i][k] = cards[i][k], cards[i][j]
|
|
})
|
|
}
|
|
}
|
|
|
|
func (cg *CardGenerator) limitCards(cards [][]string) [][]string {
|
|
if cg.TotalCards < len(cards) {
|
|
return cards[:cg.TotalCards]
|
|
}
|
|
return cards
|
|
}
|
|
|
|
func (cg *CardGenerator) loadImageFiles() error {
|
|
files, err := os.ReadDir(imgDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read image directory: %w", err)
|
|
}
|
|
|
|
for _, file := range files {
|
|
if !file.IsDir() && filepath.Ext(file.Name()) == ".png" {
|
|
cg.ImageFiles = append(cg.ImageFiles, filepath.Join(imgDir, file.Name()))
|
|
}
|
|
}
|
|
|
|
requiredImages := cg.calculateRequiredImages()
|
|
|
|
if len(cg.ImageFiles) < requiredImages {
|
|
return fmt.Errorf("not enough images in the img folder: required %d, found %d", requiredImages, len(cg.ImageFiles))
|
|
}
|
|
|
|
rand.Shuffle(len(cg.ImageFiles), func(i, j int) {
|
|
cg.ImageFiles[i], cg.ImageFiles[j] = cg.ImageFiles[j], cg.ImageFiles[i]
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
func (cg *CardGenerator) calculateRequiredImages() int {
|
|
n := cg.ImagesPerCard - 1
|
|
return n*n + n + 1
|
|
}
|
|
|
|
func generatePDF(cards [][]string, roundCards bool) error {
|
|
pdf := fpdf.New("P", "mm", "A4", "")
|
|
pdf.SetAutoPageBreak(true, 10)
|
|
|
|
pageWidth, pageHeight, _ := pdf.PageSize(1)
|
|
cardSize := math.Min(cardWidth, cardHeight)
|
|
cardsPerRow := int((pageWidth - 2*margin) / (cardSize + margin))
|
|
cardsPerCol := int((pageHeight - 2*margin) / (cardSize + margin))
|
|
cardsPerPage := cardsPerRow * cardsPerCol
|
|
|
|
for i, card := range cards {
|
|
if i%cardsPerPage == 0 {
|
|
pdf.AddPage()
|
|
}
|
|
|
|
col := i % cardsPerRow
|
|
row := (i / cardsPerRow) % cardsPerCol
|
|
|
|
x := margin + float64(col)*(cardSize+margin)
|
|
y := margin + float64(row)*(cardSize+margin)
|
|
|
|
slog.Info("Processing card", "index", i, "x", x, "y", y)
|
|
|
|
if roundCards {
|
|
if err := processRoundCard(pdf, x, y, card); err != nil {
|
|
return fmt.Errorf("failed to process round card %d: %w", i, err)
|
|
}
|
|
} else {
|
|
if err := processSquareCard(pdf, x, y, card); err != nil {
|
|
return fmt.Errorf("failed to process square card %d: %w", i, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return pdf.OutputFileAndClose(outputFileName)
|
|
}
|
|
|
|
func processRoundCard(pdf *fpdf.Fpdf, x, y float64, card []string) error {
|
|
diameter := math.Min(cardWidth, cardHeight)
|
|
radius := diameter / 2
|
|
|
|
pdf.SetDrawColor(0, 0, 0)
|
|
pdf.Circle(x+radius, y+radius, radius, "D")
|
|
|
|
availableRadius := radius - 5
|
|
optimalImageSize := availableRadius * 2 / math.Sqrt(float64(len(card)))
|
|
|
|
for i, imgFile := range card {
|
|
angle := 2 * math.Pi * float64(i) / float64(len(card))
|
|
distanceFromCenter := availableRadius * 0.6
|
|
|
|
imgX := x + radius + distanceFromCenter*math.Cos(angle) - optimalImageSize/2
|
|
imgY := y + radius + distanceFromCenter*math.Sin(angle) - optimalImageSize/2
|
|
|
|
if err := processImage(pdf, imgFile, imgX, imgY, optimalImageSize); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func processSquareCard(pdf *fpdf.Fpdf, x, y float64, card []string) error {
|
|
pdf.Rect(x, y, cardWidth, cardHeight, "D")
|
|
|
|
availableWidth := cardWidth - 10
|
|
availableHeight := cardHeight - 10
|
|
optimalImageSize := math.Min(availableWidth/2, availableHeight/float64(len(card)))
|
|
|
|
for i, imgFile := range card {
|
|
imgX := x + 5 + rand.Float64()*(availableWidth-optimalImageSize)
|
|
imgY := y + 5 + float64(i)*(availableHeight/float64(len(card))) + rand.Float64()*(availableHeight/float64(len(card))-optimalImageSize)
|
|
|
|
if err := processImage(pdf, imgFile, imgX, imgY, optimalImageSize); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func processImage(pdf *fpdf.Fpdf, imgFile string, x, y, size float64) error {
|
|
file, err := os.Open(imgFile)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open image file: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
img, _, err := image.Decode(file)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to decode image: %w", err)
|
|
}
|
|
|
|
scaleFactor := minScaleFactor + rand.Float64()*(maxScaleFactor-minScaleFactor)
|
|
imgSize := size * scaleFactor
|
|
targetSize := uint(imgSize * dpiScale)
|
|
|
|
img = imaging.Fit(img, int(targetSize), int(targetSize), imaging.Lanczos)
|
|
|
|
rotation := rand.Intn(4) * 90
|
|
rotatedImg := imaging.Rotate(img, float64(rotation), color.Transparent)
|
|
|
|
tmpFile, err := os.CreateTemp("", "processed_*.png")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create temp file: %w", err)
|
|
}
|
|
defer os.Remove(tmpFile.Name())
|
|
|
|
if err := png.Encode(tmpFile, rotatedImg); err != nil {
|
|
return fmt.Errorf("failed to encode processed image: %w", err)
|
|
}
|
|
tmpFile.Close()
|
|
|
|
pdf.ImageOptions(
|
|
tmpFile.Name(),
|
|
x, y,
|
|
imgSize, imgSize,
|
|
false,
|
|
fpdf.ImageOptions{ImageType: "PNG"},
|
|
0,
|
|
"",
|
|
)
|
|
|
|
return nil
|
|
}
|