commit 39e1831ec6f3c31a4a1793dc0ccebd412b76535c
parent 4e362e59742bc67ef4aad4fb6527118b7e8c94f2
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Sun, 20 Sep 2026 15:41:09 -0300
icns: report what macOS will make of a file
The traps this package knows about are invisible in the output: planes that
end on their last run lose their tail to Apple silicon, an icon without its
mask draws opaque, and icp4 renders everywhere but an app bundle.
Diffstat:
| M | rle.go | | | 22 | ++++++++++++++-------- |
| M | rle_test.go | | | 4 | ++-- |
| A | validate.go | | | 217 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | validate_test.go | | | 263 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
4 files changed, 496 insertions(+), 10 deletions(-)
diff --git a/rle.go b/rle.go
@@ -13,35 +13,41 @@ import (
// produced. A lead byte below 128 introduces lead+1 literal bytes; a lead
// byte of 128 or above repeats the byte after it lead-125 times. Data that is
// already want bytes long is stored uncompressed and is returned as it is.
-func unpackRLE(data []byte, want int) ([]byte, error) {
+//
+// The second result is how many bytes of data were read, which is short of
+// its length when the stream carries padding after the last run.
+func unpackRLE(data []byte, want int) ([]byte, int, error) {
if len(data) == want {
- return data, nil
+ return data, want, nil
}
out := make([]byte, 0, want)
+ used := 0
for i := 0; i < len(data) && len(out) < want; {
lead := int(data[i])
i++
if lead < 128 {
n := lead + 1
if i+n > len(data) {
- return nil, fmt.Errorf("%w: literal run of %d bytes overruns the element", ErrMalformed, n)
+ return nil, i, fmt.Errorf("%w: literal run of %d bytes overruns the element", ErrMalformed, n)
}
out = append(out, data[i:i+n]...)
i += n
+ used = i
continue
}
if i == len(data) {
- return nil, fmt.Errorf("%w: repeat run with no byte to repeat", ErrMalformed)
+ return nil, i, fmt.Errorf("%w: repeat run with no byte to repeat", ErrMalformed)
}
for n := lead - 125; n > 0; n-- {
out = append(out, data[i])
}
i++
+ used = i
}
if len(out) != want {
- return nil, fmt.Errorf("%w: expanded to %d bytes, want %d", ErrMalformed, len(out), want)
+ return nil, len(data), fmt.Errorf("%w: expanded to %d bytes, want %d", ErrMalformed, len(out), want)
}
- return out, nil
+ return out, used, nil
}
// packRLE compresses data into the icns variant of PackBits. A run of three
@@ -122,7 +128,7 @@ func splitPlanes(img image.Image, side int) (planes, mask []byte) {
// follow an ARGB header, alpha first and then the colour channels.
func decodeARGB(data []byte, side int) (image.Image, error) {
pixels := side * side
- planes, err := unpackRLE(data, pixels*4)
+ planes, _, err := unpackRLE(data, pixels*4)
if err != nil {
return nil, err
}
@@ -144,7 +150,7 @@ func decodeARGB(data []byte, side int) (image.Image, error) {
// opaque, which is how the icons that predate masks are meant to render.
func decodeRGB(data, mask []byte, side int) (image.Image, error) {
pixels := side * side
- planes, err := unpackRLE(data, pixels*3)
+ planes, _, err := unpackRLE(data, pixels*3)
if err != nil {
return nil, err
}
diff --git a/rle_test.go b/rle_test.go
@@ -90,7 +90,7 @@ func TestUnpackRLE(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.desc, func(st *testing.T) {
- got, err := unpackRLE(tt.data, tt.want)
+ got, _, err := unpackRLE(tt.data, tt.want)
if tt.err != nil {
if !errors.Is(err, tt.err) {
st.Fatalf("error = %v, want %v", err, tt.err)
@@ -122,7 +122,7 @@ func TestPadRLE(t *testing.T) {
}
// Whatever the padding, the data still reads back.
planes := bytes.Repeat([]byte{0x40}, 768)
- out, err := unpackRLE(padRLE(packRLE(planes), len(planes)), len(planes))
+ out, _, err := unpackRLE(padRLE(packRLE(planes), len(planes)), len(planes))
if err != nil {
t.Fatal(err)
}
diff --git a/validate.go b/validate.go
@@ -0,0 +1,217 @@
+package icns
+
+import (
+ "cmp"
+ "encoding/binary"
+ "fmt"
+ "io"
+ "slices"
+ "strings"
+)
+
+// Severity ranks what a Problem means for the icon macOS ends up drawing.
+type Severity int
+
+const (
+ // Invisible means the icon cannot be read, so macOS draws another size.
+ Invisible Severity = iota
+ // Degraded means the icon is drawn, but not at the size, with the
+ // transparency, or in the places it was meant to be.
+ 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 icns file.
+type Problem struct {
+ // Severity is what the finding means for the icon macOS draws.
+ Severity Severity
+ // Icon names the icon the finding concerns, such as "it32 128", 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)
+}
+
+// Validate reads an icns file and reports what macOS will make of it, most
+// serious first. Data that cannot be parsed as an icns 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, bundleProblems(icons)...)
+ problems = append(problems, gaps(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 {
+ // JPEG 2000 is read by macOS and by nothing else without a codec, so it
+ // is reported rather than decoded.
+ if e.ImageFormat == ImageFormatJPEG2000 {
+ return []Problem{{
+ Severity: Advice,
+ Icon: e.OsType.String(),
+ Message: "stored as JPEG 2000, which macOS reads and most other tools cannot",
+ }}
+ }
+ var problems []Problem
+ if e.enc == encodingRGB && e.mask == nil {
+ problems = append(problems, Problem{
+ Severity: Degraded,
+ Icon: e.OsType.String(),
+ Message: fmt.Sprintf(
+ "the file holds no %s element, so the icon draws fully opaque",
+ e.OsType.mask,
+ ),
+ })
+ }
+ problems = append(problems, e.paddingProblems()...)
+ img, err := e.Decode()
+ if err != nil {
+ return append(problems, Problem{
+ Severity: Invisible,
+ Icon: e.OsType.String(),
+ Message: fmt.Sprintf("cannot be decoded: %v", err),
+ })
+ }
+ width, height := int(e.Size), int(e.Size)
+ if e.height > 0 {
+ height = int(e.height)
+ }
+ if size := img.Bounds().Size(); size.X != width || size.Y != height {
+ problems = append(problems, Problem{
+ Severity: Degraded,
+ Icon: e.OsType.String(),
+ Message: fmt.Sprintf(
+ "holds a %dx%d image, where the type is %dx%d",
+ size.X, size.Y, width, height,
+ ),
+ })
+ }
+ return problems
+}
+
+// paddingProblems reports a run-length encoded icon whose stream ends on its
+// last run. Apple's reader on Apple silicon drops the last value of such a
+// stream, so a byte has to follow it for the icon to survive.
+func (e Entry) paddingProblems() []Problem {
+ var (
+ pixels = int(e.Size) * int(e.Size)
+ data []byte
+ want int
+ )
+ switch e.ImageFormat {
+ case ImageFormatRGB:
+ data, want = e.data, pixels*3
+ // it32 is the one colour element that prefixes its planes with four
+ // zero bytes.
+ if e.ID == "it32" && len(data) >= 4 && binary.BigEndian.Uint32(data[:4]) == 0 {
+ data = data[4:]
+ }
+ case ImageFormatARGB:
+ data, want = e.data[len(argbHeader):], pixels*4
+ default:
+ return nil
+ }
+ // Data stored at its exact length is not compressed, so there is no run
+ // for a reader to drop.
+ if len(data) == want {
+ return nil
+ }
+ _, used, err := unpackRLE(data, want)
+ if err != nil || used < len(data) {
+ return nil
+ }
+ return []Problem{{
+ Severity: Degraded,
+ Icon: e.OsType.String(),
+ Message: "the compressed planes end on their last run, which Apple silicon drops, blanking the tail of the icon",
+ }}
+}
+
+// bundleProblems reports the small PNG types that an app bundle does not
+// render, when the file holds nothing else at their size.
+func bundleProblems(icons []Entry) []Problem {
+ held := func(id string) bool {
+ return slices.ContainsFunc(icons, func(e Entry) bool { return e.ID == id })
+ }
+ var problems []Problem
+ for _, pair := range []struct{ png, colour, mask string }{
+ {png: "icp4", colour: "is32", mask: "s8mk"},
+ {png: "icp5", colour: "il32", mask: "l8mk"},
+ } {
+ if !held(pair.png) || held(pair.colour) {
+ continue
+ }
+ problems = append(problems, Problem{
+ Severity: Degraded,
+ Icon: osTypeFromID(pair.png).String(),
+ Message: fmt.Sprintf(
+ "does not render from an app bundle, and the file holds no %s and %s at that size",
+ pair.colour, pair.mask,
+ ),
+ })
+ }
+ return problems
+}
+
+// gaps reports the written types the file lacks below the largest icon it
+// holds. Sizes above that are absent because the artwork ran out, which is
+// not a fault of the file.
+func gaps(icons []Entry) []Problem {
+ var largest uint
+ for _, icon := range icons {
+ largest = max(largest, icon.Size)
+ }
+ var absent []string
+ for _, t := range osTypes {
+ if !t.emit || t.Size > largest {
+ continue
+ }
+ if slices.ContainsFunc(icons, func(e Entry) bool { return e.ID == t.ID }) {
+ continue
+ }
+ absent = append(absent, t.String())
+ }
+ if len(absent) == 0 {
+ return nil
+ }
+ return []Problem{{
+ Severity: Advice,
+ Message: fmt.Sprintf(
+ "the file holds no %s, so macOS scales another icon where they are asked for",
+ strings.Join(absent, ", "),
+ ),
+ }}
+}
diff --git a/validate_test.go b/validate_test.go
@@ -0,0 +1,263 @@
+package icns
+
+import (
+ "bytes"
+ "image"
+ "image/png"
+ "strings"
+ "testing"
+)
+
+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
+}
+
+// found returns the first problem whose message contains want.
+func found(problems []Problem, want string) (Problem, bool) {
+ for _, p := range problems {
+ if strings.Contains(p.Message, want) {
+ return p, true
+ }
+ }
+ return Problem{}, false
+}
+
+// flat returns an image of one colour, which the run-length encoder
+// compresses rather than storing at its exact length.
+func flat(side int) image.Image {
+ img := image.NewNRGBA(image.Rect(0, 0, side, side))
+ for i := 0; i < len(img.Pix); i += 4 {
+ img.Pix[i], img.Pix[i+1], img.Pix[i+2], img.Pix[i+3] = 0x20, 0x40, 0x60, 0xFF
+ }
+ return img
+}
+
+// pngOf encodes an image of a given side, so an element can be given a
+// payload of a size its type does not name.
+func pngOf(t testing.TB, side int) []byte {
+ t.Helper()
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, flat(side)); err != nil {
+ t.Fatal(err)
+ }
+ return buf.Bytes()
+}
+
+func TestValidateAcceptsOurOwnOutput(t *testing.T) {
+ var buf bytes.Buffer
+ if err := Encode(&buf, gradient(1024)); 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)
+ }
+ }
+}
+
+// TestValidateReportsUnpaddedPlanes covers the reason padRLE exists: without
+// the trailing byte, Apple silicon drops the last run.
+func TestValidateReportsUnpaddedPlanes(t *testing.T) {
+ planes, mask := splitPlanes(flat(32), 32)
+ packed := packRLE(planes)
+ if len(packed) == len(planes) {
+ t.Fatal("fixture did not compress, so there is no run to drop")
+ }
+ data := file(
+ encodeElement("il32", packed),
+ encodeElement("l8mk", mask),
+ )
+ p, ok := found(validate(t, data), "Apple silicon drops")
+ if !ok {
+ t.Fatalf("no problem reported, got %v", validate(t, data))
+ }
+ if p.Severity != Degraded {
+ t.Errorf("severity is %s, want %s", p.Severity, Degraded)
+ }
+}
+
+func TestValidateAcceptsPaddedPlanes(t *testing.T) {
+ planes, mask := splitPlanes(flat(32), 32)
+ data := file(
+ encodeElement("il32", padRLE(packRLE(planes), len(planes))),
+ encodeElement("l8mk", mask),
+ )
+ if _, ok := found(validate(t, data), "Apple silicon drops"); ok {
+ t.Errorf("reported padding that is present: %v", validate(t, data))
+ }
+}
+
+// TestValidateAcceptsUncompressedPlanes covers the other half of the rule:
+// planes stored at their exact length hold no run to drop.
+func TestValidateAcceptsUncompressedPlanes(t *testing.T) {
+ // No three bytes in a row are equal, so every run is a literal and the
+ // encoder stores the planes as they are.
+ planes := make([]byte, 32*32*3)
+ for i := range planes {
+ planes[i] = byte(i % 251)
+ }
+ mask := make([]byte, 32*32)
+ packed := padRLE(packRLE(planes), len(planes))
+ if len(packed) != len(planes) {
+ t.Fatalf("fixture compressed to %d of %d bytes, so it is not stored", len(packed), len(planes))
+ }
+ data := file(
+ encodeElement("il32", packed),
+ encodeElement("l8mk", mask),
+ )
+ if _, ok := found(validate(t, data), "Apple silicon drops"); ok {
+ t.Errorf("reported padding for uncompressed planes: %v", validate(t, data))
+ }
+}
+
+func TestValidateReportsAMissingMask(t *testing.T) {
+ planes, _ := splitPlanes(flat(32), 32)
+ data := file(encodeElement("il32", padRLE(packRLE(planes), len(planes))))
+ p, ok := found(validate(t, data), "draws fully opaque")
+ if !ok {
+ t.Fatalf("no problem reported, got %v", validate(t, data))
+ }
+ if p.Severity != Degraded {
+ t.Errorf("severity is %s, want %s", p.Severity, Degraded)
+ }
+ if !strings.Contains(p.Message, "l8mk") {
+ t.Errorf("message does not name the mask element: %s", p.Message)
+ }
+}
+
+func TestValidateReportsIcp4WithoutIs32(t *testing.T) {
+ data := file(encodeElement("icp4", pngOf(t, 16)))
+ p, ok := found(validate(t, data), "does not render from an app bundle")
+ if !ok {
+ t.Fatalf("no problem reported, got %v", validate(t, data))
+ }
+ if p.Severity != Degraded {
+ t.Errorf("severity is %s, want %s", p.Severity, Degraded)
+ }
+ if !strings.Contains(p.Icon, "icp4") {
+ t.Errorf("problem names %q, want icp4", p.Icon)
+ }
+}
+
+func TestValidateAcceptsIcp4BesideIs32(t *testing.T) {
+ planes, mask := splitPlanes(flat(16), 16)
+ data := file(
+ encodeElement("icp4", pngOf(t, 16)),
+ encodeElement("is32", padRLE(packRLE(planes), len(planes))),
+ encodeElement("s8mk", mask),
+ )
+ if _, ok := found(validate(t, data), "does not render from an app bundle"); ok {
+ t.Errorf("reported a bundle problem for a file that holds is32: %v", validate(t, data))
+ }
+}
+
+func TestValidateReportsSizeDisagreement(t *testing.T) {
+ // ic07 names 128 pixels; the payload holds 64.
+ data := file(encodeElement("ic07", pngOf(t, 64)))
+ p, ok := found(validate(t, data), "where the type is 128x128")
+ if !ok {
+ t.Fatalf("no problem reported, got %v", validate(t, data))
+ }
+ if p.Severity != Degraded {
+ t.Errorf("severity is %s, want %s", p.Severity, Degraded)
+ }
+}
+
+func TestValidateReportsJPEG2000AsAdvice(t *testing.T) {
+ data := file(
+ encodeElement("ic10", jpeg2000header),
+ encodeElement("ic07", pngOf(t, 128)),
+ )
+ p, ok := found(validate(t, data), "JPEG 2000")
+ if !ok {
+ t.Fatalf("no problem reported, got %v", validate(t, data))
+ }
+ if p.Severity != Advice {
+ t.Errorf("severity is %s, want %s", p.Severity, Advice)
+ }
+}
+
+func TestValidateReportsGapsBelowTheLargest(t *testing.T) {
+ data := file(
+ encodeElement("ic09", pngOf(t, 512)),
+ encodeElement("ic07", pngOf(t, 128)),
+ )
+ p, ok := found(validate(t, data), "holds no")
+ if !ok {
+ t.Fatalf("no problem reported, got %v", validate(t, data))
+ }
+ if p.Severity != Advice {
+ t.Errorf("severity is %s, want %s", p.Severity, Advice)
+ }
+ for _, want := range []string{"ic13", "ic08", "ic12", "ic11", "il32", "is32"} {
+ if !strings.Contains(p.Message, want) {
+ t.Errorf("message does not name %s: %s", want, p.Message)
+ }
+ }
+ // ic10 is 1024, above the largest icon present, so the artwork simply
+ // ran out and it is not reported.
+ if strings.Contains(p.Message, "ic10") {
+ t.Errorf("message names a size above the largest held: %s", p.Message)
+ }
+}
+
+func TestValidateReportsAnUndecodableIcon(t *testing.T) {
+ // An element whose type stores colour planes, holding too little to
+ // expand.
+ data := file(
+ encodeElement("il32", []byte{0xFF, 0x01}),
+ encodeElement("l8mk", make([]byte, 32*32)),
+ )
+ p, ok := found(validate(t, data), "cannot be decoded")
+ if !ok {
+ t.Fatalf("no problem reported, got %v", validate(t, data))
+ }
+ if p.Severity != Invisible {
+ t.Errorf("severity is %s, want %s", p.Severity, Invisible)
+ }
+}
+
+func TestValidateOrdersBySeverity(t *testing.T) {
+ planes, _ := splitPlanes(flat(32), 32)
+ data := file(
+ encodeElement("il32", padRLE(packRLE(planes), len(planes))),
+ encodeElement("ic10", jpeg2000header),
+ )
+ problems := validate(t, data)
+ 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)
+ }
+ }
+}
+
+func TestValidateRejectsWhatItCannotParse(t *testing.T) {
+ for _, data := range [][]byte{nil, []byte("not an icns"), file()} {
+ if _, err := Validate(bytes.NewReader(data)); err == nil {
+ t.Errorf("validating %q returned no error", data)
+ }
+ }
+}
+
+// TestValidateIgnoresColourOfMask keeps the mask elements from being read as
+// icons in their own right.
+func TestValidateIgnoresColourOfMask(t *testing.T) {
+ planes, mask := splitPlanes(flat(16), 16)
+ data := file(
+ encodeElement("is32", padRLE(packRLE(planes), len(planes))),
+ encodeElement("s8mk", mask),
+ )
+ for _, p := range validate(t, data) {
+ if strings.Contains(p.Icon, "s8mk") {
+ t.Errorf("reported the mask as an icon: %s", p)
+ }
+ }
+}