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