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:
| M | icns.go | | | 89 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------- |
| A | slot.go | | | 73 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | slot_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")
+ }
+}