animate-interval

This commit is contained in:
Alexander Wang 2023-03-23 13:37:28 -07:00
parent 718daee97c
commit f30efc5f62
No known key found for this signature in database
GPG key ID: D89FA31966BDBECE
14 changed files with 1626 additions and 426 deletions

View file

@ -1,5 +1,6 @@
#### Features 🚀 #### Features 🚀
- `--animate-interval` can be passed as a flag to animate multi-board diagrams. See [docs](https://d2lang.com/todo). [#1088](https://github.com/terrastruct/d2/pull/1088)
- `paper` is available as a `fill-pattern` option [#1070](https://github.com/terrastruct/d2/pull/1070) - `paper` is available as a `fill-pattern` option [#1070](https://github.com/terrastruct/d2/pull/1070)
#### Improvements 🧹 #### Improvements 🧹

View file

@ -83,6 +83,9 @@ Center the SVG in the containing viewbox, such as your browser screen
.It Fl -pad Ar 100 .It Fl -pad Ar 100
Pixels padded around the rendered diagram Pixels padded around the rendered diagram
.Ns . .Ns .
.It Fl -animate-interval Ar 0
If given, multiple boards are packaged as 1 SVG which transitions through each board at the interval (in milliseconds). Can only be used with SVG exports
.Ns .
.It Fl -browser Ar true .It Fl -browser Ar true
Browser executable that watch opens. Setting to 0 opens no browser Browser executable that watch opens. Setting to 0 opens no browser
.Ns . .Ns .

View file

@ -21,6 +21,7 @@ import (
"oss.terrastruct.com/d2/d2lib" "oss.terrastruct.com/d2/d2lib"
"oss.terrastruct.com/d2/d2plugin" "oss.terrastruct.com/d2/d2plugin"
"oss.terrastruct.com/d2/d2renderers/d2animate"
"oss.terrastruct.com/d2/d2renderers/d2fonts" "oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2renderers/d2svg" "oss.terrastruct.com/d2/d2renderers/d2svg"
"oss.terrastruct.com/d2/d2renderers/d2svg/appendix" "oss.terrastruct.com/d2/d2renderers/d2svg/appendix"
@ -68,7 +69,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
if err != nil { if err != nil {
return err return err
} }
darkThemeFlag, err := ms.Opts.Int64("D2_DARK_THEME", "dark-theme", "", -1, "The theme to use when the viewer's browser is in dark mode. When left unset -theme is used for both light and dark mode. Be aware that explicit styles set in D2 code will still be applied and this may produce unexpected results. We plan on resolving this by making style maps in D2 light/dark mode specific. See https://github.com/terrastruct/d2/issues/831.") darkThemeFlag, err := ms.Opts.Int64("D2_DARK_THEME", "dark-theme", "", -1, "the theme to use when the viewer's browser is in dark mode. When left unset -theme is used for both light and dark mode. Be aware that explicit styles set in D2 code will still be applied and this may produce unexpected results. We plan on resolving this by making style maps in D2 light/dark mode specific. See https://github.com/terrastruct/d2/issues/831.")
if err != nil { if err != nil {
return err return err
} }
@ -76,6 +77,10 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
if err != nil { if err != nil {
return err return err
} }
animateIntervalFlag, err := ms.Opts.Int64("D2_ANIMATE_INTERVAL", "animate-interval", "", 0, "if given, multiple boards are packaged as 1 SVG which transitions through each board at the interval (in milliseconds). Can only be used with SVG exports.")
if err != nil {
return err
}
versionFlag, err := ms.Opts.Bool("", "version", "v", false, "get the version") versionFlag, err := ms.Opts.Bool("", "version", "v", false, "get the version")
if err != nil { if err != nil {
return err return err
@ -171,6 +176,12 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
} }
if outputPath != "-" { if outputPath != "-" {
outputPath = ms.AbsPath(outputPath) outputPath = ms.AbsPath(outputPath)
if *animateIntervalFlag > 0 {
// Not checking for extension == "svg", because users may want to write SVG data to a non-svg-extension file
if filepath.Ext(outputPath) == ".png" || filepath.Ext(outputPath) == ".pdf" {
return xmain.UsageErrorf("-animate-interval can only be used when exporting to SVG.\nYou provided: %s", filepath.Ext(outputPath))
}
}
} }
match := d2themescatalog.Find(*themeFlag) match := d2themescatalog.Find(*themeFlag)
@ -231,24 +242,29 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
}() }()
} }
renderOpts := d2svg.RenderOpts{
Pad: int(*padFlag),
Sketch: *sketchFlag,
Center: *centerFlag,
ThemeID: *themeFlag,
DarkThemeID: darkThemeFlag,
}
if *watchFlag { if *watchFlag {
if inputPath == "-" { if inputPath == "-" {
return xmain.UsageErrorf("-w[atch] cannot be combined with reading input from stdin") return xmain.UsageErrorf("-w[atch] cannot be combined with reading input from stdin")
} }
w, err := newWatcher(ctx, ms, watcherOpts{ w, err := newWatcher(ctx, ms, watcherOpts{
layoutPlugin: plugin, layoutPlugin: plugin,
sketch: *sketchFlag, renderOpts: renderOpts,
center: *centerFlag, animateInterval: *animateIntervalFlag,
themeID: *themeFlag, host: *hostFlag,
darkThemeID: darkThemeFlag, port: *portFlag,
pad: *padFlag, inputPath: inputPath,
host: *hostFlag, outputPath: outputPath,
port: *portFlag, bundle: *bundleFlag,
inputPath: inputPath, forceAppendix: *forceAppendixFlag,
outputPath: outputPath, pw: pw,
bundle: *bundleFlag,
forceAppendix: *forceAppendixFlag,
pw: pw,
}) })
if err != nil { if err != nil {
return err return err
@ -259,7 +275,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
ctx, cancel := context.WithTimeout(ctx, time.Minute*2) ctx, cancel := context.WithTimeout(ctx, time.Minute*2)
defer cancel() defer cancel()
_, written, err := compile(ctx, ms, plugin, *sketchFlag, *centerFlag, *padFlag, *themeFlag, darkThemeFlag, inputPath, outputPath, *bundleFlag, *forceAppendixFlag, pw.Page) _, written, err := compile(ctx, ms, plugin, renderOpts, *animateIntervalFlag, inputPath, outputPath, *bundleFlag, *forceAppendixFlag, pw.Page)
if err != nil { if err != nil {
if written { if written {
return fmt.Errorf("failed to fully compile (partial render written): %w", err) return fmt.Errorf("failed to fully compile (partial render written): %w", err)
@ -269,7 +285,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
return nil return nil
} }
func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketch, center bool, pad, themeID int64, darkThemeID *int64, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page) (_ []byte, written bool, _ error) { func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, renderOpts d2svg.RenderOpts, animateInterval int64, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page) (_ []byte, written bool, _ error) {
start := time.Now() start := time.Now()
input, err := ms.ReadPath(inputPath) input, err := ms.ReadPath(inputPath)
if err != nil { if err != nil {
@ -285,9 +301,9 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketc
opts := &d2lib.CompileOptions{ opts := &d2lib.CompileOptions{
Layout: layout, Layout: layout,
Ruler: ruler, Ruler: ruler,
ThemeID: themeID, ThemeID: renderOpts.ThemeID,
} }
if sketch { if renderOpts.Sketch {
opts.FontFamily = go2.Pointer(d2fonts.HandDrawn) opts.FontFamily = go2.Pointer(d2fonts.HandDrawn)
} }
@ -302,6 +318,14 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketc
} }
cancel() cancel()
if animateInterval > 0 {
masterID, err := diagram.HashID()
if err != nil {
return nil, false, err
}
renderOpts.MasterID = masterID
}
pluginInfo, err := plugin.Info(ctx) pluginInfo, err := plugin.Info(ctx)
if err != nil { if err != nil {
return nil, false, err return nil, false, err
@ -312,27 +336,42 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketc
return nil, false, err return nil, false, err
} }
var svg []byte
if filepath.Ext(outputPath) == ".pdf" { if filepath.Ext(outputPath) == ".pdf" {
pageMap := pdf.BuildPDFPageMap(diagram, nil, nil) pageMap := pdf.BuildPDFPageMap(diagram, nil, nil)
svg, err = renderPDF(ctx, ms, plugin, sketch, center, pad, themeID, outputPath, page, ruler, diagram, nil, nil, pageMap) pdf, err := renderPDF(ctx, ms, plugin, renderOpts, outputPath, page, ruler, diagram, nil, nil, pageMap)
} else { if err != nil {
compileDur := time.Since(start) return pdf, false, err
svg, err = render(ctx, ms, compileDur, plugin, sketch, center, pad, themeID, darkThemeID, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram) }
}
if err != nil {
return svg, false, err
}
if filepath.Ext(outputPath) == ".pdf" {
dur := time.Since(start) dur := time.Since(start)
ms.Log.Success.Printf("successfully compiled %s to %s in %s", ms.HumanPath(inputPath), ms.HumanPath(outputPath), dur) ms.Log.Success.Printf("successfully compiled %s to %s in %s", ms.HumanPath(inputPath), ms.HumanPath(outputPath), dur)
return pdf, true, nil
} else {
compileDur := time.Since(start)
boards, err := render(ctx, ms, compileDur, plugin, renderOpts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram)
if err != nil {
return nil, false, err
}
out := boards[0]
if animateInterval > 0 {
out, err = d2animate.Wrap(diagram, boards, renderOpts, int(animateInterval))
if err != nil {
return nil, false, err
}
err = os.MkdirAll(filepath.Dir(outputPath), 0755)
if err != nil {
return nil, false, err
}
err = ms.WritePath(outputPath, out)
if err != nil {
return nil, false, err
}
ms.Log.Success.Printf("successfully compiled %s to %s in %s", ms.HumanPath(inputPath), ms.HumanPath(outputPath), time.Since(start))
}
return out, true, nil
} }
return svg, true, nil
} }
func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plugin d2plugin.Plugin, sketch, center bool, pad int64, themeID int64, darkThemeID *int64, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram) ([]byte, error) { func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram) ([][]byte, error) {
if diagram.Name != "" { if diagram.Name != "" {
ext := filepath.Ext(outputPath) ext := filepath.Ext(outputPath)
outputPath = strings.TrimSuffix(outputPath, ext) outputPath = strings.TrimSuffix(outputPath, ext)
@ -343,6 +382,7 @@ func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plug
boardOutputPath := outputPath boardOutputPath := outputPath
if len(diagram.Layers) > 0 || len(diagram.Scenarios) > 0 || len(diagram.Steps) > 0 { if len(diagram.Layers) > 0 || len(diagram.Scenarios) > 0 || len(diagram.Steps) > 0 {
if outputPath == "-" { if outputPath == "-" {
// TODO it can if composed into one
return nil, fmt.Errorf("multiboard output cannot be written to stdout") return nil, fmt.Errorf("multiboard output cannot be written to stdout")
} }
// Boards with subboards must be self-contained folders. // Boards with subboards must be self-contained folders.
@ -375,47 +415,55 @@ func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plug
stepsOutputPath += ext stepsOutputPath += ext
} }
var boards [][]byte
for _, dl := range diagram.Layers { for _, dl := range diagram.Layers {
_, err := render(ctx, ms, compileDur, plugin, sketch, center, pad, themeID, darkThemeID, inputPath, layersOutputPath, bundle, forceAppendix, page, ruler, dl) childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, layersOutputPath, bundle, forceAppendix, page, ruler, dl)
if err != nil { if err != nil {
return nil, err return nil, err
} }
boards = append(boards, childrenBoards...)
} }
for _, dl := range diagram.Scenarios { for _, dl := range diagram.Scenarios {
_, err := render(ctx, ms, compileDur, plugin, sketch, center, pad, themeID, darkThemeID, inputPath, scenariosOutputPath, bundle, forceAppendix, page, ruler, dl) childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, scenariosOutputPath, bundle, forceAppendix, page, ruler, dl)
if err != nil { if err != nil {
return nil, err return nil, err
} }
boards = append(boards, childrenBoards...)
} }
for _, dl := range diagram.Steps { for _, dl := range diagram.Steps {
_, err := render(ctx, ms, compileDur, plugin, sketch, center, pad, themeID, darkThemeID, inputPath, stepsOutputPath, bundle, forceAppendix, page, ruler, dl) childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, stepsOutputPath, bundle, forceAppendix, page, ruler, dl)
if err != nil { if err != nil {
return nil, err return nil, err
} }
boards = append(boards, childrenBoards...)
} }
if !diagram.IsFolderOnly { if !diagram.IsFolderOnly {
start := time.Now() start := time.Now()
svg, err := _render(ctx, ms, plugin, sketch, center, pad, themeID, darkThemeID, boardOutputPath, bundle, forceAppendix, page, ruler, diagram) out, err := _render(ctx, ms, plugin, opts, boardOutputPath, bundle, forceAppendix, page, ruler, diagram)
if err != nil { if err != nil {
return svg, err return boards, err
} }
dur := compileDur + time.Since(start) dur := compileDur + time.Since(start)
ms.Log.Success.Printf("successfully compiled %s to %s in %s", ms.HumanPath(inputPath), ms.HumanPath(boardOutputPath), dur) if opts.MasterID == "" {
return svg, nil ms.Log.Success.Printf("successfully compiled %s to %s in %s", ms.HumanPath(inputPath), ms.HumanPath(boardOutputPath), dur)
}
boards = append([][]byte{out}, boards...)
return boards, nil
} }
return nil, nil return nil, nil
} }
func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketch, center bool, pad int64, themeID int64, darkThemeID *int64, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram) ([]byte, error) { func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts d2svg.RenderOpts, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram) ([]byte, error) {
toPNG := filepath.Ext(outputPath) == ".png" toPNG := filepath.Ext(outputPath) == ".png"
svg, err := d2svg.Render(diagram, &d2svg.RenderOpts{ svg, err := d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: int(pad), Pad: opts.Pad,
Sketch: sketch, Sketch: opts.Sketch,
Center: center, Center: opts.Center,
ThemeID: themeID, ThemeID: opts.ThemeID,
DarkThemeID: darkThemeID, DarkThemeID: opts.DarkThemeID,
MasterID: opts.MasterID,
SetDimensions: toPNG, SetDimensions: toPNG,
}) })
if err != nil { if err != nil {
@ -461,13 +509,15 @@ func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketc
} }
} }
err = os.MkdirAll(filepath.Dir(outputPath), 0755) if opts.MasterID == "" {
if err != nil { err = os.MkdirAll(filepath.Dir(outputPath), 0755)
return svg, err if err != nil {
} return svg, err
err = ms.WritePath(outputPath, out) }
if err != nil { err = ms.WritePath(outputPath, out)
return svg, err if err != nil {
return svg, err
}
} }
if bundleErr != nil { if bundleErr != nil {
return svg, bundleErr return svg, bundleErr
@ -475,7 +525,7 @@ func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketc
return svg, nil return svg, nil
} }
func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketch, center bool, pad, themeID int64, outputPath string, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram, pdf *pdflib.GoFPDF, boardPath []string, pageMap map[string]int) (svg []byte, err error) { func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts d2svg.RenderOpts, outputPath string, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram, pdf *pdflib.GoFPDF, boardPath []string, pageMap map[string]int) (svg []byte, err error) {
var isRoot bool var isRoot bool
if pdf == nil { if pdf == nil {
pdf = pdflib.Init() pdf = pdflib.Init()
@ -501,9 +551,9 @@ func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, ske
diagram.Root.Fill = "transparent" diagram.Root.Fill = "transparent"
svg, err = d2svg.Render(diagram, &d2svg.RenderOpts{ svg, err = d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: int(pad), Pad: opts.Pad,
Sketch: sketch, Sketch: opts.Sketch,
Center: center, Center: opts.Center,
SetDimensions: true, SetDimensions: true,
}) })
if err != nil { if err != nil {
@ -537,26 +587,26 @@ func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, ske
if err != nil { if err != nil {
return svg, err return svg, err
} }
err = pdf.AddPDFPage(pngImg, currBoardPath, themeID, rootFill, diagram.Shapes, pad, viewboxX, viewboxY, pageMap) err = pdf.AddPDFPage(pngImg, currBoardPath, opts.ThemeID, rootFill, diagram.Shapes, int64(opts.Pad), viewboxX, viewboxY, pageMap)
if err != nil { if err != nil {
return svg, err return svg, err
} }
} }
for _, dl := range diagram.Layers { for _, dl := range diagram.Layers {
_, err := renderPDF(ctx, ms, plugin, sketch, center, pad, themeID, "", page, ruler, dl, pdf, currBoardPath, pageMap) _, err := renderPDF(ctx, ms, plugin, opts, "", page, ruler, dl, pdf, currBoardPath, pageMap)
if err != nil { if err != nil {
return nil, err return nil, err
} }
} }
for _, dl := range diagram.Scenarios { for _, dl := range diagram.Scenarios {
_, err := renderPDF(ctx, ms, plugin, sketch, center, pad, themeID, "", page, ruler, dl, pdf, currBoardPath, pageMap) _, err := renderPDF(ctx, ms, plugin, opts, "", page, ruler, dl, pdf, currBoardPath, pageMap)
if err != nil { if err != nil {
return nil, err return nil, err
} }
} }
for _, dl := range diagram.Steps { for _, dl := range diagram.Steps {
_, err := renderPDF(ctx, ms, plugin, sketch, center, pad, themeID, "", page, ruler, dl, pdf, currBoardPath, pageMap) _, err := renderPDF(ctx, ms, plugin, opts, "", page, ruler, dl, pdf, currBoardPath, pageMap)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -26,6 +26,7 @@ import (
"oss.terrastruct.com/util-go/xmain" "oss.terrastruct.com/util-go/xmain"
"oss.terrastruct.com/d2/d2plugin" "oss.terrastruct.com/d2/d2plugin"
"oss.terrastruct.com/d2/d2renderers/d2svg"
"oss.terrastruct.com/d2/lib/png" "oss.terrastruct.com/d2/lib/png"
) )
@ -39,20 +40,17 @@ var devMode = false
var staticFS embed.FS var staticFS embed.FS
type watcherOpts struct { type watcherOpts struct {
layoutPlugin d2plugin.Plugin layoutPlugin d2plugin.Plugin
themeID int64 renderOpts d2svg.RenderOpts
darkThemeID *int64 animateInterval int64
pad int64 host string
sketch bool port string
center bool inputPath string
host string outputPath string
port string pwd string
inputPath string bundle bool
outputPath string forceAppendix bool
pwd string pw png.Playwright
bundle bool
forceAppendix bool
pw png.Playwright
} }
type watcher struct { type watcher struct {
@ -360,7 +358,7 @@ func (w *watcher) compileLoop(ctx context.Context) error {
w.pw = newPW w.pw = newPW
} }
svg, _, err := compile(ctx, w.ms, w.layoutPlugin, w.sketch, w.center, w.pad, w.themeID, w.darkThemeID, w.inputPath, w.outputPath, w.bundle, w.forceAppendix, w.pw.Page) svg, _, err := compile(ctx, w.ms, w.layoutPlugin, w.renderOpts, w.animateInterval, w.inputPath, w.outputPath, w.bundle, w.forceAppendix, w.pw.Page)
errs := "" errs := ""
if err != nil { if err != nil {
if len(svg) > 0 { if len(svg) > 0 {

View file

@ -0,0 +1,116 @@
package d2animate
import (
"bytes"
"fmt"
"math"
"strings"
"oss.terrastruct.com/d2/d2renderers/d2sketch"
"oss.terrastruct.com/d2/d2renderers/d2svg"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/lib/version"
)
var transitionDurationMS = 1
func makeKeyframe(delayMS, durationMS, totalMS, identifier int) string {
percentageBefore := (math.Max(0, float64(delayMS-transitionDurationMS)) / float64(totalMS)) * 100.
percentageStart := (float64(delayMS) / float64(totalMS)) * 100.
percentageEnd := (float64(delayMS+durationMS-transitionDurationMS) / float64(totalMS)) * 100.
if int(math.Ceil(percentageEnd)) == 100 {
return fmt.Sprintf(`@keyframes d2Transition-%d {
0%%, %f%% {
opacity: 0;
}
%f%%, %f%% {
opacity: 1;
}
}`, identifier, percentageBefore, percentageStart, math.Ceil(percentageEnd))
}
percentageAfter := (float64(delayMS+durationMS) / float64(totalMS)) * 100.
return fmt.Sprintf(`@keyframes d2Transition-%d {
0%%, %f%% {
opacity: 0;
}
%f%%, %f%% {
opacity: 1;
}
%f%%, 100%% {
opacity: 0;
}
}`, identifier, percentageBefore, percentageStart, percentageEnd, percentageAfter)
}
func Wrap(rootDiagram *d2target.Diagram, svgs [][]byte, renderOpts d2svg.RenderOpts, intervalMS int) ([]byte, error) {
buf := &bytes.Buffer{}
// TODO account for stroke width of root border
tl, br := rootDiagram.NestedBoundingBox()
left := tl.X - renderOpts.Pad
top := tl.Y - renderOpts.Pad
width := br.X - tl.X + renderOpts.Pad*2
height := br.Y - tl.Y + renderOpts.Pad*2
fitToScreenWrapperOpening := fmt.Sprintf(`<?xml version="1.0" encoding="utf-8"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" d2Version="%s" preserveAspectRatio="xMinYMin meet" viewBox="0 0 %d %d">`,
version.Version,
width, height,
)
fmt.Fprint(buf, fitToScreenWrapperOpening)
innerOpening := fmt.Sprintf(`<svg id="d2-svg" width="%d" height="%d" viewBox="%d %d %d %d">`,
width, height, left, top, width, height)
fmt.Fprint(buf, innerOpening)
svgsStr := ""
for _, svg := range svgs {
svgsStr += string(svg) + " "
}
diagramHash, err := rootDiagram.HashID()
if err != nil {
return nil, err
}
d2svg.EmbedFonts(buf, diagramHash, svgsStr, rootDiagram.FontFamily)
themeStylesheet, err := d2svg.ThemeCSS(diagramHash, renderOpts.ThemeID, renderOpts.DarkThemeID)
if err != nil {
return nil, err
}
fmt.Fprintf(buf, `<style type="text/css"><![CDATA[%s%s]]></style>`, d2svg.BaseStylesheet, themeStylesheet)
if rootDiagram.HasShape(func(s d2target.Shape) bool {
return s.Label != "" && s.Type == d2target.ShapeText
}) {
css := d2svg.MarkdownCSS
css = strings.ReplaceAll(css, "font-italic", fmt.Sprintf("%s-font-italic", diagramHash))
css = strings.ReplaceAll(css, "font-bold", fmt.Sprintf("%s-font-bold", diagramHash))
css = strings.ReplaceAll(css, "font-mono", fmt.Sprintf("%s-font-mono", diagramHash))
css = strings.ReplaceAll(css, "font-regular", fmt.Sprintf("%s-font-regular", diagramHash))
fmt.Fprintf(buf, `<style type="text/css">%s</style>`, css)
}
if renderOpts.Sketch {
d2sketch.DefineFillPatterns(buf)
}
fmt.Fprint(buf, `<style type="text/css"><![CDATA[`)
for i := range svgs {
fmt.Fprint(buf, makeKeyframe(i*intervalMS, intervalMS, len(svgs)*intervalMS, i))
}
fmt.Fprint(buf, `]]></style>`)
for i, svg := range svgs {
str := string(svg)
str = strings.Replace(str, "<g", fmt.Sprintf(`<g style="animation: d2Transition-%d %dms infinite"`, i, len(svgs)*intervalMS), 1)
buf.Write([]byte(str))
}
fmt.Fprint(buf, "</svg>")
fmt.Fprint(buf, "</svg>")
return buf.Bytes(), nil
}

View file

@ -54,10 +54,10 @@ var TooltipIcon string
var LinkIcon string var LinkIcon string
//go:embed style.css //go:embed style.css
var baseStylesheet string var BaseStylesheet string
//go:embed github-markdown.css //go:embed github-markdown.css
var mdCSS string var MarkdownCSS string
//go:embed dots.txt //go:embed dots.txt
var dots string var dots string
@ -79,6 +79,10 @@ type RenderOpts struct {
DarkThemeID *int64 DarkThemeID *int64
// disables the fit to screen behavior and ensures the exported svg has the exact dimensions // disables the fit to screen behavior and ensures the exported svg has the exact dimensions
SetDimensions bool SetDimensions bool
// 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
} }
func dimensions(diagram *d2target.Diagram, pad int) (left, top, width, height int) { func dimensions(diagram *d2target.Diagram, pad int) (left, top, width, height int) {
@ -1382,7 +1386,7 @@ func RenderText(text string, x, height float64) string {
return strings.Join(rendered, "") return strings.Join(rendered, "")
} }
func embedFonts(buf *bytes.Buffer, diagramHash, source string, fontFamily *d2fonts.FontFamily) { func EmbedFonts(buf *bytes.Buffer, diagramHash, source string, fontFamily *d2fonts.FontFamily) {
fmt.Fprint(buf, `<style type="text/css"><![CDATA[`) fmt.Fprint(buf, `<style type="text/css"><![CDATA[`)
appendOnTrigger( appendOnTrigger(
@ -1658,14 +1662,16 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
} }
} }
// Mask URLs are global. So when multiple SVGs attach to a DOM, they share // Apply hash on IDs for targeting, to be specific for this diagram
// the same namespace for mask URLs.
diagramHash, err := diagram.HashID() diagramHash, err := diagram.HashID()
if err != nil { if err != nil {
return nil, err return nil, err
} }
// CSS names can't start with numbers, so prepend a little something // Some targeting is still per-board, like masks for connections
diagramHash = "d2-" + diagramHash isolatedDiagramHash := diagramHash
if opts != nil && opts.MasterID != "" {
diagramHash = opts.MasterID
}
// SVG has no notion of z-index. The z-index is effectively the order it's drawn. // 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 // So draw from the least nested to most nested
@ -1685,7 +1691,7 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
markers := map[string]struct{}{} markers := map[string]struct{}{}
for _, obj := range allObjects { for _, obj := range allObjects {
if c, is := obj.(d2target.Connection); is { if c, is := obj.(d2target.Connection); is {
labelMask, err := drawConnection(buf, diagramHash, c, markers, idToShape, sketchRunner) labelMask, err := drawConnection(buf, isolatedDiagramHash, c, markers, idToShape, sketchRunner)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -1708,7 +1714,7 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
left, top, w, h := dimensions(diagram, pad) left, top, w, h := dimensions(diagram, pad)
fmt.Fprint(buf, strings.Join([]string{ fmt.Fprint(buf, strings.Join([]string{
fmt.Sprintf(`<mask id="%s" maskUnits="userSpaceOnUse" x="%d" y="%d" width="%d" height="%d">`, fmt.Sprintf(`<mask id="%s" maskUnits="userSpaceOnUse" x="%d" y="%d" width="%d" height="%d">`,
diagramHash, left, top, w, h, isolatedDiagramHash, left, top, w, h,
), ),
fmt.Sprintf(`<rect x="%d" y="%d" width="%d" height="%d" fill="white"></rect>`, fmt.Sprintf(`<rect x="%d" y="%d" width="%d" height="%d" fill="white"></rect>`,
left, top, w, h, left, top, w, h,
@ -1719,31 +1725,33 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
// generate style elements that will be appended to the SVG tag // generate style elements that will be appended to the SVG tag
upperBuf := &bytes.Buffer{} upperBuf := &bytes.Buffer{}
embedFonts(upperBuf, diagramHash, buf.String(), diagram.FontFamily) // embedFonts *must* run before `d2sketch.DefineFillPatterns`, but after all elements are appended to `buf` if opts.MasterID == "" {
themeStylesheet, err := themeCSS(diagramHash, themeID, darkThemeID) EmbedFonts(upperBuf, diagramHash, buf.String(), diagram.FontFamily) // EmbedFonts *must* run before `d2sketch.DefineFillPatterns`, but after all elements are appended to `buf`
if err != nil { themeStylesheet, err := ThemeCSS(diagramHash, themeID, darkThemeID)
return nil, err if err != nil {
} return nil, err
fmt.Fprintf(upperBuf, `<style type="text/css"><![CDATA[%s%s]]></style>`, baseStylesheet, themeStylesheet)
hasMarkdown := false
for _, s := range diagram.Shapes {
if s.Label != "" && s.Type == d2target.ShapeText {
hasMarkdown = true
break
} }
} fmt.Fprintf(upperBuf, `<style type="text/css"><![CDATA[%s%s]]></style>`, BaseStylesheet, themeStylesheet)
if hasMarkdown {
css := mdCSS
css = strings.ReplaceAll(css, "font-italic", fmt.Sprintf("%s-font-italic", diagramHash))
css = strings.ReplaceAll(css, "font-bold", fmt.Sprintf("%s-font-bold", diagramHash))
css = strings.ReplaceAll(css, "font-mono", fmt.Sprintf("%s-font-mono", diagramHash))
css = strings.ReplaceAll(css, "font-regular", fmt.Sprintf("%s-font-regular", diagramHash))
fmt.Fprintf(upperBuf, `<style type="text/css">%s</style>`, css)
}
if sketchRunner != nil { hasMarkdown := false
d2sketch.DefineFillPatterns(upperBuf) for _, s := range diagram.Shapes {
if s.Label != "" && s.Type == d2target.ShapeText {
hasMarkdown = true
break
}
}
if hasMarkdown {
css := MarkdownCSS
css = strings.ReplaceAll(css, "font-italic", fmt.Sprintf("%s-font-italic", diagramHash))
css = strings.ReplaceAll(css, "font-bold", fmt.Sprintf("%s-font-bold", diagramHash))
css = strings.ReplaceAll(css, "font-mono", fmt.Sprintf("%s-font-mono", diagramHash))
css = strings.ReplaceAll(css, "font-regular", fmt.Sprintf("%s-font-regular", diagramHash))
fmt.Fprintf(upperBuf, `<style type="text/css">%s</style>`, css)
}
if sketchRunner != nil {
d2sketch.DefineFillPatterns(upperBuf)
}
} }
// This shift is for background el to envelop the diagram // This shift is for background el to envelop the diagram
@ -1838,30 +1846,45 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
if opts.Center { if opts.Center {
alignment = "xMidYMid" alignment = "xMidYMid"
} }
fitToScreenWrapper := fmt.Sprintf(`<svg %s d2Version="%s" preserveAspectRatio="%s meet" viewBox="0 0 %d %d"%s>`, fitToScreenWrapperOpening := ""
`xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"`, xmlTag := ""
version.Version, fitToScreenWrapperClosing := ""
alignment, idAttr := ""
w, h, tag := "g"
dimensions, // Many things change when this is rendering for animation
) if opts.MasterID == "" {
fitToScreenWrapperOpening = fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" d2Version="%s" preserveAspectRatio="%s meet" viewBox="0 0 %d %d"%s>`,
version.Version,
alignment,
w, h,
dimensions,
)
xmlTag = `<?xml version="1.0" encoding="utf-8"?>`
fitToScreenWrapperClosing = "</svg>"
idAttr = `id="d2-svg"`
tag = "svg"
}
// TODO minify // TODO minify
docRendered := fmt.Sprintf(`%s%s<svg id="d2-svg" class="%s" width="%d" height="%d" viewBox="%d %d %d %d">%s%s%s%s</svg></svg>`, docRendered := fmt.Sprintf(`%s%s<%s %s class="%s" width="%d" height="%d" viewBox="%d %d %d %d">%s%s%s%s</%s>%s`,
`<?xml version="1.0" encoding="utf-8"?>`, xmlTag,
fitToScreenWrapper, fitToScreenWrapperOpening,
tag,
idAttr,
diagramHash, diagramHash,
w, h, left, top, w, h, w, h, left, top, w, h,
doubleBorderElStr, doubleBorderElStr,
backgroundEl.Render(), backgroundEl.Render(),
upperBuf.String(), upperBuf.String(),
buf.String(), buf.String(),
tag,
fitToScreenWrapperClosing,
) )
return []byte(docRendered), nil return []byte(docRendered), nil
} }
// TODO include only colors that are being used to reduce size // TODO include only colors that are being used to reduce size
func themeCSS(diagramHash string, themeID int64, darkThemeID *int64) (stylesheet string, err error) { func ThemeCSS(diagramHash string, themeID int64, darkThemeID *int64) (stylesheet string, err error) {
out, err := singleThemeRulesets(diagramHash, themeID) out, err := singleThemeRulesets(diagramHash, themeID)
if err != nil { if err != nil {
return "", err return "", err

View file

@ -51,18 +51,101 @@ type Diagram struct {
Steps []*Diagram `json:"steps,omitempty"` Steps []*Diagram `json:"steps,omitempty"`
} }
func (diagram Diagram) HashID() (string, error) { func (diagram Diagram) Bytes() ([]byte, error) {
b1, err := json.Marshal(diagram.Shapes) b1, err := json.Marshal(diagram.Shapes)
if err != nil { if err != nil {
return "", err return nil, err
} }
b2, err := json.Marshal(diagram.Connections) b2, err := json.Marshal(diagram.Connections)
if err != nil {
return nil, err
}
base := append(b1, b2...)
for _, d := range diagram.Layers {
slices, err := d.Bytes()
if err != nil {
return nil, err
}
base = append(base, slices...)
}
for _, d := range diagram.Scenarios {
slices, err := d.Bytes()
if err != nil {
return nil, err
}
base = append(base, slices...)
}
for _, d := range diagram.Steps {
slices, err := d.Bytes()
if err != nil {
return nil, err
}
base = append(base, slices...)
}
return base, nil
}
func (diagram Diagram) HasShape(condition func(Shape) bool) bool {
for _, d := range diagram.Layers {
if d.HasShape(condition) {
return true
}
}
for _, d := range diagram.Scenarios {
if d.HasShape(condition) {
return true
}
}
for _, d := range diagram.Steps {
if d.HasShape(condition) {
return true
}
}
for _, s := range diagram.Shapes {
if condition(s) {
return true
}
}
return false
}
func (diagram Diagram) HashID() (string, error) {
bytes, err := diagram.Bytes()
if err != nil { if err != nil {
return "", err return "", err
} }
h := fnv.New32a() h := fnv.New32a()
h.Write(append(b1, b2...)) h.Write(bytes)
return fmt.Sprint(h.Sum32()), nil // CSS names can't start with numbers, so prepend a little something
return fmt.Sprintf("d2-%d", h.Sum32()), nil
}
func (diagram Diagram) NestedBoundingBox() (topLeft, bottomRight Point) {
tl, br := diagram.BoundingBox()
for _, d := range diagram.Layers {
tl2, br2 := d.NestedBoundingBox()
tl.X = go2.Min(tl.X, tl2.X)
tl.Y = go2.Min(tl.Y, tl2.Y)
br.X = go2.Max(br.X, br2.X)
br.Y = go2.Max(br.Y, br2.Y)
}
for _, d := range diagram.Scenarios {
tl2, br2 := d.NestedBoundingBox()
tl.X = go2.Min(tl.X, tl2.X)
tl.Y = go2.Min(tl.Y, tl2.Y)
br.X = go2.Max(br.X, br2.X)
br.Y = go2.Max(br.Y, br2.Y)
}
for _, d := range diagram.Steps {
tl2, br2 := d.NestedBoundingBox()
tl.X = go2.Min(tl.X, tl2.X)
tl.Y = go2.Min(tl.Y, tl2.Y)
br.X = go2.Max(br.X, br2.X)
br.Y = go2.Max(br.Y, br2.Y)
}
return tl, br
} }
func (diagram Diagram) BoundingBox() (topLeft, bottomRight Point) { func (diagram Diagram) BoundingBox() (topLeft, bottomRight Point) {

View file

@ -55,6 +55,42 @@ func TestCLI_E2E(t *testing.T) {
assert.Testdata(t, ".svg", svg) assert.Testdata(t, ".svg", svg)
}, },
}, },
{
name: "animation",
run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
writeFile(t, dir, "animation.d2", `Chicken's plan: {
style.font-size: 35
near: top-center
shape: text
}
steps: {
1: {
Approach road
}
2: {
Approach road -> Cross road
}
3: {
Cross road -> Make you wonder why
}
}
`)
err := runTestMain(t, ctx, dir, env, "--animate-interval=1400", "animation.d2")
assert.Success(t, err)
svg := readFile(t, dir, "animation.svg")
assert.Testdata(t, ".svg", svg)
},
},
{
name: "incompatible-animation",
run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
writeFile(t, dir, "x.d2", `x -> y`)
err := runTestMain(t, ctx, dir, env, "--animate-interval=2", "x.d2", "x.png")
assert.ErrorString(t, err, `failed to wait xmain test: e2etests-cli/d2: bad usage: -animate-interval can only be used when exporting to SVG.
You provided: .png`)
},
},
{ {
name: "hello_world_png_sketch", name: "hello_world_png_sketch",
skipCI: true, skipCI: true,

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 669 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 330 KiB

After

Width:  |  Height:  |  Size: 330 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 330 KiB

After

Width:  |  Height:  |  Size: 330 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 330 KiB

After

Width:  |  Height:  |  Size: 330 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 330 KiB

After

Width:  |  Height:  |  Size: 330 KiB