// d2svg implements an SVG renderer for d2 diagrams. // The input is d2exporter's output
package d2svg import ( _ ) const ( DEFAULT_PADDING = 100 appendixIconRadius = 16 // Legend constants LEGEND_PADDING = 20 LEGEND_ITEM_SPACING = 15 LEGEND_ICON_SIZE = 24 LEGEND_FONT_SIZE = 14 LEGEND_CORNER_PADDING = 10 ) var multipleOffset = geo.NewVector(d2target.MULTIPLE_OFFSET, -d2target.MULTIPLE_OFFSET) //go:embed tooltip.svg var TooltipIcon string //go:embed link.svg var LinkIcon string //go:embed style.css var BaseStylesheet string //go:embed github-markdown.css var MarkdownCSS string //go:embed dots.txt var dots string //go:embed lines.txt var lines string //go:embed grain.txt var grain string type RenderOpts struct { Pad *int64 Sketch *bool Center *bool ThemeID *int64 DarkThemeID *int64 ThemeOverrides *d2target.ThemeOverrides DarkThemeOverrides *d2target.ThemeOverrides Font string // the svg will be scaled by this factor, if unset the svg will fit to screen Scale *float64 // MasterID is passed when the diagram should use something other than its own hash for unique targeting // Currently, that's when multi-boards are collapsed MasterID string NoXMLTag *bool Salt *string OmitVersion *bool } func dimensions( *d2target.Diagram, int) (, , , int) { , := .BoundingBox() = .X - = .Y - = .X - .X + *2 = .Y - .Y + *2 // Account for legend dimensions if present if .Legend != nil && (len(.Legend.Shapes) > 0 || len(.Legend.Connections) > 0) { := LEGEND_PADDING + LEGEND_FONT_SIZE + LEGEND_ITEM_SPACING := 0 := 0 , := textmeasure.NewRuler() if == nil && != nil { for , := range .Legend.Shapes { if .Label == "" { continue } := &d2target.MText{ Text: .Label, FontSize: LEGEND_FONT_SIZE, } := d2graph.GetTextDimensions(nil, , , nil) = go2.IntMax(, .Width) += go2.IntMax(.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING ++ } for , := range .Legend.Connections { if .Label == "" { continue } := &d2target.MText{ Text: .Label, FontSize: LEGEND_FONT_SIZE, } := d2graph.GetTextDimensions(nil, , , nil) = go2.IntMax(, .Width) += go2.IntMax(.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING ++ } if > 0 { -= LEGEND_ITEM_SPACING / 2 } if > 0 && len(.Legend.Connections) > 0 { += LEGEND_PADDING * 1.5 } else { += LEGEND_PADDING * 1.2 } if > 0 && > 0 { := LEGEND_PADDING*2 + LEGEND_ICON_SIZE + LEGEND_PADDING + := .Y - if < .Y { = .Y } := .X + LEGEND_CORNER_PADDING + if + < { = - + /2 } if < { := - -= += } := + if + < { = - + /2 } } } } return , , , } func ( *bytes.Buffer, *d2target.Diagram, string, *d2themes.Theme) error { if .Legend == nil || (len(.Legend.Shapes) == 0 && len(.Legend.Connections) == 0) { return nil } , := .BoundingBox() , := textmeasure.NewRuler() if != nil { return } := LEGEND_PADDING + LEGEND_FONT_SIZE + LEGEND_ITEM_SPACING := 0 := 0 for , := range .Legend.Shapes { if .Label == "" { continue } := &d2target.MText{ Text: .Label, FontSize: LEGEND_FONT_SIZE, } := d2graph.GetTextDimensions(nil, , , nil) = go2.IntMax(, .Width) += go2.IntMax(.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING ++ } for , := range .Legend.Connections { if .Label == "" { continue } := &d2target.MText{ Text: .Label, FontSize: LEGEND_FONT_SIZE, } := d2graph.GetTextDimensions(nil, , , nil) = go2.IntMax(, .Width) += go2.IntMax(.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING ++ } if > 0 { -= LEGEND_ITEM_SPACING / 2 } if > 0 && len(.Legend.Connections) > 0 { += LEGEND_PADDING * 1.5 } else { += LEGEND_PADDING * 1.2 } := LEGEND_PADDING*2 + LEGEND_ICON_SIZE + LEGEND_PADDING + := .X + LEGEND_CORNER_PADDING , := .BoundingBox() := .Y - if < .Y { = .Y } := d2themes.NewThemableElement("rect", ) .Fill = "#F7F7FA" .Stroke = "#DEE1EB" .Style = "stroke-width: 1px; filter: drop-shadow(0px 2px 3px rgba(0, 0, 0, 0.1))" .X = float64() .Y = float64() .Width = float64() .Height = float64() .Rx = 4 fmt.Fprint(, .Render()) := d2themes.NewThemableElement("rect", ) .Fill = "#ffffff" .Stroke = "#DEE1EB" .Style = "stroke-width: 1px" .X = float64() .Y = float64() .Width = float64() .Height = float64() .Rx = 4 fmt.Fprint(, .Render()) := "Legend" if .Legend.Label != "" { = .Legend.Label } fmt.Fprintf(, `<text class="text-bold" x="%d" y="%d" style="font-size: %dpx;">%s</text>`, +LEGEND_PADDING, +LEGEND_PADDING+LEGEND_FONT_SIZE, LEGEND_FONT_SIZE+2, html.EscapeString()) := + LEGEND_PADDING*2 + LEGEND_FONT_SIZE := 0 for , := range .Legend.Shapes { if .Label == "" { continue } := + LEGEND_PADDING := , := renderLegendShapeIcon(, , , , ) if != nil { return } fmt.Fprint(, ) := &d2target.MText{ Text: .Label, FontSize: LEGEND_FONT_SIZE, } := d2graph.GetTextDimensions(nil, , , nil) := go2.IntMax(.Height, LEGEND_ICON_SIZE) := + /2 + int(float64(.Height)*0.3) fmt.Fprintf(, `<text class="text" x="%d" y="%d" style="font-size: %dpx;">%s</text>`, +LEGEND_ICON_SIZE+LEGEND_PADDING, , LEGEND_FONT_SIZE, html.EscapeString(.Label)) += + LEGEND_ITEM_SPACING ++ } if > 0 && len(.Legend.Connections) > 0 { += LEGEND_ITEM_SPACING / 2 := d2themes.NewThemableElement("line", ) .X1 = float64( + LEGEND_PADDING) .Y1 = float64() .X2 = float64( + - LEGEND_PADDING) .Y2 = float64() .Stroke = "#DEE1EB" .StrokeDashArray = "2,2" fmt.Fprint(, .Render()) += LEGEND_ITEM_SPACING } for , := range .Legend.Connections { if .Label == "" { continue } := + LEGEND_PADDING := + LEGEND_ICON_SIZE/2 , := renderLegendConnectionIcon(, , , ) if != nil { return } fmt.Fprint(, ) := &d2target.MText{ Text: .Label, FontSize: LEGEND_FONT_SIZE, } := d2graph.GetTextDimensions(nil, , , nil) := go2.IntMax(.Height, LEGEND_ICON_SIZE) := + /2 + int(float64(.Height)*0.2) fmt.Fprintf(, `<text class="text" x="%d" y="%d" style="font-size: %dpx;">%s</text>`, +LEGEND_ICON_SIZE+LEGEND_PADDING, , LEGEND_FONT_SIZE, html.EscapeString(.Label)) += + LEGEND_ITEM_SPACING } if > 0 && len(.Legend.Connections) > 0 { += LEGEND_PADDING / 2 } else { += LEGEND_PADDING / 4 } return nil } func renderLegendShapeIcon( d2target.Shape, , int, string, *d2themes.Theme) (string, error) { := const = 5 .Pos.X = 0 .Pos.Y = 0 .Width = LEGEND_ICON_SIZE * .Height = LEGEND_ICON_SIZE * .Label = "" := &bytes.Buffer{} := &bytes.Buffer{} := &bytes.Buffer{} fmt.Fprintf(, `<g transform="translate(%d, %d) scale(%f)">`, , , 1.0/) , := drawShape(, , , , nil, ) if != nil { return "", } fmt.Fprint(, .String()) fmt.Fprint(, `</g>`) return .String(), nil } func renderLegendConnectionIcon( d2target.Connection, , int, *d2themes.Theme) (string, error) { := &bytes.Buffer{} := &bytes.Buffer{} const = 2 := *d2target.BaseConnection() .ID = .ID .SrcArrow = .SrcArrow .DstArrow = .DstArrow .StrokeDash = .StrokeDash .StrokeWidth = .StrokeWidth .Stroke = .Stroke .Fill = .Fill .BorderRadius = .BorderRadius .Opacity = .Opacity .Animated = .Animated := 0.0 := 0.0 := float64(LEGEND_ICON_SIZE * ) .Route = []*geo.Point{ {X: , Y: }, {X: + , Y: }, } := fmt.Sprintf("legend-%s", hash(fmt.Sprintf("%s-%d-%d", .ID, , ))) := make(map[string]struct{}) := make(map[string]d2target.Shape) fmt.Fprintf(, `<g transform="translate(%d, %d) scale(%f)">`, , , 1.0/) , := drawConnection(, , , , , nil, ) if != nil { return "", } fmt.Fprint(, .String()) fmt.Fprint(, `</g>`) return .String(), nil } func arrowheadMarkerID( string, bool, d2target.Connection) string { var d2target.Arrowhead if { = .DstArrow } else { = .SrcArrow } return fmt.Sprintf("mk-%s-%s", , hash(fmt.Sprintf("%s,%t,%d,%s", , , .StrokeWidth, .Stroke, ))) } func arrowheadMarker( bool, string, d2target.Connection, *d2themes.Theme) string { := .DstArrow if ! { = .SrcArrow } := float64(.StrokeWidth) , := .Dimensions() var string switch { case d2target.ArrowArrowhead: := d2themes.NewThemableElement("polygon", ) .Fill = .Stroke .ClassName = "connection" .Attributes = fmt.Sprintf(`stroke-width="%d"`, .StrokeWidth) if { .Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f", 0., 0., , /2, 0., , /4, /2, ) } else { .Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f", 0., /2, , 0., *3/4, /2, , , ) } = .Render() case d2target.UnfilledTriangleArrowhead: := d2themes.NewThemableElement("polygon", ) .Fill = d2target.BG_COLOR .Stroke = .Stroke .ClassName = "connection" .Attributes = fmt.Sprintf(`stroke-width="%d"`, .StrokeWidth) := / 2 if { .Points = fmt.Sprintf("%f,%f %f,%f %f,%f", , , -, /2.0, , -, ) } else { .Points = fmt.Sprintf("%f,%f %f,%f %f,%f", -, , , /2.0, -, -, ) } = .Render() case d2target.TriangleArrowhead: := d2themes.NewThemableElement("polygon", ) .Fill = .Stroke .ClassName = "connection" .Attributes = fmt.Sprintf(`stroke-width="%d"`, .StrokeWidth) if { .Points = fmt.Sprintf("%f,%f %f,%f %f,%f", 0., 0., , /2.0, 0., , ) } else { .Points = fmt.Sprintf("%f,%f %f,%f %f,%f", , 0., 0., /2.0, , , ) } = .Render() case d2target.LineArrowhead: := d2themes.NewThemableElement("polyline", ) .Fill = color.None .ClassName = "connection" .Stroke = .Stroke .Attributes = fmt.Sprintf(`stroke-width="%d"`, .StrokeWidth) if { .Points = fmt.Sprintf("%f,%f %f,%f %f,%f", /2, /2, -/2, /2, /2, -/2, ) } else { .Points = fmt.Sprintf("%f,%f %f,%f %f,%f", -/2, /2, /2, /2, -/2, -/2, ) } = .Render() case d2target.FilledDiamondArrowhead: := d2themes.NewThemableElement("polygon", ) .ClassName = "connection" .Fill = .Stroke .Attributes = fmt.Sprintf(`stroke-width="%d"`, .StrokeWidth) if { .Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f", 0., /2.0, /2.0, 0., , /2.0, /2.0, , ) } else { .Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f", 0., /2.0, /2.0, 0., , /2.0, /2.0, , ) } = .Render() case d2target.DiamondArrowhead: := d2themes.NewThemableElement("polygon", ) .ClassName = "connection" .Fill = d2target.BG_COLOR .Stroke = .Stroke .Attributes = fmt.Sprintf(`stroke-width="%d"`, .StrokeWidth) if { .Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f", 0., /2.0, /2, /8, , /2.0, /2.0, *0.9, ) } else { .Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f", /8, /2.0, *0.6, /8, *1.1, /2.0, *0.6, *7/8, ) } = .Render() case d2target.FilledCircleArrowhead: := / 2 := d2themes.NewThemableElement("circle", ) .Cy = .R = - /2 .Fill = .Stroke .ClassName = "connection" .Attributes = fmt.Sprintf(`stroke-width="%d"`, .StrokeWidth) if { .Cx = + /2 } else { .Cx = - /2 } = .Render() case d2target.CircleArrowhead: := / 2 := d2themes.NewThemableElement("circle", ) .Cy = .R = - .Fill = d2target.BG_COLOR .Stroke = .Stroke .Attributes = fmt.Sprintf(`stroke-width="%d"`, .StrokeWidth) if { .Cx = + /2 } else { .Cx = - /2 } = .Render() case d2target.CrossArrowhead: := / 8 := math.Pi / 4 := geo.NewPoint(/2, /2) := geo.NewPoint(math.Cos()*.X-math.Sin()*.Y, math.Sin()*.X+math.Cos()*.Y) := d2themes.NewThemableElement("polygon", ) .Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f, %f,%f %f,%f %f,%f %f,%f %f,%f %f,%f %f,%f %f,%f", 0., /2+, /2-, /2+, /2-, , /2+, , /2+, /2+, , /2+, , /2-, /2+, /2-, /2+, 0., /2-, 0., /2-, /2-, 0., /2-, ) .Transform = fmt.Sprintf("translate(%f, %f) rotate(45)", -.X+/2, -.Y+/2) := d2themes.NewThemableElement("path", ) if { .D = fmt.Sprintf("M%f,%f %f,%f", /2, /2, , /2, ) } else { .D = fmt.Sprintf("M%f,%f %f,%f", /2, /2, 0., /2, ) } := d2themes.NewThemableElement("g", ) .Fill = d2target.BG_COLOR .Stroke = .Stroke .ClassName = "connection" .Attributes = fmt.Sprintf(`stroke-width="%d"`, .StrokeWidth) .Content = fmt.Sprintf("%s%s", .Render(), .Render(), ) = .Render() case d2target.FilledBoxArrowhead: := d2themes.NewThemableElement("polygon", ) .ClassName = "connection" .Fill = .Stroke .Attributes = fmt.Sprintf(`stroke-width="%d"`, .StrokeWidth) if { .Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f", 0., 0., 0., , , , , 0., ) } else { .Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f", 0., 0., 0., , , , , 0., ) } = .Render() case d2target.BoxArrowhead: := d2themes.NewThemableElement("polygon", ) .ClassName = "connection" .Fill = d2target.BG_COLOR .Stroke = .Stroke .Attributes = fmt.Sprintf(`stroke-width="%d"`, .StrokeWidth) .Style = fmt.Sprintf("%sstroke-linejoin:miter;", .Style) := / 2 if { .Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f", , , , -, -, -, -, , ) } else { .Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f", , , , -, -, -, -, , ) } = .Render() case d2target.CfOne, d2target.CfMany, d2target.CfOneRequired, d2target.CfManyRequired: := 3.0 + float64(.StrokeWidth)*1.8 var *d2themes.ThemableElement if == d2target.CfOneRequired || == d2target.CfManyRequired { = d2themes.NewThemableElement("path", ) .D = fmt.Sprintf("M%f,%f %f,%f", , 0., , , ) .Fill = d2target.BG_COLOR .Stroke = .Stroke .ClassName = "connection" .Attributes = fmt.Sprintf(`stroke-width="%d"`, .StrokeWidth) } else { = d2themes.NewThemableElement("circle", ) .Cx = /2.0 + 2.0 .Cy = / 2.0 .R = / 2.0 .Fill = d2target.BG_COLOR .Stroke = .Stroke .ClassName = "connection" .Attributes = fmt.Sprintf(`stroke-width="%d"`, .StrokeWidth) } := d2themes.NewThemableElement("path", ) if == d2target.CfMany || == d2target.CfManyRequired { .D = fmt.Sprintf("M%f,%f %f,%f M%f,%f %f,%f M%f,%f %f,%f", -3.0, /2.0, +, /2.0, +3.0, /2.0, +, 0., +3.0, /2.0, +, , ) } else { .D = fmt.Sprintf("M%f,%f %f,%f M%f,%f %f,%f", -3.0, /2.0, +, /2.0, *2.0, 0., *2.0, , ) } := d2themes.NewThemableElement("g", ) if ! { .Transform = fmt.Sprintf("scale(-1) translate(-%f, -%f)", , ) } .Fill = d2target.BG_COLOR .Stroke = .Stroke .ClassName = "connection" .Attributes = fmt.Sprintf(`stroke-width="%d"`, .StrokeWidth) .Content = fmt.Sprintf("%s%s", .Render(), .Render(), ) = .Render() default: return "" } var float64 := / 2 switch { case d2target.DiamondArrowhead: if { = - 0.6* } else { = /8 + 0.6* } *= 1.1 default: if { = - 1.5* } else { = 1.5 * } } return strings.Join([]string{ fmt.Sprintf(`<marker id="%s" markerWidth="%f" markerHeight="%f" refX="%f" refY="%f"`, , , , , , ), fmt.Sprintf(`viewBox="%f %f %f %f"`, 0., 0., , ), `orient="auto" markerUnits="userSpaceOnUse">`, , "</marker>", }, " ") } // compute the (dx, dy) adjustment to apply to get the arrowhead-adjusted end point func arrowheadAdjustment(, *geo.Point, d2target.Arrowhead, , int) *geo.Point { := (float64() + float64()) / 2.0 if != d2target.NoArrowhead { += float64() } := geo.NewVector(.X-.X, .Y-.Y) return .Unit().Multiply(-).ToPoint() } func getArrowheadAdjustments( d2target.Connection, map[string]d2target.Shape) (, *geo.Point) { := .Route := [.Src] := [.Dst] := arrowheadAdjustment([1], [0], .SrcArrow, .StrokeWidth, .StrokeWidth) := arrowheadAdjustment([len()-2], [len()-1], .DstArrow, .StrokeWidth, .StrokeWidth) return , } // returns the path's d attribute for the given connection func pathData( d2target.Connection, , *geo.Point) string { var []string := .Route = append(, fmt.Sprintf("M %f %f", [0].X+.X, [0].Y+.Y, )) if .IsCurve { := 1 for ; < len()-3; += 3 { = append(, fmt.Sprintf("C %f %f %f %f %f %f", [].X, [].Y, [+1].X, [+1].Y, [+2].X, [+2].Y, )) } // final curve target adjustment = append(, fmt.Sprintf("C %f %f %f %f %f %f", [].X, [].Y, [+1].X, [+1].Y, [+2].X+.X, [+2].Y+.Y, )) } else { for := 1; < len()-1; ++ { := [-1] := [] := [+1] := .VectorTo() := .VectorTo() := geo.EuclideanDistance(.X, .Y, .X, .Y) := .BorderRadius := math.Min(, /2) := .Unit().Multiply().ToPoint() := .Unit().Multiply().ToPoint() = append(, fmt.Sprintf("L %f %f", .X-.X, .Y-.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 < && < len()-2 { := [+2] := geo.NewVector(.X-.X, .Y-.Y) ++ := .Unit().Multiply().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 = append(, fmt.Sprintf("C %f %f %f %f %f %f", // Control point .X+.X, .Y+.Y, // Control point .X-.X, .Y-.Y, // Where curve ends .X+.X, .Y+.Y, )) } else { = append(, fmt.Sprintf("S %f %f %f %f", .X, .Y, .X+.X, .Y+.Y, )) } } := [len()-1] = append(, fmt.Sprintf("L %f %f", .X+.X, .Y+.Y, )) } return strings.Join(, " ") } func makeLabelMask( *geo.Point, , int, float64) string { return makeLabelMaskWithTransform(, , , , nil) } func makeLabelMaskWithTransform( *geo.Point, , int, float64, *geo.Point) string { := "black" if != 1 { = fmt.Sprintf("rgba(0,0,0,%.2f)", ) } := .X - 2 := .Y // In sketch mode, shapes use transform="translate(shapePos.X, shapePos.Y)" // so we need to adjust mask coordinates to account for this transform if != nil { -= .X -= .Y } return fmt.Sprintf(`<rect x="%f" y="%f" width="%d" height="%d" fill="%s"></rect>`, , , +4, , , ) } // border label mask is minimal, intending to only cover the portion of the border that passes through the label // otherwise, when the inside background and outside background colors don't match, there's a bigger discrepancy func makeBorderLabelMask( label.Position, *geo.Point, , int, *geo.Box, int, float64, *geo.Point) string { := "black" if != 1 { = fmt.Sprintf("rgba(0,0,0,%.2f)", ) } // In sketch mode, multiply stroke width by 3 for mask calculations to account for // the hand-drawn style that can be wider and more irregular := float64() if != nil { *= 3 } var , , , float64 switch { case label.BorderTopLeft, label.BorderTopCenter, label.BorderTopRight: = .X - 2 = .TopLeft.Y - /2 = float64( + 4) = case label.BorderBottomLeft, label.BorderBottomCenter, label.BorderBottomRight: = .X - 2 = .TopLeft.Y + .Height - /2 = float64( + 4) = case label.BorderLeftTop, label.BorderLeftMiddle, label.BorderLeftBottom: = .TopLeft.X - /2 = .Y - 2 = = float64( + 4) case label.BorderRightTop, label.BorderRightMiddle, label.BorderRightBottom: = .TopLeft.X + .Width - /2 = .Y - 2 = = float64( + 4) default: // never gets here return "" } // In sketch mode, adjust coordinates to account for shape transform if != nil { -= .X -= .Y } return fmt.Sprintf(`<rect x="%f" y="%f" width="%f" height="%f" fill="%s"></rect>`, , , , , , ) } func drawConnection( io.Writer, string, d2target.Connection, map[string]struct{}, map[string]d2target.Shape, jsrunner.JSRunner, *d2themes.Theme) ( string, error) { := "" if .Opacity != 1.0 { = fmt.Sprintf(" style='opacity:%f'", .Opacity) } := []string{base64.URLEncoding.EncodeToString([]byte(svg.EscapeText(.ID)))} = append(, .Classes...) := fmt.Sprintf(` class="%s"`, strings.Join(, " ")) fmt.Fprintf(, `<g%s%s>`, , ) var string if .SrcArrow != d2target.NoArrowhead { := arrowheadMarkerID(, false, ) if , := []; ! { := arrowheadMarker(false, , , ) if == "" { panic(fmt.Sprintf("received empty arrow head marker for: %#v", )) } fmt.Fprint(, ) [] = struct{}{} } = fmt.Sprintf(`marker-start="url(#%s)" `, ) } var string if .DstArrow != d2target.NoArrowhead { := arrowheadMarkerID(, true, ) if , := []; ! { := arrowheadMarker(true, , , ) if == "" { panic(fmt.Sprintf("received empty arrow head marker for: %#v", )) } fmt.Fprint(, ) [] = struct{}{} } = fmt.Sprintf(`marker-end="url(#%s)" `, ) } if .Icon != nil { := .GetIconPosition() if != nil { := "" if .IconBorderRadius != 0 { = fmt.Sprintf(` clip-path="inset(0 round %fpx)"`, .IconBorderRadius) } fmt.Fprintf(, `<image href="%s" x="%f" y="%f" width="%d" height="%d"%s />`, html.EscapeString(.Icon.String()), .X, .Y, d2target.DEFAULT_ICON_SIZE, d2target.DEFAULT_ICON_SIZE, , ) } } var *geo.Point if .Label != "" { = .GetLabelTopLeft() .X = math.Round(.X) .Y = math.Round(.Y) := .Copy() := .LabelWidth := .LabelHeight if .Icon != nil { += d2target.CONNECTION_ICON_LABEL_GAP + d2target.DEFAULT_ICON_SIZE .X -= float64(d2target.CONNECTION_ICON_LABEL_GAP + d2target.DEFAULT_ICON_SIZE) } if label.FromString(.LabelPosition).IsOnEdge() { = makeLabelMask(, , , 1) } else { = makeLabelMask(, , , 0.75) } } else if .Icon != nil { := .GetIconPosition() if != nil { := &geo.Point{ X: .X, Y: .Y, } if label.FromString(.IconPosition).IsOnEdge() { = makeLabelMask(, d2target.DEFAULT_ICON_SIZE, d2target.DEFAULT_ICON_SIZE, 1) } else { = makeLabelMask(, d2target.DEFAULT_ICON_SIZE, d2target.DEFAULT_ICON_SIZE, 0.75) } } } , := getArrowheadAdjustments(, ) := pathData(, , ) := fmt.Sprintf(`mask="url(#%s)"`, ) if != nil { , := d2sketch.Connection(, , , ) if != nil { return "", } fmt.Fprint(, ) // render sketch arrowheads separately , := d2sketch.Arrowheads(, , , ) if != nil { return "", } fmt.Fprint(, ) } else { := "" if .Animated { = " animated-connection" } // If connection is animated and bidirectional if .Animated && ((.DstArrow == d2target.NoArrowhead && .SrcArrow == d2target.NoArrowhead) || (.DstArrow != d2target.NoArrowhead && .SrcArrow != d2target.NoArrowhead)) { // There is no pure CSS way to animate bidirectional connections in two directions, so we split it up , , := svg.SplitPath(, 0.5) if != nil { return "", } := d2themes.NewThemableElement("path", ) .D = .Fill = color.None .Stroke = .Stroke .ClassName = fmt.Sprintf("connection%s", ) .Style = .CSSStyle() .Style += "animation-direction: reverse;" .Attributes = fmt.Sprintf("%s%s", , ) fmt.Fprint(, .Render()) := d2themes.NewThemableElement("path", ) .D = .Fill = color.None .Stroke = .Stroke .ClassName = fmt.Sprintf("connection%s", ) .Style = .CSSStyle() .Attributes = fmt.Sprintf("%s%s", , ) fmt.Fprint(, .Render()) } else { := d2themes.NewThemableElement("path", ) .D = .Fill = color.None .Stroke = .Stroke .ClassName = fmt.Sprintf("connection%s", ) .Style = .CSSStyle() .Attributes = fmt.Sprintf("%s%s%s", , , ) fmt.Fprint(, .Render()) } } if .Label != "" { if .Language == "latex" { , := d2latex.Render(.Label) if != nil { return , } // Remove XML declaration and DOCTYPE from LaTeX SVG output := `<?xml version="1.0" encoding="UTF-8"?>` := `<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">` = strings.ReplaceAll(, , "") = strings.ReplaceAll(, , "") := d2themes.NewThemableElement("g", ) .SetTranslate(.X, .Y) .Color = .Color .Content = fmt.Fprint(, .Render()) } else if .Language == "markdown" { , := textmeasure.RenderMarkdown(.Label) if != nil { return , } fmt.Fprintf(, `<g><foreignObject requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" x="%f" y="%f" width="%d" height="%d">`, .X, .Y, .LabelWidth, .LabelHeight, ) = strings.ReplaceAll(, "<hr>", "<hr />") := d2themes.NewThemableElement("div", ) .ClassName = "md" .Content = var []string if .FontSize != textmeasure.MarkdownFontSize { = append(, fmt.Sprintf("font-size:%vpx", .FontSize)) } if .Fill != "" && .Fill != "transparent" { = append(, fmt.Sprintf(`background-color:%s`, .Fill)) } if !color.IsThemeColor(.Color) { = append(, fmt.Sprintf(`color:%s`, .Color)) } .Style = strings.Join(, ";") fmt.Fprint(, .Render()) fmt.Fprint(, `</foreignObject></g>`) } else if .Language != "" { := lexers.Get(.Language) if == nil { = lexers.Fallback } for , := range []bool{true, false} { := "github" if ! { = "catppuccin-mocha" } := styles.Get() if == nil { return , errors.New(`code snippet style "github" not found`) } , := .Tokenise(nil, .Label) if != nil { return , } := styleToSVG() := "light-code" if ! { = "dark-code" } var string if .FontSize != d2fonts.FONT_SIZE_M { = fmt.Sprintf(` style="font-size:%v"`, .FontSize) } fmt.Fprintf(, `<g transform="translate(%f %f)" class="%s"%s>`, .X, .Y, , , ) := textmeasure.CODE_LINE_HEIGHT for , := range chroma.SplitTokensIntoLines(.Tokens()) { fmt.Fprintf(, "<text class=\"text-mono\" x=\"0\" y=\"%fem\">", 1+float64()*) for , := range { := svgEscaper.Replace(.String()) := styleAttr(, .Type) if != "" { = fmt.Sprintf("<tspan %s>%s</tspan>", , ) } fmt.Fprint(, ) } fmt.Fprint(, "</text>") } fmt.Fprint(, "</g>") } } else { := "text" if .FontFamily == "mono" { = "text-mono" } if .Bold { += "-bold" } else if .Italic { += "-italic" } if .Underline { += " text-underline" } if .Fill != color.Empty { := d2themes.NewThemableElement("rect", ) .Rx = 10 .X, .Y = .X-4, .Y-3 .Width, .Height = float64(.LabelWidth)+8, float64(.LabelHeight)+6 .Fill = .Fill fmt.Fprint(, .Render()) } := d2themes.NewThemableElement("text", ) .X = .X + float64(.LabelWidth)/2 .Y = .Y + float64(.FontSize) .ClassName = .Style = fmt.Sprintf("text-anchor:%s;font-size:%vpx", "middle", .FontSize) .Content = RenderText(.Label, .X, float64(.LabelHeight)) if .Link != "" { .ClassName += " text-underline text-link" fmt.Fprintf(, `<a href="%s" xlink:href="%[1]s">`, svg.EscapeText(.Link)) } else { .Fill = .GetFontColor() } fmt.Fprint(, .Render()) if .Link != "" { fmt.Fprintf(, "</a>") } } } if .SrcLabel != nil && .SrcLabel.Label != "" { fmt.Fprint(, renderArrowheadLabel(, .SrcLabel.Label, false, )) } if .DstLabel != nil && .DstLabel.Label != "" { fmt.Fprint(, renderArrowheadLabel(, .DstLabel.Label, true, )) } fmt.Fprintf(, `</g>`) return } func renderArrowheadLabel( d2target.Connection, string, bool, *d2themes.Theme) string { var , float64 if { = float64(.DstLabel.LabelWidth) = float64(.DstLabel.LabelHeight) } else { = float64(.SrcLabel.LabelWidth) = float64(.SrcLabel.LabelHeight) } := .GetArrowheadLabelPosition() // svg text is positioned with the center of its baseline := geo.Point{ X: .X + /2., Y: .Y + float64(.FontSize), } := d2themes.NewThemableElement("text", ) .X = .X .Y = .Y .Fill = d2target.FG_COLOR if { if .DstLabel.Color != "" { .Fill = .DstLabel.Color } } else { if .SrcLabel.Color != "" { .Fill = .SrcLabel.Color } } .ClassName = "text-italic" .Style = fmt.Sprintf("text-anchor:middle;font-size:%vpx", .FontSize) .Content = RenderText(, .X, ) return .Render() } func renderOval( *geo.Point, , float64, , , , string, *d2themes.Theme) string { := d2themes.NewThemableElement("ellipse", ) .Rx = / 2 .Ry = / 2 .Cx = .X + .Rx .Cy = .Y + .Ry .Fill, .Stroke = , .FillPattern = .ClassName = "shape" .Style = return .Render() } func renderDoubleOval( *geo.Point, , float64, , , , string, *d2themes.Theme) string { var *geo.Point = .AddVector(geo.NewVector(d2target.INNER_BORDER_OFFSET, d2target.INNER_BORDER_OFFSET)) return renderOval(, , , , , , , ) + renderOval(, -10, -10, , "", , , ) } func defineGradients( io.Writer, string) { , := color.ParseGradient() fmt.Fprint(, fmt.Sprintf(`<defs>%s</defs>`, color.GradientToSVG())) } func defineShadowFilter( io.Writer) { fmt.Fprint(, `<defs> <filter id="shadow-filter" width="200%" height="200%" x="-50%" y="-50%"> <feGaussianBlur stdDeviation="1.7 " in="SourceGraphic"></feGaussianBlur> <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> </filter> </defs>`) } func render3DRect( string, d2target.Shape, *d2themes.Theme) string { := func( d2target.Point) string { return fmt.Sprintf("M%d,%d", .X+.Pos.X, .Y+.Pos.Y) } := func( d2target.Point) string { return fmt.Sprintf("L%d,%d", .X+.Pos.X, .Y+.Pos.Y) } // draw border all in one path to prevent overlapping sections var []string = append(, (d2target.Point{X: 0, Y: 0}), ) for , := range []d2target.Point{ {X: d2target.THREE_DEE_OFFSET, Y: -d2target.THREE_DEE_OFFSET}, {X: .Width + d2target.THREE_DEE_OFFSET, Y: -d2target.THREE_DEE_OFFSET}, {X: .Width + d2target.THREE_DEE_OFFSET, Y: .Height - d2target.THREE_DEE_OFFSET}, {X: .Width, Y: .Height}, {X: 0, Y: .Height}, {X: 0, Y: 0}, {X: .Width, Y: 0}, {X: .Width, Y: .Height}, } { = append(, ()) } // move to top right to draw last segment without overlapping = append(, (d2target.Point{X: .Width, Y: 0}), ) = append(, (d2target.Point{X: .Width + d2target.THREE_DEE_OFFSET, Y: -d2target.THREE_DEE_OFFSET}), ) := d2themes.NewThemableElement("path", ) .D = strings.Join(, " ") .Fill = color.None , := d2themes.ShapeTheme() .Stroke = := .CSSStyle() .Style = := .Render() // create mask from border stroke, to cut away from the shape fills := fmt.Sprintf("border-mask-%v-%v", , svg.EscapeText(.ID)) := strings.Join([]string{ fmt.Sprintf(`<defs><mask id="%s" maskUnits="userSpaceOnUse" x="%d" y="%d" width="%d" height="%d">`, , .Pos.X, .Pos.Y-d2target.THREE_DEE_OFFSET, .Width+d2target.THREE_DEE_OFFSET, .Height+d2target.THREE_DEE_OFFSET, ), fmt.Sprintf(`<rect x="%d" y="%d" width="%d" height="%d" fill="white"></rect>`, .Pos.X, .Pos.Y-d2target.THREE_DEE_OFFSET, .Width+d2target.THREE_DEE_OFFSET, .Height+d2target.THREE_DEE_OFFSET, ), fmt.Sprintf(`<path d="%s" style="%s;stroke:#000;fill:none;opacity:1;"/></mask></defs>`, strings.Join(, ""), ), }, "\n") // render the main rectangle without stroke and the border mask := d2themes.NewThemableElement("rect", ) .X = float64(.Pos.X) .Y = float64(.Pos.Y) .Width = float64(.Width) .Height = float64(.Height) .SetMaskUrl() , := d2themes.ShapeTheme() .Fill = .FillPattern = .FillPattern .Stroke = color.None .Style = .CSSStyle() := .Render() // render the side shapes in the darkened color without stroke and the border mask var []string for , := range []d2target.Point{ {X: 0, Y: 0}, {X: d2target.THREE_DEE_OFFSET, Y: -d2target.THREE_DEE_OFFSET}, {X: .Width + d2target.THREE_DEE_OFFSET, Y: -d2target.THREE_DEE_OFFSET}, {X: .Width + d2target.THREE_DEE_OFFSET, Y: .Height - d2target.THREE_DEE_OFFSET}, {X: .Width, Y: .Height}, {X: .Width, Y: 0}, } { = append(, fmt.Sprintf("%d,%d", .X+.Pos.X, .Y+.Pos.Y), ) } , := color.Darken(.Fill) if != nil { = .Fill } := d2themes.NewThemableElement("polygon", ) .Fill = .Points = strings.Join(, " ") .SetMaskUrl() .Style = .CSSStyle() := .Render() return + + + } func render3DHexagon( string, d2target.Shape, *d2themes.Theme) string { := func( d2target.Point) string { return fmt.Sprintf("M%d,%d", .X+.Pos.X, .Y+.Pos.Y) } := func( d2target.Point) string { return fmt.Sprintf("L%d,%d", .X+.Pos.X, .Y+.Pos.Y) } := func( int, float64) int { return int(float64() * ) } := 43.6 / 87.3 // draw border all in one path to prevent overlapping sections var []string // start from the top-left = append(, (d2target.Point{X: (.Width, 0.25), Y: 0}), ) := d2target.THREE_DEE_OFFSET / 2 // The following iterates through the sidepoints in clockwise order from top-left, then the main points in clockwise order from bottom-right for , := range []d2target.Point{ {X: (.Width, 0.25) + d2target.THREE_DEE_OFFSET, Y: -}, {X: (.Width, 0.75) + d2target.THREE_DEE_OFFSET, Y: -}, {X: .Width + d2target.THREE_DEE_OFFSET, Y: (.Height, ) - }, {X: (.Width, 0.75) + d2target.THREE_DEE_OFFSET, Y: .Height - }, {X: (.Width, 0.75), Y: .Height}, {X: (.Width, 0.25), Y: .Height}, {X: 0, Y: (.Height, )}, {X: (.Width, 0.25), Y: 0}, {X: (.Width, 0.75), Y: 0}, {X: .Width, Y: (.Height, )}, {X: (.Width, 0.75), Y: .Height}, } { = append(, ()) } for , := range []d2target.Point{ {X: (.Width, 0.75), Y: 0}, {X: .Width, Y: (.Height, )}, {X: (.Width, 0.75), Y: .Height}, } { = append(, ()) = append(, ( d2target.Point{X: .X + d2target.THREE_DEE_OFFSET, Y: .Y - }, )) } := d2themes.NewThemableElement("path", ) .D = strings.Join(, " ") .Fill = color.None , := d2themes.ShapeTheme() .Stroke = := .CSSStyle() .Style = := .Render() var []string for , := range []d2target.Point{ {X: (.Width, 0.25), Y: 0}, {X: (.Width, 0.75), Y: 0}, {X: .Width, Y: (.Height, )}, {X: (.Width, 0.75), Y: .Height}, {X: (.Width, 0.25), Y: .Height}, {X: 0, Y: (.Height, )}, } { = append(, fmt.Sprintf("%d,%d", .X+.Pos.X, .Y+.Pos.Y), ) } := strings.Join(, " ") // create mask from border stroke, to cut away from the shape fills := fmt.Sprintf("border-mask-%v-%v", , svg.EscapeText(.ID)) := strings.Join([]string{ fmt.Sprintf(`<defs><mask id="%s" maskUnits="userSpaceOnUse" x="%d" y="%d" width="%d" height="%d">`, , .Pos.X, .Pos.Y-d2target.THREE_DEE_OFFSET, .Width+d2target.THREE_DEE_OFFSET, .Height+d2target.THREE_DEE_OFFSET, ), fmt.Sprintf(`<rect x="%d" y="%d" width="%d" height="%d" fill="white"></rect>`, .Pos.X, .Pos.Y-d2target.THREE_DEE_OFFSET, .Width+d2target.THREE_DEE_OFFSET, .Height+d2target.THREE_DEE_OFFSET, ), fmt.Sprintf(`<path d="%s" style="%s;stroke:#000;fill:none;opacity:1;"/></mask></defs>`, strings.Join(, ""), ), }, "\n") // render the main hexagon without stroke and the border mask := d2themes.NewThemableElement("polygon", ) .X = float64(.Pos.X) .Y = float64(.Pos.Y) .Points = .SetMaskUrl() , := d2themes.ShapeTheme() .FillPattern = .FillPattern .Fill = .Stroke = color.None .Style = .CSSStyle() := .Render() // render the side shapes in the darkened color without stroke and the border mask var []string for , := range []d2target.Point{ {X: (.Width, 0.25) + d2target.THREE_DEE_OFFSET, Y: -}, {X: (.Width, 0.75) + d2target.THREE_DEE_OFFSET, Y: -}, {X: .Width + d2target.THREE_DEE_OFFSET, Y: (.Height, ) - }, {X: (.Width, 0.75) + d2target.THREE_DEE_OFFSET, Y: .Height - }, {X: (.Width, 0.75), Y: .Height}, {X: .Width, Y: (.Height, )}, {X: (.Width, 0.75), Y: 0}, {X: (.Width, 0.25), Y: 0}, } { = append(, fmt.Sprintf("%d,%d", .X+.Pos.X, .Y+.Pos.Y), ) } // TODO make darker color part of the theme? or just keep this bypass , := color.Darken(.Fill) if != nil { = .Fill } := d2themes.NewThemableElement("polygon", ) .Fill = .Points = strings.Join(, " ") .SetMaskUrl() .Style = .CSSStyle() := .Render() return + + + } func drawShape(, io.Writer, string, d2target.Shape, jsrunner.JSRunner, *d2themes.Theme) ( string, error) { := "</g>" if .Link != "" { fmt.Fprintf(, `<a href="%s" xlink:href="%[1]s">`, svg.EscapeText(.Link)) += "</a>" } // Opacity is a unique style, it applies to everything for a shape := "" if .Opacity != 1.0 { = fmt.Sprintf(" style='opacity:%f'", .Opacity) } // this clipPath must be defined outside `g` element if .BorderRadius != 0 && (.Type == d2target.ShapeClass || .Type == d2target.ShapeSQLTable) { fmt.Fprint(, clipPathForBorderRadius(, )) } var string if .IconBorderRadius != 0 && (.Type == d2target.ShapeImage) { // Set the icon's border-radius to half of it's smaller dimension in case it exceeds that // https://www.w3.org/Style/CSS/Tracker/issues/29?changelog .IconBorderRadius = min(.IconBorderRadius, min(.Width, .Height)/2) = fmt.Sprintf("%v-%v-icon", , svg.SVGID(.ID)) fmt.Fprint(, applyIconBorderRadius(, )) } := []string{base64.URLEncoding.EncodeToString([]byte(svg.EscapeText(.ID)))} if .Animated { = append(, "animated-shape") } = append(, .Classes...) := fmt.Sprintf(` class="%s"`, strings.Join(, " ")) fmt.Fprintf(, `<g%s%s>`, , ) := geo.NewPoint(float64(.Pos.X), float64(.Pos.Y)) := float64(.Width) := float64(.Height) , := d2themes.ShapeTheme() := .CSSStyle() := d2target.DSL_SHAPE_TO_SHAPE_TYPE[.Type] := shape.NewShape(, geo.NewBox(, , )) if == shape.CLOUD_TYPE && .ContentAspectRatio != nil { .SetInnerBoxAspectRatio(*.ContentAspectRatio) } var string if .Shadow { switch .Type { case d2target.ShapeText, d2target.ShapeCode, d2target.ShapeClass, d2target.ShapeSQLTable: default: = `filter="url(#shadow-filter)" ` } } var string if .Blend { = " blend" } fmt.Fprintf(, `<g class="shape%s" %s>`, , ) var *geo.Point if .Multiple { = .AddVector(multipleOffset) } switch .Type { case d2target.ShapeClass: if != nil { , := d2sketch.Class(, ) if != nil { return "", } fmt.Fprint(, ) } else { drawClass(, , , ) } := addAppendixItems(, , , ) if != nil { return "", } fmt.Fprint(, `</g>`) fmt.Fprint(, ) return , nil case d2target.ShapeSQLTable: if != nil { , := d2sketch.Table(, ) if != nil { return "", } fmt.Fprint(, ) } else { drawTable(, , , ) } := addAppendixItems(, , , ) if != nil { return "", } fmt.Fprint(, `</g>`) fmt.Fprint(, ) return , nil case d2target.ShapeOval: if .DoubleBorder { if .Multiple { fmt.Fprint(, renderDoubleOval(, , , , "", , , )) } if != nil { , := d2sketch.DoubleOval(, , ) if != nil { return "", } fmt.Fprint(, ) } else { fmt.Fprint(, renderDoubleOval(, , , , .FillPattern, , , )) } } else { if .Multiple { fmt.Fprint(, renderOval(, , , , "", , , )) } if != nil { , := d2sketch.Oval(, , ) if != nil { return "", } fmt.Fprint(, ) } else { fmt.Fprint(, renderOval(, , , , .FillPattern, , , )) } } case d2target.ShapeImage: := d2themes.NewThemableElement("image", ) .X = float64(.Pos.X) .Y = float64(.Pos.Y) .Width = float64(.Width) .Height = float64(.Height) .Href = html.EscapeString(.Icon.String()) .Fill = .Stroke = .Style = if .IconBorderRadius != 0 { .ClipPath = } fmt.Fprint(, .Render()) // TODO should standardize "" to rectangle case d2target.ShapeRectangle, d2target.ShapeSequenceDiagram, d2target.ShapeHierarchy, "": := math.MaxFloat64 if .BorderRadius != 0 { = float64(.BorderRadius) } if .ThreeDee { fmt.Fprint(, render3DRect(, , )) } else { if !.DoubleBorder { if .Multiple { := d2themes.NewThemableElement("rect", ) .X = float64(.Pos.X + 10) .Y = float64(.Pos.Y - 10) .Width = float64(.Width) .Height = float64(.Height) .Fill = .Stroke = .Style = .Rx = fmt.Fprint(, .Render()) } if != nil { , := d2sketch.Rect(, , ) if != nil { return "", } fmt.Fprint(, ) } else { := d2themes.NewThemableElement("rect", ) .X = float64(.Pos.X) .Y = float64(.Pos.Y) .Width = float64(.Width) .Height = float64(.Height) .Fill = .FillPattern = .FillPattern .Stroke = .Style = .Rx = if .Label != "" && label.FromString(.LabelPosition).IsBorder() { .Mask = fmt.Sprintf("url(#%s)", ) } fmt.Fprint(, .Render()) } } else { if .Multiple { := d2themes.NewThemableElement("rect", ) .X = float64(.Pos.X + 10) .Y = float64(.Pos.Y - 10) .Width = float64(.Width) .Height = float64(.Height) .Fill = .FillPattern = .FillPattern .Stroke = .Style = .Rx = fmt.Fprint(, .Render()) = d2themes.NewThemableElement("rect", ) .X = float64(.Pos.X + 10 + d2target.INNER_BORDER_OFFSET) .Y = float64(.Pos.Y - 10 + d2target.INNER_BORDER_OFFSET) .Width = float64(.Width - 2*d2target.INNER_BORDER_OFFSET) .Height = float64(.Height - 2*d2target.INNER_BORDER_OFFSET) .Fill = .Stroke = .Style = .Rx = fmt.Fprint(, .Render()) } if != nil { , := d2sketch.DoubleRect(, , ) if != nil { return "", } fmt.Fprint(, ) } else { := d2themes.NewThemableElement("rect", ) .X = float64(.Pos.X) .Y = float64(.Pos.Y) .Width = float64(.Width) .Height = float64(.Height) .Fill = .FillPattern = .FillPattern .Stroke = .Style = .Rx = fmt.Fprint(, .Render()) = d2themes.NewThemableElement("rect", ) .X = float64(.Pos.X + d2target.INNER_BORDER_OFFSET) .Y = float64(.Pos.Y + d2target.INNER_BORDER_OFFSET) .Width = float64(.Width - 2*d2target.INNER_BORDER_OFFSET) .Height = float64(.Height - 2*d2target.INNER_BORDER_OFFSET) .Fill = "transparent" .Stroke = .Style = .Rx = fmt.Fprint(, .Render()) } } } case d2target.ShapeHexagon: if .ThreeDee { fmt.Fprint(, render3DHexagon(, , )) } else { if .Multiple { := shape.NewShape(, geo.NewBox(, , )).GetSVGPathData() := d2themes.NewThemableElement("path", ) .Fill = .Stroke = .Style = for , := range { .D = fmt.Fprint(, .Render()) } } if != nil { , := d2sketch.Paths(, , , .GetSVGPathData()) if != nil { return "", } fmt.Fprint(, ) } else { := d2themes.NewThemableElement("path", ) .Fill = .FillPattern = .FillPattern .Stroke = .Style = for , := range .GetSVGPathData() { .D = fmt.Fprint(, .Render()) } } } case d2target.ShapeText, d2target.ShapeCode: default: if .Multiple { := shape.NewShape(, geo.NewBox(, , )).GetSVGPathData() := d2themes.NewThemableElement("path", ) .Fill = .Stroke = .Style = for , := range { .D = fmt.Fprint(, .Render()) } } if != nil { , := d2sketch.Paths(, , , .GetSVGPathData()) if != nil { return "", } fmt.Fprint(, ) } else { := d2themes.NewThemableElement("path", ) .Fill = .FillPattern = .FillPattern .Stroke = .Style = if .Label != "" && label.FromString(.LabelPosition).IsBorder() { .Mask = fmt.Sprintf("url(#%s)", ) } for , := range .GetSVGPathData() { .D = fmt.Fprint(, .Render()) } } } // // to examine shape's innerBox // innerBox := s.GetInnerBox() // el := d2themes.NewThemableElement("rect", inlineTheme) // el.X = float64(innerBox.TopLeft.X) // el.Y = float64(innerBox.TopLeft.Y) // el.Width = float64(innerBox.Width) // el.Height = float64(innerBox.Height) // el.Style = "fill:rgba(255,0,0,0.5);" // fmt.Fprint(writer, el.Render()) // Closes the class=shape fmt.Fprint(, `</g>`) if .Icon != nil && .Type != d2target.ShapeImage && .Opacity != 0 { := label.FromString(.IconPosition) var *geo.Box if .IsOutside() { = .GetBox() } else { = .GetInnerBox() } := d2target.GetIconSize(, .IconPosition) := .GetPointOnBox(, label.PADDING, float64(), float64()) := "" if .IconBorderRadius != 0 { = fmt.Sprintf(` clip-path="inset(0 round %dpx)"`, .IconBorderRadius) } fmt.Fprintf(, `<image href="%s" x="%f" y="%f" width="%d" height="%d"%s />`, html.EscapeString(.Icon.String()), .X, .Y, , , , ) } if .Label != "" && .Opacity != 0 { := label.FromString(.LabelPosition) var *geo.Box if .IsOutside() || .IsBorder() { = .GetBox().Copy() // if it is 3d/multiple, place label using box around those if .ThreeDee { := d2target.THREE_DEE_OFFSET if .Type == d2target.ShapeHexagon { /= 2 } .TopLeft.Y -= float64() .Height += float64() .Width += d2target.THREE_DEE_OFFSET } else if .Multiple { .TopLeft.Y -= d2target.MULTIPLE_OFFSET .Height += d2target.MULTIPLE_OFFSET .Width += d2target.MULTIPLE_OFFSET } } else { = .GetInnerBox() } := .GetPointOnBox(, label.PADDING, float64(.LabelWidth), float64(.LabelHeight), ) if .IsBorder() { if != nil { = makeBorderLabelMask(, , .LabelWidth, .LabelHeight, , .StrokeWidth, 1.0, ) } else { = makeBorderLabelMask(, , .LabelWidth, .LabelHeight, , .StrokeWidth, 1.0, nil) } } := "text" if .FontFamily == "mono" { = "text-mono" } if .Bold { += "-bold" } else if .Italic { += "-italic" } if .Underline { += " text-underline" } if .Language == "latex" { , := d2latex.Render(.Label) if != nil { return , } // Remove XML declaration and DOCTYPE from LaTeX SVG output := `<?xml version="1.0" encoding="UTF-8"?>` := `<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">` = strings.ReplaceAll(, , "") = strings.ReplaceAll(, , "") := d2themes.NewThemableElement("g", ) := label.FromString(.LabelPosition) if == label.Unset { = label.InsideMiddleCenter } var *geo.Box if .IsOutside() { = .GetBox() } else { = .GetInnerBox() } := .GetPointOnBox(, label.PADDING, float64(.LabelWidth), float64(.LabelHeight), ) .SetTranslate(.X, .Y) .Color = .Stroke .Content = fmt.Fprint(, .Render()) } else if .Language == "markdown" { , := textmeasure.RenderMarkdown(.Label) if != nil { return , } := label.FromString(.LabelPosition) if == label.Unset { = label.InsideMiddleCenter } var *geo.Box if .IsOutside() { = .GetBox() } else { = .GetInnerBox() } := .GetPointOnBox(, label.PADDING, float64(.LabelWidth), float64(.LabelHeight), ) fmt.Fprintf(, `<g><foreignObject requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" x="%f" y="%f" width="%d" height="%d">`, .X, .Y, .LabelWidth, .LabelHeight, ) // we need the self closing form in this svg/xhtml context = strings.ReplaceAll(, "<hr>", "<hr />") := d2themes.NewThemableElement("div", ) .Content = // We have to set with styles since within foreignObject, we're in html // land and not SVG attributes var []string var []string = []string{"md"} if .FontSize != textmeasure.MarkdownFontSize { = append(, fmt.Sprintf("font-size:%vpx", .FontSize)) } if .Fill != "" && .Fill != "transparent" { if color.IsThemeColor(.Fill) { = append(, fmt.Sprintf("fill-%s", .Fill)) } else { = append(, fmt.Sprintf(`background-color:%s`, .Fill)) } } if !color.IsThemeColor(.Color) { = append(, fmt.Sprintf(`color:%s`, .Color)) } else { = append(, fmt.Sprintf("color-%s", .Color)) } .ClassName = strings.Join(, " ") // When using dark theme, inlineTheme is nil and we rely on CSS variables .Style = strings.Join(, ";") fmt.Fprint(, .Render()) fmt.Fprint(, `</foreignObject></g>`) } else if .Language != "" { := lexers.Get(.Language) if == nil { = lexers.Fallback } for , := range []bool{true, false} { := "github" if ! { = "catppuccin-mocha" } := styles.Get() if == nil { return , errors.New(`code snippet style "github" not found`) } := formatters.Get("svg") if == nil { return , errors.New(`code snippet formatter "svg" not found`) } , := .Tokenise(nil, .Label) if != nil { return , } := styleToSVG() := "light-code" if ! { = "dark-code" } var string if .FontSize != d2fonts.FONT_SIZE_M { = fmt.Sprintf(` style="font-size:%v"`, .FontSize) } fmt.Fprintf(, `<g transform="translate(%f %f)" class="%s"%s>`, .TopLeft.X, .TopLeft.Y, , , ) := d2themes.NewThemableElement("rect", ) .Width = float64(.Width) .Height = float64(.Height) .Stroke = .Stroke .ClassName = "shape" .Style = fmt.Sprintf(`fill:%s;stroke-width:%d;`, .Get(chroma.Background).Background.String(), .StrokeWidth, ) fmt.Fprint(, .Render()) // Padding = 0.5em := float64(.FontSize) / 2. fmt.Fprintf(, `<g transform="translate(%f %f)">`, , ) := textmeasure.CODE_LINE_HEIGHT for , := range chroma.SplitTokensIntoLines(.Tokens()) { fmt.Fprintf(, "<text class=\"text-mono\" x=\"0\" y=\"%fem\">", 1+float64()*) for , := range { := svgEscaper.Replace(.String()) := styleAttr(, .Type) if != "" { = fmt.Sprintf("<tspan %s>%s</tspan>", , ) } fmt.Fprint(, ) } fmt.Fprint(, "</text>") } fmt.Fprint(, "</g></g>") } } else { if .LabelFill != "" { := d2themes.NewThemableElement("rect", ) .X = .X .Y = .Y .Width = float64(.LabelWidth) .Height = float64(.LabelHeight) .Fill = .LabelFill fmt.Fprint(, .Render()) } := d2themes.NewThemableElement("text", ) .X = .X + float64(.LabelWidth)/2 // text is vertically positioned at its baseline which is at labelTL+FontSize .Y = .Y + float64(.FontSize) .Fill = .GetFontColor() .ClassName = .Style = fmt.Sprintf("text-anchor:%s;font-size:%vpx", "middle", .FontSize) .Content = RenderText(.Label, .X, float64(.LabelHeight)) fmt.Fprint(, .Render()) } } if .Tooltip != "" && .TooltipPosition == "" { fmt.Fprintf(, `<title>%s</title>`, svg.EscapeText(.Tooltip), ) } = addAppendixItems(, , , ) if != nil { return "", } fmt.Fprint(, ) return , nil } func applyIconBorderRadius( string, d2target.Shape) string { := geo.NewBox( geo.NewPoint(float64(.Pos.X), float64(.Pos.Y)), float64(.Width), float64(.Height), ) , := .TopLeft.X+.Width, .TopLeft.Y := fmt.Sprintf(`<clipPath id="%s">`, ) += fmt.Sprintf(`<path d="M %f %f L %f %f S %f %f %f %f `, .TopLeft.X, .TopLeft.Y+float64(.IconBorderRadius), .TopLeft.X, .TopLeft.Y+float64(.IconBorderRadius), .TopLeft.X, .TopLeft.Y, .TopLeft.X+float64(.IconBorderRadius), .TopLeft.Y) += fmt.Sprintf(`L %f %f L %f %f `, .TopLeft.X+.Width-float64(.IconBorderRadius), .TopLeft.Y, -float64(.IconBorderRadius), ) += fmt.Sprintf(`S %f %f %f %f `, , , , +float64(.IconBorderRadius)) += fmt.Sprintf(`L %f %f `, , +.Height-float64(.IconBorderRadius)) += fmt.Sprintf(`S %f % f %f %f `, , +.Height, -float64(.IconBorderRadius), +.Height) += fmt.Sprintf(`L %f %f `, .TopLeft.X+float64(.IconBorderRadius), .TopLeft.Y+.Height) += fmt.Sprintf(`S %f %f %f %f`, .TopLeft.X, .TopLeft.Y+.Height, .TopLeft.X, .TopLeft.Y+.Height-float64(.IconBorderRadius)) += fmt.Sprintf(`L %f %f`, .TopLeft.X, .TopLeft.Y+float64(.IconBorderRadius)) += fmt.Sprintf(`Z %f %f" `, .TopLeft.X, .TopLeft.Y) return + `fill="none" /> </clipPath>` } func addAppendixItems( io.Writer, string, d2target.Shape, shape.Shape) error { var , *geo.Point if .Tooltip != "" || .Link != "" { := .Tooltip != "" && .Link != "" := geo.NewPoint(float64(.Pos.X+.Width), float64(.Pos.Y)) := geo.NewPoint( float64(.Pos.X)+float64(.Width)/2., float64(.Pos.Y)+float64(.Height)/2., ) := geo.Vector{-2 * appendixIconRadius, 0} var bool switch .GetType() { case shape.STEP_TYPE, shape.HEXAGON_TYPE, shape.QUEUE_TYPE, shape.PAGE_TYPE: // trace straight left for these .Y = float64(.Pos.Y) case shape.PACKAGE_TYPE: // trace straight down .X = float64(.Pos.X + .Width) case shape.CIRCLE_TYPE, shape.OVAL_TYPE, shape.DIAMOND_TYPE, shape.PERSON_TYPE, shape.CLOUD_TYPE, shape.CYLINDER_TYPE: if { = true = .AddVector() } } := .VectorTo() = shape.TraceToShapeBorder(, , .AddVector()) if { if { // these shapes should have p1 on shape border = .AddVector(.Reverse()) , = , } else { = .AddVector() } } } if .Tooltip != "" { if .TooltipPosition != "" { , := renderPositionedTooltip(, .TooltipPosition) if != nil { return } fmt.Fprint(, ) } else { := int(math.Ceil(.X)) := int(math.Ceil(.Y)) fmt.Fprintf(, `<g transform="translate(%d %d)" class="appendix-icon"><title>%s</title>%s</g>`, -appendixIconRadius, -appendixIconRadius, svg.EscapeText(.Tooltip), fmt.Sprintf(TooltipIcon, , svg.SVGID(.ID)), ) } } if .Link != "" { if == nil { = } := int(math.Ceil(.X)) := int(math.Ceil(.Y)) fmt.Fprintf(, `<g transform="translate(%d %d)" class="appendix-icon">%s</g>`, -appendixIconRadius, -appendixIconRadius, fmt.Sprintf(LinkIcon, , svg.SVGID(.ID)), ) } return nil } func calculateTooltipPosition( d2target.Shape, string, , int) (, float64, string, , float64) { := float64(.Pos.X) := float64(.Pos.Y) := float64(.Width) := float64(.Height) , = d2target.CalculateTooltipPosition(, , , , , , ) switch { case "top-left": = "bottom" = 20 = float64() case "top-center": = "bottom" = float64() / 2 = float64() case "top-right": = "bottom" = float64() - 20 = float64() case "center-left": = "right" = float64() = float64() / 2 case "center-right": = "left" = 0 = float64() / 2 case "bottom-left": = "top" = 20 = 0 case "bottom-center": = "top" = float64() / 2 = 0 case "bottom-right": = "top" = float64() - 20 = 0 default: = "bottom" = float64() / 2 = float64() } return , , , , } func renderTooltipTail( string, , float64) string { := 8.0 switch { case "top": return fmt.Sprintf(`<path d="M %f %f L %f %f L %f %f Z" fill="white" stroke="#DEE1EB" stroke-width="1"/>`, -/2, , +/2, , , -) case "bottom": return fmt.Sprintf(`<path d="M %f %f L %f %f L %f %f Z" fill="white" stroke="#DEE1EB" stroke-width="1"/>`, -/2, , +/2, , , +) case "left": return fmt.Sprintf(`<path d="M %f %f L %f %f L %f %f Z" fill="white" stroke="#DEE1EB" stroke-width="1"/>`, , -/2, , +/2, -, ) case "right": return fmt.Sprintf(`<path d="M %f %f L %f %f L %f %f Z" fill="white" stroke="#DEE1EB" stroke-width="1"/>`, , -/2, , +/2, +, ) default: return "" } } func renderPositionedTooltip( d2target.Shape, string) (string, error) { if .Tooltip == "" || == "" { return "", nil } var , int var string , := textmeasure.NewRuler() if != nil { return "", } := go2.Pointer(d2fonts.SourceSansPro) := d2fonts.FONT_SIZE_M , , := textmeasure.MeasureMarkdown(.Tooltip, , , nil, ) if != nil { return "", } = + 20 = + 20 , := textmeasure.RenderMarkdown(.Tooltip) if != nil { return "", } , , , , := calculateTooltipPosition(, , , ) = fmt.Sprintf( `<foreignObject x="%f" y="%f" width="%d" height="%d"><div xmlns="http://www.w3.org/1999/xhtml" class="md color-N1">%s</div></foreignObject>`, +10, +10, -20, -20, , ) := fmt.Sprintf( `<rect x="%f" y="%f" width="%d" height="%d" rx="4" ry="4" fill="white" stroke="#DEE1EB" stroke-width="1"/>`, , , , , ) := renderTooltipTail(, +, +) return fmt.Sprintf(`<g class="positioned-tooltip">%s%s%s</g>`, , , ), nil } func ( string, , float64) string { if !strings.Contains(, "\n") { return svg.EscapeText() } := []string{} := strings.Split(, "\n") for , := range { := / float64(len()) if == 0 { = 0 } := svg.EscapeText() if == "" { // if there are multiple newlines in a row we still need text for the tspan to render = " " } = append(, fmt.Sprintf(`<tspan x="%f" dy="%f">%s</tspan>`, , , )) } return strings.Join(, "") } func ( *bytes.Buffer, , string, *d2fonts.FontFamily, *d2fonts.FontFamily, string) { fmt.Fprint(, `<style type="text/css"><![CDATA[`) appendOnTrigger( , , []string{ `class="text"`, `class="text `, `class="md"`, `class="md `, }, fmt.Sprintf(` .%s .text { font-family: "%s-font-regular"; } @font-face { font-family: %s-font-regular; src: url("%s"); }`, , , , .Font(0, d2fonts.FONT_STYLE_REGULAR).GetEncodedSubset(), ), ) appendOnTrigger( , , []string{ `class="md"`, `class="md `, }, fmt.Sprintf(` @font-face { font-family: %s-font-semibold; src: url("%s"); }`, , .Font(0, d2fonts.FONT_STYLE_SEMIBOLD).GetEncodedSubset(), ), ) appendOnTrigger( , , []string{ `text-underline`, }, ` .text-underline { text-decoration: underline; }`, ) appendOnTrigger( , , []string{ `text-link`, }, ` .text-link { fill: blue; } .text-link:visited { fill: purple; }`, ) appendOnTrigger( , , []string{ `animated-connection`, }, ` @keyframes dashdraw { from { stroke-dashoffset: 0; } } `, ) appendOnTrigger( , , []string{ `animated-shape`, }, ` @keyframes shapeappear { 0%, 100% { transform: translateY(0); filter: drop-shadow(0px 0px 0px rgba(0,0,0,0)); } 50% { transform: translateY(-4px); filter: drop-shadow(0px 12.6px 25.2px rgba(50,50,93,0.25)) drop-shadow(0px 7.56px 15.12px rgba(0,0,0,0.1)); } } .animated-shape { animation: shapeappear 1s linear infinite; } `, ) appendOnTrigger( , , []string{ `appendix-icon`, }, ` .appendix-icon { filter: drop-shadow(0px 0px 32px rgba(31, 36, 58, 0.1)); }`, ) appendOnTrigger( , , []string{ `class="text-bold`, `<b>`, `<strong>`, }, fmt.Sprintf(` .%s .text-bold { font-family: "%s-font-bold"; } @font-face { font-family: %s-font-bold; src: url("%s"); }`, , , , .Font(0, d2fonts.FONT_STYLE_BOLD).GetEncodedSubset(), ), ) appendOnTrigger( , , []string{ `class="text-italic`, `<em>`, `<dfn>`, }, fmt.Sprintf(` .%s .text-italic { font-family: "%s-font-italic"; } @font-face { font-family: %s-font-italic; src: url("%s"); }`, , , , .Font(0, d2fonts.FONT_STYLE_ITALIC).GetEncodedSubset(), ), ) appendOnTrigger( , , []string{ `class="text-mono`, `<pre>`, `<code>`, `<kbd>`, `<samp>`, }, fmt.Sprintf(` .%s .text-mono { font-family: "%s-font-mono"; } @font-face { font-family: %s-font-mono; src: url("%s"); }`, , , , .Font(0, d2fonts.FONT_STYLE_REGULAR).GetEncodedSubset(), ), ) appendOnTrigger( , , []string{ `class="text-mono-bold`, }, fmt.Sprintf(` .%s .text-mono-bold { font-family: "%s-font-mono-bold"; } @font-face { font-family: %s-font-mono-bold; src: url("%s"); }`, , , , .Font(0, d2fonts.FONT_STYLE_BOLD).GetEncodedSubset(), ), ) appendOnTrigger( , , []string{ `class="text-mono-italic`, }, fmt.Sprintf(` .%s .text-mono-italic { font-family: "%s-font-mono-italic"; } @font-face { font-family: %s-font-mono-italic; src: url("%s"); }`, , , , .Font(0, d2fonts.FONT_STYLE_ITALIC).GetEncodedSubset(), ), ) appendOnTrigger( , , []string{ `sketch-overlay-bright`, }, fmt.Sprintf(` .sketch-overlay-bright { fill: url(#streaks-bright-%s); mix-blend-mode: darken; }`, ), ) appendOnTrigger( , , []string{ `sketch-overlay-normal`, }, fmt.Sprintf(` .sketch-overlay-normal { fill: url(#streaks-normal-%s); mix-blend-mode: color-burn; }`, ), ) appendOnTrigger( , , []string{ `sketch-overlay-dark`, }, fmt.Sprintf(` .sketch-overlay-dark { fill: url(#streaks-dark-%s); mix-blend-mode: overlay; }`, ), ) appendOnTrigger( , , []string{ `sketch-overlay-darker`, }, fmt.Sprintf(` .sketch-overlay-darker { fill: url(#streaks-darker-%s); mix-blend-mode: lighten; }`, ), ) fmt.Fprint(, `]]></style>`) } func appendOnTrigger( *bytes.Buffer, string, []string, string) { for , := range { if strings.Contains(, ) { fmt.Fprint(, ) break } } } var DEFAULT_DARK_THEME *int64 = nil // no theme selected func ( *d2target.Diagram, *RenderOpts) ([]byte, error) { var jsrunner.JSRunner := DEFAULT_PADDING := d2themescatalog.NeutralDefault.ID := DEFAULT_DARK_THEME var *float64 if != nil { if .Pad != nil { = int(*.Pad) } if .Sketch != nil && *.Sketch { = jsrunner.NewJSRunner() := d2sketch.LoadJS() if != nil { return nil, } } if .ThemeID != nil { = *.ThemeID } = .DarkThemeID = .Scale } else { = &RenderOpts{} } := &bytes.Buffer{} // only define shadow filter if a shape uses it for , := range .Shapes { if .Shadow { defineShadowFilter() break } } if color.IsGradient(.Root.Fill) { defineGradients(, .Root.Fill) } if color.IsGradient(.Root.Stroke) { defineGradients(, .Root.Stroke) } for , := range .Shapes { if color.IsGradient(.Fill) { defineGradients(, .Fill) } if color.IsGradient(.Stroke) { defineGradients(, .Stroke) } if color.IsGradient(.Color) { defineGradients(, .Color) } } for , := range .Connections { if color.IsGradient(.Stroke) { defineGradients(, .Stroke) } if color.IsGradient(.Fill) { defineGradients(, .Fill) } } // Apply hash on IDs for targeting, to be specific for this diagram , := .HashID(.Salt) if != nil { return nil, } // Some targeting is still per-board, like masks for connections := if != nil && .MasterID != "" { = .MasterID } // 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 := make(map[string]d2target.Shape) := make([]DiagramObject, 0, len(.Shapes)+len(.Connections)) for , := range .Shapes { [.ID] = = append(, ) } for , := range .Connections { = append(, ) } sortObjects() := &bytes.Buffer{} var []string := map[string]struct{}{} var *d2themes.Theme // We only want to inline when no dark theme is specified, otherwise the inline style will override the CSS if == nil { = go2.Pointer(d2themescatalog.Find()) .ApplyOverrides(.ThemeOverrides) } for , := range { if , := .(d2target.Connection); { , := drawConnection(, , , , , , ) if != nil { return nil, } if != "" { = append(, ) } } else if , := .(d2target.Shape); { , := drawShape(, , , , , ) if != nil { return nil, } else if != "" { = append(, ) } } else { return nil, fmt.Errorf("unknown object of type %T", ) } } // add all appendix items afterwards so they are always on top fmt.Fprint(, ) if .Legend != nil && (len(.Legend.Shapes) > 0 || len(.Legend.Connections) > 0) { := &bytes.Buffer{} := RenderLegend(, , , ) if != nil { return nil, } fmt.Fprint(, ) } // Note: we always want this since we reference it on connections even if there end up being no masked labels , , , := dimensions(, ) if .Legend != nil && (len(.Legend.Shapes) > 0 || len(.Legend.Connections) > 0) { , := .BoundingBox() := LEGEND_PADDING + LEGEND_FONT_SIZE + LEGEND_ITEM_SPACING := 0 := 0 , := textmeasure.NewRuler() if != nil { for , := range .Legend.Shapes { if .Label == "" { continue } := &d2target.MText{ Text: .Label, FontSize: LEGEND_FONT_SIZE, } := d2graph.GetTextDimensions(nil, , , nil) = go2.IntMax(, .Width) += go2.IntMax(.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING ++ } for , := range .Legend.Connections { if .Label == "" { continue } := &d2target.MText{ Text: .Label, FontSize: LEGEND_FONT_SIZE, } := d2graph.GetTextDimensions(nil, , , nil) = go2.IntMax(, .Width) += go2.IntMax(.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING ++ } if > 0 { -= LEGEND_ITEM_SPACING / 2 } += LEGEND_PADDING if > 0 && > 0 { := LEGEND_PADDING*2 + LEGEND_ICON_SIZE + LEGEND_PADDING + := .Y - if < .Y { = .Y } := .X + LEGEND_CORNER_PADDING + if + < { = - + /2 } if < { := - -= += } := + if + < { = - + /2 } } } } fmt.Fprint(, strings.Join([]string{ fmt.Sprintf(`<mask id="%s" maskUnits="userSpaceOnUse" x="%d" y="%d" width="%d" height="%d">`, , , , , , ), fmt.Sprintf(`<rect x="%d" y="%d" width="%d" height="%d" fill="white"></rect>`, , , , , ), strings.Join(, "\n"), `</mask>`, }, "\n")) // generate style elements that will be appended to the SVG tag := &bytes.Buffer{} if .MasterID == "" { EmbedFonts(, , .String(), .FontFamily, .MonoFontFamily, .GetCorpus()) // EmbedFonts *must* run before `d2sketch.DefineFillPatterns`, but after all elements are appended to `buf` , := ThemeCSS(, &, , .ThemeOverrides, .DarkThemeOverrides) if != nil { return nil, } fmt.Fprintf(, `<style type="text/css"><![CDATA[%s%s]]></style>`, BaseStylesheet, ) := false for , := range .Shapes { if .Language == "markdown" { = true break } } if ! { for , := range .Connections { if .Language == "markdown" { = true break } } } if { := MarkdownCSS = strings.ReplaceAll(, ".md", fmt.Sprintf(".%s .md", )) = strings.ReplaceAll(, "font-italic", fmt.Sprintf("%s-font-italic", )) = strings.ReplaceAll(, "font-bold", fmt.Sprintf("%s-font-bold", )) = strings.ReplaceAll(, "font-mono", fmt.Sprintf("%s-font-mono", )) = strings.ReplaceAll(, "font-regular", fmt.Sprintf("%s-font-regular", )) = strings.ReplaceAll(, "font-semibold", fmt.Sprintf("%s-font-semibold", )) fmt.Fprintf(, `<style type="text/css">%s</style>`, ) } if != nil { d2sketch.DefineFillPatterns(, ) } } // This shift is for background el to envelop the diagram -= int(math.Ceil(float64(.Root.StrokeWidth) / 2.)) -= int(math.Ceil(float64(.Root.StrokeWidth) / 2.)) += int(math.Ceil(float64(.Root.StrokeWidth)/2.) * 2.) += int(math.Ceil(float64(.Root.StrokeWidth)/2.) * 2.) := d2themes.NewThemableElement("rect", ) // We don't want to change the document viewbox, only the background el .X = float64() .Y = float64() .Width = float64() .Height = float64() .Fill = .Root.Fill .Stroke = .Root.Stroke .FillPattern = .Root.FillPattern .Rx = float64(.Root.BorderRadius) if .Root.StrokeDash != 0 { , := svg.GetStrokeDashAttributes(float64(.Root.StrokeWidth), .Root.StrokeDash) .StrokeDashArray = fmt.Sprintf("%f, %f", , ) } .Attributes = fmt.Sprintf(`stroke-width="%d"`, .Root.StrokeWidth) // This shift is for viewbox to envelop the background el -= int(math.Ceil(float64(.Root.StrokeWidth) / 2.)) -= int(math.Ceil(float64(.Root.StrokeWidth) / 2.)) += int(math.Ceil(float64(.Root.StrokeWidth)/2.) * 2.) += int(math.Ceil(float64(.Root.StrokeWidth)/2.) * 2.) := "" if .Root.DoubleBorder { := d2target.INNER_BORDER_OFFSET -= int(math.Ceil(float64(.Root.StrokeWidth)/2.)) + -= int(math.Ceil(float64(.Root.StrokeWidth)/2.)) + += int(math.Ceil(float64(.Root.StrokeWidth)/2.)*2.) + 2* += int(math.Ceil(float64(.Root.StrokeWidth)/2.)*2.) + 2* := .Copy() // No need to double-paint .Fill = "transparent" .X = float64() .Y = float64() .Width = float64() .Height = float64() = .Render() -= int(math.Ceil(float64(.Root.StrokeWidth) / 2.)) -= int(math.Ceil(float64(.Root.StrokeWidth) / 2.)) += int(math.Ceil(float64(.Root.StrokeWidth)/2.) * 2.) += int(math.Ceil(float64(.Root.StrokeWidth)/2.) * 2.) } := .String() := "" for , := range d2ast.FillPatterns { if strings.Contains(, fmt.Sprintf("%s-overlay", )) || .Root.FillPattern == { if == "" { fmt.Fprint(, `<style type="text/css"><![CDATA[`) } switch { case "dots": += fmt.Sprintf(dots, ) case "lines": += fmt.Sprintf(lines, ) case "grain": += fmt.Sprintf(grain, ) case "paper": += fmt.Sprintf(paper, ) } fmt.Fprintf(, ` .%s-overlay { fill: url(#%s-%s); mix-blend-mode: multiply; }`, , , ) } } if != "" { fmt.Fprint(, `]]></style>`) fmt.Fprint(, "<defs>") fmt.Fprint(, ) fmt.Fprint(, "</defs>") } var string if != nil { = fmt.Sprintf(` width="%d" height="%d"`, int(math.Ceil((*)*float64())), int(math.Ceil((*)*float64())), ) } := "xMinYMin" if .Center != nil && *.Center { = "xMidYMid" } := "" := "" := "" := "" := "g" // Many things change when this is rendering for animation if .MasterID == "" { := "" if .OmitVersion == nil || !*.OmitVersion { = fmt.Sprintf(`data-d2-version="%s"`, version.Version) } = fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" %s preserveAspectRatio="%s meet" viewBox="0 0 %d %d"%s>`, , , , , , ) if .NoXMLTag == nil || !*.NoXMLTag { = `<?xml version="1.0" encoding="utf-8"?>` } = "</svg>" = `d2-svg` = "svg" } // TODO minify := fmt.Sprintf(`%s%s<%s class="%s" width="%d" height="%d" viewBox="%d %d %d %d">%s%s%s%s</%s>%s`, , , , strings.Join([]string{, }, " "), , , , , , , , .Render(), .String(), .String(), , , ) return []byte(), nil } // TODO include only colors that are being used to reduce size func ( string, *int64, *int64, , *d2target.ThemeOverrides) ( string, error) { if == nil { = &d2themescatalog.NeutralDefault.ID } , := singleThemeRulesets(, *, ) if != nil { return "", } if != nil { , := singleThemeRulesets(, *, ) if != nil { return "", } += fmt.Sprintf("@media screen and (prefers-color-scheme:dark){%s}", ) } return , nil } func singleThemeRulesets( string, int64, *d2target.ThemeOverrides) ( string, error) { := "" := d2themescatalog.Find() .ApplyOverrides() // Global theme colors for , := range []string{"fill", "stroke", "background-color", "color"} { += fmt.Sprintf(` .%s .%s-N1{%s:%s;} .%s .%s-N2{%s:%s;} .%s .%s-N3{%s:%s;} .%s .%s-N4{%s:%s;} .%s .%s-N5{%s:%s;} .%s .%s-N6{%s:%s;} .%s .%s-N7{%s:%s;} .%s .%s-B1{%s:%s;} .%s .%s-B2{%s:%s;} .%s .%s-B3{%s:%s;} .%s .%s-B4{%s:%s;} .%s .%s-B5{%s:%s;} .%s .%s-B6{%s:%s;} .%s .%s-AA2{%s:%s;} .%s .%s-AA4{%s:%s;} .%s .%s-AA5{%s:%s;} .%s .%s-AB4{%s:%s;} .%s .%s-AB5{%s:%s;}`, , , , .Colors.Neutrals.N1, , , , .Colors.Neutrals.N2, , , , .Colors.Neutrals.N3, , , , .Colors.Neutrals.N4, , , , .Colors.Neutrals.N5, , , , .Colors.Neutrals.N6, , , , .Colors.Neutrals.N7, , , , .Colors.B1, , , , .Colors.B2, , , , .Colors.B3, , , , .Colors.B4, , , , .Colors.B5, , , , .Colors.B6, , , , .Colors.AA2, , , , .Colors.AA4, , , , .Colors.AA5, , , , .Colors.AB4, , , , .Colors.AB5, ) } // Appendix += fmt.Sprintf(".appendix text.text{fill:%s}", .Colors.Neutrals.N1) // Markdown specific rulesets += fmt.Sprintf(".md{--color-fg-default:%s;--color-fg-muted:%s;--color-fg-subtle:%s;--color-canvas-default:%s;--color-canvas-subtle:%s;--color-border-default:%s;--color-border-muted:%s;--color-neutral-muted:%s;--color-accent-fg:%s;--color-accent-emphasis:%s;--color-attention-subtle:%s;--color-danger-fg:%s;}", .Colors.Neutrals.N1, .Colors.Neutrals.N2, .Colors.Neutrals.N3, .Colors.Neutrals.N7, .Colors.Neutrals.N6, .Colors.B1, .Colors.B2, .Colors.Neutrals.N6, .Colors.B2, .Colors.B2, .Colors.Neutrals.N2, // TODO or N3 --color-attention-subtle "red", ) // Sketch style specific rulesets // B , := color.LuminanceCategory(.Colors.B1) if != nil { return "", } += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.B1, , , blendMode()) , = color.LuminanceCategory(.Colors.B2) if != nil { return "", } += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.B2, , , blendMode()) , = color.LuminanceCategory(.Colors.B3) if != nil { return "", } += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.B3, , , blendMode()) , = color.LuminanceCategory(.Colors.B4) if != nil { return "", } += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.B4, , , blendMode()) , = color.LuminanceCategory(.Colors.B5) if != nil { return "", } += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.B5, , , blendMode()) , = color.LuminanceCategory(.Colors.B6) if != nil { return "", } += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.B6, , , blendMode()) // AA , = color.LuminanceCategory(.Colors.AA2) if != nil { return "", } += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.AA2, , , blendMode()) , = color.LuminanceCategory(.Colors.AA4) if != nil { return "", } += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.AA4, , , blendMode()) , = color.LuminanceCategory(.Colors.AA5) if != nil { return "", } += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.AA5, , , blendMode()) // AB , = color.LuminanceCategory(.Colors.AB4) if != nil { return "", } += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.AB4, , , blendMode()) , = color.LuminanceCategory(.Colors.AB5) if != nil { return "", } += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.AB5, , , blendMode()) // Neutrals , = color.LuminanceCategory(.Colors.Neutrals.N1) if != nil { return "", } += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.N1, , , blendMode()) , = color.LuminanceCategory(.Colors.Neutrals.N2) if != nil { return "", } += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.N2, , , blendMode()) , = color.LuminanceCategory(.Colors.Neutrals.N3) if != nil { return "", } += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.N3, , , blendMode()) , = color.LuminanceCategory(.Colors.Neutrals.N4) if != nil { return "", } += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.N4, , , blendMode()) , = color.LuminanceCategory(.Colors.Neutrals.N5) if != nil { return "", } += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.N5, , , blendMode()) , = color.LuminanceCategory(.Colors.Neutrals.N6) if != nil { return "", } += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.N6, , , blendMode()) , = color.LuminanceCategory(.Colors.Neutrals.N7) if != nil { return "", } += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.N7, , , blendMode()) if .IsDark() { += ".light-code{display: none}" += ".dark-code{display: block}" } else { += ".light-code{display: block}" += ".dark-code{display: none}" } return , nil } func blendMode( string) string { switch { case "bright": return "darken" case "normal": return "color-burn" case "dark": return "overlay" case "darker": return "lighten" } panic("invalid luminance category") } type DiagramObject interface { GetID() string GetZIndex() int } // 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 func sortObjects( []DiagramObject) { sort.SliceStable(, func(, int) bool { // first sort by zIndex := [].GetZIndex() := [].GetZIndex() if != { return < } // then, if both are shapes, parents come before their children , := [].(d2target.Shape) , := [].(d2target.Shape) if && { return .Level < .Level } // then, shapes come before connections , := [].(d2target.Connection) return && }) } func hash( string) string { const = "lalalas" := fnv.New32a() .Write([]byte(fmt.Sprintf("%s%s", , ))) return fmt.Sprint(.Sum32()) } func ( *d2target.Diagram, *RenderOpts) ([][]byte, error) { var [][]byte for , := range .Layers { , := (, ) if != nil { return nil, } = append(, ...) } for , := range .Scenarios { , := (, ) if != nil { return nil, } = append(, ...) } for , := range .Steps { , := (, ) if != nil { return nil, } = append(, ...) } if !.IsFolderOnly { , := Render(, ) if != nil { return , } = append([][]byte{}, ...) return , nil } return , nil }