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
136 lines
3.6 KiB
Go
136 lines
3.6 KiB
Go
package svg
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Emit renders the document as an SVG file.
|
|
func Emit(d *Doc) string {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %s %s" width="%s" height="%s">`,
|
|
num(d.W), num(d.H), num(d.W), num(d.H))
|
|
b.WriteString("\n")
|
|
if d.Bg != "" {
|
|
fmt.Fprintf(&b, ` <rect width="100%%" height="100%%" fill="%s"/>`+"\n", d.Bg)
|
|
}
|
|
for _, el := range d.Elems {
|
|
emitElem(&b, el, 1)
|
|
}
|
|
b.WriteString("</svg>\n")
|
|
return b.String()
|
|
}
|
|
|
|
func emitElem(b *strings.Builder, el *Elem, depth int) {
|
|
ind := strings.Repeat(" ", depth)
|
|
attrs := attrString(el.Attrs)
|
|
switch el.Kind {
|
|
case "rect":
|
|
fmt.Fprintf(b, `%s<rect x="%s" y="%s" width="%s" height="%s"%s/>`+"\n",
|
|
ind, num(el.Nums[0]), num(el.Nums[1]), num(el.Nums[2]), num(el.Nums[3]), attrs)
|
|
case "circle":
|
|
fmt.Fprintf(b, `%s<circle cx="%s" cy="%s" r="%s"%s/>`+"\n",
|
|
ind, num(el.Nums[0]), num(el.Nums[1]), num(el.Nums[2]), attrs)
|
|
case "ellipse":
|
|
fmt.Fprintf(b, `%s<ellipse cx="%s" cy="%s" rx="%s" ry="%s"%s/>`+"\n",
|
|
ind, num(el.Nums[0]), num(el.Nums[1]), num(el.Nums[2]), num(el.Nums[3]), attrs)
|
|
case "line":
|
|
fmt.Fprintf(b, `%s<line x1="%s" y1="%s" x2="%s" y2="%s"%s/>`+"\n",
|
|
ind, num(el.Nums[0]), num(el.Nums[1]), num(el.Nums[2]), num(el.Nums[3]), attrs)
|
|
case "polyline", "polygon":
|
|
pts := make([]string, len(el.Points))
|
|
for i, p := range el.Points {
|
|
pts[i] = num(p[0]) + "," + num(p[1])
|
|
}
|
|
fmt.Fprintf(b, `%s<%s points="%s"%s/>`+"\n", ind, el.Kind, strings.Join(pts, " "), attrs)
|
|
case "path":
|
|
fmt.Fprintf(b, `%s<path d="%s"%s/>`+"\n", ind, escape(el.D), attrs)
|
|
case "text":
|
|
fmt.Fprintf(b, `%s<text x="%s" y="%s"%s>%s</text>`+"\n",
|
|
ind, num(el.Nums[0]), num(el.Nums[1]), attrs, escape(el.Text))
|
|
case "group":
|
|
fmt.Fprintf(b, "%s<g%s>\n", ind, attrs)
|
|
for _, kid := range el.Kids {
|
|
emitElem(b, kid, depth+1)
|
|
}
|
|
fmt.Fprintf(b, "%s</g>\n", ind)
|
|
}
|
|
}
|
|
|
|
// defaults SVG would otherwise pick that surprise agents: lines and
|
|
// paths get a visible stroke if none set; polyline defaults fill=none
|
|
// so it doesn't render as a filled blob.
|
|
func effectiveAttrs(el *Elem) map[string]string {
|
|
out := map[string]string{}
|
|
for k, v := range el.Attrs {
|
|
out[k] = v
|
|
}
|
|
switch el.Kind {
|
|
case "line", "polyline":
|
|
if out["stroke"] == "" {
|
|
out["stroke"] = "black"
|
|
}
|
|
if el.Kind == "polyline" && out["fill"] == "" {
|
|
out["fill"] = "none"
|
|
}
|
|
case "path":
|
|
if out["stroke"] == "" && out["fill"] == "" {
|
|
out["stroke"] = "black"
|
|
out["fill"] = "none"
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func attrString(attrs map[string]string) string {
|
|
if len(attrs) == 0 {
|
|
return ""
|
|
}
|
|
keys := make([]string, 0, len(attrs))
|
|
for k := range attrs {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
var b strings.Builder
|
|
for _, k := range keys {
|
|
fmt.Fprintf(&b, ` %s="%s"`, k, escape(attrs[k]))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func num(f float64) string {
|
|
return strconv.FormatFloat(f, 'f', -1, 64)
|
|
}
|
|
|
|
func escape(s string) string {
|
|
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
|
|
return r.Replace(s)
|
|
}
|
|
|
|
// normalize applies the effective attrs (visibility defaults) onto the
|
|
// tree before emit/raster so both outputs agree.
|
|
func (d *Doc) normalize() {
|
|
var walk func(els []*Elem)
|
|
walk = func(els []*Elem) {
|
|
for _, el := range els {
|
|
el.Attrs = effectiveAttrs(el)
|
|
if el.Kind == "group" {
|
|
walk(el.Kids)
|
|
}
|
|
}
|
|
}
|
|
walk(d.Elems)
|
|
}
|
|
|
|
// Build parses src and emits SVG in one step.
|
|
func Build(src string) (string, *Doc, error) {
|
|
doc, err := Parse(src)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
doc.normalize()
|
|
return Emit(doc), doc, nil
|
|
}
|