icns

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

validate.go (7122B)


      1 package ico
      2 
      3 import (
      4 	"cmp"
      5 	"encoding/binary"
      6 	"fmt"
      7 	"io"
      8 	"slices"
      9 	"strings"
     10 )
     11 
     12 // Severity ranks what a Problem means for the icon Windows ends up drawing.
     13 type Severity int
     14 
     15 const (
     16 	// Invisible means Windows passes the icon over and draws another size.
     17 	Invisible Severity = iota
     18 	// Degraded means Windows draws the icon, but not at the size or with the
     19 	// transparency it was given.
     20 	Degraded
     21 	// Advice means nothing present is wrong, only that something usual is
     22 	// absent.
     23 	Advice
     24 )
     25 
     26 func (s Severity) String() string {
     27 	switch s {
     28 	case Invisible:
     29 		return "invisible"
     30 	case Degraded:
     31 		return "degraded"
     32 	case Advice:
     33 		return "advice"
     34 	}
     35 	return fmt.Sprintf("unknown severity %d", s)
     36 }
     37 
     38 // Problem is one finding about an ico file.
     39 type Problem struct {
     40 	// Severity is what the finding means for the icon Windows draws.
     41 	Severity Severity
     42 	// Icon names the icon the finding concerns, such as "256x256 (PNG)", and
     43 	// is empty when the finding concerns the file as a whole.
     44 	Icon string
     45 	// Message describes what was found.
     46 	Message string
     47 }
     48 
     49 func (p Problem) String() string {
     50 	if p.Icon == "" {
     51 		return fmt.Sprintf("%s: %s", p.Severity, p.Message)
     52 	}
     53 	return fmt.Sprintf("%s: %s: %s", p.Severity, p.Icon, p.Message)
     54 }
     55 
     56 // drawn are the sizes Windows asks for often enough that a file without them
     57 // leaves it scaling another icon.
     58 var drawn = []int{16, 32, 48, 256}
     59 
     60 // Validate reads an ico file and reports what Windows will make of it, most
     61 // serious first. Data that cannot be parsed as an ico at all is returned as
     62 // an error rather than as a Problem.
     63 func Validate(r io.Reader) ([]Problem, error) {
     64 	d, err := NewDecoder(r)
     65 	if err != nil {
     66 		return nil, err
     67 	}
     68 	icons := d.Icons()
     69 	var problems []Problem
     70 	for _, icon := range icons {
     71 		problems = append(problems, icon.problems()...)
     72 	}
     73 	problems = append(problems, missing(icons)...)
     74 	problems = append(problems, duplicated(icons)...)
     75 	slices.SortStableFunc(problems, func(a, b Problem) int {
     76 		return cmp.Compare(a.Severity, b.Severity)
     77 	})
     78 	return problems, nil
     79 }
     80 
     81 // problems reports what the icon's own bytes say about it.
     82 func (e Entry) problems() []Problem {
     83 	if e.Format == FormatPNG {
     84 		return e.pngProblems()
     85 	}
     86 	return e.bmpProblems()
     87 }
     88 
     89 // pngProblems reads the image header of a PNG icon and compares what it
     90 // declares against what Windows reads and what the directory promised.
     91 func (e Entry) pngProblems() []Problem {
     92 	width, height, colour, ok := ihdr(e.data)
     93 	if !ok {
     94 		return []Problem{{
     95 			Severity: Invisible,
     96 			Icon:     e.String(),
     97 			Message:  "begins with a PNG signature but holds no image header",
     98 		}}
     99 	}
    100 	var problems []Problem
    101 	// Windows reads a PNG icon only when it is stored as 32 bit RGBA. The
    102 	// colour types without an alpha channel are greyscale, truecolour and
    103 	// indexed.
    104 	switch colour {
    105 	case 0, 2, 3:
    106 		problems = append(problems, Problem{
    107 			Severity: Invisible,
    108 			Icon:     e.String(),
    109 			Message: fmt.Sprintf(
    110 				"stored as %s, which carries no alpha channel; Windows draws a PNG icon only when it is 32 bit RGBA",
    111 				colourType(colour),
    112 			),
    113 		})
    114 	}
    115 	if width != e.Width || height != e.Height {
    116 		problems = append(problems, Problem{
    117 			Severity: Degraded,
    118 			Icon:     e.String(),
    119 			Message: fmt.Sprintf(
    120 				"holds a %dx%d image, but the directory lists it as %dx%d",
    121 				width, height, e.Width, e.Height,
    122 			),
    123 		})
    124 	}
    125 	return problems
    126 }
    127 
    128 // bmpProblems reads the header of a bitmap icon and compares what it declares
    129 // against the directory. The stored height covers the pixels and the mask
    130 // together, so it is twice the height of the icon.
    131 func (e Entry) bmpProblems() []Problem {
    132 	if len(e.data) < headerSize {
    133 		return []Problem{{
    134 			Severity: Invisible,
    135 			Icon:     e.String(),
    136 			Message:  fmt.Sprintf("holds %d bytes, too few for a bitmap header", len(e.data)),
    137 		}}
    138 	}
    139 	var (
    140 		width       = int(int32(binary.LittleEndian.Uint32(e.data[4:8])))
    141 		storedH     = int(int32(binary.LittleEndian.Uint32(e.data[8:12])))
    142 		compression = binary.LittleEndian.Uint32(e.data[16:20])
    143 		problems    []Problem
    144 	)
    145 	if compression != 0 {
    146 		problems = append(problems, Problem{
    147 			Severity: Invisible,
    148 			Icon:     e.String(),
    149 			Message:  "the bitmap is compressed",
    150 		})
    151 	}
    152 	if storedH <= 0 || storedH%2 != 0 {
    153 		problems = append(problems, Problem{
    154 			Severity: Invisible,
    155 			Icon:     e.String(),
    156 			Message: fmt.Sprintf(
    157 				"the bitmap declares a height of %d, which is not the pixels and the mask together",
    158 				storedH,
    159 			),
    160 		})
    161 	} else if width != e.Width || storedH/2 != e.Height {
    162 		problems = append(problems, Problem{
    163 			Severity: Degraded,
    164 			Icon:     e.String(),
    165 			Message: fmt.Sprintf(
    166 				"the bitmap is %dx%d, but the directory lists it as %dx%d",
    167 				width, storedH/2, e.Width, e.Height,
    168 			),
    169 		})
    170 	}
    171 	if e.Width >= pngAbove {
    172 		problems = append(problems, Problem{
    173 			Severity: Advice,
    174 			Icon:     e.String(),
    175 			Message: fmt.Sprintf(
    176 				"a bitmap this size costs %d bytes, where a PNG would cost a fraction of it",
    177 				len(e.data),
    178 			),
    179 		})
    180 	}
    181 	return problems
    182 }
    183 
    184 // missing reports the sizes Windows draws that the file does not hold.
    185 func missing(icons []Entry) []Problem {
    186 	var absent []string
    187 	for _, size := range drawn {
    188 		if slices.ContainsFunc(icons, func(e Entry) bool {
    189 			return e.Width == size && e.Height == size
    190 		}) {
    191 			continue
    192 		}
    193 		absent = append(absent, fmt.Sprintf("%dx%d", size, size))
    194 	}
    195 	if len(absent) == 0 {
    196 		return nil
    197 	}
    198 	return []Problem{{
    199 		Severity: Advice,
    200 		Message: fmt.Sprintf(
    201 			"the file holds no %s, so Windows scales another icon to draw them",
    202 			strings.Join(absent, ", "),
    203 		),
    204 	}}
    205 }
    206 
    207 // duplicated reports sizes the file holds more than once.
    208 func duplicated(icons []Entry) []Problem {
    209 	seen := make(map[string]int, len(icons))
    210 	var order []string
    211 	for _, icon := range icons {
    212 		size := fmt.Sprintf("%dx%d", icon.Width, icon.Height)
    213 		if seen[size] == 0 {
    214 			order = append(order, size)
    215 		}
    216 		seen[size]++
    217 	}
    218 	var problems []Problem
    219 	for _, size := range order {
    220 		if seen[size] < 2 {
    221 			continue
    222 		}
    223 		problems = append(problems, Problem{
    224 			Severity: Degraded,
    225 			Message: fmt.Sprintf(
    226 				"the file holds %d icons of %s, and which one Windows draws is not defined",
    227 				seen[size], size,
    228 			),
    229 		})
    230 	}
    231 	return problems
    232 }
    233 
    234 // ihdr reads the width, height and colour type a PNG declares. The boolean
    235 // reports whether the image header is present.
    236 func ihdr(data []byte) (width, height int, colour byte, ok bool) {
    237 	// The signature, then the length and type of the first chunk, then the
    238 	// thirteen bytes the image header holds.
    239 	const at = 16
    240 	if len(data) < at+10 || string(data[12:16]) != "IHDR" {
    241 		return 0, 0, 0, false
    242 	}
    243 	width = int(binary.BigEndian.Uint32(data[at : at+4]))
    244 	height = int(binary.BigEndian.Uint32(data[at+4 : at+8]))
    245 	return width, height, data[at+9], true
    246 }
    247 
    248 // colourType names how a PNG stores its pixels.
    249 func colourType(c byte) string {
    250 	switch c {
    251 	case 0:
    252 		return "greyscale"
    253 	case 2:
    254 		return "truecolour"
    255 	case 3:
    256 		return "indexed colour"
    257 	case 4:
    258 		return "greyscale with alpha"
    259 	case 6:
    260 		return "truecolour with alpha"
    261 	}
    262 	return fmt.Sprintf("colour type %d", c)
    263 }