Monorepo for Tangled
tangled.org
1package main
2
3import (
4 "bytes"
5 _ "embed"
6 "flag"
7 "fmt"
8 "image"
9 "image/color"
10 "image/png"
11 "math"
12 "os"
13 "path/filepath"
14 "strconv"
15 "strings"
16 "text/template"
17
18 "github.com/srwiley/oksvg"
19 "github.com/srwiley/rasterx"
20 "golang.org/x/image/draw"
21 "tangled.org/core/ico"
22)
23
24func main() {
25 var (
26 size string
27 fillColor string
28 output string
29 templatePath string
30 kind string
31 favicon bool
32 )
33
34 flag.StringVar(&templatePath, "template", "", "Path to a dolly go-html template file, or a directory of templates")
35 flag.StringVar(&size, "size", "512", "Output size as WIDTH (height derived from aspect ratio, e.g., 512) or WIDTHxHEIGHT (e.g., 512x512)")
36 flag.StringVar(&fillColor, "color", "#000000", "Fill color in hex format (e.g., #FF5733)")
37 flag.StringVar(&output, "output", "dolly.svg", "Output file path (format detected from extension: .svg, .png, or .ico)")
38 flag.StringVar(&kind, "kind", "logo", "Asset to generate: logo (dolly only) or logotype (dolly + wordmark)")
39 flag.BoolVar(&favicon, "favicon", false, "Embed a prefers-color-scheme style block so the SVG reacts to dark mode (SVG output only)")
40 flag.Parse()
41
42 if templatePath == "" {
43 fmt.Fprintf(os.Stderr, "Empty template path")
44 os.Exit(1)
45 }
46
47 if kind != "logo" && kind != "logotype" {
48 fmt.Fprintf(os.Stderr, "Invalid kind: %s. Must be logo or logotype\n", kind)
49 os.Exit(1)
50 }
51
52 width, height, err := parseSize(size)
53 if err != nil {
54 fmt.Fprintf(os.Stderr, "Error parsing size: %v\n", err)
55 os.Exit(1)
56 }
57
58 // Detect format from file extension
59 ext := strings.ToLower(filepath.Ext(output))
60 format := strings.TrimPrefix(ext, ".")
61
62 if format != "svg" && format != "png" && format != "ico" {
63 fmt.Fprintf(os.Stderr, "Invalid file extension: %s. Must be .svg, .png, or .ico\n", ext)
64 os.Exit(1)
65 }
66
67 if fillColor != "currentColor" && !isValidHexColor(fillColor) {
68 fmt.Fprintf(os.Stderr, "Invalid color format: %s. Use hex format like #FF5733\n", fillColor)
69 os.Exit(1)
70 }
71
72 tpl, err := loadTemplates(templatePath)
73 if err != nil {
74 fmt.Fprintf(os.Stderr, "Failed to load templates from path %s: %v\n", templatePath, err)
75 os.Exit(1)
76 }
77
78 if favicon && format != "svg" {
79 fmt.Fprintf(os.Stderr, "-favicon is only supported for .svg output\n")
80 os.Exit(1)
81 }
82
83 svgData, err := dolly(tpl, "fragments/dolly/"+kind, fillColor, favicon)
84 if err != nil {
85 fmt.Fprintf(os.Stderr, "Error generating SVG: %v\n", err)
86 os.Exit(1)
87 }
88
89 // Derive height from the SVG's aspect ratio when only a width was given
90 if height == 0 && format != "svg" {
91 height, err = deriveHeight(svgData, width)
92 if err != nil {
93 fmt.Fprintf(os.Stderr, "Error deriving height: %v\n", err)
94 os.Exit(1)
95 }
96 }
97
98 // Create output directory if it doesn't exist
99 dir := filepath.Dir(output)
100 if dir != "" && dir != "." {
101 if err := os.MkdirAll(dir, 0755); err != nil {
102 fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err)
103 os.Exit(1)
104 }
105 }
106
107 switch format {
108 case "svg":
109 err = saveSVG(svgData, output, width, height)
110 case "png":
111 err = savePNG(svgData, output, width, height)
112 case "ico":
113 err = saveICO(svgData, output, width, height)
114 }
115
116 if err != nil {
117 fmt.Fprintf(os.Stderr, "Error saving file: %v\n", err)
118 os.Exit(1)
119 }
120
121 if format == "svg" {
122 // size is irrelevant for svg output; it scales to its viewBox
123 fmt.Printf("Successfully generated %s\n", output)
124 } else {
125 fmt.Printf("Successfully generated %s (%dx%d)\n", output, width, height)
126 }
127}
128
129func loadTemplates(path string) (*template.Template, error) {
130 info, err := os.Stat(path)
131 if err != nil {
132 return nil, err
133 }
134
135 if info.IsDir() {
136 return template.ParseGlob(filepath.Join(path, "*.html"))
137 }
138
139 return template.ParseFiles(path)
140}
141
142func dolly(tpl *template.Template, name, hexColor string, favicon bool) ([]byte, error) {
143 var svgData bytes.Buffer
144 if err := tpl.ExecuteTemplate(&svgData, name, map[string]any{
145 "FillColor": hexColor,
146 "Classes": "",
147 "Favicon": favicon,
148 }); err != nil {
149 return nil, err
150 }
151
152 return svgData.Bytes(), nil
153}
154
155func svgToImage(svgData []byte, w, h int) (image.Image, error) {
156 icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData))
157 if err != nil {
158 return nil, fmt.Errorf("error parsing SVG: %v", err)
159 }
160
161 icon.SetTarget(0, 0, float64(w), float64(h))
162 rgba := image.NewRGBA(image.Rect(0, 0, w, h))
163 draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.Transparent}, image.Point{}, draw.Src)
164 scanner := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())
165 raster := rasterx.NewDasher(w, h, scanner)
166 icon.Draw(raster, 1.0)
167
168 return rgba, nil
169}
170
171// parseSize parses WIDTH or WIDTHxHEIGHT. A height of 0 means "derive
172// from the SVG's aspect ratio".
173func parseSize(size string) (int, int, error) {
174 if !strings.Contains(size, "x") {
175 width, err := strconv.Atoi(size)
176 if err != nil {
177 return 0, 0, fmt.Errorf("invalid width: %v", err)
178 }
179 if width <= 0 {
180 return 0, 0, fmt.Errorf("width must be positive")
181 }
182 return width, 0, nil
183 }
184
185 parts := strings.Split(size, "x")
186 if len(parts) != 2 {
187 return 0, 0, fmt.Errorf("invalid size format, use WIDTH or WIDTHxHEIGHT")
188 }
189
190 width, err := strconv.Atoi(parts[0])
191 if err != nil {
192 return 0, 0, fmt.Errorf("invalid width: %v", err)
193 }
194
195 height, err := strconv.Atoi(parts[1])
196 if err != nil {
197 return 0, 0, fmt.Errorf("invalid height: %v", err)
198 }
199
200 if width <= 0 || height <= 0 {
201 return 0, 0, fmt.Errorf("width and height must be positive")
202 }
203
204 return width, height, nil
205}
206
207func deriveHeight(svgData []byte, width int) (int, error) {
208 icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData))
209 if err != nil {
210 return 0, fmt.Errorf("error parsing SVG: %v", err)
211 }
212
213 if icon.ViewBox.W <= 0 || icon.ViewBox.H <= 0 {
214 return 0, fmt.Errorf("SVG has an invalid viewBox (%gx%g)", icon.ViewBox.W, icon.ViewBox.H)
215 }
216
217 return int(math.Round(float64(width) * icon.ViewBox.H / icon.ViewBox.W)), nil
218}
219
220func isValidHexColor(hex string) bool {
221 if len(hex) != 7 || hex[0] != '#' {
222 return false
223 }
224 _, err := strconv.ParseUint(hex[1:], 16, 32)
225 return err == nil
226}
227
228func saveSVG(svgData []byte, filepath string, _, _ int) error {
229 return os.WriteFile(filepath, svgData, 0644)
230}
231
232func savePNG(svgData []byte, filepath string, width, height int) error {
233 img, err := svgToImage(svgData, width, height)
234 if err != nil {
235 return err
236 }
237
238 f, err := os.Create(filepath)
239 if err != nil {
240 return err
241 }
242 defer f.Close()
243
244 return png.Encode(f, img)
245}
246
247func saveICO(svgData []byte, filepath string, width, height int) error {
248 img, err := svgToImage(svgData, width, height)
249 if err != nil {
250 return err
251 }
252
253 icoData, err := ico.ImageToIco(img)
254 if err != nil {
255 return err
256 }
257
258 return os.WriteFile(filepath, icoData, 0644)
259}