icns

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

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

icns: pad compressed colour planes with a trailing byte

The oracle found it: Apple's own reader drops the last value of a run-length
stream on Apple silicon, so the tail of the blue plane came back black in a
16 pixel icon. A byte of padding is what it loses instead.

Diffstat:
Moracle_test.go | 28++++++++++++++++++----------
Mrle.go | 12++++++++++++
Mrle_test.go | 24++++++++++++++++++++++++
Mwriter.go | 2+-
4 files changed, 55 insertions(+), 11 deletions(-)

diff --git a/oracle_test.go b/oracle_test.go @@ -107,26 +107,34 @@ func TestIconutilWrites(t *testing.T) { t.Fatalf("decoding what iconutil wrote: %v", err) } icons := d.Icons() - t.Logf("iconutil wrote %d icons this package reads", len(icons)) if len(icons) == 0 { t.Fatal("no icons were read back") } + // Each slot was painted its own colour, so the colour that comes out + // names the file that went in. That reports the mapping for the types + // iconutil writes and this package does not, rather than guessing it. + source := map[color.NRGBA]Slot{} + for slot, c := range want { + source[c] = slot + } for _, icon := range icons { - slot, ok := slotOf[icon.ID] - if !ok { - t.Errorf("iconutil wrote %s, which this package does not place in a slot", icon.ID) - continue - } img, err := icon.Decode() if err != nil { t.Errorf("%s: %v", icon.ID, err) continue } - if got := uint(img.Bounds().Dx()); got != slot.Pixels() { - t.Errorf("%s is %d pixels, want %d", icon.ID, got, slot.Pixels()) + got := centre(img) + slot, ok := source[got] + if !ok { + t.Errorf("%s holds %v, which is no slot's artwork", icon.ID, got) + continue + } + t.Logf("iconutil wrote %-4s %4dpx from slot %s", icon.ID, img.Bounds().Dx(), slot) + if px := uint(img.Bounds().Dx()); px != slot.Pixels() { + t.Errorf("%s is %d pixels, but slot %s is %d", icon.ID, px, slot, slot.Pixels()) } - if got := centre(img); got != want[slot] { - t.Errorf("%s holds %v, want %v from slot %s", icon.ID, got, want[slot], slot) + if ours, ok := slotOf[icon.ID]; ok && ours != slot { + t.Errorf("%s carries slot %s, but this package fills it from %s", icon.ID, slot, ours) } } } diff --git a/rle.go b/rle.go @@ -85,6 +85,18 @@ const ( maxRepeat = 130 ) +// padRLE appends a byte to compressed data, which a decoder that drops the +// last value of a stream then loses instead of a pixel. Apple's own reader +// does exactly that on Apple silicon, turning the tail of the blue plane +// black. Data that was stored uncompressed is left at its exact length, +// which is how a reader tells the two apart. +func padRLE(data []byte, uncompressed int) []byte { + if len(data) == uncompressed { + return data + } + return append(data, 0) +} + // splitPlanes separates an image into the three colour planes and the alpha // mask that the legacy elements store separately. The planes hold straight // colour, so alpha is divided back out. diff --git a/rle_test.go b/rle_test.go @@ -107,6 +107,30 @@ func TestUnpackRLE(t *testing.T) { } } +func TestPadRLE(t *testing.T) { + t.Parallel() + // Compressed data gets a byte so a reader that drops the last value of + // the stream loses the padding instead of a pixel. + if got := padRLE([]byte{0x80, 0x07}, 3); !bytes.Equal(got, []byte{0x80, 0x07, 0x00}) { + t.Errorf("compressed data = %v, want a trailing zero", got) + } + // Uncompressed data has to keep its exact length, which is how a reader + // tells that it was never compressed. + raw := []byte{1, 2, 3} + if got := padRLE(raw, len(raw)); !bytes.Equal(got, raw) { + t.Errorf("uncompressed data = %v, want it unchanged", got) + } + // Whatever the padding, the data still reads back. + planes := bytes.Repeat([]byte{0x40}, 768) + out, err := unpackRLE(padRLE(packRLE(planes), len(planes)), len(planes)) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(out, planes) { + t.Fatal("padded data did not survive the round trip") + } +} + // 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) { diff --git a/writer.go b/writer.go @@ -40,7 +40,7 @@ func (i *Icon) encode() error { if i.Type.enc == encodingRGB { planes, mask := splitPlanes(i.Image, int(i.Type.Size)) i.elements = []element{ - {id: i.Type.ID, payload: packRLE(planes)}, + {id: i.Type.ID, payload: padRLE(packRLE(planes), len(planes))}, {id: i.Type.mask, payload: mask}, } return nil