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 }