icns

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

commit eeb191d483b2832b63f1a1e1da839d09cdf871b6
parent d42b4fcb13f7994a900d73eec35031c423bb9bd7
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Fri, 18 Sep 2026 16:09:38 -0400

icns: let registered decoders read the compressed elements

Refusing JPEG 2000 outright made a file written between 10.5 and 10.6
unreadable, with no way in short of the raw bytes. Handing those elements to
image.Decode means a program that imports a codec reads them, and one that
does not carries no dependency.

Diffstat:
Mreader.go | 42+++++++++++++++++++++++-------------------
Mreader_test.go | 38++++++++++++++++++++++++++++++++++++++
Mreadme.md | 6++++--
3 files changed, 65 insertions(+), 21 deletions(-)

diff --git a/reader.go b/reader.go @@ -4,6 +4,7 @@ import ( "bytes" "cmp" "encoding/binary" + "errors" "fmt" "image" "io" @@ -54,11 +55,11 @@ type Entry struct { mask []byte } -// Decode decodes the icon's pixels. +// Decode decodes the icon's pixels. The formats icns defines itself are +// decoded here; an element holding a whole image file is passed to +// image.Decode, so it is read by whatever the program has registered. 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 @@ -85,6 +86,9 @@ func (e Entry) Decode() (image.Image, error) { return img, nil default: img, _, err := image.Decode(bytes.NewReader(e.data)) + if errors.Is(err, image.ErrFormat) { + return nil, fmt.Errorf("%w: icon %s is %s, which no registered decoder reads", ErrUnsupportedFormat, e.OsType, e.ImageFormat) + } if err != nil { return nil, fmt.Errorf("decoding icon %s %s: %w", e.OsType, e.ImageFormat, err) } @@ -141,53 +145,53 @@ func (e Entry) colours() int { } } -// 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. +// Payload returns the bytes the file stores for the icon. 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. +// Decode returns the largest icon in the icns file that can be decoded, +// ignoring all other sizes. 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 { - if icon.ImageFormat == ImageFormatJPEG2000 { + img, err := icon.Decode() + if errors.Is(err, ErrUnsupportedFormat) { continue } - return icon.Decode() + return img, err } - return nil, fmt.Errorf("%w: only %s icons present", ErrUnsupportedFormat, ImageFormatJPEG2000) + return nil, fmt.Errorf("%w: no icon is in a format a registered decoder reads", ErrUnsupportedFormat) } -// DecodeAll extracts every icon resolution present in the icns data that this -// package can decode. JPEG 2000 is ignored due to lack of image decoding -// support. +// DecodeAll extracts every icon resolution present in the icns data that can +// be decoded. An icon in a format no registered decoder reads is ignored. 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 { - if icon.ImageFormat == ImageFormatJPEG2000 { + img, err := icon.Decode() + if errors.Is(err, ErrUnsupportedFormat) { continue } - img, err := icon.Decode() if err != nil { return nil, err } images = append(images, img) } if len(images) == 0 { - return nil, fmt.Errorf("%w: only %s icons present", ErrUnsupportedFormat, ImageFormatJPEG2000) + return nil, fmt.Errorf("%w: no icon is in a format a registered decoder reads", ErrUnsupportedFormat) } // An element may hold an image of a size other than the one its type // names, so order by what was actually decoded. diff --git a/reader_test.go b/reader_test.go @@ -7,6 +7,7 @@ import ( "image" "image/color" "image/png" + "io" "reflect" "testing" ) @@ -160,6 +161,43 @@ func TestDecoder(t *testing.T) { } } +// TestDecodeUsesRegisteredFormats checks that an element holding a whole +// image file is handed to whatever decoder the program registered. That is +// how a JPEG 2000 icon is read without this package depending on a codec for +// it: the caller imports one, and these elements start decoding. +func TestDecodeUsesRegisteredFormats(t *testing.T) { + // Registration is global and cannot be undone, so this uses a magic + // nothing else does and does not run in parallel. + const magic = "notarealformat" + want := color.NRGBA{R: 0x11, G: 0x22, B: 0x33, A: 0xFF} + image.RegisterFormat("notareal", magic, + func(r io.Reader) (image.Image, error) { return solid(64, want), nil }, + func(r io.Reader) (image.Config, error) { + return image.Config{Width: 64, Height: 64, ColorModel: color.NRGBAModel}, nil + }) + + data := file(encodeElement("ic12", []byte(magic+" and then the pixels"))) + img, err := Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("an element in a registered format did not decode: %v", err) + } + if got := centre(img); got != want { + t.Fatalf("decoded %v, want %v from the registered decoder", got, want) + } +} + +// TestDecodeWithoutARegisteredFormat checks the other side of that: an icon +// nothing can read is reported as unsupported rather than as corrupt, which +// is what lets the callers above skip past it to a size they can read. +func TestDecodeWithoutARegisteredFormat(t *testing.T) { + t.Parallel() + data := file(encodeElement("ic07", append(jpeg2000header, 1, 2, 3, 4))) + _, err := Decode(bytes.NewReader(data)) + if !errors.Is(err, ErrUnsupportedFormat) { + t.Fatalf("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 diff --git a/readme.md b/readme.md @@ -16,7 +16,9 @@ With this library you can use pure Go to create `icns` files from any source ima A small CLI app `icnsify` is provided allowing you to create icns files using this library from the command line. It supports piping, which is something `iconutil` does not do, making it substantially easier to wrap or chuck into a shell pipeline. -Note: `icns` files are written with an icon at every size macOS draws, the retina OSTypes for the larger ones and the colour and mask pair Apple still uses at 16 and 32 pixels. Decoding reaches further back than writing does. Alongside PNG it understands the `is32`, `il32`, `ih32` and `it32` colour and mask elements, the `ARGB` sidebar and toolbar icons, and the 1-, 4- and 8-bit indexed icons of System 7 through Mac OS 8. Where a file holds the same icon at several depths, the richest is returned first. JPEG 2000 icons are identified but not decoded; `Entry.Payload` hands over their bytes. +Note: `icns` files are written with an icon at every size macOS draws, the retina OSTypes for the larger ones and the colour and mask pair Apple still uses at 16 and 32 pixels. Decoding reaches further back than writing does. Alongside PNG it understands the `is32`, `il32`, `ih32` and `it32` colour and mask elements, the `ARGB` sidebar and toolbar icons, and the 1-, 4- and 8-bit indexed icons of System 7 through Mac OS 8. Where a file holds the same icon at several depths, the richest is returned first. + +Elements that hold a whole image file are passed to `image.Decode`, so they read in whatever formats the program has registered. That is how a JPEG 2000 icon is handled: import a decoder for it and those icons start decoding, while a program that does not carries no codec and skips past them to a size it can read. ## GUI @@ -162,7 +164,7 @@ for _, icon := range d.Icons() { // Largest first. } ``` -`Entry.Payload` returns the bytes the file stores, which is how to reach a JPEG 2000 icon: this package identifies that format but cannot decode it. +`Entry.Payload` returns the bytes the file stores, for handling an element yourself. ## Development