Files
MealQuest/web/scripts/generate-icons/main.go
T
dev_agentandClaude Opus 5.5 bcc9e56749
CI / ci (push) Successful in 10m2s
CI / ci (pull_request) Successful in 9m58s
Serve the MealQuest favicon instead of the browser default (Maestro 7c778111)
index.html linked only the apple-touch-icon, so browsers fell back to a
default favicon. generate-icons now also writes favicon.ico (16/32/48
PNG-in-ICO) and 16/32 px PNG favicons from the v1 logo; index.html links
them, and the service worker precaches every icon and favicon so the
offline shell keeps them (the glob covers the manifest icons, so the
plugin no longer injects them a second time).

pwa.test.ts asserts the build ships the favicons at the right sizes,
links them from index.html, and precaches each icon exactly once.

Task: 7c778111-c615-4437-a3f6-0e36bcb97e63

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:23:21 -04:00

243 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Command generate-icons writes the PWA icons and favicons in web/public from
// the MealQuest logo. Run it from the repository root:
//
// go run ./web/scripts/generate-icons
//
// The logo fills the regular icons edge to edge. The maskable icon shrinks it
// onto the colour of its own parchment, so the banner stays inside the
// circular safe zone (radius 40% of the icon) that launchers may crop to.
package main
import (
"bytes"
"encoding/binary"
"fmt"
"image"
"image/color"
"image/png"
"io"
"math"
"os"
"path/filepath"
"slices"
"golang.org/x/image/draw"
)
const (
source = "design/logos/logo_v1_2Oct2026.jpg"
public = "web/public"
maskableScale = 0.66
)
// faviconSizes are the images packed into favicon.ico.
var faviconSizes = []int{16, 32, 48}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "generate-icons:", err)
os.Exit(1)
}
}
func run() error {
logo, err := load(source)
if err != nil {
return err
}
if b := logo.Bounds(); b.Dx() != b.Dy() {
return fmt.Errorf("%s is %dx%d; the logo must be square", source, b.Dx(), b.Dy())
}
outputs := []struct {
path string
image *image.RGBA
}{
{"icons/icon-192.png", resized(logo, 192)},
{"icons/icon-512.png", resized(logo, 512)},
{"icons/icon-maskable-512.png", maskable(logo, 512)},
{"icons/favicon-16.png", resized(logo, 16)},
{"icons/favicon-32.png", resized(logo, 32)},
{"apple-touch-icon.png", resized(logo, 180)},
}
for _, out := range outputs {
path := filepath.Join(public, out.path)
if err := save(path, out.image); err != nil {
return err
}
fmt.Printf("wrote %s (%dx%d)\n", path, out.image.Rect.Dx(), out.image.Rect.Dy())
}
ico, err := favicon(logo)
if err != nil {
return err
}
path := filepath.Join(public, "favicon.ico")
if err := os.WriteFile(path, ico, 0o644); err != nil {
return err
}
fmt.Printf("wrote %s (%v)\n", path, faviconSizes)
return nil
}
// favicon is an ICO file holding the logo at each of faviconSizes as an
// embedded PNG: a 6-byte header, a 16-byte directory entry per image, then
// the images.
func favicon(logo image.Image) ([]byte, error) {
var images [][]byte
for _, size := range faviconSizes {
var buf bytes.Buffer
if err := encode(&buf, resized(logo, size)); err != nil {
return nil, fmt.Errorf("encode favicon %dx%d: %w", size, size, err)
}
images = append(images, buf.Bytes())
}
var out bytes.Buffer
le := binary.LittleEndian
out.Write(le.AppendUint16(nil, 0)) // reserved
out.Write(le.AppendUint16(nil, 1)) // type: icon
out.Write(le.AppendUint16(nil, uint16(len(images)))) // image count
offset := 6 + 16*len(images)
for i, size := range faviconSizes {
out.WriteByte(byte(size)) // width
out.WriteByte(byte(size)) // height
out.WriteByte(0) // palette size: none
out.WriteByte(0) // reserved
out.Write(le.AppendUint16(nil, 1)) // colour planes
out.Write(le.AppendUint16(nil, 32)) // bits per pixel
out.Write(le.AppendUint32(nil, uint32(len(images[i]))))
out.Write(le.AppendUint32(nil, uint32(offset)))
offset += len(images[i])
}
for _, img := range images {
out.Write(img)
}
return out.Bytes(), nil
}
// load decodes the logo. The file is a PNG despite its .jpg name, so the
// format is sniffed rather than taken from the extension.
func load(path string) (image.Image, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return nil, fmt.Errorf("decode %s: %w", path, err)
}
return img, nil
}
func save(path string, img image.Image) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
f, err := os.Create(path)
if err != nil {
return err
}
if err := encode(f, img); err != nil {
f.Close()
return fmt.Errorf("encode %s: %w", path, err)
}
return f.Close()
}
func encode(w io.Writer, img image.Image) error {
enc := png.Encoder{CompressionLevel: png.BestCompression}
return enc.Encode(w, img)
}
func resized(logo image.Image, size int) *image.RGBA {
dst := image.NewRGBA(image.Rect(0, 0, size, size))
draw.CatmullRom.Scale(dst, dst.Rect, logo, logo.Bounds(), draw.Src, nil)
return dst
}
func maskable(logo image.Image, size int) *image.RGBA {
// The padding is the median colour of the logo's top edge, which is bare
// parchment; a feathered edge hides the seam.
b := logo.Bounds()
canvas := image.NewRGBA(image.Rect(0, 0, size, size))
draw.Draw(canvas, canvas.Rect, image.NewUniform(medianColour(logo, image.Rect(b.Min.X, b.Min.Y, b.Max.X, b.Min.Y+b.Dy()/40))), image.Point{}, draw.Src)
inner := int(math.Round(float64(size) * maskableScale))
offset := (size - inner) / 2
margin := inner / 24
at := image.Rect(offset, offset, offset+inner, offset+inner)
draw.DrawMask(canvas, at, resized(logo, inner), image.Point{}, feather(inner, margin), image.Point{}, draw.Over)
return canvas
}
// medianColour is the per-channel median of the pixels in r.
func medianColour(img image.Image, r image.Rectangle) color.RGBA {
var channels [3][]uint8
for y := r.Min.Y; y < r.Max.Y; y++ {
for x := r.Min.X; x < r.Max.X; x++ {
c := color.RGBAModel.Convert(img.At(x, y)).(color.RGBA)
channels[0] = append(channels[0], c.R)
channels[1] = append(channels[1], c.G)
channels[2] = append(channels[2], c.B)
}
}
var median [3]uint8
for i, values := range channels {
slices.Sort(values)
median[i] = values[len(values)/2]
}
return color.RGBA{R: median[0], G: median[1], B: median[2], A: 0xff}
}
// feather is an opaque square inset by margin on a size×size mask, its edge
// softened by a Gaussian blur with a standard deviation of margin/2.
func feather(size, margin int) *image.Alpha {
values := make([]float64, size*size)
for y := margin; y <= size-margin && y < size; y++ {
for x := margin; x <= size-margin && x < size; x++ {
values[y*size+x] = 255
}
}
kernel := gaussian(float64(margin) / 2)
values = blur(values, size, kernel, 1, size)
values = blur(values, size, kernel, size, 1)
mask := image.NewAlpha(image.Rect(0, 0, size, size))
for i, v := range values {
mask.Pix[i] = uint8(math.Round(min(max(v, 0), 255)))
}
return mask
}
// gaussian is a normalised kernel reaching three standard deviations.
func gaussian(sigma float64) []float64 {
radius := int(math.Ceil(3 * sigma))
kernel := make([]float64, 2*radius+1)
var sum float64
for i := range kernel {
d := float64(i - radius)
kernel[i] = math.Exp(-d * d / (2 * sigma * sigma))
sum += kernel[i]
}
for i := range kernel {
kernel[i] /= sum
}
return kernel
}
// blur convolves each line of a size×size grid with kernel: step is the
// distance between neighbours along a line and stride between lines. Samples
// beyond the edge repeat the edge.
func blur(values []float64, size int, kernel []float64, step, stride int) []float64 {
radius := len(kernel) / 2
out := make([]float64, len(values))
for line := range size {
for i := range size {
var v float64
for k, w := range kernel {
j := min(max(i+k-radius, 0), size-1)
v += w * values[line*stride+j*step]
}
out[line*stride+i*step] = v
}
}
return out
}