// Package main provides a sprite generator tool for the CV website. // It processes PNG images from source directories, normalizes them to standard // icon sizes (80x80 for 1x, 160x160 for 2x), and combines them into horizontal // sprite sheets. package main import ( "encoding/json" "fmt" "image" "image/color" "image/png" "os" "path/filepath" "sort" "strings" "golang.org/x/image/draw" "golang.org/x/text/cases" "golang.org/x/text/language" ) // SpriteCategory defines a category of icons to process type SpriteCategory struct { Name string // Category name (companies, projects, courses) SourceDir string // Source directory for images OutputName string // Output sprite filename (without extension) Icons []string // List of icon filenames (populated during processing) } // SpriteMapEntry represents a single icon in the sprite map type SpriteMapEntry struct { Index int `json:"index"` Name string `json:"name"` } // SpriteMap represents the complete mapping of icons to positions type SpriteMap struct { Companies []SpriteMapEntry `json:"companies"` Projects []SpriteMapEntry `json:"projects"` Courses []SpriteMapEntry `json:"courses"` } // ShowcaseIcon represents an icon for the showcase page type ShowcaseIcon struct { Index int Name string } // ShowcaseCategory represents a category for the showcase page type ShowcaseCategory struct { Name string CSSClass string SpriteFile string Icons []ShowcaseIcon } const ( baseIconSize = 60 // Base icon size (1x) - fits within 80px box with 10px padding retinaIconSize = 120 // Retina icon size (2x) staticDir = "static/images" spritesDir = "static/images/sprites" ) func main() { fmt.Println("CSS Sprite Generator for CV Website") fmt.Println("====================================") fmt.Println() // Define categories categories := []SpriteCategory{ {Name: "companies", SourceDir: filepath.Join(staticDir, "companies"), OutputName: "sprite-companies"}, {Name: "projects", SourceDir: filepath.Join(staticDir, "projects"), OutputName: "sprite-projects"}, {Name: "courses", SourceDir: filepath.Join(staticDir, "courses"), OutputName: "sprite-courses"}, } // Process each category spriteMap := SpriteMap{} var showcaseCategories []ShowcaseCategory for i := range categories { cat := &categories[i] fmt.Printf("Processing %s...\n", cat.Name) // Scan source directory for PNG files icons, err := scanDirectory(cat.SourceDir) if err != nil { fmt.Printf(" ERROR: Failed to scan %s: %v\n", cat.SourceDir, err) continue } cat.Icons = icons fmt.Printf(" Found %d icons\n", len(icons)) if len(icons) == 0 { fmt.Printf(" Skipping (no icons found)\n") continue } // Generate sprite sheets (1x and 2x) err = generateSprite(cat, baseIconSize, "") if err != nil { fmt.Printf(" ERROR: Failed to generate 1x sprite: %v\n", err) continue } err = generateSprite(cat, retinaIconSize, "@2x") if err != nil { fmt.Printf(" ERROR: Failed to generate 2x sprite: %v\n", err) continue } // Build sprite map entry entries := make([]SpriteMapEntry, len(icons)) showcaseIcons := make([]ShowcaseIcon, len(icons)) for idx, icon := range icons { entries[idx] = SpriteMapEntry{Index: idx, Name: icon} showcaseIcons[idx] = ShowcaseIcon{Index: idx, Name: strings.TrimSuffix(icon, filepath.Ext(icon))} } switch cat.Name { case "companies": spriteMap.Companies = entries case "projects": spriteMap.Projects = entries case "courses": spriteMap.Courses = entries } // Build showcase category showcaseCategories = append(showcaseCategories, ShowcaseCategory{ Name: cat.Name, CSSClass: "icon-" + strings.TrimSuffix(cat.Name, "s"), // companies -> icon-company SpriteFile: cat.OutputName + ".png", Icons: showcaseIcons, }) fmt.Printf(" Generated: %s.png and %s@2x.png\n", cat.OutputName, cat.OutputName) } // Write sprite map JSON err := writeSpriteMap(spriteMap) if err != nil { fmt.Printf("\nERROR: Failed to write sprite-map.json: %v\n", err) os.Exit(1) } fmt.Println("\nGenerated: sprite-map.json") // Generate showcase HTML page err = generateShowcasePage(showcaseCategories) if err != nil { fmt.Printf("\nERROR: Failed to generate showcase page: %v\n", err) os.Exit(1) } fmt.Println("Generated: sprite-showcase.html") // Print summary fmt.Println("\n====================================") fmt.Println("Sprite generation complete!") fmt.Printf(" Companies: %d icons\n", len(spriteMap.Companies)) fmt.Printf(" Projects: %d icons\n", len(spriteMap.Projects)) fmt.Printf(" Courses: %d icons\n", len(spriteMap.Courses)) fmt.Printf(" Total: %d icons\n", len(spriteMap.Companies)+len(spriteMap.Projects)+len(spriteMap.Courses)) fmt.Println("\nOutput files:") fmt.Println(" - static/images/sprites/sprite-companies.png") fmt.Println(" - static/images/sprites/sprite-companies@2x.png") fmt.Println(" - static/images/sprites/sprite-projects.png") fmt.Println(" - static/images/sprites/sprite-projects@2x.png") fmt.Println(" - static/images/sprites/sprite-courses.png") fmt.Println(" - static/images/sprites/sprite-courses@2x.png") fmt.Println(" - static/images/sprites/sprite-map.json") fmt.Println(" - static/sprite-showcase.html") } // scanDirectory returns a sorted list of PNG files in the directory func scanDirectory(dir string) ([]string, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, err } var pngs []string for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() if strings.HasSuffix(strings.ToLower(name), ".png") { pngs = append(pngs, name) } } // Sort alphabetically for consistent ordering sort.Strings(pngs) return pngs, nil } // generateSprite creates a sprite sheet for the given category func generateSprite(cat *SpriteCategory, iconSize int, suffix string) error { if len(cat.Icons) == 0 { return nil } // Create sprite image (horizontal strip) spriteWidth := iconSize * len(cat.Icons) spriteHeight := iconSize sprite := image.NewRGBA(image.Rect(0, 0, spriteWidth, spriteHeight)) // Process each icon for idx, iconName := range cat.Icons { srcPath := filepath.Join(cat.SourceDir, iconName) // Load source image srcImg, err := loadImage(srcPath) if err != nil { fmt.Printf(" WARNING: Failed to load %s: %v\n", iconName, err) continue } // Resize and center icon resized := resizeAndCenter(srcImg, iconSize) // Draw onto sprite at correct position xOffset := idx * iconSize destRect := image.Rect(xOffset, 0, xOffset+iconSize, iconSize) draw.Draw(sprite, destRect, resized, image.Point{0, 0}, draw.Over) } // Save sprite outputPath := filepath.Join(spritesDir, cat.OutputName+suffix+".png") return saveImage(sprite, outputPath) } // loadImage loads a PNG image from the given path func loadImage(path string) (img image.Image, err error) { file, err := os.Open(path) if err != nil { return nil, err } defer func() { if cerr := file.Close(); cerr != nil && err == nil { err = cerr } }() img, err = png.Decode(file) if err != nil { return nil, err } return img, nil } // resizeAndCenter resizes an image to fit within the target size while maintaining // aspect ratio, then centers it on a transparent background func resizeAndCenter(src image.Image, targetSize int) *image.RGBA { // Create transparent target image dst := image.NewRGBA(image.Rect(0, 0, targetSize, targetSize)) // Fill with transparent background for y := 0; y < targetSize; y++ { for x := 0; x < targetSize; x++ { dst.Set(x, y, color.Transparent) } } // Get source dimensions srcBounds := src.Bounds() srcWidth := srcBounds.Dx() srcHeight := srcBounds.Dy() // Calculate scaling factor to fit within target while maintaining aspect ratio scaleX := float64(targetSize) / float64(srcWidth) scaleY := float64(targetSize) / float64(srcHeight) scale := scaleX if scaleY < scaleX { scale = scaleY } // Calculate new dimensions newWidth := int(float64(srcWidth) * scale) newHeight := int(float64(srcHeight) * scale) // Calculate offset to center offsetX := (targetSize - newWidth) / 2 offsetY := (targetSize - newHeight) / 2 // Create scaled image scaled := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight)) // Use high-quality scaling (CatmullRom for smooth results) draw.CatmullRom.Scale(scaled, scaled.Bounds(), src, srcBounds, draw.Over, nil) // Draw scaled image onto destination at centered position destRect := image.Rect(offsetX, offsetY, offsetX+newWidth, offsetY+newHeight) draw.Draw(dst, destRect, scaled, image.Point{0, 0}, draw.Over) return dst } // saveImage saves an image to the given path as PNG func saveImage(img image.Image, path string) (err error) { // Ensure directory exists dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { return err } file, err := os.Create(path) if err != nil { return err } defer func() { if cerr := file.Close(); cerr != nil && err == nil { err = cerr } }() return png.Encode(file, img) } // writeSpriteMap writes the sprite map to a JSON file func writeSpriteMap(spriteMap SpriteMap) error { data, err := json.MarshalIndent(spriteMap, "", " ") if err != nil { return err } outputPath := filepath.Join(spritesDir, "sprite-map.json") return os.WriteFile(outputPath, data, 0644) } // generateShowcasePage creates an HTML showcase page for visual QA func generateShowcasePage(categories []ShowcaseCategory) error { html := `
On retina displays, the @2x sprite should load automatically for crisp rendering.
Open DevTools (Network tab, filter by Images) to verify: