Merge pull request #1200 from ejulio-ts/gh-1151-gif
GH 1151: export GIF
This commit is contained in:
commit
ac1ea5f9e5
11 changed files with 449 additions and 12 deletions
42
d2cli/export.go
Normal file
42
d2cli/export.go
Normal 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
88
d2cli/export_test.go
Normal 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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
107
d2cli/main.go
107
d2cli/main.go
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
1
go.mod
generated
|
|
@ -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
2
go.sum
generated
|
|
@ -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
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
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
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
143
lib/xgif/xgif.go
Normal 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
35
lib/xgif/xgif_test.go
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue