diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index f73c396f..5fe74447 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -39,6 +39,9 @@ RUN go install github.com/air-verse/air@latest # Install lefthook RUN go install github.com/evilmartians/lefthook@latest +# Install gotestsum +RUN go install gotest.tools/gotestsum@latest + # Install golangci-lint RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.1.6 diff --git a/integration/load_test.go b/integration/load_test.go index 74de94c4..491bacc9 100644 --- a/integration/load_test.go +++ b/integration/load_test.go @@ -1,40 +1,38 @@ package integration import ( - "bytes" "fmt" - "image/png" - "io" "net/http" "os" "path" "path/filepath" - "strings" "testing" - "github.com/corona10/goimagehash" - "github.com/imgproxy/imgproxy/v3/config" "github.com/imgproxy/imgproxy/v3/imagetype" + "github.com/imgproxy/imgproxy/v3/testutil" "github.com/imgproxy/imgproxy/v3/vips" "github.com/stretchr/testify/suite" ) const ( - similarityThreshold = 5 // Distance between images to be considered similar + maxDistance = 0 // maximum image distance ) type LoadTestSuite struct { Suite - testImagesPath string + matcher *testutil.ImageHashMatcher + testImagesPath string + saveTmpImagesPath string } func (s *LoadTestSuite) SetupTest() { s.testImagesPath = s.TestData.Path("test-images") + s.saveTmpImagesPath = os.Getenv("TEST_SAVE_TMP_IMAGES") + s.matcher = testutil.NewImageHashMatcher(s.TestData) - config.MaxAnimationFrames = 999 - config.DevelopmentErrorsMode = true - + s.Config().Security.DefaultOptions.MaxAnimationFrames = 999 + s.Config().Server.DevelopmentErrorsMode = true s.Config().Fetcher.Transport.Local.Root = s.testImagesPath } @@ -55,40 +53,19 @@ func (s *LoadTestSuite) testLoadFolder(folder string) { } // get the base name of the file (8-bpp.png) - basePath := filepath.Base(path) - - // Replace the extension with .png - referencePath := strings.TrimSuffix(basePath, filepath.Ext(basePath)) + ".png" - - // Construct the full path to the reference image (integration/ folder) - referencePath = filepath.Join(s.testImagesPath, "integration", folder, referencePath) + baseName := filepath.Base(path) // Construct the source URL for imgproxy (no processing) - sourceUrl := fmt.Sprintf("/insecure/plain/local:///%s/%s@png", folder, basePath) + sourceUrl := fmt.Sprintf("/insecure/plain/local:///%s/%s@bmp", folder, baseName) - imgproxyImageBytes := s.fetchImage(sourceUrl) - imgproxyImage, err := png.Decode(bytes.NewReader(imgproxyImageBytes)) - s.Require().NoError(err, "Failed to decode PNG image from imgproxy for %s", basePath) + // Read source image from imgproxy + resp := s.GET(sourceUrl) + defer resp.Body.Close() - referenceFile, err := os.Open(referencePath) - s.Require().NoError(err) - defer referenceFile.Close() + s.Require().Equal(http.StatusOK, resp.StatusCode, "expected status code 200 OK, got %d, path: %s", resp.StatusCode, path) - referenceImage, err := png.Decode(referenceFile) - s.Require().NoError(err, "Failed to decode PNG reference image for %s", referencePath) - - hash1, err := goimagehash.DifferenceHash(imgproxyImage) - s.Require().NoError(err) - - hash2, err := goimagehash.DifferenceHash(referenceImage) - s.Require().NoError(err) - - distance, err := hash1.Distance(hash2) - s.Require().NoError(err) - - s.Require().LessOrEqual(distance, similarityThreshold, - "Image %s differs from reference image %s by %d, which is greater than the allowed threshold of %d", - basePath, referencePath, distance, similarityThreshold) + // Match image to precalculated hash + s.matcher.ImageMatches(s.T(), resp.Body, baseName, maxDistance) return nil }) @@ -96,19 +73,6 @@ func (s *LoadTestSuite) testLoadFolder(folder string) { s.Require().NoError(err) } -// fetchImage fetches an image from the imgproxy server -func (s *LoadTestSuite) fetchImage(path string) []byte { - resp := s.GET(path) - defer resp.Body.Close() - - s.Require().Equal(http.StatusOK, resp.StatusCode, "Expected status code 200 OK, got %d, path: %s", resp.StatusCode, path) - - bytes, err := io.ReadAll(resp.Body) - s.Require().NoError(err, "Failed to read response body from %s", path) - - return bytes -} - // TestLoadSaveToPng ensures that our load pipeline works, // including standard and custom loaders. For each source image // in the folder, it does the passthrough request through imgproxy: diff --git a/processing/processing_test.go b/processing/processing_test.go index ed1aadaa..a4f08219 100644 --- a/processing/processing_test.go +++ b/processing/processing_test.go @@ -9,51 +9,64 @@ import ( "github.com/stretchr/testify/suite" - "github.com/imgproxy/imgproxy/v3/config" "github.com/imgproxy/imgproxy/v3/fetcher" "github.com/imgproxy/imgproxy/v3/ierrors" "github.com/imgproxy/imgproxy/v3/imagedata" "github.com/imgproxy/imgproxy/v3/logger" "github.com/imgproxy/imgproxy/v3/options" "github.com/imgproxy/imgproxy/v3/security" + "github.com/imgproxy/imgproxy/v3/testutil" "github.com/imgproxy/imgproxy/v3/vips" ) type ProcessingTestSuite struct { - suite.Suite - idf *imagedata.Factory - pof *options.Factory + testutil.LazySuite + + imageDataFactory testutil.LazyObj[*imagedata.Factory] + securityConfig testutil.LazyObj[*security.Config] + security testutil.LazyObj[*security.Checker] + poConfig testutil.LazyObj[*options.Config] + po testutil.LazyObj[*options.Factory] } func (s *ProcessingTestSuite) SetupSuite() { - config.Reset() - - config.MaxSrcResolution = 10 * 1024 * 1024 - config.MaxSrcFileSize = 10 * 1024 * 1024 - config.MaxAnimationFrames = 100 - config.MaxAnimationFrameResolution = 10 * 1024 * 1024 - s.Require().NoError(vips.Init()) logger.Mute() - fc := fetcher.NewDefaultConfig() - f, err := fetcher.New(&fc) - s.Require().NoError(err) + s.imageDataFactory, _ = testutil.NewLazySuiteObj(s, func() (*imagedata.Factory, error) { + c := fetcher.NewDefaultConfig() + f, err := fetcher.New(&c) + if err != nil { + return nil, err + } - s.idf = imagedata.NewFactory(f) + return imagedata.NewFactory(f), nil + }) - scfg, err := security.LoadConfigFromEnv(nil) - s.Require().NoError(err) + s.securityConfig, _ = testutil.NewLazySuiteObj(s, func() (*security.Config, error) { + c := security.NewDefaultConfig() - security, err := security.New(scfg) - s.Require().NoError(err) + c.DefaultOptions.MaxSrcResolution = 10 * 1024 * 1024 + c.DefaultOptions.MaxSrcFileSize = 10 * 1024 * 1024 + c.DefaultOptions.MaxAnimationFrames = 100 + c.DefaultOptions.MaxAnimationFrameResolution = 10 * 1024 * 1024 - cfg, err := options.LoadConfigFromEnv(nil) - s.Require().NoError(err) + return &c, nil + }) - s.pof, err = options.NewFactory(cfg, security) - s.Require().NoError(err) + s.security, _ = testutil.NewLazySuiteObj(s, func() (*security.Checker, error) { + return security.New(s.securityConfig()) + }) + + s.poConfig, _ = testutil.NewLazySuiteObj(s, func() (*options.Config, error) { + c := options.NewDefaultConfig() + return &c, nil + }) + + s.po, _ = testutil.NewLazySuiteObj(s, func() (*options.Factory, error) { + return options.NewFactory(s.poConfig(), s.security()) + }) } func (s *ProcessingTestSuite) TearDownSuite() { @@ -65,7 +78,7 @@ func (s *ProcessingTestSuite) openFile(name string) imagedata.ImageData { s.Require().NoError(err) path := filepath.Join(wd, "..", "testdata", name) - imagedata, err := s.idf.NewFromPath(path) + imagedata, err := s.imageDataFactory().NewFromPath(path) s.Require().NoError(err) return imagedata @@ -80,7 +93,7 @@ func (s *ProcessingTestSuite) checkSize(r *Result, width, height int) { func (s *ProcessingTestSuite) TestResizeToFit() { imgdata := s.openFile("test2.jpg") - po := s.pof.NewProcessingOptions() + po := s.po().NewProcessingOptions() po.ResizingType = options.ResizeFit testCases := []struct { @@ -118,7 +131,7 @@ func (s *ProcessingTestSuite) TestResizeToFit() { func (s *ProcessingTestSuite) TestResizeToFitEnlarge() { imgdata := s.openFile("test2.jpg") - po := s.pof.NewProcessingOptions() + po := s.po().NewProcessingOptions() po.ResizingType = options.ResizeFit po.Enlarge = true @@ -157,7 +170,7 @@ func (s *ProcessingTestSuite) TestResizeToFitEnlarge() { func (s *ProcessingTestSuite) TestResizeToFitExtend() { imgdata := s.openFile("test2.jpg") - po := s.pof.NewProcessingOptions() + po := s.po().NewProcessingOptions() po.ResizingType = options.ResizeFit po.Extend = options.ExtendOptions{ Enabled: true, @@ -201,7 +214,7 @@ func (s *ProcessingTestSuite) TestResizeToFitExtend() { func (s *ProcessingTestSuite) TestResizeToFitExtendAR() { imgdata := s.openFile("test2.jpg") - po := s.pof.NewProcessingOptions() + po := s.po().NewProcessingOptions() po.ResizingType = options.ResizeFit po.ExtendAspectRatio = options.ExtendOptions{ Enabled: true, @@ -245,7 +258,7 @@ func (s *ProcessingTestSuite) TestResizeToFitExtendAR() { func (s *ProcessingTestSuite) TestResizeToFill() { imgdata := s.openFile("test2.jpg") - po := s.pof.NewProcessingOptions() + po := s.po().NewProcessingOptions() po.ResizingType = options.ResizeFill testCases := []struct { @@ -283,7 +296,7 @@ func (s *ProcessingTestSuite) TestResizeToFill() { func (s *ProcessingTestSuite) TestResizeToFillEnlarge() { imgdata := s.openFile("test2.jpg") - po := s.pof.NewProcessingOptions() + po := s.po().NewProcessingOptions() po.ResizingType = options.ResizeFill po.Enlarge = true @@ -322,7 +335,7 @@ func (s *ProcessingTestSuite) TestResizeToFillEnlarge() { func (s *ProcessingTestSuite) TestResizeToFillExtend() { imgdata := s.openFile("test2.jpg") - po := s.pof.NewProcessingOptions() + po := s.po().NewProcessingOptions() po.ResizingType = options.ResizeFill po.Extend = options.ExtendOptions{ Enabled: true, @@ -368,7 +381,7 @@ func (s *ProcessingTestSuite) TestResizeToFillExtend() { func (s *ProcessingTestSuite) TestResizeToFillExtendAR() { imgdata := s.openFile("test2.jpg") - po := s.pof.NewProcessingOptions() + po := s.po().NewProcessingOptions() po.ResizingType = options.ResizeFill po.ExtendAspectRatio = options.ExtendOptions{ Enabled: true, @@ -414,7 +427,7 @@ func (s *ProcessingTestSuite) TestResizeToFillExtendAR() { func (s *ProcessingTestSuite) TestResizeToFillDown() { imgdata := s.openFile("test2.jpg") - po := s.pof.NewProcessingOptions() + po := s.po().NewProcessingOptions() po.ResizingType = options.ResizeFillDown testCases := []struct { @@ -452,7 +465,7 @@ func (s *ProcessingTestSuite) TestResizeToFillDown() { func (s *ProcessingTestSuite) TestResizeToFillDownEnlarge() { imgdata := s.openFile("test2.jpg") - po := s.pof.NewProcessingOptions() + po := s.po().NewProcessingOptions() po.ResizingType = options.ResizeFillDown po.Enlarge = true @@ -491,7 +504,7 @@ func (s *ProcessingTestSuite) TestResizeToFillDownEnlarge() { func (s *ProcessingTestSuite) TestResizeToFillDownExtend() { imgdata := s.openFile("test2.jpg") - po := s.pof.NewProcessingOptions() + po := s.po().NewProcessingOptions() po.ResizingType = options.ResizeFillDown po.Extend = options.ExtendOptions{ Enabled: true, @@ -537,7 +550,7 @@ func (s *ProcessingTestSuite) TestResizeToFillDownExtend() { func (s *ProcessingTestSuite) TestResizeToFillDownExtendAR() { imgdata := s.openFile("test2.jpg") - po := s.pof.NewProcessingOptions() + po := s.po().NewProcessingOptions() po.ResizingType = options.ResizeFillDown po.ExtendAspectRatio = options.ExtendOptions{ Enabled: true, @@ -581,7 +594,7 @@ func (s *ProcessingTestSuite) TestResizeToFillDownExtendAR() { func (s *ProcessingTestSuite) TestResultSizeLimit() { imgdata := s.openFile("test2.jpg") - po := s.pof.NewProcessingOptions() + po := s.po().NewProcessingOptions() testCases := []struct { limit int @@ -1009,7 +1022,7 @@ func (s *ProcessingTestSuite) TestResultSizeLimit() { } func (s *ProcessingTestSuite) TestImageResolutionTooLarge() { - po := s.pof.NewProcessingOptions() + po := s.po().NewProcessingOptions() po.SecurityOptions.MaxSrcResolution = 1 imgdata := s.openFile("test2.jpg") diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/1-bpp.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/1-bpp.hash new file mode 100644 index 00000000..7b95af91 Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/1-bpp.hash differ diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/16-bpp.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/16-bpp.hash new file mode 100644 index 00000000..3a28ee61 Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/16-bpp.hash differ diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/24-bpp-no-alpha-mask.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/24-bpp-no-alpha-mask.hash new file mode 100644 index 00000000..4f00b051 Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/24-bpp-no-alpha-mask.hash differ diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/24-bpp.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/24-bpp.hash new file mode 100644 index 00000000..4f00b051 Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/24-bpp.hash differ diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/32-bpp-with-alpha-self-gen.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/32-bpp-with-alpha-self-gen.hash new file mode 100644 index 00000000..5b2e4f6d Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/32-bpp-with-alpha-self-gen.hash differ diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/32-bpp-with-alpha.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/32-bpp-with-alpha.hash new file mode 100644 index 00000000..4f00b051 Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/32-bpp-with-alpha.hash differ diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/4-bpp.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/4-bpp.hash new file mode 100644 index 00000000..3a28ee61 Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/4-bpp.hash differ diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp-rle-move-to-x.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp-rle-move-to-x.hash new file mode 100644 index 00000000..af6bf9cd Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp-rle-move-to-x.hash differ diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp-rle-single-color.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp-rle-single-color.hash new file mode 100644 index 00000000..6a8c8bdc Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp-rle-single-color.hash differ diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp-rle-small.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp-rle-small.hash new file mode 100644 index 00000000..db3b3d7a Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp-rle-small.hash differ diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp-rle.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp-rle.hash new file mode 100644 index 00000000..db3b3d7a Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp-rle.hash differ diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp.hash new file mode 100644 index 00000000..db3b3d7a Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/8-bpp.hash differ diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/gif.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/gif.hash new file mode 100644 index 00000000..754be8f1 Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/gif.hash differ diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/heif.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/heif.hash new file mode 100644 index 00000000..005a110b Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/heif.hash differ diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/jpg.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/jpg.hash new file mode 100644 index 00000000..ec962f62 Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/jpg.hash differ diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/jxl.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/jxl.hash new file mode 100644 index 00000000..d644c21b Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/jxl.hash differ diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/multi-bmp.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/multi-bmp.hash new file mode 100644 index 00000000..6a8c8bdc Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/multi-bmp.hash differ diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/multi-png.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/multi-png.hash new file mode 100644 index 00000000..6a8c8bdc Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/multi-png.hash differ diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/png-256x256.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/png-256x256.hash new file mode 100644 index 00000000..39a9cf78 Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/png-256x256.hash differ diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/single-bmp.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/single-bmp.hash new file mode 100644 index 00000000..287e431b Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/single-bmp.hash differ diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/svg.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/svg.hash new file mode 100644 index 00000000..95ab5a6b Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/svg.hash differ diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/tiff.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/tiff.hash new file mode 100644 index 00000000..9ae60805 Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/tiff.hash differ diff --git a/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/webp.hash b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/webp.hash new file mode 100644 index 00000000..92744d16 Binary files /dev/null and b/testdata/test-hashes/TestIntegration/TestLoadSaveToPng/webp.hash differ diff --git a/testutil/image_hash_matcher.c b/testutil/image_hash_matcher.c new file mode 100644 index 00000000..21dc3fd3 --- /dev/null +++ b/testutil/image_hash_matcher.c @@ -0,0 +1,82 @@ +#include "image_hash_matcher.h" + +/** + * vips_image_read_to_memory: converts VipsImage to RGBA format and reads into memory buffer + * @in: VipsImage to convert and read + * @buf: pointer to buffer pointer (will be allocated) + * @size: pointer to size_t to store the buffer size + * + * Converts the VipsImage to RGBA format using VIPS operations and reads the raw pixel data. + * The caller is responsible for freeing the buffer using vips_memory_buffer_free(). + * + * Returns: 0 on success, -1 on error + */ +int +vips_image_read_from_to_memory(void *in, size_t in_size, void **out, size_t *out_size, int *out_width, int *out_height) +{ + if (!in || !out || !out_size || !out_width || !out_height) { + vips_error("vips_image_read_from_to_memory", "invalid arguments"); + return -1; + } + + VipsImage *base = vips_image_new_from_buffer(in, in_size, "", NULL); + if (base == NULL) { + return -1; + } + + VipsImage **t = (VipsImage **) vips_object_local_array(VIPS_OBJECT(base), 2); + + // Initialize output parameters + *out = NULL; + *out_size = 0; + + // Convert to sRGB colorspace first if needed + if (vips_colourspace(base, &t[0], VIPS_INTERPRETATION_sRGB, NULL) != 0) { + VIPS_UNREF(base); + vips_error("vips_image_read_from_to_memory", "failed to convert to sRGB"); + return -1; + } + + in = t[0]; + + // Add alpha channel if not present (convert to RGBA) + if (!vips_image_hasalpha(base)) { + // Add alpha channel + if (vips_addalpha(base, &t[1], NULL) != 0) { + VIPS_UNREF(base); + vips_error("vips_image_read_from_to_memory", "failed to add alpha channel"); + return -1; + } + in = t[1]; + } + + // Get raw pixel data, width and height + *out = vips_image_write_to_memory(in, out_size); + *out_width = base->Xsize; + *out_height = base->Ysize; + + // Dispose the image regardless of the result + VIPS_UNREF(base); + + if (*out == NULL) { + vips_error("vips_image_read_from_to_memory", "failed to write image to memory"); + return -1; + } + + return 0; +} + +/** + * vips_memory_buffer_free: frees memory buffer allocated by vips_image_write_to_memory + * @buf: buffer pointer to free + * + * Frees the memory buffer allocated by vips_image_write_to_memory. + * Safe to call with NULL pointer. + */ +void +vips_memory_buffer_free(void *buf) +{ + if (buf) { + g_free(buf); + } +} diff --git a/testutil/image_hash_matcher.go b/testutil/image_hash_matcher.go new file mode 100644 index 00000000..870d36d4 --- /dev/null +++ b/testutil/image_hash_matcher.go @@ -0,0 +1,189 @@ +package testutil + +/* +#cgo pkg-config: vips +#cgo CFLAGS: -O3 +#cgo LDFLAGS: -lm +#include +#include "image_hash_matcher.h" +*/ +import "C" +import ( + "bytes" + "fmt" + "image" + "io" + "os" + "path" + "path/filepath" + "strings" + "testing" + "unsafe" + + "github.com/corona10/goimagehash" + "github.com/imgproxy/imgproxy/v3/imagetype" + "github.com/stretchr/testify/require" +) + +const ( + // hashPath is a path to hash data in testdata folder + hashPath = "test-hashes" + + // If TEST_CREATE_MISSING_HASHES is set, matcher would create missing hash files + createMissingHashesEnv = "TEST_CREATE_MISSING_HASHES" + + // If this is set, the images are saved to this folder before hash is calculated + saveTmpImagesPathEnv = "TEST_SAVE_TMP_IMAGES_PATH" +) + +// ImageHashMatcher is a helper struct for image hash comparison in tests +type ImageHashMatcher struct { + hashesPath string + createMissingHashes bool + saveTmpImagesPath string +} + +// NewImageHashMatcher creates a new ImageHashMatcher instance +func NewImageHashMatcher(testDataProvider *TestDataProvider) *ImageHashMatcher { + hashesPath := testDataProvider.Path(hashPath) + createMissingHashes := len(os.Getenv(createMissingHashesEnv)) > 0 + saveTmpImagesPath := os.Getenv(saveTmpImagesPathEnv) + + return &ImageHashMatcher{ + hashesPath: hashesPath, + createMissingHashes: createMissingHashes, + saveTmpImagesPath: saveTmpImagesPath, + } +} + +// ImageHashMatches is a testing helper, which accepts image as reader, calculates +// difference hash and compares it with a hash saved to testdata/test-hashes +// folder. +func (m *ImageHashMatcher) ImageMatches(t *testing.T, img io.Reader, key string, maxDistance int) { + t.Helper() + + // Read image in memory + buf, err := io.ReadAll(img) + require.NoError(t, err) + + // Save tmp image if requested + m.saveTmpImage(t, key, buf) + + // Convert to RGBA and read into memory using VIPS + var data unsafe.Pointer + var size C.size_t + var width, height C.int + + // no one knows why this triggers linter + //nolint:gocritic + readErr := C.vips_image_read_from_to_memory(unsafe.Pointer(unsafe.SliceData(buf)), C.size_t(len(buf)), &data, &size, &width, &height) + if readErr != 0 { + t.Fatal("failed to load image using VIPS") // FailNow() + } + defer C.vips_memory_buffer_free(data) + + // Convert raw RGBA pixel data to Go image.Image + goImg, err := createRGBAFromRGBAPixels(int(width), int(height), data, size) + require.NoError(t, err) + + sourceHash, err := goimagehash.DifferenceHash(goImg) + require.NoError(t, err) + + // Calculate image hash path (create folder if missing) + hashPath, err := m.makeTargetPath(t, m.hashesPath, t.Name(), key, "hash") + require.NoError(t, err) + + // Try to read or create the hash file + f, err := os.Open(hashPath) + if os.IsNotExist(err) { + // If the hash file does not exist, and we are not allowed to create it, fail + if !m.createMissingHashes { + require.NoError(t, err, "failed to read target hash from %s, use TEST_CREATE_MISSING_HASHES=true to create it", hashPath) + } + + // Create missing hash file + h, hashErr := os.Create(hashPath) + require.NoError(t, hashErr, "failed to create target hash file %s", hashPath) + defer h.Close() + + // Dump calculated source hash to this hash file + hashErr = sourceHash.Dump(h) + require.NoError(t, hashErr, "failed to write target hash to %s", hashPath) + + t.Logf("Created missing hash in %s", hashPath) + return + } + + // Otherwise, if there is no error or error is something else + require.NoError(t, err) + + // Load image hash from hash file + targetHash, err := goimagehash.LoadImageHash(f) + require.NoError(t, err, "failed to load target hash from %s", hashPath) + + // Ensure distance is OK + distance, err := sourceHash.Distance(targetHash) + require.NoError(t, err, "failed to calculate hash distance for %s", key) + + require.LessOrEqual(t, distance, maxDistance, "image hashes are too different for %s: distance %d", key, distance) +} + +// makeTargetPath creates the target directory and returns file path for saving +// the image or hash. +func (m *ImageHashMatcher) makeTargetPath(t *testing.T, base, folder, filename, ext string) (string, error) { + // Create the target directory if it doesn't exist + targetDir := path.Join(base, folder) + err := os.MkdirAll(targetDir, 0755) + require.NoError(t, err, "failed to create %s target directory", targetDir) + + // Replace the extension with the detected one + filename = strings.TrimSuffix(filename, filepath.Ext(filename)) + "." + ext + + // Create the target file + targetPath := path.Join(targetDir, filename) + + return targetPath, nil +} + +// saveTmpImage saves the provided image data to a temporary file +func (m *ImageHashMatcher) saveTmpImage(t *testing.T, key string, buf []byte) { + if m.saveTmpImagesPath == "" { + return + } + + // Detect the image type to get the correct extension + ext, err := imagetype.Detect(bytes.NewReader(buf)) + require.NoError(t, err) + + targetPath, err := m.makeTargetPath(t, m.saveTmpImagesPath, t.Name(), key, ext.String()) + require.NoError(t, err, "failed to create TEST_SAVE_TMP_IMAGES target path for %s/%s", t.Name(), key) + + targetFile, err := os.Create(targetPath) + require.NoError(t, err, "failed to create TEST_SAVE_TMP_IMAGES target file %s", targetPath) + defer targetFile.Close() + + // Write the image data to the file + _, err = io.Copy(targetFile, bytes.NewReader(buf)) + require.NoError(t, err, "failed to write to TEST_SAVE_TMP_IMAGES target file %s", targetPath) + + t.Logf("Saved temporary image to %s", targetPath) +} + +// createRGBAFromRGBAPixels creates a Go image.Image from raw RGBA VIPS pixel data +func createRGBAFromRGBAPixels(width, height int, data unsafe.Pointer, size C.size_t) (*image.RGBA, error) { + // RGBA should have 4 bands + expectedSize := width * height * 4 + if int(size) != expectedSize { + return nil, fmt.Errorf("size mismatch: expected %d bytes for RGBA, got %d", expectedSize, int(size)) + } + + pixels := unsafe.Slice((*byte)(data), int(size)) + + // Create RGBA image + img := image.NewRGBA(image.Rect(0, 0, width, height)) + + // Copy RGBA pixel data directly + copy(img.Pix, pixels) + + return img, nil +} diff --git a/testutil/image_hash_matcher.h b/testutil/image_hash_matcher.h new file mode 100644 index 00000000..f25095d6 --- /dev/null +++ b/testutil/image_hash_matcher.h @@ -0,0 +1,19 @@ +#include +#include // uintptr_t + +#include + +#ifndef TESTUTIL_IMAGE_HASH_MATCHER_H +#define TESTUTIL_IMAGE_HASH_MATCHER_H + +// Function to read VipsImage as RGBA into memory buffer +int vips_image_read_from_to_memory( + void *in, size_t in_size, // inner raw buffer and its size + void **out, size_t *out_size, // out raw buffer an its size + int *out_width, int *out_height // out image width and height +); + +// Function to free/discard the memory buffer +void vips_memory_buffer_free(void *buf); + +#endif diff --git a/vips/bmpsave.c b/vips/bmpsave.c index cf9a2bde..c66aba32 100644 --- a/vips/bmpsave.c +++ b/vips/bmpsave.c @@ -124,7 +124,7 @@ vips_foreign_save_bmp_build(VipsObject *object) // bands (3 or 4) * 8 bits int bands = vips_image_get_bands(in); - if ((bands > 3) || (bands > 4)) { + if ((bands < 3) || (bands > 4)) { vips_error("vips_foreign_save_bmp_build", "BMP source file must have 3 or 4 bands (RGB or RGBA)"); return -1; } diff --git a/vips/source.h b/vips/source.h index f6dca285..06adea83 100644 --- a/vips/source.h +++ b/vips/source.h @@ -21,7 +21,7 @@ typedef struct _VipsImgproxySourceClass { // creates new vips async source from a reader handle VipsImgproxySource *vips_new_imgproxy_source(uintptr_t readerHandle); -#endif - // unreferences the source, which leads to reader close void unref_imgproxy_source(VipsImgproxySource *source); + +#endif