Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

svg.gno

3.54 Kb · 146 lines
  1package art
  2
  3import (
  4	"strings"
  5
  6	"gno.land/p/moul/svg/v0"
  7)
  8
  9// SVGOpts configures [Canvas.SVG]. The zero value is usable: every field falls
 10// back to the constant beside it.
 11type SVGOpts struct {
 12	CellW, CellH int    // cell size in SVG units; 0 means 8 and 16
 13	FG, BG       int    // what a cell's Default resolves to; Default stays unset
 14	Font         string // font-family; "" means a monospace stack
 15}
 16
 17// Defaults for [SVGOpts]. 8x16 is the usual terminal cell ratio, so art laid
 18// out for a terminal keeps its proportions.
 19const (
 20	DefaultCellW = 8
 21	DefaultCellH = 16
 22	DefaultFont  = "ui-monospace,SFMono-Regular,Menlo,Consolas,monospace"
 23)
 24
 25// SVG renders the canvas as an SVG document: background colours as run-length
 26// rectangles, and one <text> element per non-blank cell, centred in its cell so
 27// the grid stays aligned whatever the renderer's idea of a monospace advance
 28// width is.
 29//
 30// This is how colour reaches gnoweb. [Canvas.ANSI] cannot: raw ESC bytes are
 31// stripped by p/nt/markdown/sanitize and are garbage in HTML regardless. An
 32// SVG carries the same two colours per cell through a medium the web can show,
 33// which is the whole argument for making the target a parameter.
 34//
 35// One element per cell rather than one per run is deliberate: a run of text
 36// would have to assume the font's advance width, and a renderer that disagreed
 37// would shear the picture. Dense pixel art should go through [Pix.SVG] instead,
 38// which is run-length and has no font to get wrong.
 39func (c Canvas) SVG(o SVGOpts) *svg.Canvas {
 40	cw, ch := o.CellW, o.CellH
 41	if cw <= 0 {
 42		cw = DefaultCellW
 43	}
 44	if ch <= 0 {
 45		ch = DefaultCellH
 46	}
 47	font := o.Font
 48	if font == "" {
 49		font = DefaultFont
 50	}
 51
 52	out := svg.NewCanvas(c.W*cw, c.H*ch)
 53	out.WithViewBox(0, 0, c.W*cw, c.H*ch)
 54	out.AddStyle("text", "font-family:"+font+";font-size:"+itoa(ch)+"px;text-anchor:middle;dominant-baseline:central")
 55	out.AddStyle("rect", "shape-rendering:crispEdges")
 56
 57	// Backgrounds first, as horizontal runs, so they sit under the glyphs.
 58	for y := 0; y < c.H; y++ {
 59		x := 0
 60		for x < c.W {
 61			bg := c.bgAt(x, y, o.BG)
 62			run := 1
 63			for x+run < c.W && c.bgAt(x+run, y, o.BG) == bg {
 64				run++
 65			}
 66			if bg != Default {
 67				out.Append(svg.NewRectangle(x*cw, y*ch, run*cw, ch, Hex(bg)))
 68			}
 69			x += run
 70		}
 71	}
 72
 73	for y := 0; y < c.H; y++ {
 74		for x := 0; x < c.W; x++ {
 75			cell := c.Cells[y*c.W+x]
 76			if cell.R == ' ' || cell.R == 0 {
 77				continue
 78			}
 79			fg := cell.FG
 80			if fg == Default {
 81				fg = o.FG
 82			}
 83			fill := Hex(fg)
 84			if fill == "" {
 85				fill = "currentColor"
 86			}
 87			out.Append(svg.NewText(x*cw+cw/2, y*ch+ch/2, escapeXML(string(cell.R)), fill))
 88		}
 89	}
 90	return out
 91}
 92
 93func (c Canvas) bgAt(x, y, fallback int) int {
 94	bg := c.Cells[y*c.W+x].BG
 95	if bg == Default {
 96		return fallback
 97	}
 98	return bg
 99}
100
101// escapeXML is here and not in p/moul/svg because svg.Text interpolates its
102// content straight into the document: a rune this package can legitimately
103// produce, or a title a caller typed, would otherwise close the element.
104func escapeXML(s string) string {
105	if !strings.ContainsAny(s, "&<>\"") {
106		return s
107	}
108	var b strings.Builder
109	for _, r := range s {
110		switch r {
111		case '&':
112			b.WriteString("&amp;")
113		case '<':
114			b.WriteString("&lt;")
115		case '>':
116			b.WriteString("&gt;")
117		case '"':
118			b.WriteString("&quot;")
119		default:
120			b.WriteRune(r)
121		}
122	}
123	return b.String()
124}
125
126func itoa(v int) string {
127	if v == 0 {
128		return "0"
129	}
130	neg := v < 0
131	if neg {
132		v = -v
133	}
134	var buf [20]byte
135	i := len(buf)
136	for v > 0 {
137		i--
138		buf[i] = byte('0' + v%10)
139		v /= 10
140	}
141	if neg {
142		i--
143		buf[i] = '-'
144	}
145	return string(buf[i:])
146}