icns

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

writer.go (6048B)


      1 package appicon
      2 
      3 import (
      4 	"bytes"
      5 	"encoding/json"
      6 	"fmt"
      7 	"image/png"
      8 	"os"
      9 	"path"
     10 	"path/filepath"
     11 	"strings"
     12 )
     13 
     14 // manifest is the file name the bundle's directory holds beside its assets.
     15 const manifest = "icon.json"
     16 
     17 // assets is the directory within the bundle that holds the layer images.
     18 const assets = "Assets"
     19 
     20 // Files renders the bundle as the files its directory holds, keyed by their
     21 // path within it. The manifest is at icon.json and every layer's image is
     22 // under Assets.
     23 func (b Bundle) Files() (map[string][]byte, error) {
     24 	doc, names, err := b.document()
     25 	if err != nil {
     26 		return nil, err
     27 	}
     28 	encoded, err := json.MarshalIndent(doc, "", "  ")
     29 	if err != nil {
     30 		return nil, fmt.Errorf("writing the manifest: %w", err)
     31 	}
     32 	out := map[string][]byte{manifest: append(encoded, '\n')}
     33 	for i, group := range b.Groups {
     34 		for j, layer := range group.Layers {
     35 			buf := bytes.NewBuffer(nil)
     36 			if err := png.Encode(buf, layer.Image); err != nil {
     37 				return nil, fmt.Errorf("writing layer %s: %w", names[i][j], err)
     38 			}
     39 			out[path.Join(assets, names[i][j])] = buf.Bytes()
     40 		}
     41 	}
     42 	return out, nil
     43 }
     44 
     45 // Write writes the bundle into dir, which is created along with the assets
     46 // directory beneath it. The directory is the bundle, so its name is what
     47 // carries the .icon extension.
     48 func (b Bundle) Write(dir string) error {
     49 	files, err := b.Files()
     50 	if err != nil {
     51 		return err
     52 	}
     53 	if err := os.MkdirAll(filepath.Join(dir, assets), 0o755); err != nil {
     54 		return fmt.Errorf("preparing the bundle: %w", err)
     55 	}
     56 	for name, data := range files {
     57 		at := filepath.Join(dir, filepath.FromSlash(name))
     58 		if err := os.WriteFile(at, data, 0o644); err != nil {
     59 			return fmt.Errorf("writing %s: %w", name, err)
     60 		}
     61 	}
     62 	return nil
     63 }
     64 
     65 // document builds the manifest and the file name chosen for every layer,
     66 // which the caller needs to write the images where the manifest says.
     67 func (b Bundle) document() (icon, [][]string, error) {
     68 	var layers int
     69 	for _, group := range b.Groups {
     70 		layers += len(group.Layers)
     71 	}
     72 	if layers == 0 {
     73 		return icon{}, nil, ErrNoLayers
     74 	}
     75 	doc := icon{
     76 		Fill:      b.Fill,
     77 		Fills:     b.Fills,
     78 		Platforms: platforms{Squares: b.Platforms},
     79 	}
     80 	// A specialised fill says everything the plain one does, so only one of
     81 	// the two is written.
     82 	if len(doc.Fills) > 0 {
     83 		doc.Fill = ""
     84 	} else if doc.Fill == "" {
     85 		doc.Fill = FillAutomatic
     86 	}
     87 	if len(doc.Platforms.Squares) == 0 {
     88 		doc.Platforms.Squares = []string{PlatformMac}
     89 	}
     90 	var (
     91 		names = make([][]string, len(b.Groups))
     92 		taken = make(map[string]bool, layers)
     93 		n     int
     94 	)
     95 	for i, group := range b.Groups {
     96 		names[i] = make([]string, len(group.Layers))
     97 		out := jsonGroup{
     98 			BlendModes:     group.BlendModes,
     99 			BlurMaterials:  group.BlurMaterials,
    100 			Layers:         make([]jsonLayer, len(group.Layers)),
    101 			Lighting:       group.Lighting,
    102 			Specular:       group.Specular,
    103 			Translucencies: group.Translucencies,
    104 		}
    105 		if len(group.BlurMaterials) == 0 {
    106 			out.BlurMaterial = group.BlurMaterial
    107 		}
    108 		for j, layer := range group.Layers {
    109 			if layer.Image == nil {
    110 				return icon{}, nil, fmt.Errorf("layer %q has no image", layer.Name)
    111 			}
    112 			n++
    113 			name := layerName(layer.Name, n)
    114 			if taken[name] {
    115 				return icon{}, nil, fmt.Errorf("%w: %q", ErrDuplicateLayer, name)
    116 			}
    117 			taken[name] = true
    118 			file := name + ".png"
    119 			names[i][j] = file
    120 			out.Layers[j] = jsonLayer{
    121 				BlendModes: layer.BlendModes,
    122 				Fills:      layer.Fills,
    123 				Glass:      layer.Glass,
    124 				Hidden:     layer.Hidden,
    125 				ImageName:  file,
    126 				Name:       name,
    127 				Position:   layer.Position,
    128 			}
    129 		}
    130 		if s := group.Shadow; s != nil {
    131 			kind := s.Kind
    132 			if kind == "" {
    133 				kind = ShadowNeutral
    134 			}
    135 			out.Shadow = &Shadow{Kind: kind, Opacity: s.Opacity}
    136 		}
    137 		if t := group.Translucency; t != nil && len(group.Translucencies) == 0 {
    138 			out.Translucency = t
    139 		}
    140 		doc.Groups = append(doc.Groups, out)
    141 	}
    142 	return doc, names, nil
    143 }
    144 
    145 // layerName renders a layer's name as something that can also be a file name,
    146 // falling back to its position when nothing usable is left.
    147 func layerName(name string, position int) string {
    148 	clean := strings.Map(func(r rune) rune {
    149 		switch {
    150 		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
    151 			return r
    152 		case r == '-', r == '_', r == ' ':
    153 			return r
    154 		}
    155 		return -1
    156 	}, name)
    157 	clean = strings.TrimSpace(clean)
    158 	if clean == "" {
    159 		return fmt.Sprintf("Layer %d", position)
    160 	}
    161 	return clean
    162 }
    163 
    164 // The manifest's shape. The names are the ones Icon Composer writes, which
    165 // is what actool reads.
    166 type icon struct {
    167 	Fill      string              `json:"fill,omitempty"`
    168 	Fills     []Specialized[Fill] `json:"fill-specializations,omitempty"`
    169 	Groups    []jsonGroup         `json:"groups"`
    170 	Platforms platforms           `json:"supported-platforms"`
    171 }
    172 
    173 type platforms struct {
    174 	Squares []string `json:"squares"`
    175 }
    176 
    177 type jsonGroup struct {
    178 	BlendModes     []Specialized[string]       `json:"blend-mode-specializations,omitempty"`
    179 	BlurMaterial   *float64                    `json:"blur-material,omitempty"`
    180 	BlurMaterials  []Specialized[float64]      `json:"blur-material-specializations,omitempty"`
    181 	Layers         []jsonLayer                 `json:"layers"`
    182 	Lighting       string                      `json:"lighting,omitempty"`
    183 	Shadow         *Shadow                     `json:"shadow,omitempty"`
    184 	Specular       bool                        `json:"specular,omitempty"`
    185 	Translucency   *Translucency               `json:"translucency,omitempty"`
    186 	Translucencies []Specialized[Translucency] `json:"translucency-specializations,omitempty"`
    187 }
    188 
    189 type jsonLayer struct {
    190 	BlendModes []Specialized[string] `json:"blend-mode-specializations,omitempty"`
    191 	Fills      []Specialized[Fill]   `json:"fill-specializations,omitempty"`
    192 	Glass      bool                  `json:"glass"`
    193 	Hidden     bool                  `json:"hidden,omitempty"`
    194 	ImageName  string                `json:"image-name"`
    195 	Name       string                `json:"name"`
    196 	Position   *Position             `json:"position,omitempty"`
    197 }