feat: implement CSS sprite system for image optimization
Reduces HTTP requests from 44+ individual images to 3 sprite sheets (~93% reduction). Includes Go sprite generator tool, CSS classes, template integration, and E2E tests. - Add cmd/sprites/main.go for sprite generation (60x60px + 120x120px @2x) - Add _sprites.css with responsive sizing and retina support - Update templates to use sprites with logoIndex fallback - Add Makefile targets: sprites, sprites-clean - Add 9-test E2E suite for sprite functionality - Add doc/22-SPRITES.md with usage documentation
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
// 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) (image.Image, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
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) 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 file.Close()
|
||||
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user