diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md index d69053fba3..6b64ad66cc 100644 --- a/ci/release/changelogs/next.md +++ b/ci/release/changelogs/next.md @@ -5,6 +5,7 @@ - Vars: vars in markdown blocks are substituted [#2218](https://github.com/terrastruct/d2/pull/2218) - Markdown: Github-flavored tables work in `md` blocks [#2221](https://github.com/terrastruct/d2/pull/2221) - `d2 fmt` now supports a `--check` flag [#2253](https://github.com/terrastruct/d2/pull/2253) +- CLI: PNG output to stdout is supported using `--stdout-format png -` [#2291](https://github.com/terrastruct/d2/pull/2291) #### Improvements 🧹 diff --git a/ci/release/template/man/d2.1 b/ci/release/template/man/d2.1 index 42cead1790..a3bf928c03 100644 --- a/ci/release/template/man/d2.1 +++ b/ci/release/template/man/d2.1 @@ -134,6 +134,9 @@ Print usage information and exit .It Fl v , -version Print version information and exit .Ns . +.It Fl -stdout-format Ar string +Set the output format when writing to stdout. Supported formats are: png, svg. Only used when output is set to stdout (-) +.Ns . .El .Sh SUBCOMMANDS .Bl -tag -width Fl @@ -197,6 +200,8 @@ See -h[ost] flag. See -p[ort] flag. .It Ev Sy BROWSER See --browser flag. +.It Ev Sy D2_STDOUT_FORMAT +See --stdout-format flag. .El .Sh SEE ALSO .Xr d2plugin-tala 1 diff --git a/d2cli/export.go b/d2cli/export.go index 6da34bdb89..dd3e5c54df 100644 --- a/d2cli/export.go +++ b/d2cli/export.go @@ -1,7 +1,9 @@ package d2cli import ( + "fmt" "path/filepath" + "strings" ) type exportExtension string @@ -14,6 +16,24 @@ const SVG exportExtension = ".svg" var SUPPORTED_EXTENSIONS = []exportExtension{SVG, PNG, PDF, PPTX, GIF} +var STDOUT_FORMAT_MAP = map[string]exportExtension{ + "png": PNG, + "svg": SVG, +} + +var SUPPORTED_STDOUT_FORMATS = []string{"png", "svg"} + +func getOutputFormat(stdoutFormatFlag *string, outputPath string) (exportExtension, error) { + if *stdoutFormatFlag != "" { + format := strings.ToLower(*stdoutFormatFlag) + if ext, ok := STDOUT_FORMAT_MAP[format]; ok { + return ext, nil + } + return "", fmt.Errorf("%s is not a supported format. Supported formats are: %s", *stdoutFormatFlag, SUPPORTED_STDOUT_FORMATS) + } + return getExportExtension(outputPath), nil +} + func getExportExtension(outputPath string) exportExtension { ext := filepath.Ext(outputPath) for _, kext := range SUPPORTED_EXTENSIONS { diff --git a/d2cli/export_test.go b/d2cli/export_test.go index eb7ac44ee5..6022c65d07 100644 --- a/d2cli/export_test.go +++ b/d2cli/export_test.go @@ -8,6 +8,7 @@ import ( func TestOutputFormat(t *testing.T) { type testCase struct { + stdoutFormatFlag string outputPath string extension exportExtension supportsDarkTheme bool @@ -41,6 +42,15 @@ func TestOutputFormat(t *testing.T) { requiresAnimationInterval: false, requiresPngRender: false, }, + { + stdoutFormatFlag: "png", + outputPath: "-", + extension: PNG, + supportsDarkTheme: false, + supportsAnimation: false, + requiresAnimationInterval: false, + requiresPngRender: true, + }, { outputPath: "/out.png", extension: PNG, @@ -78,7 +88,8 @@ func TestOutputFormat(t *testing.T) { for _, tc := range testCases { tc := tc t.Run(tc.outputPath, func(t *testing.T) { - extension := getExportExtension(tc.outputPath) + extension, err := getOutputFormat(&tc.stdoutFormatFlag, tc.outputPath) + assert.NoError(t, err) assert.Equal(t, tc.extension, extension) assert.Equal(t, tc.supportsAnimation, extension.supportsAnimation()) assert.Equal(t, tc.supportsDarkTheme, extension.supportsDarkTheme()) diff --git a/d2cli/main.go b/d2cli/main.go index 0240e9d7ca..4f6ec54c2e 100644 --- a/d2cli/main.go +++ b/d2cli/main.go @@ -103,6 +103,11 @@ func Run(ctx context.Context, ms *xmain.State) (err error) { if err != nil { return err } + stdoutFormatFlag := ms.Opts.String("", "stdout-format", "", "", "output format when writing to stdout (svg, png). Usage: d2 input.d2 --stdout-format png - > output.png") + if err != nil { + return err + } + browserFlag := ms.Opts.String("BROWSER", "browser", "", "", "browser executable that watch opens. Setting to 0 opens no browser.") centerFlag, err := ms.Opts.Bool("D2_CENTER", "center", "c", false, "center the SVG in the containing viewbox, such as your browser screen") if err != nil { @@ -218,7 +223,12 @@ func Run(ctx context.Context, ms *xmain.State) (err error) { if filepath.Ext(outputPath) == ".ppt" { return xmain.UsageErrorf("D2 does not support ppt exports, did you mean \"pptx\"?") } - outputFormat := getExportExtension(outputPath) + + outputFormat, err := getOutputFormat(stdoutFormatFlag, outputPath) + if err != nil { + return xmain.UsageErrorf("%v", err) + } + if outputPath != "-" { outputPath = ms.AbsPath(outputPath) if *animateIntervalFlag > 0 && !outputFormat.supportsAnimation() { @@ -330,6 +340,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) { forceAppendix: *forceAppendixFlag, pw: pw, fontFamily: fontFamily, + outputFormat: outputFormat, }) if err != nil { return err @@ -360,7 +371,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) { ctx, cancel := timelib.WithTimeout(ctx, time.Minute*2) defer cancel() - _, written, err := compile(ctx, ms, plugins, nil, layoutFlag, renderOpts, fontFamily, *animateIntervalFlag, inputPath, outputPath, boardPath, noChildren, *bundleFlag, *forceAppendixFlag, pw.Page) + _, written, err := compile(ctx, ms, plugins, nil, layoutFlag, renderOpts, fontFamily, *animateIntervalFlag, inputPath, outputPath, boardPath, noChildren, *bundleFlag, *forceAppendixFlag, pw.Page, outputFormat) if err != nil { if written { return fmt.Errorf("failed to fully compile (partial render written) %s: %w", ms.HumanPath(inputPath), err) @@ -435,7 +446,7 @@ func RouterResolver(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plu } } -func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs fs.FS, layout *string, renderOpts d2svg.RenderOpts, fontFamily *d2fonts.FontFamily, animateInterval int64, inputPath, outputPath string, boardPath []string, noChildren, bundle, forceAppendix bool, page playwright.Page) (_ []byte, written bool, _ error) { +func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs fs.FS, layout *string, renderOpts d2svg.RenderOpts, fontFamily *d2fonts.FontFamily, animateInterval int64, inputPath, outputPath string, boardPath []string, noChildren, bundle, forceAppendix bool, page playwright.Page, ext exportExtension) (_ []byte, written bool, _ error) { start := time.Now() input, err := ms.ReadPath(inputPath) if err != nil { @@ -527,7 +538,6 @@ func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs return nil, false, err } - ext := getExportExtension(outputPath) switch ext { case GIF: svg, pngs, err := renderPNGsForGIF(ctx, ms, plugin, renderOpts, ruler, page, inputPath, diagram) @@ -603,9 +613,9 @@ func compile(ctx context.Context, ms *xmain.State, plugins []d2plugin.Plugin, fs var boards [][]byte var err error if noChildren { - boards, err = renderSingle(ctx, ms, compileDur, plugin, renderOpts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram) + boards, err = renderSingle(ctx, ms, compileDur, plugin, renderOpts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram, ext) } else { - boards, err = render(ctx, ms, compileDur, plugin, renderOpts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram) + boards, err = render(ctx, ms, compileDur, plugin, renderOpts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram, ext) } if err != nil { return nil, false, err @@ -744,7 +754,7 @@ func relink(currDiagramPath string, d *d2target.Diagram, linkToOutput map[string return nil } -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) { +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, ext exportExtension) ([][]byte, error) { if diagram.Name != "" { ext := filepath.Ext(outputPath) outputPath = strings.TrimSuffix(outputPath, ext) @@ -790,21 +800,21 @@ func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plug var boards [][]byte for _, dl := range diagram.Layers { - childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, layersOutputPath, bundle, forceAppendix, page, ruler, dl) + childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, layersOutputPath, bundle, forceAppendix, page, ruler, dl, ext) if err != nil { return nil, err } boards = append(boards, childrenBoards...) } for _, dl := range diagram.Scenarios { - childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, scenariosOutputPath, bundle, forceAppendix, page, ruler, dl) + childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, scenariosOutputPath, bundle, forceAppendix, page, ruler, dl, ext) if err != nil { return nil, err } boards = append(boards, childrenBoards...) } for _, dl := range diagram.Steps { - childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, stepsOutputPath, bundle, forceAppendix, page, ruler, dl) + childrenBoards, err := render(ctx, ms, compileDur, plugin, opts, inputPath, stepsOutputPath, bundle, forceAppendix, page, ruler, dl, ext) if err != nil { return nil, err } @@ -813,7 +823,7 @@ func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plug if !diagram.IsFolderOnly { start := time.Now() - out, err := _render(ctx, ms, plugin, opts, inputPath, boardOutputPath, bundle, forceAppendix, page, ruler, diagram) + out, err := _render(ctx, ms, plugin, opts, inputPath, boardOutputPath, bundle, forceAppendix, page, ruler, diagram, ext) if err != nil { return boards, err } @@ -827,9 +837,9 @@ func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plug return boards, nil } -func renderSingle(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) { +func renderSingle(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, outputFormat exportExtension) ([][]byte, error) { start := time.Now() - out, err := _render(ctx, ms, plugin, opts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram) + out, err := _render(ctx, ms, plugin, opts, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram, outputFormat) if err != nil { return [][]byte{}, err } @@ -840,8 +850,9 @@ func renderSingle(ctx context.Context, ms *xmain.State, compileDur time.Duration return [][]byte{out}, nil } -func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram) ([]byte, error) { - toPNG := getExportExtension(outputPath) == PNG +func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram, outputFormat exportExtension) ([]byte, error) { + toPNG := outputFormat == PNG + var scale *float64 if opts.Scale != nil { scale = opts.Scale diff --git a/d2cli/watch.go b/d2cli/watch.go index 61b236348c..4b4ac179b6 100644 --- a/d2cli/watch.go +++ b/d2cli/watch.go @@ -57,6 +57,7 @@ type watcherOpts struct { forceAppendix bool pw png.Playwright fontFamily *d2fonts.FontFamily + outputFormat exportExtension } type watcher struct { @@ -430,7 +431,7 @@ func (w *watcher) compileLoop(ctx context.Context) error { if w.boardPath != "" { boardPath = strings.Split(w.boardPath, string(os.PathSeparator)) } - svg, _, err := compile(ctx, w.ms, w.plugins, &fs, w.layout, w.renderOpts, w.fontFamily, w.animateInterval, w.inputPath, w.outputPath, boardPath, false, w.bundle, w.forceAppendix, w.pw.Page) + svg, _, err := compile(ctx, w.ms, w.plugins, &fs, w.layout, w.renderOpts, w.fontFamily, w.animateInterval, w.inputPath, w.outputPath, boardPath, false, w.bundle, w.forceAppendix, w.pw.Page, w.outputFormat) w.boardpathMu.Unlock() errs := "" if err != nil {