icns

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

commit e4ff63acb36960f8f936895b4bf76510161fca62
parent de819650ba126bd03004da4e787f4c22ca464116
Author: Jack Mordaunt <jackmordaunt@gmail.com>
Date:   Mon, 12 Feb 2018 15:11:51 +0100

Merged in testing branch.

Diffstat:
Mcmd/icnsify/doc.go | 2+-
Mcmd/icnsify/main.go | 11+++++------
Mdoc.go | 17+++++++++--------
Aerror.go | 22++++++++++++++++++++++
Micns.go | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Aicns_test.go | 312+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dicon.go | 71-----------------------------------------------------------------------
Diconset.go | 67-------------------------------------------------------------------
Minterpolation.go | 20--------------------
Areadme.md | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awriter.go | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 639 insertions(+), 201 deletions(-)

diff --git a/cmd/icnsify/doc.go b/cmd/icnsify/doc.go @@ -13,7 +13,7 @@ func usage() { You can also pipe to stdin and from stdout. The pipes will be detected automatically, and both --input and --output will be ignored. - cat icon.png | icns | cat > icon.icns + cat icon.png | icnsify | cat > icon.icns `) } diff --git a/cmd/icnsify/main.go b/cmd/icnsify/main.go @@ -50,15 +50,14 @@ func main() { defer outputf.Close() output = outputf } - img, _, err := image.Decode(input) + img, format, err := image.Decode(input) if err != nil { log.Fatalf("decoding image: %v", err) } - if err := icns.EncodeWithInterpolationFunction( - output, - img, - algorithm, - ); err != nil { + enc := icns.NewEncoder(output). + WithAlgorithm(algorithm). + WithFormat(format) + if err := enc.Encode(img); err != nil { log.Fatalf("encoding icns: %v", err) } } diff --git a/doc.go b/doc.go @@ -6,14 +6,15 @@ // a Mac native cli utility, or 2. use tools that wrap `ImageMagick` which adds // a large dependency to your project for such a simple use case. // +// With this library you can use pure Go to create icns files from any source +// image, given that you can decode it into an `image.Image`, without any +// heavyweight dependencies or subprocessing required. You can also use this +// library to create icns files on windows and linux. +// +// A small CLI app `icnsify` is provided to allow you to create icns files +// using this library from the command line. It supports piping, which is +// something `iconutil` does not do, making it substantially easier to wrap. +// // Note: All icons within the icns are sized for high dpi retina screens, using // the appropriate icns OSTypes. -// -// Todo(jackmordaunt): -// - Write tests (only manual testing has been done) -// - How to test the correctness of a file format? -// - Create Decoder (.icns -> image.Image) -// - Register decoder to image.Decode in init func -// - Encode based on input image format (jpg -> jpg, png -> png) to avoid -// lossy conversions package icns diff --git a/error.go b/error.go @@ -0,0 +1,22 @@ +package icns + +import ( + "fmt" + "image" +) + +// ErrImageTooSmall is returned when the image is too small to process. +type ErrImageTooSmall struct { + need int + image image.Image +} + +func (err ErrImageTooSmall) Error() string { + b := err.image.Bounds().Max + format := "image is too small: %dx%d, need at least %dx%d" + return fmt.Sprintf(format, b.X, b.Y, err.need, err.need) +} + +func panicf(format string, values ...interface{}) { + panic(fmt.Sprintf(format, values...)) +} diff --git a/icns.go b/icns.go @@ -1,38 +1,85 @@ package icns import ( + "errors" "image" "io" "github.com/nfnt/resize" ) -// Encode writes img to wr in ICNS format. -// img is assumed to be a rectangle; non-square dimensions will be squared -// without preserving the aspect ratio. -// Uses nearest neighbor as interpolation algorithm. -func Encode(wr io.Writer, img image.Image) error { - iconset, err := NewIconSet(img, NearestNeighbor) +// Encoder encodes ICNS files from a source image. +type Encoder struct { + Wr io.Writer + Algorithm InterpolationFunction + Format string +} + +// NewEncoder initialises an encoder. +func NewEncoder(wr io.Writer) *Encoder { + return &Encoder{ + Wr: wr, + } +} + +// WithAlgorithm applies the interpolation function used to resize the image. +func (enc *Encoder) WithAlgorithm(a InterpolationFunction) *Encoder { + enc.Algorithm = a + return enc +} + +// WithFormat applies the image format identifier used during registration by +// image/png and image/jpeg packages. +func (enc *Encoder) WithFormat(format string) *Encoder { + enc.Format = format + return enc +} + +// Encode icns with the given configuration. +func (enc *Encoder) Encode(img image.Image) error { + if enc.Wr == nil { + return errors.New("cannot write to nil writer") + } + if img == nil { + return errors.New("cannot encode nil image") + } + iconset, err := NewIconSet(img, enc.Algorithm, enc.Format) if err != nil { return err } - if _, err := iconset.WriteTo(wr); err != nil { + if _, err := iconset.WriteTo(enc.Wr); err != nil { return err } return nil } +// Encode writes img to wr in ICNS format. +// img is assumed to be a rectangle; non-square dimensions will be squared +// without preserving the aspect ratio. +// Uses nearest neighbor as interpolation algorithm. +func Encode(wr io.Writer, img image.Image) error { + return NewEncoder(wr).Encode(img) +} + // NewIconSet uses the source image to create an IconSet. // If width != height, the image will be resized using the largest side without // preserving the aspect ratio. -func NewIconSet(img image.Image, interp InterpolationFunction) (*IconSet, error) { +func NewIconSet(img image.Image, interp InterpolationFunction, format string) (*IconSet, error) { biggest := findNearestSize(img) + if biggest == 0 { + return nil, ErrImageTooSmall{image: img, need: 16} + } icons := []*Icon{} for _, size := range sizesFrom(biggest) { + t, ok := getType(size) + if !ok { + continue + } iconImg := resize.Resize(size, size, img, interp) icon := &Icon{ - Type: getType(size), - Image: iconImg, + Type: t, + Image: iconImg, + format: format, } icons = append(icons, icon) } @@ -63,7 +110,7 @@ var sizes = []uint{ func findNearestSize(img image.Image) uint { size := biggestSide(img) for _, s := range sizes { - if size > s { + if size >= s { return s } } @@ -81,31 +128,39 @@ func biggestSide(img image.Image) uint { return size } -// returns a slice containing the sizes less than and including max. +// sizesFrom returns a slice containing the sizes less than and including max. func sizesFrom(max uint) []uint { for ii, s := range sizes { - if s == max { + if s <= max { return sizes[ii:len(sizes)] } } return nil } -var types = map[uint]OsType{ - 1024: "ic10", - 512: "ic14", - 256: "ic13", - 128: "ic07", - 64: "ic12", - 32: "ic11", -} +// OsType is a 4 character identifier used to differentiate icon types. +type OsType string -// should this return error, panic or return a default (but probably incorrect) -// format? For now, failing explicitly is preferable to failing silently. -func getType(size uint) OsType { - v, ok := types[size] - if !ok { - panic("could not select the correct icon type") +// getType returns the type for the given icon size (in px). +// The boolean indicates whether the type exists. +func getType(size uint) (OsType, bool) { + // 'types' is a map of the OSTypes we care about. + // All dimensions are considered as retina. + // + // Todo(jackmordaunt): Not sure if only retina is sufficient. Should all + // types be handled? `iconutil` uses file names to determine whether a + // retina image is desired eg: "icon_256x256@2.png", without such a hint + // how can you disambiguate 256x256 standard vs 256x256 retina? + // Do we even need to consider standard sizes over retina? + // For now, just retina types are considered. + types := map[uint]OsType{ + 1024: "ic10", + 512: "ic14", + 256: "ic13", + 128: "ic07", + 64: "ic12", + 32: "ic11", } - return v + v, ok := types[size] + return v, ok } diff --git a/icns_test.go b/icns_test.go @@ -0,0 +1,312 @@ +package icns + +import ( + "bytes" + "image" + "image/jpeg" + "image/png" + "io" + "io/ioutil" + "testing" + + "github.com/jackmordaunt/deep" + "github.com/pkg/errors" +) + +// TestEncode tests for input validation, sanity checks and errors. +// The validity of the encoding is not tested here. +// Super large images are not tested because the resizing takes too +// long for unit testing. +func TestEncode(t *testing.T) { + t.Parallel() + tests := []struct { + desc string + wr io.Writer + img image.Image + + wantErr bool + }{ + { + "nil image", + ioutil.Discard, + nil, + true, + }, + { + "nil writer", + nil, + rect(0, 0, 50, 50), + true, + }, + { + "valid sqaure", + ioutil.Discard, + rect(0, 0, 50, 50), + false, + }, + { + "valid non-square", + ioutil.Discard, + rect(0, 0, 10, 50), + false, + }, + { + "valid non-square, weird dimensions", + ioutil.Discard, + rect(0, 0, 17, 77), + false, + }, + { + "invalid zero img", + ioutil.Discard, + rect(0, 0, 0, 0), + true, + }, + { + "invalid small img", + ioutil.Discard, + rect(0, 0, 1, 1), + true, + }, + { + "valid square not at origin point", + ioutil.Discard, + rect(10, 10, 50, 50), + false, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(st *testing.T) { + err := Encode(tt.wr, tt.img) + if !tt.wantErr && err != nil { + st.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestSizesFromMax(t *testing.T) { + t.Parallel() + tests := []struct { + desc string + from uint + want []uint + }{ + { + "small", + 100, + []uint{64, 32}, + }, + { + "large", + 99999, + []uint{1024, 512, 256, 64, 32}, + }, + { + "smallest", + 0, + []uint{}, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(st *testing.T) { + got := sizesFrom(tt.from) + if !deep.EqualContents(got, tt.want) { + st.Errorf("want=%d, got=%d", tt.want, got) + } + }) + } +} + +func TestBiggestSide(t *testing.T) { + t.Parallel() + tests := []struct { + desc string + img image.Image + want uint + }{ + { + "equal", + rect(0, 0, 100, 100), + 100, + }, + { + "right larger", + rect(0, 0, 50, 100), + 100, + }, + { + "left larger", + rect(0, 0, 100, 50), + 100, + }, + { + "off by one", + rect(0, 0, 100, 99), + 100, + }, + { + "empty", + rect(0, 0, 0, 0), + 0, + }, + { + "left empty", + rect(0, 0, 0, 10), + 10, + }, + { + "right empty", + rect(0, 0, 10, 0), + 10, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(st *testing.T) { + got := biggestSide(tt.img) + if got != tt.want { + st.Errorf("want=%d, got=%d", tt.want, got) + } + }) + } +} + +func TestFindNearestSize(t *testing.T) { + t.Parallel() + tests := []struct { + desc string + img image.Image + want uint + }{ + { + "small", + rect(0, 0, 100, 100), + 64, + }, + { + "very large", + rect(0, 0, 123456789, 123456789), + 1024, + }, + { + "too small", + rect(0, 0, 16, 16), + 0, + }, + { + "off by one", + rect(0, 0, 33, 33), + 32, + }, + { + "exact", + rect(0, 0, 256, 256), + 256, + }, + { + "exact", + rect(0, 0, 1024, 1024), + 1024, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(st *testing.T) { + got := findNearestSize(tt.img) + if tt.want != got { + st.Errorf("want=%d, got=%d", tt.want, got) + } + }) + } +} + +func TestEncodeImage(t *testing.T) { + t.Parallel() + tests := []struct { + desc string + + img image.Image + format string + + want string + }{ + { + "png - png", + _decode(_png(rect(0, 0, 50, 50))), + "png", + "png", + }, + { + "default png - png", + _decode(_png(rect(0, 0, 50, 50))), + "", + "png", + }, + { + "jpg - jpg", + _decode(_jpg(rect(0, 0, 50, 50))), + "jpeg", + "jpeg", + }, + { + "default jpg - png", + _decode(_jpg(rect(0, 0, 50, 50))), + "", + "png", + }, + { + "invalid format identifier", + _decode(_jpg(rect(0, 0, 50, 50))), + "asdf", + "png", + }, + { + "not actually a jpeg: forces a conversion", + _decode(_png(rect(0, 0, 50, 50))), + "jpeg", + "jpeg", + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(st *testing.T) { + data, err := encodeImage(tt.img, tt.format) + if err != nil { + st.Fatalf("encoding image: %v", err) + } + _, f, err := image.Decode(bytes.NewBuffer(data)) + if err != nil { + st.Fatalf("decoding iamge: %v", err) + } + if f != tt.want { + st.Fatalf("formats: want=%s, got=%s", tt.want, f) + } + }) + } +} + +func rect(x0, y0, x1, y1 int) image.Image { + return image.Rect(x0, y0, x1, y1) +} + +func _png(img image.Image) io.Reader { + buf := bytes.NewBuffer(nil) + if err := png.Encode(buf, img); err != nil { + panic(errors.Wrapf(err, "encoding png")) + } + return buf +} + +func _jpg(img image.Image) io.Reader { + buf := bytes.NewBuffer(nil) + if err := jpeg.Encode(buf, img, nil); err != nil { + panic(errors.Wrapf(err, "encoding jpeg")) + } + return buf +} + +func _decode(r io.Reader) image.Image { + m, _, err := image.Decode(r) + if err != nil { + panic(errors.Wrapf(err, "decoding image")) + } + return m +} diff --git a/icon.go b/icon.go @@ -1,71 +0,0 @@ -package icns - -import ( - "bytes" - "image" - "image/png" - "io" -) - -// OsType is a 4 character identifier used to differentiate icon types. -type OsType string - -// Icon encodes an icns icon. -type Icon struct { - Type OsType - Image image.Image - - header [8]byte - headerSet bool - data []byte -} - -// WriteTo encodes the icon into wr. -func (i *Icon) WriteTo(wr io.Writer) (int64, error) { - var written int64 - if err := i.encodePng(); err != nil { - return written, err - } - size, err := i.writeHeader(wr) - written += size - if err != nil { - return written, err - } - size, err = i.writeData(wr) - written += size - if err != nil { - return written, err - } - return written, nil -} - -func (i *Icon) encodePng() error { - if len(i.data) > 0 { - return nil - } - buf := bytes.NewBuffer(nil) - if err := png.Encode(buf, i.Image); err != nil { - return err - } - i.data = buf.Bytes() - return nil -} - -func (i *Icon) writeHeader(wr io.Writer) (int64, error) { - if !i.headerSet { - defer func() { i.headerSet = true }() - i.header[0] = i.Type[0] - i.header[1] = i.Type[1] - i.header[2] = i.Type[2] - i.header[3] = i.Type[3] - length := uint32(len(i.data) + 8) - writeUint32(i.header[4:8], length) - } - written, err := wr.Write(i.header[:8]) - return int64(written), err -} - -func (i *Icon) writeData(wr io.Writer) (int64, error) { - written, err := wr.Write(i.data) - return int64(written), err -} diff --git a/iconset.go b/iconset.go @@ -1,67 +0,0 @@ -package icns - -import ( - "bytes" - "io" -) - -// IconSet encodes a set of icons into an ICNS file. -type IconSet struct { - Icons []*Icon - - header [8]byte - headerSet bool - data []byte -} - -// WriteTo writes the ICNS file to wr. -func (s *IconSet) WriteTo(wr io.Writer) (int64, error) { - var written int64 - if err := s.encodeIcons(); err != nil { - return written, err - } - size, err := s.writeHeader(wr) - written += size - if err != nil { - return written, err - } - size, err = s.writeData(wr) - written += size - if err != nil { - return written, err - } - return written, nil -} - -func (s *IconSet) encodeIcons() error { - if len(s.data) > 0 { - return nil - } - buf := bytes.NewBuffer(nil) - for _, icon := range s.Icons { - if _, err := icon.WriteTo(buf); err != nil { - return err - } - } - s.data = buf.Bytes() - return nil -} - -func (s *IconSet) writeHeader(wr io.Writer) (int64, error) { - if !s.headerSet { - defer func() { s.headerSet = true }() - s.header[0] = 'i' - s.header[1] = 'c' - s.header[2] = 'n' - s.header[3] = 's' - length := uint32(len(s.data) + 8) - writeUint32(s.header[4:8], length) - } - written, err := wr.Write(s.header[:8]) - return int64(written), err -} - -func (s *IconSet) writeData(wr io.Writer) (int64, error) { - written, err := wr.Write(s.data) - return int64(written), err -} diff --git a/interpolation.go b/interpolation.go @@ -1,9 +1,6 @@ package icns import ( - "image" - "io" - "github.com/nfnt/resize" ) @@ -25,20 +22,3 @@ const ( // Lanczos interpolation (a=3) Lanczos3 ) - -// EncodeWithInterpolationFunction uses the given interpolation function resize -// the image before writing out to wr. -func EncodeWithInterpolationFunction( - wr io.Writer, - img image.Image, - interp InterpolationFunction, -) error { - iconset, err := NewIconSet(img, interp) - if err != nil { - return err - } - if _, err := iconset.WriteTo(wr); err != nil { - return err - } - return nil -} diff --git a/readme.md b/readme.md @@ -0,0 +1,61 @@ +# icns + +Easily convert `.jpg` and `.png` to `.icns` with the command line tool `icnsify`, or use the library to convert from any `image.Image` to `.icns`. + +`go get github.com/jackmordaunt/icns` + +`icns` files allow for high resolution icons to make your apps look sexy. The most common ways to generate icns files are: + +1. `iconutil`, which is a Mac native cli utility. +2. `ImageMagick` which adds a large dependency to your project for such a simple use case. + +With this library you can use pure Go to create `icns` files from any source image, given that you can decode it into an `image.Image`, without any heavyweight dependencies or subprocessing required. You can also use it to create icns files on windows and linux (thanks Go). + +A small CLI app `icnsify` is provided allowing you to create icns files using this library from the command line. It supports piping, which is something `iconutil` does not do, making it substantially easier to wrap or chuck into a shell pipeline. + +Note: All icons within the `icns` are sized for high dpi retina screens, using the appropriate `icns` OSTypes. + +## Command Line + +Pipe it + +`cat icon.png | icnsify | cat > icon.icns` + +Standard + +`icnsify -i icon.png -o icon.icns` + +## Library Usage + +```go +func main() { + pngf, err := os.Open("path/to/icon.png") + if err != nil { + log.Fatalf("opening source image: %v", err) + } + defer pngf.Close() + srcImg, _, err := image.Decode(pngf) + if err != nil { + log.Fatalf("decoding source image: %v", err) + } + dest, err := os.Open("path/to/icon.icns") + if err != nil { + log.Fatalf("opening destination file: %v", err) + } + defer dest.Close() + if err := icns.Encode(dest, srcImg); err != nil { + log.Fatalf("encoding icns: %v", err) + } +} +``` + +## Roadmap + +* [x] Encoder: `image.Image -> .icns` +* [x] Command Line Interface + * [x] Encoding + * [x] Pipe support + * [ ] Decoding +* [ ] Implement Decoder: `.icns -> image.Image` +* [ ] Symmetric test: `decode(encode(img)) == img` +* [x] Encode based on input image format (jpg -> jpg, png -> png) to avoid lossy conversions diff --git a/writer.go b/writer.go @@ -0,0 +1,146 @@ +package icns + +import ( + "bytes" + "image" + "image/jpeg" + "image/png" + "io" +) + +// Icon encodes an icns icon. +type Icon struct { + Type OsType + Image image.Image + format string + + header [8]byte + headerSet bool + data []byte +} + +// WriteTo encodes the icon into wr. +func (i *Icon) WriteTo(wr io.Writer) (int64, error) { + var written int64 + if err := i.encodeImage(); err != nil { + return written, err + } + size, err := i.writeHeader(wr) + written += size + if err != nil { + return written, err + } + size, err = i.writeData(wr) + written += size + if err != nil { + return written, err + } + return written, nil +} + +func (i *Icon) encodeImage() error { + if len(i.data) > 0 { + return nil + } + data, err := encodeImage(i.Image, i.format) + if err != nil { + return err + } + i.data = data + return nil +} + +func encodeImage(img image.Image, format string) ([]byte, error) { + buf := bytes.NewBuffer(nil) + switch format { + case "jpeg": + if err := jpeg.Encode(buf, img, nil); err != nil { + return nil, err + } + default: + if err := png.Encode(buf, img); err != nil { + return nil, err + } + } + return buf.Bytes(), nil +} + +func (i *Icon) writeHeader(wr io.Writer) (int64, error) { + if !i.headerSet { + defer func() { i.headerSet = true }() + i.header[0] = i.Type[0] + i.header[1] = i.Type[1] + i.header[2] = i.Type[2] + i.header[3] = i.Type[3] + length := uint32(len(i.data) + 8) + writeUint32(i.header[4:8], length) + } + written, err := wr.Write(i.header[:8]) + return int64(written), err +} + +func (i *Icon) writeData(wr io.Writer) (int64, error) { + written, err := wr.Write(i.data) + return int64(written), err +} + +// IconSet encodes a set of icons into an ICNS file. +type IconSet struct { + Icons []*Icon + + header [8]byte + headerSet bool + data []byte +} + +// WriteTo writes the ICNS file to wr. +func (s *IconSet) WriteTo(wr io.Writer) (int64, error) { + var written int64 + if err := s.encodeIcons(); err != nil { + return written, err + } + size, err := s.writeHeader(wr) + written += size + if err != nil { + return written, err + } + size, err = s.writeData(wr) + written += size + if err != nil { + return written, err + } + return written, nil +} + +func (s *IconSet) encodeIcons() error { + if len(s.data) > 0 { + return nil + } + buf := bytes.NewBuffer(nil) + for _, icon := range s.Icons { + if _, err := icon.WriteTo(buf); err != nil { + return err + } + } + s.data = buf.Bytes() + return nil +} + +func (s *IconSet) writeHeader(wr io.Writer) (int64, error) { + if !s.headerSet { + defer func() { s.headerSet = true }() + s.header[0] = 'i' + s.header[1] = 'c' + s.header[2] = 'n' + s.header[3] = 's' + length := uint32(len(s.data) + 8) + writeUint32(s.header[4:8], length) + } + written, err := wr.Write(s.header[:8]) + return int64(written), err +} + +func (s *IconSet) writeData(wr io.Writer) (int64, error) { + written, err := wr.Write(s.data) + return int64(written), err +}