1package html
2
3import (
4 "fmt"
5 "html"
6 "io"
7 "sort"
8 "strings"
9
10 "github.com/alecthomas/chroma"
11)
12
13// Option sets an option of the HTML formatter.
14type Option func(f *Formatter)
15
16// Standalone configures the HTML formatter for generating a standalone HTML document.
17func Standalone(b bool) Option { return func(f *Formatter) { f.standalone = b } }
18
19// ClassPrefix sets the CSS class prefix.
20func ClassPrefix(prefix string) Option { return func(f *Formatter) { f.prefix = prefix } }
21
22// WithClasses emits HTML using CSS classes, rather than inline styles.
23func WithClasses(b bool) Option { return func(f *Formatter) { f.Classes = b } }
24
25// TabWidth sets the number of characters for a tab. Defaults to 8.
26func TabWidth(width int) Option { return func(f *Formatter) { f.tabWidth = width } }
27
28// PreventSurroundingPre prevents the surrounding pre tags around the generated code.
29func PreventSurroundingPre(b bool) Option {
30 return func(f *Formatter) {
31 if b {
32 f.preWrapper = nopPreWrapper
33 } else {
34 f.preWrapper = defaultPreWrapper
35 }
36 }
37}
38
39// WithPreWrapper allows control of the surrounding pre tags.
40func WithPreWrapper(wrapper PreWrapper) Option {
41 return func(f *Formatter) {
42 f.preWrapper = wrapper
43 }
44}
45
46// WithLineNumbers formats output with line numbers.
47func WithLineNumbers(b bool) Option {
48 return func(f *Formatter) {
49 f.lineNumbers = b
50 }
51}
52
53// LineNumbersInTable will, when combined with WithLineNumbers, separate the line numbers
54// and code in table td's, which make them copy-and-paste friendly.
55func LineNumbersInTable(b bool) Option {
56 return func(f *Formatter) {
57 f.lineNumbersInTable = b
58 }
59}
60
61// HighlightLines higlights the given line ranges with the Highlight style.
62//
63// A range is the beginning and ending of a range as 1-based line numbers, inclusive.
64func HighlightLines(ranges [][2]int) Option {
65 return func(f *Formatter) {
66 f.highlightRanges = ranges
67 sort.Sort(f.highlightRanges)
68 }
69}
70
71// BaseLineNumber sets the initial number to start line numbering at. Defaults to 1.
72func BaseLineNumber(n int) Option {
73 return func(f *Formatter) {
74 f.baseLineNumber = n
75 }
76}
77
78// New HTML formatter.
79func New(options ...Option) *Formatter {
80 f := &Formatter{
81 baseLineNumber: 1,
82 preWrapper: defaultPreWrapper,
83 }
84 for _, option := range options {
85 option(f)
86 }
87 return f
88}
89
90// PreWrapper defines the operations supported in WithPreWrapper.
91type PreWrapper interface {
92 // Start is called to write a start <pre> element.
93 // The code flag tells whether this block surrounds
94 // highlighted code. This will be false when surrounding
95 // line numbers.
96 Start(code bool, styleAttr string) string
97
98 // End is called to write the end </pre> element.
99 End(code bool) string
100}
101
102type preWrapper struct {
103 start func(code bool, styleAttr string) string
104 end func(code bool) string
105}
106
107func (p preWrapper) Start(code bool, styleAttr string) string {
108 return p.start(code, styleAttr)
109}
110
111func (p preWrapper) End(code bool) string {
112 return p.end(code)
113}
114
115var (
116 nopPreWrapper = preWrapper{
117 start: func(code bool, styleAttr string) string { return "" },
118 end: func(code bool) string { return "" },
119 }
120 defaultPreWrapper = preWrapper{
121 start: func(code bool, styleAttr string) string {
122 return fmt.Sprintf("<pre%s>", styleAttr)
123 },
124 end: func(code bool) string {
125 return "</pre>"
126 },
127 }
128)
129
130// Formatter that generates HTML.
131type Formatter struct {
132 standalone bool
133 prefix string
134 Classes bool // Exported field to detect when classes are being used
135 preWrapper PreWrapper
136 tabWidth int
137 lineNumbers bool
138 lineNumbersInTable bool
139 highlightRanges highlightRanges
140 baseLineNumber int
141}
142
143type highlightRanges [][2]int
144
145func (h highlightRanges) Len() int { return len(h) }
146func (h highlightRanges) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
147func (h highlightRanges) Less(i, j int) bool { return h[i][0] < h[j][0] }
148
149func (f *Formatter) Format(w io.Writer, style *chroma.Style, iterator chroma.Iterator) (err error) {
150 return f.writeHTML(w, style, iterator.Tokens())
151}
152
153// We deliberately don't use html/template here because it is two orders of magnitude slower (benchmarked).
154//
155// OTOH we need to be super careful about correct escaping...
156func (f *Formatter) writeHTML(w io.Writer, style *chroma.Style, tokens []chroma.Token) (err error) { // nolint: gocyclo
157 css := f.styleToCSS(style)
158 if !f.Classes {
159 for t, style := range css {
160 css[t] = compressStyle(style)
161 }
162 }
163 if f.standalone {
164 fmt.Fprint(w, "<html>\n")
165 if f.Classes {
166 fmt.Fprint(w, "<style type=\"text/css\">\n")
167 err = f.WriteCSS(w, style)
168 if err != nil {
169 return err
170 }
171 fmt.Fprintf(w, "body { %s; }\n", css[chroma.Background])
172 fmt.Fprint(w, "</style>")
173 }
174 fmt.Fprintf(w, "<body%s>\n", f.styleAttr(css, chroma.Background))
175 }
176
177 wrapInTable := f.lineNumbers && f.lineNumbersInTable
178
179 lines := chroma.SplitTokensIntoLines(tokens)
180 lineDigits := len(fmt.Sprintf("%d", len(lines)))
181 highlightIndex := 0
182
183 if wrapInTable {
184 // List line numbers in its own <td>
185 fmt.Fprintf(w, "<div%s>\n", f.styleAttr(css, chroma.Background))
186 fmt.Fprintf(w, "<table%s><tr>", f.styleAttr(css, chroma.LineTable))
187 fmt.Fprintf(w, "<td%s>\n", f.styleAttr(css, chroma.LineTableTD))
188 fmt.Fprintf(w, f.preWrapper.Start(false, f.styleAttr(css, chroma.Background)))
189 for index := range lines {
190 line := f.baseLineNumber + index
191 highlight, next := f.shouldHighlight(highlightIndex, line)
192 if next {
193 highlightIndex++
194 }
195 if highlight {
196 fmt.Fprintf(w, "<span%s>", f.styleAttr(css, chroma.LineHighlight))
197 }
198
199 fmt.Fprintf(w, "<span%s>%*d\n</span>", f.styleAttr(css, chroma.LineNumbersTable), lineDigits, line)
200
201 if highlight {
202 fmt.Fprintf(w, "</span>")
203 }
204 }
205 fmt.Fprint(w, f.preWrapper.End(false))
206 fmt.Fprint(w, "</td>\n")
207 fmt.Fprintf(w, "<td%s>\n", f.styleAttr(css, chroma.LineTableTD, "width:100%"))
208 }
209
210 fmt.Fprintf(w, f.preWrapper.Start(true, f.styleAttr(css, chroma.Background)))
211
212 highlightIndex = 0
213 for index, tokens := range lines {
214 // 1-based line number.
215 line := f.baseLineNumber + index
216 highlight, next := f.shouldHighlight(highlightIndex, line)
217 if next {
218 highlightIndex++
219 }
220 if highlight {
221 fmt.Fprintf(w, "<span%s>", f.styleAttr(css, chroma.LineHighlight))
222 }
223
224 if f.lineNumbers && !wrapInTable {
225 fmt.Fprintf(w, "<span%s>%*d</span>", f.styleAttr(css, chroma.LineNumbers), lineDigits, line)
226 }
227
228 for _, token := range tokens {
229 html := html.EscapeString(token.String())
230 attr := f.styleAttr(css, token.Type)
231 if attr != "" {
232 html = fmt.Sprintf("<span%s>%s</span>", attr, html)
233 }
234 fmt.Fprint(w, html)
235 }
236 if highlight {
237 fmt.Fprintf(w, "</span>")
238 }
239 }
240
241 fmt.Fprintf(w, f.preWrapper.End(true))
242
243 if wrapInTable {
244 fmt.Fprint(w, "</td></tr></table>\n")
245 fmt.Fprint(w, "</div>\n")
246 }
247
248 if f.standalone {
249 fmt.Fprint(w, "\n</body>\n")
250 fmt.Fprint(w, "</html>\n")
251 }
252
253 return nil
254}
255
256func (f *Formatter) shouldHighlight(highlightIndex, line int) (bool, bool) {
257 next := false
258 for highlightIndex < len(f.highlightRanges) && line > f.highlightRanges[highlightIndex][1] {
259 highlightIndex++
260 next = true
261 }
262 if highlightIndex < len(f.highlightRanges) {
263 hrange := f.highlightRanges[highlightIndex]
264 if line >= hrange[0] && line <= hrange[1] {
265 return true, next
266 }
267 }
268 return false, next
269}
270
271func (f *Formatter) class(t chroma.TokenType) string {
272 for t != 0 {
273 if cls, ok := chroma.StandardTypes[t]; ok {
274 if cls != "" {
275 return f.prefix + cls
276 }
277 return ""
278 }
279 t = t.Parent()
280 }
281 if cls := chroma.StandardTypes[t]; cls != "" {
282 return f.prefix + cls
283 }
284 return ""
285}
286
287func (f *Formatter) styleAttr(styles map[chroma.TokenType]string, tt chroma.TokenType, extraCSS ...string) string {
288 if f.Classes {
289 cls := f.class(tt)
290 if cls == "" {
291 return ""
292 }
293 return fmt.Sprintf(` class="%s"`, cls)
294 }
295 if _, ok := styles[tt]; !ok {
296 tt = tt.SubCategory()
297 if _, ok := styles[tt]; !ok {
298 tt = tt.Category()
299 if _, ok := styles[tt]; !ok {
300 return ""
301 }
302 }
303 }
304 css := []string{styles[tt]}
305 css = append(css, extraCSS...)
306 return fmt.Sprintf(` style="%s"`, strings.Join(css, ";"))
307}
308
309func (f *Formatter) tabWidthStyle() string {
310 if f.tabWidth != 0 && f.tabWidth != 8 {
311 return fmt.Sprintf("; -moz-tab-size: %[1]d; -o-tab-size: %[1]d; tab-size: %[1]d", f.tabWidth)
312 }
313 return ""
314}
315
316// WriteCSS writes CSS style definitions (without any surrounding HTML).
317func (f *Formatter) WriteCSS(w io.Writer, style *chroma.Style) error {
318 css := f.styleToCSS(style)
319 // Special-case background as it is mapped to the outer ".chroma" class.
320 if _, err := fmt.Fprintf(w, "/* %s */ .%schroma { %s }\n", chroma.Background, f.prefix, css[chroma.Background]); err != nil {
321 return err
322 }
323 // Special-case code column of table to expand width.
324 if f.lineNumbers && f.lineNumbersInTable {
325 if _, err := fmt.Fprintf(w, "/* %s */ .%schroma .%s:last-child { width: 100%%; }",
326 chroma.LineTableTD, f.prefix, f.class(chroma.LineTableTD)); err != nil {
327 return err
328 }
329 }
330 tts := []int{}
331 for tt := range css {
332 tts = append(tts, int(tt))
333 }
334 sort.Ints(tts)
335 for _, ti := range tts {
336 tt := chroma.TokenType(ti)
337 if tt == chroma.Background {
338 continue
339 }
340 styles := css[tt]
341 if _, err := fmt.Fprintf(w, "/* %s */ .%schroma .%s { %s }\n", tt, f.prefix, f.class(tt), styles); err != nil {
342 return err
343 }
344 }
345 return nil
346}
347
348func (f *Formatter) styleToCSS(style *chroma.Style) map[chroma.TokenType]string {
349 classes := map[chroma.TokenType]string{}
350 bg := style.Get(chroma.Background)
351 // Convert the style.
352 for t := range chroma.StandardTypes {
353 entry := style.Get(t)
354 if t != chroma.Background {
355 entry = entry.Sub(bg)
356 }
357 if entry.IsZero() {
358 continue
359 }
360 classes[t] = StyleEntryToCSS(entry)
361 }
362 classes[chroma.Background] += f.tabWidthStyle()
363 lineNumbersStyle := "margin-right: 0.4em; padding: 0 0.4em 0 0.4em;"
364 // All rules begin with default rules followed by user provided rules
365 classes[chroma.LineNumbers] = lineNumbersStyle + classes[chroma.LineNumbers]
366 classes[chroma.LineNumbersTable] = lineNumbersStyle + classes[chroma.LineNumbersTable]
367 classes[chroma.LineHighlight] = "display: block; width: 100%;" + classes[chroma.LineHighlight]
368 classes[chroma.LineTable] = "border-spacing: 0; padding: 0; margin: 0; border: 0; width: auto; overflow: auto; display: block;" + classes[chroma.LineTable]
369 classes[chroma.LineTableTD] = "vertical-align: top; padding: 0; margin: 0; border: 0;" + classes[chroma.LineTableTD]
370 return classes
371}
372
373// StyleEntryToCSS converts a chroma.StyleEntry to CSS attributes.
374func StyleEntryToCSS(e chroma.StyleEntry) string {
375 styles := []string{}
376 if e.Colour.IsSet() {
377 styles = append(styles, "color: "+e.Colour.String())
378 }
379 if e.Background.IsSet() {
380 styles = append(styles, "background-color: "+e.Background.String())
381 }
382 if e.Bold == chroma.Yes {
383 styles = append(styles, "font-weight: bold")
384 }
385 if e.Italic == chroma.Yes {
386 styles = append(styles, "font-style: italic")
387 }
388 if e.Underline == chroma.Yes {
389 styles = append(styles, "text-decoration: underline")
390 }
391 return strings.Join(styles, "; ")
392}
393
394// Compress CSS attributes - remove spaces, transform 6-digit colours to 3.
395func compressStyle(s string) string {
396 parts := strings.Split(s, ";")
397 out := []string{}
398 for _, p := range parts {
399 p = strings.Join(strings.Fields(p), " ")
400 p = strings.Replace(p, ": ", ":", 1)
401 if strings.Contains(p, "#") {
402 c := p[len(p)-6:]
403 if c[0] == c[1] && c[2] == c[3] && c[4] == c[5] {
404 p = p[:len(p)-6] + c[0:1] + c[2:3] + c[4:5]
405 }
406 }
407 out = append(out, p)
408 }
409 return strings.Join(out, ";")
410}