1// Package svg contains an SVG formatter.
2package svg
3
4import (
5 "encoding/base64"
6 "errors"
7 "fmt"
8 "io"
9 "io/ioutil"
10 "path"
11 "strings"
12
13 "github.com/alecthomas/chroma"
14)
15
16// Option sets an option of the SVG formatter.
17type Option func(f *Formatter)
18
19// FontFamily sets the font-family.
20func FontFamily(fontFamily string) Option { return func(f *Formatter) { f.fontFamily = fontFamily } }
21
22// EmbedFontFile embeds given font file
23func EmbedFontFile(fontFamily string, fileName string) (option Option, err error) {
24 var format FontFormat
25 switch path.Ext(fileName) {
26 case ".woff":
27 format = WOFF
28 case ".woff2":
29 format = WOFF2
30 case ".ttf":
31 format = TRUETYPE
32 default:
33 return nil, errors.New("unexpected font file suffix")
34 }
35
36 var content []byte
37 if content, err = ioutil.ReadFile(fileName); err == nil {
38 option = EmbedFont(fontFamily, base64.StdEncoding.EncodeToString(content), format)
39 }
40 return
41}
42
43// EmbedFont embeds given base64 encoded font
44func EmbedFont(fontFamily string, font string, format FontFormat) Option {
45 return func(f *Formatter) { f.fontFamily = fontFamily; f.embeddedFont = font; f.fontFormat = format }
46}
47
48// New SVG formatter.
49func New(options ...Option) *Formatter {
50 f := &Formatter{fontFamily: "Consolas, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace"}
51 for _, option := range options {
52 option(f)
53 }
54 return f
55}
56
57// Formatter that generates SVG.
58type Formatter struct {
59 fontFamily string
60 embeddedFont string
61 fontFormat FontFormat
62}
63
64func (f *Formatter) Format(w io.Writer, style *chroma.Style, iterator chroma.Iterator) (err error) {
65 f.writeSVG(w, style, iterator.Tokens())
66 return err
67}
68
69var svgEscaper = strings.NewReplacer(
70 `&`, "&",
71 `<`, "<",
72 `>`, ">",
73 `"`, """,
74 ` `, " ",
75 ` `, "    ",
76)
77
78// EscapeString escapes special characters.
79func escapeString(s string) string {
80 return svgEscaper.Replace(s)
81}
82
83func (f *Formatter) writeSVG(w io.Writer, style *chroma.Style, tokens []chroma.Token) { // nolint: gocyclo
84 svgStyles := f.styleToSVG(style)
85 lines := chroma.SplitTokensIntoLines(tokens)
86
87 fmt.Fprint(w, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
88 fmt.Fprint(w, "<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.0//EN\" \"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd\">\n")
89 fmt.Fprintf(w, "<svg width=\"%dpx\" height=\"%dpx\" xmlns=\"http://www.w3.org/2000/svg\">\n", 8*maxLineWidth(lines), 10+int(16.8*float64(len(lines)+1)))
90
91 if f.embeddedFont != "" {
92 f.writeFontStyle(w)
93 }
94
95 fmt.Fprintf(w, "<rect width=\"100%%\" height=\"100%%\" fill=\"%s\"/>\n", style.Get(chroma.Background).Background.String())
96 fmt.Fprintf(w, "<g font-family=\"%s\" font-size=\"14px\" fill=\"%s\">\n", f.fontFamily, style.Get(chroma.Text).Colour.String())
97
98 f.writeTokenBackgrounds(w, lines, style)
99
100 for index, tokens := range lines {
101 fmt.Fprintf(w, "<text x=\"0\" y=\"%fem\" xml:space=\"preserve\">", 1.2*float64(index+1))
102
103 for _, token := range tokens {
104 text := escapeString(token.String())
105 attr := f.styleAttr(svgStyles, token.Type)
106 if attr != "" {
107 text = fmt.Sprintf("<tspan %s>%s</tspan>", attr, text)
108 }
109 fmt.Fprint(w, text)
110 }
111 fmt.Fprint(w, "</text>")
112 }
113
114 fmt.Fprint(w, "\n</g>\n")
115 fmt.Fprint(w, "</svg>\n")
116}
117
118func maxLineWidth(lines [][]chroma.Token) int {
119 maxWidth := 0
120 for _, tokens := range lines {
121 length := 0
122 for _, token := range tokens {
123 length += len(strings.Replace(token.String(), ` `, " ", -1))
124 }
125 if length > maxWidth {
126 maxWidth = length
127 }
128 }
129 return maxWidth
130}
131
132// There is no background attribute for text in SVG so simply calculate the position and text
133// of tokens with a background color that differs from the default and add a rectangle for each before
134// adding the token.
135func (f *Formatter) writeTokenBackgrounds(w io.Writer, lines [][]chroma.Token, style *chroma.Style) {
136 for index, tokens := range lines {
137 lineLength := 0
138 for _, token := range tokens {
139 length := len(strings.Replace(token.String(), ` `, " ", -1))
140 tokenBackground := style.Get(token.Type).Background
141 if tokenBackground.IsSet() && tokenBackground != style.Get(chroma.Background).Background {
142 fmt.Fprintf(w, "<rect id=\"%s\" x=\"%dch\" y=\"%fem\" width=\"%dch\" height=\"1.2em\" fill=\"%s\" />\n", escapeString(token.String()), lineLength, 1.2*float64(index)+0.25, length, style.Get(token.Type).Background.String())
143 }
144 lineLength += length
145 }
146 }
147}
148
149type FontFormat int
150
151// https://transfonter.org/formats
152const (
153 WOFF FontFormat = iota
154 WOFF2
155 TRUETYPE
156)
157
158var fontFormats = [...]string{
159 "woff",
160 "woff2",
161 "truetype",
162}
163
164func (f *Formatter) writeFontStyle(w io.Writer) {
165 fmt.Fprintf(w, `<style>
166@font-face {
167 font-family: '%s';
168 src: url(data:application/x-font-%s;charset=utf-8;base64,%s) format('%s');'
169 font-weight: normal;
170 font-style: normal;
171}
172</style>`, f.fontFamily, fontFormats[f.fontFormat], f.embeddedFont, fontFormats[f.fontFormat])
173}
174
175func (f *Formatter) styleAttr(styles map[chroma.TokenType]string, tt chroma.TokenType) string {
176 if _, ok := styles[tt]; !ok {
177 tt = tt.SubCategory()
178 if _, ok := styles[tt]; !ok {
179 tt = tt.Category()
180 if _, ok := styles[tt]; !ok {
181 return ""
182 }
183 }
184 }
185 return styles[tt]
186}
187
188func (f *Formatter) styleToSVG(style *chroma.Style) map[chroma.TokenType]string {
189 converted := map[chroma.TokenType]string{}
190 bg := style.Get(chroma.Background)
191 // Convert the style.
192 for t := range chroma.StandardTypes {
193 entry := style.Get(t)
194 if t != chroma.Background {
195 entry = entry.Sub(bg)
196 }
197 if entry.IsZero() {
198 continue
199 }
200 converted[t] = StyleEntryToSVG(entry)
201 }
202 return converted
203}
204
205// StyleEntryToSVG converts a chroma.StyleEntry to SVG attributes.
206func StyleEntryToSVG(e chroma.StyleEntry) string {
207 var styles []string
208
209 if e.Colour.IsSet() {
210 styles = append(styles, "fill=\""+e.Colour.String()+"\"")
211 }
212 if e.Bold == chroma.Yes {
213 styles = append(styles, "font-weight=\"bold\"")
214 }
215 if e.Italic == chroma.Yes {
216 styles = append(styles, "font-style=\"italic\"")
217 }
218 if e.Underline == chroma.Yes {
219 styles = append(styles, "text-decoration=\"underline\"")
220 }
221 return strings.Join(styles, " ")
222}