2026-10-12 09:23:21 -04:00
|
|
|
|
// Command generate-icons writes the PWA icons and favicons in web/public from
|
|
|
|
|
|
// the MealQuest logo. Run it from the repository root:
|
2026-10-05 15:22:37 -04:00
|
|
|
|
//
|
|
|
|
|
|
// 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 (
|
2026-10-12 09:23:21 -04:00
|
|
|
|
"bytes"
|
|
|
|
|
|
"encoding/binary"
|
2026-10-05 15:22:37 -04:00
|
|
|
|
"fmt"
|
|
|
|
|
|
"image"
|
|
|
|
|
|
"image/color"
|
|
|
|
|
|
"image/png"
|
2026-10-12 09:23:21 -04:00
|
|
|
|
"io"
|
2026-10-05 15:22:37 -04:00
|
|
|
|
"math"
|
|
|
|
|
|
"os"
|
|
|
|
|
|
"path/filepath"
|
|
|
|
|
|
"slices"
|
|
|
|
|
|
|
|
|
|
|
|
"golang.org/x/image/draw"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
source = "design/logos/logo_v1_2Oct2026.jpg"
|
|
|
|
|
|
public = "web/public"
|
|
|
|
|
|
maskableScale = 0.66
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-10-12 09:23:21 -04:00
|
|
|
|
// faviconSizes are the images packed into favicon.ico.
|
|
|
|
|
|
var faviconSizes = []int{16, 32, 48}
|
|
|
|
|
|
|
2026-10-05 15:22:37 -04:00
|
|
|
|
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)},
|
2026-10-12 09:23:21 -04:00
|
|
|
|
{"icons/favicon-16.png", resized(logo, 16)},
|
|
|
|
|
|
{"icons/favicon-32.png", resized(logo, 32)},
|
2026-10-05 15:22:37 -04:00
|
|
|
|
{"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())
|
|
|
|
|
|
}
|
2026-10-12 09:23:21 -04:00
|
|
|
|
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)
|
2026-10-05 15:22:37 -04:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 09:23:21 -04:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-05 15:22:37 -04:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
2026-10-12 09:23:21 -04:00
|
|
|
|
if err := encode(f, img); err != nil {
|
2026-10-05 15:22:37 -04:00
|
|
|
|
f.Close()
|
|
|
|
|
|
return fmt.Errorf("encode %s: %w", path, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return f.Close()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 09:23:21 -04:00
|
|
|
|
func encode(w io.Writer, img image.Image) error {
|
|
|
|
|
|
enc := png.Encoder{CompressionLevel: png.BestCompression}
|
|
|
|
|
|
return enc.Encode(w, img)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-05 15:22:37 -04:00
|
|
|
|
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
|
|
|
|
|
|
}
|