diff --git a/.travis.yml b/.travis.yml index 5277918..1777d8a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,10 +3,14 @@ services: - docker go: - - "1.8" + - "1.15" script: - docker run --entrypoint=/go/src/github.com/Pixboost/transformimgs/test.sh -v $(pwd):/go/src/github.com/Pixboost/transformimgs transformimgs before_install: - docker build -t transformimgs -f Dockerfile.dev . + +after_success: + - bash <(curl -s https://codecov.io/bash) + diff --git a/Dockerfile b/Dockerfile index 942f01d..77f7190 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,11 @@ FROM golang:1.14-buster AS build -#Installing godeps -RUN go get github.com/golang/dep/cmd/dep - RUN mkdir -p /go/src/github.com/Pixboost/ WORKDIR /go/src/github.com/Pixboost/ RUN git clone https://github.com/Pixboost/transformimgs.git WORKDIR /go/src/github.com/Pixboost/transformimgs/ -RUN dep ensure +RUN go mod vendor WORKDIR /go/src/github.com/Pixboost/transformimgs/cmd diff --git a/Dockerfile.dev b/Dockerfile.dev index aa7d920..e3e5aec 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -42,9 +42,6 @@ ENV PATH $GOPATH/bin:$PATH RUN mkdir -p "$GOPATH/src" "$GOPATH/bin" && chmod -R 777 "$GOPATH" WORKDIR $GOPATH -#Installing godeps -RUN go get github.com/golang/dep/cmd/dep - ENV IM_HOME /usr/local/bin VOLUME /go/src/github.com/Pixboost/transformimgs/ diff --git a/README.md b/README.md index ea36221..9600a01 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,17 @@ # TransformImgs [![Build Status](https://travis-ci.org/Pixboost/transformimgs.svg?branch=master)](https://travis-ci.org/Pixboost/transformimgs) +[![codecov](https://codecov.io/gh/Pixboost/transformimgs/branch/master/graph/badge.svg)](https://codecov.io/gh/Pixboost/transformimgs) +[![Docker Pulls](https://img.shields.io/docker/pulls/pixboost/transformimgs)](https://hub.docker.com/r/pixboost/transformimgs/) [![Docker Automated build](https://img.shields.io/docker/automated/jrottenberg/ffmpeg.svg)](https://hub.docker.com/r/pixboost/transformimgs/) -Image transformations web service. Provides Http API to image -manipulation operations backed by [Imagemagick](http://imagemagick.org) CLI. +Open Source [Image CDN](https://web.dev/image-cdns/) that provides image transformation API and supports +the latest image formats, such as WebP, AVIF. + +There are two ways of using the service: + +* Deploy on your own infrastructure using docker image +* Use as SaaS at [pixboost.com](https://pixboost.com?source=github) ## Table of Contents @@ -62,21 +69,19 @@ $ docker-compose up ### Building and Running from sources -Dependencies: +Prerequisites: -* Go 1.8+ -* [Gorilla MUX](https://github.com/gorilla/mux) for HTTP routing -* [kolibri](https://github.com/dooman87/kolibri) for healthcheck and testing -* [glogi](https://github.com/dooman87/glogi) for logging interface -* Installed [imagemagick](http://imagemagick.org) +* Go with [modules support](https://golang.org/ref/mod) +* Installed [imagemagick v7.0.25+](http://imagemagick.org) with AVIF support. Run script assumes that binaries are in `/usr/local/bin` ``` -$ go get github.com/golang/dep/cmd/dep -$ go get github.com/Pixboost/transformimgs -$ cd $GOPATH/src/github.com/Pixboost/transformimgs +$ git clone git@github.com:Pixboost/transformimgs.git +$ cd transformimgs $ ./run.sh ``` +Go modules have been introduced in v6. + ### Performance tests There is a [JMeter](https://jmeter.apache.org) performance test you can run against a service. To run tests: diff --git a/cmd/main.go b/cmd/main.go index 3c5747a..5eae118 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -20,9 +20,9 @@ package main import ( "flag" - "github.com/Pixboost/transformimgs/img" - "github.com/Pixboost/transformimgs/img/loader" - "github.com/Pixboost/transformimgs/img/processor" + "github.com/Pixboost/transformimgs/v6/img" + "github.com/Pixboost/transformimgs/v6/img/loader" + "github.com/Pixboost/transformimgs/v6/img/processor" "github.com/dooman87/kolibri/health" "net/http" "os" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0e33c32 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/Pixboost/transformimgs/v6 + +go 1.15 + +require ( + github.com/dooman87/glogi v0.0.0-20171229170332-1a9ee96f1380 + github.com/dooman87/kolibri v0.0.0-20170117194222-c194ff118b67 + github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f // indirect + github.com/gorilla/mux v1.3.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..963cefb --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/dooman87/glogi v0.0.0-20171229170332-1a9ee96f1380 h1:OC9HJVXdYvuq5z4t2lS7650zQKnXvUtFXellMX2zu7E= +github.com/dooman87/glogi v0.0.0-20171229170332-1a9ee96f1380/go.mod h1:uWlPVNZ0PJcbKCdXMJL/MGta7m/H+wg0nzy6ZKYvEGw= +github.com/dooman87/kolibri v0.0.0-20170117194222-c194ff118b67 h1:5zx4LUSP0iPn0KL6ciINexzNAw4imx4Db7B+LHCIP3s= +github.com/dooman87/kolibri v0.0.0-20170117194222-c194ff118b67/go.mod h1:IGXOwI2+tWhVzcLeKONI0eXxxFVC4+A5ZFCup6fuQqE= +github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f h1:9oNbS1z4rVpbnkHBdPZU4jo9bSmrLpII768arSyMFgk= +github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/mux v1.3.0 h1:HwSEKGN6U5T2aAQTfu5pW8fiwjSp3IgwdRbkICydk/c= +github.com/gorilla/mux v1.3.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= diff --git a/img/loader/http.go b/img/loader/http.go index 7368789..d3a9ab1 100644 --- a/img/loader/http.go +++ b/img/loader/http.go @@ -3,7 +3,7 @@ package loader import ( "context" "fmt" - "github.com/Pixboost/transformimgs/img" + "github.com/Pixboost/transformimgs/v6/img" "io/ioutil" "net/http" ) diff --git a/img/loader/http_test.go b/img/loader/http_test.go index becb051..04026ef 100644 --- a/img/loader/http_test.go +++ b/img/loader/http_test.go @@ -2,7 +2,7 @@ package loader_test import ( "context" - "github.com/Pixboost/transformimgs/img/loader" + "github.com/Pixboost/transformimgs/v6/img/loader" "github.com/dooman87/kolibri/test" "net/http" "net/http/httptest" diff --git a/img/processor/dummy.go b/img/processor/dummy.go deleted file mode 100644 index 2506816..0000000 --- a/img/processor/dummy.go +++ /dev/null @@ -1,18 +0,0 @@ -package processor - -type Dummy struct{} - -//Returns original data -func (p *Dummy) Resize(data []byte, size string, imgId string) ([]byte, error) { - return data, nil -} - -//Returns original data -func (p *Dummy) FitToSize(data []byte, size string, imgId string) ([]byte, error) { - return data, nil -} - -//Returns original data -func (p *Dummy) Optimise(data []byte, imgId string, supportedFormats []string) ([]byte, error) { - return data, nil -} diff --git a/img/processor/imagemagick.go b/img/processor/imagemagick.go index 6ae337d..f274904 100644 --- a/img/processor/imagemagick.go +++ b/img/processor/imagemagick.go @@ -3,7 +3,8 @@ package processor import ( "bytes" "fmt" - "github.com/Pixboost/transformimgs/img" + "github.com/Pixboost/transformimgs/v6/img" + "github.com/Pixboost/transformimgs/v6/img/processor/internal" "os/exec" ) @@ -16,15 +17,7 @@ type ImageMagick struct { // GetAdditionalArgs could return additional argument to ImageMagick "convert" command. // "op" is the name of the operation: "optimise", "resize" or "fit". // Argument name and value should be in separate array elements. - GetAdditionalArgs func(op string, image []byte, imageInfo *ImageInfo) []string -} - -type ImageInfo struct { - format string - quality int - opaque bool - width int - height int + GetAdditionalArgs func(op string, image []byte, imageInfo *img.Info) []string } var convertOpts = []string{ @@ -90,23 +83,30 @@ func NewImageMagick(im string, idi string) (*ImageMagick, error) { // // Format of the size argument is WIDTHxHEIGHT with any of the dimension could be dropped, e.g. 300, x200, 300x200. func (p *ImageMagick) Resize(data []byte, size string, imgId string, supportedFormats []string) (*img.Image, error) { - imgInfo, err := p.loadImageInfo(bytes.NewReader(data), imgId) + source, err := p.loadImageInfo(bytes.NewReader(data), imgId) if err != nil { return nil, err } - outputFormatArg, mimeType := getOutputFormat(imgInfo, supportedFormats) + target := &img.Info{ + Opaque: source.Opaque, + } + err = internal.CalculateTargetSizeForResize(source, target, size) + if err != nil { + img.Log.Errorf("could not calculate target size for [%s], size: [%s]\n", imgId, size) + } + outputFormatArg, mimeType := getOutputFormat(source, target, supportedFormats) args := make([]string, 0) args = append(args, "-") //Input args = append(args, "-resize", size) - args = append(args, getQualityOptions(imgInfo, mimeType)...) + args = append(args, getQualityOptions(source, mimeType)...) args = append(args, p.AdditionalArgs...) if p.GetAdditionalArgs != nil { - args = append(args, p.GetAdditionalArgs("resize", data, imgInfo)...) + args = append(args, p.GetAdditionalArgs("resize", data, source)...) } args = append(args, convertOpts...) - args = append(args, getConvertFormatOptions(imgInfo)...) + args = append(args, getConvertFormatOptions(source)...) args = append(args, outputFormatArg) //Output outputImageData, err := p.execImagemagick(bytes.NewReader(data), args, imgId) @@ -125,26 +125,33 @@ func (p *ImageMagick) Resize(data []byte, size string, imgId string, supportedFo // // Format of the size argument is WIDTHxHEIGHT, e.g. 300x200. Both dimensions must be included. func (p *ImageMagick) FitToSize(data []byte, size string, imgId string, supportedFormats []string) (*img.Image, error) { - imgInfo, err := p.loadImageInfo(bytes.NewReader(data), imgId) + source, err := p.loadImageInfo(bytes.NewReader(data), imgId) if err != nil { return nil, err } - outputFormatArg, mimeType := getOutputFormat(imgInfo, supportedFormats) + target := &img.Info{ + Opaque: source.Opaque, + } + err = internal.CalculateTargetSizeForFit(target, size) + if err != nil { + img.Log.Errorf("could not calculate target size for [%s], size: [%s]\n", imgId, size) + } + outputFormatArg, mimeType := getOutputFormat(source, target, supportedFormats) args := make([]string, 0) args = append(args, "-") //Input args = append(args, "-resize", size+"^") - args = append(args, getQualityOptions(imgInfo, mimeType)...) + args = append(args, getQualityOptions(source, mimeType)...) args = append(args, p.AdditionalArgs...) if p.GetAdditionalArgs != nil { - args = append(args, p.GetAdditionalArgs("fit", data, imgInfo)...) + args = append(args, p.GetAdditionalArgs("fit", data, source)...) } args = append(args, convertOpts...) args = append(args, cutToFitOpts...) args = append(args, "-extent", size) - args = append(args, getConvertFormatOptions(imgInfo)...) + args = append(args, getConvertFormatOptions(source)...) args = append(args, outputFormatArg) //Output outputImageData, err := p.execImagemagick(bytes.NewReader(data), args, imgId) @@ -159,23 +166,28 @@ func (p *ImageMagick) FitToSize(data []byte, size string, imgId string, supporte } func (p *ImageMagick) Optimise(data []byte, imgId string, supportedFormats []string) (*img.Image, error) { - imgInfo, err := p.loadImageInfo(bytes.NewReader(data), imgId) + source, err := p.loadImageInfo(bytes.NewReader(data), imgId) if err != nil { return nil, err } - outputFormatArg, mimeType := getOutputFormat(imgInfo, supportedFormats) + target := &img.Info{ + Opaque: source.Opaque, + Width: source.Width, + Height: source.Height, + } + outputFormatArg, mimeType := getOutputFormat(source, target, supportedFormats) args := make([]string, 0) args = append(args, "-") //Input - args = append(args, getQualityOptions(imgInfo, mimeType)...) + args = append(args, getQualityOptions(source, mimeType)...) args = append(args, p.AdditionalArgs...) if p.GetAdditionalArgs != nil { - args = append(args, p.GetAdditionalArgs("optimise", data, imgInfo)...) + args = append(args, p.GetAdditionalArgs("optimise", data, source)...) } args = append(args, convertOpts...) - args = append(args, getConvertFormatOptions(imgInfo)...) + args = append(args, getConvertFormatOptions(source)...) args = append(args, outputFormatArg) //Output result, err := p.execImagemagick(bytes.NewReader(data), args, imgId) @@ -217,7 +229,7 @@ func (p *ImageMagick) execImagemagick(in *bytes.Reader, args []string, imgId str return out.Bytes(), nil } -func (p *ImageMagick) loadImageInfo(in *bytes.Reader, imgId string) (*ImageInfo, error) { +func (p *ImageMagick) loadImageInfo(in *bytes.Reader, imgId string) (*img.Info, error) { var out, cmderr bytes.Buffer cmd := exec.Command(p.identifyCmd) cmd.Args = append(cmd.Args, "-format", "%m %Q %[opaque] %w %h", "-") @@ -236,8 +248,8 @@ func (p *ImageMagick) loadImageInfo(in *bytes.Reader, imgId string) (*ImageInfo, return nil, err } - imageInfo := &ImageInfo{} - _, err = fmt.Sscanf(out.String(), "%s %d %t %d %d", &imageInfo.format, &imageInfo.quality, &imageInfo.opaque, &imageInfo.width, &imageInfo.height) + imageInfo := &img.Info{} + _, err = fmt.Sscanf(out.String(), "%s %d %t %d %d", &imageInfo.Format, &imageInfo.Quality, &imageInfo.Opaque, &imageInfo.Width, &imageInfo.Height) if err != nil { return nil, err } @@ -245,16 +257,17 @@ func (p *ImageMagick) loadImageInfo(in *bytes.Reader, imgId string) (*ImageInfo, return imageInfo, nil } -func getOutputFormat(inf *ImageInfo, supportedFormats []string) (string, string) { +func getOutputFormat(src *img.Info, target *img.Info, supportedFormats []string) (string, string) { webP := false avif := false for _, f := range supportedFormats { - if f == "image/webp" && inf.height < MaxWebpHeight && inf.width < MaxWebpWidth { + if f == "image/webp" && src.Height < MaxWebpHeight && src.Width < MaxWebpWidth { webP = true } // ImageMagick doesn't support encoding of alpha channel for AVIF. // Converting 1000x1000 image into AVIF will consume about 500Mb of RAM. - if f == "image/avif" && inf.opaque && (inf.width*inf.height) < (1000*1000) { + targetSize := target.Width * target.Height + if f == "image/avif" && src.Opaque && targetSize < (1000*1000) && targetSize != 0 { avif = true } } @@ -269,12 +282,12 @@ func getOutputFormat(inf *ImageInfo, supportedFormats []string) (string, string) return "-", "" } -func getConvertFormatOptions(inf *ImageInfo) []string { - if inf.format == "PNG" { +func getConvertFormatOptions(inf *img.Info) []string { + if inf.Format == "PNG" { opts := []string{ "-define", "webp:lossless=true", } - if inf.opaque { + if inf.Opaque { opts = append(opts, "-colors", "256") } return opts @@ -283,15 +296,15 @@ func getConvertFormatOptions(inf *ImageInfo) []string { return []string{} } -func getQualityOptions(inf *ImageInfo, outputMimeType string) []string { - if inf.quality == 100 { +func getQualityOptions(inf *img.Info, outputMimeType string) []string { + if inf.Quality == 100 { return []string{"-quality", "82"} } if outputMimeType == "image/avif" { - if inf.quality > 85 { + if inf.Quality > 85 { return []string{"-quality", "70"} - } else if inf.quality > 75 { + } else if inf.Quality > 75 { return []string{"-quality", "60"} } else { return []string{"-quality", "50"} diff --git a/img/processor/imagemagick_test.go b/img/processor/imagemagick_test.go index 7105eca..2712e91 100644 --- a/img/processor/imagemagick_test.go +++ b/img/processor/imagemagick_test.go @@ -2,28 +2,17 @@ package processor_test import ( "fmt" - "github.com/Pixboost/transformimgs/img" - "github.com/Pixboost/transformimgs/img/processor" + "github.com/Pixboost/transformimgs/v6/img" + "github.com/Pixboost/transformimgs/v6/img/processor" "io/ioutil" "os" - "strings" "testing" ) -var ( - FILES = []string{ - "HT_Paper.png", - "HT_Stationery.png", - "JBBAKUMBBK_baku_medium_back_chair_black.jpg", - "otto-funhouse.jpg", - "OW20170515_HPHB_B2B2.jpg", - "OW20170515_HPHB_B2C4.jpg", - "Monochrome_CategoryImage2.png", - "otto-brights-stationery.jpg", - "ollie.png", - "webp-invalid-height.jpg", - } -) +type test struct { + file string + expectedOutputMimeType string +} type result struct { file string @@ -72,7 +61,7 @@ func BenchmarkImageMagickProcessor_Optimise_Avif(b *testing.B) { } func benchmarkWithFormats(b *testing.B, formats []string) { - f := fmt.Sprintf("%s/%s", "./test_files", "OW20170515_HPHB_B2B2.jpg") + f := fmt.Sprintf("%s/%s", "./test_files", "medium-jpeg.jpg") orig, err := ioutil.ReadFile(f) if err != nil { @@ -90,75 +79,122 @@ func benchmarkWithFormats(b *testing.B, formats []string) { processor.Debug = true } -func TestImageMagickProcessor_Optimise(t *testing.T) { - imgOpT(t, func(orig []byte, imgId string) (*img.Image, error) { - return proc.Optimise(orig, imgId, []string{}) - }) +func TestImageMagickProcessor_NoAccept(t *testing.T) { + tests := []*test{ + {"big-jpeg.jpg", ""}, + {"opaque-png.png", ""}, + {"transparent-png-use-original.png", ""}, + {"transparent-png.png", ""}, + } - imgOpT(t, func(orig []byte, imgId string) (*img.Image, error) { - return procWithArgs.Optimise(orig, imgId, []string{}) - }) -} + t.Run("optimise", func(t *testing.T) { + testImages(t, func(orig []byte, imgId string) (*img.Image, error) { + return proc.Optimise(orig, imgId, []string{}) + }, tests) -func TestImageMagickProcessor_Resize(t *testing.T) { - imgOpT(t, func(orig []byte, imgId string) (*img.Image, error) { - return proc.Resize(orig, "50", imgId, []string{}) + testImages(t, func(orig []byte, imgId string) (*img.Image, error) { + return procWithArgs.Optimise(orig, imgId, []string{}) + }, tests) }) - imgOpT(t, func(orig []byte, imgId string) (*img.Image, error) { - return procWithArgs.Resize(orig, "50", imgId, []string{}) - }) -} + t.Run("resize", func(t *testing.T) { + testImages(t, func(orig []byte, imgId string) (*img.Image, error) { + return proc.Resize(orig, "50", imgId, []string{}) + }, tests) -func TestImageMagickProcessor_FitToSize(t *testing.T) { - imgOpT(t, func(orig []byte, imgId string) (*img.Image, error) { - return proc.FitToSize(orig, "50x50", imgId, []string{}) + testImages(t, func(orig []byte, imgId string) (*img.Image, error) { + return procWithArgs.Resize(orig, "50", imgId, []string{}) + }, tests) }) - imgOpT(t, func(orig []byte, imgId string) (*img.Image, error) { - return procWithArgs.FitToSize(orig, "50x50", imgId, []string{}) + t.Run("fit", func(t *testing.T) { + testImages(t, func(orig []byte, imgId string) (*img.Image, error) { + return proc.FitToSize(orig, "50x50", imgId, []string{}) + }, tests) + + testImages(t, func(orig []byte, imgId string) (*img.Image, error) { + return procWithArgs.FitToSize(orig, "50x50", imgId, []string{}) + }, tests) }) } func TestImageMagickProcessor_Optimise_Webp(t *testing.T) { - imgOpT(t, func(orig []byte, imgId string) (*img.Image, error) { + testImages(t, func(orig []byte, imgId string) (*img.Image, error) { return proc.Optimise(orig, imgId, []string{"image/webp"}) - }) + }, + []*test{ + {"big-jpeg.jpg", "image/webp"}, + {"opaque-png.png", "image/webp"}, + {"transparent-png-use-original.png", "image/webp"}, + {"webp-invalid-height.jpg", ""}, + }) } func TestImageMagickProcessor_Resize_Webp(t *testing.T) { - imgOpT(t, func(orig []byte, imgId string) (*img.Image, error) { + testImages(t, func(orig []byte, imgId string) (*img.Image, error) { return proc.Resize(orig, "50", imgId, []string{"image/webp"}) - }) + }, + []*test{ + {"big-jpeg.jpg", "image/webp"}, + {"opaque-png.png", "image/webp"}, + {"transparent-png-use-original.png", "image/webp"}, + {"webp-invalid-height.jpg", ""}, + }) } func TestImageMagickProcessor_FitToSize_Webp(t *testing.T) { - imgOpT(t, func(orig []byte, imgId string) (*img.Image, error) { + testImages(t, func(orig []byte, imgId string) (*img.Image, error) { return proc.FitToSize(orig, "50x50", imgId, []string{"image/webp"}) - }) + }, + []*test{ + {"big-jpeg.jpg", "image/webp"}, + {"opaque-png.png", "image/webp"}, + {"transparent-png-use-original.png", "image/webp"}, + {"webp-invalid-height.jpg", ""}, + }) } func TestImageMagickProcessor_Optimise_Avif(t *testing.T) { - imgOpT(t, func(orig []byte, imgId string) (*img.Image, error) { + testImages(t, func(orig []byte, imgId string) (*img.Image, error) { return proc.Optimise(orig, imgId, []string{"image/avif"}) - }) + }, + []*test{ + {"big-jpeg.jpg", ""}, + {"medium-jpeg.jpg", "image/avif"}, + {"opaque-png.png", "image/avif"}, + {"transparent-png-use-original.png", ""}, + }) } func TestImageMagickProcessor_Resize_Avif(t *testing.T) { - imgOpT(t, func(orig []byte, imgId string) (*img.Image, error) { + testImages(t, func(orig []byte, imgId string) (*img.Image, error) { return proc.Resize(orig, "50", imgId, []string{"image/avif"}) - }) + }, + []*test{ + {"big-jpeg.jpg", "image/avif"}, + {"medium-jpeg.jpg", "image/avif"}, + {"opaque-png.png", "image/avif"}, + {"transparent-png-use-original.png", ""}, + }) } func TestImageMagickProcessor_FitToSize_Avif(t *testing.T) { - imgOpT(t, func(orig []byte, imgId string) (*img.Image, error) { + testImages(t, func(orig []byte, imgId string) (*img.Image, error) { return proc.FitToSize(orig, "50x50", imgId, []string{"image/avif"}) - }) + }, + []*test{ + {"big-jpeg.jpg", "image/avif"}, + {"medium-jpeg.jpg", "image/avif"}, + {"opaque-png.png", "image/avif"}, + {"transparent-png-use-original.png", ""}, + }) } -func imgOpT(t *testing.T, fn transform) { +func testImages(t *testing.T, fn transform, files []*test) { results := make([]*result, 0) - for _, imgFile := range FILES { + for _, tt := range files { + imgFile := tt.file + f := fmt.Sprintf("%s/%s", "./test_files", imgFile) orig, err := ioutil.ReadFile(f) @@ -166,7 +202,7 @@ func imgOpT(t *testing.T, fn transform) { t.Errorf("Can't read file %s: %+v", f, err) } - optimisedImg, err := fn(orig, f) + transformedImg, err := fn(orig, f) if err != nil { t.Errorf("Can't transform file: %+v", err) @@ -175,28 +211,16 @@ func imgOpT(t *testing.T, fn transform) { results = append(results, &result{ file: imgFile, origSize: len(orig), - optSize: len(optimisedImg.Data), + optSize: len(transformedImg.Data), }) //Writes converted file for manual verification. - // ioutil.WriteFile(fmt.Sprintf("./test_files/opt_%s_%s", t.Name(), imgFile), optimisedImg, 0777) - - if strings.HasSuffix(t.Name(), "_Webp") && optimisedImg.MimeType != "image/webp" && imgFile != "webp-invalid-height.jpg" { - t.Errorf("Expected image/webp mime type, but got %s", optimisedImg.MimeType) - } - - // AVIF doesn't support images with transparency, so MIME type will be empty in those cases - // Max size for AVIF is 1000x1000, so webp-invalid-height.jpg won't be processes as well - if strings.HasSuffix(t.Name(), "_Avif") && - !(optimisedImg.MimeType == "image/avif" || - len(optimisedImg.MimeType) == 0 && (imgFile == "HT_Paper.png" || imgFile == "HT_Stationery.png" || imgFile == "ollie.png" || imgFile == "webp-invalid-height.jpg")) { - t.Errorf("Expected image/avif mime type, but got %s", optimisedImg.MimeType) - } + // ioutil.WriteFile(fmt.Sprintf("./test_files/opt_%s_%s", t.Name(), imgFile), transformedImg, 0777) - if !strings.HasSuffix(t.Name(), "_Webp") && !strings.HasSuffix(t.Name(), "_Avif") && len(optimisedImg.MimeType) != 0 { - t.Errorf("Expected empty mime type, but got %s", optimisedImg.MimeType) + if transformedImg.MimeType != tt.expectedOutputMimeType { + t.Errorf("%s: Expected [%s] mime type, but got [%s]", tt.file, tt.expectedOutputMimeType, transformedImg.MimeType) } - if len(optimisedImg.Data) > len(orig) { + if len(transformedImg.Data) > len(orig) { t.Errorf("Image %s is not optimised", f) } } diff --git a/img/processor/internal/util.go b/img/processor/internal/util.go new file mode 100644 index 0000000..5f9bed2 --- /dev/null +++ b/img/processor/internal/util.go @@ -0,0 +1,69 @@ +package internal + +import ( + "fmt" + "github.com/Pixboost/transformimgs/v6/img" + "regexp" + "strconv" +) + +var ( + resizeRegexp = regexp.MustCompile(`^(\d*)[x]?(\d*)$`) + fitRegexp = regexp.MustCompile(`^(\d*)x(\d*)$`) +) + +func CalculateTargetSizeForFit(target *img.Info, targetSize string) error { + parsedSize := fitRegexp.FindStringSubmatch(targetSize) + if len(parsedSize) < 3 || len(parsedSize[1]) == 0 || len(parsedSize[2]) == 0 { + return fmt.Errorf("expected target size in format [WIDTH]x[HEIGHT], but got [%s]", targetSize) + } + + var err error + + target.Width, err = strconv.Atoi(parsedSize[1]) + if err != nil { + return fmt.Errorf("expected target size in format [WIDTH]x[HEIGHT], but got [%s]", targetSize) + } + + target.Height, err = strconv.Atoi(parsedSize[2]) + if err != nil { + return fmt.Errorf("expected target size in format [WIDTH]x[HEIGHT], but got [%s]", targetSize) + } + + return nil +} + +func CalculateTargetSizeForResize(source *img.Info, target *img.Info, targetSize string) error { + if source.Width <= 0 || source.Height <= 0 { + return nil + } + + var err error + + parsedSize := resizeRegexp.FindStringSubmatch(targetSize) + if len(parsedSize) < 3 { + return fmt.Errorf("expected target size in format [WIDTH]x[HEIGHT], but got [%s]", targetSize) + } + + if len(parsedSize[1]) > 0 { + target.Width, err = strconv.Atoi(parsedSize[1]) + if err != nil { + return fmt.Errorf("expected target size in format [WIDTH]x[HEIGHT], but got [%s]", targetSize) + } + } + // If width specified then height will follow aspect ratio + if len(parsedSize[2]) > 0 && target.Width == 0 { + target.Height, err = strconv.Atoi(parsedSize[2]) + if err != nil { + return fmt.Errorf("expected target size in format [WIDTH]x[HEIGHT], but got [%s]", targetSize) + } + } + aspectRatio := float32(source.Width) / float32(source.Height) + if target.Width > 0 { + target.Height = int(float32(target.Width) / aspectRatio) + } else if target.Height > 0 { + target.Width = int(float32(target.Height) * aspectRatio) + } + + return nil +} diff --git a/img/processor/internal/util_test.go b/img/processor/internal/util_test.go new file mode 100644 index 0000000..2c90f80 --- /dev/null +++ b/img/processor/internal/util_test.go @@ -0,0 +1,89 @@ +package internal + +import ( + "github.com/Pixboost/transformimgs/v6/img" + "testing" +) + +type fitTest struct { + targetSize string + expectedWidth int + expectedHeight int + error string +} + +func TestCalculateTargetSizeForFit(t *testing.T) { + tests := []*fitTest{ + {"400x300", 400, 300, ""}, + {"400", 0, 0, "expected target size in format [WIDTH]x[HEIGHT], but got [400]"}, + {"400x", 0, 0, "expected target size in format [WIDTH]x[HEIGHT], but got [400x]"}, + {"x300", 0, 0, "expected target size in format [WIDTH]x[HEIGHT], but got [x300]"}, + {"400ab300", 0, 0, "expected target size in format [WIDTH]x[HEIGHT], but got [400ab300]"}, + {"abc", 0, 0, "expected target size in format [WIDTH]x[HEIGHT], but got [abc]"}, + {"abx400", 0, 0, "expected target size in format [WIDTH]x[HEIGHT], but got [abx400]"}, + {"300xabc", 0, 0, "expected target size in format [WIDTH]x[HEIGHT], but got [300xabc]"}, + {"xabc", 0, 0, "expected target size in format [WIDTH]x[HEIGHT], but got [xabc]"}, + } + + for idx, tt := range tests { + target := &img.Info{} + err := CalculateTargetSizeForFit(target, tt.targetSize) + + if target.Width != tt.expectedWidth { + t.Errorf("Test %d failed: Expected [%d] width, but got [%d]", idx, tt.expectedWidth, target.Width) + } + if target.Height != tt.expectedHeight { + t.Errorf("Test %d failed: Expected [%d] height, but got [%d]", idx, tt.expectedHeight, target.Height) + } + if len(tt.error) == 0 && err != nil { + t.Errorf("Test %d failed: Expected no error, but got [%s]", idx, err) + } + if len(tt.error) > 0 && err.Error() != tt.error { + t.Errorf("Test %d failed: mismatched errors. Expected [%s], but got [%s]", idx, tt.error, err.Error()) + } + } +} + +type resizeTest struct { + sourceWidth int + sourceHeight int + targetSize string + expectedWidth int + expectedHeight int + error string +} + +func TestCalculateTargetSizeForResize(t *testing.T) { + tests := []*resizeTest{ + {800, 600, "400", 400, 300, ""}, + {800, 600, "400x", 400, 300, ""}, + {800, 600, "x300", 400, 300, ""}, + {800, 600, "400x300", 400, 300, ""}, + {800, 600, "400ab300", 0, 0, "expected target size in format [WIDTH]x[HEIGHT], but got [400ab300]"}, + {0, 0, "400x300", 0, 0, ""}, + {800, 600, "abc", 0, 0, "expected target size in format [WIDTH]x[HEIGHT], but got [abc]"}, + {800, 600, "abx400", 0, 0, "expected target size in format [WIDTH]x[HEIGHT], but got [abx400]"}, + {800, 600, "300xabc", 0, 0, "expected target size in format [WIDTH]x[HEIGHT], but got [300xabc]"}, + } + + for idx, tt := range tests { + target := &img.Info{} + err := CalculateTargetSizeForResize(&img.Info{ + Width: tt.sourceWidth, + Height: tt.sourceHeight, + }, target, tt.targetSize) + + if target.Width != tt.expectedWidth { + t.Errorf("Test %d failed: Expected [%d] width, but got [%d]", idx, tt.expectedWidth, target.Width) + } + if target.Height != tt.expectedHeight { + t.Errorf("Test %d failed: Expected [%d] height, but got [%d]", idx, tt.expectedHeight, target.Height) + } + if len(tt.error) == 0 && err != nil { + t.Errorf("Test %d failed: Expected no error, but got [%s]", idx, err) + } + if len(tt.error) > 0 && err.Error() != tt.error { + t.Errorf("Test %d failed: mismatched errors. Expected [%s], but got [%s]", idx, tt.error, err.Error()) + } + } +} diff --git a/img/processor/test_files/HT_Stationery.png b/img/processor/test_files/HT_Stationery.png deleted file mode 100644 index b091459..0000000 Binary files a/img/processor/test_files/HT_Stationery.png and /dev/null differ diff --git a/img/processor/test_files/JBBAKUMBBK_baku_medium_back_chair_black.jpg b/img/processor/test_files/JBBAKUMBBK_baku_medium_back_chair_black.jpg deleted file mode 100644 index 186e621..0000000 Binary files a/img/processor/test_files/JBBAKUMBBK_baku_medium_back_chair_black.jpg and /dev/null differ diff --git a/img/processor/test_files/OW20170515_HPHB_B2C4.jpg b/img/processor/test_files/OW20170515_HPHB_B2C4.jpg deleted file mode 100644 index 9428612..0000000 Binary files a/img/processor/test_files/OW20170515_HPHB_B2C4.jpg and /dev/null differ diff --git a/img/processor/test_files/big-jpeg.jpg b/img/processor/test_files/big-jpeg.jpg new file mode 100644 index 0000000..4e27bc4 Binary files /dev/null and b/img/processor/test_files/big-jpeg.jpg differ diff --git a/img/processor/test_files/OW20170515_HPHB_B2B2.jpg b/img/processor/test_files/medium-jpeg.jpg similarity index 100% rename from img/processor/test_files/OW20170515_HPHB_B2B2.jpg rename to img/processor/test_files/medium-jpeg.jpg diff --git a/img/processor/test_files/Monochrome_CategoryImage2.png b/img/processor/test_files/opaque-png.png similarity index 100% rename from img/processor/test_files/Monochrome_CategoryImage2.png rename to img/processor/test_files/opaque-png.png diff --git a/img/processor/test_files/otto-brights-stationery.jpg b/img/processor/test_files/otto-brights-stationery.jpg deleted file mode 100644 index fc8a9a8..0000000 Binary files a/img/processor/test_files/otto-brights-stationery.jpg and /dev/null differ diff --git a/img/processor/test_files/otto-funhouse.jpg b/img/processor/test_files/otto-funhouse.jpg deleted file mode 100644 index 39bf74a..0000000 Binary files a/img/processor/test_files/otto-funhouse.jpg and /dev/null differ diff --git a/img/processor/test_files/HT_Paper.png b/img/processor/test_files/transparent-png-use-original.png similarity index 100% rename from img/processor/test_files/HT_Paper.png rename to img/processor/test_files/transparent-png-use-original.png diff --git a/img/processor/test_files/ollie.png b/img/processor/test_files/transparent-png.png similarity index 100% rename from img/processor/test_files/ollie.png rename to img/processor/test_files/transparent-png.png diff --git a/img/queue.go b/img/queue.go index c138d16..9fc8335 100644 --- a/img/queue.go +++ b/img/queue.go @@ -22,7 +22,10 @@ func (q *Queue) start() { op.Result, op.Err = op.Optimise(op.Image, op.ImgId, op.SupportedFormats) } } + op.FinishedCond.L.Lock() op.Finished = true + op.FinishedCond.L.Unlock() + op.FinishedCond.Signal() } } diff --git a/img/service.go b/img/service.go index c2bd954..ca5fbd9 100644 --- a/img/service.go +++ b/img/service.go @@ -20,11 +20,6 @@ var CacheTTL int // By default is using glogi.SimpleLogger. var Log glogi.Logger = glogi.NewSimpleLogger() -type Image struct { - Data []byte - MimeType string -} - // Loaders is responsible for loading an original image for transformation type Loader interface { // Load loads an image from the given source. @@ -200,6 +195,14 @@ func (r *Service) ResizeUrl(resp http.ResponseWriter, req *http.Request) { http.Error(resp, "size param is required", http.StatusBadRequest) return } + if match, err := regexp.MatchString(`^\d*[x]?\d*$`, size); !match || err != nil { + if err != nil { + Log.Printf("Error while matching size: %s\n", err.Error()) + } + http.Error(resp, "size param should be in format WxH", http.StatusBadRequest) + return + } + supportedFormats := getSupportedFormats(req) Log.Printf("Resizing image %s to %s\n", imgUrl, size) diff --git a/img/service_test.go b/img/service_test.go index 82ab28f..1fda035 100644 --- a/img/service_test.go +++ b/img/service_test.go @@ -3,7 +3,7 @@ package img_test import ( "context" "errors" - "github.com/Pixboost/transformimgs/img" + "github.com/Pixboost/transformimgs/v6/img" "github.com/dooman87/kolibri/test" "net/http" "net/http/httptest" @@ -185,7 +185,22 @@ func TestService_ResizeUrl(t *testing.T) { }, { Url: "http://localhost/img/http%3A%2F%2Fsite.com/img.png/resize?size=BADSIZE", - ExpectedCode: http.StatusInternalServerError, + ExpectedCode: http.StatusBadRequest, + Description: "Resize error", + }, + { + Url: "http://localhost/img/http%3A%2F%2Fsite.com/img.png/resize?size=300xx", + ExpectedCode: http.StatusBadRequest, + Description: "Resize error", + }, + { + Url: "http://localhost/img/http%3A%2F%2Fsite.com/img.png/resize?size=abcx200", + ExpectedCode: http.StatusBadRequest, + Description: "Resize error", + }, + { + Url: "http://localhost/img/http%3A%2F%2Fsite.com/img.png/resize?size=300xabc", + ExpectedCode: http.StatusBadRequest, Description: "Resize error", }, } diff --git a/img/types.go b/img/types.go new file mode 100644 index 0000000..a7d5759 --- /dev/null +++ b/img/types.go @@ -0,0 +1,15 @@ +package img + +type Image struct { + Data []byte + MimeType string +} + +// Info holds basic information about image +type Info struct { + Format string + Quality int + Opaque bool + Width int + Height int +} diff --git a/run.sh b/run.sh index 7e38374..760164a 100644 --- a/run.sh +++ b/run.sh @@ -7,7 +7,7 @@ set -e # script to run the application inside docker # container. -dep ensure +go mod vendor echo 'Running Tests' go test $(go list ./... | grep -v /vendor/) -v -bench . -benchmem diff --git a/test.sh b/test.sh index 4c6d761..0b6d4b5 100755 --- a/test.sh +++ b/test.sh @@ -2,7 +2,7 @@ set -e -dep ensure +go mod vendor echo 'Running Tests' -go test $(go list ./... | grep -v /vendor/) -v -bench . -benchmem +go test $(go list ./... | grep -v /vendor/) -v -bench . -benchmem -race -coverprofile=coverage.txt -covermode=atomic