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:
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