1// Package svg contains an SVG formatter.
  2package svg
  3
  4import (
  5	"encoding/base64"
  6	"errors"
  7	"fmt"
  8	"io"
  9	"os"
 10	"path"
 11	"strings"
 12
 13	"github.com/alecthomas/chroma/v2"
 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 = os.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.ReplaceAll(token.String(), `	`, "    "))
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.ReplaceAll(token.String(), `	`, "    "))
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}