icns

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

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 }