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
431 lines
10 KiB
Go
431 lines
10 KiB
Go
// 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
|
|
}
|