commit 9eb03ebdb77803f4855049bdd6254036417eb98d
parent 8204a2af5ae9eb0d8aabf9640f844d66f133b017
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Fri, 18 Sep 2026 16:09:38 -0400
ico: add a sibling package for the Windows icon format
A program that ships a macOS icon usually needs a Windows one from the same
artwork, and the shell extension already puts this project on Windows. The
API mirrors the icns one so both formats read the same way, and the two
share resampling.
Diffstat:
| A | ico/bmp.go | | | 133 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | ico/bmp_test.go | | | 191 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | ico/ico.go | | | 167 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | ico/ico_test.go | | | 196 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | ico/reader.go | | | 193 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | ico/writer.go | | | 138 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | readme.md | | | 17 | +++++++++++++++++ |
7 files changed, 1035 insertions(+), 0 deletions(-)
diff --git a/ico/bmp.go b/ico/bmp.go
@@ -0,0 +1,133 @@
+package ico
+
+import (
+ "encoding/binary"
+ "fmt"
+ "image"
+ "image/color"
+)
+
+// decodeBMP reads the device independent bitmap an icon holds: a header, a
+// colour table when the pixels are indexed, the pixels bottom up, and a one
+// bit mask. The stored height covers the pixels and the mask together, so it
+// is twice the height of the icon.
+func decodeBMP(data []byte, width, height int) (image.Image, error) {
+ if len(data) < headerSize {
+ return nil, fmt.Errorf("%w: holds %d bytes, too few for a bitmap header", ErrMalformed, len(data))
+ }
+ var (
+ infoSize = int(binary.LittleEndian.Uint32(data[0:4]))
+ w = int(int32(binary.LittleEndian.Uint32(data[4:8])))
+ storedH = int(int32(binary.LittleEndian.Uint32(data[8:12])))
+ bits = int(binary.LittleEndian.Uint16(data[14:16]))
+ compression = binary.LittleEndian.Uint32(data[16:20])
+ colours = int(binary.LittleEndian.Uint32(data[32:36]))
+ )
+ if compression != 0 {
+ return nil, fmt.Errorf("%w: the bitmap is compressed", ErrUnsupportedFormat)
+ }
+ switch bits {
+ case 1, 4, 8, 24, 32:
+ default:
+ return nil, fmt.Errorf("%w: %d bits per pixel", ErrUnsupportedFormat, bits)
+ }
+ h := storedH / 2
+ if storedH <= 0 || storedH%2 != 0 || w <= 0 {
+ return nil, fmt.Errorf("%w: the bitmap is %dx%d", ErrMalformed, w, storedH)
+ }
+ if infoSize < headerSize || infoSize > len(data) {
+ return nil, fmt.Errorf("%w: the header claims %d bytes", ErrMalformed, infoSize)
+ }
+
+ // An indexed bitmap carries its own colour table, so nothing outside the
+ // file is needed to read it.
+ var palette []color.NRGBA
+ offset := infoSize
+ if bits <= 8 {
+ entries := colours
+ if entries == 0 {
+ entries = 1 << bits
+ }
+ if offset+entries*4 > len(data) {
+ return nil, fmt.Errorf("%w: the colour table of %d runs past the icon", ErrMalformed, entries)
+ }
+ palette = make([]color.NRGBA, entries)
+ for i := range palette {
+ at := offset + i*4
+ palette[i] = color.NRGBA{R: data[at+2], G: data[at+1], B: data[at], A: 0xFF}
+ }
+ offset += entries * 4
+ }
+
+ var (
+ stride = ((w*bits + 31) / 32) * 4
+ maskStride = ((w + 31) / 32) * 4
+ pixelBytes = stride * h
+ )
+ if offset+pixelBytes > len(data) {
+ return nil, fmt.Errorf("%w: %d bytes of pixels do not fit in the icon", ErrMalformed, pixelBytes)
+ }
+ pixels := data[offset : offset+pixelBytes]
+ // The mask is optional in practice: a truncated icon still draws.
+ var mask []byte
+ if end := offset + pixelBytes + maskStride*h; end <= len(data) {
+ mask = data[offset+pixelBytes : end]
+ }
+
+ img := image.NewNRGBA(image.Rect(0, 0, w, h))
+ var opaque bool
+ for y := 0; y < h; y++ {
+ row := pixels[(h-1-y)*stride:]
+ for x := 0; x < w; x++ {
+ c, err := pixelAt(row, x, bits, palette)
+ if err != nil {
+ return nil, err
+ }
+ if c.A != 0 {
+ opaque = true
+ }
+ img.SetNRGBA(x, y, c)
+ }
+ }
+ // A 32 bit icon carries its own alpha, unless whoever wrote it left the
+ // channel empty and meant the mask to be read instead.
+ if mask != nil && (bits != 32 || !opaque) {
+ for y := 0; y < h; y++ {
+ row := mask[(h-1-y)*maskStride:]
+ for x := 0; x < w; x++ {
+ c := img.NRGBAAt(x, y)
+ c.A = 0xFF
+ if row[x/8]&(0x80>>(x%8)) != 0 {
+ c.A = 0
+ }
+ img.SetNRGBA(x, y, c)
+ }
+ }
+ }
+ return img, nil
+}
+
+// pixelAt reads one pixel out of a bitmap row.
+func pixelAt(row []byte, x, bits int, palette []color.NRGBA) (color.NRGBA, error) {
+ index := func(i byte) (color.NRGBA, error) {
+ if int(i) >= len(palette) {
+ return color.NRGBA{}, fmt.Errorf("%w: colour %d is outside a table of %d", ErrMalformed, i, len(palette))
+ }
+ return palette[i], nil
+ }
+ switch bits {
+ case 32:
+ return color.NRGBA{R: row[x*4+2], G: row[x*4+1], B: row[x*4], A: row[x*4+3]}, nil
+ case 24:
+ return color.NRGBA{R: row[x*3+2], G: row[x*3+1], B: row[x*3], A: 0xFF}, nil
+ case 8:
+ return index(row[x])
+ case 4:
+ if x%2 == 0 {
+ return index(row[x/2] >> 4)
+ }
+ return index(row[x/2] & 0x0F)
+ default:
+ return index((row[x/8] >> (7 - x%8)) & 1)
+ }
+}
diff --git a/ico/bmp_test.go b/ico/bmp_test.go
@@ -0,0 +1,191 @@
+package ico
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "image/color"
+ "testing"
+)
+
+// colourTable builds a table of n entries where index 1 is c.
+func colourTable(n int, c color.NRGBA) []byte {
+ out := make([]byte, n*4)
+ out[4], out[5], out[6] = c.B, c.G, c.R
+ return out
+}
+
+// bmpIcon builds one bitmap icon of the given depth, every pixel the same
+// colour, with an all opaque mask.
+func bmpIcon(size, bits int, c color.NRGBA) []byte {
+ var (
+ stride = ((size*bits + 31) / 32) * 4
+ maskStride = ((size + 31) / 32) * 4
+ palette []byte
+ fill byte
+ )
+ switch bits {
+ case 1:
+ palette, fill = colourTable(2, c), 0xFF
+ case 4:
+ palette, fill = colourTable(16, c), 0x11
+ case 8:
+ palette, fill = colourTable(256, c), 0x01
+ }
+ out := make([]byte, 0, headerSize+len(palette)+stride*size+maskStride*size)
+ out = binary.LittleEndian.AppendUint32(out, headerSize)
+ out = binary.LittleEndian.AppendUint32(out, uint32(size))
+ out = binary.LittleEndian.AppendUint32(out, uint32(size*2))
+ out = binary.LittleEndian.AppendUint16(out, 1)
+ out = binary.LittleEndian.AppendUint16(out, uint16(bits))
+ out = binary.LittleEndian.AppendUint32(out, 0)
+ out = binary.LittleEndian.AppendUint32(out, 0)
+ out = binary.LittleEndian.AppendUint32(out, 0)
+ out = binary.LittleEndian.AppendUint32(out, 0)
+ out = binary.LittleEndian.AppendUint32(out, uint32(len(palette)/4))
+ out = binary.LittleEndian.AppendUint32(out, 0)
+ out = append(out, palette...)
+
+ rows := make([]byte, stride*size)
+ switch bits {
+ case 32:
+ for y := 0; y < size; y++ {
+ row := rows[y*stride:]
+ for x := 0; x < size; x++ {
+ row[x*4], row[x*4+1], row[x*4+2], row[x*4+3] = c.B, c.G, c.R, c.A
+ }
+ }
+ case 24:
+ for y := 0; y < size; y++ {
+ row := rows[y*stride:]
+ for x := 0; x < size; x++ {
+ row[x*3], row[x*3+1], row[x*3+2] = c.B, c.G, c.R
+ }
+ }
+ default:
+ for i := range rows {
+ rows[i] = fill
+ }
+ }
+ out = append(out, rows...)
+ return append(out, make([]byte, maskStride*size)...)
+}
+
+// icoFile wraps one icon in a directory.
+func icoFile(size int, data []byte) []byte {
+ out := make([]byte, 0, directorySize+entrySize+len(data))
+ out = binary.LittleEndian.AppendUint16(out, 0)
+ out = binary.LittleEndian.AppendUint16(out, 1)
+ out = binary.LittleEndian.AppendUint16(out, 1)
+ out = append(out, byte(size), byte(size), 0, 0)
+ out = binary.LittleEndian.AppendUint16(out, 1)
+ out = binary.LittleEndian.AppendUint16(out, 32)
+ out = binary.LittleEndian.AppendUint32(out, uint32(len(data)))
+ out = binary.LittleEndian.AppendUint32(out, uint32(directorySize+entrySize))
+ return append(out, data...)
+}
+
+// TestDecodeDepths covers the bit depths older icons are written at, which
+// carry their own colour table and take their alpha from the mask.
+func TestDecodeDepths(t *testing.T) {
+ t.Parallel()
+ for _, bits := range []int{1, 4, 8, 24, 32} {
+ t.Run(map[int]string{1: "one bit", 4: "four bit", 8: "eight bit", 24: "twenty four bit", 32: "thirty two bit"}[bits], func(st *testing.T) {
+ want := color.NRGBA{R: 0x30, G: 0x90, B: 0xC0, A: 0xFF}
+ img, err := Decode(bytes.NewReader(icoFile(32, bmpIcon(32, bits, want))))
+ if err != nil {
+ st.Fatal(err)
+ }
+ if got := img.Bounds().Dx(); got != 32 {
+ st.Fatalf("decoded a %dpx icon, want 32", got)
+ }
+ if got := centre(img); got != want {
+ st.Fatalf("centre = %v, want %v", got, want)
+ }
+ })
+ }
+}
+
+// TestDecodeMaskWhenAlphaIsEmpty covers the writers that left the alpha
+// channel of a 32 bit icon at zero and meant the mask to be read.
+func TestDecodeMaskWhenAlphaIsEmpty(t *testing.T) {
+ t.Parallel()
+ const size = 16
+ data := bmpIcon(size, 32, color.NRGBA{R: 0x80, G: 0x40, B: 0x20, A: 0x00})
+ img, err := Decode(bytes.NewReader(icoFile(size, data)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The mask is all opaque, so the icon has to come back opaque rather
+ // than invisible.
+ if got := centre(img); got.A != 0xFF {
+ t.Fatalf("centre = %v, want it opaque from the mask", got)
+ }
+}
+
+func TestDecodeMalformed(t *testing.T) {
+ t.Parallel()
+ valid := bmpIcon(16, 32, color.NRGBA{A: 0xFF})
+ tests := []struct {
+ desc string
+ data []byte
+ want error
+ }{
+ {"empty", nil, ErrInvalidHeader},
+ {"short", []byte{0, 0}, ErrInvalidHeader},
+ {"reserved is not zero", []byte{1, 0, 1, 0, 1, 0}, ErrInvalidHeader},
+ {"a cursor, not an icon", []byte{0, 0, 2, 0, 1, 0}, ErrInvalidHeader},
+ {"no entries", []byte{0, 0, 1, 0, 0, 0}, ErrNoIcons},
+ {"truncated directory", []byte{0, 0, 1, 0, 2, 0, 1, 2, 3}, ErrMalformed},
+ {"icon lies outside the file", icoFile(16, valid)[:directorySize+entrySize+4], ErrMalformed},
+ {"bitmap header is short", icoFile(16, []byte{1, 2, 3}), ErrMalformed},
+ {"pixels do not fit", icoFile(16, bmpIcon(16, 32, color.NRGBA{})[:headerSize+16]), ErrMalformed},
+ }
+ for _, tt := range tests {
+ t.Run(tt.desc, func(st *testing.T) {
+ if _, err := Decode(bytes.NewReader(tt.data)); !errors.Is(err, tt.want) {
+ st.Fatalf("error = %v, want %v", err, tt.want)
+ }
+ })
+ }
+}
+
+func TestDecodeUnsupported(t *testing.T) {
+ t.Parallel()
+ // Sixteen bits per pixel, which this package does not read.
+ odd := bmpIcon(16, 32, color.NRGBA{A: 0xFF})
+ binary.LittleEndian.PutUint16(odd[14:16], 16)
+ if _, err := Decode(bytes.NewReader(icoFile(16, odd))); !errors.Is(err, ErrUnsupportedFormat) {
+ t.Errorf("error = %v, want ErrUnsupportedFormat", err)
+ }
+ // A compressed bitmap, which this package does not read either.
+ compressed := bmpIcon(16, 32, color.NRGBA{A: 0xFF})
+ binary.LittleEndian.PutUint32(compressed[16:20], 1)
+ if _, err := Decode(bytes.NewReader(icoFile(16, compressed))); !errors.Is(err, ErrUnsupportedFormat) {
+ t.Errorf("error = %v, want ErrUnsupportedFormat", err)
+ }
+}
+
+// FuzzDecode checks that arbitrary input never panics or hangs the decoder.
+func FuzzDecode(f *testing.F) {
+ var valid bytes.Buffer
+ if err := Encode(&valid, gradient(64)); err != nil {
+ f.Fatal(err)
+ }
+ f.Add(valid.Bytes())
+ f.Add([]byte{})
+ f.Add([]byte{0, 0, 1, 0, 0, 0})
+ for _, bits := range []int{1, 4, 8, 24, 32} {
+ f.Add(icoFile(16, bmpIcon(16, bits, color.NRGBA{A: 0xFF})))
+ }
+ f.Add(icoFile(16, []byte{1, 2, 3}))
+ f.Fuzz(func(t *testing.T, data []byte) {
+ if d, err := NewDecoder(bytes.NewReader(data)); err == nil {
+ for _, icon := range d.Icons() {
+ icon.Decode()
+ }
+ }
+ Decode(bytes.NewReader(data))
+ DecodeAll(bytes.NewReader(data))
+ })
+}
diff --git a/ico/ico.go b/ico/ico.go
@@ -0,0 +1,167 @@
+// Package ico implements an encoder and decoder for the Windows icon format.
+//
+// An ico file holds several sizes of the same icon, the way an icns does, so
+// a program that ships both writes one source image to each and lets the
+// platform pick. The sizes written are the ones Windows draws, from 16 up to
+// 256 pixels.
+//
+// Each icon is stored either as a device independent bitmap, which every
+// version of Windows reads, or as a PNG, which Vista introduced and which
+// keeps the largest size from dominating the file.
+package ico
+
+import (
+ "errors"
+ "fmt"
+ "image"
+ "io"
+
+ "github.com/jackmordaunt/icns/v4/internal/resample"
+)
+
+// InterpolationFunction is the algorithm used to resize the image.
+type InterpolationFunction = resample.Function
+
+// InterpolationFunction constants, ordered from fastest to highest quality.
+const (
+ // Nearest-neighbor interpolation
+ NearestNeighbor = resample.NearestNeighbor
+ // Bilinear interpolation
+ Bilinear = resample.Bilinear
+ // Bicubic interpolation (with cubic hermite spline)
+ Bicubic = resample.Bicubic
+ // Mitchell-Netravali interpolation
+ MitchellNetravali = resample.MitchellNetravali
+ // Lanczos interpolation (a=2)
+ Lanczos2 = resample.Lanczos2
+ // Lanczos interpolation (a=3)
+ Lanczos3 = resample.Lanczos3
+)
+
+// Errors returned by the decoder. They are wrapped with detail, so compare
+// with errors.Is.
+var (
+ // ErrInvalidHeader means the data does not begin with an icon directory.
+ ErrInvalidHeader = errors.New("invalid header for ico file")
+ // ErrMalformed means an offset or length disagrees with the data
+ // present; the file is truncated or corrupt.
+ ErrMalformed = errors.New("malformed ico file")
+ // ErrNoIcons means the directory holds no entries.
+ ErrNoIcons = errors.New("no icons found")
+ // ErrUnsupportedFormat means an icon is stored in a way this package
+ // cannot read, and no registered decoder reads it either.
+ ErrUnsupportedFormat = errors.New("unsupported image format")
+)
+
+// ErrImageTooSmall is returned when the image is too small to process.
+type ErrImageTooSmall struct {
+ need int
+ image image.Image
+}
+
+func (err ErrImageTooSmall) Error() string {
+ b := err.image.Bounds()
+ return fmt.Sprintf("image is too small: %dx%d, need at least %dx%d", b.Dx(), b.Dy(), err.need, err.need)
+}
+
+// Format is how an icon's pixels are stored inside the file.
+type Format int
+
+const (
+ // FormatBMP is a device independent bitmap with a one bit mask.
+ FormatBMP Format = iota
+ // FormatPNG is a whole PNG file.
+ FormatPNG
+)
+
+func (f Format) String() string {
+ switch f {
+ case FormatBMP:
+ return "BMP"
+ case FormatPNG:
+ return "PNG"
+ }
+ return fmt.Sprintf("unknown format %d", f)
+}
+
+// sizes are the icon sizes written, largest first. Windows draws icons at
+// each of them, and 256 is the largest the format addresses.
+var sizes = []uint{256, 128, 64, 48, 32, 24, 16}
+
+// Sizes returns the icon sizes an encoded file holds, largest first.
+func Sizes() []uint {
+ out := make([]uint, len(sizes))
+ copy(out, sizes)
+ return out
+}
+
+// smallest is the size below which there is nothing worth writing.
+const smallest = 16
+
+// Encoder encodes ico files from a source image.
+type Encoder struct {
+ Wr io.Writer
+ Algorithm InterpolationFunction
+}
+
+// NewEncoder initialises an encoder.
+func NewEncoder(wr io.Writer) *Encoder {
+ return &Encoder{
+ Wr: wr,
+ Algorithm: MitchellNetravali,
+ }
+}
+
+// WithAlgorithm applies the interpolation function used to resize the image.
+func (enc *Encoder) WithAlgorithm(a InterpolationFunction) *Encoder {
+ enc.Algorithm = a
+ return enc
+}
+
+// Encode writes the image at every size the file holds, resizing it for each.
+func (enc *Encoder) Encode(img image.Image) error {
+ if enc.Wr == nil {
+ return errors.New("cannot write to nil writer")
+ }
+ if img == nil {
+ return errors.New("cannot encode nil image")
+ }
+ return enc.write(nil, img)
+}
+
+// EncodeSizes writes artwork supplied per size, so a drawing made for one
+// size is used there rather than reduced from a larger one. Sizes given no
+// artwork are filled from the largest image supplied, which also sets the
+// largest icon written.
+func (enc *Encoder) EncodeSizes(images map[uint]image.Image) error {
+ if enc.Wr == nil {
+ return errors.New("cannot write to nil writer")
+ }
+ if len(images) == 0 {
+ return errors.New("cannot encode without an image")
+ }
+ var source image.Image
+ for _, size := range sizes {
+ img, ok := images[size]
+ if !ok {
+ continue
+ }
+ if img == nil {
+ return fmt.Errorf("cannot encode nil image for %d", size)
+ }
+ // sizes runs largest first, so the first match is the biggest slot
+ // that was filled.
+ if source == nil || resample.BiggestSide(img) > resample.BiggestSide(source) {
+ source = img
+ }
+ }
+ if source == nil {
+ return errors.New("no image was given for a size this format holds")
+ }
+ return enc.write(images, source)
+}
+
+// Encode writes img to wr in ico format, at every size the file holds.
+func Encode(wr io.Writer, img image.Image) error {
+ return NewEncoder(wr).Encode(img)
+}
diff --git a/ico/ico_test.go b/ico/ico_test.go
@@ -0,0 +1,196 @@
+package ico
+
+import (
+ "bytes"
+ "encoding/binary"
+ "image"
+ "image/color"
+ "reflect"
+ "testing"
+)
+
+// gradient returns a side by side image whose colour and alpha vary with
+// position, so a round trip cannot pass by accident.
+func gradient(side int) *image.NRGBA {
+ img := image.NewNRGBA(image.Rect(0, 0, side, side))
+ for y := 0; y < side; y++ {
+ for x := 0; x < side; x++ {
+ img.SetNRGBA(x, y, color.NRGBA{
+ R: uint8(x * 255 / side),
+ G: uint8(y * 255 / side),
+ B: uint8((x + y) * 255 / (2 * side)),
+ A: uint8(255 - y*255/(2*side)),
+ })
+ }
+ }
+ return img
+}
+
+func solid(side int, c color.NRGBA) *image.NRGBA {
+ img := image.NewNRGBA(image.Rect(0, 0, side, side))
+ for i := 0; i < len(img.Pix); i += 4 {
+ img.Pix[i], img.Pix[i+1], img.Pix[i+2], img.Pix[i+3] = c.R, c.G, c.B, c.A
+ }
+ return img
+}
+
+func centre(img image.Image) color.NRGBA {
+ b := img.Bounds()
+ return color.NRGBAModel.Convert(img.At(b.Min.X+b.Dx()/2, b.Min.Y+b.Dy()/2)).(color.NRGBA)
+}
+
+func same(a, b image.Image) bool {
+ if a.Bounds().Size() != b.Bounds().Size() {
+ return false
+ }
+ ab, bb := a.Bounds(), b.Bounds()
+ for y := 0; y < ab.Dy(); y++ {
+ for x := 0; x < ab.Dx(); x++ {
+ if a.At(ab.Min.X+x, ab.Min.Y+y) != b.At(bb.Min.X+x, bb.Min.Y+y) {
+ ar, ag, al, aa := a.At(ab.Min.X+x, ab.Min.Y+y).RGBA()
+ br, bg, bl, ba := b.At(bb.Min.X+x, bb.Min.Y+y).RGBA()
+ if ar != br || ag != bg || al != bl || aa != ba {
+ return false
+ }
+ }
+ }
+ }
+ return true
+}
+
+func TestEncode(t *testing.T) {
+ t.Parallel()
+ buf := bytes.NewBuffer(nil)
+ if err := Encode(buf, gradient(256)); err != nil {
+ t.Fatal(err)
+ }
+ d, err := NewDecoder(bytes.NewReader(buf.Bytes()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var (
+ got []int
+ formats []Format
+ )
+ for _, icon := range d.Icons() {
+ got = append(got, icon.Width)
+ formats = append(formats, icon.Format)
+ if icon.Width != icon.Height {
+ t.Errorf("icon %s is not square", icon)
+ }
+ }
+ if want := []int{256, 128, 64, 48, 32, 24, 16}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("sizes = %v, want %v", got, want)
+ }
+ // The largest is a PNG, since a bitmap at 256 is a quarter of a megabyte.
+ want := []Format{FormatPNG, FormatBMP, FormatBMP, FormatBMP, FormatBMP, FormatBMP, FormatBMP}
+ if !reflect.DeepEqual(formats, want) {
+ t.Fatalf("formats = %v, want %v", formats, want)
+ }
+}
+
+// TestDirectory reads the directory by hand, since every reader of the file
+// finds its icons through those offsets.
+func TestDirectory(t *testing.T) {
+ t.Parallel()
+ buf := bytes.NewBuffer(nil)
+ if err := Encode(buf, gradient(64)); err != nil {
+ t.Fatal(err)
+ }
+ data := buf.Bytes()
+ if got := binary.LittleEndian.Uint16(data[0:2]); got != 0 {
+ t.Errorf("reserved = %d, want 0", got)
+ }
+ if got := binary.LittleEndian.Uint16(data[2:4]); got != 1 {
+ t.Errorf("type = %d, want 1 for an icon", got)
+ }
+ count := int(binary.LittleEndian.Uint16(data[4:6]))
+ if count != 5 { // 64, 48, 32, 24 and 16.
+ t.Fatalf("count = %d, want 5", count)
+ }
+ offset := directorySize + entrySize*count
+ for i := 0; i < count; i++ {
+ row := data[directorySize+entrySize*i:]
+ size := int(binary.LittleEndian.Uint32(row[8:12]))
+ at := int(binary.LittleEndian.Uint32(row[12:16]))
+ if at != offset {
+ t.Errorf("icon %d starts at %d, want %d, so the images are not packed in order", i, at, offset)
+ }
+ if at+size > len(data) {
+ t.Fatalf("icon %d runs past the end of the file", i)
+ }
+ offset += size
+ }
+ if offset != len(data) {
+ t.Errorf("the icons end at %d but the file is %d bytes", offset, len(data))
+ }
+}
+
+// TestRoundTrip checks that an image at an exact icon size comes back
+// unchanged: nothing is resampled and a 32 bit bitmap is lossless.
+func TestRoundTrip(t *testing.T) {
+ t.Parallel()
+ src := gradient(64)
+ buf := bytes.NewBuffer(nil)
+ if err := Encode(buf, src); err != nil {
+ t.Fatal(err)
+ }
+ img, err := Decode(bytes.NewReader(buf.Bytes()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !same(img, src) {
+ t.Fatal("the decoded icon differs from the source")
+ }
+}
+
+func TestEncodeSizes(t *testing.T) {
+ t.Parallel()
+ var (
+ images = map[uint]image.Image{}
+ want = map[uint]color.NRGBA{}
+ )
+ for i, size := range Sizes() {
+ c := color.NRGBA{R: uint8(20 + i*30), G: uint8(200 - i*20), B: 0x40, A: 0xFF}
+ images[size] = solid(int(size), c)
+ want[size] = c
+ }
+ buf := bytes.NewBuffer(nil)
+ if err := NewEncoder(buf).EncodeSizes(images); err != nil {
+ t.Fatal(err)
+ }
+ d, err := NewDecoder(bytes.NewReader(buf.Bytes()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ icons := d.Icons()
+ if len(icons) != len(want) {
+ t.Fatalf("encoded %d icons, want %d", len(icons), len(want))
+ }
+ for _, icon := range icons {
+ img, err := icon.Decode()
+ if err != nil {
+ t.Fatalf("%s: %v", icon, err)
+ }
+ if got := centre(img); got != want[uint(icon.Width)] {
+ t.Errorf("the %d pixel icon is %v, want %v", icon.Width, got, want[uint(icon.Width)])
+ }
+ }
+}
+
+func TestEncodeRejects(t *testing.T) {
+ t.Parallel()
+ buf := bytes.NewBuffer(nil)
+ if err := Encode(buf, nil); err == nil {
+ t.Error("encoding a nil image was accepted")
+ }
+ if err := Encode(nil, gradient(64)); err == nil {
+ t.Error("encoding to a nil writer was accepted")
+ }
+ if err := Encode(buf, gradient(8)); err == nil {
+ t.Error("encoding an image below the smallest icon was accepted")
+ }
+ if err := NewEncoder(buf).EncodeSizes(nil); err == nil {
+ t.Error("encoding without artwork was accepted")
+ }
+}
diff --git a/ico/reader.go b/ico/reader.go
@@ -0,0 +1,193 @@
+package ico
+
+import (
+ "bytes"
+ "cmp"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "image"
+ "image/color"
+ "io"
+ "slices"
+)
+
+var pngHeader = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}
+
+// Decoder reads an ico file and decodes its icons on demand, so a caller
+// after one size does not pay for the rest.
+type Decoder struct {
+ entries []Entry
+}
+
+// NewDecoder reads r and identifies the icons it holds without decoding any
+// of their pixels.
+func NewDecoder(r io.Reader) (*Decoder, error) {
+ entries, err := directory(r)
+ if err != nil {
+ return nil, err
+ }
+ // Largest first, keeping file order between icons of equal size.
+ slices.SortStableFunc(entries, func(a, b Entry) int {
+ return cmp.Compare(b.Width*b.Height, a.Width*a.Height)
+ })
+ return &Decoder{entries: entries}, nil
+}
+
+// Icons returns the icons in the file, largest first.
+func (d *Decoder) Icons() []Entry {
+ return slices.Clone(d.entries)
+}
+
+// Entry is one icon in an ico file, before its pixels are decoded.
+type Entry struct {
+ // Width and Height are the dimensions the directory gives.
+ Width, Height int
+ // Format is how the icon's pixels are stored.
+ Format Format
+
+ data []byte
+}
+
+func (e Entry) String() string {
+ return fmt.Sprintf("%dx%d (%s)", e.Width, e.Height, e.Format)
+}
+
+// Payload returns the bytes the file stores for the icon. For a PNG icon it
+// is a whole image file; for a bitmap it is the header, pixels and mask.
+//
+// The bytes are not copied, and must not be modified.
+func (e Entry) Payload() []byte {
+ return e.data
+}
+
+// Decode decodes the icon's pixels. A PNG icon is passed to image.Decode, so
+// it is read by whatever the program has registered.
+func (e Entry) Decode() (image.Image, error) {
+ if e.Format == FormatPNG {
+ img, _, err := image.Decode(bytes.NewReader(e.data))
+ if errors.Is(err, image.ErrFormat) {
+ return nil, fmt.Errorf("%w: a %s icon, which no registered decoder reads", ErrUnsupportedFormat, e.Format)
+ }
+ if err != nil {
+ return nil, fmt.Errorf("decoding %s icon: %w", e, err)
+ }
+ return img, nil
+ }
+ img, err := decodeBMP(e.data, e.Width, e.Height)
+ if err != nil {
+ return nil, fmt.Errorf("decoding %s icon: %w", e, err)
+ }
+ return img, nil
+}
+
+// directory splits an ico file into the icons it lists.
+func directory(r io.Reader) ([]Entry, error) {
+ data, err := io.ReadAll(r)
+ if err != nil {
+ return nil, err
+ }
+ if len(data) < directorySize {
+ return nil, ErrInvalidHeader
+ }
+ var (
+ reserved = binary.LittleEndian.Uint16(data[0:2])
+ kind = binary.LittleEndian.Uint16(data[2:4])
+ count = int(binary.LittleEndian.Uint16(data[4:6]))
+ )
+ if reserved != 0 || kind != 1 {
+ return nil, ErrInvalidHeader
+ }
+ if count == 0 {
+ return nil, ErrNoIcons
+ }
+ if len(data) < directorySize+entrySize*count {
+ return nil, fmt.Errorf("%w: the directory lists %d icons but is truncated", ErrMalformed, count)
+ }
+ entries := make([]Entry, 0, count)
+ for i := 0; i < count; i++ {
+ row := data[directorySize+entrySize*i:]
+ var (
+ width = int(row[0])
+ height = int(row[1])
+ size = int(binary.LittleEndian.Uint32(row[8:12]))
+ offset = int(binary.LittleEndian.Uint32(row[12:16]))
+ )
+ // Zero stands for 256, which does not fit in a byte.
+ if width == 0 {
+ width = 256
+ }
+ if height == 0 {
+ height = 256
+ }
+ if size < 0 || offset < 0 || offset+size > len(data) || offset < directorySize {
+ return nil, fmt.Errorf("%w: icon %d lies at %d for %d bytes, outside the file", ErrMalformed, i, offset, size)
+ }
+ payload := data[offset : offset+size]
+ entry := Entry{Width: width, Height: height, data: payload}
+ if bytes.HasPrefix(payload, pngHeader) {
+ entry.Format = FormatPNG
+ }
+ entries = append(entries, entry)
+ }
+ return entries, nil
+}
+
+// Decode returns the largest icon in the ico file that can be decoded. An
+// icon in a format no registered decoder reads is passed over, so the result
+// may be smaller than the largest present.
+func Decode(r io.Reader) (image.Image, error) {
+ d, err := NewDecoder(r)
+ if err != nil {
+ return nil, err
+ }
+ for _, icon := range d.entries {
+ img, err := icon.Decode()
+ if errors.Is(err, ErrUnsupportedFormat) {
+ continue
+ }
+ return img, err
+ }
+ return nil, fmt.Errorf("%w: no icon is in a format a registered decoder reads", ErrUnsupportedFormat)
+}
+
+// DecodeAll extracts every icon in the file that can be decoded, largest
+// first.
+func DecodeAll(r io.Reader) (images []image.Image, err error) {
+ d, err := NewDecoder(r)
+ if err != nil {
+ return nil, err
+ }
+ for _, icon := range d.entries {
+ img, err := icon.Decode()
+ if errors.Is(err, ErrUnsupportedFormat) {
+ continue
+ }
+ if err != nil {
+ return nil, err
+ }
+ images = append(images, img)
+ }
+ if len(images) == 0 {
+ return nil, fmt.Errorf("%w: no icon is in a format a registered decoder reads", ErrUnsupportedFormat)
+ }
+ return images, nil
+}
+
+// DecodeConfig returns the dimensions of the largest icon in the file.
+func DecodeConfig(r io.Reader) (image.Config, error) {
+ d, err := NewDecoder(r)
+ if err != nil {
+ return image.Config{}, err
+ }
+ largest := d.entries[0]
+ return image.Config{
+ Width: largest.Width,
+ Height: largest.Height,
+ ColorModel: color.NRGBAModel,
+ }, nil
+}
+
+func init() {
+ image.RegisterFormat("ico", "\x00\x00\x01\x00", Decode, DecodeConfig)
+}
diff --git a/ico/writer.go b/ico/writer.go
@@ -0,0 +1,138 @@
+package ico
+
+import (
+ "bytes"
+ "encoding/binary"
+ "image"
+ "image/color"
+ "image/png"
+
+ "github.com/jackmordaunt/icns/v4/internal/resample"
+)
+
+const (
+ // directorySize is the header that counts the icons.
+ directorySize = 6
+ // entrySize is one icon's row in the directory.
+ entrySize = 16
+ // headerSize is the BITMAPINFOHEADER every bitmap icon begins with.
+ headerSize = 40
+ // pngAbove is the size from which a PNG is written instead of a bitmap:
+ // a 256 pixel bitmap is a quarter of a megabyte on its own.
+ pngAbove = 256
+)
+
+// write encodes an icon at every size up to the source, taking artwork from
+// images where a size has its own.
+func (enc *Encoder) write(images map[uint]image.Image, source image.Image) error {
+ if resample.BiggestSide(source) < smallest {
+ return ErrImageTooSmall{image: source, need: smallest}
+ }
+ type icon struct {
+ size uint
+ data []byte
+ }
+ var icons []icon
+ for _, size := range sizes {
+ if size > resample.BiggestSide(source) {
+ continue
+ }
+ art := source
+ if supplied, ok := images[size]; ok {
+ art = supplied
+ }
+ scaled := resample.Square(art, size, enc.Algorithm)
+ data, err := encodeIcon(scaled, int(size))
+ if err != nil {
+ return err
+ }
+ icons = append(icons, icon{size: size, data: data})
+ }
+ if len(icons) == 0 {
+ return ErrImageTooSmall{image: source, need: smallest}
+ }
+
+ out := make([]byte, 0, directorySize+entrySize*len(icons))
+ out = binary.LittleEndian.AppendUint16(out, 0) // Reserved.
+ out = binary.LittleEndian.AppendUint16(out, 1) // An icon, not a cursor.
+ out = binary.LittleEndian.AppendUint16(out, uint16(len(icons)))
+ offset := directorySize + entrySize*len(icons)
+ for _, ic := range icons {
+ // 256 does not fit in a byte and is written as zero.
+ side := byte(ic.size)
+ out = append(out, side, side, 0, 0)
+ out = binary.LittleEndian.AppendUint16(out, 1) // Colour planes.
+ out = binary.LittleEndian.AppendUint16(out, 32) // Bits per pixel.
+ out = binary.LittleEndian.AppendUint32(out, uint32(len(ic.data)))
+ out = binary.LittleEndian.AppendUint32(out, uint32(offset))
+ offset += len(ic.data)
+ }
+ if _, err := enc.Wr.Write(out); err != nil {
+ return err
+ }
+ for _, ic := range icons {
+ if _, err := enc.Wr.Write(ic.data); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// encodeIcon stores one icon, as a PNG at the largest size and as a bitmap
+// below it.
+func encodeIcon(img image.Image, size int) ([]byte, error) {
+ if size >= pngAbove {
+ buf := bytes.NewBuffer(nil)
+ if err := png.Encode(buf, withAlpha{img}); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+ }
+ return encodeBMP(img, size), nil
+}
+
+// withAlpha reports that an image has transparency whatever its pixels hold,
+// so image/png gives the icon an alpha channel. Windows reads a PNG icon only
+// when it is stored as 32 bit RGBA, and the encoder writes 24 bit truecolour
+// for an image that is entirely opaque.
+type withAlpha struct{ image.Image }
+
+func (withAlpha) Opaque() bool { return false }
+
+// encodeBMP writes the bitmap an icon holds: a header, the pixels bottom up
+// in blue, green, red, alpha order, then the one bit mask that predates the
+// alpha channel and that some of Windows still reads.
+func encodeBMP(img image.Image, size int) []byte {
+ var (
+ maskStride = ((size + 31) / 32) * 4
+ pixels = size * size * 4
+ out = make([]byte, 0, headerSize+pixels+maskStride*size)
+ )
+ out = binary.LittleEndian.AppendUint32(out, headerSize)
+ out = binary.LittleEndian.AppendUint32(out, uint32(size))
+ // The height covers the pixels and the mask together.
+ out = binary.LittleEndian.AppendUint32(out, uint32(size*2))
+ out = binary.LittleEndian.AppendUint16(out, 1)
+ out = binary.LittleEndian.AppendUint16(out, 32)
+ out = binary.LittleEndian.AppendUint32(out, 0) // Uncompressed.
+ out = binary.LittleEndian.AppendUint32(out, uint32(pixels+maskStride*size))
+ out = binary.LittleEndian.AppendUint32(out, 0) // Pixels per metre, across.
+ out = binary.LittleEndian.AppendUint32(out, 0) // Pixels per metre, down.
+ out = binary.LittleEndian.AppendUint32(out, 0) // Colours used.
+ out = binary.LittleEndian.AppendUint32(out, 0) // Colours that matter.
+
+ origin := img.Bounds().Min
+ mask := make([]byte, maskStride*size)
+ for y := size - 1; y >= 0; y-- {
+ for x := 0; x < size; x++ {
+ c := color.NRGBAModel.Convert(img.At(origin.X+x, origin.Y+y)).(color.NRGBA)
+ out = append(out, c.B, c.G, c.R, c.A)
+ if c.A == 0 {
+ // A set bit means the background shows through.
+ row := (size - 1 - y) * maskStride
+ mask[row+x/8] |= 0x80 >> (x % 8)
+ }
+ }
+ }
+ return append(out, mask...)
+}
diff --git a/readme.md b/readme.md
@@ -166,6 +166,22 @@ for _, icon := range d.Icons() { // Largest first.
`Entry.Payload` returns the bytes the file stores, for handling an element yourself.
+## Windows icons
+
+`ico` is a sibling package for the Windows `.ico` format, with the same shape as the icns API, so one mental model covers both.
+
+```go
+import "github.com/jackmordaunt/icns/v4/ico"
+
+if err := ico.Encode(dest, srcImg); err != nil {
+ log.Fatalf("encoding ico: %v", err)
+}
+```
+
+A file is written with an icon at 256, 128, 64, 48, 32, 24 and 16 pixels, skipping any larger than the source. The 256 is a PNG, since a bitmap at that size is a quarter of a megabyte on its own; the rest are 32-bit bitmaps with the one-bit mask Windows still reads. `NewEncoder(dest).EncodeSizes(images)` takes a drawing per size, as `EncodeSlots` does for icns.
+
+Decoding reads PNG icons and bitmaps at 1, 4, 8, 24 and 32 bits per pixel, taking the colour table from the file and the alpha from the mask where the pixels carry none. `NewDecoder`, `Icons`, `Entry.Decode` and `Entry.Payload` work as their icns counterparts do, and the package registers itself with `image.Decode`.
+
## Development
The repository is a Go workspace of three modules:
@@ -202,6 +218,7 @@ $env:GOWORK = 'off'; go get github.com/jackmordaunt/icns/v4@latest; go mod tidy;
- [x] Implement Decoder: `.icns -> image.Image`
- [x] Symmetric test: `decode(encode(img)) == img`
- [x] Windows Explorer thumbnails
+- [x] Windows `.ico` encoder and decoder
## Coffee