Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
6 changes: 4 additions & 2 deletions ci/release/template/man/d2.1
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
.Op Fl -theme Em 0
.Op Fl -salt Ar string
.Ar file.d2
.Op Ar file.svg | file.png | file.pdf | file.pptx | file.gif | file.txt
.Op Ar file.svg | file.png | file.pdf | file.pptx | file.gif | file.txt ...
.Nm d2
.Ar layout Op Ar name
.Nm d2
Expand Down Expand Up @@ -43,7 +43,9 @@ if no output path is passed.
.Pp
Pass - to have
.Nm
read from stdin or write to stdout.
read from stdin or write to stdout. Multiple output paths can be specified in multiple formats after the input path.
.Pp
Example: d2 in.d2 out.svg out.png out.pdf
.Pp
Never use the presence of the output file to check for success.
Always use the exit status of
Expand Down
8 changes: 6 additions & 2 deletions d2cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,18 @@ import (
func help(ms *xmain.State) {
fmt.Fprintf(ms.Stdout, `%[1]s %[2]s
Usage:
%[1]s [--watch=false] [--theme=0] file.d2 [file.svg | file.png | file.pdf | file.pptx | file.gif | file.txt]
%[1]s [--watch=false] [--theme=0] file.d2 [file.svg | file.png | file.pdf | file.pptx | file.gif | file.txt] ...
%[1]s layout [name]
%[1]s fmt file.d2 ...
%[1]s play [--theme=0] [--sketch] file.d2
%[1]s validate file.d2

%[1]s compiles and renders file.d2 to file.svg | file.png | file.pdf | file.pptx | file.gif | file.txt
It defaults to file.svg if an output path is not provided.
It defaults to file.svg if an output path is not provided. Multiple output paths can be specified in
multiple formats after the input path.

Example:
d2 in.d2 out.svg out.png out.pdf

Use - to have d2 read from stdin or write to stdout.

Expand Down
84 changes: 61 additions & 23 deletions d2cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
}

var inputPath string
var outputPath string
var outputPaths []string

if len(ms.Opts.Flags.Args()) == 0 {
if versionFlag != nil && *versionFlag {
Expand All @@ -224,45 +224,55 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
}
help(ms)
return nil
} else if len(ms.Opts.Flags.Args()) >= 3 {
return xmain.UsageErrorf("too many arguments passed")
}

if len(ms.Opts.Flags.Args()) >= 1 {
inputPath = ms.Opts.Flags.Arg(0)
}

if len(ms.Opts.Flags.Args()) >= 2 {
outputPath = ms.Opts.Flags.Arg(1)
for i := 1; i < len(ms.Opts.Flags.Args()); i++ {
outputPaths = append(outputPaths, ms.Opts.Flags.Arg(i))
}
} else {
if inputPath == "-" {
outputPath = "-"
outputPaths = []string{"-"}
} else {
outputPath = renameExt(inputPath, ".svg")
outputPaths = []string{renameExt(inputPath, ".svg")}
}
}

if inputPath != "-" {
inputPath = ms.AbsPath(inputPath)
d, err := os.Stat(inputPath)
if err == nil && d.IsDir() {
inputPath = filepath.Join(inputPath, "index.d2")
}
}
if filepath.Ext(outputPath) == ".ppt" {
return xmain.UsageErrorf("D2 does not support ppt exports, did you mean \"pptx\"?")
}

outputFormat, err := getOutputFormat(stdoutFormatFlag, outputPath)
if err != nil {
return xmain.UsageErrorf("%v", err)
}
outputFormats := make([]exportExtension, len(outputPaths))
for i, outputPath := range outputPaths {
if filepath.Ext(outputPath) == ".ppt" {
return xmain.UsageErrorf("D2 does not support ppt exports, did you mean \"pptx\"?")
}

if outputPath != "-" {
outputPath = ms.AbsPath(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))
format, err := getOutputFormat(stdoutFormatFlag, outputPath)
if err != nil {
return xmain.UsageErrorf("%v", err)
}
outputFormats[i] = format

if outputPath != "-" {
outputPaths[i] = ms.AbsPath(outputPath)
if *animateIntervalFlag > 0 && !format.supportsAnimation() {
return xmain.UsageErrorf("--animate-interval can only be used when exporting to SVG or GIF.\nYou provided: %s", filepath.Ext(outputPath))
}
}
}

outputPath := outputPaths[0]
outputFormat := outputFormats[0]

match := d2themescatalog.Find(*themeFlag)
if match == (d2themes.Theme{}) {
return xmain.UsageErrorf("-t[heme] could not be found. The available options are:\n%s\nYou provided: %d", d2themescatalog.CLIString(), *themeFlag)
Expand Down Expand Up @@ -322,8 +332,17 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
darkThemeFlag = nil
}
}

var requiresPNG bool
for _, format := range outputFormats {
if format.requiresPNGRenderer() {
requiresPNG = true
break
}
}

var pw png.Playwright
if outputFormat.requiresPNGRenderer() {
if requiresPNG {
pw, err = png.InitPlaywrightWithPrompt()
if err != nil {
return err
Expand Down Expand Up @@ -412,12 +431,31 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
ms.Log.Debug.Printf("GIF export: animate-interval not specified, defaulting to 1000ms")
}

_, written, err := compile(ctx, ms, plugins, nil, layoutFlag, renderOpts, fontFamily, monoFontFamily, animateInterval, inputPath, outputPath, boardPath, noChildren, *bundleFlag, *forceAppendixFlag, pw.Browser, outputFormat, *asciiModeFlag)
if err != nil {
if written {
return fmt.Errorf("failed to fully compile (partial render written) %s: %w", ms.HumanPath(inputPath), err)
// Compile to all requested output formats
var firstErr error
var anyWritten bool
for i, outPath := range outputPaths {
outFormat := outputFormats[i]

_, written, err := compile(ctx, ms, plugins, nil, layoutFlag, renderOpts, fontFamily, monoFontFamily, animateInterval, inputPath, outPath, boardPath, noChildren, *bundleFlag, *forceAppendixFlag, pw.Browser, outFormat, *asciiModeFlag)
if err != nil {
if firstErr == nil {
firstErr = err
}
if written {
anyWritten = true
}
if len(outputPaths) > 1 {
ms.Log.Warn.Printf("failed to compile %s to %s: %v", ms.HumanPath(inputPath), ms.HumanPath(outPath), err)
}
}
}

if firstErr != nil {
if anyWritten {
return fmt.Errorf("failed to fully compile (partial render written) %s: %w", ms.HumanPath(inputPath), firstErr)
}
return fmt.Errorf("failed to compile %s: %w", ms.HumanPath(inputPath), err)
return fmt.Errorf("failed to compile %s: %w", ms.HumanPath(inputPath), firstErr)
}
return nil
}
Expand Down
87 changes: 87 additions & 0 deletions e2etests-cli/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1625,6 +1625,93 @@ c
assert.False(t, strings.Contains(string(noVersionEnvSvg), "data-d2-version="))
},
},
{
name: "multi-output-svg",
run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
writeFile(t, dir, "test.d2", `x -> y`)
err := runTestMain(t, ctx, dir, env, "test.d2", "out1.svg", "out2.svg")
assert.Success(t, err)
svg1 := readFile(t, dir, "out1.svg")
svg2 := readFile(t, dir, "out2.svg")
assert.True(t, len(svg1) > 0)
assert.True(t, len(svg2) > 0)
assert.Equal(t, string(svg1), string(svg2))
},
},
{
name: "multi-output-different-formats",
skipCI: true,
run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
writeFile(t, dir, "test.d2", `x -> y`)
err := runTestMain(t, ctx, dir, env, "test.d2", "out.svg", "out.pdf")
assert.Success(t, err)
svg := readFile(t, dir, "out.svg")
pdf := readFile(t, dir, "out.pdf")
assert.True(t, len(svg) > 0)
assert.True(t, len(pdf) > 0)
assert.True(t, strings.Contains(string(svg), "<svg"))
assert.True(t, strings.HasPrefix(string(pdf), "%PDF"))
},
},
{
name: "multi-output-three-formats",
skipCI: true,
run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
writeFile(t, dir, "test.d2", `a -> b -> c`)
err := runTestMain(t, ctx, dir, env, "test.d2", "out.svg", "out.pdf", "out2.svg")
assert.Success(t, err)
svg := readFile(t, dir, "out.svg")
pdf := readFile(t, dir, "out.pdf")
svg2 := readFile(t, dir, "out2.svg")
assert.True(t, len(svg) > 0)
assert.True(t, len(pdf) > 0)
assert.True(t, len(svg2) > 0)
assert.Equal(t, string(svg), string(svg2))
},
},
{
name: "multi-output-animate-svg-gif",
skipCI: true,
run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
writeFile(t, dir, "animation.d2", `steps: {
1: { x -> y }
2: { y -> z }
}`)
err := runTestMain(t, ctx, dir, env, "--animate-interval=1000", "animation.d2", "out.svg", "out.gif")
assert.Success(t, err)
svg := readFile(t, dir, "out.svg")
gif := readFile(t, dir, "out.gif")
assert.True(t, len(svg) > 0)
assert.True(t, len(gif) > 0)
},
},
{
name: "multi-output-invalid-format-continues",
run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
writeFile(t, dir, "test.d2", `x -> y`)
// Request animation on non-animatable format, but should still produce SVG
err := runTestMain(t, ctx, dir, env, "--animate-interval=1000", "test.d2", "out.svg", "out.png")
// Should fail because PNG doesn't support animation
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.\nYou provided: .png")
},
},
{
name: "multi-output-unsupported-format",
run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
writeFile(t, dir, "test.d2", `x -> y`)
err := runTestMain(t, ctx, dir, env, "test.d2", "out.svg", "out.tiff", "out.png")
assert.Success(t, err)
svg := readFile(t, dir, "out.svg")
assert.True(t, len(svg) > 0)
assert.True(t, strings.Contains(string(svg), "<svg"))
// out.tiff should actually be SVG (defaults to SVG for unknown extensions)
tiff := readFile(t, dir, "out.tiff")
assert.True(t, len(tiff) > 0)
assert.True(t, strings.Contains(string(tiff), "<svg"))
png := readFile(t, dir, "out.png")
assert.True(t, len(png) > 0)
},
},
}

ctx := context.Background()
Expand Down