diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md index cc7633dd5..00eefa603 100644 --- a/ci/release/changelogs/next.md +++ b/ci/release/changelogs/next.md @@ -11,3 +11,5 @@ [#187](https://github.com/terrastruct/d2/pull/187) - System dark mode was incorrectly applying to markdown in renders. [#159](https://github.com/terrastruct/d2/issues/159) +- Fixes markdown newlines created with a trailing double space or backslash. + [#214](https://github.com/terrastruct/d2/pull/214) diff --git a/d2renderers/textmeasure/markdown.go b/d2renderers/textmeasure/markdown.go index 3b538759b..056af2f77 100644 --- a/d2renderers/textmeasure/markdown.go +++ b/d2renderers/textmeasure/markdown.go @@ -85,6 +85,7 @@ func init() { markdownRenderer = goldmark.New( goldmark.WithRendererOptions( goldmarkHtml.WithUnsafe(), + goldmarkHtml.WithXHTML(), ), ) } @@ -114,9 +115,9 @@ func MeasureMarkdown(mdText string, ruler *Ruler) (width, height int, err error) // TODO consider setting a max width + (manual) text wrapping bodyNode := doc.Find("body").First().Nodes[0] - bodyWidth, bodyHeight, _, _ := ruler.measureNode(0, bodyNode, font) + bodyAttrs := ruler.measureNode(0, bodyNode, font) - return int(math.Ceil(bodyWidth)), int(math.Ceil(bodyHeight)), nil + return int(math.Ceil(bodyAttrs.width)), int(math.Ceil(bodyAttrs.height)), nil } func hasPrev(n *html.Node) bool { @@ -191,8 +192,16 @@ func hasAncestorElement(n *html.Node, elType string) bool { return hasAncestorElement(n.Parent, elType) } +type blockAttrs struct { + width, height, marginTop, marginBottom float64 +} + +func (b *blockAttrs) isNotEmpty() bool { + return b != nil && *b != blockAttrs{} +} + // 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) { +func (ruler *Ruler) measureNode(depth int, n *html.Node, font d2fonts.Font) blockAttrs { var parentElementType string if n.Parent != nil && n.Parent.Type == html.ElementNode { parentElementType = n.Parent.Data @@ -201,7 +210,7 @@ func (ruler *Ruler) measureNode(depth int, n *html.Node, font d2fonts.Font) (wid switch n.Type { case html.TextNode: if strings.TrimSpace(n.Data) == "" { - return + return blockAttrs{} } spaceWidths := 0. @@ -242,8 +251,9 @@ func (ruler *Ruler) measureNode(depth int, n *html.Node, font d2fonts.Font) (wid w *= FontSize_pre_code_em h *= FontSize_pre_code_em } - return w + spaceWidths, h, 0, 0 + return blockAttrs{w + spaceWidths, h, 0, 0} case html.ElementNode: + isCode := false switch n.Data { case "h1", "h2", "h3", "h4", "h5", "h6": font = HeaderFonts[n.Data] @@ -259,96 +269,138 @@ func (ruler *Ruler) measureNode(depth int, n *html.Node, font d2fonts.Font) (wid case "pre", "code": font.Family = d2fonts.SourceCodePro font.Style = d2fonts.FONT_STYLE_REGULAR + isCode = true } + block := blockAttrs{} + if n.FirstChild != nil { first := getNext(n.FirstChild) last := getPrev(n.LastChild) - var prevMarginBottom float64 + var blocks []blockAttrs + var current *blockAttrs + // first create blocks from combined inline elements, then combine all blocks + // current will be non-nil while inline elements are being combined into a block for child := n.FirstChild; child != nil; child = child.NextSibling { - childWidth, childHeight, childMarginTop, childMarginBottom := ruler.measureNode(depth+1, child, font) + childBlock := 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 current != nil { + blocks = append(blocks, *current) } - if child == last { - if n.Data == "blockquote" { - childMarginBottom = 0. - } - marginBottom = go2.Max(marginBottom, childMarginBottom) + current = &blockAttrs{} + if child == first && n.Data == "blockquote" { + current.marginTop = 0. } else { - childHeight += childMarginBottom - prevMarginBottom = childMarginBottom + current.marginTop = childBlock.marginTop + } + if child == last && n.Data == "blockquote" { + current.marginBottom = 0. + } else { + current.marginBottom = childBlock.marginBottom } - height += childHeight - width = go2.Max(width, childWidth) - } else { - marginTop = go2.Max(marginTop, childMarginTop) - marginBottom = go2.Max(marginBottom, childMarginBottom) + current.width = childBlock.width + current.height = childBlock.height + blocks = append(blocks, *current) + current = nil + } else if child.Type == html.ElementNode && child.Data == "br" { + if current != nil { + if !isCode && current.height > 0 && current.height < MarkdownLineHeightPx { + current.height = MarkdownLineHeightPx + } + blocks = append(blocks, *current) + current = nil + } + } else if childBlock.isNotEmpty() { + if current == nil { + current = &childBlock + } else { + current.marginTop = go2.Max(current.marginTop, childBlock.marginTop) + current.marginBottom = go2.Max(current.marginBottom, childBlock.marginBottom) - width += childWidth - height = go2.Max(height, childHeight) + current.width += childBlock.width + current.height = go2.Max(current.height, childBlock.height) + } } } + if current != nil { + if !isCode && current.height > 0 && current.height < MarkdownLineHeightPx { + current.height = MarkdownLineHeightPx + } + blocks = append(blocks, *current) + current = nil + } + + var prevMarginBottom float64 + for i, b := range blocks { + if i == 0 { + block.marginTop = go2.Max(block.marginTop, b.marginTop) + } else { + marginDiff := b.marginTop - prevMarginBottom + if marginDiff > 0 { + block.height += marginDiff + } + } + if i == len(blocks)-1 { + block.marginBottom = go2.Max(block.marginBottom, b.marginBottom) + } else { + block.height += b.marginBottom + prevMarginBottom = b.marginBottom + } + + block.height += b.height + block.width = go2.Max(block.width, b.width) + } } switch n.Data { case "blockquote": - width += (2*PaddingLR_blockquote_em + BorderLeft_blockquote_em) * float64(font.Size) - marginBottom = go2.Max(marginBottom, MarginBottom_blockquote) + block.width += (2*PaddingLR_blockquote_em + BorderLeft_blockquote_em) * float64(font.Size) + block.marginBottom = go2.Max(block.marginBottom, MarginBottom_blockquote) case "p": if parentElementType == "li" { - marginTop = go2.Max(marginTop, MarginTop_li_p) + block.marginTop = go2.Max(block.marginTop, MarginTop_li_p) } - marginBottom = go2.Max(marginBottom, MarginBottom_p) + block.marginBottom = go2.Max(block.marginBottom, MarginBottom_p) case "h1", "h2", "h3", "h4", "h5", "h6": - marginTop = go2.Max(marginTop, MarginTop_h) - marginBottom = go2.Max(marginBottom, MarginBottom_h) + block.marginTop = go2.Max(block.marginTop, MarginTop_h) + block.marginBottom = go2.Max(block.marginBottom, MarginBottom_h) switch n.Data { case "h1", "h2": - height += PaddingBottom_h1_h2_em * float64(font.Size) + block.height += PaddingBottom_h1_h2_em * float64(font.Size) } case "li": - width += PaddingLeft_ul_ol + block.width += PaddingLeft_ul_ol if hasPrev(n) { - marginTop = go2.Max(marginTop, 4) + block.marginTop = go2.Max(block.marginTop, 4) } case "ol", "ul": if hasAncestorElement(n, "ul") || hasAncestorElement(n, "ol") { - marginTop = 0 - marginBottom = 0 + block.marginTop = 0 + block.marginBottom = 0 } else { - marginBottom = go2.Max(marginBottom, MarginBottom_ul) + block.marginBottom = go2.Max(block.marginBottom, MarginBottom_ul) } case "pre": - width += 2 * Padding_pre - height += 2 * Padding_pre - marginBottom = go2.Max(marginBottom, MarginBottom_pre) + block.width += 2 * Padding_pre + block.height += 2 * Padding_pre + block.marginBottom = go2.Max(block.marginBottom, MarginBottom_pre) case "code": if parentElementType != "pre" { - width += 2 * PaddingLeftRight_code_em * float64(font.Size) - height += 2 * PaddingTopBottom_code_em * float64(font.Size) + block.width += 2 * PaddingLeftRight_code_em * float64(font.Size) + block.height += 2 * PaddingTopBottom_code_em * float64(font.Size) } case "hr": - height += Height_hr - marginTop = go2.Max(marginTop, MarginTopBottom_hr) - marginBottom = go2.Max(marginBottom, MarginTopBottom_hr) + block.height += Height_hr + block.marginTop = go2.Max(block.marginTop, MarginTopBottom_hr) + block.marginBottom = go2.Max(block.marginBottom, MarginTopBottom_hr) } - - if height > 0 && height < MarkdownLineHeightPx { - height = MarkdownLineHeightPx + if block.height > 0 && block.height < MarkdownLineHeightPx { + block.height = MarkdownLineHeightPx } + return block } - return width, height, marginTop, marginBottom + return blockAttrs{} } diff --git a/e2etests/stable_test.go b/e2etests/stable_test.go index 2bfb4d5a5..31acccc44 100644 --- a/e2etests/stable_test.go +++ b/e2etests/stable_test.go @@ -925,6 +925,28 @@ x -> y: { stroke-dash: 5 } } +`, + }, + { + name: "md_2space_newline", + script: ` +markdown: { + md: |md +Lorem ipsum dolor sit amet, consectetur adipiscing elit, +sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. +| +} +`, + }, + { + name: "md_backslash_newline", + script: ` +markdown: { + md: |md +Lorem ipsum dolor sit amet, consectetur adipiscing elit,\ +sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. +| +} `, }, } diff --git a/e2etests/testdata/stable/md_2space_newline/dagre/board.exp.json b/e2etests/testdata/stable/md_2space_newline/dagre/board.exp.json new file mode 100644 index 000000000..f67223c23 --- /dev/null +++ b/e2etests/testdata/stable/md_2space_newline/dagre/board.exp.json @@ -0,0 +1,81 @@ +{ + "name": "", + "shapes": [ + { + "id": "markdown", + "type": "", + "pos": { + "x": 0, + "y": 0 + }, + "width": 559, + "height": 148, + "level": 1, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "#E3E9FD", + "stroke": "#0D32B2", + "shadow": false, + "3d": false, + "multiple": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "fields": null, + "methods": null, + "columns": null, + "label": "markdown", + "fontSize": 28, + "fontFamily": "DEFAULT", + "language": "", + "color": "#0A0F25", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 129, + "labelHeight": 41, + "labelPosition": "INSIDE_TOP_CENTER" + }, + { + "id": "markdown.md", + "type": "text", + "pos": { + "x": 50, + "y": 50 + }, + "width": 459, + "height": 48, + "level": 2, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "#FFFFFF", + "stroke": "#0D32B2", + "shadow": false, + "3d": false, + "multiple": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "fields": null, + "methods": null, + "columns": null, + "label": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, \nsed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "markdown", + "color": "#0A0F25", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 459, + "labelHeight": 48 + } + ], + "connections": [] +} diff --git a/e2etests/testdata/stable/md_2space_newline/dagre/sketch.exp.svg b/e2etests/testdata/stable/md_2space_newline/dagre/sketch.exp.svg new file mode 100644 index 000000000..4a3236965 --- /dev/null +++ b/e2etests/testdata/stable/md_2space_newline/dagre/sketch.exp.svg @@ -0,0 +1,789 @@ + +markdownLorem ipsum dolor sit amet, consectetur adipiscing elit, +sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + \ No newline at end of file diff --git a/e2etests/testdata/stable/md_2space_newline/elk/board.exp.json b/e2etests/testdata/stable/md_2space_newline/elk/board.exp.json new file mode 100644 index 000000000..a856cf8a0 --- /dev/null +++ b/e2etests/testdata/stable/md_2space_newline/elk/board.exp.json @@ -0,0 +1,81 @@ +{ + "name": "", + "shapes": [ + { + "id": "markdown", + "type": "", + "pos": { + "x": 12, + "y": 12 + }, + "width": 609, + "height": 198, + "level": 1, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "#E3E9FD", + "stroke": "#0D32B2", + "shadow": false, + "3d": false, + "multiple": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "fields": null, + "methods": null, + "columns": null, + "label": "markdown", + "fontSize": 28, + "fontFamily": "DEFAULT", + "language": "", + "color": "#0A0F25", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 129, + "labelHeight": 41, + "labelPosition": "INSIDE_TOP_CENTER" + }, + { + "id": "markdown.md", + "type": "text", + "pos": { + "x": 87, + "y": 87 + }, + "width": 459, + "height": 48, + "level": 2, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "#FFFFFF", + "stroke": "#0D32B2", + "shadow": false, + "3d": false, + "multiple": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "fields": null, + "methods": null, + "columns": null, + "label": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, \nsed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "markdown", + "color": "#0A0F25", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 459, + "labelHeight": 48 + } + ], + "connections": [] +} diff --git a/e2etests/testdata/stable/md_2space_newline/elk/sketch.exp.svg b/e2etests/testdata/stable/md_2space_newline/elk/sketch.exp.svg new file mode 100644 index 000000000..bd07661ae --- /dev/null +++ b/e2etests/testdata/stable/md_2space_newline/elk/sketch.exp.svg @@ -0,0 +1,789 @@ + +markdownLorem ipsum dolor sit amet, consectetur adipiscing elit, +sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + \ No newline at end of file diff --git a/e2etests/testdata/stable/md_backslash_newline/dagre/board.exp.json b/e2etests/testdata/stable/md_backslash_newline/dagre/board.exp.json new file mode 100644 index 000000000..de8d2ef89 --- /dev/null +++ b/e2etests/testdata/stable/md_backslash_newline/dagre/board.exp.json @@ -0,0 +1,81 @@ +{ + "name": "", + "shapes": [ + { + "id": "markdown", + "type": "", + "pos": { + "x": 0, + "y": 0 + }, + "width": 559, + "height": 148, + "level": 1, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "#E3E9FD", + "stroke": "#0D32B2", + "shadow": false, + "3d": false, + "multiple": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "fields": null, + "methods": null, + "columns": null, + "label": "markdown", + "fontSize": 28, + "fontFamily": "DEFAULT", + "language": "", + "color": "#0A0F25", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 129, + "labelHeight": 41, + "labelPosition": "INSIDE_TOP_CENTER" + }, + { + "id": "markdown.md", + "type": "text", + "pos": { + "x": 50, + "y": 50 + }, + "width": 459, + "height": 48, + "level": 2, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "#FFFFFF", + "stroke": "#0D32B2", + "shadow": false, + "3d": false, + "multiple": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "fields": null, + "methods": null, + "columns": null, + "label": "Lorem ipsum dolor sit amet, consectetur adipiscing elit,\\\nsed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "markdown", + "color": "#0A0F25", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 459, + "labelHeight": 48 + } + ], + "connections": [] +} diff --git a/e2etests/testdata/stable/md_backslash_newline/dagre/sketch.exp.svg b/e2etests/testdata/stable/md_backslash_newline/dagre/sketch.exp.svg new file mode 100644 index 000000000..4a3236965 --- /dev/null +++ b/e2etests/testdata/stable/md_backslash_newline/dagre/sketch.exp.svg @@ -0,0 +1,789 @@ + +markdownLorem ipsum dolor sit amet, consectetur adipiscing elit, +sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + \ No newline at end of file diff --git a/e2etests/testdata/stable/md_backslash_newline/elk/board.exp.json b/e2etests/testdata/stable/md_backslash_newline/elk/board.exp.json new file mode 100644 index 000000000..03cbdd66f --- /dev/null +++ b/e2etests/testdata/stable/md_backslash_newline/elk/board.exp.json @@ -0,0 +1,81 @@ +{ + "name": "", + "shapes": [ + { + "id": "markdown", + "type": "", + "pos": { + "x": 12, + "y": 12 + }, + "width": 609, + "height": 198, + "level": 1, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "#E3E9FD", + "stroke": "#0D32B2", + "shadow": false, + "3d": false, + "multiple": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "fields": null, + "methods": null, + "columns": null, + "label": "markdown", + "fontSize": 28, + "fontFamily": "DEFAULT", + "language": "", + "color": "#0A0F25", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 129, + "labelHeight": 41, + "labelPosition": "INSIDE_TOP_CENTER" + }, + { + "id": "markdown.md", + "type": "text", + "pos": { + "x": 87, + "y": 87 + }, + "width": 459, + "height": 48, + "level": 2, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "#FFFFFF", + "stroke": "#0D32B2", + "shadow": false, + "3d": false, + "multiple": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "fields": null, + "methods": null, + "columns": null, + "label": "Lorem ipsum dolor sit amet, consectetur adipiscing elit,\\\nsed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "markdown", + "color": "#0A0F25", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 459, + "labelHeight": 48 + } + ], + "connections": [] +} diff --git a/e2etests/testdata/stable/md_backslash_newline/elk/sketch.exp.svg b/e2etests/testdata/stable/md_backslash_newline/elk/sketch.exp.svg new file mode 100644 index 000000000..bd07661ae --- /dev/null +++ b/e2etests/testdata/stable/md_backslash_newline/elk/sketch.exp.svg @@ -0,0 +1,789 @@ + +markdownLorem ipsum dolor sit amet, consectetur adipiscing elit, +sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + \ No newline at end of file
Lorem ipsum dolor sit amet, consectetur adipiscing elit, +sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.