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:
332
svg-maker/svg/raster.go
Normal file
332
svg-maker/svg/raster.go
Normal file
@@ -0,0 +1,332 @@
|
||||
package svg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Raster renders the document to a small RGBA grid so an agent can see
|
||||
// roughly what it drew without an image viewer. Painter's algorithm in
|
||||
// document order, 2x2 supersampling, honors fill/stroke/opacity.
|
||||
// Text is approximated by its baseline and an underline-box (real
|
||||
// glyph rendering is out of scope for a preview).
|
||||
type Raster struct {
|
||||
W, H int
|
||||
Pix [][4]float64 // r g b a, premultiplied-ish blend target
|
||||
}
|
||||
|
||||
type rgba struct{ r, g, b, a float64 }
|
||||
|
||||
// RenderGrid rasterizes doc to cols pixels wide (rows follow aspect).
|
||||
func RenderGrid(d *Doc, cols int) *Raster {
|
||||
if cols < 8 {
|
||||
cols = 8
|
||||
}
|
||||
if cols > 400 {
|
||||
cols = 400
|
||||
}
|
||||
rows := int(math.Round(float64(cols) * d.H / d.W))
|
||||
if rows < 1 {
|
||||
rows = 1
|
||||
}
|
||||
if rows > 400 {
|
||||
rows = 400
|
||||
}
|
||||
r := &Raster{W: cols, H: rows, Pix: make([][4]float64, cols*rows)}
|
||||
bg := parseColor(d.Bg)
|
||||
if d.Bg == "" {
|
||||
bg = rgba{0, 0, 0, 0}
|
||||
}
|
||||
for i := range r.Pix {
|
||||
r.Pix[i] = [4]float64{bg.r, bg.g, bg.b, bg.a}
|
||||
}
|
||||
scaleX := d.W / float64(cols)
|
||||
scaleY := d.H / float64(rows)
|
||||
|
||||
var walk func(els []*Elem, inherited map[string]string)
|
||||
walk = func(els []*Elem, inherited map[string]string) {
|
||||
for _, el := range els {
|
||||
attrs := merged(inherited, el.Attrs)
|
||||
if el.Kind == "group" {
|
||||
walk(el.Kids, attrs)
|
||||
continue
|
||||
}
|
||||
r.drawElem(el, attrs, scaleX, scaleY)
|
||||
}
|
||||
}
|
||||
walk(d.Elems, nil)
|
||||
return r
|
||||
}
|
||||
|
||||
func merged(parent, child map[string]string) map[string]string {
|
||||
if parent == nil {
|
||||
return child
|
||||
}
|
||||
out := map[string]string{}
|
||||
for k, v := range parent {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range child {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Raster) drawElem(el *Elem, attrs map[string]string, sx, sy float64) {
|
||||
fill, hasFill := paint(attrs, "fill", el.Kind)
|
||||
stroke, hasStroke := paint(attrs, "stroke", el.Kind)
|
||||
sw := attrFloat(attrs, "stroke-width", 1)
|
||||
opacity := attrFloat(attrs, "opacity", 1)
|
||||
|
||||
inFill, inStroke := coverageFuncs(el, sw, attrs)
|
||||
if inFill == nil && inStroke == nil {
|
||||
return
|
||||
}
|
||||
for py := 0; py < r.H; py++ {
|
||||
for px := 0; px < r.W; px++ {
|
||||
var fillCov, strokeCov float64
|
||||
for _, dx := range []float64{0.25, 0.75} {
|
||||
for _, dy := range []float64{0.25, 0.75} {
|
||||
ux := (float64(px) + dx) * sx
|
||||
uy := (float64(py) + dy) * sy
|
||||
if hasFill && inFill != nil && inFill(ux, uy) {
|
||||
fillCov += 0.25
|
||||
}
|
||||
if hasStroke && inStroke != nil && inStroke(ux, uy) {
|
||||
strokeCov += 0.25
|
||||
}
|
||||
}
|
||||
}
|
||||
if fillCov > 0 {
|
||||
r.blend(px, py, fill, fillCov*opacity*attrFloat(attrs, "fill-opacity", 1))
|
||||
}
|
||||
if strokeCov > 0 {
|
||||
r.blend(px, py, stroke, strokeCov*opacity*attrFloat(attrs, "stroke-opacity", 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// coverageFuncs returns point-inside tests for the fill body and the
|
||||
// stroke band of an element, in user coordinates.
|
||||
func coverageFuncs(el *Elem, sw float64, attrs map[string]string) (inFill, inStroke func(x, y float64) bool) {
|
||||
half := sw / 2
|
||||
switch el.Kind {
|
||||
case "rect":
|
||||
x0, y0, w, h := el.Nums[0], el.Nums[1], el.Nums[2], el.Nums[3]
|
||||
inFill = func(x, y float64) bool { return x >= x0 && x <= x0+w && y >= y0 && y <= y0+h }
|
||||
inStroke = func(x, y float64) bool {
|
||||
near := func(v, edge float64) bool { return math.Abs(v-edge) <= half }
|
||||
inX := x >= x0-half && x <= x0+w+half
|
||||
inY := y >= y0-half && y <= y0+h+half
|
||||
return (inX && (near(y, y0) || near(y, y0+h))) || (inY && (near(x, x0) || near(x, x0+w)))
|
||||
}
|
||||
case "circle":
|
||||
cx, cy, rad := el.Nums[0], el.Nums[1], el.Nums[2]
|
||||
inFill = func(x, y float64) bool { return math.Hypot(x-cx, y-cy) <= rad }
|
||||
inStroke = func(x, y float64) bool { return math.Abs(math.Hypot(x-cx, y-cy)-rad) <= half }
|
||||
case "ellipse":
|
||||
cx, cy, rx, ry := el.Nums[0], el.Nums[1], el.Nums[2], el.Nums[3]
|
||||
if rx <= 0 || ry <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
norm := func(x, y float64) float64 {
|
||||
dx, dy := (x-cx)/rx, (y-cy)/ry
|
||||
return math.Sqrt(dx*dx + dy*dy)
|
||||
}
|
||||
inFill = func(x, y float64) bool { return norm(x, y) <= 1 }
|
||||
inStroke = func(x, y float64) bool {
|
||||
// approximate band by comparing scaled radial distance
|
||||
n := norm(x, y)
|
||||
tol := half / math.Min(rx, ry)
|
||||
return math.Abs(n-1) <= tol
|
||||
}
|
||||
case "line":
|
||||
seg := [2][2]float64{{el.Nums[0], el.Nums[1]}, {el.Nums[2], el.Nums[3]}}
|
||||
inStroke = func(x, y float64) bool { return distSeg(x, y, seg[0], seg[1]) <= math.Max(half, 0.5) }
|
||||
case "polyline", "polygon":
|
||||
pts := el.Points
|
||||
inStroke = func(x, y float64) bool {
|
||||
last := len(pts) - 1
|
||||
for i := 0; i < last; i++ {
|
||||
if distSeg(x, y, pts[i], pts[i+1]) <= math.Max(half, 0.5) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if el.Kind == "polygon" && distSeg(x, y, pts[last], pts[0]) <= math.Max(half, 0.5) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if el.Kind == "polygon" {
|
||||
inFill = func(x, y float64) bool { return pointInPolygon(x, y, pts) }
|
||||
}
|
||||
case "path":
|
||||
subs, err := flattenPath(el.D)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
inStroke = func(x, y float64) bool {
|
||||
for _, sp := range subs {
|
||||
for i := 0; i < len(sp)-1; i++ {
|
||||
if distSeg(x, y, sp[i], sp[i+1]) <= math.Max(half, 0.5) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
inFill = func(x, y float64) bool {
|
||||
in := false
|
||||
for _, sp := range subs {
|
||||
if pointInPolygon(x, y, sp) {
|
||||
in = !in
|
||||
}
|
||||
}
|
||||
return in
|
||||
}
|
||||
case "text":
|
||||
// baseline box: width ~0.6em per char, height 1em above the
|
||||
// baseline, honoring text-anchor — real glyphs are out of scope
|
||||
size := attrFloat(attrs, "font-size", 16)
|
||||
x0, y0 := el.Nums[0], el.Nums[1]
|
||||
w := 0.6 * size * float64(len([]rune(el.Text)))
|
||||
switch attrs["text-anchor"] {
|
||||
case "middle":
|
||||
x0 -= w / 2
|
||||
case "end":
|
||||
x0 -= w
|
||||
}
|
||||
edge := math.Max(size/12, 0.75)
|
||||
inFill = func(x, y float64) bool {
|
||||
return x >= x0 && x <= x0+w && y >= y0-size && y <= y0 &&
|
||||
(y >= y0-edge || y <= y0-size+edge || x <= x0+edge || x >= x0+w-edge)
|
||||
}
|
||||
}
|
||||
return inFill, inStroke
|
||||
}
|
||||
|
||||
func paint(attrs map[string]string, key, kind string) (rgba, bool) {
|
||||
v := attrs[key]
|
||||
if v == "" {
|
||||
if key == "fill" && kind != "line" && kind != "polyline" && kind != "path" {
|
||||
return rgba{0, 0, 0, 1}, true // SVG default fill is black
|
||||
}
|
||||
return rgba{}, false
|
||||
}
|
||||
if v == "none" || v == "transparent" {
|
||||
return rgba{}, false
|
||||
}
|
||||
return parseColor(v), true
|
||||
}
|
||||
|
||||
func attrFloat(attrs map[string]string, key string, def float64) float64 {
|
||||
if v, ok := attrs[key]; ok {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func (r *Raster) blend(x, y int, c rgba, a float64) {
|
||||
if a <= 0 {
|
||||
return
|
||||
}
|
||||
if a > 1 {
|
||||
a = 1
|
||||
}
|
||||
i := y*r.W + x
|
||||
p := r.Pix[i]
|
||||
p[0] = c.r*a + p[0]*(1-a)
|
||||
p[1] = c.g*a + p[1]*(1-a)
|
||||
p[2] = c.b*a + p[2]*(1-a)
|
||||
p[3] = math.Max(p[3], a)
|
||||
r.Pix[i] = p
|
||||
}
|
||||
|
||||
func distSeg(x, y float64, a, b [2]float64) float64 {
|
||||
dx, dy := b[0]-a[0], b[1]-a[1]
|
||||
l2 := dx*dx + dy*dy
|
||||
if l2 == 0 {
|
||||
return math.Hypot(x-a[0], y-a[1])
|
||||
}
|
||||
t := ((x-a[0])*dx + (y-a[1])*dy) / l2
|
||||
t = math.Max(0, math.Min(1, t))
|
||||
return math.Hypot(x-(a[0]+t*dx), y-(a[1]+t*dy))
|
||||
}
|
||||
|
||||
func pointInPolygon(x, y float64, pts [][2]float64) bool {
|
||||
in := false
|
||||
n := len(pts)
|
||||
for i, j := 0, n-1; i < n; j, i = i, i+1 {
|
||||
xi, yi := pts[i][0], pts[i][1]
|
||||
xj, yj := pts[j][0], pts[j][1]
|
||||
if (yi > y) != (yj > y) && x < (xj-xi)*(y-yi)/(yj-yi)+xi {
|
||||
in = !in
|
||||
}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// parseColor handles #rgb/#rrggbb plus the CSS names agents actually
|
||||
// use; unknown names render mid-gray rather than failing the preview.
|
||||
func parseColor(s string) rgba {
|
||||
s = strings.TrimSpace(strings.ToLower(s))
|
||||
if strings.HasPrefix(s, "#") {
|
||||
h := s[1:]
|
||||
if len(h) == 3 {
|
||||
h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]})
|
||||
}
|
||||
if len(h) == 6 {
|
||||
r, err1 := strconv.ParseUint(h[0:2], 16, 8)
|
||||
g, err2 := strconv.ParseUint(h[2:4], 16, 8)
|
||||
b, err3 := strconv.ParseUint(h[4:6], 16, 8)
|
||||
if err1 == nil && err2 == nil && err3 == nil {
|
||||
return rgba{float64(r), float64(g), float64(b), 1}
|
||||
}
|
||||
}
|
||||
}
|
||||
if c, ok := cssColors[s]; ok {
|
||||
return c
|
||||
}
|
||||
return rgba{128, 128, 128, 1}
|
||||
}
|
||||
|
||||
var cssColors = map[string]rgba{
|
||||
"black": {0, 0, 0, 1}, "white": {255, 255, 255, 1}, "red": {255, 0, 0, 1},
|
||||
"green": {0, 128, 0, 1}, "lime": {0, 255, 0, 1}, "blue": {0, 0, 255, 1},
|
||||
"yellow": {255, 255, 0, 1}, "orange": {255, 165, 0, 1}, "purple": {128, 0, 128, 1},
|
||||
"gray": {128, 128, 128, 1}, "grey": {128, 128, 128, 1}, "silver": {192, 192, 192, 1},
|
||||
"cyan": {0, 255, 255, 1}, "magenta": {255, 0, 255, 1}, "pink": {255, 192, 203, 1},
|
||||
"brown": {165, 42, 42, 1}, "navy": {0, 0, 128, 1}, "teal": {0, 128, 128, 1},
|
||||
"olive": {128, 128, 0, 1}, "maroon": {128, 0, 0, 1}, "aqua": {0, 255, 255, 1},
|
||||
"fuchsia": {255, 0, 255, 1}, "gold": {255, 215, 0, 1},
|
||||
}
|
||||
|
||||
// ANSI renders the raster with truecolor half-blocks, two pixel rows
|
||||
// per text row — same technique as spritec's preview.
|
||||
func (r *Raster) ANSI() string {
|
||||
const reset = "\x1b[0m"
|
||||
var b strings.Builder
|
||||
for y := 0; y < r.H; y += 2 {
|
||||
for x := 0; x < r.W; x++ {
|
||||
top := r.Pix[y*r.W+x]
|
||||
var bot [4]float64
|
||||
if y+1 < r.H {
|
||||
bot = r.Pix[(y+1)*r.W+x]
|
||||
}
|
||||
if top[3] == 0 && bot[3] == 0 {
|
||||
b.WriteString(" ")
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm\x1b[48;2;%d;%d;%dm▀%s",
|
||||
int(top[0]), int(top[1]), int(top[2]),
|
||||
int(bot[0]), int(bot[1]), int(bot[2]), reset)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
Reference in New Issue
Block a user