icns

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

commit 28e0d609d39c2ef139480defe3df865ca45af813
parent 56bd4e8ba5a555d192d0bad277c3a736c9906991
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Fri, 18 Sep 2026 16:09:32 -0400

icns: encode from artwork supplied per slot

One image was resized into every size, which is worst at 16 and 32 pixels
where icons are drawn by hand rather than reduced. A slot is a size and a
scale, not a pixel count, because 16x16@2x and 32x32 are both 32 pixels but
fill different elements.

Diffstat:
Micns.go | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Aslot.go | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aslot_test.go | 223+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 367 insertions(+), 18 deletions(-)

diff --git a/icns.go b/icns.go @@ -5,6 +5,7 @@ import ( "fmt" "image" "io" + "sort" "sync" "golang.org/x/image/draw" @@ -35,17 +36,26 @@ func (enc *Encoder) Encode(img image.Image) error { if enc.Wr == nil { return errors.New("cannot write to nil writer") } - if img == nil { - return errors.New("cannot encode nil image") - } iconset, err := NewIconSet(img, enc.Algorithm) if err != nil { return err } - if _, err := iconset.WriteTo(enc.Wr); err != nil { + _, err = iconset.WriteTo(enc.Wr) + return err +} + +// EncodeSlots icns from artwork supplied per slot, so hand tuned art is used +// where it is given rather than resized from a single source. +func (enc *Encoder) EncodeSlots(images map[Slot]image.Image) error { + if enc.Wr == nil { + return errors.New("cannot write to nil writer") + } + iconset, err := NewIconSetFrom(images, enc.Algorithm) + if err != nil { return err } - return nil + _, err = iconset.WriteTo(enc.Wr) + return err } // Encode writes img to wr in ICNS format. @@ -60,9 +70,46 @@ func Encode(wr io.Writer, img image.Image) error { // If width != height, the image will be resized using the largest side without // preserving the aspect ratio. func NewIconSet(img image.Image, interp InterpolationFunction) (*IconSet, error) { - biggest := findNearestSize(img) + if img == nil { + return nil, errors.New("cannot encode nil image") + } + return newIconSet(nil, img, interp) +} + +// NewIconSetFrom uses artwork supplied per slot to create an IconSet, which is +// what an iconset directory holds. A slot given artwork of the wrong size has +// it resized; a slot given none is filled from the largest image supplied, +// which also sets the largest icon written. +func NewIconSetFrom(images map[Slot]image.Image, interp InterpolationFunction) (*IconSet, error) { + if len(images) == 0 { + return nil, errors.New("cannot encode without an image") + } + slots := make([]Slot, 0, len(images)) + for slot, img := range images { + if img == nil { + return nil, fmt.Errorf("cannot encode nil image for %s", slot) + } + slots = append(slots, slot) + } + // The largest artwork stands in for the slots left empty. Ties are broken + // by slot so the choice does not depend on map ordering. + sort.Slice(slots, func(ii, jj int) bool { + left, right := biggestSide(images[slots[ii]]), biggestSide(images[slots[jj]]) + if left != right { + return left > right + } + if slots[ii].Points != slots[jj].Points { + return slots[ii].Points > slots[jj].Points + } + return slots[ii].Scale > slots[jj].Scale + }) + return newIconSet(images, images[slots[0]], interp) +} + +func newIconSet(images map[Slot]image.Image, source image.Image, interp InterpolationFunction) (*IconSet, error) { + biggest := findNearestSize(source) if biggest == 0 { - return nil, ErrImageTooSmall{image: img, need: 16} + return nil, ErrImageTooSmall{image: source, need: 16} } var plan []OsType for _, size := range sizesFrom(biggest) { @@ -78,9 +125,13 @@ func NewIconSet(img image.Image, interp InterpolationFunction) (*IconSet, error) work.Add(1) go func() { defer work.Done() + art := source + if supplied, ok := images[osType.slot]; ok { + art = supplied + } icons[i] = &Icon{ Type: osType, - Image: resizeSquare(img, osType.Size, interp), + Image: resizeSquare(art, osType.Size, interp), } }() } @@ -196,6 +247,8 @@ type OsType struct { enc encoding // mask is the element holding this type's alpha, for encodingRGB. mask string + // slot is the iconset slot this type fills, for the written types. + slot Slot // emit marks the types the encoder writes. More types can be read than // are written. emit bool @@ -206,14 +259,14 @@ func (t OsType) String() string { } var osTypes = []OsType{ - {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: "ic10", Size: 1024, slot: Slot{512, 2}, emit: true}, + {ID: "ic14", Size: 512, slot: Slot{256, 2}, emit: true}, + {ID: "ic09", Size: 512, slot: Slot{512, 1}, emit: true}, + {ID: "ic13", Size: 256, slot: Slot{128, 2}, emit: true}, + {ID: "ic08", Size: 256, slot: Slot{256, 1}, emit: true}, + {ID: "ic07", Size: 128, slot: Slot{128, 1}, emit: true}, + {ID: "ic12", Size: 64, slot: Slot{32, 2}, emit: true}, + {ID: "ic11", Size: 32, slot: Slot{16, 2}, emit: true}, {ID: "icp6", Size: 48}, {ID: "icp5", Size: 32}, @@ -224,8 +277,8 @@ var osTypes = []OsType{ // 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", emit: true}, - {ID: "is32", Size: 16, enc: encodingRGB, mask: "s8mk", emit: true}, + {ID: "il32", Size: 32, enc: encodingRGB, mask: "l8mk", slot: Slot{32, 1}, emit: true}, + {ID: "is32", Size: 16, enc: encodingRGB, mask: "s8mk", slot: Slot{16, 1}, emit: true}, } // getTypesFromSize returns the writable types for the given icon size (in px). diff --git a/slot.go b/slot.go @@ -0,0 +1,73 @@ +package icns + +import ( + "fmt" + "regexp" + "strconv" +) + +// Slot identifies an icon by the size it is drawn at and the display scale it +// is drawn for, the way an iconset names its files. Artwork for 16x16@2x and +// for 32x32 is 32 pixels either way, but the two fill different slots and +// need not be the same drawing. +type Slot struct { + // Points is the size the icon is drawn at. + Points uint + // Scale is the display scale, 1 for a plain display and 2 for retina. + Scale uint +} + +// Pixels returns the dimensions artwork for this slot has. +func (s Slot) Pixels() uint { + return s.Points * s.Scale +} + +// String renders the slot the way an iconset names it, such as "16x16@2x". +func (s Slot) String() string { + if s.Scale > 1 { + return fmt.Sprintf("%dx%d@%dx", s.Points, s.Points, s.Scale) + } + return fmt.Sprintf("%dx%d", s.Points, s.Points) +} + +// Slots returns the slots an encoded icns holds, largest artwork first. +func Slots() []Slot { + var slots []Slot + for _, size := range sizes { + types, ok := getTypesFromSize(size) + if !ok { + continue + } + for _, t := range types { + slots = append(slots, t.slot) + } + } + return slots +} + +// iconsetName matches the file names iconutil accepts in an iconset. +var iconsetName = regexp.MustCompile(`^icon_(\d+)x(\d+)(?:@(\d+)x)?\.png$`) + +// ParseSlot reads an iconset file name, such as "icon_16x16@2x.png". The +// boolean reports whether the name is one. +func ParseSlot(name string) (Slot, bool) { + match := iconsetName.FindStringSubmatch(name) + if match == nil { + return Slot{}, false + } + width, err := strconv.ParseUint(match[1], 10, 32) + if err != nil { + return Slot{}, false + } + height, err := strconv.ParseUint(match[2], 10, 32) + if err != nil || width != height || width == 0 { + return Slot{}, false + } + scale := uint64(1) + if match[3] != "" { + if scale, err = strconv.ParseUint(match[3], 10, 32); err != nil || scale == 0 { + return Slot{}, false + } + } + return Slot{Points: uint(width), Scale: uint(scale)}, true +} diff --git a/slot_test.go b/slot_test.go @@ -0,0 +1,223 @@ +package icns + +import ( + "bytes" + "image" + "image/color" + "reflect" + "testing" +) + +// slotOf names the element that fills each slot, which is what per-slot +// artwork has to land in. +var slotOf = map[string]Slot{ + "ic10": {512, 2}, + "ic09": {512, 1}, + "ic14": {256, 2}, + "ic08": {256, 1}, + "ic13": {128, 2}, + "ic07": {128, 1}, + "ic12": {32, 2}, + "ic11": {16, 2}, + "il32": {32, 1}, + "is32": {16, 1}, +} + +func solid(side int, c color.NRGBA) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, side, side)) + for i := 0; i < len(img.Pix); i += 4 { + img.Pix[i], img.Pix[i+1], img.Pix[i+2], img.Pix[i+3] = c.R, c.G, c.B, c.A + } + return img +} + +// centre reports the colour at the middle of img. +func centre(img image.Image) color.NRGBA { + b := img.Bounds() + return color.NRGBAModel.Convert(img.At(b.Min.X+b.Dx()/2, b.Min.Y+b.Dy()/2)).(color.NRGBA) +} + +func TestParseSlot(t *testing.T) { + t.Parallel() + tests := []struct { + name string + want Slot + ok bool + }{ + {"icon_16x16.png", Slot{16, 1}, true}, + {"icon_16x16@2x.png", Slot{16, 2}, true}, + {"icon_32x32@2x.png", Slot{32, 2}, true}, + {"icon_512x512.png", Slot{512, 1}, true}, + {"icon_512x512@2x.png", Slot{512, 2}, true}, + {"icon_16x32.png", Slot{}, false}, + {"icon_0x0.png", Slot{}, false}, + {"icon_16x16@0x.png", Slot{}, false}, + {"icon_16x16.jpg", Slot{}, false}, + {"16x16.png", Slot{}, false}, + {"icon_16x16@2x.png.bak", Slot{}, false}, + {"", Slot{}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(st *testing.T) { + got, ok := ParseSlot(tt.name) + if ok != tt.ok || got != tt.want { + st.Fatalf("ParseSlot(%q) = %v, %v; want %v, %v", tt.name, got, ok, tt.want, tt.ok) + } + }) + } +} + +func TestSlotString(t *testing.T) { + t.Parallel() + if got := (Slot{16, 1}).String(); got != "16x16" { + t.Errorf("got %q, want 16x16", got) + } + if got := (Slot{512, 2}).String(); got != "512x512@2x" { + t.Errorf("got %q, want 512x512@2x", got) + } + if got := (Slot{16, 2}).Pixels(); got != 32 { + t.Errorf("16x16@2x is %d pixels, want 32", got) + } +} + +// TestSlots checks that the slots reported are exactly the ten an iconset +// holds, so a caller knows what artwork to supply. +func TestSlots(t *testing.T) { + t.Parallel() + got := Slots() + if len(got) != len(slotOf) { + t.Fatalf("Slots returned %d entries, want %d", len(got), len(slotOf)) + } + seen := map[Slot]bool{} + for _, slot := range got { + seen[slot] = true + } + for id, slot := range slotOf { + if !seen[slot] { + t.Errorf("slot %s, filled by %s, is missing", slot, id) + } + } + // Largest artwork first. + for i := 1; i < len(got); i++ { + if got[i].Pixels() > got[i-1].Pixels() { + t.Fatalf("slot %d (%s) is larger than the one before it (%s)", i, got[i], got[i-1]) + } + } +} + +// TestEncodeSlots checks that artwork given for a slot reaches the element +// that fills it, including the three pixel sizes two slots share. +func TestEncodeSlots(t *testing.T) { + t.Parallel() + var ( + images = map[Slot]image.Image{} + want = map[Slot]color.NRGBA{} + ) + for i, slot := range Slots() { + c := color.NRGBA{R: uint8(10 + i*20), G: uint8(200 - i*15), B: 0x40, A: 0xFF} + // Artwork at exactly the slot's size, so nothing is resampled and + // the colour has to survive exactly. + images[slot] = solid(int(slot.Pixels()), c) + want[slot] = c + } + buf := bytes.NewBuffer(nil) + if err := NewEncoder(buf).EncodeSlots(images); err != nil { + t.Fatal(err) + } + d, err := NewDecoder(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatal(err) + } + icons := d.Icons() + if len(icons) != len(slotOf) { + t.Fatalf("encoded %d icons, want %d", len(icons), len(slotOf)) + } + for _, icon := range icons { + slot, ok := slotOf[icon.ID] + if !ok { + t.Fatalf("unexpected element %s", icon.ID) + } + img, err := icon.Decode() + if err != nil { + t.Fatalf("%s: %v", icon.ID, err) + } + if got := uint(img.Bounds().Dx()); got != slot.Pixels() { + t.Errorf("%s is %d pixels, want %d", icon.ID, got, slot.Pixels()) + } + if got := centre(img); got != want[slot] { + t.Errorf("%s fills slot %s with %v, want %v", icon.ID, slot, got, want[slot]) + } + } +} + +func TestEncodeSlotsFallback(t *testing.T) { + t.Parallel() + var ( + source = color.NRGBA{R: 0x20, G: 0x40, B: 0x60, A: 0xFF} + tuned = color.NRGBA{R: 0xF0, G: 0x10, B: 0x80, A: 0xFF} + ) + // Only two slots are given artwork, and the 16 pixel one is supplied at + // the wrong size so it has to be resized into place. + images := map[Slot]image.Image{ + {Points: 512, Scale: 2}: solid(1024, source), + {Points: 16, Scale: 1}: solid(64, tuned), + } + buf := bytes.NewBuffer(nil) + if err := NewEncoder(buf).EncodeSlots(images); err != nil { + t.Fatal(err) + } + d, err := NewDecoder(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatal(err) + } + if got := len(d.Icons()); got != len(slotOf) { + t.Fatalf("encoded %d icons, want the full set of %d", got, len(slotOf)) + } + for _, icon := range d.Icons() { + img, err := icon.Decode() + if err != nil { + t.Fatalf("%s: %v", icon.ID, err) + } + want := source + if icon.ID == "is32" { + want = tuned + if got := img.Bounds().Dx(); got != 16 { + t.Errorf("is32 is %d pixels, want 16", got) + } + } + if got := centre(img); got != want { + t.Errorf("%s = %v, want %v", icon.ID, got, want) + } + } +} + +func TestEncodeSlotsRejects(t *testing.T) { + t.Parallel() + buf := bytes.NewBuffer(nil) + if err := NewEncoder(buf).EncodeSlots(nil); err == nil { + t.Error("encoding without artwork was accepted") + } + if err := NewEncoder(buf).EncodeSlots(map[Slot]image.Image{{16, 1}: nil}); err == nil { + t.Error("encoding a nil image was accepted") + } + if err := NewEncoder(buf).EncodeSlots(map[Slot]image.Image{{16, 1}: solid(8, color.NRGBA{})}); err == nil { + t.Error("encoding artwork below the smallest icon was accepted") + } +} + +// TestEncodeSlotsMatchesSingleImage checks the two entry points agree when +// the artwork is the same. +func TestEncodeSlotsMatchesSingleImage(t *testing.T) { + t.Parallel() + src := gradient(256) + var single, slots bytes.Buffer + if err := Encode(&single, src); err != nil { + t.Fatal(err) + } + if err := NewEncoder(&slots).EncodeSlots(map[Slot]image.Image{{Points: 128, Scale: 2}: src}); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(single.Bytes(), slots.Bytes()) { + t.Fatal("the same artwork through each entry point produced different files") + } +}