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("&")
113 case '<':
114 b.WriteString("<")
115 case '>':
116 b.WriteString(">")
117 case '"':
118 b.WriteString(""")
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}