Merge pull request #1200 from ejulio-ts/gh-1151-gif

GH 1151: export GIF
This commit is contained in:
Júlio César Batista 2023-04-17 14:47:27 -03:00 committed by GitHub
commit ac1ea5f9e5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 449 additions and 12 deletions

42
d2cli/export.go Normal file
View file

@ -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
}

88
d2cli/export_test.go Normal file
View file

@ -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())
})
}
}

View file

@ -38,6 +38,7 @@ import (
"oss.terrastruct.com/d2/lib/pptx" "oss.terrastruct.com/d2/lib/pptx"
"oss.terrastruct.com/d2/lib/textmeasure" "oss.terrastruct.com/d2/lib/textmeasure"
"oss.terrastruct.com/d2/lib/version" "oss.terrastruct.com/d2/lib/version"
"oss.terrastruct.com/d2/lib/xgif"
"cdr.dev/slog" "cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman" "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") 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 != "-" { if outputPath != "-" {
outputPath = ms.AbsPath(outputPath) outputPath = ms.AbsPath(outputPath)
if *animateIntervalFlag > 0 { if *animateIntervalFlag > 0 && !outputFormat.supportsAnimation() {
// Not checking for extension == "svg", because users may want to write SVG data to a non-svg-extension file return xmain.UsageErrorf("-animate-interval can only be used when exporting to SVG or GIF.\nYou provided: %s", filepath.Ext(outputPath))
if filepath.Ext(outputPath) == ".png" || filepath.Ext(outputPath) == ".pdf" || filepath.Ext(outputPath) == ".pptx" { } else if *animateIntervalFlag <= 0 && outputFormat.requiresAnimationInterval() {
return xmain.UsageErrorf("-animate-interval can only be used when exporting to SVG.\nYou provided: %s", filepath.Ext(outputPath)) 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) ms.Log.Debug.Printf("using layout plugin %s (%s)", *layoutFlag, plocation)
var pw png.Playwright if !outputFormat.supportsDarkTheme() {
if filepath.Ext(outputPath) == ".png" || filepath.Ext(outputPath) == ".pdf" || filepath.Ext(outputPath) == ".pptx" {
if darkThemeFlag != nil { if darkThemeFlag != nil {
ms.Log.Warn.Printf("--dark-theme cannot be used while exporting to another format other than .svg") ms.Log.Warn.Printf("--dark-theme cannot be used while exporting to another format other than .svg")
darkThemeFlag = nil darkThemeFlag = nil
} }
}
var pw png.Playwright
if outputFormat.requiresPNGRenderer() {
pw, err = png.InitPlaywright() pw, err = png.InitPlaywright()
if err != nil { if err != nil {
return err return err
@ -350,8 +356,29 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, rende
return nil, false, err return nil, false, err
} }
switch filepath.Ext(outputPath) { ext := getExportExtension(outputPath)
case ".pdf": 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) pageMap := buildBoardIDToIndex(diagram, nil, nil)
pdf, err := renderPDF(ctx, ms, plugin, renderOpts, outputPath, page, ruler, diagram, nil, nil, pageMap) pdf, err := renderPDF(ctx, ms, plugin, renderOpts, outputPath, page, ruler, diagram, nil, nil, pageMap)
if err != nil { if err != nil {
@ -360,7 +387,7 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, rende
dur := time.Since(start) dur := time.Since(start)
ms.Log.Success.Printf("successfully compiled %s to %s in %s", ms.HumanPath(inputPath), ms.HumanPath(outputPath), dur) ms.Log.Success.Printf("successfully compiled %s to %s in %s", ms.HumanPath(inputPath), ms.HumanPath(outputPath), dur)
return pdf, true, nil return pdf, true, nil
case ".pptx": case PPTX:
var username string var username string
if user, err := user.Current(); err == nil { if user, err := user.Current(); err == nil {
username = user.Username 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) { 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{ svg, err := d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: opts.Pad, Pad: opts.Pad,
Sketch: opts.Sketch, Sketch: opts.Sketch,
@ -1006,3 +1033,61 @@ func buildBoardIDToIndex(diagram *d2target.Diagram, dictionary map[string]int, p
return dictionary 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
}

View file

@ -10,6 +10,7 @@ import (
"oss.terrastruct.com/d2/d2cli" "oss.terrastruct.com/d2/d2cli"
"oss.terrastruct.com/d2/lib/pptx" "oss.terrastruct.com/d2/lib/pptx"
"oss.terrastruct.com/d2/lib/xgif"
"oss.terrastruct.com/util-go/assert" "oss.terrastruct.com/util-go/assert"
"oss.terrastruct.com/util-go/diff" "oss.terrastruct.com/util-go/diff"
"oss.terrastruct.com/util-go/xmain" "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) { run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
writeFile(t, dir, "x.d2", `x -> y`) writeFile(t, dir, "x.d2", `x -> y`)
err := runTestMain(t, ctx, dir, env, "--animate-interval=2", "x.d2", "x.png") 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`) You provided: .png`)
}, },
}, },
@ -245,6 +246,14 @@ layers: {
testdataIgnoreDiff(t, ".pdf", pdf) 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", name: "how_to_solve_problems_pptx",
skipCI: true, skipCI: true,
@ -277,6 +286,38 @@ steps: {
assert.Success(t, err) 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", name: "stdin",
run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) { run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {

1
go.mod generated
View file

@ -9,6 +9,7 @@ require (
github.com/dop251/goja v0.0.0-20230122112309-96b1610dd4f7 github.com/dop251/goja v0.0.0-20230122112309-96b1610dd4f7
github.com/dsoprea/go-exif/v3 v3.0.0-20221012082141-d21ac8e2de85 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/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/fsnotify/fsnotify v1.6.0
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/jung-kurt/gofpdf v1.16.2 github.com/jung-kurt/gofpdf v1.16.2

2
go.sum generated
View file

@ -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.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/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/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 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=

BIN
lib/xgif/test_input1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

BIN
lib/xgif/test_input2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

BIN
lib/xgif/test_output.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

143
lib/xgif/xgif.go Normal file
View file

@ -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
}

35
lib/xgif/xgif_test.go Normal file
View file

@ -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)
}