From 15e1fbaeea6a4ff869cb11c1ea6315bd851dc60c Mon Sep 17 00:00:00 2001 From: Dudley Adams Date: Thu, 13 Nov 2025 01:01:38 -0500 Subject: [PATCH] Issue #2380 - Multiple outputs in a single pass --- ci/release/template/man/d2.1 | 6 ++- d2cli/help.go | 8 +++- d2cli/main.go | 84 ++++++++++++++++++++++++---------- e2etests-cli/main_test.go | 87 ++++++++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 27 deletions(-) diff --git a/ci/release/template/man/d2.1 b/ci/release/template/man/d2.1 index d6450e468a..0ae68f1db5 100644 --- a/ci/release/template/man/d2.1 +++ b/ci/release/template/man/d2.1 @@ -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 @@ -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 diff --git a/d2cli/help.go b/d2cli/help.go index d66ded5d56..de98e0b0ef 100644 --- a/d2cli/help.go +++ b/d2cli/help.go @@ -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. diff --git a/d2cli/main.go b/d2cli/main.go index 6e49ad0615..ddd7e20ed9 100644 --- a/d2cli/main.go +++ b/d2cli/main.go @@ -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 { @@ -224,22 +224,24 @@ 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) @@ -247,22 +249,30 @@ func Run(ctx context.Context, ms *xmain.State) (err error) { 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) @@ -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 @@ -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 } diff --git a/e2etests-cli/main_test.go b/e2etests-cli/main_test.go index 8d2913f6e9..dc118e378e 100644 --- a/e2etests-cli/main_test.go +++ b/e2etests-cli/main_test.go @@ -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), " 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), " 0) + assert.True(t, strings.Contains(string(tiff), " 0) + }, + }, } ctx := context.Background()