You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

html.go 13KB

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