diff --git a/d2renderers/d2sketch/fillpattern.svg b/d2renderers/d2sketch/fillpattern.svg deleted file mode 100644 index 0bcc3f228..000000000 --- a/d2renderers/d2sketch/fillpattern.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/d2renderers/d2sketch/sketch.go b/d2renderers/d2sketch/sketch.go index 19c68c4a8..e5ceab8aa 100644 --- a/d2renderers/d2sketch/sketch.go +++ b/d2renderers/d2sketch/sketch.go @@ -17,9 +17,6 @@ import ( "oss.terrastruct.com/util-go/go2" ) -//go:embed fillpattern.svg -var fillPattern string - //go:embed rough.js var roughJS string @@ -51,17 +48,23 @@ func InitSketchVM() (*Runner, error) { return &r, nil } -// DefineFillPattern adds a reusable pattern that is overlayed on shapes with +// DefineFillPatterns adds reusable patterns that are overlayed on shapes with // fill. This gives it a subtle streaky effect that subtly looks hand-drawn but // not distractingly so. -func DefineFillPattern() string { - return fmt.Sprintf(` - - %s - -`, fillPattern) +func DefineFillPatterns() string { + out := "" + out += defineFillPattern("bright", "rgba(0, 0, 0, 0.1)") + out += defineFillPattern("normal", "rgba(0, 0, 0, 0.16)") + out += defineFillPattern("dark", "rgba(0, 0, 0, 0.32)") + out += defineFillPattern("darker", "rgba(255, 255, 255, 0.24)") + out += "" + return out +} + +func defineFillPattern(luminanceCategory, fill string) string { + return fmt.Sprintf(` + + `, luminanceCategory, fill) } func Rect(r *Runner, shape d2target.Shape) (string, error) { @@ -85,10 +88,17 @@ func Rect(r *Runner, shape d2target.Shape) (string, error) { pathEl.D = p output += pathEl.Render() } - output += fmt.Sprintf( - ``, - shape.Pos.X, shape.Pos.Y, shape.Width, shape.Height, - ) + + sketchOEl := svg_style.NewThemableElement("rect") + sketchOEl.Transform = fmt.Sprintf("translate(%d %d)", shape.Pos.X, shape.Pos.Y) + sketchOEl.Width = float64(shape.Width) + sketchOEl.Height = float64(shape.Height) + renderedSO, err := svg_style.NewThemableSketchOverlay(sketchOEl, pathEl.Fill).Render() + if err != nil { + return "", err + } + output += renderedSO + return output, nil } @@ -113,10 +123,20 @@ func Oval(r *Runner, shape d2target.Shape) (string, error) { pathEl.D = p output += pathEl.Render() } - output += fmt.Sprintf( - ``, - shape.Pos.X+shape.Width/2, shape.Pos.Y+shape.Height/2, shape.Width/2, shape.Height/2, - ) + + soElement := svg_style.NewThemableElement("ellipse") + soElement.Transform = fmt.Sprintf("translate(%d %d)", shape.Pos.X+shape.Width/2, shape.Pos.Y+shape.Height/2) + soElement.Rx = float64(shape.Width / 2) + soElement.Ry = float64(shape.Height / 2) + renderedSO, err := svg_style.NewThemableSketchOverlay( + soElement, + pathEl.Fill, + ).Render() + if err != nil { + return "", err + } + output += renderedSO + return output, nil } @@ -142,11 +162,18 @@ func Paths(r *Runner, shape d2target.Shape, paths []string) (string, error) { pathEl.D = p output += pathEl.Render() } + + soElement := svg_style.NewThemableElement("path") for _, p := range sketchPaths { - output += fmt.Sprintf( - ``, - p, - ) + soElement.D = p + renderedSO, err := svg_style.NewThemableSketchOverlay( + soElement, + pathEl.Fill, + ).Render() + if err != nil { + return "", err + } + output += renderedSO } } return output, nil @@ -299,10 +326,17 @@ func Table(r *Runner, shape d2target.Shape) (string, error) { output += pathEl.Render() } } - output += fmt.Sprintf( - ``, - shape.Pos.X, shape.Pos.Y, shape.Width, shape.Height, - ) + + sketchOEl := svg_style.NewThemableElement("rect") + sketchOEl.Transform = fmt.Sprintf("translate(%d %d)", shape.Pos.X, shape.Pos.Y) + sketchOEl.Width = float64(shape.Width) + sketchOEl.Height = float64(shape.Height) + renderedSO, err := svg_style.NewThemableSketchOverlay(sketchOEl, pathEl.Fill).Render() + if err != nil { + return "", err + } + output += renderedSO + return output, nil } @@ -353,10 +387,15 @@ func Class(r *Runner, shape d2target.Shape) (string, error) { output += pathEl.Render() } - output += fmt.Sprintf( - ``, - shape.Pos.X, shape.Pos.Y, shape.Width, headerBox.Height, - ) + sketchOEl := svg_style.NewThemableElement("rect") + sketchOEl.Transform = fmt.Sprintf("translate(%d %d)", shape.Pos.X, shape.Pos.Y) + sketchOEl.Width = float64(shape.Width) + sketchOEl.Height = headerBox.Height + renderedSO, err := svg_style.NewThemableSketchOverlay(sketchOEl, pathEl.Fill).Render() + if err != nil { + return "", err + } + output += renderedSO if shape.Label != "" { tl := label.InsideMiddleCenter.GetPointOnBox( diff --git a/d2renderers/d2svg/d2svg.go b/d2renderers/d2svg/d2svg.go index e2b4d23c3..2c1b82bf7 100644 --- a/d2renderers/d2svg/d2svg.go +++ b/d2renderers/d2svg/d2svg.go @@ -56,9 +56,6 @@ var LinkIcon string //go:embed style.css var baseStylesheet string -//go:embed sketchstyle.css -var sketchStyleCSS string - //go:embed github-markdown.css var mdCSS string @@ -1248,12 +1245,11 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) { backgroundEl.Fill = color.N7 // generate elements that will be appended to the SVG tag - themeStylesheet := themeCSS(themeID, darkThemeID) - sketchStylesheet := "" - if sketchRunner != nil { - sketchStylesheet = "\n" + sketchStyleCSS + themeStylesheet, err := themeCSS(themeID, darkThemeID) + if err != nil { + return nil, err } - svgOut := fmt.Sprintf(``, baseStylesheet, themeStylesheet, sketchStylesheet) + svgOut := fmt.Sprintf(``, baseStylesheet, themeStylesheet) // this script won't run in --watch mode because script tags are ignored when added via el.innerHTML = element // https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML svgOut += fmt.Sprintf(``, fitToScreenScript) @@ -1268,7 +1264,7 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) { svgOut += fmt.Sprintf(``, mdCSS) } if sketchRunner != nil { - svgOut += d2sketch.DefineFillPattern() + svgOut += d2sketch.DefineFillPatterns() } svgOut += embedFonts(buf, diagram.FontFamily) @@ -1282,20 +1278,29 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) { return []byte(docRendered), nil } -func themeCSS(themeID, darkThemeID int64) (stylesheet string) { - out := singleThemeRulesets(themeID) - - if darkThemeID != math.MaxInt64 { - out += fmt.Sprintf("@media screen and (prefers-color-scheme:dark){%s}", singleThemeRulesets(darkThemeID)) +// TODO include only colors that are being used to reduce size +func themeCSS(themeID, darkThemeID int64) (stylesheet string, err error) { + out, err := singleThemeRulesets(themeID) + if err != nil { + return "", err } - return out + if darkThemeID != math.MaxInt64 { + darkOut, err := singleThemeRulesets(darkThemeID) + if err != nil { + return "", err + } + out += fmt.Sprintf("@media screen and (prefers-color-scheme:dark){%s}", darkOut) + } + + return out, nil } -func singleThemeRulesets(themeID int64) (rulesets string) { +func singleThemeRulesets(themeID int64) (rulesets string, err error) { out := "" theme := d2themescatalog.Find(themeID) + // Global theme colors for _, property := range []string{"fill", "stroke", "background-color", "color"} { out += fmt.Sprintf(".%s-N1{%s:%s;}.%s-N2{%s:%s;}.%s-N3{%s:%s;}.%s-N4{%s:%s;}.%s-N5{%s:%s;}.%s-N6{%s:%s;}.%s-N7{%s:%s;}.%s-B1{%s:%s;}.%s-B2{%s:%s;}.%s-B3{%s:%s;}.%s-B4{%s:%s;}.%s-B5{%s:%s;}.%s-B6{%s:%s;}.%s-AA2{%s:%s;}.%s-AA4{%s:%s;}.%s-AA5{%s:%s;}.%s-AB4{%s:%s;}.%s-AB5{%s:%s;}", property, property, theme.Colors.Neutrals.N1, @@ -1319,6 +1324,7 @@ func singleThemeRulesets(themeID int64) (rulesets string) { ) } + // Markdown specific rulesets out += 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;}", theme.Colors.Neutrals.N1, theme.Colors.Neutrals.N2, theme.Colors.Neutrals.N3, theme.Colors.Neutrals.N7, theme.Colors.Neutrals.N6, @@ -1329,7 +1335,91 @@ func singleThemeRulesets(themeID int64) (rulesets string) { "red", ) - return out + // Sketch style specific rulesets + lc, err := color.LuminanceCategory(theme.Colors.B1) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.B1, lc, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.B2) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.B2, lc, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.B3) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.B3, lc, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.B4) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.B4, lc, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.B5) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.B5, lc, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.B6) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.B6, lc, blendMode(lc)) + + lc, err = color.LuminanceCategory(theme.Colors.Neutrals.N1) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.N1, lc, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.Neutrals.N2) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.N2, lc, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.Neutrals.N3) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.N3, lc, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.Neutrals.N4) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.N4, lc, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.Neutrals.N5) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.N5, lc, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.Neutrals.N6) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.N6, lc, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.Neutrals.N7) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.N7, lc, blendMode(lc)) + + // TODO Add the rest of the colors so we can allow the user to specify theme colors too + + return out, nil +} + +func blendMode(lc string) string { + switch lc { + case "bright": + return "darken" + case "normal": + return "color-burn" + case "dark": + return "overlay" + case "darker": + return "lighten" + } + panic("invalid luminance category") } type DiagramObject interface { diff --git a/d2renderers/d2svg/sketchstyle.css b/d2renderers/d2svg/sketchstyle.css deleted file mode 100644 index 33654aba2..000000000 --- a/d2renderers/d2svg/sketchstyle.css +++ /dev/null @@ -1,4 +0,0 @@ -.sketch-overlay { - fill: url(#streaks); - mix-blend-mode: overlay; -} diff --git a/d2renderers/d2svg/style.css b/d2renderers/d2svg/style.css index 7312ba8de..cb85e5a9d 100644 --- a/d2renderers/d2svg/style.css +++ b/d2renderers/d2svg/style.css @@ -10,3 +10,20 @@ mix-Blend-mode: multiply; opacity: 0.5; } + +.sketch-overlay-bright { + fill: url(#streaks-bright); + mix-blend-mode: darken; +} +.sketch-overlay-normal { + fill: url(#streaks-normal); + mix-blend-mode: color-burn; +} +.sketch-overlay-dark { + fill: url(#streaks-dark); + mix-blend-mode: overlay; +} +.sketch-overlay-darker { + fill: url(#streaks-darker); + mix-blend-mode: lighten; +} diff --git a/lib/color/color.go b/lib/color/color.go index 1bb7d854d..ae3a07f55 100644 --- a/lib/color/color.go +++ b/lib/color/color.go @@ -1,16 +1,21 @@ package color import ( + "fmt" "regexp" "github.com/lucasb-eyer/go-colorful" "github.com/mazznoer/csscolorparser" ) -var themeRegex = regexp.MustCompile("^B[1-6]$") +var themeColorRegex = regexp.MustCompile(`^N[1-7]|B[1-6]|AA[245]|AB[45]$`) + +func IsThemeColor(colorString string) bool { + return themeColorRegex.Match([]byte(colorString)) +} func Darken(colorString string) (string, error) { - if themeRegex.MatchString(colorString) { + if IsThemeColor(colorString) { switch colorString[1] { case '1': return B1, nil @@ -24,13 +29,15 @@ func Darken(colorString string) (string, error) { return B4, nil case '6': return B5, nil + default: + return "", fmt.Errorf("darkening color \"%s\" is not yet supported", colorString) // TODO Add the rest of the colors so we can allow the user to specify theme colors too } } - return DarkenCSS(colorString) + return darkenCSS(colorString) } -func DarkenCSS(colorString string) (string, error) { +func darkenCSS(colorString string) (string, error) { c, err := csscolorparser.Parse(colorString) if err != nil { return "", err @@ -40,6 +47,38 @@ func DarkenCSS(colorString string) (string, error) { return colorful.Hsl(h, s, l-.1).Clamped().Hex(), nil } +func LuminanceCategory(colorString string) (string, error) { + l, err := Luminance(colorString) + if err != nil { + return "", err + } + + switch { + case l >= .88: + return "bright", nil + case l >= .55: + return "normal", nil + case l >= .30: + return "dark", nil + default: + return "darker", nil + } +} + +func Luminance(colorString string) (float64, error) { + c, err := csscolorparser.Parse(colorString) + if err != nil { + return 0, err + } + + l := float64( + float64(0.299)*float64(c.R) + + float64(0.587)*float64(c.G) + + float64(0.114)*float64(c.B), + ) + return l, nil +} + const ( N1 = "N1" N2 = "N2" diff --git a/lib/svg/style/common.go b/lib/svg/style/common.go new file mode 100644 index 000000000..6ff9fe302 --- /dev/null +++ b/lib/svg/style/common.go @@ -0,0 +1,52 @@ +package style + +import ( + "fmt" + + "oss.terrastruct.com/d2/d2target" + "oss.terrastruct.com/d2/lib/svg" +) + +func ShapeStyle(shape d2target.Shape) string { + out := "" + + out += fmt.Sprintf(`opacity:%f;`, shape.Opacity) + out += fmt.Sprintf(`stroke-width:%d;`, shape.StrokeWidth) + if shape.StrokeDash != 0 { + dashSize, gapSize := svg.GetStrokeDashAttributes(float64(shape.StrokeWidth), shape.StrokeDash) + out += fmt.Sprintf(`stroke-dasharray:%f,%f;`, dashSize, gapSize) + } + + return out +} + +func ShapeTheme(shape d2target.Shape) (fill, stroke string) { + if shape.Type == d2target.ShapeSQLTable || shape.Type == d2target.ShapeClass { + // Fill is used for header fill in these types + // This fill property is just background of rows + fill = shape.Stroke + // Stroke (border) of these shapes should match the header fill + stroke = shape.Fill + } else { + fill = shape.Fill + stroke = shape.Stroke + } + return fill, stroke +} + +func ConnectionStyle(connection d2target.Connection) string { + out := "" + + out += fmt.Sprintf(`opacity:%f;`, connection.Opacity) + out += fmt.Sprintf(`stroke-width:%d;`, connection.StrokeWidth) + if connection.StrokeDash != 0 { + dashSize, gapSize := svg.GetStrokeDashAttributes(float64(connection.StrokeWidth), connection.StrokeDash) + out += fmt.Sprintf(`stroke-dasharray:%f,%f;`, dashSize, gapSize) + } + + return out +} + +func ConnectionTheme(connection d2target.Connection) (stroke string) { + return connection.Stroke +} diff --git a/lib/svg/style/themable_element.go b/lib/svg/style/element.go similarity index 70% rename from lib/svg/style/themable_element.go rename to lib/svg/style/element.go index f457705c3..a8edaf37d 100644 --- a/lib/svg/style/themable_element.go +++ b/lib/svg/style/element.go @@ -3,57 +3,10 @@ package style import ( "fmt" "math" - "regexp" - "oss.terrastruct.com/d2/d2target" "oss.terrastruct.com/d2/lib/color" - "oss.terrastruct.com/d2/lib/svg" ) -func ShapeStyle(shape d2target.Shape) string { - out := "" - - out += fmt.Sprintf(`opacity:%f;`, shape.Opacity) - out += fmt.Sprintf(`stroke-width:%d;`, shape.StrokeWidth) - if shape.StrokeDash != 0 { - dashSize, gapSize := svg.GetStrokeDashAttributes(float64(shape.StrokeWidth), shape.StrokeDash) - out += fmt.Sprintf(`stroke-dasharray:%f,%f;`, dashSize, gapSize) - } - - return out -} - -func ShapeTheme(shape d2target.Shape) (fill, stroke string) { - if shape.Type == d2target.ShapeSQLTable || shape.Type == d2target.ShapeClass { - // Fill is used for header fill in these types - // This fill property is just background of rows - fill = shape.Stroke - // Stroke (border) of these shapes should match the header fill - stroke = shape.Fill - } else { - fill = shape.Fill - stroke = shape.Stroke - } - return fill, stroke -} - -func ConnectionStyle(connection d2target.Connection) string { - out := "" - - out += fmt.Sprintf(`opacity:%f;`, connection.Opacity) - out += fmt.Sprintf(`stroke-width:%d;`, connection.StrokeWidth) - if connection.StrokeDash != 0 { - dashSize, gapSize := svg.GetStrokeDashAttributes(float64(connection.StrokeWidth), connection.StrokeDash) - out += fmt.Sprintf(`stroke-dasharray:%f,%f;`, dashSize, gapSize) - } - - return out -} - -func ConnectionTheme(connection d2target.Connection) (stroke string) { - return connection.Stroke -} - // ThemableElement is a helper class for creating new XML elements. // This should be preffered over formatting and must be used // whenever Fill, Stroke, BackgroundColor or Color contains a color from a theme. @@ -128,8 +81,6 @@ func NewThemableElement(tag string) *ThemableElement { } func (el *ThemableElement) Render() string { - re := regexp.MustCompile(`^N[1-7]|B[1-6]|AA[245]|AB[45]$`) - out := "<" + el.tag if el.X != math.MaxFloat64 { @@ -195,22 +146,22 @@ func (el *ThemableElement) Render() string { style := el.Style // Add class {property}-{theme color} if the color is from a theme, set the property otherwise - if re.MatchString(el.Stroke) { + if color.IsThemeColor(el.Stroke) { class += fmt.Sprintf(" stroke-%s", el.Stroke) } else if len(el.Stroke) > 0 { out += fmt.Sprintf(` stroke="%s"`, el.Stroke) } - if re.MatchString(el.Fill) { + if color.IsThemeColor(el.Fill) { class += fmt.Sprintf(" fill-%s", el.Fill) } else if len(el.Fill) > 0 { out += fmt.Sprintf(` fill="%s"`, el.Fill) } - if re.MatchString(el.BackgroundColor) { + if color.IsThemeColor(el.BackgroundColor) { class += fmt.Sprintf(" background-color-%s", el.BackgroundColor) } else if len(el.BackgroundColor) > 0 { out += fmt.Sprintf(` background-color="%s"`, el.BackgroundColor) } - if re.MatchString(el.Color) { + if color.IsThemeColor(el.Color) { class += fmt.Sprintf(" color-%s", el.Color) } else if len(el.Color) > 0 { out += fmt.Sprintf(` color="%s"`, el.Color) diff --git a/lib/svg/style/sketch_overlay.go b/lib/svg/style/sketch_overlay.go new file mode 100644 index 000000000..1c488be8f --- /dev/null +++ b/lib/svg/style/sketch_overlay.go @@ -0,0 +1,34 @@ +package style + +import ( + "fmt" + + "oss.terrastruct.com/d2/lib/color" +) + +type ThemableSketchOverlay struct { + el *ThemableElement + fill string +} + +func NewThemableSketchOverlay(el *ThemableElement, fill string) *ThemableSketchOverlay { + return &ThemableSketchOverlay{ + el, + fill, + } +} + +// WARNING: Do not reuse the element afterwards as this function changes the Class propery +func (o *ThemableSketchOverlay) Render() (string, error) { + if color.IsThemeColor(o.fill) { + o.el.Class += fmt.Sprintf(" sketch-overlay-%s", o.fill) // e.g. sketch-overlay-B3 + } else { + lc, err := color.LuminanceCategory(o.fill) + if err != nil { + return "", err + } + o.el.Class += fmt.Sprintf(" sketch-overlay-%s", lc) // e.g. sketch-overlay-dark + } + + return o.el.Render(), nil +}