d2/d2renderers/d2svg/d2svg.go

1396 lines
41 KiB
Go
Raw Normal View History

// d2svg implements an SVG renderer for d2 diagrams.
// The input is d2exporter's output
package d2svg
import (
"bytes"
_ "embed"
"errors"
"fmt"
"hash/fnv"
2022-12-24 20:45:12 +00:00
"html"
"io"
"sort"
"strings"
"math"
"github.com/alecthomas/chroma"
"github.com/alecthomas/chroma/formatters"
"github.com/alecthomas/chroma/lexers"
"github.com/alecthomas/chroma/styles"
2022-12-01 18:48:01 +00:00
"oss.terrastruct.com/util-go/go2"
2022-12-05 20:48:03 +00:00
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
2022-11-27 18:11:14 +00:00
"oss.terrastruct.com/d2/d2renderers/d2latex"
2022-12-21 07:43:45 +00:00
"oss.terrastruct.com/d2/d2renderers/d2sketch"
"oss.terrastruct.com/d2/d2target"
2022-11-10 19:21:14 +00:00
"oss.terrastruct.com/d2/lib/color"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/d2/lib/shape"
2022-12-21 07:43:45 +00:00
"oss.terrastruct.com/d2/lib/svg"
"oss.terrastruct.com/d2/lib/textmeasure"
)
const (
2022-12-12 07:31:01 +00:00
DEFAULT_PADDING = 100
MIN_ARROWHEAD_STROKE_WIDTH = 2
2022-12-27 07:56:23 +00:00
2022-12-29 00:19:30 +00:00
appendixIconRadius = 16
)
var multipleOffset = geo.NewVector(d2target.MULTIPLE_OFFSET, -d2target.MULTIPLE_OFFSET)
2023-01-19 07:12:26 +00:00
2022-12-27 07:56:23 +00:00
//go:embed tooltip.svg
2022-12-28 20:07:01 +00:00
var TooltipIcon string
2022-12-27 07:56:23 +00:00
2022-12-29 00:19:30 +00:00
//go:embed link.svg
var LinkIcon string
2022-11-12 18:29:21 +00:00
//go:embed style.css
var styleCSS string
2022-12-21 07:43:45 +00:00
//go:embed sketchstyle.css
var sketchStyleCSS string
//go:embed github-markdown.css
var mdCSS string
2022-12-21 07:43:45 +00:00
type RenderOpts struct {
Pad int
Sketch bool
}
2022-12-12 07:31:01 +00:00
func setViewbox(writer io.Writer, diagram *d2target.Diagram, pad int) (width int, height int) {
tl, br := diagram.BoundingBox()
2022-12-12 07:31:01 +00:00
w := br.X - tl.X + pad*2
h := br.Y - tl.Y + pad*2
// TODO minify
// TODO background stuff. e.g. dotted, grid, colors
fmt.Fprintf(writer, `<?xml version="1.0" encoding="utf-8"?>
<svg
2023-01-03 23:01:21 +00:00
id="d2-svg"
style="background: white;"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
2022-12-12 07:31:01 +00:00
width="%d" height="%d" viewBox="%d %d %d %d">`, w, h, tl.X-pad, tl.Y-pad, w, h)
return w, h
}
func arrowheadMarkerID(isTarget bool, connection d2target.Connection) string {
var arrowhead d2target.Arrowhead
if isTarget {
arrowhead = connection.DstArrow
} else {
arrowhead = connection.SrcArrow
}
return fmt.Sprintf("mk-%s", hash(fmt.Sprintf("%s,%t,%d,%s",
arrowhead, isTarget, connection.StrokeWidth, connection.Stroke,
)))
}
func arrowheadDimensions(arrowhead d2target.Arrowhead, strokeWidth float64) (width, height float64) {
var widthMultiplier float64
var heightMultiplier float64
switch arrowhead {
case d2target.ArrowArrowhead:
widthMultiplier = 5
heightMultiplier = 5
case d2target.TriangleArrowhead:
widthMultiplier = 5
heightMultiplier = 6
case d2target.LineArrowhead:
widthMultiplier = 5
heightMultiplier = 8
case d2target.FilledDiamondArrowhead:
widthMultiplier = 11
heightMultiplier = 7
case d2target.DiamondArrowhead:
widthMultiplier = 11
heightMultiplier = 9
case d2target.FilledCircleArrowhead, d2target.CircleArrowhead:
widthMultiplier = 12
heightMultiplier = 12
case d2target.CfOne, d2target.CfMany, d2target.CfOneRequired, d2target.CfManyRequired:
widthMultiplier = 14
heightMultiplier = 15
}
clippedStrokeWidth := go2.Max(MIN_ARROWHEAD_STROKE_WIDTH, strokeWidth)
return clippedStrokeWidth * widthMultiplier, clippedStrokeWidth * heightMultiplier
}
func arrowheadMarker(isTarget bool, id string, connection d2target.Connection) string {
arrowhead := connection.DstArrow
if !isTarget {
arrowhead = connection.SrcArrow
}
strokeWidth := float64(connection.StrokeWidth)
width, height := arrowheadDimensions(arrowhead, strokeWidth)
var path string
switch arrowhead {
case d2target.ArrowArrowhead:
attrs := fmt.Sprintf(`class="connection" fill="%s" stroke-width="%d"`, connection.Stroke, connection.StrokeWidth)
if isTarget {
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f %f,%f" />`,
attrs,
0., 0.,
width, height/2,
0., height,
width/4, height/2,
)
} else {
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f %f,%f" />`,
attrs,
0., height/2,
width, 0.,
width*3/4, height/2,
width, height,
)
}
case d2target.TriangleArrowhead:
attrs := fmt.Sprintf(`class="connection" fill="%s" stroke-width="%d"`, connection.Stroke, connection.StrokeWidth)
if isTarget {
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f" />`,
attrs,
0., 0.,
width, height/2.0,
0., height,
)
} else {
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f" />`,
attrs,
width, 0.,
0., height/2.0,
width, height,
)
}
case d2target.LineArrowhead:
attrs := fmt.Sprintf(`class="connection" fill="none" stroke="%s" stroke-width="%d"`, connection.Stroke, connection.StrokeWidth)
if isTarget {
path = fmt.Sprintf(`<polyline %s points="%f,%f %f,%f %f,%f"/>`,
attrs,
strokeWidth/2, strokeWidth/2,
width-strokeWidth/2, height/2,
strokeWidth/2, height-strokeWidth/2,
)
} else {
path = fmt.Sprintf(`<polyline %s points="%f,%f %f,%f %f,%f"/>`,
attrs,
width-strokeWidth/2, strokeWidth/2,
strokeWidth/2, height/2,
width-strokeWidth/2, height-strokeWidth/2,
)
}
case d2target.FilledDiamondArrowhead:
attrs := fmt.Sprintf(`class="connection" fill="%s" stroke-width="%d"`, connection.Stroke, connection.StrokeWidth)
if isTarget {
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f %f,%f" />`,
attrs,
0., height/2.0,
width/2.0, 0.,
width, height/2.0,
width/2.0, height,
)
} else {
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f %f,%f" />`,
attrs,
0., height/2.0,
width/2.0, 0.,
width, height/2.0,
width/2.0, height,
)
}
case d2target.DiamondArrowhead:
attrs := fmt.Sprintf(`class="connection" fill="white" stroke="%s" stroke-width="%d"`, connection.Stroke, connection.StrokeWidth)
if isTarget {
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f %f,%f" />`,
attrs,
0., height/2.0,
width/2, height/8,
width, height/2.0,
width/2.0, height*0.9,
)
} else {
path = fmt.Sprintf(`<polygon %s points="%f,%f %f,%f %f,%f %f,%f" />`,
attrs,
width/8, height/2.0,
width*0.6, height/8,
width*1.1, height/2.0,
width*0.6, height*7/8,
)
}
2023-01-10 03:44:45 +00:00
case d2target.FilledCircleArrowhead:
attrs := fmt.Sprintf(`class="connection" fill="%s" stroke-width="%d"`, connection.Stroke, connection.StrokeWidth)
2023-01-19 19:19:29 +00:00
radius := width / 2
2023-01-10 03:44:45 +00:00
if isTarget {
2023-01-19 19:19:29 +00:00
path = fmt.Sprintf(`<circle %s cx="%f" cy="%f" r="%f"/>`,
2023-01-10 03:44:45 +00:00
attrs,
2023-01-19 19:19:29 +00:00
radius+strokeWidth/2,
radius,
radius-strokeWidth/2,
2023-01-10 03:44:45 +00:00
)
} else {
2023-01-19 19:19:29 +00:00
path = fmt.Sprintf(`<circle %s cx="%f" cy="%f" r="%f"/>`,
2023-01-10 03:44:45 +00:00
attrs,
2023-01-19 19:19:29 +00:00
radius-strokeWidth/2,
radius,
radius-strokeWidth/2,
2023-01-10 03:44:45 +00:00
)
}
case d2target.CircleArrowhead:
attrs := fmt.Sprintf(`class="connection" fill="white" stroke="%s" stroke-width="%d"`, connection.Stroke, connection.StrokeWidth)
2023-01-19 19:19:29 +00:00
radius := width / 2
2023-01-10 03:44:45 +00:00
if isTarget {
path = fmt.Sprintf(`<circle %s cx="%f" cy="%f" r="%f"/>`,
attrs,
2023-01-19 19:19:29 +00:00
radius+strokeWidth/2,
radius,
radius-strokeWidth,
2023-01-10 03:44:45 +00:00
)
} else {
path = fmt.Sprintf(`<circle %s cx="%f" cy="%f" r="%f"/>`,
attrs,
2023-01-19 19:19:29 +00:00
radius-strokeWidth/2,
radius,
radius-strokeWidth,
2023-01-10 03:44:45 +00:00
)
}
case d2target.CfOne, d2target.CfMany, d2target.CfOneRequired, d2target.CfManyRequired:
attrs := fmt.Sprintf(`class="connection" stroke="%s" stroke-width="%d" fill="white"`, connection.Stroke, connection.StrokeWidth)
2023-01-01 10:31:45 +00:00
offset := 4.0 + float64(connection.StrokeWidth*2)
var modifier string
if arrowhead == d2target.CfOneRequired || arrowhead == d2target.CfManyRequired {
2023-01-01 10:31:45 +00:00
modifier = fmt.Sprintf(`<path %s d="M%f,%f %f,%f"/>`,
attrs,
offset, 0.,
offset, height,
)
} else {
2023-01-01 10:31:45 +00:00
modifier = fmt.Sprintf(`<circle %s cx="%f" cy="%f" r="%f"/>`,
attrs,
offset/2.0+1.0, height/2.0,
offset/2.0,
)
}
if !isTarget {
attrs = fmt.Sprintf(`%s transform="scale(-1) translate(-%f, -%f)"`, attrs, width, height)
}
if arrowhead == d2target.CfMany || arrowhead == d2target.CfManyRequired {
2023-01-01 10:31:45 +00:00
path = fmt.Sprintf(`<g %s>%s<path d="M%f,%f %f,%f M%f,%f %f,%f M%f,%f %f,%f"/></g>`,
attrs, modifier,
2023-01-01 10:31:45 +00:00
width-3.0, height/2.0,
width+offset, height/2.0,
offset+2.0, height/2.0,
width+offset, 0.,
offset+2.0, height/2.0,
width+offset, height,
)
} else {
2023-01-01 10:31:45 +00:00
path = fmt.Sprintf(`<g %s>%s<path d="M%f,%f %f,%f M%f,%f %f,%f"/></g>`,
attrs, modifier,
2023-01-01 10:31:45 +00:00
width-3.0, height/2.0,
width+offset, height/2.0,
offset*1.8, 0.,
offset*1.8, height,
)
}
default:
return ""
}
var refX float64
refY := height / 2
switch arrowhead {
case d2target.DiamondArrowhead:
if isTarget {
refX = width - 0.6*strokeWidth
} else {
refX = width/8 + 0.6*strokeWidth
}
width *= 1.1
default:
if isTarget {
refX = width - 1.5*strokeWidth
} else {
refX = 1.5 * strokeWidth
}
}
return strings.Join([]string{
fmt.Sprintf(`<marker id="%s" markerWidth="%f" markerHeight="%f" refX="%f" refY="%f"`,
id, width, height, refX, refY,
),
fmt.Sprintf(`viewBox="%f %f %f %f"`, 0., 0., width, height),
`orient="auto" markerUnits="userSpaceOnUse">`,
path,
"</marker>",
}, " ")
}
// compute the (dx, dy) adjustment to apply to get the arrowhead-adjusted end point
func arrowheadAdjustment(start, end *geo.Point, arrowhead d2target.Arrowhead, edgeStrokeWidth, shapeStrokeWidth int) *geo.Point {
distance := (float64(edgeStrokeWidth) + float64(shapeStrokeWidth)) / 2.0
if arrowhead != d2target.NoArrowhead {
distance += float64(edgeStrokeWidth)
}
v := geo.NewVector(end.X-start.X, end.Y-start.Y)
return v.Unit().Multiply(-distance).ToPoint()
}
func getArrowheadAdjustments(connection d2target.Connection, idToShape map[string]d2target.Shape) (srcAdj, dstAdj *geo.Point) {
route := connection.Route
srcShape := idToShape[connection.Src]
dstShape := idToShape[connection.Dst]
sourceAdjustment := arrowheadAdjustment(route[1], route[0], connection.SrcArrow, connection.StrokeWidth, srcShape.StrokeWidth)
targetAdjustment := arrowheadAdjustment(route[len(route)-2], route[len(route)-1], connection.DstArrow, connection.StrokeWidth, dstShape.StrokeWidth)
return sourceAdjustment, targetAdjustment
}
// returns the path's d attribute for the given connection
func pathData(connection d2target.Connection, srcAdj, dstAdj *geo.Point) string {
var path []string
route := connection.Route
path = append(path, fmt.Sprintf("M %f %f",
route[0].X+srcAdj.X,
route[0].Y+srcAdj.Y,
))
if connection.IsCurve {
i := 1
for ; i < len(route)-3; i += 3 {
path = append(path, fmt.Sprintf("C %f %f %f %f %f %f",
route[i].X, route[i].Y,
route[i+1].X, route[i+1].Y,
route[i+2].X, route[i+2].Y,
))
}
// final curve target adjustment
path = append(path, fmt.Sprintf("C %f %f %f %f %f %f",
route[i].X, route[i].Y,
route[i+1].X, route[i+1].Y,
route[i+2].X+dstAdj.X,
route[i+2].Y+dstAdj.Y,
))
} else {
for i := 1; i < len(route)-1; i++ {
prevSource := route[i-1]
prevTarget := route[i]
currTarget := route[i+1]
prevVector := prevSource.VectorTo(prevTarget)
currVector := prevTarget.VectorTo(currTarget)
dist := geo.EuclideanDistance(prevTarget.X, prevTarget.Y, currTarget.X, currTarget.Y)
units := math.Min(10, dist/2)
prevTranslations := prevVector.Unit().Multiply(units).ToPoint()
currTranslations := currVector.Unit().Multiply(units).ToPoint()
path = append(path, fmt.Sprintf("L %f %f",
prevTarget.X-prevTranslations.X,
prevTarget.Y-prevTranslations.Y,
))
// If the segment length is too small, instead of drawing 2 arcs, just skip this segment and bezier curve to the next one
if units < 10 && i < len(route)-2 {
nextTarget := route[i+2]
nextVector := geo.NewVector(nextTarget.X-currTarget.X, nextTarget.Y-currTarget.Y)
i++
nextTranslations := nextVector.Unit().Multiply(units).ToPoint()
// These 2 bezier control points aren't just at the corner -- they are reflected at the corner, which causes the curve to be ~tangent to the corner,
// which matches how the two arcs look
path = append(path, fmt.Sprintf("C %f %f %f %f %f %f",
// Control point
prevTarget.X+prevTranslations.X,
prevTarget.Y+prevTranslations.Y,
// Control point
currTarget.X-nextTranslations.X,
currTarget.Y-nextTranslations.Y,
// Where curve ends
currTarget.X+nextTranslations.X,
currTarget.Y+nextTranslations.Y,
))
} else {
path = append(path, fmt.Sprintf("S %f %f %f %f",
prevTarget.X,
prevTarget.Y,
prevTarget.X+currTranslations.X,
prevTarget.Y+currTranslations.Y,
))
}
}
lastPoint := route[len(route)-1]
path = append(path, fmt.Sprintf("L %f %f",
lastPoint.X+dstAdj.X,
lastPoint.Y+dstAdj.Y,
))
}
return strings.Join(path, " ")
}
2022-12-05 19:57:16 +00:00
func makeLabelMask(labelTL *geo.Point, width, height int) string {
2022-12-03 06:47:54 +00:00
return fmt.Sprintf(`<rect x="%f" y="%f" width="%d" height="%d" fill="black"></rect>`,
labelTL.X, labelTL.Y,
2022-12-05 19:57:16 +00:00
width,
height,
2022-12-03 06:47:54 +00:00
)
}
2022-12-21 07:43:45 +00:00
func drawConnection(writer io.Writer, labelMaskID string, connection d2target.Connection, markers map[string]struct{}, idToShape map[string]d2target.Shape, sketchRunner *d2sketch.Runner) (labelMask string, _ error) {
2023-01-19 08:46:30 +00:00
opacityStyle := ""
if connection.Opacity != 1.0 {
opacityStyle = fmt.Sprintf(" style='opacity:%f'", connection.Opacity)
}
fmt.Fprintf(writer, `<g id="%s"%s>`, svg.EscapeText(connection.ID), opacityStyle)
var markerStart string
if connection.SrcArrow != d2target.NoArrowhead {
id := arrowheadMarkerID(false, connection)
if _, in := markers[id]; !in {
marker := arrowheadMarker(false, id, connection)
if marker == "" {
panic(fmt.Sprintf("received empty arrow head marker for: %#v", connection))
}
fmt.Fprint(writer, marker)
markers[id] = struct{}{}
}
markerStart = fmt.Sprintf(`marker-start="url(#%s)" `, id)
}
var markerEnd string
if connection.DstArrow != d2target.NoArrowhead {
id := arrowheadMarkerID(true, connection)
if _, in := markers[id]; !in {
marker := arrowheadMarker(true, id, connection)
if marker == "" {
panic(fmt.Sprintf("received empty arrow head marker for: %#v", connection))
}
fmt.Fprint(writer, marker)
markers[id] = struct{}{}
}
markerEnd = fmt.Sprintf(`marker-end="url(#%s)" `, id)
}
var labelTL *geo.Point
if connection.Label != "" {
labelTL = connection.GetLabelTopLeft()
labelTL.X = math.Round(labelTL.X)
labelTL.Y = math.Round(labelTL.Y)
if label.Position(connection.LabelPosition).IsOnEdge() {
2022-12-05 19:57:16 +00:00
labelMask = makeLabelMask(labelTL, connection.LabelWidth, connection.LabelHeight)
}
}
srcAdj, dstAdj := getArrowheadAdjustments(connection, idToShape)
path := pathData(connection, srcAdj, dstAdj)
2023-01-12 20:01:49 +00:00
mask := fmt.Sprintf(`mask="url(#%s)"`, labelMaskID)
2022-12-21 07:43:45 +00:00
if sketchRunner != nil {
2023-01-12 20:01:49 +00:00
out, err := d2sketch.Connection(sketchRunner, connection, path, mask)
2022-12-21 07:43:45 +00:00
if err != nil {
return "", err
}
2023-01-12 20:01:49 +00:00
fmt.Fprint(writer, out)
// render sketch arrowheads separately
arrowPaths, err := d2sketch.Arrowheads(sketchRunner, connection, srcAdj, dstAdj)
2023-01-13 02:27:53 +00:00
if err != nil {
return "", err
2023-01-12 20:01:49 +00:00
}
2023-01-13 02:27:53 +00:00
fmt.Fprint(writer, arrowPaths)
2022-12-21 07:43:45 +00:00
} else {
2023-01-12 18:51:26 +00:00
animatedClass := ""
if connection.Animated {
animatedClass = " animated-connection"
}
2023-01-12 20:01:49 +00:00
fmt.Fprintf(writer, `<path d="%s" class="connection%s" style="fill:none;%s" %s%s%s/>`,
path, animatedClass, connection.CSSStyle(), markerStart, markerEnd, mask)
2022-12-21 07:43:45 +00:00
}
if connection.Label != "" {
fontClass := "text"
if connection.Bold {
fontClass += "-bold"
} else if connection.Italic {
fontClass += "-italic"
}
fontColor := "black"
if connection.Color != "" {
fontColor = connection.Color
}
if connection.Fill != "" {
fmt.Fprintf(writer, `<rect x="%f" y="%f" width="%d" height="%d" style="fill:%s" />`,
labelTL.X, labelTL.Y, connection.LabelWidth, connection.LabelHeight, connection.Fill)
}
textStyle := fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "middle", connection.FontSize, fontColor)
x := labelTL.X + float64(connection.LabelWidth)/2
y := labelTL.Y + float64(connection.FontSize)
fmt.Fprintf(writer, `<text class="%s" x="%f" y="%f" style="%s">%s</text>`,
fontClass,
x, y,
textStyle,
2022-12-28 04:29:51 +00:00
RenderText(connection.Label, x, float64(connection.LabelHeight)),
)
}
2022-11-24 04:14:46 +00:00
length := geo.Route(connection.Route).Length()
if connection.SrcLabel != "" {
2022-11-24 04:40:24 +00:00
// TODO use arrowhead label dimensions https://github.com/terrastruct/d2/issues/183
2022-11-24 04:14:46 +00:00
size := float64(connection.FontSize)
position := 0.
if length > 0 {
position = size / length
}
2022-11-24 04:40:24 +00:00
fmt.Fprint(writer, renderArrowheadLabel(connection, connection.SrcLabel, position, size, size))
2022-11-24 04:14:46 +00:00
}
if connection.DstLabel != "" {
2022-11-24 04:40:24 +00:00
// TODO use arrowhead label dimensions https://github.com/terrastruct/d2/issues/183
2022-11-24 04:14:46 +00:00
size := float64(connection.FontSize)
position := 1.
if length > 0 {
position -= size / length
}
2022-11-24 04:40:24 +00:00
fmt.Fprint(writer, renderArrowheadLabel(connection, connection.DstLabel, position, size, size))
2022-11-24 04:14:46 +00:00
}
fmt.Fprintf(writer, `</g>`)
2022-12-03 06:47:54 +00:00
return
}
2022-11-24 04:40:24 +00:00
func renderArrowheadLabel(connection d2target.Connection, text string, position, width, height float64) string {
labelTL := label.UnlockedTop.GetPointOnRoute(connection.Route, float64(connection.StrokeWidth), position, width, height)
textStyle := fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "middle", connection.FontSize, "black")
x := labelTL.X + width/2
y := labelTL.Y + float64(connection.FontSize)
return fmt.Sprintf(`<text class="text-italic" x="%f" y="%f" style="%s">%s</text>`,
x, y,
textStyle,
2022-12-28 04:29:51 +00:00
RenderText(text, x, height),
2022-11-24 04:40:24 +00:00
)
}
2022-11-12 18:29:21 +00:00
func renderOval(tl *geo.Point, width, height float64, style string) string {
2022-11-09 03:40:20 +00:00
rx := width / 2
ry := height / 2
cx := tl.X + rx
cy := tl.Y + ry
2022-11-12 18:29:21 +00:00
return fmt.Sprintf(`<ellipse class="shape" cx="%f" cy="%f" rx="%f" ry="%f" style="%s" />`, cx, cy, rx, ry, style)
2022-11-09 03:40:20 +00:00
}
func renderDoubleOval(tl *geo.Point, width, height float64, style string) string {
2023-01-22 10:40:47 +00:00
var innerTL *geo.Point = tl.AddVector(geo.NewVector(d2target.INNER_BORDER_OFFSET, d2target.INNER_BORDER_OFFSET))
2023-01-19 07:12:26 +00:00
return renderOval(tl, width, height, style) + renderOval(innerTL, width-10, height-10, style)
2022-12-30 09:14:44 +00:00
}
2022-11-09 19:10:51 +00:00
func defineShadowFilter(writer io.Writer) {
fmt.Fprint(writer, `<defs>
<filter id="shadow-filter" width="200%" height="200%" x="-50%" y="-50%">
<feGaussianBlur stdDeviation="1.7 " in="SourceGraphic"></feGaussianBlur>
2022-11-09 19:14:31 +00:00
<feFlood flood-color="#3d4574" flood-opacity="0.4" result="ShadowFeFlood" in="SourceGraphic"></feFlood>
<feComposite in="ShadowFeFlood" in2="SourceAlpha" operator="in" result="ShadowFeComposite"></feComposite>
<feOffset dx="3" dy="5" result="ShadowFeOffset" in="ShadowFeComposite"></feOffset>
<feBlend in="SourceGraphic" in2="ShadowFeOffset" mode="normal" result="ShadowFeBlend"></feBlend>
2022-11-09 19:10:51 +00:00
</filter>
</defs>`)
}
func render3dRect(targetShape d2target.Shape) string {
moveTo := func(p d2target.Point) string {
2022-11-30 00:39:38 +00:00
return fmt.Sprintf("M%d,%d", p.X+targetShape.Pos.X, p.Y+targetShape.Pos.Y)
}
lineTo := func(p d2target.Point) string {
2022-11-30 00:39:38 +00:00
return fmt.Sprintf("L%d,%d", p.X+targetShape.Pos.X, p.Y+targetShape.Pos.Y)
}
// draw border all in one path to prevent overlapping sections
var borderSegments []string
borderSegments = append(borderSegments,
moveTo(d2target.Point{X: 0, Y: 0}),
)
for _, v := range []d2target.Point{
2023-01-19 19:51:30 +00:00
{X: d2target.THREE_DEE_OFFSET, Y: -d2target.THREE_DEE_OFFSET},
{X: targetShape.Width + d2target.THREE_DEE_OFFSET, Y: -d2target.THREE_DEE_OFFSET},
{X: targetShape.Width + d2target.THREE_DEE_OFFSET, Y: targetShape.Height - d2target.THREE_DEE_OFFSET},
{X: targetShape.Width, Y: targetShape.Height},
{X: 0, Y: targetShape.Height},
{X: 0, Y: 0},
{X: targetShape.Width, Y: 0},
{X: targetShape.Width, Y: targetShape.Height},
} {
borderSegments = append(borderSegments, lineTo(v))
}
// move to top right to draw last segment without overlapping
borderSegments = append(borderSegments,
moveTo(d2target.Point{X: targetShape.Width, Y: 0}),
)
borderSegments = append(borderSegments,
2023-01-19 19:51:30 +00:00
lineTo(d2target.Point{X: targetShape.Width + d2target.THREE_DEE_OFFSET, Y: -d2target.THREE_DEE_OFFSET}),
)
border := targetShape
border.Fill = "none"
2023-01-12 19:22:53 +00:00
borderStyle := border.CSSStyle()
renderedBorder := fmt.Sprintf(`<path d="%s" style="%s"/>`,
2022-11-30 00:39:38 +00:00
strings.Join(borderSegments, " "), borderStyle)
// create mask from border stroke, to cut away from the shape fills
2022-12-22 19:06:57 +00:00
maskID := fmt.Sprintf("border-mask-%v", svg.EscapeText(targetShape.ID))
borderMask := strings.Join([]string{
fmt.Sprintf(`<defs><mask id="%s" maskUnits="userSpaceOnUse" x="%d" y="%d" width="%d" height="%d">`,
2023-01-19 19:51:30 +00:00
maskID, targetShape.Pos.X, targetShape.Pos.Y-d2target.THREE_DEE_OFFSET, targetShape.Width+d2target.THREE_DEE_OFFSET, targetShape.Height+d2target.THREE_DEE_OFFSET,
),
fmt.Sprintf(`<rect x="%d" y="%d" width="%d" height="%d" fill="white"></rect>`,
2023-01-19 19:51:30 +00:00
targetShape.Pos.X, targetShape.Pos.Y-d2target.THREE_DEE_OFFSET, targetShape.Width+d2target.THREE_DEE_OFFSET, targetShape.Height+d2target.THREE_DEE_OFFSET,
),
fmt.Sprintf(`<path d="%s" style="%s;stroke:#000;fill:none;opacity:1;"/></mask></defs>`,
strings.Join(borderSegments, ""), borderStyle),
}, "\n")
// render the main rectangle without stroke and the border mask
mainShape := targetShape
mainShape.Stroke = "none"
mainRect := fmt.Sprintf(`<rect x="%d" y="%d" width="%d" height="%d" style="%s" mask="url(#%s)"/>`,
2023-01-12 19:22:53 +00:00
targetShape.Pos.X, targetShape.Pos.Y, targetShape.Width, targetShape.Height, mainShape.CSSStyle(), maskID,
)
// render the side shapes in the darkened color without stroke and the border mask
var sidePoints []string
for _, v := range []d2target.Point{
{X: 0, Y: 0},
2023-01-19 19:51:30 +00:00
{X: d2target.THREE_DEE_OFFSET, Y: -d2target.THREE_DEE_OFFSET},
{X: targetShape.Width + d2target.THREE_DEE_OFFSET, Y: -d2target.THREE_DEE_OFFSET},
{X: targetShape.Width + d2target.THREE_DEE_OFFSET, Y: targetShape.Height - d2target.THREE_DEE_OFFSET},
{X: targetShape.Width, Y: targetShape.Height},
{X: targetShape.Width, Y: 0},
} {
sidePoints = append(sidePoints,
fmt.Sprintf("%d,%d", v.X+targetShape.Pos.X, v.Y+targetShape.Pos.Y),
)
}
darkerColor, err := color.Darken(targetShape.Fill)
if err != nil {
darkerColor = targetShape.Fill
}
sideShape := targetShape
sideShape.Fill = darkerColor
sideShape.Stroke = "none"
renderedSides := fmt.Sprintf(`<polygon points="%s" style="%s" mask="url(#%s)"/>`,
2023-01-12 19:22:53 +00:00
strings.Join(sidePoints, " "), sideShape.CSSStyle(), maskID)
return borderMask + mainRect + renderedSides + renderedBorder
}
2022-12-21 07:43:45 +00:00
func drawShape(writer io.Writer, targetShape d2target.Shape, sketchRunner *d2sketch.Runner) (labelMask string, err error) {
2022-12-29 00:19:30 +00:00
closingTag := "</g>"
if targetShape.Link != "" {
2022-12-29 00:41:15 +00:00
fmt.Fprintf(writer, `<a href="%s" xlink:href="%[1]s">`, targetShape.Link)
2022-12-29 00:19:30 +00:00
closingTag += "</a>"
}
2023-01-19 08:46:30 +00:00
// Opacity is a unique style, it applies to everything for a shape
opacityStyle := ""
if targetShape.Opacity != 1.0 {
opacityStyle = fmt.Sprintf(" style='opacity:%f'", targetShape.Opacity)
}
fmt.Fprintf(writer, `<g id="%s"%s>`, svg.EscapeText(targetShape.ID), opacityStyle)
tl := geo.NewPoint(float64(targetShape.Pos.X), float64(targetShape.Pos.Y))
width := float64(targetShape.Width)
height := float64(targetShape.Height)
2023-01-12 19:22:53 +00:00
style := targetShape.CSSStyle()
shapeType := d2target.DSL_SHAPE_TO_SHAPE_TYPE[targetShape.Type]
s := shape.NewShape(shapeType, geo.NewBox(tl, width, height))
2022-11-10 03:24:27 +00:00
var shadowAttr string
2022-11-10 19:21:14 +00:00
if targetShape.Shadow {
2022-11-10 03:24:27 +00:00
switch targetShape.Type {
case d2target.ShapeText,
d2target.ShapeCode,
d2target.ShapeClass,
d2target.ShapeSQLTable:
default:
shadowAttr = `filter="url(#shadow-filter)" `
}
}
2022-12-05 19:22:16 +00:00
var blendModeClass string
if targetShape.Blend {
blendModeClass = " blend"
}
fmt.Fprintf(writer, `<g class="shape%s" %s>`, blendModeClass, shadowAttr)
2022-11-10 03:24:27 +00:00
2022-11-09 03:40:20 +00:00
var multipleTL *geo.Point
if targetShape.Multiple {
multipleTL = tl.AddVector(multipleOffset)
2022-11-09 03:40:20 +00:00
}
switch targetShape.Type {
case d2target.ShapeClass:
2022-12-22 19:32:41 +00:00
if sketchRunner != nil {
out, err := d2sketch.Class(sketchRunner, targetShape)
if err != nil {
return "", err
}
fmt.Fprint(writer, out)
2022-12-22 19:32:41 +00:00
} else {
drawClass(writer, targetShape)
}
2023-01-13 16:16:18 +00:00
addAppendixItems(writer, targetShape)
fmt.Fprint(writer, `</g>`)
fmt.Fprint(writer, closingTag)
2022-12-05 19:57:16 +00:00
return labelMask, nil
case d2target.ShapeSQLTable:
2022-12-22 19:06:57 +00:00
if sketchRunner != nil {
out, err := d2sketch.Table(sketchRunner, targetShape)
if err != nil {
return "", err
}
fmt.Fprint(writer, out)
2022-12-22 19:06:57 +00:00
} else {
drawTable(writer, targetShape)
}
2023-01-13 16:16:18 +00:00
addAppendixItems(writer, targetShape)
fmt.Fprint(writer, `</g>`)
fmt.Fprint(writer, closingTag)
2022-12-05 19:57:16 +00:00
return labelMask, nil
case d2target.ShapeOval:
2023-01-19 07:12:26 +00:00
if targetShape.DoubleBorder {
if targetShape.Multiple {
2023-01-19 07:12:26 +00:00
fmt.Fprint(writer, renderDoubleOval(multipleTL, width, height, style))
2022-12-21 07:43:45 +00:00
}
if sketchRunner != nil {
2023-01-19 07:12:26 +00:00
out, err := d2sketch.DoubleOval(sketchRunner, targetShape)
if err != nil {
return "", err
}
fmt.Fprint(writer, out)
} else {
2023-01-19 07:12:26 +00:00
fmt.Fprint(writer, renderDoubleOval(tl, width, height, style))
2022-12-30 09:14:44 +00:00
}
} else {
if targetShape.Multiple {
2023-01-19 07:12:26 +00:00
fmt.Fprint(writer, renderOval(multipleTL, width, height, style))
}
if sketchRunner != nil {
2023-01-19 07:12:26 +00:00
out, err := d2sketch.Oval(sketchRunner, targetShape)
if err != nil {
return "", err
}
fmt.Fprint(writer, out)
} else {
2023-01-19 07:12:26 +00:00
fmt.Fprint(writer, renderOval(tl, width, height, style))
}
2022-12-30 09:14:44 +00:00
}
2023-01-24 09:29:38 +00:00
case d2target.ShapeImage:
2022-11-12 18:29:21 +00:00
fmt.Fprintf(writer, `<image href="%s" x="%d" y="%d" width="%d" height="%d" style="%s" />`,
2022-12-24 20:45:12 +00:00
html.EscapeString(targetShape.Icon.String()),
2022-11-12 18:29:21 +00:00
targetShape.Pos.X, targetShape.Pos.Y, targetShape.Width, targetShape.Height, style)
2022-11-10 03:24:27 +00:00
// TODO should standardize "" to rectangle
2022-12-05 21:53:09 +00:00
case d2target.ShapeRectangle, d2target.ShapeSequenceDiagram, "":
2022-11-10 19:21:14 +00:00
if targetShape.ThreeDee {
fmt.Fprint(writer, render3dRect(targetShape))
} else {
2022-12-31 07:57:22 +00:00
if !targetShape.DoubleBorder {
if targetShape.Multiple {
fmt.Fprintf(writer, `<rect x="%d" y="%d" width="%d" height="%d" style="%s" />`,
targetShape.Pos.X+10, targetShape.Pos.Y-10, targetShape.Width, targetShape.Height, style)
}
if sketchRunner != nil {
out, err := d2sketch.Rect(sketchRunner, targetShape)
if err != nil {
return "", err
}
fmt.Fprint(writer, out)
2022-12-31 07:57:22 +00:00
} else {
fmt.Fprintf(writer, `<rect x="%d" y="%d" width="%d" height="%d" style="%s" />`,
targetShape.Pos.X, targetShape.Pos.Y, targetShape.Width, targetShape.Height, style)
2022-12-21 07:43:45 +00:00
}
} else {
2022-12-31 07:57:22 +00:00
if targetShape.Multiple {
fmt.Fprintf(writer, `<rect x="%d" y="%d" width="%d" height="%d" style="%s" />`,
targetShape.Pos.X+10, targetShape.Pos.Y-10, targetShape.Width, targetShape.Height, style)
fmt.Fprintf(writer, `<rect x="%d" y="%d" width="%d" height="%d" style="%s" />`,
targetShape.Pos.X+10+d2target.INNER_BORDER_OFFSET, targetShape.Pos.Y-10+d2target.INNER_BORDER_OFFSET, targetShape.Width-2*d2target.INNER_BORDER_OFFSET, targetShape.Height-2*d2target.INNER_BORDER_OFFSET, style)
2022-12-31 07:57:22 +00:00
}
if sketchRunner != nil {
out, err := d2sketch.DoubleRect(sketchRunner, targetShape)
if err != nil {
return "", err
}
fmt.Fprint(writer, out)
2022-12-31 07:57:22 +00:00
} else {
fmt.Fprintf(writer, `<rect x="%d" y="%d" width="%d" height="%d" style="%s" />`,
targetShape.Pos.X, targetShape.Pos.Y, targetShape.Width, targetShape.Height, style)
fmt.Fprintf(writer, `<rect x="%d" y="%d" width="%d" height="%d" style="%s" />`,
targetShape.Pos.X+d2target.INNER_BORDER_OFFSET, targetShape.Pos.Y+d2target.INNER_BORDER_OFFSET, targetShape.Width-2*d2target.INNER_BORDER_OFFSET, targetShape.Height-2*d2target.INNER_BORDER_OFFSET, style)
2022-12-31 07:57:22 +00:00
}
2022-12-21 07:43:45 +00:00
}
2022-11-09 03:40:20 +00:00
}
2022-11-10 03:24:27 +00:00
case d2target.ShapeText, d2target.ShapeCode:
default:
2022-11-09 03:40:20 +00:00
if targetShape.Multiple {
multiplePathData := shape.NewShape(shapeType, geo.NewBox(multipleTL, width, height)).GetSVGPathData()
for _, pathData := range multiplePathData {
2022-11-12 18:34:14 +00:00
fmt.Fprintf(writer, `<path d="%s" style="%s"/>`, pathData, style)
2022-11-09 03:40:20 +00:00
}
}
2022-12-21 07:43:45 +00:00
if sketchRunner != nil {
out, err := d2sketch.Paths(sketchRunner, targetShape, s.GetSVGPathData())
if err != nil {
return "", err
}
fmt.Fprint(writer, out)
2022-12-21 07:43:45 +00:00
} else {
for _, pathData := range s.GetSVGPathData() {
fmt.Fprintf(writer, `<path d="%s" style="%s"/>`, pathData, style)
}
}
}
2022-12-29 00:19:30 +00:00
// Closes the class=shape
fmt.Fprint(writer, `</g>`)
2022-11-10 03:24:27 +00:00
if targetShape.Icon != nil && targetShape.Type != d2target.ShapeImage {
iconPosition := label.Position(targetShape.IconPosition)
var box *geo.Box
if iconPosition.IsOutside() {
box = s.GetBox()
} else {
box = s.GetInnerBox()
}
iconSize := targetShape.GetIconSize(box)
tl := iconPosition.GetPointOnBox(box, label.PADDING, float64(iconSize), float64(iconSize))
fmt.Fprintf(writer, `<image href="%s" x="%f" y="%f" width="%d" height="%d" />`,
2022-12-24 20:45:12 +00:00
html.EscapeString(targetShape.Icon.String()),
tl.X,
tl.Y,
iconSize,
iconSize,
)
}
if targetShape.Label != "" {
labelPosition := label.Position(targetShape.LabelPosition)
var box *geo.Box
if labelPosition.IsOutside() {
box = s.GetBox()
} else {
box = s.GetInnerBox()
}
labelTL := labelPosition.GetPointOnBox(box, label.PADDING,
float64(targetShape.LabelWidth),
float64(targetShape.LabelHeight),
)
fontClass := "text"
if targetShape.Bold {
fontClass += "-bold"
} else if targetShape.Italic {
fontClass += "-italic"
}
if targetShape.Underline {
fontClass += " text-underline"
}
if targetShape.Type == d2target.ShapeCode {
2022-11-27 21:54:41 +00:00
lexer := lexers.Get(targetShape.Language)
if lexer == nil {
2023-01-12 07:53:02 +00:00
lexer = lexers.Fallback
2022-11-27 21:54:41 +00:00
}
style := styles.Get("github")
if style == nil {
2022-12-05 19:57:16 +00:00
return labelMask, errors.New(`code snippet style "github" not found`)
2022-11-27 21:54:41 +00:00
}
formatter := formatters.Get("svg")
if formatter == nil {
2022-12-05 19:57:16 +00:00
return labelMask, errors.New(`code snippet formatter "svg" not found`)
2022-11-27 21:54:41 +00:00
}
iterator, err := lexer.Tokenise(nil, targetShape.Label)
if err != nil {
2022-12-05 19:57:16 +00:00
return labelMask, err
2022-11-27 21:54:41 +00:00
}
svgStyles := styleToSVG(style)
containerStyle := fmt.Sprintf(`stroke: %s;fill:%s`, targetShape.Stroke, style.Get(chroma.Background).Background.String())
2023-01-19 08:52:10 +00:00
fmt.Fprintf(writer, `<g transform="translate(%f %f)">`, box.TopLeft.X, box.TopLeft.Y)
2022-11-27 21:54:41 +00:00
fmt.Fprintf(writer, `<rect class="shape" width="%d" height="%d" style="%s" />`,
targetShape.Width, targetShape.Height, containerStyle)
// Padding
fmt.Fprint(writer, `<g transform="translate(6 6)">`)
2022-11-27 21:54:41 +00:00
for index, tokens := range chroma.SplitTokensIntoLines(iterator.Tokens()) {
// TODO mono font looks better with 1.2 em (use px equivalent), but textmeasure needs to account for it. Not obvious how that should be done
fmt.Fprintf(writer, "<text class=\"text-mono\" x=\"0\" y=\"%fem\" xml:space=\"preserve\">", 1*float64(index+1))
for _, token := range tokens {
text := svgEscaper.Replace(token.String())
attr := styleAttr(svgStyles, token.Type)
if attr != "" {
text = fmt.Sprintf("<tspan %s>%s</tspan>", attr, text)
}
fmt.Fprint(writer, text)
}
fmt.Fprint(writer, "</text>")
}
fmt.Fprint(writer, "</g></g>")
} else if targetShape.Type == d2target.ShapeText && targetShape.Language == "latex" {
render, err := d2latex.Render(targetShape.Label)
if err != nil {
return labelMask, err
}
2023-01-19 08:46:30 +00:00
fmt.Fprintf(writer, `<g transform="translate(%f %f)">`, box.TopLeft.X, box.TopLeft.Y)
fmt.Fprint(writer, render)
fmt.Fprint(writer, "</g>")
} else if targetShape.Type == d2target.ShapeText && targetShape.Language != "" {
render, err := textmeasure.RenderMarkdown(targetShape.Label)
if err != nil {
return labelMask, err
}
fmt.Fprintf(writer, `<g><foreignObject requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" x="%f" y="%f" width="%d" height="%d">`,
box.TopLeft.X, box.TopLeft.Y, targetShape.Width, targetShape.Height,
)
// we need the self closing form in this svg/xhtml context
render = strings.ReplaceAll(render, "<hr>", "<hr />")
2022-12-18 05:22:33 +00:00
var mdStyle string
if targetShape.Fill != "" {
mdStyle = fmt.Sprintf("background-color:%s;", targetShape.Fill)
}
if targetShape.Stroke != "" {
mdStyle += fmt.Sprintf("color:%s;", targetShape.Stroke)
}
fmt.Fprintf(writer, `<div xmlns="http://www.w3.org/1999/xhtml" class="md" style="%s">%v</div>`, mdStyle, render)
fmt.Fprint(writer, `</foreignObject></g>`)
} else {
fontColor := "black"
if targetShape.Color != "" {
fontColor = targetShape.Color
}
textStyle := fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "middle", targetShape.FontSize, fontColor)
2022-12-05 20:48:03 +00:00
x := labelTL.X + float64(targetShape.LabelWidth)/2.
// text is vertically positioned at its baseline which is at labelTL+FontSize
y := labelTL.Y + float64(targetShape.FontSize)
2023-02-09 22:14:31 +00:00
2023-02-09 22:16:20 +00:00
// background style does not exist for <text>, so draw a rectangle behind it to emulate
2023-02-09 22:14:31 +00:00
if targetShape.LabelFill != "" {
fmt.Fprintf(writer, `<rect x="%f" y="%f" width="%d" height="%d" fill="%s"></rect>`,
labelTL.X, labelTL.Y,
targetShape.LabelWidth, targetShape.LabelHeight,
targetShape.LabelFill,
)
}
fmt.Fprintf(writer, `<text class="%s" x="%f" y="%f" style="%s">%s</text>`,
fontClass,
x, y,
textStyle,
2022-12-28 04:29:51 +00:00
RenderText(targetShape.Label, x, float64(targetShape.LabelHeight)),
)
2022-12-05 23:53:43 +00:00
if targetShape.Blend {
labelMask = makeLabelMask(labelTL, targetShape.LabelWidth, targetShape.LabelHeight-d2graph.INNER_LABEL_PADDING)
}
}
}
2022-12-27 07:56:23 +00:00
2023-01-13 16:16:18 +00:00
addAppendixItems(writer, targetShape)
fmt.Fprint(writer, closingTag)
2023-01-13 16:16:18 +00:00
return labelMask, nil
}
func addAppendixItems(writer io.Writer, shape d2target.Shape) {
2022-12-29 00:19:30 +00:00
rightPadForTooltip := 0
2023-01-13 16:16:18 +00:00
if shape.Tooltip != "" {
2022-12-29 00:19:30 +00:00
rightPadForTooltip = 2 * appendixIconRadius
fmt.Fprintf(writer, `<g transform="translate(%d %d)" class="appendix-icon">%s</g>`,
2023-01-13 16:16:18 +00:00
shape.Pos.X+shape.Width-appendixIconRadius,
shape.Pos.Y-appendixIconRadius,
2022-12-28 20:07:01 +00:00
TooltipIcon,
2022-12-27 07:56:23 +00:00
)
2023-01-13 16:16:18 +00:00
fmt.Fprintf(writer, `<title>%s</title>`, shape.Tooltip)
2022-12-27 07:56:23 +00:00
}
2023-01-13 16:16:18 +00:00
if shape.Link != "" {
2022-12-29 00:19:30 +00:00
fmt.Fprintf(writer, `<g transform="translate(%d %d)" class="appendix-icon">%s</g>`,
2023-01-13 16:16:18 +00:00
shape.Pos.X+shape.Width-appendixIconRadius-rightPadForTooltip,
shape.Pos.Y-appendixIconRadius,
2022-12-29 00:19:30 +00:00
LinkIcon,
)
}
}
2022-12-28 04:29:51 +00:00
func RenderText(text string, x, height float64) string {
if !strings.Contains(text, "\n") {
2022-12-22 19:06:57 +00:00
return svg.EscapeText(text)
}
rendered := []string{}
lines := strings.Split(text, "\n")
for i, line := range lines {
dy := height / float64(len(lines))
if i == 0 {
dy = 0
}
2022-12-22 19:06:57 +00:00
escaped := svg.EscapeText(line)
if escaped == "" {
// if there are multiple newlines in a row we still need text for the tspan to render
escaped = " "
}
rendered = append(rendered, fmt.Sprintf(`<tspan x="%f" dy="%f">%s</tspan>`, x, dy, escaped))
}
return strings.Join(rendered, "")
}
2022-12-21 07:43:45 +00:00
func embedFonts(buf *bytes.Buffer, fontFamily *d2fonts.FontFamily) {
content := buf.String()
buf.WriteString(`<style type="text/css"><![CDATA[`)
triggers := []string{
`class="text"`,
`class="text `,
`class="md"`,
}
for _, t := range triggers {
if strings.Contains(content, t) {
fmt.Fprintf(buf, `
.text {
font-family: "font-regular";
}
@font-face {
font-family: font-regular;
src: url("%s");
}`,
2022-12-21 07:43:45 +00:00
d2fonts.FontEncodings[fontFamily.Font(0, d2fonts.FONT_STYLE_REGULAR)])
break
}
}
triggers = []string{
`text-underline`,
}
for _, t := range triggers {
if strings.Contains(content, t) {
buf.WriteString(`
.text-underline {
text-decoration: underline;
}`)
break
}
}
2023-01-12 18:51:26 +00:00
triggers = []string{
`animated-connection`,
}
for _, t := range triggers {
if strings.Contains(content, t) {
buf.WriteString(`
@keyframes dashdraw {
from {
2023-01-12 19:06:36 +00:00
stroke-dashoffset: 0;
2023-01-12 18:51:26 +00:00
}
}
2023-01-12 19:06:36 +00:00
`)
2023-01-12 18:51:26 +00:00
break
}
}
2022-12-27 07:56:23 +00:00
triggers = []string{
2022-12-29 00:19:30 +00:00
`appendix-icon`,
2022-12-27 07:56:23 +00:00
}
for _, t := range triggers {
if strings.Contains(content, t) {
buf.WriteString(`
2022-12-29 00:19:30 +00:00
.appendix-icon {
2022-12-28 23:15:38 +00:00
filter: drop-shadow(0px 0px 32px rgba(31, 36, 58, 0.1));
2022-12-27 07:56:23 +00:00
}`)
break
}
}
triggers = []string{
`class="text-bold"`,
`<b>`,
`<strong>`,
}
for _, t := range triggers {
if strings.Contains(content, t) {
fmt.Fprintf(buf, `
.text-bold {
font-family: "font-bold";
}
@font-face {
font-family: font-bold;
src: url("%s");
}`,
2022-12-21 07:43:45 +00:00
d2fonts.FontEncodings[fontFamily.Font(0, d2fonts.FONT_STYLE_BOLD)])
break
}
}
triggers = []string{
`class="text-italic"`,
`<em>`,
`<dfn>`,
}
for _, t := range triggers {
if strings.Contains(content, t) {
fmt.Fprintf(buf, `
.text-italic {
font-family: "font-italic";
}
@font-face {
font-family: font-italic;
src: url("%s");
}`,
2022-12-21 07:43:45 +00:00
d2fonts.FontEncodings[fontFamily.Font(0, d2fonts.FONT_STYLE_ITALIC)])
break
}
}
2022-11-07 19:52:01 +00:00
triggers = []string{
`class="text-mono"`,
`<pre>`,
`<code>`,
`<kbd>`,
`<samp>`,
}
for _, t := range triggers {
if strings.Contains(content, t) {
fmt.Fprintf(buf, `
.text-mono {
font-family: "font-mono";
}
@font-face {
font-family: font-mono;
src: url("%s");
}`,
2022-11-07 19:52:01 +00:00
d2fonts.FontEncodings[d2fonts.SourceCodePro.Font(0, d2fonts.FONT_STYLE_REGULAR)])
break
}
}
2023-01-29 22:17:34 +00:00
triggers = []string{
`class="text-mono-bold"`,
}
for _, t := range triggers {
if strings.Contains(content, t) {
fmt.Fprintf(buf, `
.text-mono-bold {
font-family: "font-mono-bold";
}
@font-face {
font-family: font-mono-bold;
src: url("%s");
}`,
d2fonts.FontEncodings[d2fonts.SourceCodePro.Font(0, d2fonts.FONT_STYLE_BOLD)])
break
}
}
triggers = []string{
`class="text-mono-italic"`,
}
for _, t := range triggers {
if strings.Contains(content, t) {
fmt.Fprintf(buf, `
.text-mono-italic {
font-family: "font-mono-italic";
}
@font-face {
font-family: font-mono-italic;
src: url("%s");
}`,
d2fonts.FontEncodings[d2fonts.SourceCodePro.Font(0, d2fonts.FONT_STYLE_ITALIC)])
break
}
}
buf.WriteString(`]]></style>`)
}
2023-01-03 19:27:15 +00:00
//go:embed fitToScreen.js
var fitToScreenScript string
// TODO minify output at end
2022-12-21 07:43:45 +00:00
func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
var sketchRunner *d2sketch.Runner
pad := DEFAULT_PADDING
if opts != nil {
pad = opts.Pad
if opts.Sketch {
var err error
sketchRunner, err = d2sketch.InitSketchVM()
if err != nil {
return nil, err
}
}
}
buf := &bytes.Buffer{}
2022-12-12 07:31:01 +00:00
w, h := setViewbox(buf, diagram, pad)
2022-12-21 07:43:45 +00:00
styleCSS2 := ""
if sketchRunner != nil {
styleCSS2 = "\n" + sketchStyleCSS
}
2022-11-12 18:29:21 +00:00
buf.WriteString(fmt.Sprintf(`<style type="text/css">
<![CDATA[
2022-12-21 07:43:45 +00:00
%s%s
]]>
2022-12-21 07:43:45 +00:00
</style>`, styleCSS, styleCSS2))
2023-01-03 23:32:31 +00:00
// this script won't run in --watch mode because script tags are ignored when added via el.innerHTML = element
// https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML
2023-01-03 19:27:15 +00:00
buf.WriteString(fmt.Sprintf(`<script type="application/javascript"><![CDATA[%s]]></script>`, fitToScreenScript))
hasMarkdown := false
for _, s := range diagram.Shapes {
if s.Label != "" && s.Type == d2target.ShapeText {
hasMarkdown = true
break
}
}
if hasMarkdown {
fmt.Fprintf(buf, `<style type="text/css">%s</style>`, mdCSS)
}
2022-12-21 07:43:45 +00:00
if sketchRunner != nil {
fmt.Fprint(buf, d2sketch.DefineFillPattern())
2022-12-21 07:43:45 +00:00
}
2022-11-09 19:10:51 +00:00
// only define shadow filter if a shape uses it
for _, s := range diagram.Shapes {
if s.Shadow {
defineShadowFilter(buf)
break
}
}
2022-12-06 06:33:35 +00:00
// Mask URLs are global. So when multiple SVGs attach to a DOM, they share
// the same namespace for mask URLs.
2022-12-06 06:32:23 +00:00
labelMaskID, err := diagram.HashID()
if err != nil {
return nil, err
}
// SVG has no notion of z-index. The z-index is effectively the order it's drawn.
// So draw from the least nested to most nested
idToShape := make(map[string]d2target.Shape)
2022-11-30 21:15:33 +00:00
allObjects := make([]DiagramObject, 0, len(diagram.Shapes)+len(diagram.Connections))
for _, s := range diagram.Shapes {
idToShape[s.ID] = s
allObjects = append(allObjects, s)
}
for _, c := range diagram.Connections {
allObjects = append(allObjects, c)
}
2022-11-30 19:22:43 +00:00
sortObjects(allObjects)
2022-12-03 06:47:54 +00:00
var labelMasks []string
markers := map[string]struct{}{}
for _, obj := range allObjects {
if c, is := obj.(d2target.Connection); is {
2022-12-21 07:43:45 +00:00
labelMask, err := drawConnection(buf, labelMaskID, c, markers, idToShape, sketchRunner)
if err != nil {
return nil, err
}
2022-12-06 03:50:42 +00:00
if labelMask != "" {
2022-12-03 06:47:54 +00:00
labelMasks = append(labelMasks, labelMask)
}
} else if s, is := obj.(d2target.Shape); is {
2022-12-21 07:43:45 +00:00
labelMask, err := drawShape(buf, s, sketchRunner)
2022-12-06 03:50:42 +00:00
if err != nil {
return nil, err
2022-12-05 19:57:16 +00:00
} else if labelMask != "" {
labelMasks = append(labelMasks, labelMask)
}
} else {
2022-12-17 01:15:58 +00:00
return nil, fmt.Errorf("unknown object of type %T", obj)
}
}
2022-12-17 01:15:37 +00:00
// Note: we always want this since we reference it on connections even if there end up being no masked labels
fmt.Fprint(buf, strings.Join([]string{
fmt.Sprintf(`<mask id="%s" maskUnits="userSpaceOnUse" x="%d" y="%d" width="%d" height="%d">`,
labelMaskID, -pad, -pad, w, h,
2022-12-17 01:15:37 +00:00
),
fmt.Sprintf(`<rect x="%d" y="%d" width="%d" height="%d" fill="white"></rect>`,
-pad, -pad, w, h,
2022-12-17 01:15:37 +00:00
),
strings.Join(labelMasks, "\n"),
`</mask>`,
}, "\n"))
2022-12-03 06:47:54 +00:00
2022-12-21 07:43:45 +00:00
embedFonts(buf, diagram.FontFamily)
buf.WriteString(`</svg>`)
return buf.Bytes(), nil
}
2022-11-30 21:15:33 +00:00
type DiagramObject interface {
GetID() string
GetZIndex() int
}
2022-11-30 19:22:43 +00:00
// sortObjects sorts all diagrams objects (shapes and connections) in the desired drawing order
// the sorting criteria is:
// 1. zIndex, lower comes first
// 2. two shapes with the same zIndex are sorted by their level (container nesting), containers come first
// 3. two shapes with the same zIndex and same level, are sorted in the order they were exported
// 4. shape and edge, shapes come first
2022-11-30 21:15:33 +00:00
func sortObjects(allObjects []DiagramObject) {
2022-11-30 19:22:43 +00:00
sort.SliceStable(allObjects, func(i, j int) bool {
// first sort by zIndex
iZIndex := allObjects[i].GetZIndex()
jZIndex := allObjects[j].GetZIndex()
if iZIndex != jZIndex {
return iZIndex < jZIndex
}
2022-11-30 21:15:33 +00:00
// then, if both are shapes, parents come before their children
2022-11-30 19:22:43 +00:00
iShape, iIsShape := allObjects[i].(d2target.Shape)
jShape, jIsShape := allObjects[j].(d2target.Shape)
if iIsShape && jIsShape {
return iShape.Level < jShape.Level
}
// then, shapes come before connections
_, jIsConnection := allObjects[j].(d2target.Connection)
return iIsShape && jIsConnection
})
}
func hash(s string) string {
const secret = "lalalas"
h := fnv.New32a()
h.Write([]byte(fmt.Sprintf("%s%s", s, secret)))
return fmt.Sprint(h.Sum32())
}