Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GH 1151: export GIF #1200

Merged
merged 13 commits into from
Apr 17, 2023
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions d2cli/export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package d2cli

import (
"path/filepath"
)

type exportExtension string

const GIF exportExtension = ".gif"
const PNG exportExtension = ".png"
const PPTX exportExtension = ".pptx"
const PPT exportExtension = ".ppt"
const PDF exportExtension = ".pdf"
const SVG exportExtension = ".svg"

var SUPPORTED_EXTENSIONS = []exportExtension{SVG, PNG, PDF, PPTX, GIF, PPT}

func getExportExtension(outputPath string) exportExtension {
ext := filepath.Ext(outputPath)
for _, kext := range SUPPORTED_EXTENSIONS {
if kext == exportExtension(ext) {
return exportExtension(ext)
}
}
// default is svg
return exportExtension(SVG)
}

func (ex exportExtension) supportsAnimation() bool {
return ex == SVG || ex == GIF
}

func (ex exportExtension) requiresAnimationInterval() bool {
return ex == GIF
}

func (ex exportExtension) requiresPNGRenderer() bool {
return ex == PNG || ex == PDF || ex == PPTX || ex == GIF
}

func (ex exportExtension) supportsDarkTheme() bool {
return ex == SVG
}
96 changes: 96 additions & 0 deletions d2cli/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package d2cli

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestOutputFormat(t *testing.T) {
type testCase struct {
outputPath string
extension exportExtension
supportsDarkTheme bool
supportsAnimation bool
requiresAnimationInterval bool
requiresPngRender bool
}
testCases := []testCase{
{
outputPath: "/out.svg",
extension: SVG,
supportsDarkTheme: true,
supportsAnimation: true,
requiresAnimationInterval: false,
requiresPngRender: false,
},
{
// assumes SVG by default
outputPath: "/out",
extension: SVG,
supportsDarkTheme: true,
supportsAnimation: true,
requiresAnimationInterval: false,
requiresPngRender: false,
},
{
outputPath: "-",
extension: SVG,
supportsDarkTheme: true,
supportsAnimation: true,
requiresAnimationInterval: false,
requiresPngRender: false,
},
{
outputPath: "/out.png",
extension: PNG,
supportsDarkTheme: false,
supportsAnimation: false,
requiresAnimationInterval: false,
requiresPngRender: true,
},
{
outputPath: "/out.pptx",
extension: PPTX,
supportsDarkTheme: false,
supportsAnimation: false,
requiresAnimationInterval: false,
requiresPngRender: true,
},
{
outputPath: "/out.ppt",
extension: PPT,
supportsDarkTheme: false,
supportsAnimation: false,
requiresAnimationInterval: false,
requiresPngRender: false,
},
{
outputPath: "/out.pdf",
extension: PDF,
supportsDarkTheme: false,
supportsAnimation: false,
requiresAnimationInterval: false,
requiresPngRender: true,
},
{
outputPath: "/out.gif",
extension: GIF,
supportsDarkTheme: false,
supportsAnimation: true,
requiresAnimationInterval: true,
requiresPngRender: true,
},
}

for _, tc := range testCases {
tc := tc
t.Run(tc.outputPath, func(t *testing.T) {
extension := getExportExtension(tc.outputPath)
assert.Equal(t, tc.extension, extension)
assert.Equal(t, tc.supportsAnimation, extension.supportsAnimation())
assert.Equal(t, tc.supportsDarkTheme, extension.supportsDarkTheme())
assert.Equal(t, tc.requiresPngRender, extension.requiresPNGRenderer())
})
}
}
107 changes: 96 additions & 11 deletions d2cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import (
"oss.terrastruct.com/d2/lib/pptx"
"oss.terrastruct.com/d2/lib/textmeasure"
"oss.terrastruct.com/d2/lib/version"
"oss.terrastruct.com/d2/lib/xgif"

"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
Expand Down Expand Up @@ -186,13 +187,16 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
inputPath = filepath.Join(inputPath, "index.d2")
}
}
outputFormat := getExportExtension(outputPath)
if outputFormat == PPT {
return xmain.UsageErrorf("D2 does not support ppt exports, did you mean \"pptx\"?")
}
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" || filepath.Ext(outputPath) == ".pptx" {
return xmain.UsageErrorf("-animate-interval can only be used when exporting to SVG.\nYou provided: %s", filepath.Ext(outputPath))
}
if *animateIntervalFlag > 0 && !outputFormat.supportsAnimation() {
return xmain.UsageErrorf("-animate-interval can only be used when exporting to SVG or GIF.\nYou provided: %s", filepath.Ext(outputPath))
} else if *animateIntervalFlag <= 0 && outputFormat.requiresAnimationInterval() {
return xmain.UsageErrorf("-animate-interval must be greater than 0 for %s outputs.\nYou provided: %d", outputFormat, *animateIntervalFlag)
}
}

Expand Down Expand Up @@ -236,12 +240,14 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
}
ms.Log.Debug.Printf("using layout plugin %s (%s)", *layoutFlag, plocation)

var pw png.Playwright
if filepath.Ext(outputPath) == ".png" || filepath.Ext(outputPath) == ".pdf" || filepath.Ext(outputPath) == ".pptx" {
if !outputFormat.supportsDarkTheme() {
if darkThemeFlag != nil {
ms.Log.Warn.Printf("--dark-theme cannot be used while exporting to another format other than .svg")
darkThemeFlag = nil
}
}
var pw png.Playwright
if outputFormat.requiresPNGRenderer() {
pw, err = png.InitPlaywright()
if err != nil {
return err
Expand Down Expand Up @@ -350,8 +356,29 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, rende
return nil, false, err
}

switch filepath.Ext(outputPath) {
case ".pdf":
ext := getExportExtension(outputPath)
switch ext {
case GIF:
svg, pngs, err := renderPNGsForGIF(ctx, ms, plugin, renderOpts, ruler, page, diagram)
if err != nil {
return nil, false, err
}
out, err := xgif.AnimatePNGs(ms, pngs, 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
}
dur := time.Since(start)
ms.Log.Success.Printf("successfully compiled %s to %s in %s", ms.HumanPath(inputPath), ms.HumanPath(outputPath), dur)
return svg, true, nil
case PDF:
pageMap := buildBoardIDToIndex(diagram, nil, nil)
pdf, err := renderPDF(ctx, ms, plugin, renderOpts, outputPath, page, ruler, diagram, nil, nil, pageMap)
if err != nil {
Expand All @@ -360,7 +387,7 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, rende
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
case ".pptx":
case PPTX:
var username string
if user, err := user.Current(); err == nil {
username = user.Username
Expand Down Expand Up @@ -598,7 +625,7 @@ func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plug
}

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 := getExportExtension(outputPath) == PNG
svg, err := d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: opts.Pad,
Sketch: opts.Sketch,
Expand Down Expand Up @@ -989,3 +1016,61 @@ func buildBoardIDToIndex(diagram *d2target.Diagram, dictionary map[string]int, p

return dictionary
}

func renderPNGsForGIF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts d2svg.RenderOpts, ruler *textmeasure.Ruler, page playwright.Page, diagram *d2target.Diagram) (svg []byte, pngs [][]byte, err error) {
if !diagram.IsFolderOnly {
svg, err = d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: opts.Pad,
Sketch: opts.Sketch,
Center: opts.Center,
SetDimensions: true,
})
if err != nil {
return nil, nil, err
}

svg, err = plugin.PostProcess(ctx, svg)
if err != nil {
return nil, nil, err
}

svg, bundleErr := imgbundler.BundleLocal(ctx, ms, svg)
svg, bundleErr2 := imgbundler.BundleRemote(ctx, ms, svg)
bundleErr = multierr.Combine(bundleErr, bundleErr2)
if bundleErr != nil {
return nil, nil, bundleErr
}

svg = appendix.Append(diagram, ruler, svg)

pngImg, err := png.ConvertSVG(ms, page, svg)
if err != nil {
return nil, nil, err
}
pngs = append(pngs, pngImg)
}

for _, dl := range diagram.Layers {
_, layerPNGs, err := renderPNGsForGIF(ctx, ms, plugin, opts, ruler, page, dl)
if err != nil {
return nil, nil, err
}
pngs = append(pngs, layerPNGs...)
}
for _, dl := range diagram.Scenarios {
_, scenarioPNGs, err := renderPNGsForGIF(ctx, ms, plugin, opts, ruler, page, dl)
if err != nil {
return nil, nil, err
}
pngs = append(pngs, scenarioPNGs...)
}
for _, dl := range diagram.Steps {
_, stepsPNGs, err := renderPNGsForGIF(ctx, ms, plugin, opts, ruler, page, dl)
if err != nil {
return nil, nil, err
}
pngs = append(pngs, stepsPNGs...)
}

return svg, pngs, nil
}
43 changes: 42 additions & 1 deletion e2etests-cli/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"oss.terrastruct.com/d2/d2cli"
"oss.terrastruct.com/d2/lib/pptx"
"oss.terrastruct.com/d2/lib/xgif"
"oss.terrastruct.com/util-go/assert"
"oss.terrastruct.com/util-go/diff"
"oss.terrastruct.com/util-go/xmain"
Expand Down Expand Up @@ -141,7 +142,7 @@ a -> b: italic font
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.
assert.ErrorString(t, err, `failed to wait xmain test: e2etests-cli/d2: bad usage: -animate-interval can only be used when exporting to SVG or GIF.
You provided: .png`)
},
},
Expand Down Expand Up @@ -245,6 +246,14 @@ layers: {
testdataIgnoreDiff(t, ".pdf", pdf)
},
},
{
name: "export_ppt",
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, "x.d2", "x.ppt")
assert.ErrorString(t, err, `failed to wait xmain test: e2etests-cli/d2: bad usage: D2 does not support ppt exports, did you mean "pptx"?`)
},
},
{
name: "how_to_solve_problems_pptx",
skipCI: true,
Expand Down Expand Up @@ -277,6 +286,38 @@ steps: {
assert.Success(t, err)
},
},
{
name: "how_to_solve_problems_gif",
skipCI: true,
run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
writeFile(t, dir, "in.d2", `how to solve a hard problem? {
link: steps.2
}
steps: {
1: {
w: write down the problem
}
2: {
w -> t
t: think really hard about it
}
3: {
t -> w2
w2: write down the solution
w2: {
link: https://d2lang.com
}
}
}
`)
err := runTestMain(t, ctx, dir, env, "--animate-interval=10", "in.d2", "how_to_solve_problems.gif")
assert.Success(t, err)

gifBytes := readFile(t, dir, "how_to_solve_problems.gif")
err = xgif.Validate(gifBytes, 4, 10)
assert.Success(t, err)
},
},
{
name: "stdin",
run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
Expand Down
1 change: 1 addition & 0 deletions go.mod

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file added lib/xgif/test_input1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added lib/xgif/test_input2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added lib/xgif/test_output.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading