icns

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

icns.go (9547B)


      1 package icns
      2 
      3 import (
      4 	"cmp"
      5 	"errors"
      6 	"fmt"
      7 	"image"
      8 	"io"
      9 	"slices"
     10 	"sync"
     11 
     12 	"github.com/jackmordaunt/icns/v4/internal/resample"
     13 )
     14 
     15 // Encoder encodes ICNS files from a source image.
     16 type Encoder struct {
     17 	Wr        io.Writer
     18 	Algorithm InterpolationFunction
     19 }
     20 
     21 // NewEncoder initialises an encoder.
     22 func NewEncoder(wr io.Writer) *Encoder {
     23 	return &Encoder{
     24 		Wr:        wr,
     25 		Algorithm: MitchellNetravali,
     26 	}
     27 }
     28 
     29 // WithAlgorithm applies the interpolation function used to resize the image.
     30 func (enc *Encoder) WithAlgorithm(a InterpolationFunction) *Encoder {
     31 	enc.Algorithm = a
     32 	return enc
     33 }
     34 
     35 // Encode icns with the given configuration.
     36 func (enc *Encoder) Encode(img image.Image) error {
     37 	if enc.Wr == nil {
     38 		return errors.New("cannot write to nil writer")
     39 	}
     40 	iconset, err := NewIconSet(img, enc.Algorithm)
     41 	if err != nil {
     42 		return err
     43 	}
     44 	_, err = iconset.WriteTo(enc.Wr)
     45 	return err
     46 }
     47 
     48 // EncodeSlots icns from artwork supplied per slot, so hand tuned art is used
     49 // where it is given rather than resized from a single source.
     50 func (enc *Encoder) EncodeSlots(images map[Slot]image.Image) error {
     51 	if enc.Wr == nil {
     52 		return errors.New("cannot write to nil writer")
     53 	}
     54 	iconset, err := NewIconSetFrom(images, enc.Algorithm)
     55 	if err != nil {
     56 		return err
     57 	}
     58 	_, err = iconset.WriteTo(enc.Wr)
     59 	return err
     60 }
     61 
     62 // Encode writes img to wr in ICNS format.
     63 // img is assumed to be a rectangle; non-square dimensions will be squared
     64 // without preserving the aspect ratio.
     65 // Uses nearest neighbor as interpolation algorithm.
     66 func Encode(wr io.Writer, img image.Image) error {
     67 	return NewEncoder(wr).Encode(img)
     68 }
     69 
     70 // NewIconSet uses the source image to create an IconSet.
     71 // If width != height, the image will be resized using the largest side without
     72 // preserving the aspect ratio.
     73 func NewIconSet(img image.Image, interp InterpolationFunction) (*IconSet, error) {
     74 	if img == nil {
     75 		return nil, errors.New("cannot encode nil image")
     76 	}
     77 	return newIconSet(nil, img, interp)
     78 }
     79 
     80 // NewIconSetFrom uses artwork supplied per slot to create an IconSet, which is
     81 // what an iconset directory holds. A slot given artwork of the wrong size has
     82 // it resized; a slot given none is filled from the largest image supplied,
     83 // which also sets the largest icon written.
     84 func NewIconSetFrom(images map[Slot]image.Image, interp InterpolationFunction) (*IconSet, error) {
     85 	if len(images) == 0 {
     86 		return nil, errors.New("cannot encode without an image")
     87 	}
     88 	slots := make([]Slot, 0, len(images))
     89 	for slot, img := range images {
     90 		if img == nil {
     91 			return nil, fmt.Errorf("cannot encode nil image for %s", slot)
     92 		}
     93 		slots = append(slots, slot)
     94 	}
     95 	// The largest artwork stands in for the slots left empty. Ties are broken
     96 	// by slot so the choice does not depend on map ordering.
     97 	slices.SortFunc(slots, func(a, b Slot) int {
     98 		if order := cmp.Compare(resample.BiggestSide(images[b]), resample.BiggestSide(images[a])); order != 0 {
     99 			return order
    100 		}
    101 		if order := cmp.Compare(b.Points, a.Points); order != 0 {
    102 			return order
    103 		}
    104 		return cmp.Compare(b.Scale, a.Scale)
    105 	})
    106 	return newIconSet(images, images[slots[0]], interp)
    107 }
    108 
    109 func newIconSet(images map[Slot]image.Image, source image.Image, interp InterpolationFunction) (*IconSet, error) {
    110 	biggest := findNearestSize(source)
    111 	if biggest == 0 {
    112 		return nil, ErrImageTooSmall{image: source, need: 16}
    113 	}
    114 	var plan []OsType
    115 	for _, size := range sizesFrom(biggest) {
    116 		types, ok := getTypesFromSize(size)
    117 		if !ok {
    118 			continue
    119 		}
    120 		plan = append(plan, types...)
    121 	}
    122 	icons := make([]*Icon, len(plan))
    123 	work := sync.WaitGroup{}
    124 	for i, osType := range plan {
    125 		work.Add(1)
    126 		go func() {
    127 			defer work.Done()
    128 			art := source
    129 			if supplied, ok := images[osType.slot]; ok {
    130 				art = supplied
    131 			}
    132 			icons[i] = &Icon{
    133 				Type:  osType,
    134 				Image: resample.Square(art, osType.Size, interp),
    135 			}
    136 		}()
    137 	}
    138 	work.Wait()
    139 	iconSet := &IconSet{
    140 		Icons: icons,
    141 	}
    142 	return iconSet, nil
    143 }
    144 
    145 var sizes = []uint{
    146 	1024,
    147 	512,
    148 	256,
    149 	128,
    150 	64,
    151 	32,
    152 	16,
    153 }
    154 
    155 // findNearestSize finds the biggest icon size we can use for this image.
    156 func findNearestSize(img image.Image) uint {
    157 	size := resample.BiggestSide(img)
    158 	for _, s := range sizes {
    159 		if size >= s {
    160 			return s
    161 		}
    162 	}
    163 	return 0
    164 }
    165 
    166 // sizesFrom returns a slice containing the sizes less than and including max.
    167 func sizesFrom(max uint) []uint {
    168 	for ii, s := range sizes {
    169 		if s <= max {
    170 			return sizes[ii:]
    171 		}
    172 	}
    173 	return []uint{}
    174 }
    175 
    176 // IconDescription describes an icon.
    177 type IconDescription struct {
    178 	OsType
    179 	ImageFormat
    180 }
    181 
    182 func (desc IconDescription) String() string {
    183 	return fmt.Sprintf("%s (%s)", desc.OsType, desc.ImageFormat)
    184 }
    185 
    186 // ImageFormat specifies the type of image data associated with an icon.
    187 type ImageFormat int
    188 
    189 const (
    190 	ImageFormatPNG ImageFormat = iota
    191 	ImageFormatJPEG2000
    192 	// ImageFormatRGB is 24-bit colour in run-length encoded channel planes,
    193 	// with alpha held in a separate mask element.
    194 	ImageFormatRGB
    195 	// ImageFormatARGB is run-length encoded channel planes that carry their
    196 	// own alpha, behind an "ARGB" header.
    197 	ImageFormatARGB
    198 	// ImageFormatBitmap is one bit per pixel with a one bit mask.
    199 	ImageFormatBitmap
    200 	// ImageFormatIndexed is an index per pixel into a fixed colour table.
    201 	ImageFormatIndexed
    202 )
    203 
    204 func (f ImageFormat) String() string {
    205 	switch f {
    206 	case ImageFormatPNG:
    207 		return "PNG"
    208 	case ImageFormatJPEG2000:
    209 		return "JPEG 2000"
    210 	case ImageFormatRGB:
    211 		return "24-bit RGB"
    212 	case ImageFormatARGB:
    213 		return "ARGB"
    214 	case ImageFormatBitmap:
    215 		return "1-bit"
    216 	case ImageFormatIndexed:
    217 		return "indexed colour"
    218 	}
    219 	return fmt.Sprintf("unknown format %d", f)
    220 }
    221 
    222 // encoding is how an element stores its image data.
    223 type encoding int
    224 
    225 const (
    226 	// encodingCompressed holds a whole image file, PNG or JPEG 2000.
    227 	encodingCompressed encoding = iota
    228 	// encodingRGB holds run-length encoded colour planes, with alpha in the
    229 	// separate element named by OsType.mask.
    230 	encodingRGB
    231 	// encodingBitmap holds one bit per pixel followed by its own mask.
    232 	encodingBitmap
    233 	// encodingIndexed4 and encodingIndexed8 hold an index per pixel into a
    234 	// fixed colour table, with alpha in the mask half of OsType.mask.
    235 	encodingIndexed4
    236 	encodingIndexed8
    237 )
    238 
    239 // OsType is a 4 character identifier used to differentiate icon types.
    240 type OsType struct {
    241 	ID   string
    242 	Size uint
    243 
    244 	// enc is how this element stores its image data.
    245 	enc encoding
    246 	// mask is the element holding this type's alpha, for the encodings that
    247 	// keep it apart from the colour.
    248 	mask string
    249 	// height is the pixel height, when the icon is not square.
    250 	height uint
    251 	// slot is the iconset slot this type fills, for the written types.
    252 	slot Slot
    253 	// emit marks the types the encoder writes. More types can be read than
    254 	// are written.
    255 	emit bool
    256 }
    257 
    258 func (t OsType) String() string {
    259 	return fmt.Sprintf("%s %d", t.ID, t.Size)
    260 }
    261 
    262 var osTypes = []OsType{
    263 	{ID: "ic10", Size: 1024, slot: Slot{512, 2}, emit: true},
    264 	{ID: "ic14", Size: 512, slot: Slot{256, 2}, emit: true},
    265 	{ID: "ic09", Size: 512, slot: Slot{512, 1}, emit: true},
    266 	{ID: "ic13", Size: 256, slot: Slot{128, 2}, emit: true},
    267 	{ID: "ic08", Size: 256, slot: Slot{256, 1}, emit: true},
    268 	{ID: "ic07", Size: 128, slot: Slot{128, 1}, emit: true},
    269 	{ID: "ic12", Size: 64, slot: Slot{32, 2}, emit: true},
    270 	{ID: "ic11", Size: 32, slot: Slot{16, 2}, emit: true},
    271 
    272 	{ID: "icp6", Size: 48},
    273 	{ID: "icp5", Size: 32},
    274 	{ID: "icp4", Size: 16},
    275 
    276 	// Toolbar and sidebar icons, which hold ARGB or PNG.
    277 	{ID: "SB24", Size: 48},
    278 	{ID: "icsB", Size: 36},
    279 	{ID: "ic05", Size: 32},
    280 	{ID: "sb24", Size: 24},
    281 	{ID: "icsb", Size: 18},
    282 	{ID: "ic04", Size: 16},
    283 
    284 	// The small sizes are written as colour and mask rather than PNG, which
    285 	// is what Apple still emits for them: icp4 and icp5 hold PNG but do not
    286 	// render from an app bundle.
    287 	{ID: "it32", Size: 128, enc: encodingRGB, mask: "t8mk"},
    288 	{ID: "ih32", Size: 48, enc: encodingRGB, mask: "h8mk"},
    289 
    290 	// Icons from System 7 through Mac OS 8, an index per pixel against a
    291 	// fixed table, with alpha in the mask half of the "#" element.
    292 	{ID: "ich8", Size: 48, enc: encodingIndexed8, mask: "ich#"},
    293 	{ID: "ich4", Size: 48, enc: encodingIndexed4, mask: "ich#"},
    294 	{ID: "ich#", Size: 48, enc: encodingBitmap},
    295 	{ID: "icl8", Size: 32, enc: encodingIndexed8, mask: "ICN#"},
    296 	{ID: "icl4", Size: 32, enc: encodingIndexed4, mask: "ICN#"},
    297 	{ID: "ICN#", Size: 32, enc: encodingBitmap},
    298 	{ID: "ICON", Size: 32, enc: encodingBitmap},
    299 	{ID: "ics8", Size: 16, enc: encodingIndexed8, mask: "ics#"},
    300 	{ID: "ics4", Size: 16, enc: encodingIndexed4, mask: "ics#"},
    301 	{ID: "ics#", Size: 16, enc: encodingBitmap},
    302 	{ID: "icm8", Size: 16, height: 12, enc: encodingIndexed8, mask: "icm#"},
    303 	{ID: "icm4", Size: 16, height: 12, enc: encodingIndexed4, mask: "icm#"},
    304 	{ID: "icm#", Size: 16, height: 12, enc: encodingBitmap},
    305 	{ID: "il32", Size: 32, enc: encodingRGB, mask: "l8mk", slot: Slot{32, 1}, emit: true},
    306 	{ID: "is32", Size: 16, enc: encodingRGB, mask: "s8mk", slot: Slot{16, 1}, emit: true},
    307 }
    308 
    309 // getTypesFromSize returns the writable types for the given icon size (in px).
    310 // The boolean indicates whether the types exist.
    311 func getTypesFromSize(size uint) ([]OsType, bool) {
    312 	var retOsTypes []OsType
    313 	for _, t := range osTypes {
    314 		if t.Size == size && t.emit {
    315 			retOsTypes = append(retOsTypes, t)
    316 		}
    317 	}
    318 	return retOsTypes, len(retOsTypes) != 0
    319 }
    320 
    321 func getTypeFromID(ID string) (OsType, bool) {
    322 	for _, t := range osTypes {
    323 		if t.ID == ID {
    324 			return t, true
    325 		}
    326 	}
    327 	return OsType{}, false
    328 }
    329 
    330 func osTypeFromID(ID string) OsType {
    331 	t, _ := getTypeFromID(ID)
    332 	return t
    333 }