diff --git a/d2cli/export.go b/d2cli/export.go new file mode 100644 index 000000000..6da34bdb8 --- /dev/null +++ b/d2cli/export.go @@ -0,0 +1,42 @@ +package d2cli + +import ( + "path/filepath" +) + +type exportExtension string + +const GIF exportExtension = ".gif" +const PNG exportExtension = ".png" +const PPTX exportExtension = ".pptx" +const PDF exportExtension = ".pdf" +const SVG exportExtension = ".svg" + +var SUPPORTED_EXTENSIONS = []exportExtension{SVG, PNG, PDF, PPTX, GIF} + +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 +} diff --git a/d2cli/export_test.go b/d2cli/export_test.go new file mode 100644 index 000000000..eb7ac44ee --- /dev/null +++ b/d2cli/export_test.go @@ -0,0 +1,88 @@ +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.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()) + }) + } +} diff --git a/d2cli/main.go b/d2cli/main.go index b55ff47f3..c28b95a9b 100644 --- a/d2cli/main.go +++ b/d2cli/main.go @@ -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" @@ -186,13 +187,16 @@ 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 := getExportExtension(outputPath) 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) } } @@ -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 @@ -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 { @@ -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 @@ -615,7 +642,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, @@ -1006,3 +1033,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 +} diff --git a/e2etests-cli/main_test.go b/e2etests-cli/main_test.go index 11e1b6f0e..8ba39dbe3 100644 --- a/e2etests-cli/main_test.go +++ b/e2etests-cli/main_test.go @@ -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" @@ -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`) }, }, @@ -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, @@ -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) { diff --git a/go.mod b/go.mod index 914cf8703..ce86dfc85 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/dop251/goja v0.0.0-20230122112309-96b1610dd4f7 github.com/dsoprea/go-exif/v3 v3.0.0-20221012082141-d21ac8e2de85 github.com/dsoprea/go-png-image-structure/v2 v2.0.0-20210512210324-29b889a6093d + github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4 github.com/fsnotify/fsnotify v1.6.0 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 github.com/jung-kurt/gofpdf v1.16.2 diff --git a/go.sum b/go.sum index bceb44ec8..900e291b9 100644 --- a/go.sum +++ b/go.sum @@ -60,6 +60,8 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4 h1:BBade+JlV/f7JstZ4pitd4tHhpN+w+6I+LyOS7B4fyU= +github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4/go.mod h1:H7chHJglrhPPzetLdzBleF8d22WYOv7UM/lEKYiwlKM= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= diff --git a/lib/xgif/test_input1.png b/lib/xgif/test_input1.png new file mode 100644 index 000000000..48ab8a6a1 Binary files /dev/null and b/lib/xgif/test_input1.png differ diff --git a/lib/xgif/test_input2.png b/lib/xgif/test_input2.png new file mode 100644 index 000000000..9ee883c55 Binary files /dev/null and b/lib/xgif/test_input2.png differ diff --git a/lib/xgif/test_output.gif b/lib/xgif/test_output.gif new file mode 100644 index 000000000..5226b6427 Binary files /dev/null and b/lib/xgif/test_output.gif differ diff --git a/lib/xgif/xgif.go b/lib/xgif/xgif.go new file mode 100644 index 000000000..967b446fc --- /dev/null +++ b/lib/xgif/xgif.go @@ -0,0 +1,143 @@ +// xgif is a helper package to create GIF animations based on PNG images +// The resulting animations have the following properties: +// 1. All frames have the same size (max(pngs.width), max(pngs.height)) +// 2. All PNGs are centered in the given frame +// 3. The frame background is plain white +// Note that to convert from a PNG to a GIF compatible image (Bitmap), the PNG image must be quantized (colors are aggregated in median buckets) +// so that it has at most 255 colors. +// This is required because GIFs support only 256 colors and we must keep 1 slot for the white background. +package xgif + +import ( + "bytes" + "fmt" + "image" + "image/color" + "image/gif" + "image/png" + "time" + + "github.com/ericpauley/go-quantize/quantize" + + "oss.terrastruct.com/d2/lib/background" + "oss.terrastruct.com/util-go/go2" + "oss.terrastruct.com/util-go/xmain" +) + +const INFINITE_LOOP = 0 +const BG_INDEX uint8 = 255 + +var BG_COLOR = color.White + +func AnimatePNGs(ms *xmain.State, pngs [][]byte, animIntervalMs int) ([]byte, error) { + if ms != nil { + cancel := background.Repeat(func() { + ms.Log.Info.Printf("generating GIF...") + }, time.Second*5) + defer cancel() + } + + var width, height int + pngImgs := make([]image.Image, len(pngs)) + for i, pngBytes := range pngs { + img, err := png.Decode(bytes.NewBuffer(pngBytes)) + if err != nil { + return nil, err + } + pngImgs[i] = img + bounds := img.Bounds() + width = go2.Max(width, bounds.Dx()) + height = go2.Max(height, bounds.Dy()) + } + + interval := animIntervalMs / 10 // gif animation interval is in 100ths of a second + anim := &gif.GIF{ + LoopCount: INFINITE_LOOP, + Config: image.Config{ + Width: width, + Height: height, + }, + } + + for _, pngImage := range pngImgs { + // 1. convert the PNG into a GIF compatible image (Bitmap) by quantizing it to 255 colors + buf := bytes.NewBuffer(nil) + err := gif.Encode(buf, pngImage, &gif.Options{ + NumColors: 255, // GIFs can have up to 256 colors, so keep 1 slot for white background + Quantizer: quantize.MedianCutQuantizer{}, + }) + if err != nil { + return nil, err + } + gifImg, err := gif.Decode(buf) + if err != nil { + return nil, err + } + palettedImg, ok := gifImg.(*image.Paletted) + if !ok { + return nil, fmt.Errorf("decoded gif image could not be cast as *image.Paletted") + } + + // 2. make GIF frames of the same size, keeping images centered and with a white background + bounds := pngImage.Bounds() + top := (height - bounds.Dy()) / 2 + bottom := top + bounds.Dy() + left := (width - bounds.Dx()) / 2 + right := left + bounds.Dx() + + palettedImg.Palette[BG_INDEX] = BG_COLOR + frame := image.NewPaletted(image.Rect(0, 0, width, height), palettedImg.Palette) + for x := 0; x < width; x++ { + for y := 0; y < height; y++ { + if x <= left || y <= top || x >= right || y >= bottom { + frame.SetColorIndex(x, y, BG_INDEX) + } else { + frame.SetColorIndex(x, y, palettedImg.ColorIndexAt(x-left, y-top)) + } + } + } + + anim.Image = append(anim.Image, frame) + anim.Delay = append(anim.Delay, interval) + } + + buf := bytes.NewBuffer(nil) + err := gif.EncodeAll(buf, anim) + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func Validate(gifBytes []byte, nFrames int, intervalMS int) error { + anim, err := gif.DecodeAll(bytes.NewBuffer(gifBytes)) + if err != nil { + return err + } + + if anim.LoopCount != INFINITE_LOOP { + return fmt.Errorf("expected infinite loop, got=%d", anim.LoopCount) + } + + if len(anim.Image) != nFrames { + return fmt.Errorf("expected %d frames, got=%d", nFrames, len(anim.Image)) + } + + interval := intervalMS / 10 + width, height := anim.Config.Width, anim.Config.Height + for i, frame := range anim.Image { + w := frame.Bounds().Dx() + if w != width { + return fmt.Errorf("expected all frames to have the same width=%d, got=%d at frame=%d", width, w, i) + } + h := frame.Bounds().Dy() + if h != height { + return fmt.Errorf("expected all frames to have the same height=%d, got=%d at frame=%d", height, h, i) + } + if anim.Delay[i] != interval { + return fmt.Errorf("expected interval between frames to be %d, got=%d at frame=%d", interval, anim.Delay[i], i) + } + } + + return nil +} diff --git a/lib/xgif/xgif_test.go b/lib/xgif/xgif_test.go new file mode 100644 index 000000000..bc3cc1061 --- /dev/null +++ b/lib/xgif/xgif_test.go @@ -0,0 +1,35 @@ +package xgif + +import ( + _ "embed" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +//go:embed test_input1.png +var test_input1 []byte + +//go:embed test_input2.png +var test_input2 []byte + +//go:embed test_output.gif +var test_output []byte + +func TestPngToGif(t *testing.T) { + boards := [][]byte{test_input1, test_input2} + interval := 1_000 + gifBytes, err := AnimatePNGs(nil, boards, interval) + assert.NoError(t, err) + + // use this to update the test output + if false { + f, err := os.Create("test_output_2.gif") + assert.NoError(t, err) + defer f.Close() + f.Write(gifBytes) + } + + assert.Equal(t, test_output, gifBytes) +}