icns

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

commit 952d580cc7f3df1b9affea829ddfef91120d1ef5
parent 39e1831ec6f3c31a4a1793dc0ccebd412b76535c
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Sun, 20 Sep 2026 15:54:15 -0300

icnsify: check an icon from the command line

Validation is worth having in a build, where nobody is looking at the icon.
The exit code separates the findings that change what is drawn from the ones
that only note something absent.

Diffstat:
Acmd/icnsify/check.go | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acmd/icnsify/check_test.go | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcmd/icnsify/doc.go | 2+-
Mcmd/icnsify/main.go | 20++++++++++++++++++++
4 files changed, 252 insertions(+), 1 deletion(-)

diff --git a/cmd/icnsify/check.go b/cmd/icnsify/check.go @@ -0,0 +1,81 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/jackmordaunt/icns/v4" + "github.com/jackmordaunt/icns/v4/ico" +) + +// check reports what the platform that owns the format will make of an icon +// file. Findings are printed in order of severity, and the error reports the +// ones that change what is drawn. +func check(path string, r io.Reader) error { + data, err := io.ReadAll(r) + if err != nil { + return fmt.Errorf("reading %s: %w", name(path), err) + } + var lines []string + serious := 0 + switch container(data, extension(filepath.Ext(path))) { + case ".icns": + problems, err := icns.Validate(bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("reading %s: %w", name(path), err) + } + for _, p := range problems { + lines = append(lines, p.String()) + if p.Severity != icns.Advice { + serious++ + } + } + case ".ico": + problems, err := ico.Validate(bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("reading %s: %w", name(path), err) + } + for _, p := range problems { + lines = append(lines, p.String()) + if p.Severity != ico.Advice { + serious++ + } + } + default: + return fmt.Errorf("%s is not an icns or ico file", name(path)) + } + for _, line := range lines { + fmt.Fprintf(os.Stdout, "%s: %s\n", name(path), line) + } + if serious > 0 { + return fmt.Errorf("%s: %d of %d findings change what is drawn", name(path), serious, len(lines)) + } + return nil +} + +// container names the icon format the data holds, by the bytes it begins +// with and failing that by the extension it was given. +func container(data []byte, ext string) string { + switch { + case len(data) >= 4 && string(data[:4]) == "icns": + return ".icns" + case len(data) >= 4 && string(data[:4]) == "\x00\x00\x01\x00": + return ".ico" + } + if containers[ext] { + return ext + } + return "" +} + +// name labels the file in a report, calling the one arriving on a pipe what +// it is rather than leaving it blank. +func name(path string) string { + if path == "" { + return "stdin" + } + return filepath.Base(path) +} diff --git a/cmd/icnsify/check_test.go b/cmd/icnsify/check_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "bytes" + "encoding/binary" + "image" + "image/color" + "image/png" + "strings" + "testing" + + "github.com/jackmordaunt/icns/v4" + "github.com/jackmordaunt/icns/v4/ico" +) + +func art(side int) image.Image { + img := image.NewNRGBA(image.Rect(0, 0, side, side)) + for y := 0; y < side; y++ { + for x := 0; x < side; x++ { + img.SetNRGBA(x, y, color.NRGBA{ + R: uint8(x * 255 / side), + G: uint8(y * 255 / side), + B: 0x40, + A: uint8(255 - y*128/side), + }) + } + } + return img +} + +func encoded(t *testing.T, write func(*bytes.Buffer) error) []byte { + t.Helper() + var buf bytes.Buffer + if err := write(&buf); err != nil { + t.Fatalf("encoding: %v", err) + } + return buf.Bytes() +} + +// opaqueICO holds one PNG frame with no alpha channel, which Windows passes +// over. +func opaqueICO(t *testing.T) []byte { + t.Helper() + solid := image.NewNRGBA(image.Rect(0, 0, 256, 256)) + for i := 0; i < len(solid.Pix); i += 4 { + solid.Pix[i], solid.Pix[i+1], solid.Pix[i+2], solid.Pix[i+3] = 9, 9, 9, 255 + } + frame := encoded(t, func(b *bytes.Buffer) error { return png.Encode(b, solid) }) + out := make([]byte, 0, 22+len(frame)) + out = binary.LittleEndian.AppendUint16(out, 0) + out = binary.LittleEndian.AppendUint16(out, 1) + out = binary.LittleEndian.AppendUint16(out, 1) + out = append(out, 0, 0, 0, 0) + out = binary.LittleEndian.AppendUint16(out, 1) + out = binary.LittleEndian.AppendUint16(out, 32) + out = binary.LittleEndian.AppendUint32(out, uint32(len(frame))) + out = binary.LittleEndian.AppendUint32(out, 22) + return append(out, frame...) +} + +// bundleICNS holds only the small PNG type that an app bundle will not +// render. +func bundleICNS(t *testing.T) []byte { + t.Helper() + frame := encoded(t, func(b *bytes.Buffer) error { return png.Encode(b, art(16)) }) + element := func(id string, payload []byte) []byte { + out := append([]byte{}, id...) + out = binary.BigEndian.AppendUint32(out, uint32(8+len(payload))) + return append(out, payload...) + } + return element("icns", element("icp4", frame)) +} + +func TestCheckPassesOurOwnOutput(t *testing.T) { + for _, tt := range []struct { + name string + data []byte + }{ + {"icns", encoded(t, func(b *bytes.Buffer) error { return icns.Encode(b, art(1024)) })}, + {"ico", encoded(t, func(b *bytes.Buffer) error { return ico.Encode(b, art(256)) })}, + } { + t.Run(tt.name, func(t *testing.T) { + if err := check("icon."+tt.name, bytes.NewReader(tt.data)); err != nil { + t.Errorf("check reported %v", err) + } + }) + } +} + +func TestCheckFailsOnFindingsThatShow(t *testing.T) { + for _, tt := range []struct { + name string + data []byte + }{ + {"icon.ico", opaqueICO(t)}, + {"icon.icns", bundleICNS(t)}, + } { + t.Run(tt.name, func(t *testing.T) { + err := check(tt.name, bytes.NewReader(tt.data)) + if err == nil { + t.Fatal("check reported nothing") + } + if !strings.Contains(err.Error(), "change what is drawn") { + t.Errorf("error is %v, want it to count the findings", err) + } + if !strings.Contains(err.Error(), tt.name) { + t.Errorf("error does not name the file: %v", err) + } + }) + } +} + +func TestCheckRejectsWhatIsNotAnIcon(t *testing.T) { + plain := encoded(t, func(b *bytes.Buffer) error { return png.Encode(b, art(64)) }) + err := check("art.png", bytes.NewReader(plain)) + if err == nil || !strings.Contains(err.Error(), "not an icns or ico file") { + t.Errorf("check returned %v, want it to refuse a plain image", err) + } +} + +// TestContainerReadsTheBytesFirst keeps a misnamed file from being validated +// against the wrong format. +func TestContainerReadsTheBytesFirst(t *testing.T) { + for _, tt := range []struct { + name string + data []byte + ext string + want string + }{ + {"icns named ico", encoded(t, func(b *bytes.Buffer) error { return icns.Encode(b, art(64)) }), ".ico", ".icns"}, + {"ico named icns", encoded(t, func(b *bytes.Buffer) error { return ico.Encode(b, art(64)) }), ".icns", ".ico"}, + {"unknown bytes, known extension", []byte("rubbish"), ".icns", ".icns"}, + {"unknown bytes, plain extension", []byte("rubbish"), ".png", ""}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := container(tt.data, tt.ext); got != tt.want { + t.Errorf("container = %q, want %q", got, tt.want) + } + }) + } +} + +func TestNameLabelsAPipe(t *testing.T) { + if got := name(""); got != "stdin" { + t.Errorf("name(\"\") = %q, want stdin", got) + } + if got := name("/tmp/art/icon.icns"); got != "icon.icns" { + t.Errorf("name = %q, want the base name", got) + } +} 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] [-f format] [-r quality]\n\nOptions:\n", buildInfo()) + fmt.Fprintf(w, "%s\n\nUsage: icnsify [-i input] [-o output] [-f format] [-r quality] [-c]\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" { diff --git a/cmd/icnsify/main.go b/cmd/icnsify/main.go @@ -49,6 +49,9 @@ func run() error { "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 checkOnly bool + boolFlag(&checkOnly, "check", "c", + "Report what the platforms will make of an icon file, and exit.") var showVersion bool boolFlag(&showVersion, "version", "v", "Print the version and exit.") flag.Usage = usage @@ -72,6 +75,23 @@ func run() error { return err } } + // Checking reads the input and writes a report, so it runs before any + // output path is resolved or created. + if checkOnly { + if piping { + return check("", os.Stdin) + } + if inputPath == "" { + usage() + return errUsage + } + source, err := os.Open(inputPath) + if err != nil { + return fmt.Errorf("opening source image: %w", err) + } + defer source.Close() + return check(inputPath, source) + } if outputFormat != "" && !writable(extension(outputFormat)) { return fmt.Errorf("cannot write %s: choose from icns, ico, png or jpg", outputFormat) }