icns

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

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 }