Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260package main
import ( "bytes" _ "embed" "flag" "fmt" "image" "image/color" "image/png" "math" "os" "path/filepath" "strconv" "strings" "text/template"
"github.com/srwiley/oksvg" "github.com/srwiley/rasterx" "golang.org/x/image/draw" "tangled.org/core/ico")
func main() { var ( size string fillColor string output string templatePath string kind string favicon bool )
flag.StringVar(&templatePath, "template", "", "Path to a dolly go-html template file, or a directory of templates") flag.StringVar(&size, "size", "512", "Output size as WIDTH (height derived from aspect ratio, e.g., 512) or WIDTHxHEIGHT (e.g., 512x512)") flag.StringVar(&fillColor, "color", "#000000", "Fill color in hex format (e.g., #FF5733)") flag.StringVar(&output, "output", "dolly.svg", "Output file path (format detected from extension: .svg, .png, or .ico)") flag.StringVar(&kind, "kind", "logo", "Asset to generate: logo (dolly only) or logotype (dolly + wordmark)") flag.BoolVar(&favicon, "favicon", false, "Embed a prefers-color-scheme style block so the SVG reacts to dark mode (SVG output only)") flag.Parse()
if templatePath == "" { fmt.Fprintf(os.Stderr, "Empty template path") os.Exit(1) }
if kind != "logo" && kind != "logotype" { fmt.Fprintf(os.Stderr, "Invalid kind: %s. Must be logo or logotype\n", kind) os.Exit(1) }
width, height, err := parseSize(size) if err != nil { fmt.Fprintf(os.Stderr, "Error parsing size: %v\n", err) os.Exit(1) }
// Detect format from file extension ext := strings.ToLower(filepath.Ext(output)) format := strings.TrimPrefix(ext, ".")
if format != "svg" && format != "png" && format != "ico" { fmt.Fprintf(os.Stderr, "Invalid file extension: %s. Must be .svg, .png, or .ico\n", ext) os.Exit(1) }
if fillColor != "currentColor" && !isValidHexColor(fillColor) { fmt.Fprintf(os.Stderr, "Invalid color format: %s. Use hex format like #FF5733\n", fillColor) os.Exit(1) }
tpl, err := loadTemplates(templatePath) if err != nil { fmt.Fprintf(os.Stderr, "Failed to load templates from path %s: %v\n", templatePath, err) os.Exit(1) }
if favicon && format != "svg" { fmt.Fprintf(os.Stderr, "-favicon is only supported for .svg output\n") os.Exit(1) }
svgData, err := dolly(tpl, "fragments/dolly/"+kind, fillColor, favicon) if err != nil { fmt.Fprintf(os.Stderr, "Error generating SVG: %v\n", err) os.Exit(1) }
// Derive height from the SVG's aspect ratio when only a width was given if height == 0 && format != "svg" { height, err = deriveHeight(svgData, width) if err != nil { fmt.Fprintf(os.Stderr, "Error deriving height: %v\n", err) os.Exit(1) } }
// Create output directory if it doesn't exist dir := filepath.Dir(output) if dir != "" && dir != "." { if err := os.MkdirAll(dir, 0755); err != nil { fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err) os.Exit(1) } }
switch format { case "svg": err = saveSVG(svgData, output, width, height) case "png": err = savePNG(svgData, output, width, height) case "ico": err = saveICO(svgData, output, width, height) }
if err != nil { fmt.Fprintf(os.Stderr, "Error saving file: %v\n", err) os.Exit(1) }
if format == "svg" { // size is irrelevant for svg output; it scales to its viewBox fmt.Printf("Successfully generated %s\n", output) } else { fmt.Printf("Successfully generated %s (%dx%d)\n", output, width, height) }}
func loadTemplates(path string) (*template.Template, error) { info, err := os.Stat(path) if err != nil { return nil, err }
if info.IsDir() { return template.ParseGlob(filepath.Join(path, "*.html")) }
return template.ParseFiles(path)}
func dolly(tpl *template.Template, name, hexColor string, favicon bool) ([]byte, error) { var svgData bytes.Buffer if err := tpl.ExecuteTemplate(&svgData, name, map[string]any{ "FillColor": hexColor, "Classes": "", "Favicon": favicon, }); err != nil { return nil, err }
return svgData.Bytes(), nil}
func svgToImage(svgData []byte, w, h int) (image.Image, error) { icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) if err != nil { return nil, fmt.Errorf("error parsing SVG: %v", err) }
icon.SetTarget(0, 0, float64(w), float64(h)) rgba := image.NewRGBA(image.Rect(0, 0, w, h)) draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.Transparent}, image.Point{}, draw.Src) scanner := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds()) raster := rasterx.NewDasher(w, h, scanner) icon.Draw(raster, 1.0)
return rgba, nil}
// parseSize parses WIDTH or WIDTHxHEIGHT. A height of 0 means "derive// from the SVG's aspect ratio".func parseSize(size string) (int, int, error) { if !strings.Contains(size, "x") { width, err := strconv.Atoi(size) if err != nil { return 0, 0, fmt.Errorf("invalid width: %v", err) } if width <= 0 { return 0, 0, fmt.Errorf("width must be positive") } return width, 0, nil }
parts := strings.Split(size, "x") if len(parts) != 2 { return 0, 0, fmt.Errorf("invalid size format, use WIDTH or WIDTHxHEIGHT") }
width, err := strconv.Atoi(parts[0]) if err != nil { return 0, 0, fmt.Errorf("invalid width: %v", err) }
height, err := strconv.Atoi(parts[1]) if err != nil { return 0, 0, fmt.Errorf("invalid height: %v", err) }
if width <= 0 || height <= 0 { return 0, 0, fmt.Errorf("width and height must be positive") }
return width, height, nil}
func deriveHeight(svgData []byte, width int) (int, error) { icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) if err != nil { return 0, fmt.Errorf("error parsing SVG: %v", err) }
if icon.ViewBox.W <= 0 || icon.ViewBox.H <= 0 { return 0, fmt.Errorf("SVG has an invalid viewBox (%gx%g)", icon.ViewBox.W, icon.ViewBox.H) }
return int(math.Round(float64(width) * icon.ViewBox.H / icon.ViewBox.W)), nil}
func isValidHexColor(hex string) bool { if len(hex) != 7 || hex[0] != '#' { return false } _, err := strconv.ParseUint(hex[1:], 16, 32) return err == nil}
func saveSVG(svgData []byte, filepath string, _, _ int) error { return os.WriteFile(filepath, svgData, 0644)}
func savePNG(svgData []byte, filepath string, width, height int) error { img, err := svgToImage(svgData, width, height) if err != nil { return err }
f, err := os.Create(filepath) if err != nil { return err } defer f.Close()
return png.Encode(f, img)}
func saveICO(svgData []byte, filepath string, width, height int) error { img, err := svgToImage(svgData, width, height) if err != nil { return err }
icoData, err := ico.ImageToIco(img) if err != nil { return err }
return os.WriteFile(filepath, icoData, 0644)}