Files
cv-site/cmd/sprites/main.go
T

534 lines
15 KiB
Go
Raw Normal View History

// 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 := `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CSS Sprite Showcase</title>
<link rel="stylesheet" href="/static/css/04-interactive/_sprites.css">
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
background: #f5f5f5;
}
h1 {
color: #333;
border-bottom: 2px solid #333;
padding-bottom: 0.5rem;
}
h2 {
color: #666;
margin-top: 2rem;
}
h3 {
color: #888;
margin-top: 1.5rem;
}
.sprite-full {
background: white;
padding: 1rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin: 1rem 0;
overflow-x: auto;
}
.sprite-full img {
display: block;
height: 48px;
image-rendering: crisp-edges;
}
.icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 1rem;
margin: 1rem 0;
}
.icon-item {
background: white;
padding: 1rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
}
.icon-item label {
display: block;
margin-top: 0.5rem;
font-size: 0.75rem;
color: #666;
word-break: break-all;
}
.zoom-test {
background: white;
padding: 1rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin: 1rem 0;
}
.zoom-test div {
display: flex;
align-items: center;
gap: 1rem;
margin: 0.5rem 0;
}
.zoom-test span:first-child {
width: 60px;
font-weight: bold;
}
.retina-test {
background: white;
padding: 1rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin: 1rem 0;
display: flex;
gap: 2rem;
}
.retina-test div {
text-align: center;
}
.retina-test label {
display: block;
margin-top: 0.5rem;
font-size: 0.875rem;
color: #666;
}
.summary {
background: #e8f5e9;
padding: 1rem;
border-radius: 8px;
margin: 1rem 0;
}
.summary ul {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
</style>
</head>
<body>
<h1>CSS Sprite Showcase</h1>
<div class="summary">
<strong>Summary:</strong>
<ul>
`
// Add summary counts
titleCaser := cases.Title(language.English)
for _, cat := range categories {
html += fmt.Sprintf(" <li>%s: %d icons</li>\n", titleCaser.String(cat.Name), len(cat.Icons))
}
totalIcons := 0
for _, cat := range categories {
totalIcons += len(cat.Icons)
}
html += fmt.Sprintf(" <li><strong>Total: %d icons</strong></li>\n", totalIcons)
html += " </ul>\n </div>\n\n"
// Add each category
for _, cat := range categories {
html += fmt.Sprintf(` <section>
<h2>%s (Full Sprite)</h2>
<div class="sprite-full">
<img src="/static/images/sprites/%s" alt="%s sprite">
</div>
<h3>Individual Icons</h3>
<div class="icon-grid">
`, titleCaser.String(cat.Name), cat.SpriteFile, cat.Name)
for _, icon := range cat.Icons {
html += fmt.Sprintf(` <div class="icon-item">
<span class="icon-sprite %s" style="--icon-index: %d;"></span>
<label>%d: %s</label>
</div>
`, cat.CSSClass, icon.Index, icon.Index, icon.Name)
}
html += " </div>\n </section>\n\n"
}
// Add zoom test section
html += ` <section>
<h2>Zoom Test</h2>
<div class="zoom-test">
<div style="zoom: 1;"><span>100%:</span><span class="icon-sprite icon-company" style="--icon-index: 0;"></span></div>
<div style="zoom: 2;"><span>200%:</span><span class="icon-sprite icon-company" style="--icon-index: 0;"></span></div>
<div style="zoom: 3;"><span>300%:</span><span class="icon-sprite icon-company" style="--icon-index: 0;"></span></div>
</div>
</section>
<section>
<h2>Retina Test</h2>
<p>On retina displays, the @2x sprite should load automatically for crisp rendering.</p>
<div class="retina-test">
<div>
<span class="icon-sprite icon-company" style="--icon-index: 0;"></span>
<label>Should be crisp on retina</label>
</div>
<div>
<span class="icon-sprite icon-project" style="--icon-index: 0;"></span>
<label>Project icon</label>
</div>
<div>
<span class="icon-sprite icon-course" style="--icon-index: 0;"></span>
<label>Course icon</label>
</div>
</div>
</section>
<section>
<h2>Network Verification</h2>
<p>Open DevTools (Network tab, filter by Images) to verify:</p>
<ul>
<li>Only 3 sprite images should load (not 44+ individual images)</li>
<li>On retina displays, @2x versions should load</li>
</ul>
</section>
</body>
</html>
`
outputPath := "static/sprite-showcase.html"
return os.WriteFile(outputPath, []byte(html), 0644)
}