commit 335a96d4f5a847f6dba50f5572b012bc2909d756
parent 28e0d609d39c2ef139480defe3df865ca45af813
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Fri, 18 Sep 2026 16:09:33 -0400
cmd/icnsify: convert an iconset directory
Per-slot artwork was reachable only from Go, and an iconset directory is how
that artwork already arrives from a designer or from iconutil. Names that do
not match a slot are ignored rather than refused, so stray files in the
folder do not stop a conversion.
Diffstat:
2 files changed, 71 insertions(+), 0 deletions(-)
diff --git a/cmd/icnsify/iconset.go b/cmd/icnsify/iconset.go
@@ -0,0 +1,66 @@
+package main
+
+import (
+ "fmt"
+ "image"
+ "image/png"
+ "log/slog"
+ "os"
+ "path/filepath"
+
+ "github.com/jackmordaunt/icns/v4"
+)
+
+// encodeIconSet writes an icns from an iconset directory, the layout iconutil
+// accepts: one PNG per slot, named icon_16x16.png, icon_16x16@2x.png and so
+// on. Files that are not named that way are ignored.
+func encodeIconSet(dir, out string, algorithm icns.InterpolationFunction) error {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return fmt.Errorf("reading iconset: %w", err)
+ }
+ images := make(map[icns.Slot]image.Image, len(entries))
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ slot, ok := icns.ParseSlot(entry.Name())
+ if !ok {
+ continue
+ }
+ img, err := readPNG(filepath.Join(dir, entry.Name()))
+ if err != nil {
+ return err
+ }
+ images[slot] = img
+ slog.Info("found", "slot", slot, "file", entry.Name())
+ }
+ if len(images) == 0 {
+ return fmt.Errorf("no iconset images in %s", dir)
+ }
+ if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
+ return fmt.Errorf("preparing output directory: %w", err)
+ }
+ f, err := os.Create(out)
+ if err != nil {
+ return fmt.Errorf("creating output file: %w", err)
+ }
+ defer f.Close()
+ if err := icns.NewEncoder(f).WithAlgorithm(algorithm).EncodeSlots(images); err != nil {
+ return fmt.Errorf("encoding icns: %w", err)
+ }
+ return nil
+}
+
+func readPNG(path string) (image.Image, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("opening iconset image: %w", err)
+ }
+ defer f.Close()
+ img, err := png.Decode(f)
+ if err != nil {
+ return nil, fmt.Errorf("decoding %s: %w", filepath.Base(path), err)
+ }
+ return img, nil
+}
diff --git a/cmd/icnsify/main.go b/cmd/icnsify/main.go
@@ -65,6 +65,11 @@ func run() error {
usage()
return errUsage
}
+ // A directory is an iconset: artwork per slot rather than one image
+ // to resize for every size.
+ if info, err := os.Stat(in); err == nil && info.IsDir() {
+ return encodeIconSet(in, out, algorithm)
+ }
sourcef, err := os.Open(in)
if err != nil {
return fmt.Errorf("opening source image: %w", err)