d2/d2renderers/textmeasure/markdown.go
Alexander Wang 524c089a74 oss
Co-authored-by: Anmol Sethi <hi@nhooyr.io>
2022-11-03 06:54:49 -07:00

325 lines
8.1 KiB
Go

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
}