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>
243 lines
7.1 KiB
Go
243 lines
7.1 KiB
Go
// 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
|
||
}
|