// 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 ") 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 ") } 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 ") } 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 )") } 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 ") case "circle": el.Nums, el.Attrs, err = p.numsAndAttrs(rest, 3, "circle needs: circle ") case "ellipse": el.Nums, el.Attrs, err = p.numsAndAttrs(rest, 4, "ellipse needs: ellipse ") case "line": el.Nums, el.Attrs, err = p.numsAndAttrs(rest, 4, "line needs: line ") 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 "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 }