1. // Package svg contains an SVG formatter.
    
  2. package svg
    
  3. 
    
  4. import (
    
  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.
    
  17. type Option func(f *Formatter)
    
  18. 
    
  19. // FontFamily sets the font-family.
    
  20. func FontFamily(fontFamily string) Option { return func(f *Formatter) { f.fontFamily = fontFamily } }
    
  21. 
    
  22. // EmbedFontFile embeds given font file
    
  23. func 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
    
  44. func 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.
    
  49. func 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.
    
  58. type Formatter struct {
    
  59. 	fontFamily   string
    
  60. 	embeddedFont string
    
  61. 	fontFormat   FontFormat
    
  62. }
    
  63. 
    
  64. func (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. 
    
  69. var svgEscaper = strings.NewReplacer(
    
  70. 	`&`, "&",
    
  71. 	`<`, "&lt;",
    
  72. 	`>`, "&gt;",
    
  73. 	`"`, "&quot;",
    
  74. 	` `, "&#160;",
    
  75. 	`	`, "&#160;&#160;&#160;&#160;",
    
  76. )
    
  77. 
    
  78. // EscapeString escapes special characters.
    
  79. func escapeString(s string) string {
    
  80. 	return svgEscaper.Replace(s)
    
  81. }
    
  82. 
    
  83. func (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. 
    
  118. func 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.
    
  135. func (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. 
    
  149. type FontFormat int
    
  150. 
    
  151. // https://transfonter.org/formats
    
  152. const (
    
  153. 	WOFF FontFormat = iota
    
  154. 	WOFF2
    
  155. 	TRUETYPE
    
  156. )
    
  157. 
    
  158. var fontFormats = [...]string{
    
  159. 	"woff",
    
  160. 	"woff2",
    
  161. 	"truetype",
    
  162. }
    
  163. 
    
  164. func (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. 
    
  175. func (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. 
    
  188. func (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.
    
  206. func 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. }