e576377359
Apple's developer.apple.com ships a classic BMP/DIB favicon.ico rather than a PNG one, so the icon discovery that feeds the ZIM book icon found nothing and the archive shipped without an Illustrator_48x48@1 image. Decode the .ico container directly: read the directory, pick the largest entry, and decode it either as the PNG a modern favicon embeds or as the BITMAPINFOHEADER bitmap older ones still carry. The BMP path handles 32/24/8/4/1 bpp, the stacked AND transparency mask, and the all-zero alpha case where a 32-bpp icon leans on the mask for its shape.
213 lines
6.6 KiB
Go
213 lines
6.6 KiB
Go
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 TestDecodeIconGarbageICOFails(t *testing.T) {
|
|
// A one-entry .ico whose payload is neither a PNG nor a complete DIB header
|
|
// (here four filler bytes) must error so the caller falls back to the default
|
|
// icon rather than crashing.
|
|
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}) // truncated 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("garbage .ico should fail to decode")
|
|
}
|
|
}
|
|
|
|
// icoWithBMP32 wraps a solid px-by-px 32-bpp BMP/DIB bitmap (the format Apple's
|
|
// favicon.ico uses) in a single-entry .ico container, with an all-opaque AND
|
|
// mask. It exercises the hand-written BMP path without a binary fixture.
|
|
func icoWithBMP32(px int, c color.RGBA) []byte {
|
|
var dib bytes.Buffer
|
|
leWrite(&dib, uint32(40)) // biSize
|
|
leWrite(&dib, int32(px)) // biWidth
|
|
leWrite(&dib, int32(px*2)) // biHeight (color + mask, stacked)
|
|
leWrite(&dib, uint16(1)) // biPlanes
|
|
leWrite(&dib, uint16(32)) // biBitCount
|
|
leWrite(&dib, uint32(0)) // biCompression = BI_RGB
|
|
leWrite(&dib, uint32(0)) // biSizeImage
|
|
leWrite(&dib, int32(0)) // biXPelsPerMeter
|
|
leWrite(&dib, int32(0)) // biYPelsPerMeter
|
|
leWrite(&dib, uint32(0)) // biClrUsed
|
|
leWrite(&dib, uint32(0)) // biClrImportant
|
|
for i := 0; i < px*px; i++ { // XOR: BGRA, full alpha
|
|
dib.Write([]byte{c.B, c.G, c.R, 0xff})
|
|
}
|
|
andStride := ((px + 31) / 32) * 4
|
|
dib.Write(make([]byte, andStride*px)) // AND mask, all zero = opaque
|
|
|
|
var ico bytes.Buffer
|
|
leWrite(&ico, uint16(0))
|
|
leWrite(&ico, uint16(1))
|
|
leWrite(&ico, uint16(1))
|
|
ico.WriteByte(byte(px))
|
|
ico.WriteByte(byte(px))
|
|
ico.WriteByte(0)
|
|
ico.WriteByte(0)
|
|
leWrite(&ico, uint16(1))
|
|
leWrite(&ico, uint16(32))
|
|
leWrite(&ico, uint32(dib.Len()))
|
|
leWrite(&ico, uint32(22))
|
|
ico.Write(dib.Bytes())
|
|
return ico.Bytes()
|
|
}
|
|
|
|
func TestDecodeIconBMP32(t *testing.T) {
|
|
want := color.RGBA{R: 0x33, G: 0x66, B: 0x99, A: 0xff}
|
|
p := filepath.Join(t.TempDir(), "favicon.ico")
|
|
if err := os.WriteFile(p, icoWithBMP32(64, want), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
img, err := DecodeIcon(p)
|
|
if err != nil {
|
|
t.Fatalf("DecodeIcon: %v", err)
|
|
}
|
|
if img.Bounds().Dx() != 64 || img.Bounds().Dy() != 64 {
|
|
t.Fatalf("size = %v, want 64x64", img.Bounds())
|
|
}
|
|
r, g, b, a := img.At(10, 10).RGBA()
|
|
if r>>8 != 0x33 || g>>8 != 0x66 || b>>8 != 0x99 || a>>8 != 0xff {
|
|
t.Errorf("pixel = %02x%02x%02x%02x, want 336699ff", r>>8, g>>8, b>>8, a>>8)
|
|
}
|
|
}
|
|
|
|
// A BMP-only .ico now decodes end to end through FindIcon, the path that gives a
|
|
// favicon-only site (such as developer.apple.com) its book icon.
|
|
func TestFindIconDecodesBMPICO(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "favicon.ico"), icoWithBMP32(48, color.RGBA{R: 1, G: 2, B: 3, A: 0xff}), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
img, src, ok := FindIcon(dir)
|
|
if !ok {
|
|
t.Fatal("FindIcon should decode a BMP .ico")
|
|
}
|
|
if filepath.Base(src) != "favicon.ico" {
|
|
t.Errorf("src = %s, want favicon.ico", filepath.Base(src))
|
|
}
|
|
if img.Bounds().Dx() != 48 {
|
|
t.Errorf("width = %d, want 48", img.Bounds().Dx())
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|