Skip to content

Commit 54154f9

Browse files
authored
Merge pull request fyne-io#4677 from codesoap/markdown
Rework markdown parser to fix fyne-io#4613
2 parents cb099cc + 471f1cf commit 54154f9

File tree

2 files changed

+199
-180
lines changed

2 files changed

+199
-180
lines changed

widget/markdown.go

+129-166
Original file line numberDiff line numberDiff line change
@@ -27,196 +27,159 @@ func (t *RichText) ParseMarkdown(content string) {
2727
t.Refresh()
2828
}
2929

30-
type markdownRenderer struct {
31-
blockquote bool
32-
heading bool
33-
nextSeg RichTextSegment
34-
parentStack [][]RichTextSegment
35-
segs []RichTextSegment
36-
}
30+
type markdownRenderer []RichTextSegment
3731

3832
func (m *markdownRenderer) AddOptions(...renderer.Option) {}
3933

4034
func (m *markdownRenderer) Render(_ io.Writer, source []byte, n ast.Node) error {
41-
m.nextSeg = &TextSegment{}
42-
err := ast.Walk(n, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
43-
if !entering {
44-
if n.Kind().String() == "Heading" {
45-
m.segs = append(m.segs, m.nextSeg)
46-
m.heading = false
47-
}
48-
return ast.WalkContinue, m.handleExitNode(n)
49-
}
50-
51-
switch n.Kind().String() {
52-
case "List":
53-
// prepare a new child level
54-
m.parentStack = append(m.parentStack, m.segs)
55-
m.segs = nil
56-
case "ListItem":
57-
// prepare a new item level
58-
m.parentStack = append(m.parentStack, m.segs)
59-
m.segs = nil
60-
case "Heading":
61-
m.heading = true
62-
switch n.(*ast.Heading).Level {
63-
case 1:
64-
m.nextSeg = &TextSegment{
65-
Style: RichTextStyleHeading,
66-
}
67-
case 2:
68-
m.nextSeg = &TextSegment{
69-
Style: RichTextStyleSubHeading,
70-
}
71-
default:
72-
m.nextSeg = &TextSegment{
73-
Style: RichTextStyleParagraph,
74-
}
75-
m.nextSeg.(*TextSegment).Style.TextStyle.Bold = true
76-
}
77-
case "HorizontalRule", "ThematicBreak":
78-
m.segs = append(m.segs, &SeparatorSegment{})
79-
case "Link":
80-
m.nextSeg = makeLink(n.(*ast.Link))
81-
case "Paragraph":
82-
m.nextSeg = &TextSegment{
83-
Style: RichTextStyleInline, // we make it a paragraph at the end if there are no more elements
84-
}
85-
if m.blockquote {
86-
m.nextSeg.(*TextSegment).Style = RichTextStyleBlockquote
87-
}
88-
case "CodeSpan":
89-
m.nextSeg = &TextSegment{
90-
Style: RichTextStyleCodeInline,
91-
}
92-
case "CodeBlock", "FencedCodeBlock":
93-
var data []byte
94-
lines := n.Lines()
95-
for i := 0; i < lines.Len(); i++ {
96-
line := lines.At(i)
97-
data = append(data, line.Value(source)...)
98-
}
99-
if len(data) == 0 {
100-
return ast.WalkContinue, nil
101-
}
102-
if data[len(data)-1] == '\n' {
103-
data = data[:len(data)-1]
104-
}
105-
m.segs = append(m.segs, &TextSegment{
106-
Style: RichTextStyleCodeBlock,
107-
Text: string(data),
108-
})
109-
case "Emph", "Emphasis":
110-
switch n.(*ast.Emphasis).Level {
111-
case 2:
112-
m.nextSeg = &TextSegment{
113-
Style: RichTextStyleStrong,
114-
}
115-
default:
116-
m.nextSeg = &TextSegment{
117-
Style: RichTextStyleEmphasis,
118-
}
119-
}
120-
case "Strong":
121-
m.nextSeg = &TextSegment{
122-
Style: RichTextStyleStrong,
123-
}
124-
case "Text":
125-
ret := addTextToSegment(string(n.Text(source)), m.nextSeg, n)
126-
if ret != 0 {
127-
return ret, nil
128-
}
129-
130-
_, isImage := m.nextSeg.(*ImageSegment)
131-
if !m.heading && !isImage {
132-
m.segs = append(m.segs, m.nextSeg)
133-
}
134-
case "Blockquote":
135-
m.blockquote = true
136-
case "Image":
137-
m.nextSeg = makeImage(n.(*ast.Image)) // remember this for applying title
138-
m.segs = append(m.segs, m.nextSeg)
139-
}
140-
141-
return ast.WalkContinue, nil
142-
})
35+
segs, err := renderNode(source, n, false)
36+
*m = segs
14337
return err
14438
}
14539

146-
func (m *markdownRenderer) handleExitNode(n ast.Node) error {
147-
if n.Kind().String() == "Blockquote" {
148-
m.blockquote = false
149-
} else if n.Kind().String() == "List" {
150-
listSegs := m.segs
151-
m.segs = m.parentStack[len(m.parentStack)-1]
152-
m.parentStack = m.parentStack[:len(m.parentStack)-1]
153-
marker := n.(*ast.List).Marker
154-
m.segs = append(m.segs, &ListSegment{Items: listSegs, Ordered: marker != '*' && marker != '-' && marker != '+'})
155-
} else if n.Kind().String() == "ListItem" {
156-
itemSegs := m.segs
157-
m.segs = m.parentStack[len(m.parentStack)-1]
158-
m.parentStack = m.parentStack[:len(m.parentStack)-1]
159-
m.segs = append(m.segs, &ParagraphSegment{Texts: itemSegs})
160-
} else if !m.blockquote && !m.heading {
161-
if len(m.segs) > 0 {
162-
if text, ok := m.segs[len(m.segs)-1].(*TextSegment); ok && n.Kind().String() == "Paragraph" {
163-
text.Style.Inline = false
164-
}
40+
func renderNode(source []byte, n ast.Node, blockquote bool) ([]RichTextSegment, error) {
41+
switch t := n.(type) {
42+
case *ast.Document:
43+
return renderChildren(source, n, blockquote)
44+
case *ast.Paragraph:
45+
children, err := renderChildren(source, n, blockquote)
46+
if !blockquote {
47+
linebreak := &TextSegment{Style: RichTextStyleParagraph}
48+
children = append(children, linebreak)
49+
}
50+
return children, err
51+
case *ast.List:
52+
items, err := renderChildren(source, n, blockquote)
53+
return []RichTextSegment{
54+
&ListSegment{Items: items, Ordered: t.Marker != '*' && t.Marker != '-' && t.Marker != '+'},
55+
}, err
56+
case *ast.ListItem:
57+
texts, err := renderChildren(source, n, blockquote)
58+
return []RichTextSegment{&ParagraphSegment{Texts: texts}}, err
59+
case *ast.TextBlock:
60+
return renderChildren(source, n, blockquote)
61+
case *ast.Heading:
62+
text := forceIntoHeadingText(source, n)
63+
switch t.Level {
64+
case 1:
65+
return []RichTextSegment{&TextSegment{Style: RichTextStyleHeading, Text: text}}, nil
66+
case 2:
67+
return []RichTextSegment{&TextSegment{Style: RichTextStyleSubHeading, Text: text}}, nil
68+
default:
69+
textSegment := TextSegment{Style: RichTextStyleParagraph, Text: text}
70+
textSegment.Style.TextStyle.Bold = true
71+
return []RichTextSegment{&textSegment}, nil
72+
}
73+
case *ast.ThematicBreak:
74+
return []RichTextSegment{&SeparatorSegment{}}, nil
75+
case *ast.Link:
76+
link, _ := url.Parse(string(t.Destination))
77+
text := forceIntoText(source, n)
78+
return []RichTextSegment{&HyperlinkSegment{Alignment: fyne.TextAlignLeading, Text: text, URL: link}}, nil
79+
case *ast.CodeSpan:
80+
text := forceIntoText(source, n)
81+
return []RichTextSegment{&TextSegment{Style: RichTextStyleCodeInline, Text: text}}, nil
82+
case *ast.CodeBlock, *ast.FencedCodeBlock:
83+
var data []byte
84+
lines := n.Lines()
85+
for i := 0; i < lines.Len(); i++ {
86+
line := lines.At(i)
87+
data = append(data, line.Value(source)...)
16588
}
166-
m.nextSeg = &TextSegment{
167-
Style: RichTextStyleInline,
89+
if len(data) == 0 {
90+
return nil, nil
16891
}
92+
if data[len(data)-1] == '\n' {
93+
data = data[:len(data)-1]
94+
}
95+
return []RichTextSegment{&TextSegment{Style: RichTextStyleCodeBlock, Text: string(data)}}, nil
96+
case *ast.Emphasis:
97+
text := string(forceIntoText(source, n))
98+
switch t.Level {
99+
case 2:
100+
return []RichTextSegment{&TextSegment{Style: RichTextStyleStrong, Text: text}}, nil
101+
default:
102+
return []RichTextSegment{&TextSegment{Style: RichTextStyleEmphasis, Text: text}}, nil
103+
}
104+
case *ast.Text:
105+
text := string(t.Text(source))
106+
if text == "" {
107+
// These empty text elements indicate single line breaks after non-text elements in goldmark.
108+
return []RichTextSegment{&TextSegment{Style: RichTextStyleInline, Text: " "}}, nil
109+
}
110+
text = suffixSpaceIfAppropriate(text, source, n)
111+
if blockquote {
112+
return []RichTextSegment{&TextSegment{Style: RichTextStyleBlockquote, Text: text}}, nil
113+
}
114+
return []RichTextSegment{&TextSegment{Style: RichTextStyleInline, Text: text}}, nil
115+
case *ast.Blockquote:
116+
return renderChildren(source, n, true)
117+
case *ast.Image:
118+
dest := string(t.Destination)
119+
u, err := storage.ParseURI(dest)
120+
if err != nil {
121+
u = storage.NewFileURI(dest)
122+
}
123+
return []RichTextSegment{&ImageSegment{Source: u, Title: string(t.Title), Alignment: fyne.TextAlignCenter}}, nil
169124
}
170-
return nil
125+
return nil, nil
171126
}
172127

173-
func addTextToSegment(text string, s RichTextSegment, node ast.Node) ast.WalkStatus {
174-
trimmed := strings.ReplaceAll(text, "\n", " ") // newline inside paragraph is not newline
175-
if trimmed == "" {
176-
return ast.WalkContinue
128+
func suffixSpaceIfAppropriate(text string, source []byte, n ast.Node) string {
129+
next := n.NextSibling()
130+
if next != nil && next.Type() == ast.TypeInline && !strings.HasSuffix(text, " ") {
131+
return text + " "
177132
}
178-
if t, ok := s.(*TextSegment); ok {
179-
next := node.(*ast.Text).NextSibling()
180-
if next != nil {
181-
if nextText, ok := next.(*ast.Text); ok {
182-
if nextText.Segment.Start > node.(*ast.Text).Segment.Stop { // detect presence of a trailing newline
183-
trimmed = trimmed + " "
184-
}
185-
}
186-
}
133+
return text
134+
}
187135

188-
t.Text = t.Text + trimmed
189-
}
190-
if link, ok := s.(*HyperlinkSegment); ok {
191-
link.Text = link.Text + trimmed
136+
func renderChildren(source []byte, n ast.Node, blockquote bool) ([]RichTextSegment, error) {
137+
children := make([]RichTextSegment, 0, n.ChildCount())
138+
for childCount, child := n.ChildCount(), n.FirstChild(); childCount > 0; childCount-- {
139+
segs, err := renderNode(source, child, blockquote)
140+
if err != nil {
141+
return children, err
142+
}
143+
children = append(children, segs...)
144+
child = child.NextSibling()
192145
}
193-
return 0
146+
return children, nil
194147
}
195148

196-
func makeImage(n *ast.Image) *ImageSegment {
197-
dest := string(n.Destination)
198-
u, err := storage.ParseURI(dest)
199-
if err != nil {
200-
u = storage.NewFileURI(dest)
201-
}
202-
return &ImageSegment{Source: u, Title: string(n.Title), Alignment: fyne.TextAlignCenter}
149+
func forceIntoText(source []byte, n ast.Node) string {
150+
texts := make([]string, 0)
151+
ast.Walk(n, func(n2 ast.Node, entering bool) (ast.WalkStatus, error) {
152+
if entering {
153+
switch t := n2.(type) {
154+
case *ast.Text:
155+
texts = append(texts, string(t.Text(source)))
156+
}
157+
}
158+
return ast.WalkContinue, nil
159+
})
160+
return strings.Join(texts, " ")
203161
}
204162

205-
func makeLink(n *ast.Link) *HyperlinkSegment {
206-
link, _ := url.Parse(string(n.Destination))
207-
return &HyperlinkSegment{fyne.TextAlignLeading, "", link, nil}
163+
func forceIntoHeadingText(source []byte, n ast.Node) string {
164+
var text strings.Builder
165+
ast.Walk(n, func(n2 ast.Node, entering bool) (ast.WalkStatus, error) {
166+
if entering {
167+
switch t := n2.(type) {
168+
case *ast.Text:
169+
text.Write(t.Text(source))
170+
}
171+
}
172+
return ast.WalkContinue, nil
173+
})
174+
return text.String()
208175
}
209176

210177
func parseMarkdown(content string) []RichTextSegment {
211-
r := &markdownRenderer{}
212-
if content == "" {
213-
return r.segs
214-
}
215-
216-
md := goldmark.New(goldmark.WithRenderer(r))
178+
r := markdownRenderer{}
179+
md := goldmark.New(goldmark.WithRenderer(&r))
217180
err := md.Convert([]byte(content), nil)
218181
if err != nil {
219182
fyne.LogError("Failed to parse markdown", err)
220183
}
221-
return r.segs
184+
return r
222185
}

0 commit comments

Comments
 (0)