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:
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)
}