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:
195
svg-maker/svg/path.go
Normal file
195
svg-maker/svg/path.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package svg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// flattenPath turns a path-data subset (M L H V C Q Z, absolute and
|
||||
// relative) into one or more polylines, used for validation, preview
|
||||
// rasterization and measurements. Curves become 16 line segments.
|
||||
func flattenPath(d string) ([][][2]float64, error) {
|
||||
tokens, err := pathTokens(d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var subpaths [][][2]float64
|
||||
var cur [][2]float64
|
||||
var x, y, startX, startY float64
|
||||
i := 0
|
||||
cmd := ""
|
||||
|
||||
need := func(n int) ([]float64, error) {
|
||||
if i+n > len(tokens) {
|
||||
return nil, fmt.Errorf("command %s needs %d numbers", cmd, n)
|
||||
}
|
||||
out := make([]float64, n)
|
||||
for j := 0; j < n; j++ {
|
||||
v, err := strconv.ParseFloat(tokens[i+j], 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("command %s: %q is not a number", cmd, tokens[i+j])
|
||||
}
|
||||
out[j] = v
|
||||
}
|
||||
i += n
|
||||
return out, nil
|
||||
}
|
||||
flush := func() {
|
||||
if len(cur) > 1 {
|
||||
subpaths = append(subpaths, cur)
|
||||
}
|
||||
cur = nil
|
||||
}
|
||||
|
||||
for i < len(tokens) {
|
||||
t := tokens[i]
|
||||
if len(t) == 1 && strings.ContainsAny(t, "MLHVCQZmlhvcqz") {
|
||||
cmd = t
|
||||
i++
|
||||
if cmd == "Z" || cmd == "z" {
|
||||
if len(cur) > 0 {
|
||||
cur = append(cur, [2]float64{startX, startY})
|
||||
x, y = startX, startY
|
||||
}
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
} else if cmd == "" {
|
||||
return nil, fmt.Errorf("path must start with M/m, got %q", t)
|
||||
}
|
||||
// repeated coordinate groups reuse the current command
|
||||
rel := cmd >= "a" // lowercase = relative
|
||||
switch strings.ToUpper(cmd) {
|
||||
case "M":
|
||||
n, err := need(2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rel {
|
||||
n[0] += x
|
||||
n[1] += y
|
||||
}
|
||||
flush()
|
||||
x, y = n[0], n[1]
|
||||
startX, startY = x, y
|
||||
cur = [][2]float64{{x, y}}
|
||||
cmd = map[bool]string{true: "l", false: "L"}[rel] // subsequent pairs are implicit lineto
|
||||
case "L":
|
||||
n, err := need(2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rel {
|
||||
n[0] += x
|
||||
n[1] += y
|
||||
}
|
||||
x, y = n[0], n[1]
|
||||
cur = append(cur, [2]float64{x, y})
|
||||
case "H":
|
||||
n, err := need(1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rel {
|
||||
n[0] += x
|
||||
}
|
||||
x = n[0]
|
||||
cur = append(cur, [2]float64{x, y})
|
||||
case "V":
|
||||
n, err := need(1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rel {
|
||||
n[0] += y
|
||||
}
|
||||
y = n[0]
|
||||
cur = append(cur, [2]float64{x, y})
|
||||
case "Q":
|
||||
n, err := need(4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rel {
|
||||
n[0] += x
|
||||
n[1] += y
|
||||
n[2] += x
|
||||
n[3] += y
|
||||
}
|
||||
for s := 1; s <= 16; s++ {
|
||||
t := float64(s) / 16
|
||||
u := 1 - t
|
||||
px := u*u*x + 2*u*t*n[0] + t*t*n[2]
|
||||
py := u*u*y + 2*u*t*n[1] + t*t*n[3]
|
||||
cur = append(cur, [2]float64{px, py})
|
||||
}
|
||||
x, y = n[2], n[3]
|
||||
case "C":
|
||||
n, err := need(6)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rel {
|
||||
n[0] += x
|
||||
n[1] += y
|
||||
n[2] += x
|
||||
n[3] += y
|
||||
n[4] += x
|
||||
n[5] += y
|
||||
}
|
||||
for s := 1; s <= 16; s++ {
|
||||
t := float64(s) / 16
|
||||
u := 1 - t
|
||||
px := u*u*u*x + 3*u*u*t*n[0] + 3*u*t*t*n[2] + t*t*t*n[4]
|
||||
py := u*u*u*y + 3*u*u*t*n[1] + 3*u*t*t*n[3] + t*t*t*n[5]
|
||||
cur = append(cur, [2]float64{px, py})
|
||||
}
|
||||
x, y = n[4], n[5]
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported path command %q (supported: M L H V C Q Z)", cmd)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
if len(subpaths) == 0 {
|
||||
return nil, fmt.Errorf("path draws nothing")
|
||||
}
|
||||
return subpaths, nil
|
||||
}
|
||||
|
||||
// pathTokens splits path data into command letters and numbers.
|
||||
func pathTokens(d string) ([]string, error) {
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
flush := func() {
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
}
|
||||
for _, r := range d {
|
||||
switch {
|
||||
case r == ' ' || r == '\t' || r == ',':
|
||||
flush()
|
||||
case (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z'):
|
||||
flush()
|
||||
out = append(out, string(r))
|
||||
case (r >= '0' && r <= '9') || r == '.' || r == 'e' || r == 'E':
|
||||
cur.WriteRune(r)
|
||||
case r == '-' || r == '+':
|
||||
// sign starts a new number unless it follows an exponent
|
||||
s := cur.String()
|
||||
if cur.Len() > 0 && !strings.HasSuffix(s, "e") && !strings.HasSuffix(s, "E") {
|
||||
flush()
|
||||
}
|
||||
cur.WriteRune(r)
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected character %q in path data", r)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("empty path data")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user