renderer.go

  1package markdown
  2
  3import (
  4	"bytes"
  5	"fmt"
  6	stdcolor "image/color"
  7	"io"
  8	"math"
  9	"net/http"
 10	"os"
 11	"strings"
 12	"unicode"
 13
 14	"github.com/MichaelMure/go-term-text"
 15	"github.com/alecthomas/chroma"
 16	"github.com/alecthomas/chroma/formatters"
 17	"github.com/alecthomas/chroma/lexers"
 18	"github.com/alecthomas/chroma/styles"
 19	"github.com/eliukblau/pixterm/ansimage"
 20	"github.com/fatih/color"
 21	md "github.com/gomarkdown/markdown"
 22	"github.com/gomarkdown/markdown/ast"
 23	"github.com/kyokomi/emoji"
 24	"golang.org/x/net/html"
 25)
 26
 27/*
 28
 29Here are the possible cases for the AST. You can render it using PlantUML.
 30
 31@startuml
 32
 33(*) --> Document
 34BlockQuote --> BlockQuote
 35BlockQuote --> CodeBlock
 36BlockQuote --> List
 37BlockQuote --> Paragraph
 38Del --> Emph
 39Del --> Strong
 40Del --> Text
 41Document --> BlockQuote
 42Document --> CodeBlock
 43Document --> Heading
 44Document --> HorizontalRule
 45Document --> HTMLBlock
 46Document --> List
 47Document --> Paragraph
 48Document --> Table
 49Emph --> Text
 50Heading --> Code
 51Heading --> Del
 52Heading --> Emph
 53Heading --> HTMLSpan
 54Heading --> Image
 55Heading --> Link
 56Heading --> Strong
 57Heading --> Text
 58Image --> Text
 59Link --> Image
 60Link --> Text
 61ListItem --> List
 62ListItem --> Paragraph
 63List --> ListItem
 64Paragraph --> Code
 65Paragraph --> Del
 66Paragraph --> Emph
 67Paragraph --> Hardbreak
 68Paragraph --> HTMLSpan
 69Paragraph --> Image
 70Paragraph --> Link
 71Paragraph --> Strong
 72Paragraph --> Text
 73Strong --> Emph
 74Strong --> Text
 75TableBody --> TableRow
 76TableCell --> Code
 77TableCell --> Del
 78TableCell --> Emph
 79TableCell --> HTMLSpan
 80TableCell --> Image
 81TableCell --> Link
 82TableCell --> Strong
 83TableCell --> Text
 84TableHeader --> TableRow
 85TableRow --> TableCell
 86Table --> TableBody
 87Table --> TableHeader
 88
 89@enduml
 90
 91*/
 92
 93var _ md.Renderer = &renderer{}
 94
 95type renderer struct {
 96	// maximum line width allowed
 97	lineWidth int
 98	// constant left padding to apply
 99	leftPad int
100	// Dithering mode for ansimage
101	// Default is fine directly through a terminal
102	// DitheringWithBlocks is recommended if a terminal UI library is used
103	imageDithering ansimage.DitheringMode
104
105	// all the custom left paddings, without the fixed space from leftPad
106	padAccumulator []string
107
108	// one-shot indent for the first line of the inline content
109	indent string
110
111	// for Heading, Paragraph, HTMLBlock and TableCell, accumulate the content of
112	// the child nodes (Link, Text, Image, formatting ...). The result
113	// is then rendered appropriately when exiting the node.
114	inlineAccumulator strings.Builder
115	inlineAlign       text.Alignment
116
117	// record and render the heading numbering
118	headingNumbering headingNumbering
119
120	blockQuoteLevel int
121
122	table *tableRenderer
123}
124
125func newRenderer(lineWidth int, leftPad int, opts ...Options) *renderer {
126	r := &renderer{
127		lineWidth:      lineWidth,
128		leftPad:        leftPad,
129		padAccumulator: make([]string, 0, 10),
130	}
131	for _, opt := range opts {
132		opt(r)
133	}
134	return r
135}
136
137func (r *renderer) pad() string {
138	return strings.Repeat(" ", r.leftPad) + strings.Join(r.padAccumulator, "")
139}
140
141func (r *renderer) addPad(pad string) {
142	r.padAccumulator = append(r.padAccumulator, pad)
143}
144
145func (r *renderer) popPad() {
146	r.padAccumulator = r.padAccumulator[:len(r.padAccumulator)-1]
147}
148
149func (r *renderer) RenderNode(w io.Writer, node ast.Node, entering bool) ast.WalkStatus {
150	// TODO: remove
151	// fmt.Printf("%T, %v\n", node, entering)
152
153	switch node := node.(type) {
154	case *ast.Document:
155		// Nothing to do
156
157	case *ast.BlockQuote:
158		// set and remove a colored bar on the left
159		if entering {
160			r.blockQuoteLevel++
161			r.addPad(quoteShade(r.blockQuoteLevel)("┃ "))
162		} else {
163			r.blockQuoteLevel--
164			r.popPad()
165		}
166
167	case *ast.List:
168		if next := ast.GetNextNode(node); !entering && next != nil {
169			_, parentIsListItem := node.GetParent().(*ast.ListItem)
170			_, nextIsList := next.(*ast.List)
171			if !nextIsList && !parentIsListItem {
172				_, _ = fmt.Fprintln(w)
173			}
174		}
175
176	case *ast.ListItem:
177		// write the prefix, add a padding if needed, and let Paragraph handle the rest
178		if entering {
179			switch {
180			// numbered list
181			case node.ListFlags&ast.ListTypeOrdered != 0:
182				itemNumber := 1
183				siblings := node.GetParent().GetChildren()
184				for _, sibling := range siblings {
185					if sibling == node {
186						break
187					}
188					itemNumber++
189				}
190				prefix := fmt.Sprintf("%d. ", itemNumber)
191				r.indent = r.pad() + Green(prefix)
192				r.addPad(strings.Repeat(" ", text.Len(prefix)))
193
194			// header of a definition
195			case node.ListFlags&ast.ListTypeTerm != 0:
196				r.inlineAccumulator.WriteString(greenOn)
197
198			// content of a definition
199			case node.ListFlags&ast.ListTypeDefinition != 0:
200				r.addPad("  ")
201
202			// no flags means it's the normal bullet point list
203			default:
204				r.indent = r.pad() + Green("• ")
205				r.addPad("  ")
206			}
207		} else {
208			switch {
209			// numbered list
210			case node.ListFlags&ast.ListTypeOrdered != 0:
211				r.popPad()
212
213			// header of a definition
214			case node.ListFlags&ast.ListTypeTerm != 0:
215				r.inlineAccumulator.WriteString(colorOff)
216
217			// content of a definition
218			case node.ListFlags&ast.ListTypeDefinition != 0:
219				r.popPad()
220				_, _ = fmt.Fprintln(w)
221
222			// no flags means it's the normal bullet point list
223			default:
224				r.popPad()
225			}
226		}
227
228	case *ast.Paragraph:
229		// on exiting, collect and format the accumulated content
230		if !entering {
231			content := r.inlineAccumulator.String()
232			r.inlineAccumulator.Reset()
233
234			var out string
235			if r.indent != "" {
236				out, _ = text.WrapWithPadIndent(content, r.lineWidth, r.indent, r.pad())
237				r.indent = ""
238			} else {
239				out, _ = text.WrapWithPad(content, r.lineWidth, r.pad())
240			}
241			_, _ = fmt.Fprint(w, out, "\n")
242
243			// extra line break in some cases
244			if next := ast.GetNextNode(node); next != nil {
245				switch next.(type) {
246				case *ast.Paragraph, *ast.Heading, *ast.HorizontalRule,
247					*ast.CodeBlock, *ast.HTMLBlock:
248					_, _ = fmt.Fprintln(w)
249				}
250			}
251		}
252
253	case *ast.Heading:
254		if !entering {
255			r.renderHeading(w, node.Level)
256		}
257
258	case *ast.HorizontalRule:
259		r.renderHorizontalRule(w)
260
261	case *ast.Emph:
262		if entering {
263			r.inlineAccumulator.WriteString(italicOn)
264		} else {
265			r.inlineAccumulator.WriteString(italicOff)
266		}
267
268	case *ast.Strong:
269		if entering {
270			r.inlineAccumulator.WriteString(boldOn)
271		} else {
272			r.inlineAccumulator.WriteString(boldOff)
273		}
274
275	case *ast.Del:
276		if entering {
277			r.inlineAccumulator.WriteString(crossedOutOn)
278		} else {
279			r.inlineAccumulator.WriteString(crossedOutOff)
280		}
281
282	case *ast.Link:
283		if entering {
284			r.inlineAccumulator.WriteString("[")
285			r.inlineAccumulator.WriteString(string(ast.GetFirstChild(node).AsLeaf().Literal))
286			r.inlineAccumulator.WriteString("](")
287			r.inlineAccumulator.WriteString(Blue(string(node.Destination)))
288			if len(node.Title) > 0 {
289				r.inlineAccumulator.WriteString(" ")
290				r.inlineAccumulator.WriteString(string(node.Title))
291			}
292			r.inlineAccumulator.WriteString(")")
293			return ast.SkipChildren
294		}
295
296	case *ast.Image:
297		if entering {
298			var title string
299
300			// the alt text/title is weirdly parsed and is actually
301			// a child text of this node
302			if len(node.Children) == 1 {
303				if t, ok := node.Children[0].(*ast.Text); ok {
304					title = string(t.Literal)
305				}
306			}
307
308			info := fmt.Sprintf("![%s](%s)",
309				Green(string(node.Destination)), Blue(title))
310
311			switch node.GetParent().(type) {
312			case *ast.Paragraph:
313				rendered, err := r.renderImage(
314					string(node.Destination), title,
315					r.lineWidth-r.leftPad,
316				)
317				if err != nil {
318					r.inlineAccumulator.WriteString(Red(fmt.Sprintf("|%s|", err)))
319					r.inlineAccumulator.WriteString("\n")
320					r.inlineAccumulator.WriteString(info)
321					if ast.GetNextNode(node) == nil {
322						r.inlineAccumulator.WriteString("\n")
323					}
324					return ast.SkipChildren
325				}
326
327				r.inlineAccumulator.WriteString(rendered)
328				r.inlineAccumulator.WriteString(info)
329				if ast.GetNextNode(node) == nil {
330					r.inlineAccumulator.WriteString("\n")
331				}
332
333			default:
334				r.inlineAccumulator.WriteString(info)
335			}
336			return ast.SkipChildren
337		}
338
339	case *ast.Text:
340		if string(node.Literal) == "\n" {
341			break
342		}
343		content := string(node.Literal)
344		if shouldCleanText(node) {
345			content = removeLineBreak(content)
346		}
347		// emoji support !
348		emojed := emoji.Sprint(content)
349		r.inlineAccumulator.WriteString(emojed)
350
351	case *ast.HTMLBlock:
352		r.renderHTMLBlock(w, node)
353
354	case *ast.CodeBlock:
355		r.renderCodeBlock(w, node)
356
357	case *ast.Softbreak:
358		// not actually implemented in gomarkdown
359		r.inlineAccumulator.WriteString("\n")
360
361	case *ast.Hardbreak:
362		r.inlineAccumulator.WriteString("\n")
363
364	case *ast.Code:
365		r.inlineAccumulator.WriteString(BlueBgItalic(string(node.Literal)))
366
367	case *ast.HTMLSpan:
368		r.inlineAccumulator.WriteString(Red(string(node.Literal)))
369
370	case *ast.Table:
371		if entering {
372			r.table = newTableRenderer()
373		} else {
374			r.table.Render(w, r.leftPad, r.lineWidth)
375			r.table = nil
376		}
377
378	case *ast.TableCell:
379		if !entering {
380			content := r.inlineAccumulator.String()
381			r.inlineAccumulator.Reset()
382
383			if node.IsHeader {
384				r.table.AddHeaderCell(content, node.Align)
385			} else {
386				r.table.AddBodyCell(content)
387			}
388		}
389
390	case *ast.TableHeader:
391		// nothing to do
392
393	case *ast.TableBody:
394		// nothing to do
395
396	case *ast.TableRow:
397		if _, ok := node.Parent.(*ast.TableBody); ok && entering {
398			r.table.NextBodyRow()
399		}
400
401	default:
402		panic(fmt.Sprintf("Unknown node type %T", node))
403	}
404
405	return ast.GoToNext
406}
407
408func (*renderer) RenderHeader(w io.Writer, node ast.Node) {}
409
410func (*renderer) RenderFooter(w io.Writer, node ast.Node) {}
411
412func (r *renderer) renderHorizontalRule(w io.Writer) {
413	_, _ = fmt.Fprintf(w, "%s%s\n\n", r.pad(), strings.Repeat("─", r.lineWidth-r.leftPad))
414}
415
416func (r *renderer) renderHeading(w io.Writer, level int) {
417	content := r.inlineAccumulator.String()
418	r.inlineAccumulator.Reset()
419
420	// render the full line with the headingNumbering
421	r.headingNumbering.Observe(level)
422	content = fmt.Sprintf("%s %s", r.headingNumbering.Render(), content)
423	content = headingShade(level)(content)
424
425	// wrap if needed
426	wrapped, _ := text.WrapWithPad(content, r.lineWidth, r.pad())
427	_, _ = fmt.Fprintln(w, wrapped)
428
429	// render the underline, if any
430	if level == 1 {
431		_, _ = fmt.Fprintf(w, "%s%s\n", r.pad(), strings.Repeat("─", r.lineWidth-r.leftPad))
432	}
433
434	_, _ = fmt.Fprintln(w)
435}
436
437func (r *renderer) renderCodeBlock(w io.Writer, node *ast.CodeBlock) {
438	code := string(node.Literal)
439	var lexer chroma.Lexer
440	// try to get the lexer from the language tag if any
441	if len(node.Info) > 0 {
442		lexer = lexers.Get(string(node.Info))
443	}
444	// fallback on detection
445	if lexer == nil {
446		lexer = lexers.Analyse(code)
447	}
448	// all failed :-(
449	if lexer == nil {
450		lexer = lexers.Fallback
451	}
452	// simplify the lexer output
453	lexer = chroma.Coalesce(lexer)
454
455	var formatter chroma.Formatter
456	if color.NoColor {
457		formatter = formatters.Fallback
458	} else {
459		formatter = formatters.TTY8
460	}
461
462	iterator, err := lexer.Tokenise(nil, code)
463	if err != nil {
464		// Something failed, falling back to no highlight render
465		r.renderFormattedCodeBlock(w, code)
466		return
467	}
468
469	buf := &bytes.Buffer{}
470
471	err = formatter.Format(buf, styles.Pygments, iterator)
472	if err != nil {
473		// Something failed, falling back to no highlight render
474		r.renderFormattedCodeBlock(w, code)
475		return
476	}
477
478	r.renderFormattedCodeBlock(w, buf.String())
479}
480
481func (r *renderer) renderFormattedCodeBlock(w io.Writer, code string) {
482	// remove the trailing line break
483	code = strings.TrimRight(code, "\n")
484
485	r.addPad(GreenBold("┃ "))
486	output, _ := text.WrapWithPad(code, r.lineWidth, r.pad())
487	r.popPad()
488
489	_, _ = fmt.Fprint(w, output)
490
491	_, _ = fmt.Fprintf(w, "\n\n")
492}
493
494func (r *renderer) renderHTMLBlock(w io.Writer, node *ast.HTMLBlock) {
495	z := html.NewTokenizer(bytes.NewReader(node.Literal))
496
497	var buf bytes.Buffer
498
499	flushInline := func() {
500		if r.inlineAccumulator.Len() <= 0 {
501			return
502		}
503		content := r.inlineAccumulator.String()
504		r.inlineAccumulator.Reset()
505		out, _ := text.WrapWithPad(content, r.lineWidth, r.pad())
506		_, _ = fmt.Fprint(&buf, out, "\n\n")
507	}
508
509	for {
510		switch z.Next() {
511		case html.ErrorToken:
512			if z.Err() == io.EOF {
513				// normal end of the block
514				flushInline()
515				_, _ = fmt.Fprint(w, buf.String())
516				return
517			}
518			// if there is another error, fallback to a simple render
519			r.inlineAccumulator.Reset()
520
521			content := Red(string(node.Literal))
522			out, _ := text.WrapWithPad(content, r.lineWidth, r.pad())
523			_, _ = fmt.Fprint(w, out, "\n\n")
524			return
525
526		case html.TextToken:
527			t := z.Text()
528			if strings.TrimSpace(string(t)) == "" {
529				continue
530			}
531			r.inlineAccumulator.Write(t)
532
533		case html.StartTagToken: // <tag ...>
534			name, _ := z.TagName()
535			switch string(name) {
536
537			case "hr":
538				flushInline()
539				r.renderHorizontalRule(&buf)
540
541			case "div":
542				flushInline()
543				// align left by default
544				r.inlineAlign = text.AlignLeft
545				r.handleDivHTMLAttr(z)
546
547			case "h1", "h2", "h3", "h4", "h5", "h6":
548				// handled in closing tag
549				flushInline()
550
551			case "img":
552				flushInline()
553				src, title := getImgHTMLAttr(z)
554				rendered, err := r.renderImage(src, title, r.lineWidth-r.leftPad)
555				if err != nil {
556					r.inlineAccumulator.WriteString(Red(string(z.Raw())))
557					continue
558				}
559				padded := text.LeftPadLines(rendered, r.leftPad)
560				_, _ = fmt.Fprintln(&buf, padded)
561
562			// ol + li
563			// dl + (dt+dd)
564			// ul + li
565
566			// a
567			// p
568
569			// details
570			// summary
571
572			default:
573				r.inlineAccumulator.WriteString(Red(string(z.Raw())))
574			}
575		case html.EndTagToken: // </tag>
576			name, _ := z.TagName()
577			switch string(name) {
578
579			case "h1":
580				r.renderHeading(&buf, 1)
581			case "h2":
582				r.renderHeading(&buf, 2)
583			case "h3":
584				r.renderHeading(&buf, 3)
585			case "h4":
586				r.renderHeading(&buf, 4)
587			case "h5":
588				r.renderHeading(&buf, 5)
589			case "h6":
590				r.renderHeading(&buf, 6)
591
592			case "div":
593				content := r.inlineAccumulator.String()
594				r.inlineAccumulator.Reset()
595				if len(content) == 0 {
596					continue
597				}
598				// remove all line breaks, those are fully managed in HTML
599				content = strings.Replace(content, "\n", "", -1)
600				content, _ = text.WrapWithPadAlign(content, r.lineWidth, r.pad(), r.inlineAlign)
601				_, _ = fmt.Fprint(&buf, content, "\n\n")
602				r.inlineAlign = text.NoAlign
603
604			case "hr", "img":
605				// handled in opening tag
606
607			default:
608				r.inlineAccumulator.WriteString(Red(string(z.Raw())))
609			}
610
611		case html.SelfClosingTagToken: // <tag ... />
612			name, _ := z.TagName()
613			switch string(name) {
614			case "hr":
615				flushInline()
616				r.renderHorizontalRule(&buf)
617
618			default:
619				r.inlineAccumulator.WriteString(Red(string(z.Raw())))
620			}
621
622		case html.CommentToken, html.DoctypeToken:
623			// Not rendered
624
625		default:
626			panic("unhandled case")
627		}
628	}
629}
630
631func (r *renderer) handleDivHTMLAttr(z *html.Tokenizer) {
632	for {
633		key, value, more := z.TagAttr()
634		switch string(key) {
635		case "align":
636			switch string(value) {
637			case "left":
638				r.inlineAlign = text.AlignLeft
639			case "center":
640				r.inlineAlign = text.AlignCenter
641			case "right":
642				r.inlineAlign = text.AlignRight
643			}
644		}
645
646		if !more {
647			break
648		}
649	}
650}
651
652func getImgHTMLAttr(z *html.Tokenizer) (src, title string) {
653	for {
654		key, value, more := z.TagAttr()
655		switch string(key) {
656		case "src":
657			src = string(value)
658		case "alt":
659			title = string(value)
660		}
661
662		if !more {
663			break
664		}
665	}
666	return
667}
668
669func (r *renderer) renderImage(dest string, title string, lineWidth int) (string, error) {
670	reader, err := imageFromDestination(dest)
671	if err != nil {
672		return "", fmt.Errorf("failed to open: %v", err)
673	}
674
675	x := r.lineWidth - r.leftPad
676
677	if r.imageDithering == ansimage.DitheringWithChars || r.imageDithering == ansimage.DitheringWithBlocks {
678		// not sure why this is needed by ansimage
679		x *= 4
680	}
681
682	img, err := ansimage.NewScaledFromReader(reader, math.MaxInt32, x,
683		stdcolor.Black, ansimage.ScaleModeFit, r.imageDithering)
684
685	if err != nil {
686		return "", fmt.Errorf("failed to open: %v", err)
687	}
688
689	return img.Render(), nil
690}
691
692func imageFromDestination(dest string) (io.ReadCloser, error) {
693	if strings.HasPrefix(dest, "http://") || strings.HasPrefix(dest, "https://") {
694		res, err := http.Get(dest)
695		if err != nil {
696			return nil, err
697		}
698		if res.StatusCode != http.StatusOK {
699			return nil, fmt.Errorf("http: %v", http.StatusText(res.StatusCode))
700		}
701
702		return res.Body, nil
703	}
704
705	return os.Open(dest)
706}
707
708func removeLineBreak(text string) string {
709	lines := strings.Split(text, "\n")
710
711	if len(lines) <= 1 {
712		return text
713	}
714
715	for i, l := range lines {
716		switch i {
717		case 0:
718			lines[i] = strings.TrimRightFunc(l, unicode.IsSpace)
719		case len(lines) - 1:
720			lines[i] = strings.TrimLeftFunc(l, unicode.IsSpace)
721		default:
722			lines[i] = strings.TrimFunc(l, unicode.IsSpace)
723		}
724	}
725	return strings.Join(lines, " ")
726}
727
728func shouldCleanText(node ast.Node) bool {
729	for node != nil {
730		switch node.(type) {
731		case *ast.BlockQuote:
732			return false
733
734		case *ast.Heading, *ast.Image, *ast.Link,
735			*ast.TableCell, *ast.Document, *ast.ListItem:
736			return true
737		}
738
739		node = node.GetParent()
740	}
741
742	panic("bad markdown document or missing case")
743}