icns

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

commit 756ab6e2777342468a076de70cf0dccd0ab361fb
parent 92b4a77e48c6a42886b318eb36f9387d2f274592
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Sun, 20 Sep 2026 16:41:37 -0300

appicon: vary a bundle by appearance

An icon drawn once looks wrong tinted or in the dark, which is why the
manifest holds a list per value rather than a value. The plain field and the
list say the same thing, so only one of the two is written.

Diffstat:
Aappicon/appearance.go | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aappicon/appearance_test.go | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mappicon/appicon.go | 35+++++++++++++++++++++++++++++++++--
Mappicon/appicon_test.go | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mappicon/writer.go | 60+++++++++++++++++++++++++++++++++++++++++++++---------------
5 files changed, 420 insertions(+), 17 deletions(-)

diff --git a/appicon/appearance.go b/appicon/appearance.go @@ -0,0 +1,108 @@ +package appicon + +import ( + "encoding/json" + "fmt" +) + +// Appearance names a variant a value is specialised for. The zero value is +// the appearance the others vary from. +type Appearance string + +// The appearances an icon is drawn in. +const ( + // AppearanceDefault is the appearance the others vary from. + AppearanceDefault Appearance = "" + // AppearanceDark is the icon as drawn in dark mode. + AppearanceDark Appearance = "dark" + // AppearanceTinted is the icon as drawn when the system tints it. + AppearanceTinted Appearance = "tinted" +) + +// Specialized is a value that differs by appearance. An entry with the +// default appearance is the one the others vary from, and is written first. +type Specialized[T any] struct { + Appearance Appearance `json:"appearance,omitempty"` + Value T `json:"value"` +} + +// For returns the value specialised for an appearance, falling back to the +// default entry. The boolean reports whether either was found. +func For[T any](values []Specialized[T], a Appearance) (T, bool) { + var ( + out T + found bool + ) + for _, v := range values { + if v.Appearance == a { + return v.Value, true + } + if v.Appearance == AppearanceDefault { + out, found = v.Value, true + } + } + return out, found +} + +// Fill is how a surface is filled. Exactly one of the three is written, in +// the order they are listed here. +type Fill struct { + // Gradient is a list of colour stops, first to last, each a colour space + // and its components, such as "srgb:1.00000,0.25279,1.00000,1.00000". + Gradient []string + // Solid is a single colour, written the same way as a stop. + Solid string + // Name is a fill the system provides, such as "automatic", + // "system-light" or "system-dark". + Name string +} + +// NamedFill returns a fill the system provides. +func NamedFill(name string) Fill { return Fill{Name: name} } + +// SolidFill returns a fill of one colour, such as +// SolidFill("srgb:1.00000,0.25279,1.00000,1.00000"). +func SolidFill(colour string) Fill { return Fill{Solid: colour} } + +// GradientFill returns a linear gradient through the colours given. +func GradientFill(stops ...string) Fill { return Fill{Gradient: stops} } + +// MarshalJSON writes the fill the way the manifest holds it: a bare string +// for a named fill, and an object naming the kind for the others. +func (f Fill) MarshalJSON() ([]byte, error) { + switch { + case len(f.Gradient) > 0: + return json.Marshal(map[string][]string{"linear-gradient": f.Gradient}) + case f.Solid != "": + return json.Marshal(map[string]string{"solid": f.Solid}) + case f.Name != "": + return json.Marshal(f.Name) + } + return nil, fmt.Errorf("%w: a fill names no colour", ErrEmptyFill) +} + +// UnmarshalJSON reads a fill written either way. +func (f *Fill) UnmarshalJSON(data []byte) error { + var name string + if err := json.Unmarshal(data, &name); err == nil { + *f = Fill{Name: name} + return nil + } + var object struct { + Gradient []string `json:"linear-gradient"` + Solid string `json:"solid"` + } + if err := json.Unmarshal(data, &object); err != nil { + return fmt.Errorf("reading a fill: %w", err) + } + *f = Fill{Gradient: object.Gradient, Solid: object.Solid} + return nil +} + +// Position is where a layer sits relative to the space it is drawn in. +type Position struct { + // Scale multiplies the layer's size, 1 leaving it as it is. + Scale float64 `json:"scale"` + // Translation moves it, across and down, in points. + Translation [2]float64 `json:"translation-in-points"` +} diff --git a/appicon/appearance_test.go b/appicon/appearance_test.go @@ -0,0 +1,105 @@ +package appicon + +import ( + "encoding/json" + "errors" + "reflect" + "testing" +) + +func TestFillWritesTheShapeItNames(t *testing.T) { + for _, tt := range []struct { + name string + fill Fill + want string + }{ + {"a fill the system provides", NamedFill("automatic"), `"automatic"`}, + {"one colour", SolidFill("srgb:1.00000,0.25279,1.00000,1.00000"), `{"solid":"srgb:1.00000,0.25279,1.00000,1.00000"}`}, + { + name: "a gradient", + fill: GradientFill("display-p3:0.90000,0.90000,0.90000,0.83000", "srgb:1.00000,1.00000,1.00000,0.41987"), + want: `{"linear-gradient":["display-p3:0.90000,0.90000,0.90000,0.83000","srgb:1.00000,1.00000,1.00000,0.41987"]}`, + }, + // A gradient wins over the other two, so a fill given more than one + // is written the way it is documented to be. + {"a gradient beside a colour", Fill{Gradient: []string{"srgb:0,0,0,1"}, Solid: "srgb:1,1,1,1"}, `{"linear-gradient":["srgb:0,0,0,1"]}`}, + } { + t.Run(tt.name, func(t *testing.T) { + got, err := json.Marshal(tt.fill) + if err != nil { + t.Fatalf("writing: %v", err) + } + if string(got) != tt.want { + t.Errorf("wrote %s, want %s", got, tt.want) + } + }) + } +} + +func TestFillRejectsNamingNothing(t *testing.T) { + if _, err := json.Marshal(Fill{}); !errors.Is(err, ErrEmptyFill) { + t.Errorf("writing an empty fill returned %v, want ErrEmptyFill", err) + } +} + +func TestFillReadsBackWhatItWrote(t *testing.T) { + for _, want := range []Fill{ + NamedFill("system-dark"), + SolidFill("srgb:1,0,0,1"), + GradientFill("srgb:1,1,1,1", "srgb:0,0,0,1"), + } { + data, err := json.Marshal(want) + if err != nil { + t.Fatalf("writing %v: %v", want, err) + } + var got Fill + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("reading %s: %v", data, err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("read %v from %s, want %v", got, data, want) + } + } +} + +func TestSpecializedWritesTheAppearanceOnlyWhenItHasOne(t *testing.T) { + data, err := json.Marshal([]Specialized[Fill]{ + {Value: NamedFill("system-light")}, + {Appearance: AppearanceDark, Value: NamedFill("system-dark")}, + }) + if err != nil { + t.Fatalf("writing: %v", err) + } + want := `[{"value":"system-light"},{"appearance":"dark","value":"system-dark"}]` + if string(data) != want { + t.Errorf("wrote %s, want %s", data, want) + } +} + +func TestForFallsBackToTheDefault(t *testing.T) { + values := []Specialized[string]{ + {Value: "normal"}, + {Appearance: AppearanceTinted, Value: "lighten"}, + } + for _, tt := range []struct { + appearance Appearance + want string + }{ + {AppearanceDefault, "normal"}, + {AppearanceTinted, "lighten"}, + {AppearanceDark, "normal"}, + } { + got, ok := For(values, tt.appearance) + if !ok || got != tt.want { + t.Errorf("For(%q) = %q, %v; want %q, true", tt.appearance, got, ok, tt.want) + } + } + if _, ok := For[string](nil, AppearanceDark); ok { + t.Error("For on nothing reported a value") + } + // Without a default there is nothing to fall back to. + only := []Specialized[string]{{Appearance: AppearanceTinted, Value: "lighten"}} + if _, ok := For(only, AppearanceDark); ok { + t.Error("For fell back to an appearance that is not the default") + } +} diff --git a/appicon/appicon.go b/appicon/appicon.go @@ -24,13 +24,20 @@ var ( // ErrDuplicateLayer means two layers share a name, so one image would // overwrite the other. ErrDuplicateLayer = errors.New("two layers share a name") + // ErrEmptyFill means a fill names neither a gradient, a colour nor a + // fill the system provides. + ErrEmptyFill = errors.New("fill names no colour") ) // Bundle is an icon: a manifest naming layers, and the images they hold. type Bundle struct { // Fill is how the space behind the layers is filled. Empty is written as - // "automatic", which leaves the choice to the compiler. + // "automatic", which leaves the choice to the compiler, and is ignored + // when Fills is given. Fill string + // Fills is the fill specialised by appearance, for an icon whose + // background differs in dark mode or when tinted. + Fills []Specialized[Fill] // Groups are composited back to front. Groups []Group // Platforms are the platforms the icon offers square artwork for. Empty @@ -46,8 +53,23 @@ type Group struct { // manifest. Shadow *Shadow // Translucency is how far the group lets the material behind it through. - // Nil leaves it out of the manifest. + // Nil leaves it out of the manifest, and it is ignored when + // Translucencies is given. Translucency *Translucency + // Translucencies is the translucency specialised by appearance. + Translucencies []Specialized[Translucency] + // Lighting is how the group is lit, such as "individual" or "combined". + Lighting string + // Specular asks for a specular highlight across the group. + Specular bool + // BlurMaterial is the material the group blurs what is behind it with. + // Nil leaves it out, and it is ignored when BlurMaterials is given. + BlurMaterial *float64 + // BlurMaterials is the blur material specialised by appearance. + BlurMaterials []Specialized[float64] + // BlendModes is how the group composites, specialised by appearance, + // such as "normal" or "lighten". + BlendModes []Specialized[string] } // Layer is one image in the stack. @@ -59,6 +81,15 @@ type Layer struct { // Glass asks for the layer to be treated as glass, which the compiler // lights and refracts rather than drawing flat. Glass bool + // Hidden keeps the layer in the manifest without drawing it. + Hidden bool + // Position is where the layer sits. Nil leaves it where it was drawn. + Position *Position + // Fills is the layer's own fill, specialised by appearance. A layer + // given one is filled with it rather than with its image's colour. + Fills []Specialized[Fill] + // BlendModes is how the layer composites, specialised by appearance. + BlendModes []Specialized[string] } // Shadow is the shadow a group casts. diff --git a/appicon/appicon_test.go b/appicon/appicon_test.go @@ -143,6 +143,135 @@ func TestGlassAndSeveralGroups(t *testing.T) { } } +// TestSpecializedValuesReplaceThePlainOnes covers the pairs where the +// manifest holds either a value or a list of them specialised by appearance, +// never both. +func TestSpecializedValuesReplaceThePlainOnes(t *testing.T) { + material := 1.0 + b := Bundle{ + Fill: "automatic", + Fills: []Specialized[Fill]{{Value: NamedFill("system-light")}}, + Groups: []Group{{ + Layers: []Layer{{Name: "One", Image: art(16)}}, + Translucency: &Translucency{Enabled: true, Value: 0.5}, + Translucencies: []Specialized[Translucency]{{Value: Translucency{Enabled: false, Value: 0.8}}}, + BlurMaterial: &material, + BlurMaterials: []Specialized[float64]{{Value: 2}}, + }}, + } + files, err := b.Files() + if err != nil { + t.Fatalf("rendering: %v", err) + } + doc := decode(t, files) + if _, ok := doc["fill"]; ok { + t.Errorf("wrote a plain fill beside the specialised one: %v", doc["fill"]) + } + if _, ok := doc["fill-specializations"]; !ok { + t.Error("did not write the specialised fill") + } + group := doc["groups"].([]any)[0].(map[string]any) + for _, plain := range []string{"translucency", "blur-material"} { + if _, ok := group[plain]; ok { + t.Errorf("wrote a plain %s beside the specialised one: %v", plain, group[plain]) + } + } + for _, special := range []string{"translucency-specializations", "blur-material-specializations"} { + if _, ok := group[special]; !ok { + t.Errorf("did not write %s", special) + } + } +} + +// TestRichManifestMatchesTheShippingShape builds the manifest a composed icon +// needs and holds its keys against the ones a shipping app's bundle uses. +func TestRichManifestMatchesTheShippingShape(t *testing.T) { + b := Bundle{ + Fills: []Specialized[Fill]{ + {Value: NamedFill("system-light")}, + {Appearance: AppearanceDark, Value: NamedFill("system-dark")}, + }, + Groups: []Group{{ + BlendModes: []Specialized[string]{{Appearance: AppearanceTinted, Value: "normal"}}, + Lighting: "individual", + Specular: true, + Shadow: &Shadow{Kind: "layer-color", Opacity: 0.5}, + Translucencies: []Specialized[Translucency]{ + {Value: Translucency{Enabled: true, Value: 0.84}}, + {Appearance: AppearanceTinted, Value: Translucency{Enabled: false, Value: 0.84}}, + }, + Layers: []Layer{{ + Name: "Cube", + Image: art(32), + Glass: true, + Position: &Position{Scale: 1.24, Translation: [2]float64{0, 0}}, + Fills: []Specialized[Fill]{ + {Appearance: AppearanceDark, Value: NamedFill("automatic")}, + {Appearance: AppearanceTinted, Value: GradientFill( + "display-p3:0.90000,0.90000,0.90000,0.83000", + "srgb:1.00000,1.00000,1.00000,0.41987", + )}, + }, + BlendModes: []Specialized[string]{{Appearance: AppearanceDark, Value: "lighten"}}, + }}, + }}, + } + files, err := b.Files() + if err != nil { + t.Fatalf("rendering: %v", err) + } + doc := decode(t, files) + group := doc["groups"].([]any)[0].(map[string]any) + for _, key := range []string{ + "blend-mode-specializations", "lighting", "shadow", "specular", + "translucency-specializations", "layers", + } { + if _, ok := group[key]; !ok { + t.Errorf("group is missing %s", key) + } + } + layer := group["layers"].([]any)[0].(map[string]any) + for _, key := range []string{ + "blend-mode-specializations", "fill-specializations", "glass", + "image-name", "name", "position", + } { + if _, ok := layer[key]; !ok { + t.Errorf("layer is missing %s", key) + } + } + // A gradient is the one fill written as an object naming its kind. + tinted := layer["fill-specializations"].([]any)[1].(map[string]any) + gradient, ok := tinted["value"].(map[string]any)["linear-gradient"].([]any) + if !ok || len(gradient) != 2 { + t.Errorf("gradient is %v, want two stops", tinted["value"]) + } + if got := layer["position"].(map[string]any)["scale"]; got != 1.24 { + t.Errorf("scale is %v, want 1.24", got) + } +} + +// TestHiddenIsWrittenOnlyWhenSet keeps a manifest from carrying a key for +// every default a layer did not set. +func TestHiddenIsWrittenOnlyWhenSet(t *testing.T) { + files, err := New(art(16), "Plain").Files() + if err != nil { + t.Fatalf("rendering: %v", err) + } + layer := decode(t, files)["groups"].([]any)[0].(map[string]any)["layers"].([]any)[0].(map[string]any) + if _, ok := layer["hidden"]; ok { + t.Errorf("wrote hidden for a layer that is not: %v", layer) + } + b := Bundle{Groups: []Group{{Layers: []Layer{{Name: "Gone", Image: art(16), Hidden: true}}}}} + files, err = b.Files() + if err != nil { + t.Fatalf("rendering: %v", err) + } + layer = decode(t, files)["groups"].([]any)[0].(map[string]any)["layers"].([]any)[0].(map[string]any) + if layer["hidden"] != true { + t.Errorf("hidden is %v, want true", layer["hidden"]) + } +} + func TestFilesRejectsWhatItCannotWrite(t *testing.T) { for _, tt := range []struct { name string diff --git a/appicon/writer.go b/appicon/writer.go @@ -74,9 +74,14 @@ func (b Bundle) document() (icon, [][]string, error) { } doc := icon{ Fill: b.Fill, + Fills: b.Fills, Platforms: platforms{Squares: b.Platforms}, } - if doc.Fill == "" { + // A specialised fill says everything the plain one does, so only one of + // the two is written. + if len(doc.Fills) > 0 { + doc.Fill = "" + } else if doc.Fill == "" { doc.Fill = FillAutomatic } if len(doc.Platforms.Squares) == 0 { @@ -89,7 +94,17 @@ func (b Bundle) document() (icon, [][]string, error) { ) for i, group := range b.Groups { names[i] = make([]string, len(group.Layers)) - out := jsonGroup{Layers: make([]jsonLayer, len(group.Layers))} + out := jsonGroup{ + BlendModes: group.BlendModes, + BlurMaterials: group.BlurMaterials, + Layers: make([]jsonLayer, len(group.Layers)), + Lighting: group.Lighting, + Specular: group.Specular, + Translucencies: group.Translucencies, + } + if len(group.BlurMaterials) == 0 { + out.BlurMaterial = group.BlurMaterial + } for j, layer := range group.Layers { if layer.Image == nil { return icon{}, nil, fmt.Errorf("layer %q has no image", layer.Name) @@ -103,9 +118,13 @@ func (b Bundle) document() (icon, [][]string, error) { file := name + ".png" names[i][j] = file out.Layers[j] = jsonLayer{ - Glass: layer.Glass, - ImageName: file, - Name: name, + BlendModes: layer.BlendModes, + Fills: layer.Fills, + Glass: layer.Glass, + Hidden: layer.Hidden, + ImageName: file, + Name: name, + Position: layer.Position, } } if s := group.Shadow; s != nil { @@ -115,7 +134,7 @@ func (b Bundle) document() (icon, [][]string, error) { } out.Shadow = &jsonShadow{Kind: kind, Opacity: s.Opacity} } - if t := group.Translucency; t != nil { + if t := group.Translucency; t != nil && len(group.Translucencies) == 0 { out.Translucency = &jsonTranslucency{Enabled: t.Enabled, Value: t.Value} } doc.Groups = append(doc.Groups, out) @@ -145,9 +164,10 @@ func layerName(name string, position int) string { // The manifest's shape. The names are the ones Icon Composer writes, which // is what actool reads. type icon struct { - Fill string `json:"fill"` - Groups []jsonGroup `json:"groups"` - Platforms platforms `json:"supported-platforms"` + Fill string `json:"fill,omitempty"` + Fills []Specialized[Fill] `json:"fill-specializations,omitempty"` + Groups []jsonGroup `json:"groups"` + Platforms platforms `json:"supported-platforms"` } type platforms struct { @@ -155,15 +175,25 @@ type platforms struct { } type jsonGroup struct { - Layers []jsonLayer `json:"layers"` - Shadow *jsonShadow `json:"shadow,omitempty"` - Translucency *jsonTranslucency `json:"translucency,omitempty"` + BlendModes []Specialized[string] `json:"blend-mode-specializations,omitempty"` + BlurMaterial *float64 `json:"blur-material,omitempty"` + BlurMaterials []Specialized[float64] `json:"blur-material-specializations,omitempty"` + Layers []jsonLayer `json:"layers"` + Lighting string `json:"lighting,omitempty"` + Shadow *jsonShadow `json:"shadow,omitempty"` + Specular bool `json:"specular,omitempty"` + Translucency *jsonTranslucency `json:"translucency,omitempty"` + Translucencies []Specialized[Translucency] `json:"translucency-specializations,omitempty"` } type jsonLayer struct { - Glass bool `json:"glass"` - ImageName string `json:"image-name"` - Name string `json:"name"` + BlendModes []Specialized[string] `json:"blend-mode-specializations,omitempty"` + Fills []Specialized[Fill] `json:"fill-specializations,omitempty"` + Glass bool `json:"glass"` + Hidden bool `json:"hidden,omitempty"` + ImageName string `json:"image-name"` + Name string `json:"name"` + Position *Position `json:"position,omitempty"` } type jsonShadow struct {