534 lines
15 KiB
Go
534 lines
15 KiB
Go
// 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)
|
|
}
|