| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222 |
- package widget
- import (
- "io"
- "net/url"
- "strings"
- "github.com/yuin/goldmark"
- "github.com/yuin/goldmark/ast"
- "github.com/yuin/goldmark/renderer"
- "fyne.io/fyne/v2"
- "fyne.io/fyne/v2/storage"
- )
- // NewRichTextFromMarkdown configures a RichText widget by parsing the provided markdown content.
- //
- // Since: 2.1
- func NewRichTextFromMarkdown(content string) *RichText {
- return NewRichText(parseMarkdown(content)...)
- }
- // ParseMarkdown allows setting the content of this RichText widget from a markdown string.
- // It will replace the content of this widget similarly to SetText, but with the appropriate formatting.
- func (t *RichText) ParseMarkdown(content string) {
- t.Segments = parseMarkdown(content)
- t.Refresh()
- }
- type markdownRenderer struct {
- blockquote bool
- heading bool
- nextSeg RichTextSegment
- parentStack [][]RichTextSegment
- segs []RichTextSegment
- }
- func (m *markdownRenderer) AddOptions(...renderer.Option) {}
- func (m *markdownRenderer) Render(_ io.Writer, source []byte, n ast.Node) error {
- m.nextSeg = &TextSegment{}
- err := ast.Walk(n, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
- if !entering {
- if n.Kind().String() == "Heading" {
- m.segs = append(m.segs, m.nextSeg)
- m.heading = false
- }
- return ast.WalkContinue, m.handleExitNode(n)
- }
- switch n.Kind().String() {
- case "List":
- // prepare a new child level
- m.parentStack = append(m.parentStack, m.segs)
- m.segs = nil
- case "ListItem":
- // prepare a new item level
- m.parentStack = append(m.parentStack, m.segs)
- m.segs = nil
- case "Heading":
- m.heading = true
- switch n.(*ast.Heading).Level {
- case 1:
- m.nextSeg = &TextSegment{
- Style: RichTextStyleHeading,
- }
- case 2:
- m.nextSeg = &TextSegment{
- Style: RichTextStyleSubHeading,
- }
- default:
- m.nextSeg = &TextSegment{
- Style: RichTextStyleParagraph,
- }
- m.nextSeg.(*TextSegment).Style.TextStyle.Bold = true
- }
- case "HorizontalRule", "ThematicBreak":
- m.segs = append(m.segs, &SeparatorSegment{})
- case "Link":
- m.nextSeg = makeLink(n.(*ast.Link))
- case "Paragraph":
- m.nextSeg = &TextSegment{
- Style: RichTextStyleInline, // we make it a paragraph at the end if there are no more elements
- }
- if m.blockquote {
- m.nextSeg.(*TextSegment).Style = RichTextStyleBlockquote
- }
- case "CodeSpan":
- m.nextSeg = &TextSegment{
- Style: RichTextStyleCodeInline,
- }
- case "CodeBlock", "FencedCodeBlock":
- var data []byte
- lines := n.Lines()
- for i := 0; i < lines.Len(); i++ {
- line := lines.At(i)
- data = append(data, line.Value(source)...)
- }
- if len(data) == 0 {
- return ast.WalkContinue, nil
- }
- if data[len(data)-1] == '\n' {
- data = data[:len(data)-1]
- }
- m.segs = append(m.segs, &TextSegment{
- Style: RichTextStyleCodeBlock,
- Text: string(data),
- })
- case "Emph", "Emphasis":
- switch n.(*ast.Emphasis).Level {
- case 2:
- m.nextSeg = &TextSegment{
- Style: RichTextStyleStrong,
- }
- default:
- m.nextSeg = &TextSegment{
- Style: RichTextStyleEmphasis,
- }
- }
- case "Strong":
- m.nextSeg = &TextSegment{
- Style: RichTextStyleStrong,
- }
- case "Text":
- ret := addTextToSegment(string(n.Text(source)), m.nextSeg, n)
- if ret != 0 {
- return ret, nil
- }
- _, isImage := m.nextSeg.(*ImageSegment)
- if !m.heading && !isImage {
- m.segs = append(m.segs, m.nextSeg)
- }
- case "Blockquote":
- m.blockquote = true
- case "Image":
- m.nextSeg = makeImage(n.(*ast.Image)) // remember this for applying title
- m.segs = append(m.segs, m.nextSeg)
- }
- return ast.WalkContinue, nil
- })
- return err
- }
- func (m *markdownRenderer) handleExitNode(n ast.Node) error {
- if n.Kind().String() == "Blockquote" {
- m.blockquote = false
- } else if n.Kind().String() == "List" {
- listSegs := m.segs
- m.segs = m.parentStack[len(m.parentStack)-1]
- m.parentStack = m.parentStack[:len(m.parentStack)-1]
- marker := n.(*ast.List).Marker
- m.segs = append(m.segs, &ListSegment{Items: listSegs, Ordered: marker != '*' && marker != '-' && marker != '+'})
- } else if n.Kind().String() == "ListItem" {
- itemSegs := m.segs
- m.segs = m.parentStack[len(m.parentStack)-1]
- m.parentStack = m.parentStack[:len(m.parentStack)-1]
- m.segs = append(m.segs, &ParagraphSegment{Texts: itemSegs})
- } else if !m.blockquote && !m.heading {
- if len(m.segs) > 0 {
- if text, ok := m.segs[len(m.segs)-1].(*TextSegment); ok && n.Kind().String() == "Paragraph" {
- text.Style.Inline = false
- }
- }
- m.nextSeg = &TextSegment{
- Style: RichTextStyleInline,
- }
- }
- return nil
- }
- func addTextToSegment(text string, s RichTextSegment, node ast.Node) ast.WalkStatus {
- trimmed := strings.ReplaceAll(text, "\n", " ") // newline inside paragraph is not newline
- if trimmed == "" {
- return ast.WalkContinue
- }
- if t, ok := s.(*TextSegment); ok {
- next := node.(*ast.Text).NextSibling()
- if next != nil {
- if nextText, ok := next.(*ast.Text); ok {
- if nextText.Segment.Start > node.(*ast.Text).Segment.Stop { // detect presence of a trailing newline
- trimmed = trimmed + " "
- }
- }
- }
- t.Text = t.Text + trimmed
- }
- if link, ok := s.(*HyperlinkSegment); ok {
- link.Text = link.Text + trimmed
- }
- return 0
- }
- func makeImage(n *ast.Image) *ImageSegment {
- dest := string(n.Destination)
- u, err := storage.ParseURI(dest)
- if err != nil {
- u = storage.NewFileURI(dest)
- }
- return &ImageSegment{Source: u, Title: string(n.Title), Alignment: fyne.TextAlignCenter}
- }
- func makeLink(n *ast.Link) *HyperlinkSegment {
- link, _ := url.Parse(string(n.Destination))
- return &HyperlinkSegment{fyne.TextAlignLeading, "", link, nil}
- }
- func parseMarkdown(content string) []RichTextSegment {
- r := &markdownRenderer{}
- if content == "" {
- return r.segs
- }
- md := goldmark.New(goldmark.WithRenderer(r))
- err := md.Convert([]byte(content), nil)
- if err != nil {
- fyne.LogError("Failed to parse markdown", err)
- }
- return r.segs
- }
|