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 🚀
- `--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)
#### Improvements 🧹

View file

@ -83,6 +83,9 @@ Center the SVG in the containing viewbox, such as your browser screen
.It Fl -pad Ar 100
Pixels padded around the rendered diagram
.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
Browser executable that watch opens. Setting to 0 opens no browser
.Ns .

View file

@ -21,6 +21,7 @@ import (
"oss.terrastruct.com/d2/d2lib"
"oss.terrastruct.com/d2/d2plugin"
"oss.terrastruct.com/d2/d2renderers/d2animate"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2renderers/d2svg"
"oss.terrastruct.com/d2/d2renderers/d2svg/appendix"
@ -68,7 +69,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
if err != nil {
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 {
return err
}
@ -76,6 +77,10 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
if err != nil {
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")
if err != nil {
return err
@ -171,6 +176,12 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
}
if 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)
@ -231,17 +242,22 @@ 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 inputPath == "-" {
return xmain.UsageErrorf("-w[atch] cannot be combined with reading input from stdin")
}
w, err := newWatcher(ctx, ms, watcherOpts{
layoutPlugin: plugin,
sketch: *sketchFlag,
center: *centerFlag,
themeID: *themeFlag,
darkThemeID: darkThemeFlag,
pad: *padFlag,
renderOpts: renderOpts,
animateInterval: *animateIntervalFlag,
host: *hostFlag,
port: *portFlag,
inputPath: inputPath,
@ -259,7 +275,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
ctx, cancel := context.WithTimeout(ctx, time.Minute*2)
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 written {
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
}
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()
input, err := ms.ReadPath(inputPath)
if err != nil {
@ -285,9 +301,9 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketc
opts := &d2lib.CompileOptions{
Layout: layout,
Ruler: ruler,
ThemeID: themeID,
ThemeID: renderOpts.ThemeID,
}
if sketch {
if renderOpts.Sketch {
opts.FontFamily = go2.Pointer(d2fonts.HandDrawn)
}
@ -302,6 +318,14 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketc
}
cancel()
if animateInterval > 0 {
masterID, err := diagram.HashID()
if err != nil {
return nil, false, err
}
renderOpts.MasterID = masterID
}
pluginInfo, err := plugin.Info(ctx)
if err != nil {
return nil, false, err
@ -312,27 +336,42 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketc
return nil, false, err
}
var svg []byte
if filepath.Ext(outputPath) == ".pdf" {
pageMap := pdf.BuildPDFPageMap(diagram, nil, nil)
svg, err = renderPDF(ctx, ms, plugin, sketch, center, pad, themeID, outputPath, page, ruler, diagram, nil, nil, pageMap)
} else {
compileDur := time.Since(start)
svg, err = render(ctx, ms, compileDur, plugin, sketch, center, pad, themeID, darkThemeID, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram)
}
pdf, err := renderPDF(ctx, ms, plugin, renderOpts, outputPath, page, ruler, diagram, nil, nil, pageMap)
if err != nil {
return svg, false, err
return pdf, false, err
}
if filepath.Ext(outputPath) == ".pdf" {
dur := time.Since(start)
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 != "" {
ext := filepath.Ext(outputPath)
outputPath = strings.TrimSuffix(outputPath, ext)
@ -343,6 +382,7 @@ func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plug
boardOutputPath := outputPath
if len(diagram.Layers) > 0 || len(diagram.Scenarios) > 0 || len(diagram.Steps) > 0 {
if outputPath == "-" {
// TODO it can if composed into one
return nil, fmt.Errorf("multiboard output cannot be written to stdout")
}
// 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
}
var boards [][]byte
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 {
return nil, err
}
boards = append(boards, childrenBoards...)
}
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 {
return nil, err
}
boards = append(boards, childrenBoards...)
}
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 {
return nil, err
}
boards = append(boards, childrenBoards...)
}
if !diagram.IsFolderOnly {
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 {
return svg, err
return boards, err
}
dur := compileDur + time.Since(start)
if opts.MasterID == "" {
ms.Log.Success.Printf("successfully compiled %s to %s in %s", ms.HumanPath(inputPath), ms.HumanPath(boardOutputPath), dur)
return svg, nil
}
boards = append([][]byte{out}, boards...)
return boards, 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"
svg, err := d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: int(pad),
Sketch: sketch,
Center: center,
ThemeID: themeID,
DarkThemeID: darkThemeID,
Pad: opts.Pad,
Sketch: opts.Sketch,
Center: opts.Center,
ThemeID: opts.ThemeID,
DarkThemeID: opts.DarkThemeID,
MasterID: opts.MasterID,
SetDimensions: toPNG,
})
if err != nil {
@ -461,6 +509,7 @@ func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketc
}
}
if opts.MasterID == "" {
err = os.MkdirAll(filepath.Dir(outputPath), 0755)
if err != nil {
return svg, err
@ -469,13 +518,14 @@ func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketc
if err != nil {
return svg, err
}
}
if bundleErr != nil {
return svg, bundleErr
}
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
if pdf == nil {
pdf = pdflib.Init()
@ -501,9 +551,9 @@ func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, ske
diagram.Root.Fill = "transparent"
svg, err = d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: int(pad),
Sketch: sketch,
Center: center,
Pad: opts.Pad,
Sketch: opts.Sketch,
Center: opts.Center,
SetDimensions: true,
})
if err != nil {
@ -537,26 +587,26 @@ func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, ske
if err != nil {
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 {
return svg, err
}
}
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 {
return nil, err
}
}
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 {
return nil, err
}
}
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 {
return nil, err
}

View file

@ -26,6 +26,7 @@ import (
"oss.terrastruct.com/util-go/xmain"
"oss.terrastruct.com/d2/d2plugin"
"oss.terrastruct.com/d2/d2renderers/d2svg"
"oss.terrastruct.com/d2/lib/png"
)
@ -40,11 +41,8 @@ var staticFS embed.FS
type watcherOpts struct {
layoutPlugin d2plugin.Plugin
themeID int64
darkThemeID *int64
pad int64
sketch bool
center bool
renderOpts d2svg.RenderOpts
animateInterval int64
host string
port string
inputPath string
@ -360,7 +358,7 @@ func (w *watcher) compileLoop(ctx context.Context) error {
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 := ""
if err != nil {
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
//go:embed style.css
var baseStylesheet string
var BaseStylesheet string
//go:embed github-markdown.css
var mdCSS string
var MarkdownCSS string
//go:embed dots.txt
var dots string
@ -79,6 +79,10 @@ type RenderOpts struct {
DarkThemeID *int64
// disables the fit to screen behavior and ensures the exported svg has the exact dimensions
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) {
@ -1382,7 +1386,7 @@ func RenderText(text string, x, height float64) string {
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[`)
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
// the same namespace for mask URLs.
// Apply hash on IDs for targeting, to be specific for this diagram
diagramHash, err := diagram.HashID()
if err != nil {
return nil, err
}
// CSS names can't start with numbers, so prepend a little something
diagramHash = "d2-" + diagramHash
// Some targeting is still per-board, like masks for connections
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.
// 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{}{}
for _, obj := range allObjects {
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 {
return nil, err
}
@ -1708,7 +1714,7 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
left, top, w, h := dimensions(diagram, pad)
fmt.Fprint(buf, strings.Join([]string{
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>`,
left, top, w, h,
@ -1719,12 +1725,13 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
// generate style elements that will be appended to the SVG tag
upperBuf := &bytes.Buffer{}
embedFonts(upperBuf, diagramHash, buf.String(), diagram.FontFamily) // embedFonts *must* run before `d2sketch.DefineFillPatterns`, but after all elements are appended to `buf`
themeStylesheet, err := themeCSS(diagramHash, themeID, darkThemeID)
if opts.MasterID == "" {
EmbedFonts(upperBuf, diagramHash, buf.String(), diagram.FontFamily) // EmbedFonts *must* run before `d2sketch.DefineFillPatterns`, but after all elements are appended to `buf`
themeStylesheet, err := ThemeCSS(diagramHash, themeID, darkThemeID)
if err != nil {
return nil, err
}
fmt.Fprintf(upperBuf, `<style type="text/css"><![CDATA[%s%s]]></style>`, baseStylesheet, themeStylesheet)
fmt.Fprintf(upperBuf, `<style type="text/css"><![CDATA[%s%s]]></style>`, BaseStylesheet, themeStylesheet)
hasMarkdown := false
for _, s := range diagram.Shapes {
@ -1734,7 +1741,7 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
}
}
if hasMarkdown {
css := mdCSS
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))
@ -1745,6 +1752,7 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
if sketchRunner != nil {
d2sketch.DefineFillPatterns(upperBuf)
}
}
// This shift is for background el to envelop the diagram
left -= int(math.Ceil(float64(diagram.Root.StrokeWidth) / 2.))
@ -1838,30 +1846,45 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
if opts.Center {
alignment = "xMidYMid"
}
fitToScreenWrapper := fmt.Sprintf(`<svg %s d2Version="%s" preserveAspectRatio="%s meet" viewBox="0 0 %d %d"%s>`,
`xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"`,
fitToScreenWrapperOpening := ""
xmlTag := ""
fitToScreenWrapperClosing := ""
idAttr := ""
tag := "g"
// 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
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>`,
`<?xml version="1.0" encoding="utf-8"?>`,
fitToScreenWrapper,
docRendered := fmt.Sprintf(`%s%s<%s %s class="%s" width="%d" height="%d" viewBox="%d %d %d %d">%s%s%s%s</%s>%s`,
xmlTag,
fitToScreenWrapperOpening,
tag,
idAttr,
diagramHash,
w, h, left, top, w, h,
doubleBorderElStr,
backgroundEl.Render(),
upperBuf.String(),
buf.String(),
tag,
fitToScreenWrapperClosing,
)
return []byte(docRendered), nil
}
// 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)
if err != nil {
return "", err

View file

@ -51,18 +51,101 @@ type Diagram struct {
Steps []*Diagram `json:"steps,omitempty"`
}
func (diagram Diagram) HashID() (string, error) {
func (diagram Diagram) Bytes() ([]byte, error) {
b1, err := json.Marshal(diagram.Shapes)
if err != nil {
return "", err
return nil, err
}
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 {
return "", err
}
h := fnv.New32a()
h.Write(append(b1, b2...))
return fmt.Sprint(h.Sum32()), nil
h.Write(bytes)
// 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) {

View file

@ -55,6 +55,42 @@ func TestCLI_E2E(t *testing.T) {
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",
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