diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md
index 460e4b017..71d2d53e2 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/d2graph/d2graph.go b/d2graph/d2graph.go
index ba6053a89..b9b27b64c 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 7ffa5c1c7..386d6e9c8 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 4cc6d9695..43998b0a7 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 000000000..acf4fd6f6
--- /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 000000000..d985e628c
--- /dev/null
+++ b/e2etests/testdata/txtar/gradient/dagre/sketch.exp.svg
@@ -0,0 +1,125 @@
+
\ 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 000000000..7803d98f0
--- /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 000000000..e7ea01ea8
--- /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 3a95203ba..d669dcc18 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 7a7a4d9c1..cbd8971f7 100644
--- a/lib/color/color.go
+++ b/lib/color/color.go
@@ -9,6 +9,8 @@ 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 +505,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 000000000..29f29acc3
--- /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)
+}