badges/cmd/logogen/main.go

209 lines
6.4 KiB
Go
Raw Normal View History

2026-04-18 10:19:58 +00:00
// logogen converts an image to a .bin file suitable for embedding in badge firmware.
//
// Usage:
//
// logogen -size <target|WxH> -image <path|shortname> [-output logo.bin] [-images-dir images] [-cache-dir cache]
//
// Examples:
//
// logogen -size nicebadge -image tinygo
// logogen -size gopherbadge -image gopherconeu
// logogen -size 320x240 -image /path/to/logo.jpg
// logogen -size gobadge -image ./my-logo.png -output logo.bin
//
// Short names resolve to image files inside -images-dir.
// If a predefined resized variant exists (e.g. tinygo_320x240.png), it is used directly.
// Generated .bin files are cached in -cache-dir to avoid reprocessing.
package main
import (
"flag"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"log"
"os"
"path/filepath"
"strconv"
"strings"
draw2 "golang.org/x/image/draw"
)
type resolution struct {
width, height int
}
func (r resolution) String() string {
return fmt.Sprintf("%dx%d", r.width, r.height)
}
var predefinedTargets = map[string]resolution{
"nicebadge": {240, 135},
"gopherbadge": {320, 240},
"gobadge": {160, 128},
"pybadge": {160, 128},
}
func main() {
size := flag.String("size", "", "Resolution: WxH or target name (nicebadge, gopherbadge, gobadge, pybadge)")
imageArg := flag.String("image", "", "Image: file path (relative/absolute) or short name (tinygo, gopherconeu, golab, ...)")
output := flag.String("output", "logo.bin", "Output .bin file path")
imagesDir := flag.String("images-dir", "images", "Directory with source images and predefined variants")
cacheDir := flag.String("cache-dir", "cache", "Directory for cached .bin files")
flag.Parse()
if *size == "" {
flag.Usage()
log.Fatal("\nMust specify -size (e.g. -size nicebadge or -size 320x240)")
}
if *imageArg == "" {
flag.Usage()
log.Fatal("\nMust specify -image (file path or short name)")
}
res := parseSize(*size)
// Determine whether -image is a file path or a short name.
// Heuristic: contains path separator, starts with '.' or '..' , or has a file extension → path.
isPath := strings.ContainsAny(*imageArg, "/\\") ||
strings.HasPrefix(*imageArg, ".") ||
filepath.Ext(*imageArg) != ""
var baseName string // used as cache/predefined key
var srcPath string // resolved source image file
if isPath {
absPath, err := filepath.Abs(*imageArg)
if err != nil {
log.Fatalf("invalid image path %q: %v", *imageArg, err)
}
srcPath = absPath
base := filepath.Base(absPath)
baseName = strings.TrimSuffix(base, filepath.Ext(base))
} else {
baseName = *imageArg
srcPath = findSourceImage(*imageArg, res, *imagesDir)
}
cacheFile := filepath.Join(*cacheDir, fmt.Sprintf("%s_%s.bin", baseName, res))
// 1. Check cache.
if data, err := os.ReadFile(cacheFile); err == nil {
fmt.Printf("Using cached: %s\n", cacheFile)
writeOutput(*output, data)
fmt.Printf("Written: %s (%s)\n", *output, res)
return
}
// 2. Load, resize if needed, convert.
img := loadAndResize(srcPath, res)
binData := convertToBin(img, res)
// 3. Save cache.
if err := os.MkdirAll(*cacheDir, 0755); err != nil {
log.Printf("Warning: cannot create cache dir %s: %v", *cacheDir, err)
} else if err := os.WriteFile(cacheFile, binData, 0644); err != nil {
log.Printf("Warning: cannot save cache %s: %v", cacheFile, err)
} else {
fmt.Printf("Cached: %s\n", cacheFile)
}
writeOutput(*output, binData)
fmt.Printf("Generated: %s (%s, %d bytes)\n", *output, res, len(binData))
}
// parseSize accepts either a target name (nicebadge, gopherbadge, ...) or WxH format.
func parseSize(s string) resolution {
if res, ok := predefinedTargets[strings.ToLower(s)]; ok {
return res
}
parts := strings.SplitN(strings.ToLower(s), "x", 2)
if len(parts) != 2 {
log.Fatalf("invalid size %q: must be WxH (e.g. 320x240) or a target name (nicebadge, gopherbadge, gobadge, pybadge)", s)
}
w, err1 := strconv.Atoi(parts[0])
h, err2 := strconv.Atoi(parts[1])
if err1 != nil || err2 != nil || w <= 0 || h <= 0 {
log.Fatalf("invalid size %q: width and height must be positive integers", s)
}
return resolution{w, h}
}
// findSourceImage looks for the best source image for a short name in imagesDir.
// Priority:
// 1. <name>_<WxH>.png / .jpg — predefined resized variant (no resize needed)
// 2. <name>.png / .jpg / .jpeg — base source image (will be resized)
func findSourceImage(name string, res resolution, imagesDir string) string {
resStr := res.String()
extensions := []string{".png", ".jpg", ".jpeg"}
// Try predefined resized variant first.
for _, ext := range extensions {
p := filepath.Join(imagesDir, fmt.Sprintf("%s_%s%s", name, resStr, ext))
if _, err := os.Stat(p); err == nil {
fmt.Printf("Using predefined: %s\n", p)
return p
}
}
// Try base source image.
for _, ext := range extensions {
p := filepath.Join(imagesDir, name+ext)
if _, err := os.Stat(p); err == nil {
return p
}
}
log.Fatalf("image not found for short name %q in directory %q\n"+
"Expected one of: %s_%s.png, %s.png, %s.jpg, ...",
name, imagesDir, name, resStr, name, name)
return ""
}
// loadAndResize opens a JPEG or PNG and scales it to the target resolution.
// If the image is already the exact target size, scaling is skipped.
func loadAndResize(path string, res resolution) image.Image {
f, err := os.Open(path)
if err != nil {
log.Fatalf("cannot open image %s: %v", path, err)
}
defer f.Close()
img, format, err := image.Decode(f)
if err != nil {
log.Fatalf("cannot decode image %s: %v", path, err)
}
fmt.Printf("Loaded: %s (%s, %dx%d)\n", path, format, img.Bounds().Dx(), img.Bounds().Dy())
if img.Bounds().Dx() == res.width && img.Bounds().Dy() == res.height {
return img
}
fmt.Printf("Resizing to %s...\n", res)
dst := image.NewRGBA(image.Rect(0, 0, res.width, res.height))
draw2.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), draw2.Over, nil)
return dst
}
// convertToBin encodes the image pixels as a hex string: "RRGGBB" per pixel, row-major.
// This matches the format expected by showLogoBin() in badge.go.
func convertToBin(img image.Image, res resolution) []byte {
var sb strings.Builder
sb.Grow(res.width * res.height * 6)
for y := 0; y < res.height; y++ {
for x := 0; x < res.width; x++ {
r, g, b, _ := img.At(x, y).RGBA()
fmt.Fprintf(&sb, "%02x%02x%02x", uint8(r>>8), uint8(g>>8), uint8(b>>8))
}
}
return []byte(sb.String())
}
func writeOutput(path string, data []byte) {
if err := os.WriteFile(path, data, 0644); err != nil {
log.Fatalf("cannot write output %s: %v", path, err)
}
}