icns

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

commit 4e362e59742bc67ef4aad4fb6527118b7e8c94f2
parent b18d6e718a37b11a153f4ccdd8b2654a5568cc54
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Sun, 20 Sep 2026 15:35:40 -0300

ico: report what Windows will make of a file

An icon Windows silently passes over looks exactly like one it draws: an
opaque PNG frame loses its alpha channel on the way out, and every Windows
decoder skips it. A failure that presents as success has to be asked for.

Diffstat:
Aico/validate.go | 263+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aico/validate_test.go | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 476 insertions(+), 0 deletions(-)

diff --git a/ico/validate.go b/ico/validate.go @@ -0,0 +1,263 @@ +package ico + +import ( + "cmp" + "encoding/binary" + "fmt" + "io" + "slices" + "strings" +) + +// Severity ranks what a Problem means for the icon Windows ends up drawing. +type Severity int + +const ( + // Invisible means Windows passes the icon over and draws another size. + Invisible Severity = iota + // Degraded means Windows draws the icon, but not at the size or with the + // transparency it was given. + Degraded + // Advice means nothing present is wrong, only that something usual is + // absent. + Advice +) + +func (s Severity) String() string { + switch s { + case Invisible: + return "invisible" + case Degraded: + return "degraded" + case Advice: + return "advice" + } + return fmt.Sprintf("unknown severity %d", s) +} + +// Problem is one finding about an ico file. +type Problem struct { + // Severity is what the finding means for the icon Windows draws. + Severity Severity + // Icon names the icon the finding concerns, such as "256x256 (PNG)", and + // is empty when the finding concerns the file as a whole. + Icon string + // Message describes what was found. + Message string +} + +func (p Problem) String() string { + if p.Icon == "" { + return fmt.Sprintf("%s: %s", p.Severity, p.Message) + } + return fmt.Sprintf("%s: %s: %s", p.Severity, p.Icon, p.Message) +} + +// drawn are the sizes Windows asks for often enough that a file without them +// leaves it scaling another icon. +var drawn = []int{16, 32, 48, 256} + +// Validate reads an ico file and reports what Windows will make of it, most +// serious first. Data that cannot be parsed as an ico at all is returned as +// an error rather than as a Problem. +func Validate(r io.Reader) ([]Problem, error) { + d, err := NewDecoder(r) + if err != nil { + return nil, err + } + icons := d.Icons() + var problems []Problem + for _, icon := range icons { + problems = append(problems, icon.problems()...) + } + problems = append(problems, missing(icons)...) + problems = append(problems, duplicated(icons)...) + slices.SortStableFunc(problems, func(a, b Problem) int { + return cmp.Compare(a.Severity, b.Severity) + }) + return problems, nil +} + +// problems reports what the icon's own bytes say about it. +func (e Entry) problems() []Problem { + if e.Format == FormatPNG { + return e.pngProblems() + } + return e.bmpProblems() +} + +// pngProblems reads the image header of a PNG icon and compares what it +// declares against what Windows reads and what the directory promised. +func (e Entry) pngProblems() []Problem { + width, height, colour, ok := ihdr(e.data) + if !ok { + return []Problem{{ + Severity: Invisible, + Icon: e.String(), + Message: "begins with a PNG signature but holds no image header", + }} + } + var problems []Problem + // Windows reads a PNG icon only when it is stored as 32 bit RGBA. The + // colour types without an alpha channel are greyscale, truecolour and + // indexed. + switch colour { + case 0, 2, 3: + problems = append(problems, Problem{ + Severity: Invisible, + Icon: e.String(), + Message: fmt.Sprintf( + "stored as %s, which carries no alpha channel; Windows draws a PNG icon only when it is 32 bit RGBA", + colourType(colour), + ), + }) + } + if width != e.Width || height != e.Height { + problems = append(problems, Problem{ + Severity: Degraded, + Icon: e.String(), + Message: fmt.Sprintf( + "holds a %dx%d image, but the directory lists it as %dx%d", + width, height, e.Width, e.Height, + ), + }) + } + return problems +} + +// bmpProblems reads the header of a bitmap icon and compares what it declares +// against the directory. The stored height covers the pixels and the mask +// together, so it is twice the height of the icon. +func (e Entry) bmpProblems() []Problem { + if len(e.data) < headerSize { + return []Problem{{ + Severity: Invisible, + Icon: e.String(), + Message: fmt.Sprintf("holds %d bytes, too few for a bitmap header", len(e.data)), + }} + } + var ( + width = int(int32(binary.LittleEndian.Uint32(e.data[4:8]))) + storedH = int(int32(binary.LittleEndian.Uint32(e.data[8:12]))) + compression = binary.LittleEndian.Uint32(e.data[16:20]) + problems []Problem + ) + if compression != 0 { + problems = append(problems, Problem{ + Severity: Invisible, + Icon: e.String(), + Message: "the bitmap is compressed", + }) + } + if storedH <= 0 || storedH%2 != 0 { + problems = append(problems, Problem{ + Severity: Invisible, + Icon: e.String(), + Message: fmt.Sprintf( + "the bitmap declares a height of %d, which is not the pixels and the mask together", + storedH, + ), + }) + } else if width != e.Width || storedH/2 != e.Height { + problems = append(problems, Problem{ + Severity: Degraded, + Icon: e.String(), + Message: fmt.Sprintf( + "the bitmap is %dx%d, but the directory lists it as %dx%d", + width, storedH/2, e.Width, e.Height, + ), + }) + } + if e.Width >= pngAbove { + problems = append(problems, Problem{ + Severity: Advice, + Icon: e.String(), + Message: fmt.Sprintf( + "a bitmap this size costs %d bytes, where a PNG would cost a fraction of it", + len(e.data), + ), + }) + } + return problems +} + +// missing reports the sizes Windows draws that the file does not hold. +func missing(icons []Entry) []Problem { + var absent []string + for _, size := range drawn { + if slices.ContainsFunc(icons, func(e Entry) bool { + return e.Width == size && e.Height == size + }) { + continue + } + absent = append(absent, fmt.Sprintf("%dx%d", size, size)) + } + if len(absent) == 0 { + return nil + } + return []Problem{{ + Severity: Advice, + Message: fmt.Sprintf( + "the file holds no %s, so Windows scales another icon to draw them", + strings.Join(absent, ", "), + ), + }} +} + +// duplicated reports sizes the file holds more than once. +func duplicated(icons []Entry) []Problem { + seen := make(map[string]int, len(icons)) + var order []string + for _, icon := range icons { + size := fmt.Sprintf("%dx%d", icon.Width, icon.Height) + if seen[size] == 0 { + order = append(order, size) + } + seen[size]++ + } + var problems []Problem + for _, size := range order { + if seen[size] < 2 { + continue + } + problems = append(problems, Problem{ + Severity: Degraded, + Message: fmt.Sprintf( + "the file holds %d icons of %s, and which one Windows draws is not defined", + seen[size], size, + ), + }) + } + return problems +} + +// ihdr reads the width, height and colour type a PNG declares. The boolean +// reports whether the image header is present. +func ihdr(data []byte) (width, height int, colour byte, ok bool) { + // The signature, then the length and type of the first chunk, then the + // thirteen bytes the image header holds. + const at = 16 + if len(data) < at+10 || string(data[12:16]) != "IHDR" { + return 0, 0, 0, false + } + width = int(binary.BigEndian.Uint32(data[at : at+4])) + height = int(binary.BigEndian.Uint32(data[at+4 : at+8])) + return width, height, data[at+9], true +} + +// colourType names how a PNG stores its pixels. +func colourType(c byte) string { + switch c { + case 0: + return "greyscale" + case 2: + return "truecolour" + case 3: + return "indexed colour" + case 4: + return "greyscale with alpha" + case 6: + return "truecolour with alpha" + } + return fmt.Sprintf("colour type %d", c) +} diff --git a/ico/validate_test.go b/ico/validate_test.go @@ -0,0 +1,213 @@ +package ico + +import ( + "bytes" + "encoding/binary" + "image" + "image/color" + "image/png" + "strings" + "testing" +) + +// frame is one icon to place in a file the encoder would not write itself. +type frame struct { + width, height int + data []byte +} + +// buildICO assembles a file from payloads already encoded, so a test can +// store something invalid on purpose. +func buildICO(frames ...frame) []byte { + out := make([]byte, 0, directorySize+entrySize*len(frames)) + out = binary.LittleEndian.AppendUint16(out, 0) + out = binary.LittleEndian.AppendUint16(out, 1) + out = binary.LittleEndian.AppendUint16(out, uint16(len(frames))) + offset := directorySize + entrySize*len(frames) + for _, f := range frames { + out = append(out, byte(f.width), byte(f.height), 0, 0) + out = binary.LittleEndian.AppendUint16(out, 1) + out = binary.LittleEndian.AppendUint16(out, 32) + out = binary.LittleEndian.AppendUint32(out, uint32(len(f.data))) + out = binary.LittleEndian.AppendUint32(out, uint32(offset)) + offset += len(f.data) + } + for _, f := range frames { + out = append(out, f.data...) + } + return out +} + +// encodePNG encodes img the way a program that does not know what Windows +// requires would: through image/png, which drops the alpha channel from an +// image that is entirely opaque. +func encodePNG(t *testing.T, img image.Image) []byte { + t.Helper() + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encoding png: %v", err) + } + return buf.Bytes() +} + +func validate(t *testing.T, data []byte) []Problem { + t.Helper() + problems, err := Validate(bytes.NewReader(data)) + if err != nil { + t.Fatalf("validating: %v", err) + } + return problems +} + +// find returns the first problem whose message contains want. +func find(problems []Problem, want string) (Problem, bool) { + for _, p := range problems { + if strings.Contains(p.Message, want) { + return p, true + } + } + return Problem{}, false +} + +func TestValidateAcceptsOurOwnOutput(t *testing.T) { + var buf bytes.Buffer + if err := Encode(&buf, gradient(256)); err != nil { + t.Fatalf("encoding: %v", err) + } + if problems := validate(t, buf.Bytes()); len(problems) != 0 { + for _, p := range problems { + t.Errorf("unexpected problem: %s", p) + } + } +} + +// TestValidateAcceptsAnOpaqueSource covers the case the encoder guards +// against: an image with no transparency still has to reach Windows with an +// alpha channel. +func TestValidateAcceptsAnOpaqueSource(t *testing.T) { + var buf bytes.Buffer + if err := Encode(&buf, solid(256, color.NRGBA{R: 1, G: 2, B: 3, A: 255})); err != nil { + t.Fatalf("encoding: %v", err) + } + if problems := validate(t, buf.Bytes()); len(problems) != 0 { + for _, p := range problems { + t.Errorf("unexpected problem: %s", p) + } + } +} + +func TestValidateReportsAPNGWithoutAlpha(t *testing.T) { + // image/png writes truecolour for an image that is entirely opaque, + // which is the frame Windows passes over. + opaque := encodePNG(t, solid(256, color.NRGBA{R: 1, G: 2, B: 3, A: 255})) + if _, _, colour, ok := ihdr(opaque); !ok || colour != 2 { + t.Fatalf("fixture is colour type %d, want truecolour", colour) + } + problems := validate(t, buildICO(frame{width: 0, height: 0, data: opaque})) + p, ok := find(problems, "no alpha channel") + if !ok { + t.Fatalf("no problem reported, got %v", problems) + } + if p.Severity != Invisible { + t.Errorf("severity is %s, want %s", p.Severity, Invisible) + } + if !strings.Contains(p.Icon, "256x256") { + t.Errorf("problem names %q, want the 256 pixel icon", p.Icon) + } +} + +func TestValidateAcceptsAPNGWithAlpha(t *testing.T) { + withA := encodePNG(t, withAlpha{solid(256, color.NRGBA{R: 1, G: 2, B: 3, A: 255})}) + if _, _, colour, ok := ihdr(withA); !ok || colour != 6 { + t.Fatalf("fixture is colour type %d, want truecolour with alpha", colour) + } + problems := validate(t, buildICO(frame{width: 0, height: 0, data: withA})) + if _, ok := find(problems, "alpha channel"); ok { + t.Errorf("reported a problem for a frame that has one: %v", problems) + } +} + +func TestValidateReportsSizeDisagreement(t *testing.T) { + // The directory says 64, the image is 32. + small := encodePNG(t, withAlpha{gradient(32)}) + problems := validate(t, buildICO(frame{width: 64, height: 64, data: small})) + p, ok := find(problems, "the directory lists it as 64x64") + if !ok { + t.Fatalf("no problem reported, got %v", problems) + } + if p.Severity != Degraded { + t.Errorf("severity is %s, want %s", p.Severity, Degraded) + } +} + +func TestValidateReportsDuplicateSizes(t *testing.T) { + one := encodePNG(t, withAlpha{gradient(32)}) + two := encodePNG(t, withAlpha{solid(32, color.NRGBA{A: 255})}) + problems := validate(t, buildICO( + frame{width: 32, height: 32, data: one}, + frame{width: 32, height: 32, data: two}, + )) + p, ok := find(problems, "2 icons of 32x32") + if !ok { + t.Fatalf("no problem reported, got %v", problems) + } + if p.Severity != Degraded { + t.Errorf("severity is %s, want %s", p.Severity, Degraded) + } +} + +func TestValidateReportsMissingSizes(t *testing.T) { + var buf bytes.Buffer + if err := Encode(&buf, gradient(16)); err != nil { + t.Fatalf("encoding: %v", err) + } + problems := validate(t, buf.Bytes()) + p, ok := find(problems, "holds no") + if !ok { + t.Fatalf("no problem reported, got %v", problems) + } + if p.Severity != Advice { + t.Errorf("severity is %s, want %s", p.Severity, Advice) + } + for _, want := range []string{"32x32", "48x48", "256x256"} { + if !strings.Contains(p.Message, want) { + t.Errorf("message does not name %s: %s", want, p.Message) + } + } +} + +func TestValidateReportsATruncatedBitmap(t *testing.T) { + problems := validate(t, buildICO(frame{width: 32, height: 32, data: []byte{1, 2, 3}})) + p, ok := find(problems, "too few for a bitmap header") + if !ok { + t.Fatalf("no problem reported, got %v", problems) + } + if p.Severity != Invisible { + t.Errorf("severity is %s, want %s", p.Severity, Invisible) + } +} + +func TestValidateOrdersBySeverity(t *testing.T) { + // One frame Windows passes over, at a size that leaves others missing. + opaque := encodePNG(t, solid(256, color.NRGBA{R: 1, A: 255})) + problems := validate(t, buildICO(frame{width: 0, height: 0, data: opaque})) + if len(problems) < 2 { + t.Fatalf("want several problems, got %v", problems) + } + for i := 1; i < len(problems); i++ { + if problems[i-1].Severity > problems[i].Severity { + t.Fatalf("problem %d is less serious than the one after it: %v", i-1, problems) + } + } + if problems[0].Severity != Invisible { + t.Errorf("first problem is %s, want %s", problems[0].Severity, Invisible) + } +} + +func TestValidateRejectsWhatItCannotParse(t *testing.T) { + for _, data := range [][]byte{nil, []byte("not an icon"), {0, 0, 1, 0, 0, 0}} { + if _, err := Validate(bytes.NewReader(data)); err == nil { + t.Errorf("validating %q returned no error", data) + } + } +}