...

Source file src/github.com/alecthomas/chroma/v2/formatters/html/html.go

Documentation: github.com/alecthomas/chroma/v2/formatters/html

     1  package html
     2  
     3  import (
     4  	"fmt"
     5  	"html"
     6  	"io"
     7  	"sort"
     8  	"strings"
     9  
    10  	"github.com/alecthomas/chroma/v2"
    11  )
    12  
    13  // Option sets an option of the HTML formatter.
    14  type Option func(f *Formatter)
    15  
    16  // Standalone configures the HTML formatter for generating a standalone HTML document.
    17  func Standalone(b bool) Option { return func(f *Formatter) { f.standalone = b } }
    18  
    19  // ClassPrefix sets the CSS class prefix.
    20  func ClassPrefix(prefix string) Option { return func(f *Formatter) { f.prefix = prefix } }
    21  
    22  // WithClasses emits HTML using CSS classes, rather than inline styles.
    23  func WithClasses(b bool) Option { return func(f *Formatter) { f.Classes = b } }
    24  
    25  // WithAllClasses disables an optimisation that omits redundant CSS classes.
    26  func WithAllClasses(b bool) Option { return func(f *Formatter) { f.allClasses = b } }
    27  
    28  // WithCustomCSS sets user's custom CSS styles.
    29  func WithCustomCSS(css map[chroma.TokenType]string) Option {
    30  	return func(f *Formatter) {
    31  		f.customCSS = css
    32  	}
    33  }
    34  
    35  // TabWidth sets the number of characters for a tab. Defaults to 8.
    36  func TabWidth(width int) Option { return func(f *Formatter) { f.tabWidth = width } }
    37  
    38  // PreventSurroundingPre prevents the surrounding pre tags around the generated code.
    39  func PreventSurroundingPre(b bool) Option {
    40  	return func(f *Formatter) {
    41  		f.preventSurroundingPre = b
    42  
    43  		if b {
    44  			f.preWrapper = nopPreWrapper
    45  		} else {
    46  			f.preWrapper = defaultPreWrapper
    47  		}
    48  	}
    49  }
    50  
    51  // InlineCode creates inline code wrapped in a code tag.
    52  func InlineCode(b bool) Option {
    53  	return func(f *Formatter) {
    54  		f.inlineCode = b
    55  		f.preWrapper = preWrapper{
    56  			start: func(code bool, styleAttr string) string {
    57  				if code {
    58  					return fmt.Sprintf(`<code%s>`, styleAttr)
    59  				}
    60  
    61  				return ``
    62  			},
    63  			end: func(code bool) string {
    64  				if code {
    65  					return `</code>`
    66  				}
    67  
    68  				return ``
    69  			},
    70  		}
    71  	}
    72  }
    73  
    74  // WithPreWrapper allows control of the surrounding pre tags.
    75  func WithPreWrapper(wrapper PreWrapper) Option {
    76  	return func(f *Formatter) {
    77  		f.preWrapper = wrapper
    78  	}
    79  }
    80  
    81  // WrapLongLines wraps long lines.
    82  func WrapLongLines(b bool) Option {
    83  	return func(f *Formatter) {
    84  		f.wrapLongLines = b
    85  	}
    86  }
    87  
    88  // WithLineNumbers formats output with line numbers.
    89  func WithLineNumbers(b bool) Option {
    90  	return func(f *Formatter) {
    91  		f.lineNumbers = b
    92  	}
    93  }
    94  
    95  // LineNumbersInTable will, when combined with WithLineNumbers, separate the line numbers
    96  // and code in table td's, which make them copy-and-paste friendly.
    97  func LineNumbersInTable(b bool) Option {
    98  	return func(f *Formatter) {
    99  		f.lineNumbersInTable = b
   100  	}
   101  }
   102  
   103  // WithLinkableLineNumbers decorates the line numbers HTML elements with an "id"
   104  // attribute so they can be linked.
   105  func WithLinkableLineNumbers(b bool, prefix string) Option {
   106  	return func(f *Formatter) {
   107  		f.linkableLineNumbers = b
   108  		f.lineNumbersIDPrefix = prefix
   109  	}
   110  }
   111  
   112  // HighlightLines higlights the given line ranges with the Highlight style.
   113  //
   114  // A range is the beginning and ending of a range as 1-based line numbers, inclusive.
   115  func HighlightLines(ranges [][2]int) Option {
   116  	return func(f *Formatter) {
   117  		f.highlightRanges = ranges
   118  		sort.Sort(f.highlightRanges)
   119  	}
   120  }
   121  
   122  // BaseLineNumber sets the initial number to start line numbering at. Defaults to 1.
   123  func BaseLineNumber(n int) Option {
   124  	return func(f *Formatter) {
   125  		f.baseLineNumber = n
   126  	}
   127  }
   128  
   129  // New HTML formatter.
   130  func New(options ...Option) *Formatter {
   131  	f := &Formatter{
   132  		baseLineNumber: 1,
   133  		preWrapper:     defaultPreWrapper,
   134  	}
   135  	for _, option := range options {
   136  		option(f)
   137  	}
   138  	return f
   139  }
   140  
   141  // PreWrapper defines the operations supported in WithPreWrapper.
   142  type PreWrapper interface {
   143  	// Start is called to write a start <pre> element.
   144  	// The code flag tells whether this block surrounds
   145  	// highlighted code. This will be false when surrounding
   146  	// line numbers.
   147  	Start(code bool, styleAttr string) string
   148  
   149  	// End is called to write the end </pre> element.
   150  	End(code bool) string
   151  }
   152  
   153  type preWrapper struct {
   154  	start func(code bool, styleAttr string) string
   155  	end   func(code bool) string
   156  }
   157  
   158  func (p preWrapper) Start(code bool, styleAttr string) string {
   159  	return p.start(code, styleAttr)
   160  }
   161  
   162  func (p preWrapper) End(code bool) string {
   163  	return p.end(code)
   164  }
   165  
   166  var (
   167  	nopPreWrapper = preWrapper{
   168  		start: func(code bool, styleAttr string) string { return "" },
   169  		end:   func(code bool) string { return "" },
   170  	}
   171  	defaultPreWrapper = preWrapper{
   172  		start: func(code bool, styleAttr string) string {
   173  			if code {
   174  				return fmt.Sprintf(`<pre%s><code>`, styleAttr)
   175  			}
   176  
   177  			return fmt.Sprintf(`<pre%s>`, styleAttr)
   178  		},
   179  		end: func(code bool) string {
   180  			if code {
   181  				return `</code></pre>`
   182  			}
   183  
   184  			return `</pre>`
   185  		},
   186  	}
   187  )
   188  
   189  // Formatter that generates HTML.
   190  type Formatter struct {
   191  	standalone            bool
   192  	prefix                string
   193  	Classes               bool // Exported field to detect when classes are being used
   194  	allClasses            bool
   195  	customCSS             map[chroma.TokenType]string
   196  	preWrapper            PreWrapper
   197  	inlineCode            bool
   198  	preventSurroundingPre bool
   199  	tabWidth              int
   200  	wrapLongLines         bool
   201  	lineNumbers           bool
   202  	lineNumbersInTable    bool
   203  	linkableLineNumbers   bool
   204  	lineNumbersIDPrefix   string
   205  	highlightRanges       highlightRanges
   206  	baseLineNumber        int
   207  }
   208  
   209  type highlightRanges [][2]int
   210  
   211  func (h highlightRanges) Len() int           { return len(h) }
   212  func (h highlightRanges) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }
   213  func (h highlightRanges) Less(i, j int) bool { return h[i][0] < h[j][0] }
   214  
   215  func (f *Formatter) Format(w io.Writer, style *chroma.Style, iterator chroma.Iterator) (err error) {
   216  	return f.writeHTML(w, style, iterator.Tokens())
   217  }
   218  
   219  // We deliberately don't use html/template here because it is two orders of magnitude slower (benchmarked).
   220  //
   221  // OTOH we need to be super careful about correct escaping...
   222  func (f *Formatter) writeHTML(w io.Writer, style *chroma.Style, tokens []chroma.Token) (err error) { // nolint: gocyclo
   223  	css := f.styleToCSS(style)
   224  	if !f.Classes {
   225  		for t, style := range css {
   226  			css[t] = compressStyle(style)
   227  		}
   228  	}
   229  	if f.standalone {
   230  		fmt.Fprint(w, "<html>\n")
   231  		if f.Classes {
   232  			fmt.Fprint(w, "<style type=\"text/css\">\n")
   233  			err = f.WriteCSS(w, style)
   234  			if err != nil {
   235  				return err
   236  			}
   237  			fmt.Fprintf(w, "body { %s; }\n", css[chroma.Background])
   238  			fmt.Fprint(w, "</style>")
   239  		}
   240  		fmt.Fprintf(w, "<body%s>\n", f.styleAttr(css, chroma.Background))
   241  	}
   242  
   243  	wrapInTable := f.lineNumbers && f.lineNumbersInTable
   244  
   245  	lines := chroma.SplitTokensIntoLines(tokens)
   246  	lineDigits := len(fmt.Sprintf("%d", f.baseLineNumber+len(lines)-1))
   247  	highlightIndex := 0
   248  
   249  	if wrapInTable {
   250  		// List line numbers in its own <td>
   251  		fmt.Fprintf(w, "<div%s>\n", f.styleAttr(css, chroma.PreWrapper))
   252  		fmt.Fprintf(w, "<table%s><tr>", f.styleAttr(css, chroma.LineTable))
   253  		fmt.Fprintf(w, "<td%s>\n", f.styleAttr(css, chroma.LineTableTD))
   254  		fmt.Fprintf(w, f.preWrapper.Start(false, f.styleAttr(css, chroma.PreWrapper)))
   255  		for index := range lines {
   256  			line := f.baseLineNumber + index
   257  			highlight, next := f.shouldHighlight(highlightIndex, line)
   258  			if next {
   259  				highlightIndex++
   260  			}
   261  			if highlight {
   262  				fmt.Fprintf(w, "<span%s>", f.styleAttr(css, chroma.LineHighlight))
   263  			}
   264  
   265  			fmt.Fprintf(w, "<span%s%s>%s\n</span>", f.styleAttr(css, chroma.LineNumbersTable), f.lineIDAttribute(line), f.lineTitleWithLinkIfNeeded(css, lineDigits, line))
   266  
   267  			if highlight {
   268  				fmt.Fprintf(w, "</span>")
   269  			}
   270  		}
   271  		fmt.Fprint(w, f.preWrapper.End(false))
   272  		fmt.Fprint(w, "</td>\n")
   273  		fmt.Fprintf(w, "<td%s>\n", f.styleAttr(css, chroma.LineTableTD, "width:100%"))
   274  	}
   275  
   276  	fmt.Fprintf(w, f.preWrapper.Start(true, f.styleAttr(css, chroma.PreWrapper)))
   277  
   278  	highlightIndex = 0
   279  	for index, tokens := range lines {
   280  		// 1-based line number.
   281  		line := f.baseLineNumber + index
   282  		highlight, next := f.shouldHighlight(highlightIndex, line)
   283  		if next {
   284  			highlightIndex++
   285  		}
   286  
   287  		if !(f.preventSurroundingPre || f.inlineCode) {
   288  			// Start of Line
   289  			fmt.Fprint(w, `<span`)
   290  
   291  			if highlight {
   292  				// Line + LineHighlight
   293  				if f.Classes {
   294  					fmt.Fprintf(w, ` class="%s %s"`, f.class(chroma.Line), f.class(chroma.LineHighlight))
   295  				} else {
   296  					fmt.Fprintf(w, ` style="%s %s"`, css[chroma.Line], css[chroma.LineHighlight])
   297  				}
   298  				fmt.Fprint(w, `>`)
   299  			} else {
   300  				fmt.Fprintf(w, "%s>", f.styleAttr(css, chroma.Line))
   301  			}
   302  
   303  			// Line number
   304  			if f.lineNumbers && !wrapInTable {
   305  				fmt.Fprintf(w, "<span%s%s>%s</span>", f.styleAttr(css, chroma.LineNumbers), f.lineIDAttribute(line), f.lineTitleWithLinkIfNeeded(css, lineDigits, line))
   306  			}
   307  
   308  			fmt.Fprintf(w, `<span%s>`, f.styleAttr(css, chroma.CodeLine))
   309  		}
   310  
   311  		for _, token := range tokens {
   312  			html := html.EscapeString(token.String())
   313  			attr := f.styleAttr(css, token.Type)
   314  			if attr != "" {
   315  				html = fmt.Sprintf("<span%s>%s</span>", attr, html)
   316  			}
   317  			fmt.Fprint(w, html)
   318  		}
   319  
   320  		if !(f.preventSurroundingPre || f.inlineCode) {
   321  			fmt.Fprint(w, `</span>`) // End of CodeLine
   322  
   323  			fmt.Fprint(w, `</span>`) // End of Line
   324  		}
   325  	}
   326  	fmt.Fprintf(w, f.preWrapper.End(true))
   327  
   328  	if wrapInTable {
   329  		fmt.Fprint(w, "</td></tr></table>\n")
   330  		fmt.Fprint(w, "</div>\n")
   331  	}
   332  
   333  	if f.standalone {
   334  		fmt.Fprint(w, "\n</body>\n")
   335  		fmt.Fprint(w, "</html>\n")
   336  	}
   337  
   338  	return nil
   339  }
   340  
   341  func (f *Formatter) lineIDAttribute(line int) string {
   342  	if !f.linkableLineNumbers {
   343  		return ""
   344  	}
   345  	return fmt.Sprintf(" id=\"%s\"", f.lineID(line))
   346  }
   347  
   348  func (f *Formatter) lineTitleWithLinkIfNeeded(css map[chroma.TokenType]string, lineDigits, line int) string {
   349  	title := fmt.Sprintf("%*d", lineDigits, line)
   350  	if !f.linkableLineNumbers {
   351  		return title
   352  	}
   353  	return fmt.Sprintf("<a%s href=\"#%s\">%s</a>", f.styleAttr(css, chroma.LineLink), f.lineID(line), title)
   354  }
   355  
   356  func (f *Formatter) lineID(line int) string {
   357  	return fmt.Sprintf("%s%d", f.lineNumbersIDPrefix, line)
   358  }
   359  
   360  func (f *Formatter) shouldHighlight(highlightIndex, line int) (bool, bool) {
   361  	next := false
   362  	for highlightIndex < len(f.highlightRanges) && line > f.highlightRanges[highlightIndex][1] {
   363  		highlightIndex++
   364  		next = true
   365  	}
   366  	if highlightIndex < len(f.highlightRanges) {
   367  		hrange := f.highlightRanges[highlightIndex]
   368  		if line >= hrange[0] && line <= hrange[1] {
   369  			return true, next
   370  		}
   371  	}
   372  	return false, next
   373  }
   374  
   375  func (f *Formatter) class(t chroma.TokenType) string {
   376  	for t != 0 {
   377  		if cls, ok := chroma.StandardTypes[t]; ok {
   378  			if cls != "" {
   379  				return f.prefix + cls
   380  			}
   381  			return ""
   382  		}
   383  		t = t.Parent()
   384  	}
   385  	if cls := chroma.StandardTypes[t]; cls != "" {
   386  		return f.prefix + cls
   387  	}
   388  	return ""
   389  }
   390  
   391  func (f *Formatter) styleAttr(styles map[chroma.TokenType]string, tt chroma.TokenType, extraCSS ...string) string {
   392  	if f.Classes {
   393  		cls := f.class(tt)
   394  		if cls == "" {
   395  			return ""
   396  		}
   397  		return fmt.Sprintf(` class="%s"`, cls)
   398  	}
   399  	if _, ok := styles[tt]; !ok {
   400  		tt = tt.SubCategory()
   401  		if _, ok := styles[tt]; !ok {
   402  			tt = tt.Category()
   403  			if _, ok := styles[tt]; !ok {
   404  				return ""
   405  			}
   406  		}
   407  	}
   408  	css := []string{styles[tt]}
   409  	css = append(css, extraCSS...)
   410  	return fmt.Sprintf(` style="%s"`, strings.Join(css, ";"))
   411  }
   412  
   413  func (f *Formatter) tabWidthStyle() string {
   414  	if f.tabWidth != 0 && f.tabWidth != 8 {
   415  		return fmt.Sprintf("-moz-tab-size: %[1]d; -o-tab-size: %[1]d; tab-size: %[1]d;", f.tabWidth)
   416  	}
   417  	return ""
   418  }
   419  
   420  // WriteCSS writes CSS style definitions (without any surrounding HTML).
   421  func (f *Formatter) WriteCSS(w io.Writer, style *chroma.Style) error {
   422  	css := f.styleToCSS(style)
   423  	// Special-case background as it is mapped to the outer ".chroma" class.
   424  	if _, err := fmt.Fprintf(w, "/* %s */ .%sbg { %s }\n", chroma.Background, f.prefix, css[chroma.Background]); err != nil {
   425  		return err
   426  	}
   427  	// Special-case PreWrapper as it is the ".chroma" class.
   428  	if _, err := fmt.Fprintf(w, "/* %s */ .%schroma { %s }\n", chroma.PreWrapper, f.prefix, css[chroma.PreWrapper]); err != nil {
   429  		return err
   430  	}
   431  	// Special-case code column of table to expand width.
   432  	if f.lineNumbers && f.lineNumbersInTable {
   433  		if _, err := fmt.Fprintf(w, "/* %s */ .%schroma .%s:last-child { width: 100%%; }",
   434  			chroma.LineTableTD, f.prefix, f.class(chroma.LineTableTD)); err != nil {
   435  			return err
   436  		}
   437  	}
   438  	// Special-case line number highlighting when targeted.
   439  	if f.lineNumbers || f.lineNumbersInTable {
   440  		targetedLineCSS := StyleEntryToCSS(style.Get(chroma.LineHighlight))
   441  		for _, tt := range []chroma.TokenType{chroma.LineNumbers, chroma.LineNumbersTable} {
   442  			fmt.Fprintf(w, "/* %s targeted by URL anchor */ .%schroma .%s:target { %s }\n", tt, f.prefix, f.class(tt), targetedLineCSS)
   443  		}
   444  	}
   445  	tts := []int{}
   446  	for tt := range css {
   447  		tts = append(tts, int(tt))
   448  	}
   449  	sort.Ints(tts)
   450  	for _, ti := range tts {
   451  		tt := chroma.TokenType(ti)
   452  		switch tt {
   453  		case chroma.Background, chroma.PreWrapper:
   454  			continue
   455  		}
   456  		class := f.class(tt)
   457  		if class == "" {
   458  			continue
   459  		}
   460  		styles := css[tt]
   461  		if _, err := fmt.Fprintf(w, "/* %s */ .%schroma .%s { %s }\n", tt, f.prefix, class, styles); err != nil {
   462  			return err
   463  		}
   464  	}
   465  	return nil
   466  }
   467  
   468  func (f *Formatter) styleToCSS(style *chroma.Style) map[chroma.TokenType]string {
   469  	classes := map[chroma.TokenType]string{}
   470  	bg := style.Get(chroma.Background)
   471  	// Convert the style.
   472  	for t := range chroma.StandardTypes {
   473  		entry := style.Get(t)
   474  		if t != chroma.Background {
   475  			entry = entry.Sub(bg)
   476  		}
   477  
   478  		// Inherit from custom CSS provided by user
   479  		tokenCategory := t.Category()
   480  		tokenSubCategory := t.SubCategory()
   481  		if t != tokenCategory {
   482  			if css, ok := f.customCSS[tokenCategory]; ok {
   483  				classes[t] = css
   484  			}
   485  		}
   486  		if tokenCategory != tokenSubCategory {
   487  			if css, ok := f.customCSS[tokenSubCategory]; ok {
   488  				classes[t] += css
   489  			}
   490  		}
   491  		// Add custom CSS provided by user
   492  		if css, ok := f.customCSS[t]; ok {
   493  			classes[t] += css
   494  		}
   495  
   496  		if !f.allClasses && entry.IsZero() && classes[t] == `` {
   497  			continue
   498  		}
   499  
   500  		styleEntryCSS := StyleEntryToCSS(entry)
   501  		if styleEntryCSS != `` && classes[t] != `` {
   502  			styleEntryCSS += `;`
   503  		}
   504  		classes[t] = styleEntryCSS + classes[t]
   505  	}
   506  	classes[chroma.Background] += `;` + f.tabWidthStyle()
   507  	classes[chroma.PreWrapper] += classes[chroma.Background]
   508  	// Make PreWrapper a grid to show highlight style with full width.
   509  	if len(f.highlightRanges) > 0 && f.customCSS[chroma.PreWrapper] == `` {
   510  		classes[chroma.PreWrapper] += `display: grid;`
   511  	}
   512  	// Make PreWrapper wrap long lines.
   513  	if f.wrapLongLines {
   514  		classes[chroma.PreWrapper] += `white-space: pre-wrap; word-break: break-word;`
   515  	}
   516  	lineNumbersStyle := `white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;`
   517  	// All rules begin with default rules followed by user provided rules
   518  	classes[chroma.Line] = `display: flex;` + classes[chroma.Line]
   519  	classes[chroma.LineNumbers] = lineNumbersStyle + classes[chroma.LineNumbers]
   520  	classes[chroma.LineNumbersTable] = lineNumbersStyle + classes[chroma.LineNumbersTable]
   521  	classes[chroma.LineTable] = "border-spacing: 0; padding: 0; margin: 0; border: 0;" + classes[chroma.LineTable]
   522  	classes[chroma.LineTableTD] = "vertical-align: top; padding: 0; margin: 0; border: 0;" + classes[chroma.LineTableTD]
   523  	classes[chroma.LineLink] = "outline: none; text-decoration: none; color: inherit" + classes[chroma.LineLink]
   524  	return classes
   525  }
   526  
   527  // StyleEntryToCSS converts a chroma.StyleEntry to CSS attributes.
   528  func StyleEntryToCSS(e chroma.StyleEntry) string {
   529  	styles := []string{}
   530  	if e.Colour.IsSet() {
   531  		styles = append(styles, "color: "+e.Colour.String())
   532  	}
   533  	if e.Background.IsSet() {
   534  		styles = append(styles, "background-color: "+e.Background.String())
   535  	}
   536  	if e.Bold == chroma.Yes {
   537  		styles = append(styles, "font-weight: bold")
   538  	}
   539  	if e.Italic == chroma.Yes {
   540  		styles = append(styles, "font-style: italic")
   541  	}
   542  	if e.Underline == chroma.Yes {
   543  		styles = append(styles, "text-decoration: underline")
   544  	}
   545  	return strings.Join(styles, "; ")
   546  }
   547  
   548  // Compress CSS attributes - remove spaces, transform 6-digit colours to 3.
   549  func compressStyle(s string) string {
   550  	parts := strings.Split(s, ";")
   551  	out := []string{}
   552  	for _, p := range parts {
   553  		p = strings.Join(strings.Fields(p), " ")
   554  		p = strings.Replace(p, ": ", ":", 1)
   555  		if strings.Contains(p, "#") {
   556  			c := p[len(p)-6:]
   557  			if c[0] == c[1] && c[2] == c[3] && c[4] == c[5] {
   558  				p = p[:len(p)-6] + c[0:1] + c[2:3] + c[4:5]
   559  			}
   560  		}
   561  		out = append(out, p)
   562  	}
   563  	return strings.Join(out, ";")
   564  }
   565  

View as plain text