diff --git a/d2cli/main.go b/d2cli/main.go index f0c5e7ea0..804505e5d 100644 --- a/d2cli/main.go +++ b/d2cli/main.go @@ -355,6 +355,26 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, rende 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(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) @@ -401,19 +421,11 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, rende if err != nil { return nil, false, err } - // TODO: test GIF with 1 frame var out []byte if len(boards) > 0 { out = boards[0] - if animateInterval > 0 { - if ext == GIF { - tl, br := diagram.NestedBoundingBox() - width := png.SCALE*(br.X-tl.X) + renderOpts.Pad*2 - height := png.SCALE*(br.Y-tl.Y) + renderOpts.Pad*2 - out, err = xgif.AnimatePNGs(boards, width, height, int(animateInterval)) - } else { - out, err = d2animate.Wrap(diagram, boards, renderOpts, int(animateInterval)) - } + if animateInterval > 0 && ext != GIF { + out, err = d2animate.Wrap(diagram, boards, renderOpts, int(animateInterval)) if err != nil { return nil, false, err } @@ -610,7 +622,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 := getExportExtension(outputPath).requiresPNGRenderer() + toPNG := getExportExtension(outputPath) == PNG svg, err := d2svg.Render(diagram, &d2svg.RenderOpts{ Pad: opts.Pad, Sketch: opts.Sketch, @@ -1001,3 +1013,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/lib/xgif/test_output.gif b/lib/xgif/test_output.gif index d9bfda058..5226b6427 100644 Binary files a/lib/xgif/test_output.gif and b/lib/xgif/test_output.gif differ diff --git a/lib/xgif/xgif.go b/lib/xgif/xgif.go index 64823b07a..f0c149712 100644 --- a/lib/xgif/xgif.go +++ b/lib/xgif/xgif.go @@ -4,32 +4,47 @@ import ( "bytes" "fmt" "image" + "image/color" "image/gif" "image/png" "github.com/ericpauley/go-quantize/quantize" + "oss.terrastruct.com/util-go/go2" ) const INFINITE_LOOP = 0 +const BG_INDEX uint8 = 255 + +var BG_COLOR = color.White + +func AnimatePNGs(pngs [][]byte, animIntervalMs int) ([]byte, error) { + 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()) + } -func AnimatePNGs(pngs [][]byte, gifWidth, gifHeight int, animIntervalMs int) ([]byte, error) { interval := animIntervalMs / 10 // gif animation interval is in 100ths of a second anim := &gif.GIF{ LoopCount: INFINITE_LOOP, Config: image.Config{ - Width: gifWidth, - Height: gifHeight, + Width: width, + Height: height, }, } - for _, pngBytes := range pngs { - pngImage, err := png.Decode(bytes.NewBuffer(pngBytes)) - if err != nil { - return nil, err - } + 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: 256, // GIFs can have up to 256 colors + 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 { @@ -43,7 +58,27 @@ func AnimatePNGs(pngs [][]byte, gifWidth, gifHeight int, animIntervalMs int) ([] if !ok { return nil, fmt.Errorf("decoded git image could not be cast as *image.Paletted") } - anim.Image = append(anim.Image, palettedImg) + + // 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) } diff --git a/lib/xgif/xgif_test.go b/lib/xgif/xgif_test.go index b0f356654..fcdc370e7 100644 --- a/lib/xgif/xgif_test.go +++ b/lib/xgif/xgif_test.go @@ -1,13 +1,11 @@ package xgif import ( - "bytes" _ "embed" - "image/png" + "os" "testing" "github.com/stretchr/testify/assert" - "oss.terrastruct.com/util-go/go2" ) //go:embed test_input1.png @@ -20,20 +18,18 @@ var test_input2 []byte var test_output []byte func TestPngToGif(t *testing.T) { - board1, err := png.Decode(bytes.NewBuffer(test_input1)) - assert.NoError(t, err) - gifWidth := board1.Bounds().Dx() - gifHeight := board1.Bounds().Dy() - - board2, err := png.Decode(bytes.NewBuffer(test_input2)) - assert.NoError(t, err) - gifWidth = go2.Max(board2.Bounds().Dx(), gifWidth) - gifHeight = go2.Max(board2.Bounds().Dy(), gifHeight) - boards := [][]byte{test_input1, test_input2} interval := 1_000 - gifBytes, err := AnimatePNGs(boards, gifWidth, gifHeight, interval) + gifBytes, err := AnimatePNGs(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) }