validate.go (6093B)
1 package icns 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 macOS ends up drawing. 13 type Severity int 14 15 const ( 16 // Invisible means the icon cannot be read, so macOS draws another size. 17 Invisible Severity = iota 18 // Degraded means the icon is drawn, but not at the size, with the 19 // transparency, or in the places it was meant to be. 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 icns file. 39 type Problem struct { 40 // Severity is what the finding means for the icon macOS draws. 41 Severity Severity 42 // Icon names the icon the finding concerns, such as "it32 128", and is 43 // 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 // Validate reads an icns file and reports what macOS will make of it, most 57 // serious first. Data that cannot be parsed as an icns at all is returned as 58 // an error rather than as a Problem. 59 func Validate(r io.Reader) ([]Problem, error) { 60 d, err := NewDecoder(r) 61 if err != nil { 62 return nil, err 63 } 64 icons := d.Icons() 65 var problems []Problem 66 for _, icon := range icons { 67 problems = append(problems, icon.problems()...) 68 } 69 problems = append(problems, bundleProblems(icons)...) 70 problems = append(problems, gaps(icons)...) 71 slices.SortStableFunc(problems, func(a, b Problem) int { 72 return cmp.Compare(a.Severity, b.Severity) 73 }) 74 return problems, nil 75 } 76 77 // problems reports what the icon's own bytes say about it. 78 func (e Entry) problems() []Problem { 79 // JPEG 2000 is read by macOS and by nothing else without a codec, so it 80 // is reported rather than decoded. 81 if e.ImageFormat == ImageFormatJPEG2000 { 82 return []Problem{{ 83 Severity: Advice, 84 Icon: e.OsType.String(), 85 Message: "stored as JPEG 2000, which macOS reads and most other tools cannot", 86 }} 87 } 88 var problems []Problem 89 if e.enc == encodingRGB && e.mask == nil { 90 problems = append(problems, Problem{ 91 Severity: Degraded, 92 Icon: e.OsType.String(), 93 Message: fmt.Sprintf( 94 "the file holds no %s element, so the icon draws fully opaque", 95 e.OsType.mask, 96 ), 97 }) 98 } 99 problems = append(problems, e.paddingProblems()...) 100 img, err := e.Decode() 101 if err != nil { 102 return append(problems, Problem{ 103 Severity: Invisible, 104 Icon: e.OsType.String(), 105 Message: fmt.Sprintf("cannot be decoded: %v", err), 106 }) 107 } 108 width, height := int(e.Size), int(e.Size) 109 if e.height > 0 { 110 height = int(e.height) 111 } 112 if size := img.Bounds().Size(); size.X != width || size.Y != height { 113 problems = append(problems, Problem{ 114 Severity: Degraded, 115 Icon: e.OsType.String(), 116 Message: fmt.Sprintf( 117 "holds a %dx%d image, where the type is %dx%d", 118 size.X, size.Y, width, height, 119 ), 120 }) 121 } 122 return problems 123 } 124 125 // paddingProblems reports a colour plane element whose stream ends on its 126 // last run. Apple's reader on Apple silicon drops the last value of such a 127 // stream, so a byte has to follow it for the icon to survive. 128 // 129 // Only the three plane types are read this way. The ARGB elements are left 130 // alone: actool on macOS 26 writes them with their stream ending exactly on 131 // the last run, so whatever drops a value does not reach them. 132 func (e Entry) paddingProblems() []Problem { 133 if e.ImageFormat != ImageFormatRGB { 134 return nil 135 } 136 var ( 137 pixels = int(e.Size) * int(e.Size) 138 data = e.data 139 want = pixels * 3 140 ) 141 // it32 is the one colour element that prefixes its planes with four zero 142 // bytes. 143 if e.ID == "it32" && len(data) >= 4 && binary.BigEndian.Uint32(data[:4]) == 0 { 144 data = data[4:] 145 } 146 // Data stored at its exact length is not compressed, so there is no run 147 // for a reader to drop. 148 if len(data) == want { 149 return nil 150 } 151 _, used, err := unpackRLE(data, want) 152 if err != nil || used < len(data) { 153 return nil 154 } 155 return []Problem{{ 156 Severity: Degraded, 157 Icon: e.OsType.String(), 158 Message: "the compressed planes end on their last run, which Apple silicon drops, blanking the tail of the icon", 159 }} 160 } 161 162 // bundleProblems reports the small PNG types that an app bundle does not 163 // render, when the file holds nothing else at their size. 164 func bundleProblems(icons []Entry) []Problem { 165 held := func(id string) bool { 166 return slices.ContainsFunc(icons, func(e Entry) bool { return e.ID == id }) 167 } 168 var problems []Problem 169 for _, pair := range []struct{ png, colour, mask string }{ 170 {png: "icp4", colour: "is32", mask: "s8mk"}, 171 {png: "icp5", colour: "il32", mask: "l8mk"}, 172 } { 173 if !held(pair.png) || held(pair.colour) { 174 continue 175 } 176 problems = append(problems, Problem{ 177 Severity: Degraded, 178 Icon: osTypeFromID(pair.png).String(), 179 Message: fmt.Sprintf( 180 "does not render from an app bundle, and the file holds no %s and %s at that size", 181 pair.colour, pair.mask, 182 ), 183 }) 184 } 185 return problems 186 } 187 188 // gaps reports the written types the file lacks below the largest icon it 189 // holds. Sizes above that are absent because the artwork ran out, which is 190 // not a fault of the file. 191 func gaps(icons []Entry) []Problem { 192 var largest uint 193 for _, icon := range icons { 194 largest = max(largest, icon.Size) 195 } 196 var absent []string 197 for _, t := range osTypes { 198 if !t.emit || t.Size > largest { 199 continue 200 } 201 if slices.ContainsFunc(icons, func(e Entry) bool { return e.ID == t.ID }) { 202 continue 203 } 204 absent = append(absent, t.String()) 205 } 206 if len(absent) == 0 { 207 return nil 208 } 209 return []Problem{{ 210 Severity: Advice, 211 Message: fmt.Sprintf( 212 "the file holds no %s, so macOS scales another icon where they are asked for", 213 strings.Join(absent, ", "), 214 ), 215 }} 216 }