Add an ICNS encoder and favicon discovery for app icons
A pure-Go .icns writer (PNG-embedded entries from 16 to 1024) and a finder that digs the site's favicon out of a cloned mirror, preferring a large apple-touch icon and unwrapping a PNG-based .ico. These feed the bundle icon for the upcoming app formats. Adds golang.org/x/image for high-quality resampling.
This commit is contained in:
@@ -10,6 +10,7 @@ require (
|
||||
github.com/klauspost/compress v1.18.6
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6
|
||||
golang.org/x/image v0.42.0
|
||||
golang.org/x/net v0.56.0
|
||||
)
|
||||
|
||||
|
||||
@@ -87,6 +87,8 @@ github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
|
||||
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package pack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
// An .icns file is a tiny container: the magic "icns", a uint32 big-endian total
|
||||
// length, then a run of entries. Each entry is a four-byte OSType, a uint32
|
||||
// big-endian length covering the 8-byte entry header plus the payload, and the
|
||||
// payload itself. Since OS X 10.7 the payload may be a PNG, which lets us avoid
|
||||
// the old packed-RGBA formats entirely and just store one PNG per size.
|
||||
//
|
||||
// We emit the retina-era PNG OSTypes. macOS picks whichever size it needs for
|
||||
// the Dock, Finder, and Cmd-Tab, so covering 16 through 1024 keeps the icon
|
||||
// crisp everywhere without shipping a huge file.
|
||||
var icnsSizes = []struct {
|
||||
osType string
|
||||
px int
|
||||
}{
|
||||
{"icp4", 16},
|
||||
{"icp5", 32},
|
||||
{"icp6", 64},
|
||||
{"ic07", 128},
|
||||
{"ic08", 256},
|
||||
{"ic09", 512},
|
||||
{"ic10", 1024},
|
||||
}
|
||||
|
||||
// EncodeICNS renders img into a macOS .icns at every standard size. The source
|
||||
// is scaled to each size with Catmull-Rom resampling, which keeps a small
|
||||
// favicon from turning to mush when it is enlarged for the Dock. It returns an
|
||||
// error only if img is empty or a PNG fails to encode.
|
||||
func EncodeICNS(img image.Image) ([]byte, error) {
|
||||
if img == nil || img.Bounds().Empty() {
|
||||
return nil, fmt.Errorf("pack: icns source image is empty")
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
for _, s := range icnsSizes {
|
||||
scaled := scaleSquare(img, s.px)
|
||||
var pngBuf bytes.Buffer
|
||||
if err := png.Encode(&pngBuf, scaled); err != nil {
|
||||
return nil, fmt.Errorf("pack: encode %s icon: %w", s.osType, err)
|
||||
}
|
||||
body.WriteString(s.osType)
|
||||
writeU32BE(&body, uint32(8+pngBuf.Len()))
|
||||
body.Write(pngBuf.Bytes())
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
out.WriteString("icns")
|
||||
writeU32BE(&out, uint32(8+body.Len()))
|
||||
out.Write(body.Bytes())
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// scaleSquare returns img resampled to a px-by-px RGBA image. A non-square
|
||||
// source is fitted into the square and centred, so a wide or tall favicon is not
|
||||
// stretched.
|
||||
func scaleSquare(img image.Image, px int) image.Image {
|
||||
dst := image.NewRGBA(image.Rect(0, 0, px, px))
|
||||
src := img.Bounds()
|
||||
sw, sh := src.Dx(), src.Dy()
|
||||
// Scale to fit, preserving aspect ratio.
|
||||
scale := float64(px) / float64(sw)
|
||||
if float64(sh)*scale > float64(px) {
|
||||
scale = float64(px) / float64(sh)
|
||||
}
|
||||
dw, dh := int(float64(sw)*scale), int(float64(sh)*scale)
|
||||
if dw < 1 {
|
||||
dw = 1
|
||||
}
|
||||
if dh < 1 {
|
||||
dh = 1
|
||||
}
|
||||
off := image.Pt((px-dw)/2, (px-dh)/2)
|
||||
rect := image.Rectangle{Min: off, Max: off.Add(image.Pt(dw, dh))}
|
||||
xdraw.CatmullRom.Scale(dst, rect, img, src, xdraw.Over, nil)
|
||||
return dst
|
||||
}
|
||||
|
||||
func writeU32BE(w *bytes.Buffer, v uint32) {
|
||||
var b [4]byte
|
||||
binary.BigEndian.PutUint32(b[:], v)
|
||||
w.Write(b[:])
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package pack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// solid returns a w-by-h image filled with one colour, enough to exercise the
|
||||
// encoder without depending on any fixture file.
|
||||
func solid(w, h int, c color.Color) image.Image {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func TestEncodeICNS(t *testing.T) {
|
||||
data, err := EncodeICNS(solid(48, 48, color.RGBA{R: 0x33, G: 0x66, B: 0x99, A: 0xff}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data[:4]) != "icns" {
|
||||
t.Fatalf("magic = %q, want icns", data[:4])
|
||||
}
|
||||
if got := binary.BigEndian.Uint32(data[4:8]); int(got) != len(data) {
|
||||
t.Fatalf("header length = %d, want %d", got, len(data))
|
||||
}
|
||||
|
||||
// Walk the entries and confirm each declared OSType is present and its PNG
|
||||
// decodes to the size it claims.
|
||||
seen := map[string]int{}
|
||||
off := 8
|
||||
for off < len(data) {
|
||||
osType := string(data[off : off+4])
|
||||
size := int(binary.BigEndian.Uint32(data[off+4 : off+8]))
|
||||
if size < 8 || off+size > len(data) {
|
||||
t.Fatalf("entry %s has bogus length %d at offset %d", osType, size, off)
|
||||
}
|
||||
img, err := png.Decode(bytes.NewReader(data[off+8 : off+size]))
|
||||
if err != nil {
|
||||
t.Fatalf("entry %s payload is not a PNG: %v", osType, err)
|
||||
}
|
||||
seen[osType] = img.Bounds().Dx()
|
||||
off += size
|
||||
}
|
||||
|
||||
for _, s := range icnsSizes {
|
||||
px, ok := seen[s.osType]
|
||||
if !ok {
|
||||
t.Errorf("missing OSType %s", s.osType)
|
||||
continue
|
||||
}
|
||||
if px != s.px {
|
||||
t.Errorf("OSType %s is %dpx, want %d", s.osType, px, s.px)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeICNSEmpty(t *testing.T) {
|
||||
if _, err := EncodeICNS(nil); err == nil {
|
||||
t.Fatal("EncodeICNS(nil) should error")
|
||||
}
|
||||
if _, err := EncodeICNS(image.NewRGBA(image.Rect(0, 0, 0, 0))); err == nil {
|
||||
t.Fatal("EncodeICNS(empty) should error")
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package pack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
// Register the decoders image.Decode dispatches to. A favicon is almost
|
||||
// always one of these; SVG and legacy BMP-in-ICO are handled (or skipped)
|
||||
// explicitly below.
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
)
|
||||
|
||||
// iconNames ranks the file names sites use for their icon, best first. A large
|
||||
// PNG (an apple-touch icon, typically 180px) makes a far better app icon than a
|
||||
// 16px favicon.ico, so we prefer those even though .ico is the classic name.
|
||||
var iconNames = []string{
|
||||
"apple-touch-icon-precomposed.png",
|
||||
"apple-touch-icon.png",
|
||||
"icon.png",
|
||||
"favicon.png",
|
||||
"favicon.ico",
|
||||
}
|
||||
|
||||
// FindIcon looks through a cloned mirror for the site's icon and decodes it. It
|
||||
// returns the image, the path it came from (for a friendly log line), and
|
||||
// ok=false when nothing usable is found, in which case the caller just builds a
|
||||
// bundle with the default icon. Discovery never fails the pack.
|
||||
func FindIcon(mirrorDir string) (image.Image, string, bool) {
|
||||
for _, name := range iconNames {
|
||||
for _, p := range globIcon(mirrorDir, name) {
|
||||
if img, err := DecodeIcon(p); err == nil {
|
||||
return img, p, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// globIcon returns every file under dir whose base name equals name, nearest the
|
||||
// root first. Clones store assets under rewritten paths, so the icon may sit a
|
||||
// few directories deep rather than at the mirror root.
|
||||
func globIcon(dir, name string) []string {
|
||||
var hits []string
|
||||
_ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(d.Name(), name) {
|
||||
hits = append(hits, p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
// Shallower paths (fewer separators) are likelier to be the real site icon.
|
||||
for i := 1; i < len(hits); i++ {
|
||||
for j := i; j > 0 && depth(hits[j]) < depth(hits[j-1]); j-- {
|
||||
hits[j], hits[j-1] = hits[j-1], hits[j]
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
func depth(p string) int { return strings.Count(p, string(filepath.Separator)) }
|
||||
|
||||
// DecodeIcon reads an icon file into an image. It handles the stdlib raster
|
||||
// formats (PNG, JPEG, GIF) directly and unwraps a PNG stored inside a .ico
|
||||
// container, which is how modern sites ship a high-resolution favicon.ico. A
|
||||
// legacy BMP-only .ico returns an error rather than a mangled image, so the
|
||||
// caller falls back to the default icon.
|
||||
func DecodeIcon(path string) (image.Image, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isICO(data) {
|
||||
png, err := largestPNGInICO(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data = png
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pack: decode icon %q: %w", path, err)
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// isICO reports whether data begins with an ICONDIR header: reserved 0, type 1
|
||||
// (icon), and a non-zero image count.
|
||||
func isICO(data []byte) bool {
|
||||
return len(data) >= 6 &&
|
||||
data[0] == 0 && data[1] == 0 &&
|
||||
data[2] == 1 && data[3] == 0 &&
|
||||
(uint16(data[4])|uint16(data[5])<<8) > 0
|
||||
}
|
||||
|
||||
var pngMagic = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}
|
||||
|
||||
// largestPNGInICO scans an .ico directory for PNG-encoded entries and returns
|
||||
// the bytes of the largest one. It ignores BMP entries; if none of the entries
|
||||
// are PNG it returns an error.
|
||||
func largestPNGInICO(data []byte) ([]byte, error) {
|
||||
count := int(binary.LittleEndian.Uint16(data[4:6]))
|
||||
var best []byte
|
||||
var bestArea int
|
||||
for i := 0; i < count; i++ {
|
||||
e := 6 + i*16
|
||||
if e+16 > len(data) {
|
||||
break
|
||||
}
|
||||
w, h := int(data[e]), int(data[e+1])
|
||||
if w == 0 {
|
||||
w = 256
|
||||
}
|
||||
if h == 0 {
|
||||
h = 256
|
||||
}
|
||||
size := int(binary.LittleEndian.Uint32(data[e+8 : e+12]))
|
||||
off := int(binary.LittleEndian.Uint32(data[e+12 : e+16]))
|
||||
if off < 0 || size <= 0 || off+size > len(data) {
|
||||
continue
|
||||
}
|
||||
chunk := data[off : off+size]
|
||||
if !bytes.HasPrefix(chunk, pngMagic) {
|
||||
continue // a BMP/DIB entry; skip it
|
||||
}
|
||||
if w*h > bestArea {
|
||||
bestArea, best = w*h, chunk
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return nil, fmt.Errorf("pack: .ico holds no PNG entry")
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package pack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// pngBytes encodes a solid square as PNG, used to seed icon fixtures.
|
||||
func pngBytes(t *testing.T, px int) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, solid(px, px, color.RGBA{R: 0x10, G: 0x80, B: 0x40, A: 0xff})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// leWrite appends v to w in little-endian, swallowing the error a bytes.Buffer
|
||||
// never returns. It keeps the .ico fixtures readable without an errcheck flag on
|
||||
// every field.
|
||||
func leWrite(w *bytes.Buffer, v any) { _ = binary.Write(w, binary.LittleEndian, v) }
|
||||
|
||||
// icoWithPNG wraps PNG payloads in a minimal .ico container so the ICO path is
|
||||
// exercised without a binary fixture on disk.
|
||||
func icoWithPNG(pngs [][]byte) []byte {
|
||||
var dir, body bytes.Buffer
|
||||
put := leWrite
|
||||
put(&dir, uint16(0)) // reserved
|
||||
put(&dir, uint16(1)) // type: icon
|
||||
put(&dir, uint16(len(pngs))) // count
|
||||
offset := 6 + len(pngs)*16
|
||||
for i, p := range pngs {
|
||||
// width/height bytes: 0 means 256; use a distinct small size per entry.
|
||||
dim := byte(16 * (i + 1))
|
||||
dir.WriteByte(dim) // width
|
||||
dir.WriteByte(dim) // height
|
||||
dir.WriteByte(0) // colours
|
||||
dir.WriteByte(0) // reserved
|
||||
put(&dir, uint16(1)) // planes
|
||||
put(&dir, uint16(32)) // bit count
|
||||
put(&dir, uint32(len(p))) // bytes in resource
|
||||
put(&dir, uint32(offset)) // offset
|
||||
offset += len(p)
|
||||
body.Write(p)
|
||||
}
|
||||
return append(dir.Bytes(), body.Bytes()...)
|
||||
}
|
||||
|
||||
func TestDecodeIconPNG(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "favicon.png")
|
||||
if err := os.WriteFile(p, pngBytes(t, 64), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
img, err := DecodeIcon(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if img.Bounds().Dx() != 64 {
|
||||
t.Errorf("decoded width = %d, want 64", img.Bounds().Dx())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeIconICOWithPNG(t *testing.T) {
|
||||
// Two PNG entries; the decoder should pick the larger (second, 32px square).
|
||||
ico := icoWithPNG([][]byte{pngBytes(t, 16), pngBytes(t, 32)})
|
||||
p := filepath.Join(t.TempDir(), "favicon.ico")
|
||||
if err := os.WriteFile(p, ico, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
img, err := DecodeIcon(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if img.Bounds().Dx() != 32 {
|
||||
t.Errorf("decoded width = %d, want the larger 32px entry", img.Bounds().Dx())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeIconBMPOnlyICOFails(t *testing.T) {
|
||||
// A one-entry .ico whose payload is not a PNG (here just filler) must error
|
||||
// so the caller falls back to the default icon.
|
||||
var ico bytes.Buffer
|
||||
leWrite(&ico, uint16(0))
|
||||
leWrite(&ico, uint16(1))
|
||||
leWrite(&ico, uint16(1))
|
||||
ico.Write([]byte{32, 32, 0, 0})
|
||||
leWrite(&ico, uint16(1))
|
||||
leWrite(&ico, uint16(32))
|
||||
leWrite(&ico, uint32(4))
|
||||
leWrite(&ico, uint32(22))
|
||||
ico.Write([]byte{0x42, 0x4d, 0x00, 0x00}) // "BM" DIB filler
|
||||
p := filepath.Join(t.TempDir(), "favicon.ico")
|
||||
if err := os.WriteFile(p, ico.Bytes(), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := DecodeIcon(p); err == nil {
|
||||
t.Fatal("BMP-only .ico should fail to decode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindIconPrefersAppleTouch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// A tiny favicon at the root and a bigger apple-touch icon a level down.
|
||||
if err := os.WriteFile(filepath.Join(dir, "favicon.png"), pngBytes(t, 16), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sub := filepath.Join(dir, "assets")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sub, "apple-touch-icon.png"), pngBytes(t, 180), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
img, src, ok := FindIcon(dir)
|
||||
if !ok {
|
||||
t.Fatal("FindIcon found nothing")
|
||||
}
|
||||
if filepath.Base(src) != "apple-touch-icon.png" {
|
||||
t.Errorf("picked %s, want apple-touch-icon.png", filepath.Base(src))
|
||||
}
|
||||
if img.Bounds().Dx() != 180 {
|
||||
t.Errorf("width = %d, want 180", img.Bounds().Dx())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindIconNone(t *testing.T) {
|
||||
if _, _, ok := FindIcon(t.TempDir()); ok {
|
||||
t.Fatal("FindIcon should report nothing in an empty dir")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user