diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md index 67e612848..eb695339e 100644 --- a/ci/release/changelogs/next.md +++ b/ci/release/changelogs/next.md @@ -1,5 +1,7 @@ #### Features ๐Ÿš€ +- `--center` flag centers the SVG in the containing viewbox. [#1056](https://github.com/terrastruct/d2/pull/1056) + #### Improvements ๐Ÿงน - `elk` layout containers no longer overlap the label with children. [#1055](https://github.com/terrastruct/d2/pull/1055) diff --git a/ci/release/template/man/d2.1 b/ci/release/template/man/d2.1 index ed1b37017..cc012ef7e 100644 --- a/ci/release/template/man/d2.1 +++ b/ci/release/template/man/d2.1 @@ -77,6 +77,9 @@ making style maps in D2 light/dark mode specific. See .It Fl s , -sketch Ar false Renders the diagram to look like it was sketched by hand .Ns . +.It Fl -center Ar flag +Center the SVG in the containing viewbox, such as your browser screen +.Ns . .It Fl -pad Ar 100 Pixels padded around the rendered diagram .Ns . diff --git a/d2cli/main.go b/d2cli/main.go index 5c92efb5e..4763b8cff 100644 --- a/d2cli/main.go +++ b/d2cli/main.go @@ -83,6 +83,10 @@ func Run(ctx context.Context, ms *xmain.State) (err error) { if err != nil { return err } + 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 { + return err + } ps, err := d2plugin.ListPlugins(ctx) if err != nil { @@ -229,6 +233,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) { w, err := newWatcher(ctx, ms, watcherOpts{ layoutPlugin: plugin, sketch: *sketchFlag, + center: *centerFlag, themeID: *themeFlag, darkThemeID: darkThemeFlag, pad: *padFlag, @@ -249,7 +254,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, *padFlag, *themeFlag, darkThemeFlag, inputPath, outputPath, *bundleFlag, *forceAppendixFlag, pw.Page) + _, written, err := compile(ctx, ms, plugin, *sketchFlag, *centerFlag, *padFlag, *themeFlag, darkThemeFlag, inputPath, outputPath, *bundleFlag, *forceAppendixFlag, pw.Page) if err != nil { if written { return fmt.Errorf("failed to fully compile (partial render written): %w", err) @@ -259,7 +264,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 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, sketch, center bool, pad, themeID int64, darkThemeID *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 { @@ -298,10 +303,10 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketc var svg []byte if filepath.Ext(outputPath) == ".pdf" { pageMap := pdf.BuildPDFPageMap(diagram, nil, nil) - svg, err = renderPDF(ctx, ms, plugin, sketch, pad, themeID, outputPath, page, ruler, diagram, nil, nil, pageMap) + 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, pad, themeID, darkThemeID, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram) + svg, err = render(ctx, ms, compileDur, plugin, sketch, center, pad, themeID, darkThemeID, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram) } if err != nil { return svg, false, err @@ -315,7 +320,7 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketc return svg, true, nil } -func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plugin d2plugin.Plugin, sketch 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, 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) { if diagram.Name != "" { ext := filepath.Ext(outputPath) outputPath = strings.TrimSuffix(outputPath, ext) @@ -359,19 +364,19 @@ func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plug } for _, dl := range diagram.Layers { - _, err := render(ctx, ms, compileDur, plugin, sketch, pad, themeID, darkThemeID, inputPath, layersOutputPath, bundle, forceAppendix, page, ruler, dl) + _, err := render(ctx, ms, compileDur, plugin, sketch, center, pad, themeID, darkThemeID, inputPath, layersOutputPath, bundle, forceAppendix, page, ruler, dl) if err != nil { return nil, err } } for _, dl := range diagram.Scenarios { - _, err := render(ctx, ms, compileDur, plugin, sketch, pad, themeID, darkThemeID, inputPath, scenariosOutputPath, bundle, forceAppendix, page, ruler, dl) + _, err := render(ctx, ms, compileDur, plugin, sketch, center, pad, themeID, darkThemeID, inputPath, scenariosOutputPath, bundle, forceAppendix, page, ruler, dl) if err != nil { return nil, err } } for _, dl := range diagram.Steps { - _, err := render(ctx, ms, compileDur, plugin, sketch, pad, themeID, darkThemeID, inputPath, stepsOutputPath, bundle, forceAppendix, page, ruler, dl) + _, err := render(ctx, ms, compileDur, plugin, sketch, center, pad, themeID, darkThemeID, inputPath, stepsOutputPath, bundle, forceAppendix, page, ruler, dl) if err != nil { return nil, err } @@ -379,7 +384,7 @@ func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plug if !diagram.IsFolderOnly { start := time.Now() - svg, err := _render(ctx, ms, plugin, sketch, pad, themeID, darkThemeID, boardOutputPath, bundle, forceAppendix, page, ruler, diagram) + svg, err := _render(ctx, ms, plugin, sketch, center, pad, themeID, darkThemeID, boardOutputPath, bundle, forceAppendix, page, ruler, diagram) if err != nil { return svg, err } @@ -391,11 +396,12 @@ func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plug return nil, nil } -func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketch 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, 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) { toPNG := filepath.Ext(outputPath) == ".png" svg, err := d2svg.Render(diagram, &d2svg.RenderOpts{ Pad: int(pad), Sketch: sketch, + Center: center, ThemeID: themeID, DarkThemeID: darkThemeID, SetDimensions: toPNG, @@ -457,7 +463,7 @@ func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketc return svg, nil } -func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketch 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, 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) { var isRoot bool if pdf == nil { pdf = pdflib.Init() @@ -485,6 +491,7 @@ func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, ske svg, err = d2svg.Render(diagram, &d2svg.RenderOpts{ Pad: int(pad), Sketch: sketch, + Center: center, SetDimensions: true, }) if err != nil { @@ -525,19 +532,19 @@ func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, ske } for _, dl := range diagram.Layers { - _, err := renderPDF(ctx, ms, plugin, sketch, pad, themeID, "", page, ruler, dl, pdf, currBoardPath, pageMap) + _, err := renderPDF(ctx, ms, plugin, sketch, center, pad, themeID, "", page, ruler, dl, pdf, currBoardPath, pageMap) if err != nil { return nil, err } } for _, dl := range diagram.Scenarios { - _, err := renderPDF(ctx, ms, plugin, sketch, pad, themeID, "", page, ruler, dl, pdf, currBoardPath, pageMap) + _, err := renderPDF(ctx, ms, plugin, sketch, center, pad, themeID, "", page, ruler, dl, pdf, currBoardPath, pageMap) if err != nil { return nil, err } } for _, dl := range diagram.Steps { - _, err := renderPDF(ctx, ms, plugin, sketch, pad, themeID, "", page, ruler, dl, pdf, currBoardPath, pageMap) + _, err := renderPDF(ctx, ms, plugin, sketch, center, pad, themeID, "", page, ruler, dl, pdf, currBoardPath, pageMap) if err != nil { return nil, err } diff --git a/d2cli/watch.go b/d2cli/watch.go index 9e3e80010..f9cfb9e81 100644 --- a/d2cli/watch.go +++ b/d2cli/watch.go @@ -44,6 +44,7 @@ type watcherOpts struct { darkThemeID *int64 pad int64 sketch bool + center bool host string port string inputPath string @@ -359,7 +360,7 @@ func (w *watcher) compileLoop(ctx context.Context) error { w.pw = newPW } - svg, _, err := compile(ctx, w.ms, w.layoutPlugin, w.sketch, 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.sketch, w.center, w.pad, w.themeID, w.darkThemeID, w.inputPath, w.outputPath, w.bundle, w.forceAppendix, w.pw.Page) errs := "" if err != nil { if len(svg) > 0 { diff --git a/d2renderers/d2svg/d2svg.go b/d2renderers/d2svg/d2svg.go index b745bcf95..36292decc 100644 --- a/d2renderers/d2svg/d2svg.go +++ b/d2renderers/d2svg/d2svg.go @@ -71,6 +71,7 @@ var grain string type RenderOpts struct { Pad int Sketch bool + Center bool ThemeID int64 DarkThemeID *int64 // disables the fit to screen behavior and ensures the exported svg has the exact dimensions @@ -1828,9 +1829,14 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) { dimensions = fmt.Sprintf(` width="%d" height="%d"`, w, h) } - fitToScreenWrapper := fmt.Sprintf(``, + alignment := "xMinYMin" + if opts.Center { + alignment = "xMidYMid" + } + fitToScreenWrapper := fmt.Sprintf(``, `xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"`, version.Version, + alignment, w, h, dimensions, ) diff --git a/e2etests-cli/main_test.go b/e2etests-cli/main_test.go index 05595d4d4..2fdc0896d 100644 --- a/e2etests-cli/main_test.go +++ b/e2etests-cli/main_test.go @@ -45,6 +45,16 @@ func TestCLI_E2E(t *testing.T) { testdataIgnoreDiff(t, ".png", png) }, }, + { + name: "center", + run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) { + writeFile(t, dir, "hello-world.d2", `x -> y`) + err := runTestMain(t, ctx, dir, env, "--center=true", "hello-world.d2") + assert.Success(t, err) + svg := readFile(t, dir, "hello-world.svg") + assert.Testdata(t, ".svg", svg) + }, + }, { name: "hello_world_png_sketch", skipCI: true, diff --git a/e2etests-cli/testdata/TestCLI_E2E/center.exp.svg b/e2etests-cli/testdata/TestCLI_E2E/center.exp.svg new file mode 100644 index 000000000..222c52ea4 --- /dev/null +++ b/e2etests-cli/testdata/TestCLI_E2E/center.exp.svg @@ -0,0 +1,95 @@ +xy + + + diff --git a/e2etests-cli/testdata/TestCLI_E2E/internal_linked_pdf.exp.pdf b/e2etests-cli/testdata/TestCLI_E2E/internal_linked_pdf.exp.pdf index 324598cbd..57cc95f9c 100644 Binary files a/e2etests-cli/testdata/TestCLI_E2E/internal_linked_pdf.exp.pdf and b/e2etests-cli/testdata/TestCLI_E2E/internal_linked_pdf.exp.pdf differ