icns

Easily create .icns files (Mac Icons) with this Go library or the included CLI.
Log | Files | Refs | LICENSE

commit a0554fe76cb70ad063a045dcbe2428a7f0c38dbd
parent 0cebcdbed776a5835e0115d58e622ec24258f3d2
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Fri, 18 Sep 2026 16:09:32 -0400

icns: decode one icon without decoding the rest

DecodeAll was the only way in, so a caller after a single size paid to decode
every other. The stored bytes also had nowhere to go for formats this package
cannot read, leaving a JPEG 2000 file a dead end rather than something the
caller could handle itself.

Diffstat:
Mreader.go | 135++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mreader_test.go | 49+++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 135 insertions(+), 49 deletions(-)

diff --git a/reader.go b/reader.go @@ -6,27 +6,96 @@ import ( "fmt" "image" "io" + "slices" "sort" ) var jpeg2000header = []byte{0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20} +// Decoder reads an icns 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 := decode(r) + if err != nil { + return nil, err + } + // Largest first, keeping file order between icons of equal size. + sort.SliceStable(entries, func(ii, jj int) bool { + return entries[ii].Size > entries[jj].Size + }) + 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 icns file, before its pixels are decoded. +type Entry struct { + IconDescription + + data []byte + // mask holds the alpha channel for ImageFormatRGB icons, when the file + // carries the matching mask element. + mask []byte +} + +// Decode decodes the icon's pixels. +func (e Entry) Decode() (image.Image, error) { + switch e.ImageFormat { + case ImageFormatJPEG2000: + return nil, fmt.Errorf("%w: icon %s is %s", ErrUnsupportedFormat, e.OsType, e.ImageFormat) + case ImageFormatRGB: + data := e.data + // it32 is the one colour element that prefixes its planes with four + // zero bytes. + if e.ID == "it32" && len(data) >= 4 && binary.BigEndian.Uint32(data[:4]) == 0 { + data = data[4:] + } + img, err := decodeRGB(data, e.mask, int(e.Size)) + if err != nil { + return nil, fmt.Errorf("decoding icon %s %s: %w", e.OsType, e.ImageFormat, err) + } + return img, nil + default: + img, _, err := image.Decode(bytes.NewReader(e.data)) + if err != nil { + return nil, fmt.Errorf("decoding icon %s %s: %w", e.OsType, e.ImageFormat, err) + } + return img, nil + } +} + +// Payload returns the bytes the file stores for the icon, which lets a caller +// handle a format this package cannot. For PNG and JPEG 2000 icons it is a +// complete image file; for the colour and mask types it is the run-length +// encoded colour planes, without the mask that holds their alpha. +// +// The bytes are not copied, and must not be modified. +func (e Entry) Payload() []byte { + return e.data +} + // Decode returns the largest decodable icon in the icns file, ignoring all // other sizes. JPEG 2000 icons are skipped due to lack of image decoding // support, so the result may be smaller than the largest icon present. func Decode(r io.Reader) (image.Image, error) { - icons, err := decode(r) + d, err := NewDecoder(r) if err != nil { return nil, err } - sort.Slice(icons, func(ii, jj int) bool { - return icons[ii].OsType.Size > icons[jj].OsType.Size - }) - for _, icon := range icons { + for _, icon := range d.entries { if icon.ImageFormat == ImageFormatJPEG2000 { continue } - return icon.image() + return icon.Decode() } return nil, fmt.Errorf("%w: only %s icons present", ErrUnsupportedFormat, ImageFormatJPEG2000) } @@ -35,15 +104,15 @@ func Decode(r io.Reader) (image.Image, error) { // package can decode. JPEG 2000 is ignored due to lack of image decoding // support. func DecodeAll(r io.Reader) (images []image.Image, err error) { - icons, err := decode(r) + d, err := NewDecoder(r) if err != nil { return nil, err } - for _, icon := range icons { + for _, icon := range d.entries { if icon.ImageFormat == ImageFormatJPEG2000 { continue } - img, err := icon.image() + img, err := icon.Decode() if err != nil { return nil, err } @@ -52,7 +121,9 @@ func DecodeAll(r io.Reader) (images []image.Image, err error) { if len(images) == 0 { return nil, fmt.Errorf("%w: only %s icons present", ErrUnsupportedFormat, ImageFormatJPEG2000) } - sort.Slice(images, func(ii, jj int) bool { + // An element may hold an image of a size other than the one its type + // names, so order by what was actually decoded. + sort.SliceStable(images, func(ii, jj int) bool { var ( left = images[ii].Bounds().Size() right = images[jj].Bounds().Size() @@ -62,13 +133,13 @@ func DecodeAll(r io.Reader) (images []image.Image, err error) { return images, nil } -// Probe extracts descriptions of the icons in the icns. +// Probe extracts descriptions of the icons in the icns, largest first. func Probe(r io.Reader) (desc []IconDescription, _ error) { - icons, err := decode(r) + d, err := NewDecoder(r) if err != nil { return nil, err } - for _, icon := range icons { + for _, icon := range d.entries { desc = append(desc, icon.IconDescription) } return desc, nil @@ -91,7 +162,7 @@ type element struct { // The file itself is one such element of type "icns" enclosing the rest. // Every length is checked against the data present, and input that disagrees // is reported as ErrMalformed. -func decode(r io.Reader) (icons []iconReader, err error) { +func decode(r io.Reader) (icons []Entry, err error) { elements, err := elementsOf(r) if err != nil { return nil, err @@ -110,7 +181,7 @@ func decode(r io.Reader) (icons []iconReader, err error) { // decode. continue } - icon := iconReader{ + icon := Entry{ IconDescription: IconDescription{OsType: osType}, data: el.payload, } @@ -166,40 +237,6 @@ func elementsOf(r io.Reader) ([]element, error) { return elements, nil } -type iconReader struct { - IconDescription - data []byte - // mask holds the alpha channel for ImageFormatRGB icons, when the file - // carries the matching mask element. - mask []byte -} - -// image decodes the icon's pixels. -func (ir iconReader) image() (image.Image, error) { - switch ir.ImageFormat { - case ImageFormatJPEG2000: - return nil, fmt.Errorf("%w: icon %s is %s", ErrUnsupportedFormat, ir.OsType, ir.ImageFormat) - case ImageFormatRGB: - data := ir.data - // it32 is the one colour element that prefixes its planes with four - // zero bytes. - if ir.ID == "it32" && len(data) >= 4 && binary.BigEndian.Uint32(data[:4]) == 0 { - data = data[4:] - } - img, err := decodeRGB(data, ir.mask, int(ir.Size)) - if err != nil { - return nil, fmt.Errorf("decoding icon %s %s: %w", ir.OsType, ir.ImageFormat, err) - } - return img, nil - default: - img, _, err := image.Decode(bytes.NewReader(ir.data)) - if err != nil { - return nil, fmt.Errorf("decoding icon %s %s: %w", ir.OsType, ir.ImageFormat, err) - } - return img, nil - } -} - func isOsType(ID string) bool { _, ok := getTypeFromID(ID) return ok diff --git a/reader_test.go b/reader_test.go @@ -7,6 +7,7 @@ import ( "image" "image/color" "image/png" + "reflect" "testing" ) @@ -111,6 +112,54 @@ func TestDecodeFallsBackPastJPEG2000(t *testing.T) { } } +func TestDecoder(t *testing.T) { + t.Parallel() + png128 := pngBytes(t, 128) + data := file( + encodeElement("ic11", pngBytes(t, 32)), + encodeElement("ic10", jpeg2000header), + encodeElement("ic07", png128), + ) + d, err := NewDecoder(bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + icons := d.Icons() + var got []string + for _, icon := range icons { + got = append(got, icon.ID) + } + if want := []string{"ic10", "ic07", "ic11"}; !reflect.DeepEqual(got, want) { + t.Fatalf("icons = %v, want %v largest first", got, want) + } + + // An icon this package cannot decode still hands over its bytes, so a + // caller can bring its own decoder. + if _, err := icons[0].Decode(); !errors.Is(err, ErrUnsupportedFormat) { + t.Errorf("decoding the JPEG 2000 icon = %v, want ErrUnsupportedFormat", err) + } + if !bytes.Equal(icons[0].Payload(), jpeg2000header) { + t.Error("the JPEG 2000 icon's payload is not the stored bytes") + } + + img, err := icons[1].Decode() + if err != nil { + t.Fatal(err) + } + if got := img.Bounds().Dx(); got != 128 { + t.Errorf("decoded a %dpx icon, want 128", got) + } + if !bytes.Equal(icons[1].Payload(), png128) { + t.Error("the PNG icon's payload is not the stored file") + } + + // The returned slice is the caller's to reorder. + icons[0] = Entry{} + if again := d.Icons(); again[0].ID != "ic10" { + t.Errorf("Icons was affected by a change to an earlier result: %v", again[0].ID) + } +} + // FuzzDecode checks that arbitrary input never panics or hangs the decoder. func FuzzDecode(f *testing.F) { var valid bytes.Buffer