icns

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

commit d56977a3f014f7f4baf59e250f5a7d530ce6eb35
parent 1603cb1eb48f3f5767cb91baa28db86586dfe8e2
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Fri, 18 Sep 2026 16:09:35 -0400

icns: decode the ARGB sidebar and toolbar icons

Sidebar and toolbar icons hold their alpha in a plane of their own behind an
ARGB header, and were skipped, so those sizes went missing from files that
carry them. The types take either ARGB or PNG, so the payload now decides
rather than the type.

Diffstat:
Aargb_test.go | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Micns.go | 13+++++++++++++
Mreader.go | 25++++++++++++++++++-------
Mreader_test.go | 4++++
Mrle.go | 21+++++++++++++++++++++
5 files changed, 158 insertions(+), 7 deletions(-)

diff --git a/argb_test.go b/argb_test.go @@ -0,0 +1,102 @@ +package icns + +import ( + "bytes" + "errors" + "image" + "image/color" + "testing" +) + +// argbElement builds an ARGB payload for a gradient, returning it and the +// image it describes. +func argbElement(side int) (payload []byte, want *image.NRGBA) { + pixels := side * side + planes := make([]byte, pixels*4) + want = image.NewNRGBA(image.Rect(0, 0, side, side)) + for y := 0; y < side; y++ { + for x := 0; x < side; x++ { + i := y*side + x + c := color.NRGBA{ + R: uint8(x * 255 / side), + G: uint8(y * 255 / side), + B: 0x80, + A: uint8(255 - x*128/side), + } + // Alpha leads, then the colour channels. + planes[i] = c.A + planes[pixels+i] = c.R + planes[pixels*2+i] = c.G + planes[pixels*3+i] = c.B + want.SetNRGBA(x, y, c) + } + } + return append([]byte("ARGB"), rleLiterals(planes)...), want +} + +func TestDecodeARGB(t *testing.T) { + t.Parallel() + const side = 16 + payload, want := argbElement(side) + + t.Run("sidebar icon", func(st *testing.T) { + data := file(encodeElement("ic04", payload)) + desc, err := Probe(bytes.NewReader(data)) + if err != nil { + st.Fatal(err) + } + if len(desc) != 1 || desc[0].ImageFormat != ImageFormatARGB { + st.Fatalf("Probe = %v, want one ARGB icon", desc) + } + img, err := Decode(bytes.NewReader(data)) + if err != nil { + st.Fatal(err) + } + if !imageCompare(img, want) { + st.Fatal("the decoded icon differs from the source") + } + }) + + t.Run("compressed run", func(st *testing.T) { + // A flat image compresses to repeats, which exercises the other half + // of the run-length decoder. icsb is 18 pixels, not 16. + const icsbSide = 18 + planes := make([]byte, icsbSide*icsbSide*4) + for i := range planes { + planes[i] = 0xFF + } + data := file(encodeElement("icsb", append([]byte("ARGB"), packRLE(planes)...))) + img, err := Decode(bytes.NewReader(data)) + if err != nil { + st.Fatal(err) + } + if got := img.Bounds().Dx(); got != 18 { + st.Fatalf("decoded a %dpx icon, want 18", got) + } + if got := color.NRGBAModel.Convert(img.At(9, 9)).(color.NRGBA); got != (color.NRGBA{0xFF, 0xFF, 0xFF, 0xFF}) { + st.Fatalf("centre = %v, want opaque white", got) + } + }) + + t.Run("png in an ARGB capable type", func(st *testing.T) { + // These types hold either format, so the payload has to decide. + data := file(encodeElement("ic05", pngBytes(t, 32))) + desc, err := Probe(bytes.NewReader(data)) + if err != nil { + st.Fatal(err) + } + if desc[0].ImageFormat != ImageFormatPNG { + st.Fatalf("format = %s, want PNG", desc[0].ImageFormat) + } + if _, err := Decode(bytes.NewReader(data)); err != nil { + st.Fatal(err) + } + }) + + t.Run("truncated planes", func(st *testing.T) { + data := file(encodeElement("ic04", []byte("ARGB\x01\x02\x03"))) + if _, err := Decode(bytes.NewReader(data)); !errors.Is(err, ErrMalformed) { + st.Fatalf("error = %v, want ErrMalformed", err) + } + }) +} diff --git a/icns.go b/icns.go @@ -213,6 +213,9 @@ const ( // ImageFormatRGB is 24-bit colour in run-length encoded channel planes, // with alpha held in a separate mask element. ImageFormatRGB + // ImageFormatARGB is run-length encoded channel planes that carry their + // own alpha, behind an "ARGB" header. + ImageFormatARGB ) func (f ImageFormat) String() string { @@ -223,6 +226,8 @@ func (f ImageFormat) String() string { return "JPEG 2000" case ImageFormatRGB: return "24-bit RGB" + case ImageFormatARGB: + return "ARGB" } return fmt.Sprintf("unknown format %d", f) } @@ -272,6 +277,14 @@ var osTypes = []OsType{ {ID: "icp5", Size: 32}, {ID: "icp4", Size: 16}, + // Toolbar and sidebar icons, which hold ARGB or PNG. + {ID: "SB24", Size: 48}, + {ID: "icsB", Size: 36}, + {ID: "ic05", Size: 32}, + {ID: "sb24", Size: 24}, + {ID: "icsb", Size: 18}, + {ID: "ic04", Size: 16}, + // The small sizes are written as colour and mask rather than PNG, which // is what Apple still emits for them: icp4 and icp5 hold PNG but do not // render from an app bundle. diff --git a/reader.go b/reader.go @@ -10,7 +10,10 @@ import ( "slices" ) -var jpeg2000header = []byte{0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20} +var ( + jpeg2000header = []byte{0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20} + argbHeader = []byte("ARGB") +) // Decoder reads an icns file and decodes its icons on demand, so a caller // after one size does not pay for the rest. @@ -64,6 +67,12 @@ func (e Entry) Decode() (image.Image, error) { return nil, fmt.Errorf("decoding icon %s %s: %w", e.OsType, e.ImageFormat, err) } return img, nil + case ImageFormatARGB: + img, err := decodeARGB(e.data[len(argbHeader):], 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 { @@ -182,14 +191,16 @@ func decode(r io.Reader) (icons []Entry, err error) { IconDescription: IconDescription{OsType: osType}, data: el.payload, } - switch osType.enc { - case encodingRGB: + // Several types carry more than one format, so the payload decides + // wherever it says what it holds. + switch { + case osType.enc == encodingRGB: icon.ImageFormat = ImageFormatRGB icon.mask = payloads[osType.mask] - default: - if bytes.HasPrefix(el.payload, jpeg2000header) { - icon.ImageFormat = ImageFormatJPEG2000 - } + case bytes.HasPrefix(el.payload, argbHeader): + icon.ImageFormat = ImageFormatARGB + case bytes.HasPrefix(el.payload, jpeg2000header): + icon.ImageFormat = ImageFormatJPEG2000 } icons = append(icons, icon) } diff --git a/reader_test.go b/reader_test.go @@ -178,6 +178,10 @@ func FuzzDecode(f *testing.F) { f.Add(file(encodeElement("is32", rgb))) f.Add(file(encodeElement("it32", []byte{0, 0, 0, 0, 0xFF, 0x01}))) f.Add(file(encodeElement("il32", []byte{0xFF}), encodeElement("l8mk", mask))) + // ARGB, which runs the same decoder over four planes. + argb, _ := argbElement(16) + f.Add(file(encodeElement("ic04", argb))) + f.Add(file(encodeElement("ic04", []byte("ARGB")))) f.Fuzz(func(t *testing.T, data []byte) { Probe(bytes.NewReader(data)) Decode(bytes.NewReader(data)) diff --git a/rle.go b/rle.go @@ -106,6 +106,27 @@ func splitPlanes(img image.Image, side int) (planes, mask []byte) { return planes, mask } +// decodeARGB builds an image from the four run-length encoded planes that +// follow an ARGB header, alpha first and then the colour channels. +func decodeARGB(data []byte, side int) (image.Image, error) { + pixels := side * side + planes, err := unpackRLE(data, pixels*4) + if err != nil { + return nil, err + } + // Alpha is a plane of its own rather than folded into the colour, so the + // result is non-premultiplied. + img := image.NewNRGBA(image.Rect(0, 0, side, side)) + for i := 0; i < pixels; i++ { + px := img.Pix[i*4 : i*4+4 : i*4+4] + px[3] = planes[i] + px[0] = planes[pixels+i] + px[1] = planes[pixels*2+i] + px[2] = planes[pixels*3+i] + } + return img, nil +} + // decodeRGB builds an image from run-length encoded colour planes and the // raw alpha of the matching mask element. A missing mask leaves the icon // opaque, which is how the icons that predate masks are meant to render.