Decode BMP/DIB icons inside .ico files (#15)
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.
This commit is contained in:
+193
-23
@@ -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
|
||||
}
|
||||
|
||||
+82
-5
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user