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
196 lines
4.2 KiB
Go
196 lines
4.2 KiB
Go
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
|
|
}
|