icns

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

commit cadf5f4dc07baf465a309509f25dd181cce98118
parent c74311c5ee4dc722cded3cffcb748cd5fe9da86c
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Fri, 18 Sep 2026 16:09:39 -0400

icnsify: write ico files

A program that needs both icon formats had to reach for the library to get
the Windows one. The output path names the format, --format names it where
a pipe has no path to read, and an iconset directory fills either container.

Diffstat:
Mcmd/icnsify/doc.go | 3++-
Mcmd/icnsify/iconset.go | 39+++++++++++++++++++++++++++++++++++----
Acmd/icnsify/iconset_test.go | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Mcmd/icnsify/main.go | 129++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Acmd/icnsify/main_test.go | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mreadme.md | 14++++++++++++++
6 files changed, 271 insertions(+), 42 deletions(-)

diff --git a/cmd/icnsify/doc.go b/cmd/icnsify/doc.go @@ -61,7 +61,7 @@ func boolFlag(p *bool, long, short string, usage string) { func usage() { w := flag.CommandLine.Output() - fmt.Fprintf(w, "%s\n\nUsage: icnsify [-i input] [-o output] [-r quality]\n\nOptions:\n", buildInfo()) + fmt.Fprintf(w, "%s\n\nUsage: icnsify [-i input] [-o output] [-f format] [-r quality]\n\nOptions:\n", buildInfo()) for _, o := range options { fmt.Fprintf(w, " -%s, --%s\n %s", o.short, o.long, o.usage) if o.def != "" && o.def != "0" { @@ -75,5 +75,6 @@ when --input is not given, and --output is then ignored. cat icon.png | icnsify > icon.icns cat icon.icns | icnsify > icon.png + cat icon.png | icnsify -f ico > icon.ico `) } diff --git a/cmd/icnsify/iconset.go b/cmd/icnsify/iconset.go @@ -1,20 +1,26 @@ package main import ( + "cmp" "fmt" "image" "image/png" "log/slog" "os" "path/filepath" + "slices" "github.com/jackmordaunt/icns/v4" + "github.com/jackmordaunt/icns/v4/ico" ) -// 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 { +// encodeIconSet writes an icon container 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, target string, algorithm icns.InterpolationFunction) error { + if !containers[target] { + return fmt.Errorf("an iconset directory makes an icns or an ico, not %s", target) + } entries, err := os.ReadDir(dir) if err != nil { return fmt.Errorf("reading iconset: %w", err) @@ -46,12 +52,37 @@ func encodeIconSet(dir, out string, algorithm icns.InterpolationFunction) error return fmt.Errorf("creating output file: %w", err) } defer f.Close() + if target == ".ico" { + if err := ico.NewEncoder(f).WithAlgorithm(algorithm).EncodeSizes(pixelSizes(images)); err != nil { + return fmt.Errorf("encoding ico: %w", err) + } + return nil + } if err := icns.NewEncoder(f).WithAlgorithm(algorithm).EncodeSlots(images); err != nil { return fmt.Errorf("encoding icns: %w", err) } return nil } +// pixelSizes maps iconset slots onto the sizes an ico holds. Two slots can be +// the same number of pixels, 16x16@2x and 32x32 both being 32, and the +// drawing made without scaling is the one that belongs at that size. +func pixelSizes(images map[icns.Slot]image.Image) map[uint]image.Image { + slots := make([]icns.Slot, 0, len(images)) + for slot := range images { + slots = append(slots, slot) + } + // Highest scale first, so a plain drawing displaces a retina one. + slices.SortFunc(slots, func(a, b icns.Slot) int { + return cmp.Compare(b.Scale, a.Scale) + }) + out := make(map[uint]image.Image, len(slots)) + for _, slot := range slots { + out[slot.Pixels()] = images[slot] + } + return out +} + func readPNG(path string) (image.Image, error) { f, err := os.Open(path) if err != nil { diff --git a/cmd/icnsify/iconset_test.go b/cmd/icnsify/iconset_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "image" + "image/color" + "testing" + + "github.com/jackmordaunt/icns/v4" +) + +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 +} + +// TestPixelSizes covers the slots that land on the same number of pixels, +// where only one drawing can be kept. +func TestPixelSizes(t *testing.T) { + t.Parallel() + var ( + plain = color.NRGBA{R: 0xFF, A: 0xFF} + retina = color.NRGBA{B: 0xFF, A: 0xFF} + ) + images := map[icns.Slot]image.Image{ + {Points: 16, Scale: 1}: solid(16, plain), + {Points: 16, Scale: 2}: solid(32, retina), + {Points: 32, Scale: 1}: solid(32, plain), + {Points: 512, Scale: 2}: solid(1024, retina), + } + got := pixelSizes(images) + if len(got) != 3 { + t.Fatalf("mapped to %d sizes, want 3", len(got)) + } + for size, want := range map[uint]color.NRGBA{16: plain, 32: plain, 1024: retina} { + img, ok := got[size] + if !ok { + t.Errorf("no drawing at %d pixels", size) + continue + } + if img.Bounds().Dx() != int(size) { + t.Errorf("the drawing at %d pixels is %d wide", size, img.Bounds().Dx()) + } + if c := color.NRGBAModel.Convert(img.At(0, 0)).(color.NRGBA); c != want { + t.Errorf("the drawing at %d pixels is %v, want %v", size, c, want) + } + } +} diff --git a/cmd/icnsify/main.go b/cmd/icnsify/main.go @@ -15,8 +15,13 @@ import ( "strings" "github.com/jackmordaunt/icns/v4" + "github.com/jackmordaunt/icns/v4/ico" ) +// containers are the formats that hold an icon at several sizes, as opposed +// to the plain images they are built from and unpacked into. +var containers = map[string]bool{".icns": true, ".ico": true} + // errUsage signals that no work was requested; usage has been printed. var errUsage = errors.New("usage") @@ -31,14 +36,17 @@ func main() { func run() error { var ( - inputPath string - outputPath string - resize int + inputPath string + outputPath string + outputFormat string + resize int ) stringFlag(&inputPath, "input", "i", "", - "Input image for conversion to icns from jpg|png or vice versa.") + "Input image: artwork to pack, an icon file to unpack, or an iconset directory.") stringFlag(&outputPath, "output", "o", "", - "Output path, defaults to <path/to/image>.(icns|png) depending on input.") + "Output path, defaults to the input named with the target's extension.") + stringFlag(&outputFormat, "format", "f", "", + "Output format: icns, ico, png or jpg. Defaults from the output path.") intFlag(&resize, "resize", "r", 5, "Quality of resize algorithm, 0 to 5 from fastest to slowest.") var showVersion bool @@ -64,7 +72,10 @@ func run() error { return err } } - in, out, algorithm := sanitiseInputs(inputPath, outputPath, resize) + if outputFormat != "" && !writable(extension(outputFormat)) { + return fmt.Errorf("cannot write %s: choose from icns, ico, png or jpg", outputFormat) + } + in, out, algorithm := sanitiseInputs(inputPath, outputPath, outputFormat, resize) if piping { input, output = os.Stdin, os.Stdout } else { @@ -75,7 +86,7 @@ func run() error { // 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) + return encodeIconSet(in, out, target(outputFormat, out, false, ""), algorithm) } sourcef, err := os.Open(in) if err != nil { @@ -93,61 +104,105 @@ func run() error { defer outputf.Close() output = outputf } - if filepath.Ext(in) == ".icns" { + if containers[extension(filepath.Ext(in))] { by, err := io.ReadAll(input) if err != nil { return fmt.Errorf("probing file: reading file: %w", err) } - icons, err := icns.Probe(bytes.NewReader(by)) - if err != nil { + if err := describe(extension(filepath.Ext(in)), bytes.NewReader(by)); err != nil { return fmt.Errorf("probing file: %w", err) } - for _, icon := range icons { - slog.Info("found", "icon", icon) - } input = bytes.NewReader(by) } img, format, err := image.Decode(input) if err != nil { return fmt.Errorf("decoding input: %w", err) } - if format == "icns" { - imageType := strings.ToLower(filepath.Ext(out)) - if _, ok := encoders[imageType]; !ok { - imageType = ".png" + switch kind := target(outputFormat, out, piping, format); kind { + case ".icns": + if err := icns.NewEncoder(output).WithAlgorithm(algorithm).Encode(img); err != nil { + return fmt.Errorf("encoding icns: %w", err) } - if err := encoders[imageType](output, img); err != nil { - return fmt.Errorf("encoding %s: %w", imageType, err) + case ".ico": + if err := ico.NewEncoder(output).WithAlgorithm(algorithm).Encode(img); err != nil { + return fmt.Errorf("encoding ico: %w", err) + } + default: + if err := encoders[kind](output, img); err != nil { + return fmt.Errorf("encoding %s: %w", kind, err) } - return nil } - enc := icns.NewEncoder(output).WithAlgorithm(algorithm) - if err := enc.Encode(img); err != nil { - return fmt.Errorf("encoding icns: %w", err) + return nil +} + +// describe logs the icons a container holds. +func describe(ext string, r io.Reader) error { + switch ext { + case ".ico": + d, err := ico.NewDecoder(r) + if err != nil { + return err + } + for _, icon := range d.Icons() { + slog.Info("found", "icon", icon) + } + default: + icons, err := icns.Probe(r) + if err != nil { + return err + } + for _, icon := range icons { + slog.Info("found", "icon", icon) + } } return nil } +// target names the format to write. The --format flag decides it, then the +// output path, and failing both the conversion changes kind, since that is +// what converting an icon usually means: artwork becomes a container of +// icons and a container becomes a plain image. A pipe has no output path. +func target(want, out string, piping bool, got string) string { + if want != "" { + return extension(want) + } + if !piping { + if ext := extension(filepath.Ext(out)); writable(ext) { + return ext + } + } + if containers[extension(got)] { + return ".png" + } + return ".icns" +} + +// extension normalises a format name or extension to a lower case extension. +func extension(name string) string { + name = strings.ToLower(name) + if name != "" && !strings.HasPrefix(name, ".") { + name = "." + name + } + return name +} + +// writable reports whether this program can write the format. +func writable(ext string) bool { + return containers[ext] || encoders[ext] != nil +} + func sanitiseInputs( inputPath string, outputPath string, + outputFormat string, resize int, ) (string, string, icns.InterpolationFunction) { - if filepath.Ext(inputPath) == ".icns" { - if outputPath == "" { - outputPath = changeExtensionTo(inputPath, "png") - } - if filepath.Ext(outputPath) == "" { - outputPath += ".png" - } + ext := target(outputFormat, outputPath, false, extension(filepath.Ext(inputPath))) + if outputPath == "" { + outputPath = changeExtensionTo(inputPath, ext) } - if filepath.Ext(inputPath) != ".icns" { - if outputPath == "" { - outputPath = changeExtensionTo(inputPath, "icns") - } - if filepath.Ext(outputPath) == "" { - outputPath += ".icns" - } + if filepath.Ext(outputPath) == "" { + outputPath += ext } if resize < 0 { resize = 0 diff --git a/cmd/icnsify/main_test.go b/cmd/icnsify/main_test.go @@ -0,0 +1,78 @@ +package main + +import "testing" + +// TestTarget covers how the output format is chosen, since a wrong choice +// writes one format under another's name. +func TestTarget(t *testing.T) { + t.Parallel() + tests := []struct { + desc string + want string // --format + out string // output path + piping bool + got string // what the input turned out to be + expect string + }{ + {"the output path names it", "", "icon.ico", false, "png", ".ico"}, + {"an icns from artwork", "", "icon.icns", false, "png", ".icns"}, + {"a plain image out of a container", "", "icon.png", false, "icns", ".png"}, + {"the flag wins over the path", "ico", "icon.icns", false, "png", ".ico"}, + {"the flag takes a dot", ".jpg", "", false, "icns", ".jpg"}, + {"the flag takes capitals", "ICO", "", false, "png", ".ico"}, + {"a pipe packs artwork", "", "", true, "png", ".icns"}, + {"a pipe unpacks an icns", "", "", true, "icns", ".png"}, + {"a pipe unpacks an ico", "", "", true, "ico", ".png"}, + {"a pipe takes the flag", "ico", "", true, "png", ".ico"}, + {"an extension we cannot write, from artwork", "", "icon.gif", false, "png", ".icns"}, + {"an extension we cannot write, from a container", "", "icon.gif", false, "icns", ".png"}, + } + for _, tt := range tests { + t.Run(tt.desc, func(st *testing.T) { + if got := target(tt.want, tt.out, tt.piping, tt.got); got != tt.expect { + st.Errorf("target = %s, want %s", got, tt.expect) + } + }) + } +} + +func TestSanitiseInputs(t *testing.T) { + t.Parallel() + tests := []struct { + desc string + in string + out string + format string + expect string + }{ + {"artwork defaults to icns", "icon.png", "", "", "icon.icns"}, + {"the format flag sets the default", "icon.png", "", "ico", "icon.ico"}, + {"an icns defaults to png", "icon.icns", "", "", "icon.png"}, + {"an ico defaults to png", "icon.ico", "", "", "icon.png"}, + {"an icns converts to an ico", "icon.icns", "", "ico", "icon.ico"}, + {"a name without an extension gains one", "icon.png", "out", "", "out.icns"}, + {"a name without an extension, unpacking", "icon.icns", "out", "", "out.png"}, + {"the output path is kept", "icon.png", "out.ico", "", "out.ico"}, + {"the output lands beside us", "path/to/icon.png", "", "", "icon.icns"}, + } + for _, tt := range tests { + t.Run(tt.desc, func(st *testing.T) { + _, out, _ := sanitiseInputs(tt.in, tt.out, tt.format, 5) + if out != tt.expect { + st.Errorf("output = %s, want %s", out, tt.expect) + } + }) + } +} + +func TestSanitiseInputsClampsQuality(t *testing.T) { + t.Parallel() + for _, tt := range []struct { + resize int + expect int + }{{-1, 0}, {0, 0}, {3, 3}, {5, 5}, {9, 5}} { + if _, _, got := sanitiseInputs("icon.png", "", "", tt.resize); int(got) != tt.expect { + t.Errorf("resize %d became %d, want %d", tt.resize, got, tt.expect) + } + } +} diff --git a/readme.md b/readme.md @@ -100,10 +100,22 @@ Standard `icnsify -i icon.icns -o icon.png` +Windows icons, which the output path names, or `--format` where there is no path to name them + +`icnsify -i icon.png -o icon.ico` + +`cat icon.png | icnsify -f ico > icon.ico` + +Between the two icon formats, in either direction + +`icnsify -i icon.icns -o icon.ico` + From an iconset directory, which uses each drawing where it is given instead of resizing one image for every size `icnsify -i MyIcon.iconset -o MyIcon.icns` +`icnsify -i MyIcon.iconset -o MyIcon.ico` + ## Library `go get github.com/jackmordaunt/icns/v4` @@ -182,6 +194,8 @@ A file is written with an icon at 256, 128, 64, 48, 32, 24 and 16 pixels, skippi Decoding reads PNG icons and bitmaps at 1, 4, 8, 24 and 32 bits per pixel, taking the colour table from the file and the alpha from the mask where the pixels carry none. `NewDecoder`, `Icons`, `Entry.Decode` and `Entry.Payload` work as their icns counterparts do, and the package registers itself with `image.Decode`. +`icnsify` writes `.ico` too, so the format is reachable from the command line without writing a program. + ## Development The repository is a Go workspace of three modules: