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/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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
1
go.mod
generated
1
go.mod
generated
|
|
@ -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
|
||||
|
|
|
|||
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.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=
|
||||
|
|
|
|||
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