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 }