// d2svg implements an SVG renderer for d2 diagrams. // The input is d2exporter's output
package d2svg import ( _ ) const ( DEFAULT_PADDING = 100 appendixIconRadius = 16 ) 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 //go:embed paper.txt var paper 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 } func dimensions( *d2target.Diagram, int) (, , , int) { , := .BoundingBox() = .X - = .Y - = .X - .X + *2 = .Y - .Y + *2 return , , , } 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.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 { := "black" if != 1 { = fmt.Sprintf("rgba(0,0,0,%.2f)", ) } return fmt.Sprintf(`<rect x="%f" y="%f" width="%d" height="%d" fill="%s"></rect>`, .X, .Y, , , , ) } 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)" `, ) } var *geo.Point if .Label != "" { = .GetLabelTopLeft() .X = math.Round(.X) .Y = math.Round(.Y) if label.FromString(.LabelPosition).IsOnEdge() { = makeLabelMask(, .LabelWidth, .LabelHeight, 1) } else { = makeLabelMask(, .LabelWidth, .LabelHeight, 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 != "" { := "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(, )) } := []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(, , , ) fmt.Fprint(, `</g>`) fmt.Fprint(, ) return , nil case d2target.ShapeSQLTable: if != nil { , := d2sketch.Table(, ) if != nil { return "", } fmt.Fprint(, ) } else { drawTable(, , , ) } addAppendixItems(, , , ) 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 = 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 = 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 = 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()) fmt.Fprintf(, `<image href="%s" x="%f" y="%f" width="%d" height="%d" />`, html.EscapeString(.Icon.String()), .X, .Y, , , ) } if .Label != "" && .Opacity != 0 { := label.FromString(.LabelPosition) var *geo.Box if .IsOutside() { = .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), ) = makeLabelMask(, .LabelWidth, .LabelHeight, 0.75) := "text" if .FontFamily == "mono" { = "text-mono" } if .Bold { += "-bold" } else if .Italic { += "-italic" } if .Underline { += " text-underline" } if .Type == d2target.ShapeCode { := 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 .Type == d2target.ShapeText && .Language == "latex" { , := d2latex.Render(.Label) if != nil { return , } := d2themes.NewThemableElement("g", ) .SetTranslate(float64(.TopLeft.X), float64(.TopLeft.Y)) .Color = .Stroke .Content = fmt.Fprint(, .Render()) } else if .Type == d2target.ShapeText && .Language != "" { , := 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">`, .TopLeft.X, .TopLeft.Y, .Width, .Height, ) // we need the self closing form in this svg/xhtml context = strings.ReplaceAll(, "<hr>", "<hr />") := d2themes.NewThemableElement("div", ) .ClassName = "md" .Content = // We have to set with styles since within foreignObject, we're in html // land and not SVG attributes 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 .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 .Blend { = makeLabelMask(, .LabelWidth, .LabelHeight-d2graph.INNER_LABEL_PADDING, 1) } } } if .Tooltip != "" { fmt.Fprintf(, `<title>%s</title>`, svg.EscapeText(.Tooltip), ) } addAppendixItems(, , , ) fmt.Fprint(, ) return , nil } func addAppendixItems( io.Writer, string, d2target.Shape, shape.Shape) { 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 != "" { := 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)), ) } } 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, string) { fmt.Fprint(, `<style type="text/css"><![CDATA[`) appendOnTrigger( , , []string{ `class="text"`, `class="text `, `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"`}, 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"); }`, , , , d2fonts.SourceCodePro.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"); }`, , , , d2fonts.SourceCodePro.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"); }`, , , , d2fonts.SourceCodePro.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(, ) // Note: we always want this since we reference it on connections even if there end up being no masked labels , , , := dimensions(, ) 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, .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 .Label != "" && .Type == d2target.ShapeText { = 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 == "" { = fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" data-d2-version="%s" preserveAspectRatio="%s meet" viewBox="0 0 %d %d"%s>`, version.Version, , , , , ) 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 }