main.go (8112B)
1 package main 2 3 import ( 4 "bytes" 5 "errors" 6 "flag" 7 "fmt" 8 "image" 9 "image/jpeg" 10 "image/png" 11 "io" 12 "log/slog" 13 "os" 14 "path/filepath" 15 "strings" 16 17 "github.com/jackmordaunt/icns/v4" 18 "github.com/jackmordaunt/icns/v4/appicon" 19 "github.com/jackmordaunt/icns/v4/exe" 20 "github.com/jackmordaunt/icns/v4/ico" 21 ) 22 23 // containers are the formats that hold an icon at several sizes, as opposed 24 // to the plain images they are built from and unpacked into. 25 var containers = map[string]bool{".icns": true, ".ico": true} 26 27 // binaries are the Windows files that carry icons inside them rather than 28 // being icons themselves. 29 var binaries = map[string]bool{".exe": true, ".dll": true} 30 31 // bundles are the formats written as a directory rather than as a file. 32 var bundles = map[string]bool{".icon": true} 33 34 // errUsage signals that no work was requested; usage has been printed. 35 var errUsage = errors.New("usage") 36 37 func main() { 38 if err := run(); err != nil { 39 if !errors.Is(err, errUsage) { 40 slog.Error("icnsify failed", "err", err) 41 } 42 os.Exit(1) 43 } 44 } 45 46 func run() error { 47 var ( 48 inputPath string 49 outputPath string 50 outputFormat string 51 resize int 52 ) 53 stringFlag(&inputPath, "input", "i", "", 54 "Input image: artwork to pack, an icon file to unpack, or an iconset directory.") 55 stringFlag(&outputPath, "output", "o", "", 56 "Output path, defaults to the input named with the target's extension.") 57 stringFlag(&outputFormat, "format", "f", "", 58 "Output format: icns, icon, ico, png or jpg. Defaults from the output path.") 59 intFlag(&resize, "resize", "r", 5, 60 "Quality of resize algorithm, 0 to 5 from fastest to slowest.") 61 var checkOnly bool 62 boolFlag(&checkOnly, "check", "c", 63 "Report what the platforms will make of an icon file, and exit.") 64 var showVersion bool 65 boolFlag(&showVersion, "version", "v", "Print the version and exit.") 66 flag.Usage = usage 67 flag.Parse() 68 69 if showVersion { 70 fmt.Println(buildInfo()) 71 return nil 72 } 73 74 var ( 75 input io.Reader 76 output io.Writer 77 ) 78 // An explicit --input wins; otherwise a non-terminal stdin means we are 79 // part of a pipeline and both paths are ignored. 80 piping := false 81 if inputPath == "" { 82 var err error 83 if piping, err = stdinIsPipe(); err != nil { 84 return err 85 } 86 } 87 // Checking reads the input and writes a report, so it runs before any 88 // output path is resolved or created. 89 if checkOnly { 90 if piping { 91 return check("", os.Stdin) 92 } 93 if inputPath == "" { 94 usage() 95 return errUsage 96 } 97 source, err := os.Open(inputPath) 98 if err != nil { 99 return fmt.Errorf("opening source image: %w", err) 100 } 101 defer source.Close() 102 return check(inputPath, source) 103 } 104 if outputFormat != "" && !writable(extension(outputFormat)) { 105 return fmt.Errorf("cannot write %s: choose from icns, icon, ico, png or jpg", outputFormat) 106 } 107 in, out, algorithm := sanitiseInputs(inputPath, outputPath, outputFormat, resize) 108 if piping { 109 input, output = os.Stdin, os.Stdout 110 } else { 111 if in == "" { 112 usage() 113 return errUsage 114 } 115 // A directory is an iconset: artwork per slot rather than one image 116 // to resize for every size. 117 if info, err := os.Stat(in); err == nil && info.IsDir() { 118 return encodeIconSet(in, out, target(outputFormat, out, false, ""), algorithm) 119 } 120 sourcef, err := os.Open(in) 121 if err != nil { 122 return fmt.Errorf("opening source image: %w", err) 123 } 124 defer sourcef.Close() 125 input = sourcef 126 // A bundle is a directory the encoder builds itself, so there is no 127 // file to open for it. 128 if !bundles[target(outputFormat, out, false, extension(filepath.Ext(in)))] { 129 if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil { 130 return fmt.Errorf("preparing output directory: %w", err) 131 } 132 outputf, err := os.Create(out) 133 if err != nil { 134 return fmt.Errorf("creating output file: %w", err) 135 } 136 defer outputf.Close() 137 output = outputf 138 } 139 } 140 source, err := io.ReadAll(input) 141 if err != nil { 142 return fmt.Errorf("reading input: %w", err) 143 } 144 var ( 145 img image.Image 146 format string 147 ) 148 if kind := container(source, extension(filepath.Ext(in))); kind != "" { 149 if err := describe(kind, bytes.NewReader(source)); err != nil { 150 return fmt.Errorf("probing file: %w", err) 151 } 152 } 153 // A Windows binary carries icons rather than being one, so the artwork 154 // comes out of its resources instead of through an image decoder. 155 if binaries[container(source, extension(filepath.Ext(in)))] { 156 format = ".exe" 157 if img, err = exe.Decode(bytes.NewReader(source)); err != nil { 158 return fmt.Errorf("reading icons from the binary: %w", err) 159 } 160 } else if img, format, err = image.Decode(bytes.NewReader(source)); err != nil { 161 return fmt.Errorf("decoding input: %w", err) 162 } 163 switch kind := target(outputFormat, out, piping, format); kind { 164 case ".icon": 165 if piping { 166 return errors.New("a .icon is a directory, so it cannot be written to a pipe") 167 } 168 name := strings.TrimSuffix(filepath.Base(out), filepath.Ext(out)) 169 if err := appicon.New(img, name).Write(out); err != nil { 170 return fmt.Errorf("writing icon bundle: %w", err) 171 } 172 case ".icns": 173 if err := icns.NewEncoder(output).WithAlgorithm(algorithm).Encode(img); err != nil { 174 return fmt.Errorf("encoding icns: %w", err) 175 } 176 case ".ico": 177 if err := ico.NewEncoder(output).WithAlgorithm(algorithm).Encode(img); err != nil { 178 return fmt.Errorf("encoding ico: %w", err) 179 } 180 default: 181 if err := encoders[kind](output, img); err != nil { 182 return fmt.Errorf("encoding %s: %w", kind, err) 183 } 184 } 185 return nil 186 } 187 188 // describe logs the icons a container holds. 189 func describe(ext string, r io.Reader) error { 190 switch ext { 191 case ".exe", ".dll": 192 by, err := io.ReadAll(r) 193 if err != nil { 194 return err 195 } 196 groups, err := exe.Icons(bytes.NewReader(by)) 197 if err != nil { 198 return err 199 } 200 for _, group := range groups { 201 slog.Info("found", "icon", group) 202 } 203 case ".ico": 204 d, err := ico.NewDecoder(r) 205 if err != nil { 206 return err 207 } 208 for _, icon := range d.Icons() { 209 slog.Info("found", "icon", icon) 210 } 211 default: 212 icons, err := icns.Probe(r) 213 if err != nil { 214 return err 215 } 216 for _, icon := range icons { 217 slog.Info("found", "icon", icon) 218 } 219 } 220 return nil 221 } 222 223 // target names the format to write. The --format flag decides it, then the 224 // output path, and failing both the conversion changes kind, since that is 225 // what converting an icon usually means: artwork becomes a container of 226 // icons and a container becomes a plain image. A pipe has no output path. 227 func target(want, out string, piping bool, got string) string { 228 if want != "" { 229 return extension(want) 230 } 231 if !piping { 232 if ext := extension(filepath.Ext(out)); writable(ext) { 233 return ext 234 } 235 } 236 if containers[extension(got)] || binaries[extension(got)] { 237 return ".png" 238 } 239 return ".icns" 240 } 241 242 // extension normalises a format name or extension to a lower case extension. 243 func extension(name string) string { 244 name = strings.ToLower(name) 245 if name != "" && !strings.HasPrefix(name, ".") { 246 name = "." + name 247 } 248 return name 249 } 250 251 // writable reports whether this program can write the format. 252 func writable(ext string) bool { 253 return containers[ext] || bundles[ext] || encoders[ext] != nil 254 } 255 256 func sanitiseInputs( 257 inputPath string, 258 outputPath string, 259 outputFormat string, 260 resize int, 261 ) (string, string, icns.InterpolationFunction) { 262 ext := target(outputFormat, outputPath, false, extension(filepath.Ext(inputPath))) 263 if outputPath == "" { 264 outputPath = changeExtensionTo(inputPath, ext) 265 } 266 if filepath.Ext(outputPath) == "" { 267 outputPath += ext 268 } 269 if resize < 0 { 270 resize = 0 271 } 272 if resize > 5 { 273 resize = 5 274 } 275 return inputPath, outputPath, icns.InterpolationFunction(resize) 276 } 277 278 func changeExtensionTo(path, ext string) string { 279 if !strings.HasPrefix(ext, ".") { 280 ext = "." + ext 281 } 282 return filepath.Base(path[:len(path)-len(filepath.Ext(path))] + ext) 283 } 284 285 type encoderFunc func(io.Writer, image.Image) error 286 287 func encodeJPEG(w io.Writer, m image.Image) error { 288 return jpeg.Encode(w, m, &jpeg.Options{Quality: 100}) 289 } 290 291 var encoders = map[string]encoderFunc{ 292 ".png": png.Encode, 293 ".jpg": encodeJPEG, 294 ".jpeg": encodeJPEG, 295 }