Files
agent-tools/svg-maker/svg/raster.go
claude 36631c7709 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
2026-08-07 00:37:51 +02:00

333 lines
9.1 KiB
Go

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()
}