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 }