├── .gitignore ├── docs └── preview.png ├── go.mod ├── cmd ├── preview │ ├── readme.md │ ├── go.mod │ ├── go.sum │ └── main.go └── icnsify │ ├── go.mod │ ├── doc.go │ ├── pipe.go │ ├── go.sum │ └── main.go ├── go.sum ├── .github ├── dependabot.yml └── workflows │ └── winget.yml ├── release.sh ├── error.go ├── interpolation.go ├── .goreleaser.yml ├── LICENSE ├── doc.go ├── writer.go ├── readme.md ├── reader.go ├── icns.go └── icns_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | *vendor 3 | *dist -------------------------------------------------------------------------------- /docs/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JackMordaunt/icns/HEAD/docs/preview.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jackmordaunt/icns/v3 2 | 3 | go 1.21.5 4 | 5 | require github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 6 | -------------------------------------------------------------------------------- /cmd/preview/readme.md: -------------------------------------------------------------------------------- 1 | # preview 2 | 3 | Preview is a simple `.icns` image previewer. Lets you view your icons on non-Mac systems! 4 | Design mimics macOS previwer for familiarity. 5 | 6 | ![preview](../../docs/preview.png) 7 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= 2 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | tag=$1 2 | current="$(git tag --points-at HEAD)" 3 | 4 | if [[ -z "$tag" && -z $current ]] 5 | then 6 | echo "commit not tagged and no tag provided" 7 | exit 1 8 | fi 9 | 10 | if [[ ! -z "$tag" && -z $current ]] 11 | then 12 | git tag $tag -m "$tag" 13 | fi 14 | 15 | env GITHUB_TOKEN=$(pass goreleaser/github-token) goreleaser 16 | -------------------------------------------------------------------------------- /cmd/icnsify/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jackmordaunt/icns/cmd/icnsify 2 | 3 | go 1.21.5 4 | 5 | require ( 6 | github.com/jackmordaunt/icns/v3 v3.0.1 7 | github.com/spf13/afero v1.11.0 8 | github.com/spf13/pflag v1.0.5 9 | ) 10 | 11 | require ( 12 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect 13 | golang.org/x/text v0.19.0 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /.github/workflows/winget.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Winget 2 | on: 3 | release: 4 | types: [released] 5 | 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: vedantmgoyal2009/winget-releaser@v2 11 | with: 12 | identifier: JackMordaunt.icnsify 13 | installers-regex: '_windows_\w+\.zip$' 14 | token: ${{ secrets.WINGET_TOKEN }} 15 | -------------------------------------------------------------------------------- /cmd/icnsify/doc.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/pflag" 7 | ) 8 | 9 | var version = "master" 10 | 11 | func usage() { 12 | fmt.Printf("\n") 13 | pflag.Usage() 14 | fmt.Printf(` 15 | You can also pipe to stdin and from stdout. 16 | The pipes will be detected automatically, and both --input and --output will be ignored. 17 | 18 | cat icon.png | icnsify > icon.icns 19 | 20 | `) 21 | } 22 | -------------------------------------------------------------------------------- /cmd/icnsify/pipe.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func init() { 9 | info, err := os.Stdin.Stat() 10 | if err != nil { 11 | panic(fmt.Sprintf("getting info on stdin file descriptor: %v", err)) 12 | } 13 | if (info.Mode() & os.ModeCharDevice) == os.ModeCharDevice { 14 | return 15 | } 16 | if info.Size() > 0 { 17 | piping = true 18 | input = os.Stdin 19 | output = os.Stdout 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package icns 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | ) 7 | 8 | // ErrImageTooSmall is returned when the image is too small to process. 9 | type ErrImageTooSmall struct { 10 | need int 11 | image image.Image 12 | } 13 | 14 | func (err ErrImageTooSmall) Error() string { 15 | b := err.image.Bounds().Max 16 | format := "image is too small: %dx%d, need at least %dx%d" 17 | return fmt.Sprintf(format, b.X, b.Y, err.need, err.need) 18 | } 19 | -------------------------------------------------------------------------------- /interpolation.go: -------------------------------------------------------------------------------- 1 | package icns 2 | 3 | import ( 4 | "github.com/nfnt/resize" 5 | ) 6 | 7 | // InterpolationFunction is the algorithm used to resize the image. 8 | type InterpolationFunction = resize.InterpolationFunction 9 | 10 | // InterpolationFunction constants. 11 | const ( 12 | // Nearest-neighbor interpolation 13 | NearestNeighbor InterpolationFunction = iota 14 | // Bilinear interpolation 15 | Bilinear 16 | // Bicubic interpolation (with cubic hermite spline) 17 | Bicubic 18 | // Mitchell-Netravali interpolation 19 | MitchellNetravali 20 | // Lanczos interpolation (a=2) 21 | Lanczos2 22 | // Lanczos interpolation (a=3) 23 | Lanczos3 24 | ) 25 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # Make sure to check the documentation at http://goreleaser.com 2 | before: 3 | hooks: 4 | - go mod tidy 5 | - go generate ./... 6 | builds: 7 | - main: ./cmd/icnsify 8 | id: "icnsify" 9 | binary: icnsify 10 | goos: 11 | - linux 12 | - windows 13 | - darwin 14 | archives: 15 | - format_overrides: 16 | - goos: windows 17 | format: zip 18 | brews: 19 | - 20 | name: icnsify 21 | repository: 22 | owner: "jackmordaunt" 23 | name: homebrew-tap 24 | scoops: 25 | - 26 | repository: 27 | owner: "jackmordaunt" 28 | name: scoop-bucket 29 | checksum: 30 | name_template: "checksums.txt" 31 | snapshot: 32 | name_template: "{{ .Tag }}-next" 33 | changelog: 34 | sort: asc 35 | filters: 36 | exclude: 37 | - "^docs:" 38 | - "^test:" 39 | -------------------------------------------------------------------------------- /cmd/icnsify/go.sum: -------------------------------------------------------------------------------- 1 | github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o= 2 | github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ= 3 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= 4 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 5 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 6 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 7 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 8 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 9 | golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= 10 | golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 11 | -------------------------------------------------------------------------------- /cmd/preview/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jackmordaunt/icns/cmd/preview 2 | 3 | go 1.21.5 4 | 5 | require ( 6 | gioui.org v0.3.1 7 | gioui.org/x v0.3.2 8 | github.com/jackmordaunt/icns/v3 v3.0.0 9 | github.com/ncruces/zenity v0.10.10 10 | ) 11 | 12 | require ( 13 | gioui.org/cpu v0.0.0-20220412190645-f1e9e8c3b1f7 // indirect 14 | gioui.org/shader v1.0.8 // indirect 15 | github.com/akavel/rsrc v0.10.2 // indirect 16 | github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f // indirect 17 | github.com/go-text/typesetting v0.0.0-20231126133128-3b7c9205d99e // indirect 18 | github.com/josephspurrier/goversioninfo v1.4.0 // indirect 19 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect 20 | github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 // indirect 21 | golang.org/x/exp v0.0.0-20231127185646-65229373498e // indirect 22 | golang.org/x/exp/shiny v0.0.0-20231127185646-65229373498e // indirect 23 | golang.org/x/image v0.14.0 // indirect 24 | golang.org/x/sys v0.15.0 // indirect 25 | golang.org/x/text v0.14.0 // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jack Mordaunt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package icns implements an encoder for Apple's `.icns` file format. 2 | // Reference: "https://en.wikipedia.org/wiki/Apple_Icon_Image_format". 3 | // 4 | // icns files allow for high resolution icons to make your apps look sexy. 5 | // The most common ways to generate icns files are 1. use `iconutil` which is 6 | // a Mac native cli utility, or 2. use tools that wrap `ImageMagick` which adds 7 | // a large dependency to your project for such a simple use case. 8 | // 9 | // With this library you can use pure Go to create icns files from any source 10 | // image, given that you can decode it into an `image.Image`, without any 11 | // heavyweight dependencies or subprocessing required. You can also use this 12 | // library to create icns files on windows and linux. 13 | // 14 | // A small CLI app `icnsify` is provided to allow you to create icns files 15 | // using this library from the command line. It supports piping, which is 16 | // something `iconutil` does not do, making it substantially easier to wrap. 17 | // 18 | // Note: All icons within the icns are sized for high dpi retina screens, using 19 | // the appropriate icns OSTypes. 20 | package icns 21 | -------------------------------------------------------------------------------- /writer.go: -------------------------------------------------------------------------------- 1 | package icns 2 | 3 | import ( 4 | "bytes" 5 | "image" 6 | "image/png" 7 | "io" 8 | ) 9 | 10 | // Icon encodes an icns icon. 11 | type Icon struct { 12 | Type OsType 13 | Image image.Image 14 | 15 | header [8]byte 16 | headerSet bool 17 | data []byte 18 | } 19 | 20 | // WriteTo encodes the icon into wr. 21 | func (i *Icon) WriteTo(wr io.Writer) (int64, error) { 22 | var written int64 23 | if err := i.encodeImage(); err != nil { 24 | return written, err 25 | } 26 | size, err := i.writeHeader(wr) 27 | written += size 28 | if err != nil { 29 | return written, err 30 | } 31 | size, err = i.writeData(wr) 32 | written += size 33 | if err != nil { 34 | return written, err 35 | } 36 | return written, nil 37 | } 38 | 39 | func (i *Icon) encodeImage() error { 40 | if len(i.data) > 0 { 41 | return nil 42 | } 43 | data, err := encodeImage(i.Image) 44 | if err != nil { 45 | return err 46 | } 47 | i.data = data 48 | return nil 49 | } 50 | 51 | func encodeImage(img image.Image) ([]byte, error) { 52 | buf := bytes.NewBuffer(nil) 53 | if err := png.Encode(buf, img); err != nil { 54 | return nil, err 55 | } 56 | return buf.Bytes(), nil 57 | } 58 | 59 | func (i *Icon) writeHeader(wr io.Writer) (int64, error) { 60 | if !i.headerSet { 61 | defer func() { i.headerSet = true }() 62 | i.header[0] = i.Type.ID[0] 63 | i.header[1] = i.Type.ID[1] 64 | i.header[2] = i.Type.ID[2] 65 | i.header[3] = i.Type.ID[3] 66 | length := uint32(len(i.data) + 8) 67 | writeUint32(i.header[4:8], length) 68 | } 69 | written, err := wr.Write(i.header[:8]) 70 | return int64(written), err 71 | } 72 | 73 | func (i *Icon) writeData(wr io.Writer) (int64, error) { 74 | written, err := wr.Write(i.data) 75 | return int64(written), err 76 | } 77 | 78 | // IconSet encodes a set of icons into an ICNS file. 79 | type IconSet struct { 80 | Icons []*Icon 81 | 82 | header [8]byte 83 | headerSet bool 84 | data []byte 85 | } 86 | 87 | // WriteTo writes the ICNS file to wr. 88 | func (s *IconSet) WriteTo(wr io.Writer) (int64, error) { 89 | var written int64 90 | if err := s.encodeIcons(); err != nil { 91 | return written, err 92 | } 93 | size, err := s.writeHeader(wr) 94 | written += size 95 | if err != nil { 96 | return written, err 97 | } 98 | size, err = s.writeData(wr) 99 | written += size 100 | if err != nil { 101 | return written, err 102 | } 103 | return written, nil 104 | } 105 | 106 | func (s *IconSet) encodeIcons() error { 107 | if len(s.data) > 0 { 108 | return nil 109 | } 110 | buf := bytes.NewBuffer(nil) 111 | for _, icon := range s.Icons { 112 | if icon == nil { 113 | continue 114 | } 115 | if _, err := icon.WriteTo(buf); err != nil { 116 | return err 117 | } 118 | } 119 | s.data = buf.Bytes() 120 | return nil 121 | } 122 | 123 | func (s *IconSet) writeHeader(wr io.Writer) (int64, error) { 124 | if !s.headerSet { 125 | defer func() { s.headerSet = true }() 126 | s.header[0] = 'i' 127 | s.header[1] = 'c' 128 | s.header[2] = 'n' 129 | s.header[3] = 's' 130 | length := uint32(len(s.data) + 8) 131 | writeUint32(s.header[4:8], length) 132 | } 133 | written, err := wr.Write(s.header[:8]) 134 | return int64(written), err 135 | } 136 | 137 | func (s *IconSet) writeData(wr io.Writer) (int64, error) { 138 | written, err := wr.Write(s.data) 139 | return int64(written), err 140 | } 141 | -------------------------------------------------------------------------------- /cmd/icnsify/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "image" 6 | "image/jpeg" 7 | "image/png" 8 | "io" 9 | "log/slog" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | 14 | "github.com/jackmordaunt/icns/v3" 15 | "github.com/spf13/afero" 16 | 17 | "github.com/spf13/pflag" 18 | ) 19 | 20 | var ( 21 | fs = afero.NewOsFs() 22 | piping bool 23 | input io.Reader 24 | output io.Writer 25 | ) 26 | 27 | func main() { 28 | var ( 29 | inputPath = pflag.StringP( 30 | "input", 31 | "i", 32 | "", 33 | "Input image for conversion to icns from jpg|png or visa versa.", 34 | ) 35 | outputPath = pflag.StringP( 36 | "output", 37 | "o", 38 | "", 39 | "Output path, defaults to .(icns|png) depending on input.", 40 | ) 41 | resize = pflag.IntP( 42 | "resize", 43 | "r", 44 | 5, 45 | "Quality of resize algorithm. Values range from 0 to 5, fastest to slowest execution time. Defaults to slowest for best quality.", 46 | ) 47 | ) 48 | pflag.Parse() 49 | in, out, algorithm := sanitiseInputs(*inputPath, *outputPath, *resize) 50 | if !piping { 51 | if in == "" { 52 | usage() 53 | os.Exit(0) 54 | } 55 | sourcef, err := fs.Open(in) 56 | if err != nil { 57 | slog.Error("opening source image", "err", err) 58 | return 59 | } 60 | defer sourcef.Close() 61 | input = sourcef 62 | if err := fs.MkdirAll(filepath.Dir(out), 0o755); err != nil { 63 | slog.Error("preparing output directory: %v", "err", err) 64 | return 65 | } 66 | outputf, err := fs.Create(out) 67 | if err != nil { 68 | slog.Error("creating icns file", "err", err) 69 | return 70 | } 71 | defer outputf.Close() 72 | output = outputf 73 | } 74 | if filepath.Ext(*inputPath) == ".icns" { 75 | by, err := io.ReadAll(input) 76 | if err != nil { 77 | slog.Error("probing file: reading file", "err", err) 78 | return 79 | } 80 | icons, err := icns.Probe(bytes.NewReader(by)) 81 | if err != nil { 82 | slog.Error("probing file", "err", err) 83 | return 84 | } 85 | for _, icon := range icons { 86 | slog.Info("found", "icon", icon) 87 | } 88 | input = bytes.NewReader(by) 89 | } 90 | img, format, err := image.Decode(input) 91 | if err != nil { 92 | slog.Error("decoding input", "err", err) 93 | return 94 | } 95 | if format == "icns" { 96 | imageType := strings.ToLower(filepath.Ext(out)) 97 | if _, ok := encoders[imageType]; !ok { 98 | imageType = ".png" 99 | } 100 | if err := encoders[imageType](output, img); err != nil { 101 | slog.Error("encoding", "err", err, "type", imageType) 102 | } 103 | } else { 104 | enc := icns.NewEncoder(output). 105 | WithAlgorithm(algorithm) 106 | if err := enc.Encode(img); err != nil { 107 | slog.Error("encoding icns", "err", err) 108 | } 109 | } 110 | } 111 | 112 | func sanitiseInputs( 113 | inputPath string, 114 | outputPath string, 115 | resize int, 116 | ) (string, string, icns.InterpolationFunction) { 117 | if filepath.Ext(inputPath) == ".icns" { 118 | if outputPath == "" { 119 | outputPath = changeExtensionTo(inputPath, "png") 120 | } 121 | if filepath.Ext(outputPath) == "" { 122 | outputPath += ".png" 123 | } 124 | } 125 | if filepath.Ext(inputPath) != ".icns" { 126 | if outputPath == "" { 127 | outputPath = changeExtensionTo(inputPath, "icns") 128 | } 129 | if filepath.Ext(outputPath) == "" { 130 | outputPath += ".icns" 131 | } 132 | } 133 | if resize < 0 { 134 | resize = 0 135 | } 136 | if resize > 5 { 137 | resize = 5 138 | } 139 | return inputPath, outputPath, icns.InterpolationFunction(resize) 140 | } 141 | 142 | func changeExtensionTo(path, ext string) string { 143 | if !strings.HasPrefix(ext, ".") { 144 | ext = "." + ext 145 | } 146 | return filepath.Base(path[:len(path)-len(filepath.Ext(path))] + ext) 147 | } 148 | 149 | type encoderFunc func(io.Writer, image.Image) error 150 | 151 | func encodeJPEG(w io.Writer, m image.Image) error { 152 | return jpeg.Encode(w, m, &jpeg.Options{Quality: 100}) 153 | } 154 | 155 | var encoders = map[string]encoderFunc{ 156 | ".png": png.Encode, 157 | ".jpg": encodeJPEG, 158 | ".jpeg": encodeJPEG, 159 | } 160 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # icns 2 | 3 | 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`. 4 | 5 | `go get github.com/jackmordaunt/icns` 6 | 7 | `icns` files allow for high resolution icons to make your apps look sexy. The most common ways to generate icns files are: 8 | 9 | 1. `iconutil`, which is a Mac native cli utility. 10 | 2. `ImageMagick` which adds a large dependency to your project for such a simple use case. 11 | 12 | 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). 13 | 14 | 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. 15 | 16 | Note: All icons within the `icns` are sized for high dpi retina screens, using the appropriate `icns` OSTypes. 17 | 18 | ## GUI 19 | 20 | `preview` is a gui for displaying `icns` files cross-platform. 21 | 22 | ### Go Tool 23 | 24 | ``` 25 | go install github.com/jackmordaunt/icns/cmd/preview@latest 26 | ``` 27 | 28 | ### Clone 29 | 30 | ``` 31 | git clone https://github.com/jackmordaunt/icns 32 | cd icns/cmd/preview && go install . 33 | ``` 34 | 35 | Note: Gio cannot be cross-compiled right now, so there are no `preview` builds in releases. 36 | Note: `preview` has it's own `go.mod` and therefore is versioned independently (unversioned). 37 | 38 | ![preview](docs/preview.png) 39 | 40 | ## Command Line 41 | 42 | ### Go Tool 43 | 44 | ``` 45 | go install github.com/jackmordaunt/icns/cmd/icnsify@latest 46 | ``` 47 | 48 | ### [Scoop](https://scoop.sh/) 49 | 50 | ```powershell 51 | scoop bucket add extras # Ensure bucket is added first 52 | scoop install icnsify 53 | ``` 54 | 55 | Or from my personal bucket: 56 | 57 | ```powershell 58 | scoop bucket add jackmordaunt https://github.com/jackmordaunt/scoop-bucket 59 | scoop install jackmordaunt/icns # Name is defaulted to repo name. 60 | ``` 61 | 62 | ### [Winget](https://learn.microsoft.com/en-us/windows/package-manager/) 63 | 64 | ```powershell 65 | winget install icnsify 66 | ``` 67 | 68 | ### [Brew](https://brew.sh) 69 | 70 | ```sh 71 | brew tap jackmordaunt/homebrew-tap # Ensure tap is added first. 72 | brew install icnsify 73 | ``` 74 | 75 | ### Clone 76 | 77 | ``` 78 | git clone https://github.com/jackmordaunt/icns 79 | cd icns && go install ./cmd/icnsify 80 | ``` 81 | 82 | Pipe it 83 | 84 | `cat icon.png | icnsify > icon.icns` 85 | 86 | `cat icon.icns | icnsify > icon.png` 87 | 88 | Standard 89 | 90 | `icnsify -i icon.png -o icon.icns` 91 | 92 | `icnsify -i icon.icns -o icon.png` 93 | 94 | ## Library 95 | 96 | `go get github.com/jackmordaunt/icns/v3` 97 | 98 | ```go 99 | func main() { 100 | pngf, err := os.Open("path/to/icon.png") 101 | if err != nil { 102 | log.Fatalf("opening source image: %v", err) 103 | } 104 | defer pngf.Close() 105 | srcImg, _, err := image.Decode(pngf) 106 | if err != nil { 107 | log.Fatalf("decoding source image: %v", err) 108 | } 109 | dest, err := os.Create("path/to/icon.icns") 110 | if err != nil { 111 | log.Fatalf("opening destination file: %v", err) 112 | } 113 | defer dest.Close() 114 | if err := icns.Encode(dest, srcImg); err != nil { 115 | log.Fatalf("encoding icns: %v", err) 116 | } 117 | } 118 | ``` 119 | 120 | ## Roadmap 121 | 122 | - [x] Encoder: `image.Image -> .icns` 123 | - [x] Command Line Interface 124 | - [x] Encoding 125 | - [x] Pipe support 126 | - [x] Decoding 127 | - [x] Implement Decoder: `.icns -> image.Image` 128 | - [ ] Symmetric test: `decode(encode(img)) == img` 129 | 130 | ## Coffee 131 | 132 | If this software is useful to you, consider buying me a coffee! 133 | 134 | [https://liberapay.com/JackMordaunt](https://liberapay.com/JackMordaunt) 135 | -------------------------------------------------------------------------------- /reader.go: -------------------------------------------------------------------------------- 1 | package icns 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "fmt" 7 | "image" 8 | "io" 9 | "sort" 10 | ) 11 | 12 | var jpeg2000header = []byte{0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20} 13 | 14 | // Decode finds the largest icon listed in the icns file and returns it, 15 | // ignoring all other sizes. The format returned will be PNG. JPEG 2000 16 | // icons are ignored due to lack of image decoding support. 17 | func Decode(r io.Reader) (image.Image, error) { 18 | icons, err := decode(r) 19 | if err != nil { 20 | return nil, err 21 | } 22 | sort.Slice(icons, func(ii, jj int) bool { 23 | return icons[ii].OsType.Size > icons[jj].OsType.Size 24 | }) 25 | icon := icons[0] 26 | if icon.IconDescription.ImageFormat == ImageFormatJPEG2000 { 27 | return nil, fmt.Errorf("decoding largest image (icon %s %s): unsupported format", icon.OsType, icon.ImageFormat) 28 | } 29 | img, _, err := image.Decode(icon.r) 30 | if err != nil { 31 | return nil, fmt.Errorf("decoding largest image (icon %s %s): %w", icon.OsType, icon.ImageFormat, err) 32 | } 33 | return img, nil 34 | } 35 | 36 | // DecodeAll extracts all icon resolutions present in the icns data that 37 | // contain PNG data. JPEG 2000 is ignored due to lack of image decoding 38 | // support. 39 | func DecodeAll(r io.Reader) (images []image.Image, err error) { 40 | icons, err := decode(r) 41 | if err != nil { 42 | return nil, err 43 | } 44 | for _, icon := range icons { 45 | if icon.IconDescription.ImageFormat == ImageFormatJPEG2000 { 46 | continue 47 | } 48 | img, _, err := image.Decode(icon.r) 49 | if err != nil { 50 | return nil, fmt.Errorf("decoding icon %s %s: %w", icon.OsType, icon.ImageFormat, err) 51 | } 52 | images = append(images, img) 53 | } 54 | if len(images) == 0 { 55 | return nil, fmt.Errorf("no supported icons found") 56 | } 57 | sort.Slice(images, func(ii, jj int) bool { 58 | var ( 59 | left = images[ii].Bounds().Size() 60 | right = images[jj].Bounds().Size() 61 | ) 62 | return (left.X + left.Y) > (right.X + right.Y) 63 | }) 64 | return images, nil 65 | } 66 | 67 | // Probe extracts descriptions of the icons in the icns. 68 | func Probe(r io.Reader) (desc []IconDescription, _ error) { 69 | icons, err := decode(r) 70 | if err != nil { 71 | return nil, err 72 | } 73 | for _, icon := range icons { 74 | desc = append(desc, icon.IconDescription) 75 | } 76 | return desc, nil 77 | } 78 | 79 | // decode identifies the icons in the icns (without decoding the image data). 80 | func decode(r io.Reader) (icons []iconReader, err error) { 81 | data, err := io.ReadAll(r) 82 | if err != nil { 83 | return nil, err 84 | } 85 | var ( 86 | header = data[0:4] 87 | fileSize = binary.BigEndian.Uint32(data[4:8]) 88 | read = uint32(8) 89 | ) 90 | if string(header) != "icns" { 91 | return nil, fmt.Errorf("invalid header for icns file") 92 | } 93 | for read < fileSize { 94 | next := data[read : read+4] 95 | read += 4 96 | switch string(next) { 97 | case "TOC ": 98 | tocSize := binary.BigEndian.Uint32(data[read : read+4]) 99 | read += tocSize - 4 // size includes header and size fields 100 | continue 101 | case "icnV": 102 | read += 4 103 | continue 104 | } 105 | dataSize := binary.BigEndian.Uint32(data[read : read+4]) 106 | read += 4 107 | if dataSize == 0 { 108 | continue // no content, we're not interested 109 | } 110 | iconData := data[read : read+dataSize-8] 111 | read += dataSize - 8 // size includes header and size fields 112 | if isOsType(string(next)) { 113 | ir := iconReader{ 114 | IconDescription: IconDescription{ 115 | OsType: osTypeFromID(string(next)), 116 | }, 117 | r: bytes.NewBuffer(iconData), 118 | } 119 | if bytes.Equal(iconData[:8], jpeg2000header) { 120 | ir.ImageFormat = ImageFormatJPEG2000 121 | } 122 | icons = append(icons, ir) 123 | } 124 | } 125 | if len(icons) == 0 { 126 | return nil, fmt.Errorf("no icons found") 127 | } 128 | return icons, nil 129 | } 130 | 131 | type iconReader struct { 132 | IconDescription 133 | r io.Reader 134 | } 135 | 136 | func isOsType(ID string) bool { 137 | _, ok := getTypeFromID(ID) 138 | return ok 139 | } 140 | 141 | func init() { 142 | image.RegisterFormat("icns", "icns", Decode, nil) 143 | } 144 | -------------------------------------------------------------------------------- /cmd/preview/go.sum: -------------------------------------------------------------------------------- 1 | eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY= 2 | eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= 3 | gioui.org v0.3.1 h1:hslYkrkIWvx28Mxe3A87opl+8s9mnWsnWmPDh11+zco= 4 | gioui.org v0.3.1/go.mod h1:2atiYR4upH71/6ehnh6XsUELa7JZOrOHHNMDxGBZF0Q= 5 | gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= 6 | gioui.org/cpu v0.0.0-20220412190645-f1e9e8c3b1f7 h1:tNJdnP5CgM39PRc+KWmBRRYX/zJ+rd5XaYxY5d5veqA= 7 | gioui.org/cpu v0.0.0-20220412190645-f1e9e8c3b1f7/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= 8 | gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= 9 | gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= 10 | gioui.org/x v0.3.2 h1:bvrXyuKE+389UEJ6XKjmaK+WUt+jIW6s1gvi8dxqox4= 11 | gioui.org/x v0.3.2/go.mod h1:RH5KfSS6NkQEYPxNX2uBPxuklKey96Pt3bQHf0/VBLQ= 12 | github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw= 13 | github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= 14 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 15 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f h1:OGqDDftRTwrvUoL6pOG7rYTmWsTCvyEWFsMjg+HcOaA= 17 | github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f/go.mod h1:Dv9D0NUlAsaQcGQZa5kc5mqR9ua72SmA8VXi4cd+cBw= 18 | github.com/go-text/typesetting v0.0.0-20231126133128-3b7c9205d99e h1:XYK2AGBUaDkE9yG8FMOWCm0v/fjmcbkccKm1t3IIK/M= 19 | github.com/go-text/typesetting v0.0.0-20231126133128-3b7c9205d99e/go.mod h1:evDBbvNR/KaVFZ2ZlDSOWWXIUKq0wCOEtzLxRM8SG3k= 20 | github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22 h1:LBQTFxP2MfsyEDqSKmUBZaDuDHN1vpqDyOZjcqS7MYI= 21 | github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= 22 | github.com/jackmordaunt/icns/v3 v3.0.0 h1:zGmeCscotL8gG6KWUNE2JfcAkn7UjgHLvHrhtO9iIgA= 23 | github.com/jackmordaunt/icns/v3 v3.0.0/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ= 24 | github.com/josephspurrier/goversioninfo v1.4.0 h1:Puhl12NSHUSALHSuzYwPYQkqa2E1+7SrtAPJorKK0C8= 25 | github.com/josephspurrier/goversioninfo v1.4.0/go.mod h1:JWzv5rKQr+MmW+LvM412ToT/IkYDZjaclF2pKDss8IY= 26 | github.com/ncruces/zenity v0.10.10 h1:V/rtAhr5QLdDThahOkm7EYlnw4RuEsf7oN+Xb6lz1j0= 27 | github.com/ncruces/zenity v0.10.10/go.mod h1:k3k4hJ4Wt1MUbeV48y+Gbl7Fp9skfGszN/xtKmuvhZk= 28 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= 29 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 30 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 31 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 32 | github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 h1:GranzK4hv1/pqTIhMTXt2X8MmMOuH3hMeUR0o9SP5yc= 33 | github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844/go.mod h1:T1TLSfyWVBRXVGzWd0o9BI4kfoO9InEgfQe4NV3mLz8= 34 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 35 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 36 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 37 | go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= 38 | go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= 39 | golang.org/x/exp v0.0.0-20231127185646-65229373498e h1:Gvh4YaCaXNs6dKTlfgismwWZKyjVZXwOPfIyUaqU3No= 40 | golang.org/x/exp v0.0.0-20231127185646-65229373498e/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= 41 | golang.org/x/exp/shiny v0.0.0-20231127185646-65229373498e h1:OcpyLYky9rjmUp6ZYOow6ky00AmhIM2pL3vTv1lQErg= 42 | golang.org/x/exp/shiny v0.0.0-20231127185646-65229373498e/go.mod h1:UH99kUObWAZkDnWqppdQe5ZhPYESUw8I0zVV1uWBR+0= 43 | golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4= 44 | golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= 45 | golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= 46 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 47 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 48 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 49 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 50 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 51 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 52 | -------------------------------------------------------------------------------- /icns.go: -------------------------------------------------------------------------------- 1 | package icns 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "image" 7 | "io" 8 | "sync" 9 | 10 | "github.com/nfnt/resize" 11 | ) 12 | 13 | // Encoder encodes ICNS files from a source image. 14 | type Encoder struct { 15 | Wr io.Writer 16 | Algorithm InterpolationFunction 17 | } 18 | 19 | // NewEncoder initialises an encoder. 20 | func NewEncoder(wr io.Writer) *Encoder { 21 | return &Encoder{ 22 | Wr: wr, 23 | Algorithm: MitchellNetravali, 24 | } 25 | } 26 | 27 | // WithAlgorithm applies the interpolation function used to resize the image. 28 | func (enc *Encoder) WithAlgorithm(a InterpolationFunction) *Encoder { 29 | enc.Algorithm = a 30 | return enc 31 | } 32 | 33 | // Encode icns with the given configuration. 34 | func (enc *Encoder) Encode(img image.Image) error { 35 | if enc.Wr == nil { 36 | return errors.New("cannot write to nil writer") 37 | } 38 | if img == nil { 39 | return errors.New("cannot encode nil image") 40 | } 41 | iconset, err := NewIconSet(img, enc.Algorithm) 42 | if err != nil { 43 | return err 44 | } 45 | if _, err := iconset.WriteTo(enc.Wr); err != nil { 46 | return err 47 | } 48 | return nil 49 | } 50 | 51 | // Encode writes img to wr in ICNS format. 52 | // img is assumed to be a rectangle; non-square dimensions will be squared 53 | // without preserving the aspect ratio. 54 | // Uses nearest neighbor as interpolation algorithm. 55 | func Encode(wr io.Writer, img image.Image) error { 56 | return NewEncoder(wr).Encode(img) 57 | } 58 | 59 | // NewIconSet uses the source image to create an IconSet. 60 | // If width != height, the image will be resized using the largest side without 61 | // preserving the aspect ratio. 62 | func NewIconSet(img image.Image, interp InterpolationFunction) (*IconSet, error) { 63 | biggest := findNearestSize(img) 64 | if biggest == 0 { 65 | return nil, ErrImageTooSmall{image: img, need: 16} 66 | } 67 | icons := make([]*Icon, len(osTypes)) 68 | work := sync.WaitGroup{} 69 | var iconIdx int 70 | for _, size := range sizesFrom(biggest) { 71 | osTypes, ok := getTypesFromSize(size) 72 | if !ok { 73 | continue 74 | } 75 | size := size 76 | for _, osType := range osTypes { 77 | work.Add(1) 78 | go func(iconIdx int, osType OsType, size uint) { 79 | iconImg := resize.Resize(size, size, img, interp) 80 | icons[iconIdx] = &Icon{ 81 | Type: osType, 82 | Image: iconImg, 83 | } 84 | work.Done() 85 | }(iconIdx, osType, size) 86 | iconIdx += 1 87 | } 88 | } 89 | work.Wait() 90 | iconSet := &IconSet{ 91 | Icons: icons, 92 | } 93 | return iconSet, nil 94 | } 95 | 96 | // Big-endian. 97 | // https://golang.org/src/image/png/writer.go 98 | func writeUint32(b []uint8, u uint32) { 99 | b[0] = uint8(u >> 24) 100 | b[1] = uint8(u >> 16) 101 | b[2] = uint8(u >> 8) 102 | b[3] = uint8(u >> 0) 103 | } 104 | 105 | var sizes = []uint{ 106 | 1024, 107 | 512, 108 | 256, 109 | 128, 110 | 64, 111 | 32, 112 | 16, 113 | } 114 | 115 | // findNearestSize finds the biggest icon size we can use for this image. 116 | func findNearestSize(img image.Image) uint { 117 | size := biggestSide(img) 118 | for _, s := range sizes { 119 | if size >= s { 120 | return s 121 | } 122 | } 123 | return 0 124 | } 125 | 126 | func biggestSide(img image.Image) uint { 127 | var size uint 128 | b := img.Bounds() 129 | w, h := uint(b.Max.X), uint(b.Max.Y) 130 | size = w 131 | if h > size { 132 | size = h 133 | } 134 | return size 135 | } 136 | 137 | // sizesFrom returns a slice containing the sizes less than and including max. 138 | func sizesFrom(max uint) []uint { 139 | for ii, s := range sizes { 140 | if s <= max { 141 | return sizes[ii:] 142 | } 143 | } 144 | return []uint{} 145 | } 146 | 147 | // IconDescription describes an icon. 148 | type IconDescription struct { 149 | OsType 150 | ImageFormat 151 | } 152 | 153 | func (desc IconDescription) String() string { 154 | return fmt.Sprintf("%s (%s)", desc.OsType, desc.ImageFormat) 155 | } 156 | 157 | // ImageFormat specifies the type of image data associated with an icon. 158 | type ImageFormat int 159 | 160 | const ( 161 | ImageFormatPNG ImageFormat = iota 162 | ImageFormatJPEG2000 163 | ) 164 | 165 | func (f ImageFormat) String() string { 166 | switch f { 167 | case ImageFormatPNG: 168 | return "PNG" 169 | case ImageFormatJPEG2000: 170 | return "JPEG 2000" 171 | } 172 | return fmt.Sprintf("unknown format %d", f) 173 | } 174 | 175 | // OsType is a 4 character identifier used to differentiate icon types. 176 | type OsType struct { 177 | ID string 178 | Size uint 179 | } 180 | 181 | func (t OsType) String() string { 182 | return fmt.Sprintf("%s %d", t.ID, t.Size) 183 | } 184 | 185 | var osTypes = []OsType{ 186 | {ID: "ic10", Size: uint(1024)}, 187 | {ID: "ic14", Size: uint(512)}, 188 | {ID: "ic09", Size: uint(512)}, 189 | {ID: "ic13", Size: uint(256)}, 190 | {ID: "ic08", Size: uint(256)}, 191 | {ID: "ic07", Size: uint(128)}, 192 | {ID: "ic12", Size: uint(64)}, 193 | {ID: "ic11", Size: uint(32)}, 194 | } 195 | 196 | // getTypesFromSize returns the types for the given icon size (in px). 197 | // The boolean indicates whether the types exist. 198 | func getTypesFromSize(size uint) ([]OsType, bool) { 199 | var retOsTypes []OsType 200 | for _, t := range osTypes { 201 | if t.Size == size { 202 | retOsTypes = append(retOsTypes, t) 203 | } 204 | } 205 | return retOsTypes, len(retOsTypes) != 0 206 | } 207 | 208 | func getTypeFromID(ID string) (OsType, bool) { 209 | for _, t := range osTypes { 210 | if t.ID == ID { 211 | return t, true 212 | } 213 | } 214 | return OsType{}, false 215 | } 216 | 217 | func osTypeFromID(ID string) OsType { 218 | t, _ := getTypeFromID(ID) 219 | return t 220 | } 221 | -------------------------------------------------------------------------------- /icns_test.go: -------------------------------------------------------------------------------- 1 | package icns 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "image" 7 | "image/jpeg" 8 | "image/png" 9 | "io" 10 | "reflect" 11 | "testing" 12 | ) 13 | 14 | // TestDecode relies on Encode being correct. 15 | // We are testing that an ICNS with a series of icons will only yield the 16 | // largest icon in the series. 17 | func TestDecode(t *testing.T) { 18 | t.Parallel() 19 | tests := []struct { 20 | desc string 21 | input image.Image 22 | want image.Image 23 | }{ 24 | { 25 | "valid square icon, exact size", 26 | rect(0, 0, 256, 256), 27 | rect(0, 0, 256, 256), 28 | }, 29 | { 30 | "non exact size", 31 | rect(0, 0, 50, 50), 32 | rect(0, 0, 32, 32), 33 | }, 34 | } 35 | for _, tt := range tests { 36 | t.Run(tt.desc, func(st *testing.T) { 37 | buf := bytes.NewBuffer(nil) 38 | if err := Encode(buf, tt.input); err != nil { 39 | st.Fatalf("unexpected error while encoding: %v", err) 40 | } 41 | img, err := Decode(buf) 42 | if err != nil { 43 | st.Fatalf("unexpected error: %v", err) 44 | } 45 | if tt.want != nil && !imageCompare(img, tt.want) { 46 | st.Fatalf("decoded image is incorrect") 47 | } 48 | }) 49 | } 50 | } 51 | 52 | func imageCompare(left, right image.Image) bool { 53 | if left == nil && right == nil { 54 | return true 55 | } 56 | if left == nil && right != nil { 57 | return false 58 | } 59 | if left != nil && right == nil { 60 | return false 61 | } 62 | lb := left.Bounds() 63 | for ii := lb.Min.X; ii <= lb.Max.X; ii++ { 64 | for kk := lb.Min.Y; kk <= lb.Max.Y; kk++ { 65 | lr, lg, lb, la := left.At(ii, kk).RGBA() 66 | rr, rg, rb, ra := right.At(ii, kk).RGBA() 67 | if lr != rr || lg != rg || lb != rb || la != ra { 68 | return false 69 | } 70 | } 71 | } 72 | return true 73 | } 74 | 75 | // TestEncode tests for input validation, sanity checks and errors. 76 | // The validity of the encoding is not tested here. 77 | // Super large images are not tested because the resizing takes too 78 | // long for unit testing. 79 | func TestEncode(t *testing.T) { 80 | t.Parallel() 81 | tests := []struct { 82 | desc string 83 | wr io.Writer 84 | img image.Image 85 | 86 | wantErr bool 87 | }{ 88 | { 89 | "nil image", 90 | io.Discard, 91 | nil, 92 | true, 93 | }, 94 | { 95 | "nil writer", 96 | nil, 97 | rect(0, 0, 50, 50), 98 | true, 99 | }, 100 | { 101 | "valid sqaure", 102 | io.Discard, 103 | rect(0, 0, 50, 50), 104 | false, 105 | }, 106 | { 107 | "valid non-square", 108 | io.Discard, 109 | rect(0, 0, 10, 50), 110 | false, 111 | }, 112 | { 113 | "valid non-square, weird dimensions", 114 | io.Discard, 115 | rect(0, 0, 17, 77), 116 | false, 117 | }, 118 | { 119 | "invalid zero img", 120 | io.Discard, 121 | rect(0, 0, 0, 0), 122 | true, 123 | }, 124 | { 125 | "invalid small img", 126 | io.Discard, 127 | rect(0, 0, 1, 1), 128 | true, 129 | }, 130 | { 131 | "valid square not at origin point", 132 | io.Discard, 133 | rect(10, 10, 50, 50), 134 | false, 135 | }, 136 | } 137 | for _, tt := range tests { 138 | t.Run(tt.desc, func(st *testing.T) { 139 | err := Encode(tt.wr, tt.img) 140 | if !tt.wantErr && err != nil { 141 | st.Fatalf("unexpected error: %v", err) 142 | } 143 | }) 144 | } 145 | } 146 | 147 | func TestSizesFromMax(t *testing.T) { 148 | t.Parallel() 149 | tests := []struct { 150 | desc string 151 | from uint 152 | want []uint 153 | }{ 154 | { 155 | "small", 156 | 100, 157 | []uint{64, 32, 16}, 158 | }, 159 | { 160 | "large", 161 | 99999, 162 | []uint{1024, 512, 256, 128, 64, 32, 16}, 163 | }, 164 | { 165 | "smallest", 166 | 0, 167 | []uint{}, 168 | }, 169 | } 170 | for _, tt := range tests { 171 | t.Run(tt.desc, func(st *testing.T) { 172 | got := sizesFrom(tt.from) 173 | if !reflect.DeepEqual(got, tt.want) { 174 | st.Errorf("want=%d, got=%d", tt.want, got) 175 | } 176 | }) 177 | } 178 | } 179 | 180 | func TestBiggestSide(t *testing.T) { 181 | t.Parallel() 182 | tests := []struct { 183 | desc string 184 | img image.Image 185 | want uint 186 | }{ 187 | { 188 | "equal", 189 | rect(0, 0, 100, 100), 190 | 100, 191 | }, 192 | { 193 | "right larger", 194 | rect(0, 0, 50, 100), 195 | 100, 196 | }, 197 | { 198 | "left larger", 199 | rect(0, 0, 100, 50), 200 | 100, 201 | }, 202 | { 203 | "off by one", 204 | rect(0, 0, 100, 99), 205 | 100, 206 | }, 207 | { 208 | "empty", 209 | rect(0, 0, 0, 0), 210 | 0, 211 | }, 212 | { 213 | "left empty", 214 | rect(0, 0, 0, 10), 215 | 10, 216 | }, 217 | { 218 | "right empty", 219 | rect(0, 0, 10, 0), 220 | 10, 221 | }, 222 | } 223 | for _, tt := range tests { 224 | t.Run(tt.desc, func(st *testing.T) { 225 | got := biggestSide(tt.img) 226 | if got != tt.want { 227 | st.Errorf("want=%d, got=%d", tt.want, got) 228 | } 229 | }) 230 | } 231 | } 232 | 233 | func TestFindNearestSize(t *testing.T) { 234 | t.Parallel() 235 | tests := []struct { 236 | desc string 237 | img image.Image 238 | want uint 239 | }{ 240 | { 241 | "small", 242 | rect(0, 0, 100, 100), 243 | 64, 244 | }, 245 | { 246 | "very large", 247 | rect(0, 0, 123456789, 123456789), 248 | 1024, 249 | }, 250 | { 251 | "too small", 252 | rect(0, 0, 15, 15), 253 | 0, 254 | }, 255 | { 256 | "off by one", 257 | rect(0, 0, 33, 33), 258 | 32, 259 | }, 260 | { 261 | "exact", 262 | rect(0, 0, 256, 256), 263 | 256, 264 | }, 265 | { 266 | "exact", 267 | rect(0, 0, 1024, 1024), 268 | 1024, 269 | }, 270 | } 271 | for _, tt := range tests { 272 | t.Run(tt.desc, func(st *testing.T) { 273 | got := findNearestSize(tt.img) 274 | if tt.want != got { 275 | st.Errorf("want=%d, got=%d", tt.want, got) 276 | } 277 | }) 278 | } 279 | } 280 | 281 | func TestEncodeImage(t *testing.T) { 282 | t.Parallel() 283 | tests := []struct { 284 | desc string 285 | 286 | img image.Image 287 | format string 288 | 289 | want string 290 | }{ 291 | { 292 | "png - png", 293 | _decode(_png(rect(0, 0, 50, 50))), 294 | "png", 295 | "png", 296 | }, 297 | { 298 | "default png - png", 299 | _decode(_png(rect(0, 0, 50, 50))), 300 | "", 301 | "png", 302 | }, 303 | { 304 | "jpg - jpg", 305 | _decode(_jpg(rect(0, 0, 50, 50))), 306 | "jpeg", 307 | "png", 308 | }, 309 | { 310 | "default jpg - png", 311 | _decode(_jpg(rect(0, 0, 50, 50))), 312 | "", 313 | "png", 314 | }, 315 | { 316 | "invalid format identifier", 317 | _decode(_jpg(rect(0, 0, 50, 50))), 318 | "asdf", 319 | "png", 320 | }, 321 | { 322 | "not actually a jpeg", 323 | _decode(_png(rect(0, 0, 50, 50))), 324 | "jpeg", 325 | "png", 326 | }, 327 | } 328 | for _, tt := range tests { 329 | t.Run(tt.desc, func(st *testing.T) { 330 | data, err := encodeImage(tt.img) 331 | if err != nil { 332 | st.Fatalf("encoding image: %v", err) 333 | } 334 | _, f, err := image.Decode(bytes.NewBuffer(data)) 335 | if err != nil { 336 | st.Fatalf("decoding iamge: %v", err) 337 | } 338 | if f != tt.want { 339 | st.Fatalf("formats: want=%s, got=%s", tt.want, f) 340 | } 341 | }) 342 | } 343 | } 344 | 345 | func rect(x0, y0, x1, y1 int) image.Image { 346 | return image.Rect(x0, y0, x1, y1) 347 | } 348 | 349 | func _png(img image.Image) io.Reader { 350 | buf := bytes.NewBuffer(nil) 351 | if err := png.Encode(buf, img); err != nil { 352 | panic(fmt.Errorf("encoding png: %w", err)) 353 | } 354 | return buf 355 | } 356 | 357 | func _jpg(img image.Image) io.Reader { 358 | buf := bytes.NewBuffer(nil) 359 | if err := jpeg.Encode(buf, img, nil); err != nil { 360 | panic(fmt.Errorf("encoding jpeg: %w", err)) 361 | } 362 | return buf 363 | } 364 | 365 | func _decode(r io.Reader) image.Image { 366 | m, _, err := image.Decode(r) 367 | if err != nil { 368 | panic(fmt.Errorf("decoding image: %w", err)) 369 | } 370 | return m 371 | } 372 | -------------------------------------------------------------------------------- /cmd/preview/main.go: -------------------------------------------------------------------------------- 1 | // Previwer GUI for `.icns` icons. 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "image" 7 | "image/color" 8 | "image/png" 9 | "log" 10 | "os" 11 | "path/filepath" 12 | "strconv" 13 | "strings" 14 | 15 | "gioui.org/app" 16 | "gioui.org/io/key" 17 | "gioui.org/io/pointer" 18 | "gioui.org/io/system" 19 | "gioui.org/layout" 20 | l "gioui.org/layout" 21 | "gioui.org/op" 22 | "gioui.org/op/clip" 23 | "gioui.org/op/paint" 24 | "gioui.org/unit" 25 | "gioui.org/widget" 26 | m "gioui.org/widget/material" 27 | c "gioui.org/x/component" 28 | "github.com/jackmordaunt/icns/v3" 29 | "github.com/ncruces/zenity" 30 | ) 31 | 32 | // BUG(jfm): macOS file dialog returns "no such file or directory". Could be permissions issue. 33 | 34 | func main() { 35 | ui := UI{ 36 | Window: app.NewWindow(app.Title("icnsify"), app.MinSize(700, 250)), 37 | Th: m.NewTheme(), 38 | } 39 | if len(os.Args) > 1 { 40 | if file := os.Args[1]; filepath.Ext(file) == ".icns" { 41 | go func() { 42 | imgs, err := LoadImage(file) 43 | ui.ProcessedIcon <- ProcessedIconResult{ 44 | Imgs: imgs, 45 | File: filepath.Base(file), 46 | Err: err, 47 | } 48 | }() 49 | } 50 | } 51 | go func() { 52 | if err := ui.Loop(); err != nil { 53 | log.Fatalf("error: %v", err) 54 | } 55 | os.Exit(0) 56 | }() 57 | app.Main() 58 | } 59 | 60 | type ( 61 | C = l.Context 62 | D = l.Dimensions 63 | ) 64 | 65 | // UI contains all state for the UI. 66 | type UI struct { 67 | *app.Window 68 | Th *m.Theme 69 | 70 | // Preview points to the currently selected icon to render in the preview area. 71 | Preview *widget.Image 72 | // Icons contains all the different resolutions found in the icns file. 73 | Icons []widget.Image 74 | // FileName is the name of the source icon file on disk. 75 | FileName string 76 | // Source is the original image data. 77 | Source image.Image 78 | 79 | OpenBtn widget.Clickable 80 | SideBar layout.List 81 | 82 | ProcessedIcon chan ProcessedIconResult 83 | Processing bool 84 | } 85 | 86 | type ProcessedIconResult struct { 87 | File string 88 | Imgs []image.Image 89 | Err error 90 | } 91 | 92 | // Loop initializes UI state and starts the render loop. 93 | func (ui *UI) Loop() error { 94 | ui.ProcessedIcon = make(chan ProcessedIconResult) 95 | var ( 96 | ops op.Ops 97 | events = ui.Window.Events() 98 | ) 99 | for event := range events { 100 | switch event := (event).(type) { 101 | case system.DestroyEvent: 102 | return event.Err 103 | case system.FrameEvent: 104 | gtx := l.NewContext(&ops, event) 105 | ui.Update(gtx) 106 | ui.Layout(gtx) 107 | event.Frame(gtx.Ops) 108 | } 109 | } 110 | return nil 111 | } 112 | 113 | // Update the UI state. 114 | func (ui *UI) Update(gtx C) { 115 | if ui.Processing { 116 | op.InvalidateOp{}.Add(gtx.Ops) 117 | } 118 | for _, event := range gtx.Events(ui) { 119 | if k, ok := event.(key.Event); ok { 120 | if k.Name == "S" && k.Modifiers.Contain(key.ModShortcut) && ui.Source != nil { 121 | if err := func() error { 122 | file, err := zenity.SelectFileSave( 123 | zenity.Title("Save as icns"), 124 | zenity.Filename(UseExt(ui.FileName, ".icns"))) 125 | if err != nil { 126 | return fmt.Errorf("selecting file: %w", err) 127 | } 128 | if err := ui.SaveAs(file); err != nil { 129 | return fmt.Errorf("saving to icns: %w", err) 130 | } 131 | return nil 132 | }(); err != nil { 133 | log.Printf("saving png as icns: %v", err) 134 | } 135 | } 136 | } 137 | } 138 | for ii := range ui.Icons { 139 | for _, event := range gtx.Events(ui.Icons[ii]) { 140 | if c, ok := event.(pointer.Event); ok && c.Type == pointer.Release { 141 | ui.Preview = &ui.Icons[ii] 142 | } 143 | } 144 | } 145 | if ui.OpenBtn.Clicked() { 146 | ui.Processing = true 147 | go func() { 148 | imgs, file, err := func() ([]image.Image, string, error) { 149 | file, err := zenity.SelectFile(zenity.Title("Select .icns file")) 150 | if err != nil { 151 | return nil, "", fmt.Errorf("selecting file: %w", err) 152 | } 153 | imgs, err := LoadImage(file) 154 | if err != nil { 155 | return nil, "", err 156 | } 157 | return imgs, file, nil 158 | }() 159 | ui.ProcessedIcon <- ProcessedIconResult{ 160 | File: filepath.Base(file), 161 | Imgs: imgs, 162 | Err: err, 163 | } 164 | }() 165 | } 166 | select { 167 | case r := <-ui.ProcessedIcon: 168 | if r.Err != nil { 169 | // TODO(jfm): push to dismissable error stack. 170 | log.Printf("loading icns file: %v", r.Err) 171 | } else { 172 | ui.Icons = ui.Icons[:] 173 | for _, img := range r.Imgs { 174 | ui.Icons = append(ui.Icons, widget.Image{ 175 | Src: paint.NewImageOp(img), 176 | Fit: widget.Contain, 177 | Position: l.Center, 178 | }) 179 | } 180 | if len(r.Imgs) > 0 { 181 | ui.Source = r.Imgs[0] 182 | } 183 | if len(ui.Icons) > 0 { 184 | ui.Preview = &ui.Icons[0] 185 | } 186 | ui.FileName = r.File 187 | ui.Processing = false 188 | } 189 | default: 190 | } 191 | } 192 | 193 | // Layout the UI. 194 | func (ui *UI) Layout(gtx C) D { 195 | ui.SideBar.Axis = l.Vertical 196 | key.InputOp{Tag: ui}.Add(gtx.Ops) 197 | key.FocusOp{Tag: ui}.Add(gtx.Ops) 198 | return l.Flex{ 199 | Axis: l.Horizontal, 200 | }.Layout( 201 | gtx, 202 | l.Rigid(func(gtx C) D { return ui.LayoutSideBar(gtx) }), 203 | l.Flexed(1, func(gtx C) D { return ui.LayoutPreviewArea(gtx) }), 204 | ) 205 | } 206 | 207 | var ( 208 | // ThumbnailWidth specifies how wide the sidebar thumbnails should be. 209 | ThumbnailWidth = unit.Dp(125) 210 | // SelectedHighlight specifies the color to render behind the selected thumbnail. 211 | SelectedHighlight = color.NRGBA{A: 50} 212 | ) 213 | 214 | // LayoutSideBar displays a sidebar which contains a list of thumbnails for the various icns 215 | // resolutions. 216 | func (ui *UI) LayoutSideBar(gtx C) D { 217 | return l.Flex{ 218 | Axis: l.Vertical, 219 | Alignment: l.Middle, 220 | }.Layout( 221 | gtx, 222 | l.Rigid(func(gtx C) D { 223 | return l.UniformInset((5)).Layout(gtx, func(gtx C) D { 224 | return m.Label(ui.Th, (15), ui.FileName).Layout(gtx) 225 | }) 226 | }), 227 | l.Flexed(1, func(gtx C) D { 228 | return ui.SideBar.Layout(gtx, len(ui.Icons), func(gtx C, ii int) D { 229 | return l.UniformInset((15)).Layout(gtx, func(gtx C) D { 230 | cs := >x.Constraints 231 | cs.Max.X = gtx.Dp(ThumbnailWidth) 232 | return ui.LayoutThumbnail(gtx, ii) 233 | }) 234 | }) 235 | }), 236 | ) 237 | } 238 | 239 | // LayoutPreviewArea displays the selected icon resultion scaled to the size of the area. 240 | func (ui *UI) LayoutPreviewArea(gtx C) D { 241 | return l.Center.Layout(gtx, func(gtx C) D { 242 | if ui.Preview == nil { 243 | btn := m.Button(ui.Th, &ui.OpenBtn, "Open") 244 | btn.TextSize = (25) 245 | return btn.Layout(gtx) 246 | } 247 | return ui.Preview.Layout(gtx) 248 | }) 249 | } 250 | 251 | // LayoutThumbnail displays a specific icon thumbnail. 252 | func (ui *UI) LayoutThumbnail(gtx C, ii int) D { 253 | return l.Stack{}.Layout( 254 | gtx, 255 | l.Stacked(func(gtx C) D { 256 | return l.Flex{ 257 | Axis: l.Vertical, 258 | Alignment: l.Middle, 259 | }.Layout( 260 | gtx, 261 | l.Rigid(func(gtx C) D { 262 | return ui.Icons[ii].Layout(gtx) 263 | }), 264 | l.Rigid(func(gtx C) D { 265 | return m.Label(ui.Th, (15), strconv.Itoa(ii+1)). 266 | Layout(gtx) 267 | }), 268 | ) 269 | }), 270 | l.Expanded(func(gtx C) D { 271 | if ui.Icons[ii] == *ui.Preview { 272 | return c.Rect{ 273 | Size: gtx.Constraints.Min, 274 | Color: SelectedHighlight, 275 | Radii: 4, 276 | }.Layout(gtx) 277 | } 278 | return D{} 279 | }), 280 | l.Expanded(func(gtx C) D { 281 | defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Min}).Push(gtx.Ops).Pop() 282 | pointer.InputOp{ 283 | Tag: ui.Icons[ii], 284 | Types: pointer.Release, 285 | }.Add(gtx.Ops) 286 | return D{} 287 | }), 288 | ) 289 | } 290 | 291 | // SaveAs saves the previewed image as an icns icon at the path specified. 292 | func (ui *UI) SaveAs(path string) error { 293 | if ui.Source == nil { 294 | return nil 295 | } 296 | f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644) 297 | if err != nil { 298 | return fmt.Errorf("creating file: %w", err) 299 | } 300 | defer f.Close() 301 | if err := icns.Encode(f, ui.Source); err != nil { 302 | return fmt.Errorf("encoding icns: %w", err) 303 | } 304 | return nil 305 | } 306 | 307 | // LoadImages loads the specified images to preview. 308 | // Safe for concurrent use. 309 | func (ui *UI) LoadImages(name string, imgs []image.Image) { 310 | ui.ProcessedIcon <- ProcessedIconResult{ 311 | Imgs: imgs, 312 | File: name, 313 | Err: nil, 314 | } 315 | } 316 | 317 | // LoadImage will load all icons from an icns file, or generate them from a png file. 318 | func LoadImage(path string) ([]image.Image, error) { 319 | path, err := filepath.Abs(path) 320 | if err != nil { 321 | return nil, fmt.Errorf("resolving file path: %w", err) 322 | } 323 | f, err := os.OpenFile(path, os.O_RDONLY, 0644) 324 | if err != nil { 325 | return nil, err 326 | } 327 | defer f.Close() 328 | switch filepath.Ext(path) { 329 | case ".icns": 330 | imgs, err := icns.DecodeAll(f) 331 | if err != nil { 332 | return nil, fmt.Errorf("decoding icns: %w", err) 333 | } 334 | return imgs, nil 335 | case ".png": 336 | img, err := png.Decode(f) 337 | if err != nil { 338 | return nil, fmt.Errorf("decoding png: %w", err) 339 | } 340 | return []image.Image{img}, nil 341 | } 342 | return nil, nil 343 | } 344 | 345 | // UseExt replaces any existing file extension with the provided one. 346 | func UseExt(s, ext string) string { 347 | return strings.Replace(s, filepath.Ext(s), ".icns", 1) 348 | } 349 | --------------------------------------------------------------------------------