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("",
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}