From 09cf9ae7d152dac6c3a5b428791fb19e68c73eea Mon Sep 17 00:00:00 2001 From: Alexander Wang Date: Fri, 27 Sep 2024 15:49:10 -0600 Subject: [PATCH 1/2] d2render: support gradient values --- d2graph/d2graph.go | 12 +- d2renderers/d2svg/d2svg.go | 28 ++ d2themes/element.go | 6 + .../txtar/gradient/dagre/board.exp.json | 178 +++++++++++++ .../txtar/gradient/dagre/sketch.exp.svg | 125 +++++++++ .../txtar/gradient/elk/board.exp.json | 169 ++++++++++++ .../txtar/gradient/elk/sketch.exp.svg | 125 +++++++++ e2etests/txtar.txt | 14 + lib/color/color.go | 9 + lib/color/gradient.go | 248 ++++++++++++++++++ 10 files changed, 908 insertions(+), 6 deletions(-) create mode 100644 e2etests/testdata/txtar/gradient/dagre/board.exp.json create mode 100644 e2etests/testdata/txtar/gradient/dagre/sketch.exp.svg create mode 100644 e2etests/testdata/txtar/gradient/elk/board.exp.json create mode 100644 e2etests/testdata/txtar/gradient/elk/sketch.exp.svg create mode 100644 lib/color/gradient.go diff --git a/d2graph/d2graph.go b/d2graph/d2graph.go index ba6053a892..b9b27b64c5 100644 --- a/d2graph/d2graph.go +++ b/d2graph/d2graph.go @@ -253,16 +253,16 @@ func (s *Style) Apply(key, value string) error { if s.Stroke == nil { break } - if !go2.Contains(color.NamedColors, strings.ToLower(value)) && !color.ColorHexRegex.MatchString(value) { - return errors.New(`expected "stroke" to be a valid named color ("orange") or a hex code ("#f0ff3a")`) + if !color.ValidColor(value) { + return errors.New(`expected "stroke" to be a valid named color ("orange"), a hex code ("#f0ff3a"), or a gradient ("linear-gradient(red, blue)")`) } s.Stroke.Value = value case "fill": if s.Fill == nil { break } - if !go2.Contains(color.NamedColors, strings.ToLower(value)) && !color.ColorHexRegex.MatchString(value) { - return errors.New(`expected "fill" to be a valid named color ("orange") or a hex code ("#f0ff3a")`) + if !color.ValidColor(value) { + return errors.New(`expected "fill" to be a valid named color ("orange"), a hex code ("#f0ff3a"), or a gradient ("linear-gradient(red, blue)")`) } s.Fill.Value = value case "fill-pattern": @@ -348,8 +348,8 @@ func (s *Style) Apply(key, value string) error { if s.FontColor == nil { break } - if !go2.Contains(color.NamedColors, strings.ToLower(value)) && !color.ColorHexRegex.MatchString(value) { - return errors.New(`expected "font-color" to be a valid named color ("orange") or a hex code ("#f0ff3a")`) + if !color.ValidColor(value) { + return errors.New(`expected "font-color" to be a valid named color ("orange"), a hex code ("#f0ff3a"), or a gradient ("linear-gradient(red, blue)")`) } s.FontColor.Value = value case "animated": diff --git a/d2renderers/d2svg/d2svg.go b/d2renderers/d2svg/d2svg.go index 7ffa5c1c7b..386d6e9c8c 100644 --- a/d2renderers/d2svg/d2svg.go +++ b/d2renderers/d2svg/d2svg.go @@ -706,6 +706,11 @@ func renderDoubleOval(tl *geo.Point, width, height float64, fill, fillStroke, st return renderOval(tl, width, height, fill, fillStroke, stroke, style) + renderOval(innerTL, width-10, height-10, fill, "", stroke, style) } +func defineGradients(writer io.Writer, cssGradient string) { + gradient, _ := color.ParseGradient(cssGradient) + fmt.Fprint(writer, fmt.Sprintf(`%s`, color.GradientToSVG(gradient))) +} + func defineShadowFilter(writer io.Writer) { fmt.Fprint(writer, ` @@ -1824,6 +1829,29 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) { } } + if color.IsGradient(diagram.Root.Fill) { + defineGradients(buf, diagram.Root.Fill) + } + if color.IsGradient(diagram.Root.Stroke) { + defineGradients(buf, diagram.Root.Stroke) + } + for _, s := range diagram.Shapes { + if color.IsGradient(s.Fill) { + defineGradients(buf, s.Fill) + } + if color.IsGradient(s.Stroke) { + defineGradients(buf, s.Stroke) + } + if color.IsGradient(s.Color) { + defineGradients(buf, s.Color) + } + } + for _, c := range diagram.Connections { + if color.IsGradient(c.Stroke) { + defineGradients(buf, c.Stroke) + } + } + // Apply hash on IDs for targeting, to be specific for this diagram diagramHash, err := diagram.HashID() if err != nil { diff --git a/d2themes/element.go b/d2themes/element.go index 4cc6d96952..43998b0a7c 100644 --- a/d2themes/element.go +++ b/d2themes/element.go @@ -178,11 +178,17 @@ func (el *ThemableElement) Render() string { if color.IsThemeColor(el.Stroke) { class += fmt.Sprintf(" stroke-%s", el.Stroke) } else if len(el.Stroke) > 0 { + if color.IsGradient(el.Stroke) { + el.Stroke = fmt.Sprintf("url('#%s')", color.UniqueGradientID(el.Stroke)) + } out += fmt.Sprintf(` stroke="%s"`, el.Stroke) } if color.IsThemeColor(el.Fill) { class += fmt.Sprintf(" fill-%s", el.Fill) } else if len(el.Fill) > 0 { + if color.IsGradient(el.Fill) { + el.Fill = fmt.Sprintf("url('#%s')", color.UniqueGradientID(el.Fill)) + } out += fmt.Sprintf(` fill="%s"`, el.Fill) } if color.IsThemeColor(el.BackgroundColor) { diff --git a/e2etests/testdata/txtar/gradient/dagre/board.exp.json b/e2etests/testdata/txtar/gradient/dagre/board.exp.json new file mode 100644 index 0000000000..acf4fd6f65 --- /dev/null +++ b/e2etests/testdata/txtar/gradient/dagre/board.exp.json @@ -0,0 +1,178 @@ +{ + "name": "", + "isFolderOnly": false, + "fontFamily": "SourceSansPro", + "shapes": [ + { + "id": "gradient", + "type": "rectangle", + "pos": { + "x": 0, + "y": 0 + }, + "width": 106, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "linear-gradient(#f69d3c, #3f87a6)", + "stroke": "linear-gradient(to top right, red, blue)", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "gradient", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "radial-gradient(red, yellow, green, cyan, blue)", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 61, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "colors", + "type": "rectangle", + "pos": { + "x": 9, + "y": 166 + }, + "width": 89, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "linear-gradient(45deg, rgba(255,0,0,0.5) 0%, rgba(0,0,255,0.5) 100%)", + "stroke": "linear-gradient(to right, red, blue, green)", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "colors", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "linear-gradient(to bottom right, red 0%, yellow 25%, green 50%, cyan 75%, blue 100%)", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 44, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + } + ], + "connections": [ + { + "id": "(gradient -> colors)[0]", + "src": "gradient", + "srcArrow": "none", + "dst": "colors", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "route": [ + { + "x": 53, + "y": 66 + }, + { + "x": 53, + "y": 106 + }, + { + "x": 53, + "y": 126 + }, + { + "x": 53, + "y": 166 + } + ], + "isCurve": true, + "animated": false, + "tooltip": "", + "icon": null, + "zIndex": 0 + } + ], + "root": { + "id": "", + "type": "", + "pos": { + "x": 0, + "y": 0 + }, + "width": 0, + "height": 0, + "opacity": 0, + "strokeDash": 0, + "strokeWidth": 0, + "borderRadius": 0, + "fill": "radial-gradient(circle, white 0%, #8A2BE2 60%, #4B0082 100%)", + "stroke": "", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "", + "fontSize": 0, + "fontFamily": "", + "language": "", + "color": "", + "italic": false, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "zIndex": 0, + "level": 0 + } +} diff --git a/e2etests/testdata/txtar/gradient/dagre/sketch.exp.svg b/e2etests/testdata/txtar/gradient/dagre/sketch.exp.svg new file mode 100644 index 0000000000..d985e628c8 --- /dev/null +++ b/e2etests/testdata/txtar/gradient/dagre/sketch.exp.svg @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +gradientcolors + + + + \ No newline at end of file diff --git a/e2etests/testdata/txtar/gradient/elk/board.exp.json b/e2etests/testdata/txtar/gradient/elk/board.exp.json new file mode 100644 index 0000000000..7803d98f01 --- /dev/null +++ b/e2etests/testdata/txtar/gradient/elk/board.exp.json @@ -0,0 +1,169 @@ +{ + "name": "", + "isFolderOnly": false, + "fontFamily": "SourceSansPro", + "shapes": [ + { + "id": "gradient", + "type": "rectangle", + "pos": { + "x": 12, + "y": 12 + }, + "width": 106, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "linear-gradient(#f69d3c, #3f87a6)", + "stroke": "linear-gradient(to top right, red, blue)", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "gradient", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "radial-gradient(red, yellow, green, cyan, blue)", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 61, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "colors", + "type": "rectangle", + "pos": { + "x": 20, + "y": 148 + }, + "width": 89, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "linear-gradient(45deg, rgba(255,0,0,0.5) 0%, rgba(0,0,255,0.5) 100%)", + "stroke": "linear-gradient(to right, red, blue, green)", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "colors", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "linear-gradient(to bottom right, red 0%, yellow 25%, green 50%, cyan 75%, blue 100%)", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 44, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + } + ], + "connections": [ + { + "id": "(gradient -> colors)[0]", + "src": "gradient", + "srcArrow": "none", + "dst": "colors", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "route": [ + { + "x": 65, + "y": 78 + }, + { + "x": 65, + "y": 148 + } + ], + "animated": false, + "tooltip": "", + "icon": null, + "zIndex": 0 + } + ], + "root": { + "id": "", + "type": "", + "pos": { + "x": 0, + "y": 0 + }, + "width": 0, + "height": 0, + "opacity": 0, + "strokeDash": 0, + "strokeWidth": 0, + "borderRadius": 0, + "fill": "radial-gradient(circle, white 0%, #8A2BE2 60%, #4B0082 100%)", + "stroke": "", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "", + "fontSize": 0, + "fontFamily": "", + "language": "", + "color": "", + "italic": false, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "zIndex": 0, + "level": 0 + } +} diff --git a/e2etests/testdata/txtar/gradient/elk/sketch.exp.svg b/e2etests/testdata/txtar/gradient/elk/sketch.exp.svg new file mode 100644 index 0000000000..e7ea01ea8b --- /dev/null +++ b/e2etests/testdata/txtar/gradient/elk/sketch.exp.svg @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +gradientcolors + + + + \ No newline at end of file diff --git a/e2etests/txtar.txt b/e2etests/txtar.txt index 3a95203ba5..d669dcc18a 100644 --- a/e2etests/txtar.txt +++ b/e2etests/txtar.txt @@ -455,3 +455,17 @@ bob -> alice: The ability to play bridge or\ngolf as if they were games. โ—Ž: |md โ—Ž foo bar | + +-- gradient -- +style.fill: "radial-gradient(circle, white 0%, #8A2BE2 60%, #4B0082 100%)" +gradient: { + style.fill: "linear-gradient(#f69d3c, #3f87a6)" + style.stroke: "linear-gradient(to top right, red, blue)" + style.font-color: "radial-gradient(red, yellow, green, cyan, blue)" +} +colors: { + style.fill: "linear-gradient(45deg, rgba(255,0,0,0.5) 0%, rgba(0,0,255,0.5) 100%)" + style.stroke: "linear-gradient(to right, red, blue, green)" + style.font-color: "linear-gradient(to bottom right, red 0%, yellow 25%, green 50%, cyan 75%, blue 100%)" +} +gradient -> colors diff --git a/lib/color/color.go b/lib/color/color.go index 7a7a4d9c1c..d826a2eee1 100644 --- a/lib/color/color.go +++ b/lib/color/color.go @@ -9,6 +9,7 @@ import ( "github.com/lucasb-eyer/go-colorful" "github.com/mazznoer/csscolorparser" + "oss.terrastruct.com/util-go/go2" ) var themeColorRegex = regexp.MustCompile(`^(N[1-7]|B[1-6]|AA[245]|AB[45])$`) @@ -503,3 +504,11 @@ var NamedColors = []string{ } var ColorHexRegex = regexp.MustCompile(`^#(([0-9a-fA-F]{2}){3}|([0-9a-fA-F]){3})$`) + +func ValidColor(color string) bool { + if !go2.Contains(NamedColors, strings.ToLower(color)) && !ColorHexRegex.MatchString(color) && !IsGradient(color) { + return false + } + + return true +} diff --git a/lib/color/gradient.go b/lib/color/gradient.go new file mode 100644 index 0000000000..29f29acc3d --- /dev/null +++ b/lib/color/gradient.go @@ -0,0 +1,248 @@ +package color + +import ( + "crypto/sha1" + "encoding/hex" + "errors" + "fmt" + "math" + "regexp" + "strconv" + "strings" +) + +type Gradient struct { + Type string + Direction string + ColorStops []ColorStop + ID string +} + +type ColorStop struct { + Color string + Position string +} + +func ParseGradient(cssGradient string) (Gradient, error) { + cssGradient = strings.TrimSpace(cssGradient) + + re := regexp.MustCompile(`^(linear-gradient|radial-gradient)\((.*)\)$`) + matches := re.FindStringSubmatch(cssGradient) + if matches == nil { + return Gradient{}, errors.New("invalid gradient syntax") + } + + gradientType := matches[1] + params := matches[2] + + gradient := Gradient{ + Type: strings.TrimSuffix(gradientType, "-gradient"), + } + + paramList := splitParams(params) + + if len(paramList) == 0 { + return Gradient{}, errors.New("no parameters in gradient") + } + + firstParam := strings.TrimSpace(paramList[0]) + + if gradient.Type == "linear" && (strings.HasSuffix(firstParam, "deg") || strings.HasPrefix(firstParam, "to ")) { + gradient.Direction = firstParam + colorStops := paramList[1:] + if len(colorStops) == 0 { + return Gradient{}, errors.New("no color stops in gradient") + } + gradient.ColorStops = parseColorStops(colorStops) + } else if gradient.Type == "radial" && (firstParam == "circle" || firstParam == "ellipse") { + gradient.Direction = firstParam + colorStops := paramList[1:] + if len(colorStops) == 0 { + return Gradient{}, errors.New("no color stops in gradient") + } + gradient.ColorStops = parseColorStops(colorStops) + } else { + gradient.ColorStops = parseColorStops(paramList) + } + gradient.ID = UniqueGradientID(cssGradient) + + return gradient, nil +} + +func splitParams(params string) []string { + var parts []string + var buf strings.Builder + nesting := 0 + + for _, r := range params { + switch r { + case ',': + if nesting == 0 { + parts = append(parts, buf.String()) + buf.Reset() + continue + } + case '(': + nesting++ + case ')': + if nesting > 0 { + nesting-- + } + } + buf.WriteRune(r) + } + if buf.Len() > 0 { + parts = append(parts, buf.String()) + } + return parts +} + +func parseColorStops(params []string) []ColorStop { + var colorStops []ColorStop + for _, p := range params { + p = strings.TrimSpace(p) + parts := strings.Fields(p) + + switch len(parts) { + case 1: + colorStops = append(colorStops, ColorStop{Color: parts[0]}) + case 2: + colorStops = append(colorStops, ColorStop{Color: parts[0], Position: parts[1]}) + default: + continue + } + } + return colorStops +} + +func GradientToSVG(gradient Gradient) string { + switch gradient.Type { + case "linear": + return LinearGradientToSVG(gradient) + case "radial": + return RadialGradientToSVG(gradient) + default: + return "" + } +} + +func LinearGradientToSVG(gradient Gradient) string { + x1, y1, x2, y2 := parseLinearGradientDirection(gradient.Direction) + + var sb strings.Builder + sb.WriteString(fmt.Sprintf(``, x1, y1, x2, y2)) + sb.WriteString("\n") + + totalStops := len(gradient.ColorStops) + for i, cs := range gradient.ColorStops { + offset := cs.Position + if offset == "" { + offsetValue := float64(i) / float64(totalStops-1) * 100 + offset = fmt.Sprintf("%.2f%%", offsetValue) + } + sb.WriteString(fmt.Sprintf(``, offset, cs.Color)) + sb.WriteString("\n") + } + sb.WriteString(``) + return sb.String() +} + +func parseLinearGradientDirection(direction string) (x1, y1, x2, y2 string) { + x1, y1, x2, y2 = "0%", "0%", "0%", "100%" + + direction = strings.TrimSpace(direction) + if strings.HasPrefix(direction, "to ") { + dir := strings.TrimPrefix(direction, "to ") + dir = strings.TrimSpace(dir) + parts := strings.Fields(dir) + xStart, yStart := "50%", "50%" + xEnd, yEnd := "50%", "50%" + + xDirSet, yDirSet := false, false + + for _, part := range parts { + switch part { + case "left": + xStart = "100%" + xEnd = "0%" + xDirSet = true + case "right": + xStart = "0%" + xEnd = "100%" + xDirSet = true + case "top": + yStart = "100%" + yEnd = "0%" + yDirSet = true + case "bottom": + yStart = "0%" + yEnd = "100%" + yDirSet = true + } + } + + if !xDirSet { + xStart = "50%" + xEnd = "50%" + } + + if !yDirSet { + yStart = "50%" + yEnd = "50%" + } + + x1, y1 = xStart, yStart + x2, y2 = xEnd, yEnd + } else if strings.HasSuffix(direction, "deg") { + angleStr := strings.TrimSuffix(direction, "deg") + angle, err := strconv.ParseFloat(strings.TrimSpace(angleStr), 64) + if err == nil { + cssAngle := angle + svgAngle := (90 - cssAngle) * (math.Pi / 180) + + x1f := 50.0 + y1f := 50.0 + x2f := x1f + 50*math.Cos(svgAngle) + y2f := y1f + 50*math.Sin(svgAngle) + + x1 = fmt.Sprintf("%.2f%%", x1f) + y1 = fmt.Sprintf("%.2f%%", y1f) + x2 = fmt.Sprintf("%.2f%%", x2f) + y2 = fmt.Sprintf("%.2f%%", y2f) + } + } + + return x1, y1, x2, y2 +} + +func RadialGradientToSVG(gradient Gradient) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf(``, gradient.ID)) + sb.WriteString("\n") + totalStops := len(gradient.ColorStops) + for i, cs := range gradient.ColorStops { + offset := cs.Position + if offset == "" { + offsetValue := float64(i) / float64(totalStops-1) * 100 + offset = fmt.Sprintf("%.2f%%", offsetValue) + } + sb.WriteString(fmt.Sprintf(``, offset, cs.Color)) + sb.WriteString("\n") + } + sb.WriteString(``) + return sb.String() +} + +func UniqueGradientID(cssGradient string) string { + h := sha1.New() + h.Write([]byte(cssGradient)) + hash := hex.EncodeToString(h.Sum(nil)) + return "grad-" + hash +} + +var GradientRegex = regexp.MustCompile(`^(linear|radial)-gradient\((.+)\)$`) + +func IsGradient(color string) bool { + return GradientRegex.MatchString(color) +} From e6b1a72a6388d11998c8f3f3cdb59475a876c74b Mon Sep 17 00:00:00 2001 From: Alexander Wang Date: Fri, 27 Sep 2024 15:55:55 -0600 Subject: [PATCH 2/2] next --- ci/release/changelogs/next.md | 1 + lib/color/color.go | 1 + 2 files changed, 2 insertions(+) diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md index 460e4b0174..71d2d53e2d 100644 --- a/ci/release/changelogs/next.md +++ b/ci/release/changelogs/next.md @@ -6,6 +6,7 @@ - Autoformat: Reserved keywords are formatted to be lowercase [#2098](https://github.com/terrastruct/d2/pull/2098) - Misc: characters in the unicode range for Latin-1 and geometric shapes are measured more accurately [#2100](https://github.com/terrastruct/d2/pull/2100) - Imports: can now import from absolute file paths [#2113](https://github.com/terrastruct/d2/pull/2113) +- Render: linear and radial gradients are now available for `fill`, `stroke` and `font-color` [#2120](https://github.com/terrastruct/d2/pull/2120) #### Improvements ๐Ÿงน diff --git a/lib/color/color.go b/lib/color/color.go index d826a2eee1..cbd8971f70 100644 --- a/lib/color/color.go +++ b/lib/color/color.go @@ -9,6 +9,7 @@ import ( "github.com/lucasb-eyer/go-colorful" "github.com/mazznoer/csscolorparser" + "oss.terrastruct.com/util-go/go2" )