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
177 lines
4.8 KiB
Go
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)
|
|
}
|