html.go

  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}