package classes import ( "bytes" "sort" "strings" "github.com/yuin/goldmark/ast" gmtext "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" xhtml "golang.org/x/net/html" ) // SetImageAlt sets the alt attribute of every img in html whose src // attribute (unescaped) equals src, adding it when missing. It returns the // new HTML, the number of matching images and the alt the first match had // (nil when it had none). It works on the x/net/html tokenizer: a matching // img tag is written again from its token, so every attribute value, // alt included, is escaped by the tokenizer and never concatenated; every // other byte of html is copied as it is, the bytes of an unfinished tag at // the end included. An empty alt marks a decorative image (alt=""). func SetImageAlt(html, src, alt string) (string, int, *string) { if src == "" || !containsFold(html, " 0 && md[i-1] == '\\') || inRanges(skip, i) { continue } img, ok := scanInlineImage(md, i) if !ok { continue } dest := md[img.destStart:img.destEnd] escaped := string(util.URLEscape([]byte(dest), true)) if !images[dest] || (dest != src && escaped != src) { i = img.end - 1 continue } if matches == 0 { v := md[img.labelStart:img.labelEnd] old = &v } matches++ b.WriteString(md[last:img.labelStart]) b.WriteString(label) last = img.labelEnd i = img.end - 1 } if matches == 0 { return md, 0, nil } b.WriteString(md[last:]) return b.String(), matches, old } // SetImageAltOutsideCode applies SetImageAlt to the parts of markdown md // outside its fenced and indented code blocks and code spans, so a raw // in the markdown gets the alt while an shown in a code sample // keeps its bytes. func SetImageAltOutsideCode(md, src, alt string) (string, int, *string) { if src == "" || !containsFold(md, " 0 && matches == 0 { old = prev } matches += n b.WriteString(out) } for _, r := range code { apply(md[last:r[0]]) b.WriteString(md[r[0]:r[1]]) last = r[1] } apply(md[last:]) if matches == 0 { return md, 0, nil } return b.String(), matches, old } // escapeMarkdownLabel is alt as an image label: \, [ and ] escaped, line // breaks folded to spaces. func escapeMarkdownLabel(alt string) string { alt = strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ").Replace(alt) return strings.NewReplacer(`\`, `\\`, "[", `\[`, "]", `\]`).Replace(alt) } // inlineImage is the byte layout of one ![label](dest "title") in a text. type inlineImage struct { labelStart, labelEnd int destStart, destEnd int end int } // scanInlineImage reads the inline image starting at md[i] ("!["), as // goldmark's link parser reads one: a label with balanced brackets and // backslash escapes, "(", optional spaces, a destination in <...> or // without spaces with balanced parentheses, an optional title in quotes or // parentheses, and ")". func scanInlineImage(md string, i int) (inlineImage, bool) { var img inlineImage img.labelStart = i + 2 depth := 0 j := img.labelStart for ; j < len(md); j++ { c := md[j] if c == '\\' && j+1 < len(md) { j++ continue } if c == '[' { depth++ } else if c == ']' { if depth == 0 { break } depth-- } } if j >= len(md) || j+1 >= len(md) || md[j+1] != '(' { return img, false } img.labelEnd = j k := skipLinkSpace(md, j+2) if k >= len(md) { return img, false } if md[k] == '<' { e := k + 1 for ; e < len(md) && md[e] != '>' && md[e] != '\n'; e++ { if md[e] == '\\' && e+1 < len(md) { e++ } } if e >= len(md) || md[e] != '>' { return img, false } img.destStart, img.destEnd = k+1, e k = e + 1 } else { opened := 0 e := k for ; e < len(md); e++ { c := md[e] if c == '\\' && e+1 < len(md) { e++ continue } if c == '(' { opened++ } else if c == ')' { if opened == 0 { break } opened-- } else if c == ' ' || c == '\t' || c == '\n' || c == '\r' { break } } if e == k { return img, false } img.destStart, img.destEnd = k, e k = e } k = skipLinkSpace(md, k) if k < len(md) && (md[k] == '"' || md[k] == '\'' || md[k] == '(') { closer := md[k] if closer == '(' { closer = ')' } e := k + 1 for ; e < len(md) && md[e] != closer; e++ { if md[e] == '\\' && e+1 < len(md) { e++ } } if e >= len(md) { return img, false } k = skipLinkSpace(md, e+1) } if k >= len(md) || md[k] != ')' { return img, false } img.end = k + 1 return img, true } // skipLinkSpace skips spaces, tabs and at most one line break. func skipLinkSpace(md string, k int) int { newline := false for k < len(md) { switch md[k] { case ' ', '\t': case '\n', '\r': if newline { return k } newline = true default: return k } k++ } return k } // markdownLayout parses md as FormatHTML does and answers the byte ranges // an image is never read from (code blocks, code spans and HTML blocks) // and the destinations of its inline images. func markdownLayout(md string) ([][2]int, map[string]bool) { images := map[string]bool{} ranges := markdownRanges(md, true, func(n ast.Node) { if img, ok := n.(*ast.Image); ok { images[string(img.Destination)] = true } }) return ranges, images } // markdownCodeRanges is the byte ranges of md's code blocks and code // spans, in order. func markdownCodeRanges(md string) [][2]int { return markdownRanges(md, false, nil) } // markdownRanges walks md's goldmark AST (parsed from the trimmed text, as // FormatHTML converts it) and answers the sorted, merged byte ranges of its // fenced and indented code blocks, its code spans and, with html, its HTML // blocks; visit sees every node. func markdownRanges(md string, html bool, visit func(ast.Node)) [][2]int { trimmed := strings.TrimSpace(md) lead := strings.Index(md, trimmed) if trimmed == "" || lead < 0 { return nil } source := []byte(trimmed) doc := markdownEngine.Parser().Parse(gmtext.NewReader(source)) var ranges [][2]int lines := func(n ast.Node) { l := n.Lines() for i := 0; i < l.Len(); i++ { s := l.At(i) ranges = append(ranges, [2]int{lead + s.Start, lead + s.Stop}) } } _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } if visit != nil { visit(n) } switch v := n.(type) { case *ast.FencedCodeBlock: lines(v) if v.Info != nil { ranges = append(ranges, [2]int{lead + v.Info.Segment.Start, lead + v.Info.Segment.Stop}) } return ast.WalkSkipChildren, nil case *ast.CodeBlock: lines(v) return ast.WalkSkipChildren, nil case *ast.HTMLBlock: if html { lines(v) } return ast.WalkSkipChildren, nil case *ast.CodeSpan: start, stop := -1, -1 for c := v.FirstChild(); c != nil; c = c.NextSibling() { if t, ok := c.(*ast.Text); ok { if start < 0 || t.Segment.Start < start { start = t.Segment.Start } stop = max(stop, t.Segment.Stop) } } if start >= 0 { // The backticks around the span belong to it. for start > 0 && source[start-1] == '`' { start-- } for stop < len(source) && source[stop] == '`' { stop++ } ranges = append(ranges, [2]int{lead + start, lead + stop}) } return ast.WalkSkipChildren, nil } return ast.WalkContinue, nil }) sort.Slice(ranges, func(i, j int) bool { return ranges[i][0] < ranges[j][0] }) var merged [][2]int for _, r := range ranges { if r[0] >= r[1] { continue } if n := len(merged); n > 0 && r[0] <= merged[n-1][1] { merged[n-1][1] = max(merged[n-1][1], r[1]) continue } merged = append(merged, r) } return merged } // inRanges reports whether i lies in one of the sorted ranges. func inRanges(ranges [][2]int, i int) bool { for _, r := range ranges { if i < r[0] { return false } if i < r[1] { return true } } return false }