icns

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

commit 6d8994a7f3fa99bfa83d24d9455802673f00f08a
parent 0438531d4191fa06d4c1174ee2eaa194e7692fdf
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Fri, 18 Sep 2026 16:09:30 -0400

icns: decode the legacy colour and mask icon types

Icons written before 10.5, and the small sizes Apple still writes today, hold
run-length encoded colour planes with alpha in a separate mask element. The
decoder skipped them, so a 16px icon was invisible and older files looked
empty. Verified against an Apple-produced file.

Diffstat:
Micns.go | 57+++++++++++++++++++++++++++++++++++++++++++++------------
Mreader.go | 120++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mreader_test.go | 36+++++++++++++++++++++---------------
Arle.go | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Arle_test.go | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 453 insertions(+), 58 deletions(-)

diff --git a/icns.go b/icns.go @@ -159,6 +159,9 @@ type ImageFormat int const ( ImageFormatPNG ImageFormat = iota ImageFormatJPEG2000 + // ImageFormatRGB is 24-bit colour in run-length encoded channel planes, + // with alpha held in a separate mask element. + ImageFormatRGB ) func (f ImageFormat) String() string { @@ -167,14 +170,35 @@ func (f ImageFormat) String() string { return "PNG" case ImageFormatJPEG2000: return "JPEG 2000" + case ImageFormatRGB: + return "24-bit RGB" } return fmt.Sprintf("unknown format %d", f) } +// encoding is how an element stores its image data. +type encoding int + +const ( + // encodingCompressed holds a whole image file, PNG or JPEG 2000. + encodingCompressed encoding = iota + // encodingRGB holds run-length encoded colour planes, with alpha in the + // separate element named by OsType.mask. + encodingRGB +) + // OsType is a 4 character identifier used to differentiate icon types. type OsType struct { ID string Size uint + + // enc is how this element stores its image data. + enc encoding + // mask is the element holding this type's alpha, for encodingRGB. + mask string + // emit marks the types the encoder writes. More types can be read than + // are written. + emit bool } func (t OsType) String() string { @@ -182,22 +206,31 @@ func (t OsType) String() string { } var osTypes = []OsType{ - {ID: "ic10", Size: uint(1024)}, - {ID: "ic14", Size: uint(512)}, - {ID: "ic09", Size: uint(512)}, - {ID: "ic13", Size: uint(256)}, - {ID: "ic08", Size: uint(256)}, - {ID: "ic07", Size: uint(128)}, - {ID: "ic12", Size: uint(64)}, - {ID: "ic11", Size: uint(32)}, -} - -// getTypesFromSize returns the types for the given icon size (in px). + {ID: "ic10", Size: 1024, emit: true}, + {ID: "ic14", Size: 512, emit: true}, + {ID: "ic09", Size: 512, emit: true}, + {ID: "ic13", Size: 256, emit: true}, + {ID: "ic08", Size: 256, emit: true}, + {ID: "ic07", Size: 128, emit: true}, + {ID: "ic12", Size: 64, emit: true}, + {ID: "ic11", Size: 32, emit: true}, + + {ID: "icp6", Size: 48}, + {ID: "icp5", Size: 32}, + {ID: "icp4", Size: 16}, + + {ID: "it32", Size: 128, enc: encodingRGB, mask: "t8mk"}, + {ID: "ih32", Size: 48, enc: encodingRGB, mask: "h8mk"}, + {ID: "il32", Size: 32, enc: encodingRGB, mask: "l8mk"}, + {ID: "is32", Size: 16, enc: encodingRGB, mask: "s8mk"}, +} + +// getTypesFromSize returns the writable types for the given icon size (in px). // The boolean indicates whether the types exist. func getTypesFromSize(size uint) ([]OsType, bool) { var retOsTypes []OsType for _, t := range osTypes { - if t.Size == size { + if t.Size == size && t.emit { retOsTypes = append(retOsTypes, t) } } diff --git a/reader.go b/reader.go @@ -26,17 +26,13 @@ func Decode(r io.Reader) (image.Image, error) { if icon.ImageFormat == ImageFormatJPEG2000 { continue } - img, _, err := image.Decode(icon.r) - if err != nil { - return nil, fmt.Errorf("decoding icon %s %s: %w", icon.OsType, icon.ImageFormat, err) - } - return img, nil + return icon.image() } return nil, fmt.Errorf("%w: only %s icons present", ErrUnsupportedFormat, ImageFormatJPEG2000) } -// DecodeAll extracts all icon resolutions present in the icns data that -// contain PNG data. JPEG 2000 is ignored due to lack of image decoding +// 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. func DecodeAll(r io.Reader) (images []image.Image, err error) { icons, err := decode(r) @@ -44,12 +40,12 @@ func DecodeAll(r io.Reader) (images []image.Image, err error) { return nil, err } for _, icon := range icons { - if icon.IconDescription.ImageFormat == ImageFormatJPEG2000 { + if icon.ImageFormat == ImageFormatJPEG2000 { continue } - img, _, err := image.Decode(icon.r) + img, err := icon.image() if err != nil { - return nil, fmt.Errorf("decoding icon %s %s: %w", icon.OsType, icon.ImageFormat, err) + return nil, err } images = append(images, img) } @@ -82,6 +78,12 @@ func Probe(r io.Reader) (desc []IconDescription, _ error) { // every element, the file header included. const elementHeaderSize = 8 +// element is one type and payload pair from the file. +type element struct { + id string + payload []byte +} + // decode identifies the icons in the icns without decoding the image data. // // An icns file is a sequence of elements, each a 4-byte type followed by a @@ -90,6 +92,47 @@ const elementHeaderSize = 8 // 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) { + elements, err := elementsOf(r) + if err != nil { + return nil, err + } + // Masks are separate elements that may appear either side of the icon + // they belong to, so the payloads are indexed before they are paired up. + payloads := make(map[string][]byte, len(elements)) + for _, el := range elements { + payloads[el.id] = el.payload + } + for _, el := range elements { + osType, ok := getTypeFromID(el.id) + if !ok || len(el.payload) == 0 { + // Elements this package does not read: the table of contents, + // version and name records, masks, and icon types it cannot + // decode. + continue + } + icon := iconReader{ + IconDescription: IconDescription{OsType: osType}, + data: el.payload, + } + switch osType.enc { + case encodingRGB: + icon.ImageFormat = ImageFormatRGB + icon.mask = payloads[osType.mask] + default: + if bytes.HasPrefix(el.payload, jpeg2000header) { + icon.ImageFormat = ImageFormatJPEG2000 + } + } + icons = append(icons, icon) + } + if len(icons) == 0 { + return nil, ErrNoIcons + } + return icons, nil +} + +// elementsOf splits an icns file into its elements. +func elementsOf(r io.Reader) ([]element, error) { data, err := io.ReadAll(r) if err != nil { return nil, err @@ -102,6 +145,7 @@ func decode(r io.Reader) (icons []iconReader, err error) { return nil, fmt.Errorf("%w: header declares %d bytes but only %d are present", ErrMalformed, fileSize, len(data)) } data = data[:fileSize] + var elements []element for offset := elementHeaderSize; offset < len(data); { if len(data)-offset < elementHeaderSize { return nil, fmt.Errorf("%w: truncated element header at offset %d", ErrMalformed, offset) @@ -113,33 +157,47 @@ func decode(r io.Reader) (icons []iconReader, err error) { if size < elementHeaderSize || size > len(data)-offset { return nil, fmt.Errorf("%w: element %q at offset %d declares %d bytes", ErrMalformed, id, offset, size) } - payload := data[offset+elementHeaderSize : offset+size] + elements = append(elements, element{ + id: id, + payload: data[offset+elementHeaderSize : offset+size], + }) offset += size - // Elements other than icons ("TOC ", "icnV", "name", "info", ...) - // and icons of legacy types carry nothing we can decode; skip them. - if !isOsType(id) || len(payload) == 0 { - continue - } - ir := iconReader{ - IconDescription: IconDescription{ - OsType: osTypeFromID(id), - }, - r: bytes.NewReader(payload), - } - if bytes.HasPrefix(payload, jpeg2000header) { - ir.ImageFormat = ImageFormatJPEG2000 - } - icons = append(icons, ir) } - if len(icons) == 0 { - return nil, ErrNoIcons - } - return icons, nil + return elements, nil } type iconReader struct { IconDescription - r io.Reader + 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 { diff --git a/reader_test.go b/reader_test.go @@ -10,9 +10,9 @@ import ( "testing" ) -// element builds one icns element: 4-byte type, 4-byte big-endian length of -// the whole element, then the payload. -func element(id string, payload []byte) []byte { +// encodeElement builds one icns element: 4-byte type, 4-byte big-endian +// length of the whole element, then the payload. +func encodeElement(id string, payload []byte) []byte { out := make([]byte, 0, elementHeaderSize+len(payload)) out = append(out, id...) out = binary.BigEndian.AppendUint32(out, uint32(elementHeaderSize+len(payload))) @@ -21,7 +21,7 @@ func element(id string, payload []byte) []byte { // file wraps elements in an icns header with a correct length. func file(elements ...[]byte) []byte { - return element("icns", bytes.Join(elements, nil)) + return encodeElement("icns", bytes.Join(elements, nil)) } func pngBytes(t testing.TB, side int) []byte { @@ -42,16 +42,16 @@ func TestDecodeMalformed(t *testing.T) { }{ {"empty", nil, ErrInvalidHeader}, {"short", []byte("ic"), ErrInvalidHeader}, - {"wrong magic", element("ICNS", nil), ErrInvalidHeader}, + {"wrong magic", encodeElement("ICNS", nil), ErrInvalidHeader}, {"header only", file(), ErrNoIcons}, {"declared size exceeds data", append([]byte("icns"), 0xff, 0xff, 0xff, 0xff), ErrMalformed}, {"truncated element header", append([]byte("icns\x00\x00\x00\x0c"), 'i', 'c', '0', '7'), ErrMalformed}, {"element size below header", file(append([]byte("ic07"), 0, 0, 0, 4)), ErrMalformed}, {"element overruns file", file(append([]byte("ic07"), 0, 0, 1, 0)), ErrMalformed}, {"zero-length TOC loops forever without a check", file(append([]byte("TOC "), 0, 0, 0, 0)), ErrMalformed}, - {"unknown elements only", file(element("TOC ", []byte{1, 2, 3, 4}), element("icnV", []byte{0, 0, 0, 0})), ErrNoIcons}, - {"empty icon payload", file(element("ic07", nil)), ErrNoIcons}, - {"only jpeg2000", file(element("ic07", jpeg2000header)), ErrUnsupportedFormat}, + {"unknown elements only", file(encodeElement("TOC ", []byte{1, 2, 3, 4}), encodeElement("icnV", []byte{0, 0, 0, 0})), ErrNoIcons}, + {"empty icon payload", file(encodeElement("ic07", nil)), ErrNoIcons}, + {"only jpeg2000", file(encodeElement("ic07", jpeg2000header)), ErrUnsupportedFormat}, } for _, tt := range tests { t.Run(tt.desc, func(st *testing.T) { @@ -69,11 +69,11 @@ func TestDecodeMalformed(t *testing.T) { func TestDecodeSkipsNonIconElements(t *testing.T) { t.Parallel() data := file( - element("TOC ", []byte("ic07\x00\x00\x00\x10")), - element("icnV", []byte{0x40, 0x00, 0x00, 0x00}), - element("name", []byte("icon")), - element("ic07", pngBytes(t, 128)), - element("ic11", pngBytes(t, 32)), + encodeElement("TOC ", []byte("ic07\x00\x00\x00\x10")), + encodeElement("icnV", []byte{0x40, 0x00, 0x00, 0x00}), + encodeElement("name", []byte("icon")), + encodeElement("ic07", pngBytes(t, 128)), + encodeElement("ic11", pngBytes(t, 32)), ) desc, err := Probe(bytes.NewReader(data)) if err != nil { @@ -95,8 +95,8 @@ func TestDecodeSkipsNonIconElements(t *testing.T) { func TestDecodeFallsBackPastJPEG2000(t *testing.T) { t.Parallel() data := file( - element("ic10", jpeg2000header), // Largest, but undecodable. - element("ic07", pngBytes(t, 128)), + encodeElement("ic10", jpeg2000header), // Largest, but undecodable. + encodeElement("ic07", pngBytes(t, 128)), ) img, err := Decode(bytes.NewReader(data)) if err != nil { @@ -123,6 +123,12 @@ func FuzzDecode(f *testing.F) { f.Add(file(append([]byte("TOC "), 0, 0, 0, 0))) f.Add(file(append([]byte("ic07"), 0, 0, 0, 4))) f.Add(append([]byte("icns"), 0xff, 0xff, 0xff, 0xff)) + // Legacy colour and mask elements, which run the RLE decoder. + rgb, mask, _ := legacyIcon(16) + f.Add(file(encodeElement("is32", rgb), encodeElement("s8mk", mask))) + 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))) f.Fuzz(func(t *testing.T, data []byte) { Probe(bytes.NewReader(data)) Decode(bytes.NewReader(data)) diff --git a/rle.go b/rle.go @@ -0,0 +1,72 @@ +package icns + +import ( + "fmt" + "image" +) + +// Legacy icon types store their colour as three run-length encoded planes, +// one per channel, and their alpha in a separate mask element. + +// unpackRLE expands the icns variant of PackBits until want bytes are +// produced. A lead byte below 128 introduces lead+1 literal bytes; a lead +// byte of 128 or above repeats the byte after it lead-125 times. Data that is +// already want bytes long is stored uncompressed and is returned as it is. +func unpackRLE(data []byte, want int) ([]byte, error) { + if len(data) == want { + return data, nil + } + out := make([]byte, 0, want) + for i := 0; i < len(data) && len(out) < want; { + lead := int(data[i]) + i++ + if lead < 128 { + n := lead + 1 + if i+n > len(data) { + return nil, fmt.Errorf("%w: literal run of %d bytes overruns the element", ErrMalformed, n) + } + out = append(out, data[i:i+n]...) + i += n + continue + } + if i == len(data) { + return nil, fmt.Errorf("%w: repeat run with no byte to repeat", ErrMalformed) + } + for n := lead - 125; n > 0; n-- { + out = append(out, data[i]) + } + i++ + } + if len(out) != want { + return nil, fmt.Errorf("%w: expanded to %d bytes, want %d", ErrMalformed, len(out), want) + } + return out, 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. +func decodeRGB(data, mask []byte, side int) (image.Image, error) { + pixels := side * side + planes, err := unpackRLE(data, pixels*3) + if err != nil { + return nil, err + } + if mask != nil && len(mask) != pixels { + return nil, fmt.Errorf("%w: mask holds %d bytes, want %d", ErrMalformed, len(mask), pixels) + } + // The planes carry straight colour and the mask carries alpha, 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[0] = planes[i] + px[1] = planes[pixels+i] + px[2] = planes[pixels*2+i] + px[3] = 0xFF + if mask != nil { + px[3] = mask[i] + } + } + return img, nil +} diff --git a/rle_test.go b/rle_test.go @@ -0,0 +1,226 @@ +package icns + +import ( + "bytes" + "errors" + "image" + "image/color" + "testing" +) + +// rleLiterals encodes data as literal runs only, which is the simplest valid +// encoding and always longer than the input. +func rleLiterals(data []byte) []byte { + var out []byte + for len(data) > 0 { + n := min(len(data), 128) + out = append(out, byte(n-1)) + out = append(out, data[:n]...) + data = data[n:] + } + return out +} + +func TestUnpackRLE(t *testing.T) { + t.Parallel() + tests := []struct { + desc string + data []byte + // want is the number of bytes asked for, which the caller knows from + // the icon's dimensions. It is stated rather than derived, because a + // stream whose length equals it is read as uncompressed. + want int + out []byte + err error + }{ + { + desc: "literal run", + data: []byte{0x02, 0x01, 0x02, 0x03}, + want: 3, + out: []byte{0x01, 0x02, 0x03}, + }, + { + desc: "repeat run", + data: []byte{0x80, 0x07}, + want: 3, + out: []byte{0x07, 0x07, 0x07}, + }, + { + desc: "literal then repeat", + data: []byte{0x02, 0x01, 0x02, 0x02, 0x82, 0x03}, + want: 8, + out: []byte{0x01, 0x02, 0x02, 0x03, 0x03, 0x03, 0x03, 0x03}, + }, + { + desc: "longest repeat", + data: []byte{0xFF, 0x09}, + want: 130, + out: bytes.Repeat([]byte{0x09}, 130), + }, + { + desc: "stored uncompressed", + data: []byte{0x01, 0x02, 0x03}, + want: 3, + out: []byte{0x01, 0x02, 0x03}, + }, + { + desc: "literal run overruns the element", + data: []byte{0x7F, 0x01, 0x02}, + want: 200, + err: ErrMalformed, + }, + { + desc: "repeat run with no byte to repeat", + data: []byte{0x04, 0x01, 0x02, 0x03, 0x04, 0x05, 0x80}, + want: 200, + err: ErrMalformed, + }, + { + desc: "expands short", + data: []byte{0x00, 0x01}, + want: 3, + err: ErrMalformed, + }, + { + desc: "a single run expands past what was asked for", + data: []byte{0xFF, 0x09}, + want: 3, + err: ErrMalformed, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(st *testing.T) { + got, err := unpackRLE(tt.data, tt.want) + if tt.err != nil { + if !errors.Is(err, tt.err) { + st.Fatalf("error = %v, want %v", err, tt.err) + } + return + } + if err != nil { + st.Fatalf("unexpected error: %v", err) + } + if !bytes.Equal(got, tt.out) { + st.Fatalf("got %v, want %v", got, tt.out) + } + }) + } +} + +// legacyIcon builds an is32 colour element and its s8mk mask for a gradient, +// returning the elements and the image they describe. +func legacyIcon(side int) (rgb, mask []byte, want *image.NRGBA) { + pixels := side * side + planes := make([]byte, pixels*3) + mask = make([]byte, pixels) + 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: 0x40, + A: uint8((x + y) * 255 / (2 * side)), + } + planes[i] = c.R + planes[pixels+i] = c.G + planes[pixels*2+i] = c.B + mask[i] = c.A + want.SetNRGBA(x, y, c) + } + } + return rleLiterals(planes), mask, want +} + +func TestDecodeLegacyElements(t *testing.T) { + t.Parallel() + const side = 16 + rgb, mask, want := legacyIcon(side) + + t.Run("colour and mask", func(st *testing.T) { + data := file(encodeElement("is32", rgb), encodeElement("s8mk", mask)) + imgs, err := DecodeAll(bytes.NewReader(data)) + if err != nil { + st.Fatal(err) + } + if len(imgs) != 1 { + st.Fatalf("decoded %d icons, want 1", len(imgs)) + } + if !imageCompare(imgs[0], want) { + st.Fatal("decoded icon differs from the source") + } + }) + + t.Run("mask ahead of the colour", func(st *testing.T) { + // Apple writes the mask after its element, but nothing requires it. + data := file(encodeElement("s8mk", mask), encodeElement("is32", rgb)) + imgs, err := DecodeAll(bytes.NewReader(data)) + if err != nil { + st.Fatal(err) + } + if !imageCompare(imgs[0], want) { + st.Fatal("decoded icon differs from the source") + } + }) + + t.Run("no mask leaves the icon opaque", func(st *testing.T) { + data := file(encodeElement("is32", rgb)) + img, err := Decode(bytes.NewReader(data)) + if err != nil { + st.Fatal(err) + } + for y := 0; y < side; y++ { + for x := 0; x < side; x++ { + if _, _, _, a := img.At(x, y).RGBA(); a != 0xFFFF { + st.Fatalf("pixel (%d,%d) alpha = %d, want opaque", x, y, a) + } + } + } + }) + + t.Run("uncompressed colour", func(st *testing.T) { + pixels := side * side + planes := make([]byte, pixels*3) + for i := range planes { + planes[i] = byte(i) + } + data := file(encodeElement("is32", planes), encodeElement("s8mk", mask)) + img, err := Decode(bytes.NewReader(data)) + if err != nil { + st.Fatal(err) + } + if got := img.Bounds().Dx(); got != side { + st.Fatalf("decoded a %dpx icon, want %d", got, side) + } + }) + + t.Run("mask of the wrong length", func(st *testing.T) { + data := file(encodeElement("is32", rgb), encodeElement("s8mk", mask[:10])) + if _, err := Decode(bytes.NewReader(data)); !errors.Is(err, ErrMalformed) { + st.Fatalf("error = %v, want ErrMalformed", err) + } + }) +} + +// TestDecodeIT32Header covers the one colour element that prefixes its planes +// with four zero bytes. +func TestDecodeIT32Header(t *testing.T) { + t.Parallel() + const side = 128 + pixels := side * side + planes := make([]byte, pixels*3) + for i := range planes { + planes[i] = byte(i / side) + } + rgb := append([]byte{0, 0, 0, 0}, rleLiterals(planes)...) + mask := bytes.Repeat([]byte{0xFF}, pixels) + data := file(encodeElement("it32", rgb), encodeElement("t8mk", mask)) + img, err := Decode(bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + if got := img.Bounds().Size(); got != image.Pt(side, side) { + t.Fatalf("decoded %v, want %dx%d", got, side, side) + } +}