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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

svg.gno

3.58 Kb · 146 lines
  1package art
  2
  3import (
  4	"strings"
  5
  6	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/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}