commit bff19a681cc6bf5b6f291663d0ee0805332ad4d7
parent 5dbc5dd2b9cbe3f623c758462978197d3a8235f4
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Fri, 18 Sep 2026 16:09:31 -0400
icns: write true 16 and 32 pixel icons
Every icon was written for retina, so the smallest was 32 pixels standing in
for 16 at 2x and Finder had to downsample for a 16 pixel slot. Apple still
writes those two sizes as colour and mask, which this now matches.
Diffstat:
6 files changed, 129 insertions(+), 9 deletions(-)
diff --git a/doc.go b/doc.go
@@ -15,6 +15,7 @@
// using this library from the command line. It supports piping, which is
// something `iconutil` does not do, making it substantially easier to wrap.
//
-// Note: All icons within the icns are sized for high dpi retina screens, using
-// the appropriate icns OSTypes.
+// 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.
package icns
diff --git a/icns.go b/icns.go
@@ -219,10 +219,13 @@ var osTypes = []OsType{
{ID: "icp5", Size: 32},
{ID: "icp4", 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.
{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"},
+ {ID: "il32", Size: 32, enc: encodingRGB, mask: "l8mk", emit: true},
+ {ID: "is32", Size: 16, enc: encodingRGB, mask: "s8mk", emit: true},
}
// getTypesFromSize returns the writable types for the given icon size (in px).
diff --git a/icns_test.go b/icns_test.go
@@ -59,9 +59,9 @@ func TestRoundTrip(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- // 128 (ic07), 64 (ic12) and 32 (ic11); no retina OSType exists for 16px.
- if len(imgs) != 3 {
- t.Fatalf("DecodeAll returned %d icons, want 3", len(imgs))
+ // ic07, ic12, ic11, il32 and is32.
+ if len(imgs) != 5 {
+ t.Fatalf("DecodeAll returned %d icons, want 5", len(imgs))
}
if !imageCompare(imgs[0], src) {
t.Fatal("largest decoded icon differs from the source image")
@@ -103,7 +103,8 @@ func TestInterpolationFunctions(t *testing.T) {
}
sides = append(sides, b.Dx())
}
- if want := []int{128, 64, 32}; !reflect.DeepEqual(sides, want) {
+ // 32 twice: ic11 carries 16@2x and il32 carries a true 32.
+ if want := []int{128, 64, 32, 32, 16}; !reflect.DeepEqual(sides, want) {
st.Fatalf("icon sides = %v, want %v", sides, want)
}
// A resampled icon must carry the source's colour, not a blank
@@ -115,6 +116,50 @@ func TestInterpolationFunctions(t *testing.T) {
}
}
+// TestEncodedElements pins the set of elements the encoder writes, which
+// matches what iconutil produces for a full iconset.
+func TestEncodedElements(t *testing.T) {
+ t.Parallel()
+ buf := bytes.NewBuffer(nil)
+ if err := Encode(buf, gradient(1024)); err != nil {
+ t.Fatal(err)
+ }
+ els, err := elementsOf(bytes.NewReader(buf.Bytes()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got []string
+ for _, el := range els {
+ got = append(got, el.id)
+ }
+ want := []string{
+ "ic10", "ic14", "ic09", "ic13", "ic08", "ic07", "ic12",
+ "ic11", "il32", "l8mk", "is32", "s8mk",
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("elements = %v, want %v", got, want)
+ }
+}
+
+// TestLegacyRoundTrip checks that a 16px source survives the colour and mask
+// encoding unchanged: it is written at its own size, so nothing is resampled
+// and the planes are lossless.
+func TestLegacyRoundTrip(t *testing.T) {
+ t.Parallel()
+ src := gradient(16)
+ buf := bytes.NewBuffer(nil)
+ if err := Encode(buf, src); err != nil {
+ t.Fatal(err)
+ }
+ img, err := Decode(buf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !imageCompare(img, src) {
+ t.Fatal("the decoded icon differs from the source")
+ }
+}
+
// TestResizeKeepsColorOutOfTransparentPixels guards the reason resampling
// happens in premultiplied space. Filtering an opaque edge against
// transparent pixels in straight space drags their colour into the edge,
diff --git a/readme.md b/readme.md
@@ -16,7 +16,7 @@ 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: All icons within the `icns` are sized for high dpi retina screens, using the appropriate `icns` OSTypes.
+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 additionally understands the legacy `is32`, `il32`, `ih32` and `it32` elements, so icons written before macOS 10.5 read correctly.
## GUI
diff --git a/rle.go b/rle.go
@@ -3,6 +3,7 @@ package icns
import (
"fmt"
"image"
+ "image/color"
)
// Legacy icon types store their colour as three run-length encoded planes,
@@ -43,6 +44,68 @@ func unpackRLE(data []byte, want int) ([]byte, error) {
return out, nil
}
+// packRLE compresses data into the icns variant of PackBits. A run of three
+// or more equal bytes is worth encoding, since it costs two bytes either way,
+// and anything shorter goes out as literals. Data that does not compress is
+// returned unchanged, which the decoder recognises by its length.
+func packRLE(data []byte) []byte {
+ out := make([]byte, 0, len(data))
+ for i := 0; i < len(data); {
+ run := 1
+ for i+run < len(data) && run < maxRepeat && data[i+run] == data[i] {
+ run++
+ }
+ if run >= 3 {
+ out = append(out, byte(run+125), data[i])
+ i += run
+ continue
+ }
+ // Literals up to the next run of three, since that run encodes more
+ // cheaply on its own.
+ start := i
+ for i < len(data) && i-start < maxLiteral {
+ if i+2 < len(data) && data[i] == data[i+1] && data[i] == data[i+2] {
+ break
+ }
+ i++
+ }
+ out = append(out, byte(i-start-1))
+ out = append(out, data[start:i]...)
+ }
+ if len(out) >= len(data) {
+ return data
+ }
+ return out
+}
+
+const (
+ // maxLiteral is the longest literal run, from a lead byte of 127.
+ maxLiteral = 128
+ // maxRepeat is the longest repeat, from a lead byte of 255.
+ maxRepeat = 130
+)
+
+// 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.
+func splitPlanes(img image.Image, side int) (planes, mask []byte) {
+ pixels := side * side
+ planes = make([]byte, pixels*3)
+ mask = make([]byte, pixels)
+ origin := img.Bounds().Min
+ for y := 0; y < side; y++ {
+ for x := 0; x < side; x++ {
+ c := color.NRGBAModel.Convert(img.At(origin.X+x, origin.Y+y)).(color.NRGBA)
+ i := y*side + x
+ planes[i] = c.R
+ planes[pixels+i] = c.G
+ planes[pixels*2+i] = c.B
+ mask[i] = c.A
+ }
+ }
+ return planes, mask
+}
+
// 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.
diff --git a/writer.go b/writer.go
@@ -37,6 +37,14 @@ func (i *Icon) encode() error {
if len(i.elements) > 0 {
return nil
}
+ 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.mask, payload: mask},
+ }
+ return nil
+ }
data, err := encodeImage(i.Image)
if err != nil {
return err