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
333 lines
9.1 KiB
Go
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()
|
|
}
|