package textmeasure import ( "bytes" "math" "strings" "unicode/utf8" "github.com/PuerkitoBio/goquery" "github.com/yuin/goldmark" goldmarkHtml "github.com/yuin/goldmark/renderer/html" "golang.org/x/net/html" "oss.terrastruct.com/d2/d2renderers/d2fonts" "oss.terrastruct.com/d2/lib/go2" ) var markdownRenderer goldmark.Markdown const ( MarkdownFontSize = d2fonts.FONT_SIZE_M MarkdownLineHeight = 1.5 MarkdownLineHeightPx = MarkdownFontSize * MarkdownLineHeight PaddingLeft_ul_ol = 32 MarginBottom_ul = MarkdownFontSize MarginTop_li_p = MarkdownFontSize MarginBottom_p = MarkdownFontSize LineHeight_h = 1.25 MarginTop_h = 24 MarginBottom_h = 16 PaddingBottom_h1_h2_em = 0.3 Height_hr = 4 MarginTopBottom_hr = 24 Padding_pre = 16 MarginBottom_pre = 16 PaddingLR_blockquote_em = 1. MarginBottom_blockquote = 16 BorderLeft_blockquote_em = 0.25 FONT_SIZE_H1 = d2fonts.FONT_SIZE_XXXL FONT_SIZE_H2 = d2fonts.FONT_SIZE_XL FONT_SIZE_H3 = d2fonts.FONT_SIZE_L FONT_SIZE_H4 = d2fonts.FONT_SIZE_M FONT_SIZE_H5 = d2fonts.FONT_SIZE_S FONT_SIZE_H6 = d2fonts.FONT_SIZE_XS ) var HeaderToFontSize = map[string]int{ "h1": FONT_SIZE_H1, "h2": FONT_SIZE_H2, "h3": FONT_SIZE_H3, "h4": FONT_SIZE_H4, "h5": FONT_SIZE_H5, "h6": FONT_SIZE_H6, } var HeaderFonts map[string]d2fonts.Font func RenderMarkdown(m string) (string, error) { var output bytes.Buffer if err := markdownRenderer.Convert([]byte(m), &output); err != nil { return "", err } return output.String(), nil } func init() { HeaderFonts = make(map[string]d2fonts.Font) for header, fontSize := range HeaderToFontSize { HeaderFonts[header] = d2fonts.SourceSansPro.Font(fontSize, d2fonts.FONT_STYLE_BOLD) } markdownRenderer = goldmark.New( goldmark.WithRendererOptions( goldmarkHtml.WithUnsafe(), ), ) } func MeasureMarkdown(mdText string, ruler *Ruler) (width, height int, err error) { render, err := RenderMarkdown(mdText) if err != nil { return width, height, err } doc, err := goquery.NewDocumentFromReader(strings.NewReader(render)) if err != nil { return width, height, err } { originalLineHeight := ruler.LineHeightFactor ruler.LineHeightFactor = MarkdownLineHeight defer func() { ruler.LineHeightFactor = originalLineHeight }() } font := d2fonts.SourceSansPro.Font(MarkdownFontSize, d2fonts.FONT_STYLE_REGULAR) // TODO consider setting a max width + (manual) text wrapping bodyNode := doc.Find("body").First().Nodes[0] bodyWidth, bodyHeight, _, _ := ruler.measureNode(0, bodyNode, font) return int(math.Ceil(bodyWidth)), int(math.Ceil(bodyHeight)), nil } func hasPrev(n *html.Node) bool { if n.PrevSibling == nil { return false } if strings.TrimSpace(n.PrevSibling.Data) == "" { return hasPrev(n.PrevSibling) } return true } func hasNext(n *html.Node) bool { if n.NextSibling == nil { return false } // skip over empty text nodes if strings.TrimSpace(n.NextSibling.Data) == "" { return hasNext(n.NextSibling) } return true } func getPrev(n *html.Node) *html.Node { if n == nil { return nil } if strings.TrimSpace(n.Data) == "" { if next := getNext(n.PrevSibling); next != nil { return next } } return n } func getNext(n *html.Node) *html.Node { if n == nil { return nil } if strings.TrimSpace(n.Data) == "" { if next := getNext(n.NextSibling); next != nil { return next } } return n } func isBlockElement(elType string) bool { switch elType { case "blockquote", "div", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "li", "ol", "p", "pre", "ul": return true default: return false } } func hasAncestorElement(n *html.Node, elType string) bool { if n.Parent == nil { return false } if n.Parent.Type == html.ElementNode && n.Parent.Data == elType { return true } return hasAncestorElement(n.Parent, elType) } // measures node dimensions to match rendering with styles in github-markdown.css func (ruler *Ruler) measureNode(depth int, n *html.Node, font d2fonts.Font) (width, height, marginTop, marginBottom float64) { switch n.Type { case html.TextNode: if strings.TrimSpace(n.Data) == "" { return } spaceWidths := 0. // consecutive leading/trailing spaces end up rendered as a single space spaceRune, _ := utf8.DecodeRuneInString(" ") // measure will not include leading or trailing whitespace, so we have to add in the space width spaceWidth := ruler.atlases[font].glyph(spaceRune).advance str := n.Data hasCodeParent := n.Parent != nil && n.Parent.Type == html.ElementNode && (n.Parent.Data == "pre" || n.Parent.Data == "code") if !hasCodeParent { str = strings.ReplaceAll(n.Data, "\n", " ") } if strings.HasPrefix(str, " ") { str = strings.TrimPrefix(str, " ") if hasPrev(n) { spaceWidths += spaceWidth } } if strings.HasSuffix(str, " ") { str = strings.TrimSuffix(str, " ") if hasNext(n) { spaceWidths += spaceWidth } } w, h := ruler.MeasurePrecise(font, str) w += spaceWidths // fmt.Printf("%d:%s width %v height %v fontStyle %s\n", depth, n.Data, w, h, font.Style) if h > 0 && h < MarkdownLineHeightPx { h = MarkdownLineHeightPx } return w, h, 0, 0 case html.ElementNode: // fmt.Printf("%d: %v node\n", depth, n.Data) switch n.Data { case "h1", "h2", "h3", "h4", "h5", "h6": font = HeaderFonts[n.Data] originalLineHeight := ruler.LineHeightFactor ruler.LineHeightFactor = LineHeight_h defer func() { ruler.LineHeightFactor = originalLineHeight }() case "em": font.Style = d2fonts.FONT_STYLE_ITALIC case "b", "strong": font.Style = d2fonts.FONT_STYLE_BOLD case "pre", "code": // TODO monospaced font } if n.FirstChild != nil { first := getNext(n.FirstChild) last := getPrev(n.LastChild) var prevMarginBottom float64 for child := n.FirstChild; child != nil; child = child.NextSibling { childWidth, childHeight, childMarginTop, childMarginBottom := ruler.measureNode(depth+1, child, font) if child.Type == html.ElementNode && isBlockElement(child.Data) { if child == first { if n.Data == "blockquote" { childMarginTop = 0. } marginTop = go2.Max(marginTop, childMarginTop) } else { marginDiff := childMarginTop - prevMarginBottom if marginDiff > 0 { childHeight += marginDiff } } if child == last { if n.Data == "blockquote" { childMarginBottom = 0. } marginBottom = go2.Max(marginBottom, childMarginBottom) } else { childHeight += childMarginBottom prevMarginBottom = childMarginBottom } height += childHeight width = go2.Max(width, childWidth) } else { marginTop = go2.Max(marginTop, childMarginTop) marginBottom = go2.Max(marginBottom, childMarginBottom) width += childWidth height = go2.Max(height, childHeight) } } } switch n.Data { case "blockquote": width += float64(font.Size) * (2*PaddingLR_blockquote_em + BorderLeft_blockquote_em) marginBottom = go2.Max(marginBottom, MarginBottom_blockquote) case "p": if n.Parent != nil && n.Parent.Type == html.ElementNode && n.Parent.Data == "li" { marginTop = go2.Max(marginTop, MarginTop_li_p) } marginBottom = go2.Max(marginBottom, MarginBottom_p) case "h1", "h2", "h3", "h4", "h5", "h6": marginTop = go2.Max(marginTop, MarginTop_h) marginBottom = go2.Max(marginBottom, MarginBottom_h) switch n.Data { case "h1", "h2": height += float64(HeaderToFontSize[n.Data]) * PaddingBottom_h1_h2_em } case "li": width += PaddingLeft_ul_ol if hasPrev(n) { marginTop = go2.Max(marginTop, 4) } case "ol", "ul": if hasAncestorElement(n, "ul") || hasAncestorElement(n, "ol") { marginTop = 0 marginBottom = 0 } else { marginBottom = go2.Max(marginBottom, MarginBottom_ul) } case "pre": width += 2 * Padding_pre height += 2 * Padding_pre marginBottom = go2.Max(marginBottom, MarginBottom_pre) case "hr": height += Height_hr marginTop = go2.Max(marginTop, MarginTopBottom_hr) marginBottom = go2.Max(marginBottom, MarginTopBottom_hr) } // fmt.Printf("%d:%s width %v height %v mt %v mb %v\n", depth, n.Data, width, height, marginTop, marginBottom) } return width, height, marginTop, marginBottom }