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
This commit is contained in:
430
svg-maker/svg/parse.go
Normal file
430
svg-maker/svg/parse.go
Normal file
@@ -0,0 +1,430 @@
|
||||
// Package svg turns .svgd text descriptions into SVG images an agent
|
||||
// can verify: build (emit XML), preview (terminal raster) and info
|
||||
// (measurements + warnings).
|
||||
package svg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Elem is one drawing element. Kind decides which fields matter:
|
||||
//
|
||||
// rect Nums: x y w h
|
||||
// circle Nums: cx cy r
|
||||
// ellipse Nums: cx cy rx ry
|
||||
// line Nums: x1 y1 x2 y2
|
||||
// polyline Points
|
||||
// polygon Points
|
||||
// path D (subset: M L H V C Q Z, absolute + relative)
|
||||
// text Nums: x y, Text
|
||||
// group Kids (inherits its Attrs to children)
|
||||
type Elem struct {
|
||||
Kind string
|
||||
Nums []float64
|
||||
Points [][2]float64
|
||||
D string
|
||||
Text string
|
||||
Attrs map[string]string
|
||||
Line int
|
||||
Kids []*Elem
|
||||
}
|
||||
|
||||
// Doc is a parsed .svgd file.
|
||||
type Doc struct {
|
||||
W, H float64
|
||||
Bg string
|
||||
Elems []*Elem
|
||||
}
|
||||
|
||||
// attrKeys maps .svgd attribute names to SVG presentation attributes.
|
||||
// Friendly aliases keep the format short; unknown keys are errors so
|
||||
// typos surface at build time instead of becoming invisible SVG.
|
||||
var attrKeys = map[string]string{
|
||||
"fill": "fill",
|
||||
"stroke": "stroke",
|
||||
"stroke-width": "stroke-width",
|
||||
"width": "stroke-width", // common shorthand on line/path
|
||||
"opacity": "opacity",
|
||||
"fill-opacity": "fill-opacity",
|
||||
"stroke-opacity": "stroke-opacity",
|
||||
"rx": "rx",
|
||||
"ry": "ry",
|
||||
"anchor": "text-anchor",
|
||||
"size": "font-size",
|
||||
"font": "font-family",
|
||||
"dash": "stroke-dasharray",
|
||||
"linecap": "stroke-linecap",
|
||||
"linejoin": "stroke-linejoin",
|
||||
"transform": "transform",
|
||||
"id": "id",
|
||||
}
|
||||
|
||||
// flag attributes expand to fixed key/values.
|
||||
var flagAttrs = map[string][2]string{
|
||||
"bold": {"font-weight", "bold"},
|
||||
"italic": {"font-style", "italic"},
|
||||
}
|
||||
|
||||
type parser struct {
|
||||
defs map[string]string
|
||||
line int
|
||||
}
|
||||
|
||||
// Parse reads a .svgd document.
|
||||
func Parse(src string) (*Doc, error) {
|
||||
p := &parser{defs: map[string]string{}}
|
||||
doc := &Doc{W: 100, H: 100}
|
||||
sawCanvas := false
|
||||
|
||||
stack := []*[]*Elem{&doc.Elems} // group nesting; top = current container
|
||||
groupLines := []int{}
|
||||
|
||||
for i, raw := range strings.Split(src, "\n") {
|
||||
p.line = i + 1
|
||||
line := strings.TrimSpace(raw)
|
||||
if idx := findComment(line); idx >= 0 {
|
||||
line = strings.TrimSpace(line[:idx])
|
||||
}
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
tokens, err := tokenize(line)
|
||||
if err != nil {
|
||||
return nil, p.errf("%v", err)
|
||||
}
|
||||
kind, rest := tokens[0], tokens[1:]
|
||||
|
||||
switch kind {
|
||||
case "canvas":
|
||||
nums, _, err := p.numsAndAttrs(rest, 2, "canvas needs: canvas <width> <height>")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nums[0] <= 0 || nums[1] <= 0 {
|
||||
return nil, p.errf("canvas size must be positive")
|
||||
}
|
||||
doc.W, doc.H = nums[0], nums[1]
|
||||
sawCanvas = true
|
||||
|
||||
case "bg":
|
||||
if len(rest) != 1 {
|
||||
return nil, p.errf("bg needs: bg <color>")
|
||||
}
|
||||
c, err := p.color(rest[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc.Bg = c
|
||||
|
||||
case "def":
|
||||
if len(rest) != 2 {
|
||||
return nil, p.errf("def needs: def <name> <color>")
|
||||
}
|
||||
c, err := p.color(rest[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.defs[rest[0]] = c
|
||||
|
||||
case "group":
|
||||
attrs, err := p.attrs(rest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g := &Elem{Kind: "group", Attrs: attrs, Line: p.line}
|
||||
*stack[len(stack)-1] = append(*stack[len(stack)-1], g)
|
||||
stack = append(stack, &g.Kids)
|
||||
groupLines = append(groupLines, p.line)
|
||||
|
||||
case "end":
|
||||
if len(stack) == 1 {
|
||||
return nil, p.errf("end without group")
|
||||
}
|
||||
stack = stack[:len(stack)-1]
|
||||
groupLines = groupLines[:len(groupLines)-1]
|
||||
|
||||
default:
|
||||
el, err := p.element(kind, rest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
*stack[len(stack)-1] = append(*stack[len(stack)-1], el)
|
||||
}
|
||||
}
|
||||
if len(stack) > 1 {
|
||||
return nil, fmt.Errorf("line %d: group is never closed (missing end)", groupLines[len(groupLines)-1])
|
||||
}
|
||||
if !sawCanvas {
|
||||
return nil, fmt.Errorf("missing canvas line (canvas <width> <height>)")
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func (p *parser) element(kind string, rest []string) (*Elem, error) {
|
||||
el := &Elem{Kind: kind, Line: p.line}
|
||||
var err error
|
||||
switch kind {
|
||||
case "rect":
|
||||
el.Nums, el.Attrs, err = p.numsAndAttrs(rest, 4, "rect needs: rect <x> <y> <w> <h>")
|
||||
case "circle":
|
||||
el.Nums, el.Attrs, err = p.numsAndAttrs(rest, 3, "circle needs: circle <cx> <cy> <r>")
|
||||
case "ellipse":
|
||||
el.Nums, el.Attrs, err = p.numsAndAttrs(rest, 4, "ellipse needs: ellipse <cx> <cy> <rx> <ry>")
|
||||
case "line":
|
||||
el.Nums, el.Attrs, err = p.numsAndAttrs(rest, 4, "line needs: line <x1> <y1> <x2> <y2>")
|
||||
case "polyline", "polygon":
|
||||
el.Points, el.Attrs, err = p.pointsAndAttrs(rest, kind)
|
||||
case "path":
|
||||
var attrs map[string]string
|
||||
var dParts []string
|
||||
split := len(rest)
|
||||
for i, t := range rest {
|
||||
if strings.Contains(t, "=") {
|
||||
split = i
|
||||
break
|
||||
}
|
||||
dParts = append(dParts, t)
|
||||
}
|
||||
attrs, err = p.attrs(rest[split:])
|
||||
el.D, el.Attrs = strings.Join(dParts, " "), attrs
|
||||
if err == nil && el.D == "" {
|
||||
err = p.errf("path needs path data (e.g. path M0,0 L10,10 Z)")
|
||||
}
|
||||
if err == nil {
|
||||
if _, ferr := flattenPath(el.D); ferr != nil {
|
||||
err = p.errf("bad path data: %v", ferr)
|
||||
}
|
||||
}
|
||||
case "text":
|
||||
if len(rest) < 3 {
|
||||
return nil, p.errf(`text needs: text <x> <y> "string" [attrs]`)
|
||||
}
|
||||
var nums []float64
|
||||
nums, err = p.nums(rest[:2], 2, "text needs numeric x y")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
el.Nums = nums
|
||||
el.Text = rest[2]
|
||||
el.Attrs, err = p.attrs(rest[3:])
|
||||
default:
|
||||
return nil, p.errf("unknown element %q (rect, circle, ellipse, line, polyline, polygon, path, text, group, def, bg, canvas)", kind)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return el, nil
|
||||
}
|
||||
|
||||
func (p *parser) numsAndAttrs(tokens []string, n int, hint string) ([]float64, map[string]string, error) {
|
||||
if len(tokens) < n {
|
||||
return nil, nil, p.errf("%s", hint)
|
||||
}
|
||||
nums, err := p.nums(tokens[:n], n, hint)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
attrs, err := p.attrs(tokens[n:])
|
||||
return nums, attrs, err
|
||||
}
|
||||
|
||||
func (p *parser) nums(tokens []string, n int, hint string) ([]float64, error) {
|
||||
if len(tokens) != n {
|
||||
return nil, p.errf("%s", hint)
|
||||
}
|
||||
out := make([]float64, n)
|
||||
for i, t := range tokens {
|
||||
v, err := strconv.ParseFloat(t, 64)
|
||||
if err != nil {
|
||||
return nil, p.errf("%q is not a number — %s", t, hint)
|
||||
}
|
||||
out[i] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *parser) pointsAndAttrs(tokens []string, kind string) ([][2]float64, map[string]string, error) {
|
||||
var pts [][2]float64
|
||||
i := 0
|
||||
for ; i < len(tokens); i++ {
|
||||
t := tokens[i]
|
||||
if strings.Contains(t, "=") {
|
||||
break
|
||||
}
|
||||
xy := strings.Split(t, ",")
|
||||
if len(xy) != 2 {
|
||||
return nil, nil, p.errf("%s point %q must be x,y", kind, t)
|
||||
}
|
||||
x, err1 := strconv.ParseFloat(xy[0], 64)
|
||||
y, err2 := strconv.ParseFloat(xy[1], 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return nil, nil, p.errf("%s point %q must be numeric x,y", kind, t)
|
||||
}
|
||||
pts = append(pts, [2]float64{x, y})
|
||||
}
|
||||
min := 2
|
||||
if kind == "polygon" {
|
||||
min = 3
|
||||
}
|
||||
if len(pts) < min {
|
||||
return nil, nil, p.errf("%s needs at least %d x,y points", kind, min)
|
||||
}
|
||||
attrs, err := p.attrs(tokens[i:])
|
||||
return pts, attrs, err
|
||||
}
|
||||
|
||||
func (p *parser) attrs(tokens []string) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
for _, t := range tokens {
|
||||
if kv, ok := flagAttrs[t]; ok {
|
||||
out[kv[0]] = kv[1]
|
||||
continue
|
||||
}
|
||||
eq := strings.Index(t, "=")
|
||||
if eq <= 0 {
|
||||
return nil, p.errf("expected attribute key=value, got %q", t)
|
||||
}
|
||||
key, val := t[:eq], t[eq+1:]
|
||||
svgKey, ok := attrKeys[key]
|
||||
if !ok {
|
||||
return nil, p.errf("unknown attribute %q", key)
|
||||
}
|
||||
if val == "" {
|
||||
return nil, p.errf("attribute %s has empty value", key)
|
||||
}
|
||||
if key == "fill" || key == "stroke" {
|
||||
c, err := p.color(val)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
val = c
|
||||
}
|
||||
if key == "transform" {
|
||||
// commas keep the value one token: rotate(45,50,50)
|
||||
val = strings.ReplaceAll(val, ",", " ")
|
||||
}
|
||||
out[svgKey] = val
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// color resolves $vars and validates the value.
|
||||
func (p *parser) color(v string) (string, error) {
|
||||
if strings.HasPrefix(v, "$") {
|
||||
c, ok := p.defs[v[1:]]
|
||||
if !ok {
|
||||
return "", p.errf("undefined color variable %s (define it first: def %s #rrggbb)", v, v[1:])
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
if v == "none" || v == "transparent" {
|
||||
return v, nil
|
||||
}
|
||||
if strings.HasPrefix(v, "#") {
|
||||
hexPart := v[1:]
|
||||
if len(hexPart) != 3 && len(hexPart) != 6 {
|
||||
return "", p.errf("color %q must be #rgb or #rrggbb", v)
|
||||
}
|
||||
for _, r := range hexPart {
|
||||
if !strings.ContainsRune("0123456789abcdefABCDEF", r) {
|
||||
return "", p.errf("color %q has non-hex digits", v)
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
for _, r := range v {
|
||||
if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') {
|
||||
return "", p.errf("color %q must be #hex, a CSS color name, none or $var", v)
|
||||
}
|
||||
}
|
||||
return v, nil // CSS named color — trust the renderer
|
||||
}
|
||||
|
||||
func (p *parser) errf(format string, args ...interface{}) error {
|
||||
return fmt.Errorf("line %d: %s", p.line, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// tokenize splits a line on whitespace, keeping "quoted strings" as
|
||||
// single tokens (quotes stripped).
|
||||
func tokenize(line string) ([]string, error) {
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
inQuote := false
|
||||
for _, r := range line {
|
||||
switch {
|
||||
case r == '"':
|
||||
if inQuote {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
inQuote = false
|
||||
} else {
|
||||
inQuote = true
|
||||
}
|
||||
case !inQuote && (r == ' ' || r == '\t'):
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
default:
|
||||
cur.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if inQuote {
|
||||
return nil, fmt.Errorf("unterminated quote")
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("empty line")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// findComment returns the index of a # starting a comment (not inside
|
||||
// quotes, and not part of a color like #fff).
|
||||
func findComment(line string) int {
|
||||
inQuote := false
|
||||
for i, r := range line {
|
||||
if r == '"' {
|
||||
inQuote = !inQuote
|
||||
}
|
||||
if r == '#' && !inQuote {
|
||||
// a color literal follows =, whitespace-then-hex is a comment
|
||||
if i == 0 {
|
||||
return 0
|
||||
}
|
||||
prev := line[i-1]
|
||||
if prev == ' ' || prev == '\t' {
|
||||
// "... # comment" vs "def accent #fff": colors only appear
|
||||
// after def/bg or key=; a bare hex after a def/bg keyword is
|
||||
// data, so only treat as comment if it is not valid hex-ish
|
||||
rest := line[i+1:]
|
||||
stop := strings.IndexAny(rest, " \t")
|
||||
word := rest
|
||||
if stop >= 0 {
|
||||
word = rest[:stop]
|
||||
}
|
||||
if isHexWord(word) {
|
||||
continue
|
||||
}
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func isHexWord(w string) bool {
|
||||
if len(w) != 3 && len(w) != 6 {
|
||||
return false
|
||||
}
|
||||
for _, r := range w {
|
||||
if !strings.ContainsRune("0123456789abcdefABCDEF", r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user