commit a0554fe76cb70ad063a045dcbe2428a7f0c38dbd
parent 0cebcdbed776a5835e0115d58e622ec24258f3d2
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Fri, 18 Sep 2026 16:09:32 -0400
icns: decode one icon without decoding the rest
DecodeAll was the only way in, so a caller after a single size paid to decode
every other. The stored bytes also had nowhere to go for formats this package
cannot read, leaving a JPEG 2000 file a dead end rather than something the
caller could handle itself.
Diffstat:
| M | reader.go | | | 135 | ++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------- |
| M | reader_test.go | | | 49 | +++++++++++++++++++++++++++++++++++++++++++++++++ |
2 files changed, 135 insertions(+), 49 deletions(-)
diff --git a/reader.go b/reader.go
@@ -6,27 +6,96 @@ import (
"fmt"
"image"
"io"
+ "slices"
"sort"
)
var jpeg2000header = []byte{0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20}
+// Decoder reads an icns file and decodes its icons on demand, so a caller
+// after one size does not pay for the rest.
+type Decoder struct {
+ entries []Entry
+}
+
+// NewDecoder reads r and identifies the icons it holds without decoding any
+// of their pixels.
+func NewDecoder(r io.Reader) (*Decoder, error) {
+ entries, err := decode(r)
+ if err != nil {
+ return nil, err
+ }
+ // Largest first, keeping file order between icons of equal size.
+ sort.SliceStable(entries, func(ii, jj int) bool {
+ return entries[ii].Size > entries[jj].Size
+ })
+ return &Decoder{entries: entries}, nil
+}
+
+// Icons returns the icons in the file, largest first.
+func (d *Decoder) Icons() []Entry {
+ return slices.Clone(d.entries)
+}
+
+// Entry is one icon in an icns file, before its pixels are decoded.
+type Entry struct {
+ IconDescription
+
+ data []byte
+ // mask holds the alpha channel for ImageFormatRGB icons, when the file
+ // carries the matching mask element.
+ mask []byte
+}
+
+// Decode decodes the icon's pixels.
+func (e Entry) Decode() (image.Image, error) {
+ switch e.ImageFormat {
+ case ImageFormatJPEG2000:
+ return nil, fmt.Errorf("%w: icon %s is %s", ErrUnsupportedFormat, e.OsType, e.ImageFormat)
+ case ImageFormatRGB:
+ data := e.data
+ // 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:]
+ }
+ img, err := decodeRGB(data, e.mask, int(e.Size))
+ 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 {
+ return nil, fmt.Errorf("decoding icon %s %s: %w", e.OsType, e.ImageFormat, err)
+ }
+ return img, nil
+ }
+}
+
+// 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
+// encoded colour planes, without the mask that holds their alpha.
+//
+// The bytes are not copied, and must not be modified.
+func (e Entry) Payload() []byte {
+ return e.data
+}
+
// Decode returns the largest decodable icon in the icns file, ignoring all
// other sizes. JPEG 2000 icons are skipped due to lack of image decoding
// support, so the result may be smaller than the largest icon present.
func Decode(r io.Reader) (image.Image, error) {
- icons, err := decode(r)
+ d, err := NewDecoder(r)
if err != nil {
return nil, err
}
- sort.Slice(icons, func(ii, jj int) bool {
- return icons[ii].OsType.Size > icons[jj].OsType.Size
- })
- for _, icon := range icons {
+ for _, icon := range d.entries {
if icon.ImageFormat == ImageFormatJPEG2000 {
continue
}
- return icon.image()
+ return icon.Decode()
}
return nil, fmt.Errorf("%w: only %s icons present", ErrUnsupportedFormat, ImageFormatJPEG2000)
}
@@ -35,15 +104,15 @@ func Decode(r io.Reader) (image.Image, error) {
// 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)
+ d, err := NewDecoder(r)
if err != nil {
return nil, err
}
- for _, icon := range icons {
+ for _, icon := range d.entries {
if icon.ImageFormat == ImageFormatJPEG2000 {
continue
}
- img, err := icon.image()
+ img, err := icon.Decode()
if err != nil {
return nil, err
}
@@ -52,7 +121,9 @@ func DecodeAll(r io.Reader) (images []image.Image, err error) {
if len(images) == 0 {
return nil, fmt.Errorf("%w: only %s icons present", ErrUnsupportedFormat, ImageFormatJPEG2000)
}
- sort.Slice(images, func(ii, jj int) bool {
+ // An element may hold an image of a size other than the one its type
+ // names, so order by what was actually decoded.
+ sort.SliceStable(images, func(ii, jj int) bool {
var (
left = images[ii].Bounds().Size()
right = images[jj].Bounds().Size()
@@ -62,13 +133,13 @@ func DecodeAll(r io.Reader) (images []image.Image, err error) {
return images, nil
}
-// Probe extracts descriptions of the icons in the icns.
+// Probe extracts descriptions of the icons in the icns, largest first.
func Probe(r io.Reader) (desc []IconDescription, _ error) {
- icons, err := decode(r)
+ d, err := NewDecoder(r)
if err != nil {
return nil, err
}
- for _, icon := range icons {
+ for _, icon := range d.entries {
desc = append(desc, icon.IconDescription)
}
return desc, nil
@@ -91,7 +162,7 @@ type element struct {
// The file itself is one such element of type "icns" enclosing the rest.
// 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) {
+func decode(r io.Reader) (icons []Entry, err error) {
elements, err := elementsOf(r)
if err != nil {
return nil, err
@@ -110,7 +181,7 @@ func decode(r io.Reader) (icons []iconReader, err error) {
// decode.
continue
}
- icon := iconReader{
+ icon := Entry{
IconDescription: IconDescription{OsType: osType},
data: el.payload,
}
@@ -166,40 +237,6 @@ func elementsOf(r io.Reader) ([]element, error) {
return elements, nil
}
-type iconReader struct {
- IconDescription
- 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 {
_, ok := getTypeFromID(ID)
return ok
diff --git a/reader_test.go b/reader_test.go
@@ -7,6 +7,7 @@ import (
"image"
"image/color"
"image/png"
+ "reflect"
"testing"
)
@@ -111,6 +112,54 @@ func TestDecodeFallsBackPastJPEG2000(t *testing.T) {
}
}
+func TestDecoder(t *testing.T) {
+ t.Parallel()
+ png128 := pngBytes(t, 128)
+ data := file(
+ encodeElement("ic11", pngBytes(t, 32)),
+ encodeElement("ic10", jpeg2000header),
+ encodeElement("ic07", png128),
+ )
+ d, err := NewDecoder(bytes.NewReader(data))
+ if err != nil {
+ t.Fatal(err)
+ }
+ icons := d.Icons()
+ var got []string
+ for _, icon := range icons {
+ got = append(got, icon.ID)
+ }
+ if want := []string{"ic10", "ic07", "ic11"}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("icons = %v, want %v largest first", got, want)
+ }
+
+ // An icon this package cannot decode still hands over its bytes, so a
+ // caller can bring its own decoder.
+ if _, err := icons[0].Decode(); !errors.Is(err, ErrUnsupportedFormat) {
+ t.Errorf("decoding the JPEG 2000 icon = %v, want ErrUnsupportedFormat", err)
+ }
+ if !bytes.Equal(icons[0].Payload(), jpeg2000header) {
+ t.Error("the JPEG 2000 icon's payload is not the stored bytes")
+ }
+
+ img, err := icons[1].Decode()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := img.Bounds().Dx(); got != 128 {
+ t.Errorf("decoded a %dpx icon, want 128", got)
+ }
+ if !bytes.Equal(icons[1].Payload(), png128) {
+ t.Error("the PNG icon's payload is not the stored file")
+ }
+
+ // The returned slice is the caller's to reorder.
+ icons[0] = Entry{}
+ if again := d.Icons(); again[0].ID != "ic10" {
+ t.Errorf("Icons was affected by a change to an earlier result: %v", again[0].ID)
+ }
+}
+
// FuzzDecode checks that arbitrary input never panics or hangs the decoder.
func FuzzDecode(f *testing.F) {
var valid bytes.Buffer