Files
agent-tools/svg-maker/main.go
claude 36631c7709 svg-maker: svgc - .svgd text format -> SVG for agent-helm sharing
Line-based DSL (shapes, text, groups, color vars) with strict
validation and line-numbered errors, SVG emitter with visibility
defaults, truecolor terminal preview (same half-block technique as
spritec), info with bbox + outside-canvas warnings. Verified
end-to-end: svgc build -> helmd share -> visible through the hub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
2026-08-07 00:37:51 +02:00

177 lines
4.8 KiB
Go

// svgc builds SVG images from .svgd text descriptions — vector
// graphics an agent can author, verify (terminal preview + info) and
// share to agent-helm with `helmd share out.svg`.
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"gitea.brasse-pc.eu/brasse/agent-tools/svg-maker/svg"
)
var version = "dev"
const usage = `svgc - SVG maker for agents
Usage:
svgc build <file.svgd> [-o out.svg] [--preview] [--width N]
svgc preview <file.svgd> [--width N] draw it in the terminal
svgc info <file.svgd> counts, colors, bbox, warnings
svgc example print an annotated example file
svgc version
The .svgd format ('#' comments, one element per line):
canvas 200 120 required: width height
bg #1a2029 optional background
def accent #4f9cf9 named color, use as $accent
rect 10 10 60 40 fill=$accent rx=6
circle 120 40 20 fill=#3fca7c stroke=white stroke-width=2
ellipse 60 90 30 12 fill=gray
line 10 100 190 100 stroke=red width=3
polyline 10,20 30,40 50,10 stroke=white
polygon 20,80 40,60 60,80 fill=#e0a63f
path M10,10 L50,50 Q70,20 90,50 Z stroke=white
text 100 60 "Hello agent-helm" size=14 fill=white anchor=middle bold
group stroke=gray stroke-width=1 group attrs are inherited
line 0 0 10 10
end
Attributes: fill stroke stroke-width|width opacity fill-opacity
stroke-opacity rx ry anchor size font dash linecap linejoin transform
id, plus flags bold italic. Colors: #rgb #rrggbb, CSS names, none.
Show the result in agent-helm: helmd share out.svg --note "diagram"
`
const exampleFile = `# gauge.svgd - example: a small status card
canvas 240 120
bg #12161f
def ok #3fca7c
def frame #262d3a
rect 8 8 224 104 fill=none stroke=$frame stroke-width=2 rx=10
circle 40 60 22 fill=none stroke=$ok stroke-width=6
path M30,60 L38,68 L52,50 stroke=$ok width=5 linecap=round fill=none
text 76 54 "backups" size=13 fill=#8a93a5
text 76 76 "all green" size=17 fill=white bold
`
func main() {
if len(os.Args) < 2 {
fmt.Print(usage)
os.Exit(2)
}
switch os.Args[1] {
case "build":
cmdBuild(os.Args[2:])
case "preview":
cmdPreview(os.Args[2:])
case "info":
cmdInfo(os.Args[2:])
case "example":
fmt.Print(exampleFile)
case "version", "--version", "-v":
fmt.Println("svgc", version)
case "help", "--help", "-h":
fmt.Print(usage)
default:
die("unknown command %q — run 'svgc help'", os.Args[1])
}
}
func cmdBuild(args []string) {
fs := flag.NewFlagSet("build", flag.ExitOnError)
out := fs.String("o", "", "output file (default: input with .svg)")
preview := fs.Bool("preview", false, "also draw the result in the terminal")
width := fs.Int("width", 72, "preview width in characters")
pos := parseInterspersed(fs, args)
if len(pos) != 1 {
die("build takes exactly one .svgd file")
}
xml, doc := load(pos[0])
dst := *out
if dst == "" {
dst = strings.TrimSuffix(pos[0], filepath.Ext(pos[0])) + ".svg"
}
if err := os.WriteFile(dst, []byte(xml), 0o644); err != nil {
die("%v", err)
}
fmt.Printf("wrote %s (%d bytes)\n", dst, len(xml))
fmt.Print(svg.Info(doc))
if *preview {
fmt.Print(svg.RenderGrid(doc, *width).ANSI())
}
}
func cmdPreview(args []string) {
fs := flag.NewFlagSet("preview", flag.ExitOnError)
width := fs.Int("width", 72, "preview width in characters")
pos := parseInterspersed(fs, args)
if len(pos) != 1 {
die("preview takes exactly one .svgd file")
}
_, doc := load(pos[0])
fmt.Print(svg.RenderGrid(doc, *width).ANSI())
}
func cmdInfo(args []string) {
if len(args) != 1 {
die("info takes exactly one .svgd file")
}
_, doc := load(args[0])
fmt.Print(svg.Info(doc))
}
// load reads and builds a .svgd file (attrs normalized), dying with
// the parser's line-numbered error on failure. Returns the SVG XML
// and the parsed document.
func load(path string) (string, *svg.Doc) {
data, err := os.ReadFile(path)
if err != nil {
die("%v", err)
}
xml, doc, err := svg.Build(string(data))
if err != nil {
die("%s: %v", path, err)
}
return xml, doc
}
// parseInterspersed lets flags appear before or after positional args.
func parseInterspersed(fs *flag.FlagSet, args []string) []string {
var flags, pos []string
for i := 0; i < len(args); i++ {
a := args[i]
if len(a) > 1 && a[0] == '-' {
flags = append(flags, a)
name := strings.TrimLeft(a, "-")
if eq := strings.Index(name, "="); eq >= 0 {
continue
}
if f := fs.Lookup(name); f != nil {
if bf, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bf.IsBoolFlag() {
continue
}
}
if i+1 < len(args) {
i++
flags = append(flags, args[i])
}
} else {
pos = append(pos, a)
}
}
fs.Parse(flags)
return pos
}
func die(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "svgc: "+format+"\n", args...)
os.Exit(1)
}