commit 6d8994a7f3fa99bfa83d24d9455802673f00f08a
parent 0438531d4191fa06d4c1174ee2eaa194e7692fdf
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Fri, 18 Sep 2026 16:09:30 -0400
icns: decode the legacy colour and mask icon types
Icons written before 10.5, and the small sizes Apple still writes today, hold
run-length encoded colour planes with alpha in a separate mask element. The
decoder skipped them, so a 16px icon was invisible and older files looked
empty. Verified against an Apple-produced file.
Diffstat:
| M | icns.go | | | 57 | +++++++++++++++++++++++++++++++++++++++++++++------------ |
| M | reader.go | | | 120 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------- |
| M | reader_test.go | | | 36 | +++++++++++++++++++++--------------- |
| A | rle.go | | | 72 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | rle_test.go | | | 226 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
5 files changed, 453 insertions(+), 58 deletions(-)
diff --git a/icns.go b/icns.go
@@ -159,6 +159,9 @@ type ImageFormat int
const (
ImageFormatPNG ImageFormat = iota
ImageFormatJPEG2000
+ // ImageFormatRGB is 24-bit colour in run-length encoded channel planes,
+ // with alpha held in a separate mask element.
+ ImageFormatRGB
)
func (f ImageFormat) String() string {
@@ -167,14 +170,35 @@ func (f ImageFormat) String() string {
return "PNG"
case ImageFormatJPEG2000:
return "JPEG 2000"
+ case ImageFormatRGB:
+ return "24-bit RGB"
}
return fmt.Sprintf("unknown format %d", f)
}
+// encoding is how an element stores its image data.
+type encoding int
+
+const (
+ // encodingCompressed holds a whole image file, PNG or JPEG 2000.
+ encodingCompressed encoding = iota
+ // encodingRGB holds run-length encoded colour planes, with alpha in the
+ // separate element named by OsType.mask.
+ encodingRGB
+)
+
// OsType is a 4 character identifier used to differentiate icon types.
type OsType struct {
ID string
Size uint
+
+ // enc is how this element stores its image data.
+ enc encoding
+ // mask is the element holding this type's alpha, for encodingRGB.
+ mask string
+ // emit marks the types the encoder writes. More types can be read than
+ // are written.
+ emit bool
}
func (t OsType) String() string {
@@ -182,22 +206,31 @@ func (t OsType) String() string {
}
var osTypes = []OsType{
- {ID: "ic10", Size: uint(1024)},
- {ID: "ic14", Size: uint(512)},
- {ID: "ic09", Size: uint(512)},
- {ID: "ic13", Size: uint(256)},
- {ID: "ic08", Size: uint(256)},
- {ID: "ic07", Size: uint(128)},
- {ID: "ic12", Size: uint(64)},
- {ID: "ic11", Size: uint(32)},
-}
-
-// getTypesFromSize returns the types for the given icon size (in px).
+ {ID: "ic10", Size: 1024, emit: true},
+ {ID: "ic14", Size: 512, emit: true},
+ {ID: "ic09", Size: 512, emit: true},
+ {ID: "ic13", Size: 256, emit: true},
+ {ID: "ic08", Size: 256, emit: true},
+ {ID: "ic07", Size: 128, emit: true},
+ {ID: "ic12", Size: 64, emit: true},
+ {ID: "ic11", Size: 32, emit: true},
+
+ {ID: "icp6", Size: 48},
+ {ID: "icp5", Size: 32},
+ {ID: "icp4", Size: 16},
+
+ {ID: "it32", Size: 128, enc: encodingRGB, mask: "t8mk"},
+ {ID: "ih32", Size: 48, enc: encodingRGB, mask: "h8mk"},
+ {ID: "il32", Size: 32, enc: encodingRGB, mask: "l8mk"},
+ {ID: "is32", Size: 16, enc: encodingRGB, mask: "s8mk"},
+}
+
+// getTypesFromSize returns the writable types for the given icon size (in px).
// The boolean indicates whether the types exist.
func getTypesFromSize(size uint) ([]OsType, bool) {
var retOsTypes []OsType
for _, t := range osTypes {
- if t.Size == size {
+ if t.Size == size && t.emit {
retOsTypes = append(retOsTypes, t)
}
}
diff --git a/reader.go b/reader.go
@@ -26,17 +26,13 @@ func Decode(r io.Reader) (image.Image, error) {
if icon.ImageFormat == ImageFormatJPEG2000 {
continue
}
- img, _, err := image.Decode(icon.r)
- if err != nil {
- return nil, fmt.Errorf("decoding icon %s %s: %w", icon.OsType, icon.ImageFormat, err)
- }
- return img, nil
+ return icon.image()
}
return nil, fmt.Errorf("%w: only %s icons present", ErrUnsupportedFormat, ImageFormatJPEG2000)
}
-// DecodeAll extracts all icon resolutions present in the icns data that
-// contain PNG data. JPEG 2000 is ignored due to lack of image decoding
+// DecodeAll extracts every icon resolution present in the icns data that this
+// package can decode. JPEG 2000 is ignored due to lack of image decoding
// support.
func DecodeAll(r io.Reader) (images []image.Image, err error) {
icons, err := decode(r)
@@ -44,12 +40,12 @@ func DecodeAll(r io.Reader) (images []image.Image, err error) {
return nil, err
}
for _, icon := range icons {
- if icon.IconDescription.ImageFormat == ImageFormatJPEG2000 {
+ if icon.ImageFormat == ImageFormatJPEG2000 {
continue
}
- img, _, err := image.Decode(icon.r)
+ img, err := icon.image()
if err != nil {
- return nil, fmt.Errorf("decoding icon %s %s: %w", icon.OsType, icon.ImageFormat, err)
+ return nil, err
}
images = append(images, img)
}
@@ -82,6 +78,12 @@ func Probe(r io.Reader) (desc []IconDescription, _ error) {
// every element, the file header included.
const elementHeaderSize = 8
+// element is one type and payload pair from the file.
+type element struct {
+ id string
+ payload []byte
+}
+
// decode identifies the icons in the icns without decoding the image data.
//
// An icns file is a sequence of elements, each a 4-byte type followed by a
@@ -90,6 +92,47 @@ const elementHeaderSize = 8
// Every length is checked against the data present, and input that disagrees
// is reported as ErrMalformed.
func decode(r io.Reader) (icons []iconReader, err error) {
+ elements, err := elementsOf(r)
+ if err != nil {
+ return nil, err
+ }
+ // Masks are separate elements that may appear either side of the icon
+ // they belong to, so the payloads are indexed before they are paired up.
+ payloads := make(map[string][]byte, len(elements))
+ for _, el := range elements {
+ payloads[el.id] = el.payload
+ }
+ for _, el := range elements {
+ osType, ok := getTypeFromID(el.id)
+ if !ok || len(el.payload) == 0 {
+ // Elements this package does not read: the table of contents,
+ // version and name records, masks, and icon types it cannot
+ // decode.
+ continue
+ }
+ icon := iconReader{
+ IconDescription: IconDescription{OsType: osType},
+ data: el.payload,
+ }
+ switch osType.enc {
+ case encodingRGB:
+ icon.ImageFormat = ImageFormatRGB
+ icon.mask = payloads[osType.mask]
+ default:
+ if bytes.HasPrefix(el.payload, jpeg2000header) {
+ icon.ImageFormat = ImageFormatJPEG2000
+ }
+ }
+ icons = append(icons, icon)
+ }
+ if len(icons) == 0 {
+ return nil, ErrNoIcons
+ }
+ return icons, nil
+}
+
+// elementsOf splits an icns file into its elements.
+func elementsOf(r io.Reader) ([]element, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, err
@@ -102,6 +145,7 @@ func decode(r io.Reader) (icons []iconReader, err error) {
return nil, fmt.Errorf("%w: header declares %d bytes but only %d are present", ErrMalformed, fileSize, len(data))
}
data = data[:fileSize]
+ var elements []element
for offset := elementHeaderSize; offset < len(data); {
if len(data)-offset < elementHeaderSize {
return nil, fmt.Errorf("%w: truncated element header at offset %d", ErrMalformed, offset)
@@ -113,33 +157,47 @@ func decode(r io.Reader) (icons []iconReader, err error) {
if size < elementHeaderSize || size > len(data)-offset {
return nil, fmt.Errorf("%w: element %q at offset %d declares %d bytes", ErrMalformed, id, offset, size)
}
- payload := data[offset+elementHeaderSize : offset+size]
+ elements = append(elements, element{
+ id: id,
+ payload: data[offset+elementHeaderSize : offset+size],
+ })
offset += size
- // Elements other than icons ("TOC ", "icnV", "name", "info", ...)
- // and icons of legacy types carry nothing we can decode; skip them.
- if !isOsType(id) || len(payload) == 0 {
- continue
- }
- ir := iconReader{
- IconDescription: IconDescription{
- OsType: osTypeFromID(id),
- },
- r: bytes.NewReader(payload),
- }
- if bytes.HasPrefix(payload, jpeg2000header) {
- ir.ImageFormat = ImageFormatJPEG2000
- }
- icons = append(icons, ir)
}
- if len(icons) == 0 {
- return nil, ErrNoIcons
- }
- return icons, nil
+ return elements, nil
}
type iconReader struct {
IconDescription
- r io.Reader
+ data []byte
+ // mask holds the alpha channel for ImageFormatRGB icons, when the file
+ // carries the matching mask element.
+ mask []byte
+}
+
+// image decodes the icon's pixels.
+func (ir iconReader) image() (image.Image, error) {
+ switch ir.ImageFormat {
+ case ImageFormatJPEG2000:
+ return nil, fmt.Errorf("%w: icon %s is %s", ErrUnsupportedFormat, ir.OsType, ir.ImageFormat)
+ case ImageFormatRGB:
+ data := ir.data
+ // it32 is the one colour element that prefixes its planes with four
+ // zero bytes.
+ if ir.ID == "it32" && len(data) >= 4 && binary.BigEndian.Uint32(data[:4]) == 0 {
+ data = data[4:]
+ }
+ img, err := decodeRGB(data, ir.mask, int(ir.Size))
+ if err != nil {
+ return nil, fmt.Errorf("decoding icon %s %s: %w", ir.OsType, ir.ImageFormat, err)
+ }
+ return img, nil
+ default:
+ img, _, err := image.Decode(bytes.NewReader(ir.data))
+ if err != nil {
+ return nil, fmt.Errorf("decoding icon %s %s: %w", ir.OsType, ir.ImageFormat, err)
+ }
+ return img, nil
+ }
}
func isOsType(ID string) bool {
diff --git a/reader_test.go b/reader_test.go
@@ -10,9 +10,9 @@ import (
"testing"
)
-// element builds one icns element: 4-byte type, 4-byte big-endian length of
-// the whole element, then the payload.
-func element(id string, payload []byte) []byte {
+// encodeElement builds one icns element: 4-byte type, 4-byte big-endian
+// length of the whole element, then the payload.
+func encodeElement(id string, payload []byte) []byte {
out := make([]byte, 0, elementHeaderSize+len(payload))
out = append(out, id...)
out = binary.BigEndian.AppendUint32(out, uint32(elementHeaderSize+len(payload)))
@@ -21,7 +21,7 @@ func element(id string, payload []byte) []byte {
// file wraps elements in an icns header with a correct length.
func file(elements ...[]byte) []byte {
- return element("icns", bytes.Join(elements, nil))
+ return encodeElement("icns", bytes.Join(elements, nil))
}
func pngBytes(t testing.TB, side int) []byte {
@@ -42,16 +42,16 @@ func TestDecodeMalformed(t *testing.T) {
}{
{"empty", nil, ErrInvalidHeader},
{"short", []byte("ic"), ErrInvalidHeader},
- {"wrong magic", element("ICNS", nil), ErrInvalidHeader},
+ {"wrong magic", encodeElement("ICNS", nil), ErrInvalidHeader},
{"header only", file(), ErrNoIcons},
{"declared size exceeds data", append([]byte("icns"), 0xff, 0xff, 0xff, 0xff), ErrMalformed},
{"truncated element header", append([]byte("icns\x00\x00\x00\x0c"), 'i', 'c', '0', '7'), ErrMalformed},
{"element size below header", file(append([]byte("ic07"), 0, 0, 0, 4)), ErrMalformed},
{"element overruns file", file(append([]byte("ic07"), 0, 0, 1, 0)), ErrMalformed},
{"zero-length TOC loops forever without a check", file(append([]byte("TOC "), 0, 0, 0, 0)), ErrMalformed},
- {"unknown elements only", file(element("TOC ", []byte{1, 2, 3, 4}), element("icnV", []byte{0, 0, 0, 0})), ErrNoIcons},
- {"empty icon payload", file(element("ic07", nil)), ErrNoIcons},
- {"only jpeg2000", file(element("ic07", jpeg2000header)), ErrUnsupportedFormat},
+ {"unknown elements only", file(encodeElement("TOC ", []byte{1, 2, 3, 4}), encodeElement("icnV", []byte{0, 0, 0, 0})), ErrNoIcons},
+ {"empty icon payload", file(encodeElement("ic07", nil)), ErrNoIcons},
+ {"only jpeg2000", file(encodeElement("ic07", jpeg2000header)), ErrUnsupportedFormat},
}
for _, tt := range tests {
t.Run(tt.desc, func(st *testing.T) {
@@ -69,11 +69,11 @@ func TestDecodeMalformed(t *testing.T) {
func TestDecodeSkipsNonIconElements(t *testing.T) {
t.Parallel()
data := file(
- element("TOC ", []byte("ic07\x00\x00\x00\x10")),
- element("icnV", []byte{0x40, 0x00, 0x00, 0x00}),
- element("name", []byte("icon")),
- element("ic07", pngBytes(t, 128)),
- element("ic11", pngBytes(t, 32)),
+ encodeElement("TOC ", []byte("ic07\x00\x00\x00\x10")),
+ encodeElement("icnV", []byte{0x40, 0x00, 0x00, 0x00}),
+ encodeElement("name", []byte("icon")),
+ encodeElement("ic07", pngBytes(t, 128)),
+ encodeElement("ic11", pngBytes(t, 32)),
)
desc, err := Probe(bytes.NewReader(data))
if err != nil {
@@ -95,8 +95,8 @@ func TestDecodeSkipsNonIconElements(t *testing.T) {
func TestDecodeFallsBackPastJPEG2000(t *testing.T) {
t.Parallel()
data := file(
- element("ic10", jpeg2000header), // Largest, but undecodable.
- element("ic07", pngBytes(t, 128)),
+ encodeElement("ic10", jpeg2000header), // Largest, but undecodable.
+ encodeElement("ic07", pngBytes(t, 128)),
)
img, err := Decode(bytes.NewReader(data))
if err != nil {
@@ -123,6 +123,12 @@ func FuzzDecode(f *testing.F) {
f.Add(file(append([]byte("TOC "), 0, 0, 0, 0)))
f.Add(file(append([]byte("ic07"), 0, 0, 0, 4)))
f.Add(append([]byte("icns"), 0xff, 0xff, 0xff, 0xff))
+ // Legacy colour and mask elements, which run the RLE decoder.
+ rgb, mask, _ := legacyIcon(16)
+ f.Add(file(encodeElement("is32", rgb), encodeElement("s8mk", mask)))
+ f.Add(file(encodeElement("is32", rgb)))
+ f.Add(file(encodeElement("it32", []byte{0, 0, 0, 0, 0xFF, 0x01})))
+ f.Add(file(encodeElement("il32", []byte{0xFF}), encodeElement("l8mk", mask)))
f.Fuzz(func(t *testing.T, data []byte) {
Probe(bytes.NewReader(data))
Decode(bytes.NewReader(data))
diff --git a/rle.go b/rle.go
@@ -0,0 +1,72 @@
+package icns
+
+import (
+ "fmt"
+ "image"
+)
+
+// Legacy icon types store their colour as three run-length encoded planes,
+// one per channel, and their alpha in a separate mask element.
+
+// unpackRLE expands the icns variant of PackBits until want bytes are
+// 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) {
+ if len(data) == want {
+ return data, nil
+ }
+ out := make([]byte, 0, want)
+ 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)
+ }
+ out = append(out, data[i:i+n]...)
+ i += n
+ continue
+ }
+ if i == len(data) {
+ return nil, fmt.Errorf("%w: repeat run with no byte to repeat", ErrMalformed)
+ }
+ for n := lead - 125; n > 0; n-- {
+ out = append(out, data[i])
+ }
+ i++
+ }
+ if len(out) != want {
+ return nil, fmt.Errorf("%w: expanded to %d bytes, want %d", ErrMalformed, len(out), want)
+ }
+ return out, nil
+}
+
+// decodeRGB builds an image from run-length encoded colour planes and the
+// raw alpha of the matching mask element. A missing mask leaves the icon
+// 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)
+ if err != nil {
+ return nil, err
+ }
+ if mask != nil && len(mask) != pixels {
+ return nil, fmt.Errorf("%w: mask holds %d bytes, want %d", ErrMalformed, len(mask), pixels)
+ }
+ // The planes carry straight colour and the mask carries alpha, so the
+ // result is non-premultiplied.
+ img := image.NewNRGBA(image.Rect(0, 0, side, side))
+ for i := 0; i < pixels; i++ {
+ px := img.Pix[i*4 : i*4+4 : i*4+4]
+ px[0] = planes[i]
+ px[1] = planes[pixels+i]
+ px[2] = planes[pixels*2+i]
+ px[3] = 0xFF
+ if mask != nil {
+ px[3] = mask[i]
+ }
+ }
+ return img, nil
+}
diff --git a/rle_test.go b/rle_test.go
@@ -0,0 +1,226 @@
+package icns
+
+import (
+ "bytes"
+ "errors"
+ "image"
+ "image/color"
+ "testing"
+)
+
+// rleLiterals encodes data as literal runs only, which is the simplest valid
+// encoding and always longer than the input.
+func rleLiterals(data []byte) []byte {
+ var out []byte
+ for len(data) > 0 {
+ n := min(len(data), 128)
+ out = append(out, byte(n-1))
+ out = append(out, data[:n]...)
+ data = data[n:]
+ }
+ return out
+}
+
+func TestUnpackRLE(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ desc string
+ data []byte
+ // want is the number of bytes asked for, which the caller knows from
+ // the icon's dimensions. It is stated rather than derived, because a
+ // stream whose length equals it is read as uncompressed.
+ want int
+ out []byte
+ err error
+ }{
+ {
+ desc: "literal run",
+ data: []byte{0x02, 0x01, 0x02, 0x03},
+ want: 3,
+ out: []byte{0x01, 0x02, 0x03},
+ },
+ {
+ desc: "repeat run",
+ data: []byte{0x80, 0x07},
+ want: 3,
+ out: []byte{0x07, 0x07, 0x07},
+ },
+ {
+ desc: "literal then repeat",
+ data: []byte{0x02, 0x01, 0x02, 0x02, 0x82, 0x03},
+ want: 8,
+ out: []byte{0x01, 0x02, 0x02, 0x03, 0x03, 0x03, 0x03, 0x03},
+ },
+ {
+ desc: "longest repeat",
+ data: []byte{0xFF, 0x09},
+ want: 130,
+ out: bytes.Repeat([]byte{0x09}, 130),
+ },
+ {
+ desc: "stored uncompressed",
+ data: []byte{0x01, 0x02, 0x03},
+ want: 3,
+ out: []byte{0x01, 0x02, 0x03},
+ },
+ {
+ desc: "literal run overruns the element",
+ data: []byte{0x7F, 0x01, 0x02},
+ want: 200,
+ err: ErrMalformed,
+ },
+ {
+ desc: "repeat run with no byte to repeat",
+ data: []byte{0x04, 0x01, 0x02, 0x03, 0x04, 0x05, 0x80},
+ want: 200,
+ err: ErrMalformed,
+ },
+ {
+ desc: "expands short",
+ data: []byte{0x00, 0x01},
+ want: 3,
+ err: ErrMalformed,
+ },
+ {
+ desc: "a single run expands past what was asked for",
+ data: []byte{0xFF, 0x09},
+ want: 3,
+ err: ErrMalformed,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.desc, func(st *testing.T) {
+ 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)
+ }
+ return
+ }
+ if err != nil {
+ st.Fatalf("unexpected error: %v", err)
+ }
+ if !bytes.Equal(got, tt.out) {
+ st.Fatalf("got %v, want %v", got, tt.out)
+ }
+ })
+ }
+}
+
+// legacyIcon builds an is32 colour element and its s8mk mask for a gradient,
+// returning the elements and the image they describe.
+func legacyIcon(side int) (rgb, mask []byte, want *image.NRGBA) {
+ pixels := side * side
+ planes := make([]byte, pixels*3)
+ mask = make([]byte, pixels)
+ want = image.NewNRGBA(image.Rect(0, 0, side, side))
+ for y := 0; y < side; y++ {
+ for x := 0; x < side; x++ {
+ i := y*side + x
+ c := color.NRGBA{
+ R: uint8(x * 255 / side),
+ G: uint8(y * 255 / side),
+ B: 0x40,
+ A: uint8((x + y) * 255 / (2 * side)),
+ }
+ planes[i] = c.R
+ planes[pixels+i] = c.G
+ planes[pixels*2+i] = c.B
+ mask[i] = c.A
+ want.SetNRGBA(x, y, c)
+ }
+ }
+ return rleLiterals(planes), mask, want
+}
+
+func TestDecodeLegacyElements(t *testing.T) {
+ t.Parallel()
+ const side = 16
+ rgb, mask, want := legacyIcon(side)
+
+ t.Run("colour and mask", func(st *testing.T) {
+ data := file(encodeElement("is32", rgb), encodeElement("s8mk", mask))
+ imgs, err := DecodeAll(bytes.NewReader(data))
+ if err != nil {
+ st.Fatal(err)
+ }
+ if len(imgs) != 1 {
+ st.Fatalf("decoded %d icons, want 1", len(imgs))
+ }
+ if !imageCompare(imgs[0], want) {
+ st.Fatal("decoded icon differs from the source")
+ }
+ })
+
+ t.Run("mask ahead of the colour", func(st *testing.T) {
+ // Apple writes the mask after its element, but nothing requires it.
+ data := file(encodeElement("s8mk", mask), encodeElement("is32", rgb))
+ imgs, err := DecodeAll(bytes.NewReader(data))
+ if err != nil {
+ st.Fatal(err)
+ }
+ if !imageCompare(imgs[0], want) {
+ st.Fatal("decoded icon differs from the source")
+ }
+ })
+
+ t.Run("no mask leaves the icon opaque", func(st *testing.T) {
+ data := file(encodeElement("is32", rgb))
+ img, err := Decode(bytes.NewReader(data))
+ if err != nil {
+ st.Fatal(err)
+ }
+ for y := 0; y < side; y++ {
+ for x := 0; x < side; x++ {
+ if _, _, _, a := img.At(x, y).RGBA(); a != 0xFFFF {
+ st.Fatalf("pixel (%d,%d) alpha = %d, want opaque", x, y, a)
+ }
+ }
+ }
+ })
+
+ t.Run("uncompressed colour", func(st *testing.T) {
+ pixels := side * side
+ planes := make([]byte, pixels*3)
+ for i := range planes {
+ planes[i] = byte(i)
+ }
+ data := file(encodeElement("is32", planes), encodeElement("s8mk", mask))
+ img, err := Decode(bytes.NewReader(data))
+ if err != nil {
+ st.Fatal(err)
+ }
+ if got := img.Bounds().Dx(); got != side {
+ st.Fatalf("decoded a %dpx icon, want %d", got, side)
+ }
+ })
+
+ t.Run("mask of the wrong length", func(st *testing.T) {
+ data := file(encodeElement("is32", rgb), encodeElement("s8mk", mask[:10]))
+ if _, err := Decode(bytes.NewReader(data)); !errors.Is(err, ErrMalformed) {
+ st.Fatalf("error = %v, want ErrMalformed", err)
+ }
+ })
+}
+
+// TestDecodeIT32Header covers the one colour element that prefixes its planes
+// with four zero bytes.
+func TestDecodeIT32Header(t *testing.T) {
+ t.Parallel()
+ const side = 128
+ pixels := side * side
+ planes := make([]byte, pixels*3)
+ for i := range planes {
+ planes[i] = byte(i / side)
+ }
+ rgb := append([]byte{0, 0, 0, 0}, rleLiterals(planes)...)
+ mask := bytes.Repeat([]byte{0xFF}, pixels)
+ data := file(encodeElement("it32", rgb), encodeElement("t8mk", mask))
+ img, err := Decode(bytes.NewReader(data))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := img.Bounds().Size(); got != image.Pt(side, side) {
+ t.Fatalf("decoded %v, want %dx%d", got, side, side)
+ }
+}