commit ee5210fbe0fcc1637ab5ce9c087daca7ceb1580d
parent d56977a3f014f7f4baf59e250f5a7d530ce6eb35
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Fri, 18 Sep 2026 16:09:36 -0400
icns: decode the indexed colour icon types
Icons from System 7 through Mac OS 8 store an index per pixel against tables
the system fixed, and were skipped entirely. Those files often hold the same
icon at several depths, so the entries are now ordered by how much colour
they carry as well as by size.
Diffstat:
| M | icns.go | | | 35 | ++++++++++++++++++++++++++++++++++- |
| A | indexed.go | | | 117 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | indexed_test.go | | | 193 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | reader.go | | | 68 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- |
| M | reader_test.go | | | 5 | +++++ |
5 files changed, 415 insertions(+), 3 deletions(-)
diff --git a/icns.go b/icns.go
@@ -216,6 +216,10 @@ const (
// ImageFormatARGB is run-length encoded channel planes that carry their
// own alpha, behind an "ARGB" header.
ImageFormatARGB
+ // ImageFormatBitmap is one bit per pixel with a one bit mask.
+ ImageFormatBitmap
+ // ImageFormatIndexed is an index per pixel into a fixed colour table.
+ ImageFormatIndexed
)
func (f ImageFormat) String() string {
@@ -228,6 +232,10 @@ func (f ImageFormat) String() string {
return "24-bit RGB"
case ImageFormatARGB:
return "ARGB"
+ case ImageFormatBitmap:
+ return "1-bit"
+ case ImageFormatIndexed:
+ return "indexed colour"
}
return fmt.Sprintf("unknown format %d", f)
}
@@ -241,6 +249,12 @@ const (
// encodingRGB holds run-length encoded colour planes, with alpha in the
// separate element named by OsType.mask.
encodingRGB
+ // encodingBitmap holds one bit per pixel followed by its own mask.
+ encodingBitmap
+ // encodingIndexed4 and encodingIndexed8 hold an index per pixel into a
+ // fixed colour table, with alpha in the mask half of OsType.mask.
+ encodingIndexed4
+ encodingIndexed8
)
// OsType is a 4 character identifier used to differentiate icon types.
@@ -250,8 +264,11 @@ type OsType struct {
// enc is how this element stores its image data.
enc encoding
- // mask is the element holding this type's alpha, for encodingRGB.
+ // mask is the element holding this type's alpha, for the encodings that
+ // keep it apart from the colour.
mask string
+ // height is the pixel height, when the icon is not square.
+ height uint
// slot is the iconset slot this type fills, for the written types.
slot Slot
// emit marks the types the encoder writes. More types can be read than
@@ -290,6 +307,22 @@ var osTypes = []OsType{
// render from an app bundle.
{ID: "it32", Size: 128, enc: encodingRGB, mask: "t8mk"},
{ID: "ih32", Size: 48, enc: encodingRGB, mask: "h8mk"},
+
+ // Icons from System 7 through Mac OS 8, an index per pixel against a
+ // fixed table, with alpha in the mask half of the "#" element.
+ {ID: "ich8", Size: 48, enc: encodingIndexed8, mask: "ich#"},
+ {ID: "ich4", Size: 48, enc: encodingIndexed4, mask: "ich#"},
+ {ID: "ich#", Size: 48, enc: encodingBitmap},
+ {ID: "icl8", Size: 32, enc: encodingIndexed8, mask: "ICN#"},
+ {ID: "icl4", Size: 32, enc: encodingIndexed4, mask: "ICN#"},
+ {ID: "ICN#", Size: 32, enc: encodingBitmap},
+ {ID: "ICON", Size: 32, enc: encodingBitmap},
+ {ID: "ics8", Size: 16, enc: encodingIndexed8, mask: "ics#"},
+ {ID: "ics4", Size: 16, enc: encodingIndexed4, mask: "ics#"},
+ {ID: "ics#", Size: 16, enc: encodingBitmap},
+ {ID: "icm8", Size: 16, height: 12, enc: encodingIndexed8, mask: "icm#"},
+ {ID: "icm4", Size: 16, height: 12, enc: encodingIndexed4, mask: "icm#"},
+ {ID: "icm#", Size: 16, height: 12, enc: encodingBitmap},
{ID: "il32", Size: 32, enc: encodingRGB, mask: "l8mk", slot: Slot{32, 1}, emit: true},
{ID: "is32", Size: 16, enc: encodingRGB, mask: "s8mk", slot: Slot{16, 1}, emit: true},
}
diff --git a/indexed.go b/indexed.go
@@ -0,0 +1,117 @@
+package icns
+
+import (
+ "fmt"
+ "image"
+ "image/color"
+)
+
+// The icons that predate Mac OS 8.5 store an index per pixel rather than a
+// colour, against a table the system fixed, and keep their alpha as a one bit
+// mask in the element named "#" for their size.
+
+// macPalette4 is the sixteen colour table: white, the bright hues, the
+// muted ones, then the greys down to black.
+var macPalette4 = [16]color.NRGBA{
+ {0xFF, 0xFF, 0xFF, 0xFF}, // white
+ {0xFC, 0xF3, 0x05, 0xFF}, // yellow
+ {0xFF, 0x64, 0x03, 0xFF}, // orange
+ {0xDD, 0x09, 0x07, 0xFF}, // red
+ {0xF2, 0x08, 0x84, 0xFF}, // magenta
+ {0x47, 0x00, 0xA5, 0xFF}, // purple
+ {0x00, 0x00, 0xD3, 0xFF}, // blue
+ {0x02, 0xAB, 0xEA, 0xFF}, // cyan
+ {0x1F, 0xB7, 0x14, 0xFF}, // green
+ {0x00, 0x64, 0x12, 0xFF}, // dark green
+ {0x56, 0x2C, 0x05, 0xFF}, // brown
+ {0x90, 0x71, 0x3A, 0xFF}, // tan
+ {0xC0, 0xC0, 0xC0, 0xFF}, // light grey
+ {0x80, 0x80, 0x80, 0xFF}, // medium grey
+ {0x40, 0x40, 0x40, 0xFF}, // dark grey
+ {0x00, 0x00, 0x00, 0xFF}, // black
+}
+
+// macPalette8 is the 256 colour table: a six level colour cube with black
+// left out, then ten shade ramps of red, green, blue and grey, and black
+// last. White is index 0 and black is index 255.
+var macPalette8 = buildPalette8()
+
+func buildPalette8() [256]color.NRGBA {
+ var (
+ palette [256]color.NRGBA
+ levels = [6]uint8{0xFF, 0xCC, 0x99, 0x66, 0x33, 0x00}
+ // The shades between the cube's levels, darkest last.
+ shades = [10]uint8{0xEE, 0xDD, 0xBB, 0xAA, 0x88, 0x77, 0x55, 0x44, 0x22, 0x11}
+ at int
+ )
+ for _, r := range levels {
+ for _, g := range levels {
+ for _, b := range levels {
+ if r == 0 && g == 0 && b == 0 {
+ continue // Black is held back for the last index.
+ }
+ palette[at] = color.NRGBA{r, g, b, 0xFF}
+ at++
+ }
+ }
+ }
+ for _, v := range shades {
+ palette[at] = color.NRGBA{v, 0, 0, 0xFF}
+ at++
+ }
+ for _, v := range shades {
+ palette[at] = color.NRGBA{0, v, 0, 0xFF}
+ at++
+ }
+ for _, v := range shades {
+ palette[at] = color.NRGBA{0, 0, v, 0xFF}
+ at++
+ }
+ for _, v := range shades {
+ palette[at] = color.NRGBA{v, v, v, 0xFF}
+ at++
+ }
+ palette[at] = color.NRGBA{0x00, 0x00, 0x00, 0xFF}
+ return palette
+}
+
+// decodeIndexed builds an image from bits-per-pixel indices and a one bit
+// mask. bits is 1, 4 or 8, and a nil mask leaves the icon opaque.
+func decodeIndexed(data, mask []byte, w, h, bits int) (image.Image, error) {
+ var (
+ pixels = w * h
+ need = pixels * bits / 8
+ bitmap = pixels / 8
+ )
+ if len(data) < need {
+ return nil, fmt.Errorf("%w: holds %d bytes, want %d for %dx%d at %d bits", ErrMalformed, len(data), need, w, h, bits)
+ }
+ if mask != nil && len(mask) < bitmap {
+ return nil, fmt.Errorf("%w: mask holds %d bytes, want %d", ErrMalformed, len(mask), bitmap)
+ }
+ img := image.NewNRGBA(image.Rect(0, 0, w, h))
+ for i := 0; i < pixels; i++ {
+ var c color.NRGBA
+ switch bits {
+ case 1:
+ // A set bit is ink, which is black on white.
+ c = macPalette4[0]
+ if data[i/8]&(0x80>>(i%8)) != 0 {
+ c = macPalette4[15]
+ }
+ case 4:
+ index := data[i/2] >> 4
+ if i%2 == 1 {
+ index = data[i/2] & 0x0F
+ }
+ c = macPalette4[index]
+ case 8:
+ c = macPalette8[data[i]]
+ }
+ if mask != nil && mask[i/8]&(0x80>>(i%8)) == 0 {
+ c.A = 0
+ }
+ img.SetNRGBA(i%w, i/w, c)
+ }
+ return img, nil
+}
diff --git a/indexed_test.go b/indexed_test.go
@@ -0,0 +1,193 @@
+package icns
+
+import (
+ "bytes"
+ "errors"
+ "image"
+ "image/color"
+ "testing"
+)
+
+// TestPalettes pins the anchors of the two fixed colour tables. The cube is
+// indexed 36R + 6G + B over the six levels, descending from white.
+func TestPalettes(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ desc string
+ index int
+ want color.NRGBA
+ }{
+ {"white leads the table", 0, color.NRGBA{0xFF, 0xFF, 0xFF, 0xFF}},
+ {"blue steps down first", 1, color.NRGBA{0xFF, 0xFF, 0xCC, 0xFF}},
+ {"blue bottoms out", 5, color.NRGBA{0xFF, 0xFF, 0x00, 0xFF}},
+ {"green steps after blue wraps", 6, color.NRGBA{0xFF, 0xCC, 0xFF, 0xFF}},
+ {"red steps after green wraps", 36, color.NRGBA{0xCC, 0xFF, 0xFF, 0xFF}},
+ {"the cube ends before black", 214, color.NRGBA{0x00, 0x00, 0x33, 0xFF}},
+ {"the red ramp follows the cube", 215, color.NRGBA{0xEE, 0x00, 0x00, 0xFF}},
+ {"the red ramp ends", 224, color.NRGBA{0x11, 0x00, 0x00, 0xFF}},
+ {"then green", 225, color.NRGBA{0x00, 0xEE, 0x00, 0xFF}},
+ {"then blue", 235, color.NRGBA{0x00, 0x00, 0xEE, 0xFF}},
+ {"then grey", 245, color.NRGBA{0xEE, 0xEE, 0xEE, 0xFF}},
+ {"the grey ramp ends", 254, color.NRGBA{0x11, 0x11, 0x11, 0xFF}},
+ {"black is last", 255, color.NRGBA{0x00, 0x00, 0x00, 0xFF}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.desc, func(st *testing.T) {
+ if got := macPalette8[tt.index]; got != tt.want {
+ st.Fatalf("index %d = %v, want %v", tt.index, got, tt.want)
+ }
+ })
+ }
+ if got := macPalette4[0]; got != (color.NRGBA{0xFF, 0xFF, 0xFF, 0xFF}) {
+ t.Errorf("4-bit index 0 = %v, want white", got)
+ }
+ if got := macPalette4[15]; got != (color.NRGBA{0x00, 0x00, 0x00, 0xFF}) {
+ t.Errorf("4-bit index 15 = %v, want black", got)
+ }
+ // Every entry of the 256 table must be distinct, which the construction
+ // only manages because black is held back from the cube.
+ seen := map[color.NRGBA]int{}
+ for i, c := range macPalette8 {
+ if first, ok := seen[c]; ok {
+ t.Errorf("index %d repeats index %d (%v)", i, first, c)
+ }
+ seen[c] = i
+ }
+}
+
+// bitmapMask builds a "#" element: a one bit icon followed by its one bit
+// mask, with the left half of the icon opaque.
+func bitmapMask(w, h int) []byte {
+ plane := w * h / 8
+ out := make([]byte, plane*2)
+ for i := 0; i < w*h; i++ {
+ x := i % w
+ if x < w/4 {
+ out[i/8] |= 0x80 >> (i % 8) // Ink.
+ }
+ if x < w/2 {
+ out[plane+i/8] |= 0x80 >> (i % 8) // Opaque.
+ }
+ }
+ return out
+}
+
+func TestDecodeIndexed(t *testing.T) {
+ t.Parallel()
+
+ t.Run("one bit icon carries its own mask", func(st *testing.T) {
+ data := file(encodeElement("ICN#", bitmapMask(32, 32)))
+ img, err := Decode(bytes.NewReader(data))
+ if err != nil {
+ st.Fatal(err)
+ }
+ if got := img.Bounds().Size(); got != image.Pt(32, 32) {
+ st.Fatalf("decoded %v, want 32x32", got)
+ }
+ at := func(x, y int) color.NRGBA {
+ return color.NRGBAModel.Convert(img.At(x, y)).(color.NRGBA)
+ }
+ if got := at(2, 5); got != (color.NRGBA{0, 0, 0, 0xFF}) {
+ st.Errorf("ink pixel = %v, want opaque black", got)
+ }
+ if got := at(12, 5); got != (color.NRGBA{0xFF, 0xFF, 0xFF, 0xFF}) {
+ st.Errorf("paper pixel = %v, want opaque white", got)
+ }
+ if got := at(24, 5); got.A != 0 {
+ st.Errorf("pixel outside the mask = %v, want transparent", got)
+ }
+ })
+
+ t.Run("eight bit icon takes the companion mask", func(st *testing.T) {
+ // Index 3 of the 256 table, well inside the colour cube.
+ pixels := make([]byte, 32*32)
+ for i := range pixels {
+ pixels[i] = 3
+ }
+ data := file(
+ encodeElement("icl8", pixels),
+ encodeElement("ICN#", bitmapMask(32, 32)),
+ )
+ img, err := Decode(bytes.NewReader(data))
+ if err != nil {
+ st.Fatal(err)
+ }
+ at := func(x, y int) color.NRGBA {
+ return color.NRGBAModel.Convert(img.At(x, y)).(color.NRGBA)
+ }
+ want := macPalette8[3]
+ if got := at(4, 4); got != want {
+ st.Errorf("inside the mask = %v, want %v", got, want)
+ }
+ if got := at(24, 4); got.A != 0 {
+ st.Errorf("outside the mask = %v, want transparent", got)
+ }
+ })
+
+ t.Run("four bit icon packs two pixels per byte", func(st *testing.T) {
+ pixels := make([]byte, 16*16/2)
+ for i := range pixels {
+ pixels[i] = 0x3A // Index 3 then index 10.
+ }
+ data := file(
+ encodeElement("ics4", pixels),
+ encodeElement("ics#", bitmapMask(16, 16)),
+ )
+ img, err := Decode(bytes.NewReader(data))
+ if err != nil {
+ st.Fatal(err)
+ }
+ at := func(x, y int) color.NRGBA {
+ return color.NRGBAModel.Convert(img.At(x, y)).(color.NRGBA)
+ }
+ if got := at(0, 0); got != macPalette4[3] {
+ st.Errorf("first pixel = %v, want %v", got, macPalette4[3])
+ }
+ if got := at(1, 0); got != macPalette4[10] {
+ st.Errorf("second pixel = %v, want %v", got, macPalette4[10])
+ }
+ })
+
+ t.Run("the mini icon is not square", func(st *testing.T) {
+ data := file(
+ encodeElement("icm8", make([]byte, 16*12)),
+ encodeElement("icm#", bitmapMask(16, 12)),
+ )
+ img, err := Decode(bytes.NewReader(data))
+ if err != nil {
+ st.Fatal(err)
+ }
+ if got := img.Bounds().Size(); got != image.Pt(16, 12) {
+ st.Fatalf("decoded %v, want 16x12", got)
+ }
+ })
+
+ t.Run("richer depths come first", func(st *testing.T) {
+ data := file(
+ encodeElement("ICN#", bitmapMask(32, 32)),
+ encodeElement("icl4", make([]byte, 32*32/2)),
+ encodeElement("icl8", make([]byte, 32*32)),
+ )
+ d, err := NewDecoder(bytes.NewReader(data))
+ if err != nil {
+ st.Fatal(err)
+ }
+ var got []string
+ for _, icon := range d.Icons() {
+ got = append(got, icon.ID)
+ }
+ want := []string{"icl8", "icl4", "ICN#"}
+ for i := range want {
+ if got[i] != want[i] {
+ st.Fatalf("icons = %v, want %v", got, want)
+ }
+ }
+ })
+
+ t.Run("short data", func(st *testing.T) {
+ data := file(encodeElement("icl8", make([]byte, 10)))
+ if _, err := Decode(bytes.NewReader(data)); !errors.Is(err, ErrMalformed) {
+ st.Fatalf("error = %v, want ErrMalformed", err)
+ }
+ })
+}
diff --git a/reader.go b/reader.go
@@ -28,9 +28,13 @@ func NewDecoder(r io.Reader) (*Decoder, error) {
if err != nil {
return nil, err
}
- // Largest first, keeping file order between icons of equal size.
+ // Largest first, and at a given size the one carrying the most colour,
+ // since the older files hold several depths of the same icon.
slices.SortStableFunc(entries, func(a, b Entry) int {
- return cmp.Compare(b.Size, a.Size)
+ if order := cmp.Compare(b.Size, a.Size); order != 0 {
+ return order
+ }
+ return cmp.Compare(b.colours(), a.colours())
})
return &Decoder{entries: entries}, nil
}
@@ -73,6 +77,12 @@ func (e Entry) Decode() (image.Image, error) {
return nil, fmt.Errorf("decoding icon %s %s: %w", e.OsType, e.ImageFormat, err)
}
return img, nil
+ case ImageFormatBitmap, ImageFormatIndexed:
+ img, err := e.indexed()
+ if err != nil {
+ return nil, fmt.Errorf("decoding icon %s %s: %w", e.OsType, e.ImageFormat, err)
+ }
+ return img, nil
default:
img, _, err := image.Decode(bytes.NewReader(e.data))
if err != nil {
@@ -82,6 +92,55 @@ func (e Entry) Decode() (image.Image, error) {
}
}
+// indexed decodes the icon types that hold an index per pixel, finding their
+// alpha in the mask half of a companion element or of their own payload.
+func (e Entry) indexed() (image.Image, error) {
+ var (
+ width = int(e.Size)
+ height = int(e.Size)
+ bits = 1
+ )
+ if e.height > 0 {
+ height = int(e.height)
+ }
+ switch e.enc {
+ case encodingIndexed4:
+ bits = 4
+ case encodingIndexed8:
+ bits = 8
+ }
+ // A "#" element holds its bitmap first and its mask second, whether it
+ // is the icon itself or the companion an indexed icon points at.
+ var (
+ plane = width * height / 8
+ mask = e.mask
+ )
+ if e.enc == encodingBitmap {
+ mask = e.data
+ }
+ if len(mask) >= plane*2 {
+ mask = mask[plane : plane*2]
+ } else {
+ mask = nil
+ }
+ return decodeIndexed(e.data, mask, width, height, bits)
+}
+
+// colours ranks how much colour an icon carries, so the richest at a size
+// comes first.
+func (e Entry) colours() int {
+ switch e.enc {
+ case encodingBitmap:
+ return 0
+ case encodingIndexed4:
+ return 1
+ case encodingIndexed8:
+ return 2
+ default:
+ return 3
+ }
+}
+
// Payload returns the bytes the file stores for the icon, which lets a caller
// handle a format this package cannot. For PNG and JPEG 2000 icons it is a
// complete image file; for the colour and mask types it is the run-length
@@ -197,6 +256,11 @@ func decode(r io.Reader) (icons []Entry, err error) {
case osType.enc == encodingRGB:
icon.ImageFormat = ImageFormatRGB
icon.mask = payloads[osType.mask]
+ case osType.enc == encodingBitmap:
+ icon.ImageFormat = ImageFormatBitmap
+ case osType.enc == encodingIndexed4, osType.enc == encodingIndexed8:
+ icon.ImageFormat = ImageFormatIndexed
+ icon.mask = payloads[osType.mask]
case bytes.HasPrefix(el.payload, argbHeader):
icon.ImageFormat = ImageFormatARGB
case bytes.HasPrefix(el.payload, jpeg2000header):
diff --git a/reader_test.go b/reader_test.go
@@ -182,6 +182,11 @@ func FuzzDecode(f *testing.F) {
argb, _ := argbElement(16)
f.Add(file(encodeElement("ic04", argb)))
f.Add(file(encodeElement("ic04", []byte("ARGB"))))
+ // Indexed icons, whose mask lives in a companion element.
+ f.Add(file(encodeElement("icl8", make([]byte, 32*32)), encodeElement("ICN#", bitmapMask(32, 32))))
+ f.Add(file(encodeElement("icl4", make([]byte, 32*32/2))))
+ f.Add(file(encodeElement("ICN#", bitmapMask(32, 32))))
+ f.Add(file(encodeElement("icm8", make([]byte, 16*12))))
f.Fuzz(func(t *testing.T, data []byte) {
Probe(bytes.NewReader(data))
Decode(bytes.NewReader(data))