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:
| A | ico/validate.go | | | 263 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | ico/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)
+ }
+ }
+}