diff --git a/.gitignore b/.gitignore index ccd315c..beffbd4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -bin -example.* test.* -*.exe +encoded.* +decoded.* +ffwebp.exe ffwebp \ No newline at end of file diff --git a/go.mod b/go.mod index 9354e14..87b68cd 100644 --- a/go.mod +++ b/go.mod @@ -21,15 +21,19 @@ require ( github.com/spakin/netpbm v1.3.2 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef + github.com/stretchr/testify v1.10.0 github.com/xyproto/xbm v1.0.0 golang.org/x/image v0.30.0 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/ebitengine/purego v0.8.4 // indirect github.com/gopherjs/gopherjs v1.17.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sergeymakinen/go-bmp v1.0.0 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/text v0.28.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e0aa853..ec1c106 100644 --- a/go.sum +++ b/go.sum @@ -56,5 +56,7 @@ golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/integration/example.jpeg b/integration/example.jpeg new file mode 100644 index 0000000..f154d09 Binary files /dev/null and b/integration/example.jpeg differ diff --git a/test/image.avif b/integration/images/image.avif similarity index 100% rename from test/image.avif rename to integration/images/image.avif diff --git a/test/image.bmp b/integration/images/image.bmp similarity index 100% rename from test/image.bmp rename to integration/images/image.bmp diff --git a/test/image.cur b/integration/images/image.cur similarity index 100% rename from test/image.cur rename to integration/images/image.cur diff --git a/test/image.ff b/integration/images/image.ff similarity index 100% rename from test/image.ff rename to integration/images/image.ff diff --git a/test/image.gif b/integration/images/image.gif similarity index 100% rename from test/image.gif rename to integration/images/image.gif diff --git a/test/image.heic b/integration/images/image.heic similarity index 100% rename from test/image.heic rename to integration/images/image.heic diff --git a/test/image.heif b/integration/images/image.heif similarity index 100% rename from test/image.heif rename to integration/images/image.heif diff --git a/test/image.ico b/integration/images/image.ico similarity index 100% rename from test/image.ico rename to integration/images/image.ico diff --git a/test/image.jpg b/integration/images/image.jpeg similarity index 100% rename from test/image.jpg rename to integration/images/image.jpeg diff --git a/test/image.jxl b/integration/images/image.jxl similarity index 100% rename from test/image.jxl rename to integration/images/image.jxl diff --git a/test/image.pcx b/integration/images/image.pcx similarity index 100% rename from test/image.pcx rename to integration/images/image.pcx diff --git a/test/image.png b/integration/images/image.png similarity index 100% rename from test/image.png rename to integration/images/image.png diff --git a/test/image.pnm b/integration/images/image.pnm similarity index 100% rename from test/image.pnm rename to integration/images/image.pnm diff --git a/test/image.psd b/integration/images/image.psd similarity index 100% rename from test/image.psd rename to integration/images/image.psd diff --git a/test/image.qoi b/integration/images/image.qoi similarity index 100% rename from test/image.qoi rename to integration/images/image.qoi diff --git a/test/image.svg b/integration/images/image.svg similarity index 100% rename from test/image.svg rename to integration/images/image.svg diff --git a/test/image.tga b/integration/images/image.tga similarity index 100% rename from test/image.tga rename to integration/images/image.tga diff --git a/test/image.tif b/integration/images/image.tif similarity index 100% rename from test/image.tif rename to integration/images/image.tif diff --git a/test/image.tiff b/integration/images/image.tiff similarity index 100% rename from test/image.tiff rename to integration/images/image.tiff diff --git a/test/image.webp b/integration/images/image.webp similarity index 100% rename from test/image.webp rename to integration/images/image.webp diff --git a/test/image.xbm b/integration/images/image.xbm similarity index 100% rename from test/image.xbm rename to integration/images/image.xbm diff --git a/integration/integration_test.go b/integration/integration_test.go new file mode 100644 index 0000000..1cc226d --- /dev/null +++ b/integration/integration_test.go @@ -0,0 +1,155 @@ +package integration + +import ( + "errors" + "fmt" + "image/jpeg" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// skip encoding tests for these extensions +var encodeOnly = map[string]bool{ + "heic": true, + "heif": true, + "psd": true, + "svg": true, +} + +func TestFFWebP(t *testing.T) { + // build a fresh executable with "full" tags + executable, err := buildExecutable() + require.NoError(t, err) + + // resolve all test files + files, err := listFiles("images") + require.NoError(t, err) + + // test all extensions + for _, path := range files { + ext := strings.TrimLeft(filepath.Ext(path), ".") + + decoded := "decoded.jpeg" + encoded := fmt.Sprintf("encoded.%s", ext) + + // test if we can convert from the codec to jpeg + t.Run(fmt.Sprintf("decode %s", ext), func(t *testing.T) { + defer os.Remove(decoded) + + err = runCommand(executable, "-i", path, "-o", decoded) + require.NoError(t, err) + + err = validateJPEG(decoded, 0) + require.NoError(t, err) + }) + + if encodeOnly[ext] { + continue + } + + // test if we can convert from jpeg to the codec and then back to jpeg + t.Run(fmt.Sprintf("encode %s", ext), func(t *testing.T) { + defer os.Remove(encoded) + defer os.Remove(decoded) + + err = runCommand(executable, "-i", "example.jpeg", "-o", encoded) + require.NoError(t, err) + + err = runCommand(executable, "-i", encoded, "-o", decoded) + require.NoError(t, err) + + err = validateJPEG(decoded, 256) + require.NoError(t, err) + }) + } +} + +func buildExecutable() (string, error) { + if runtime.GOOS == "windows" { + err := runCommand("go", "build", "-tags", "full", "-o", "ffwebp.exe", "..\\cmd\\ffwebp") + if err != nil { + return "", err + } + + return "./ffwebp.exe", nil + } + + err := runCommand("go", "build", "-tags", "full", "-o", "ffwebp", "../cmd/ffwebp") + if err != nil { + return "", err + } + + err = runCommand("chmod", "+x", "ffwebp") + if err != nil { + return "", err + } + + return "./ffwebp", nil +} + +func listFiles(directory string) ([]string, error) { + var files []string + + err := filepath.Walk(directory, func(path string, info fs.FileInfo, err error) error { + if err != nil || info.IsDir() { + return err + } + + files = append(files, path) + + return nil + }) + if err != nil { + return nil, err + } + + return files, nil +} + +func runCommand(command string, args ...string) error { + cmd := exec.Command(command, args...) + + out, err := cmd.CombinedOutput() + if err != nil { + if len(out) > 0 { + return errors.New(string(out)) + } + + return err + } + + return nil +} + +func validateJPEG(path string, requireSize int) error { + file, err := os.OpenFile(path, os.O_RDONLY, 0) + if err != nil { + return err + } + + defer file.Close() + + img, err := jpeg.Decode(file) + if err != nil { + return err + } + + bounds := img.Bounds() + + if bounds.Dx() == 0 || bounds.Dy() == 0 { + return fmt.Errorf("invalid dimensions: %dx%d", bounds.Dx(), bounds.Dy()) + } + + if requireSize != 0 && (bounds.Dx() != requireSize || bounds.Dy() != requireSize) { + return fmt.Errorf("mismatched size: %dx%dx", bounds.Dx(), bounds.Dy()) + } + + return nil +} diff --git a/internal/codec/detect.go b/internal/codec/detect.go index 291a968..e28c612 100644 --- a/internal/codec/detect.go +++ b/internal/codec/detect.go @@ -33,7 +33,7 @@ func Sniff(reader io.Reader, input string, ignoreExtension bool) (*Sniffed, io.R if !ignoreExtension { ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(input), ".")) if ext != "" { - codec, _ := FindCodec(ext) + codec, _ := FindCodec(ext, false) if codec != nil { return &Sniffed{ Header: []byte("." + ext), @@ -95,7 +95,7 @@ func Detect(output, override string) (Codec, string, error) { } } - codec, err := FindCodec(ext) + codec, err := FindCodec(ext, true) if err != nil { return nil, "", err } @@ -107,7 +107,7 @@ func Detect(output, override string) (Codec, string, error) { return codec, ext, nil } -func FindCodec(ext string) (Codec, error) { +func FindCodec(ext string, requireEncode bool) (Codec, error) { codec, ok := codecs[ext] if ok { return codec, nil @@ -116,7 +116,7 @@ func FindCodec(ext string) (Codec, error) { for _, codec := range codecs { for _, alias := range codec.Extensions() { if ext == alias { - if !codec.CanEncode() { + if requireEncode && !codec.CanEncode() { return nil, fmt.Errorf("decode-only output codec: %q", ext) }