icns

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

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:
Mrle.go | 22++++++++++++++--------
Mrle_test.go | 4++--
Avalidate.go | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Avalidate_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) + } + } +}