diff --git a/pack/icon.go b/pack/icon.go index 40640c9..4ad44a6 100644 --- a/pack/icon.go +++ b/pack/icon.go @@ -5,14 +5,16 @@ import ( "encoding/binary" "fmt" "image" + "image/color" "io/fs" "os" "path/filepath" + "sort" "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. + // always one of these; SVG is skipped explicitly, and a BMP-in-ICO is + // decoded by hand below since the stdlib has no BMP decoder. _ "image/gif" _ "image/jpeg" "image/png" @@ -86,21 +88,20 @@ func globIcon(dir, name string) []string { 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. +// formats (PNG, JPEG, GIF) directly and unwraps a .ico container, decoding +// either the PNG a modern high-resolution favicon embeds or the classic BMP/DIB +// bitmap older ones (Apple's among them) still ship. 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) + img, err := decodeICO(data) if err != nil { - return nil, err + return nil, fmt.Errorf("pack: decode icon %q: %w", path, err) } - data = png + return img, nil } img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { @@ -120,13 +121,18 @@ func isICO(data []byte) bool { 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) { +// decodeICO decodes the best entry in an .ico container. It reads the directory, +// orders the entries largest first (a bigger source rescales to a cleaner 48x48 +// favicon), and decodes each in turn until one works: a PNG entry through the +// stdlib, a BMP/DIB entry through decodeICOBMP. It errors only when no entry +// decodes, so a truly garbage .ico still falls back to the default icon. +func decodeICO(data []byte) (image.Image, error) { + if len(data) < 6 { + return nil, fmt.Errorf("pack: .ico too short") + } count := int(binary.LittleEndian.Uint16(data[4:6])) - var best []byte - var bestArea int + type entry struct{ w, h, off, size int } + var ents []entry for i := 0; i < count; i++ { e := 6 + i*16 if e+16 > len(data) { @@ -144,16 +150,180 @@ func largestPNGInICO(data []byte) ([]byte, error) { 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 + ents = append(ents, entry{w, h, off, size}) + } + sort.SliceStable(ents, func(i, j int) bool { return ents[i].w*ents[i].h > ents[j].w*ents[j].h }) + + var firstErr error + for _, en := range ents { + chunk := data[en.off : en.off+en.size] + var ( + img image.Image + err error + ) + if bytes.HasPrefix(chunk, pngMagic) { + img, _, err = image.Decode(bytes.NewReader(chunk)) + } else { + img, err = decodeICOBMP(chunk, en.w, en.h) } - if w*h > bestArea { - bestArea, best = w*h, chunk + if err == nil { + return img, nil + } + if firstErr == nil { + firstErr = err } } - if best == nil { - return nil, fmt.Errorf("pack: .ico holds no PNG entry") + if firstErr == nil { + firstErr = fmt.Errorf("pack: .ico holds no decodable entry") } - return best, nil + return nil, firstErr +} + +// decodeICOBMP decodes one BMP/DIB icon entry: a BITMAPINFOHEADER, an optional +// color table, the XOR color bitmap, then a 1-bpp AND transparency mask. The +// header's height covers both bitmaps stacked, so the real image is half of it. +// Rows run bottom-up and are padded to a 4-byte boundary. Only uncompressed +// (BI_RGB) entries are handled, which covers every icon that is not a PNG. +func decodeICOBMP(p []byte, dirW, dirH int) (image.Image, error) { + if len(p) < 40 { + return nil, fmt.Errorf("pack: ico bmp header truncated") + } + headerSize := int(binary.LittleEndian.Uint32(p[0:4])) + width := int(int32(binary.LittleEndian.Uint32(p[4:8]))) + height := int(int32(binary.LittleEndian.Uint32(p[8:12]))) + bits := int(binary.LittleEndian.Uint16(p[14:16])) + compression := binary.LittleEndian.Uint32(p[16:20]) + clrUsed := int(binary.LittleEndian.Uint32(p[32:36])) + + if compression != 0 { + return nil, fmt.Errorf("pack: ico bmp compression %d unsupported", compression) + } + height /= 2 // drop the AND-mask half to get the picture height + if width <= 0 || height <= 0 { + width, height = dirW, dirH // fall back to the directory dimensions + } + if width <= 0 || height <= 0 || width > 1024 || height > 1024 { + return nil, fmt.Errorf("pack: ico bmp size %dx%d unreasonable", width, height) + } + + off := headerSize + if headerSize < 40 || off > len(p) { + off = 40 + } + + // A color table follows the header for the palettized depths (BGRA quads). + var palette [][4]byte + if bits <= 8 { + n := clrUsed + if n == 0 { + n = 1 << bits + } + for i := 0; i < n && off+4 <= len(p); i++ { + palette = append(palette, [4]byte{p[off], p[off+1], p[off+2], p[off+3]}) + off += 4 + } + } + + xorStride := ((width*bits + 31) / 32) * 4 + andStride := ((width + 31) / 32) * 4 + xor := p[off:] + var and []byte + if andOff := xorStride * height; andOff < len(xor) { + and = xor[andOff:] + } + + maskBit := func(mask []byte, rowStart, x int) bool { + i := rowStart + x/8 + return i < len(mask) && (mask[i]>>(7-uint(x%8)))&1 == 1 + } + + img := image.NewNRGBA(image.Rect(0, 0, width, height)) + anyAlpha := false + for y := 0; y < height; y++ { + srcY := height - 1 - y // rows are bottom-up + row := srcY * xorStride + andRow := srcY * andStride + for x := 0; x < width; x++ { + var r, g, b uint8 + a := uint8(255) + switch bits { + case 32: + i := row + x*4 + if i+4 > len(xor) { + continue + } + b, g, r, a = xor[i], xor[i+1], xor[i+2], xor[i+3] + if a != 0 { + anyAlpha = true + } + case 24: + i := row + x*3 + if i+3 > len(xor) { + continue + } + b, g, r = xor[i], xor[i+1], xor[i+2] + case 8: + i := row + x + if i >= len(xor) { + continue + } + if c, ok := paletteAt(palette, int(xor[i])); ok { + b, g, r = c[0], c[1], c[2] + } + case 4: + i := row + x/2 + if i >= len(xor) { + continue + } + idx := xor[i] >> 4 + if x&1 == 1 { + idx = xor[i] & 0x0f + } + if c, ok := paletteAt(palette, int(idx)); ok { + b, g, r = c[0], c[1], c[2] + } + case 1: + i := row + x/8 + if i >= len(xor) { + continue + } + bit := (xor[i] >> (7 - uint(x%8))) & 1 + if c, ok := paletteAt(palette, int(bit)); ok { + b, g, r = c[0], c[1], c[2] + } + default: + return nil, fmt.Errorf("pack: ico bmp %d-bpp unsupported", bits) + } + if bits != 32 && maskBit(and, andRow, x) { + a = 0 // AND-mask transparency for the non-alpha depths + } + img.SetNRGBA(x, y, color.NRGBA{R: r, G: g, B: b, A: a}) + } + } + + // A 32-bpp icon whose alpha channel is entirely zero is opaque, not + // invisible: it leans on the AND mask instead. Reapply opacity from the mask. + if bits == 32 && !anyAlpha { + for y := 0; y < height; y++ { + srcY := height - 1 - y + andRow := srcY * andStride + for x := 0; x < width; x++ { + c := img.NRGBAAt(x, y) + if maskBit(and, andRow, x) { + c.A = 0 + } else { + c.A = 255 + } + img.SetNRGBA(x, y, c) + } + } + } + return img, nil +} + +func paletteAt(pal [][4]byte, i int) ([4]byte, bool) { + if i < 0 || i >= len(pal) { + return [4]byte{}, false + } + return pal[i], true } diff --git a/pack/icon_test.go b/pack/icon_test.go index 0874170..0ae7869 100644 --- a/pack/icon_test.go +++ b/pack/icon_test.go @@ -81,9 +81,10 @@ func TestDecodeIconICOWithPNG(t *testing.T) { } } -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. +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)) @@ -93,13 +94,89 @@ func TestDecodeIconBMPOnlyICOFails(t *testing.T) { leWrite(&ico, uint16(32)) leWrite(&ico, uint32(4)) leWrite(&ico, uint32(22)) - ico.Write([]byte{0x42, 0x4d, 0x00, 0x00}) // "BM" DIB filler + 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("BMP-only .ico should fail to decode") + 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()) } }