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 }