chore: add resize tests

pull/1044/head
Oleg Lobanov 2020-07-24 20:08:26 +02:00
parent aa78e3ab1f
commit cb8ac5ebf1
No known key found for this signature in database
GPG Key ID: 7CC64E41212621B0
5 changed files with 449 additions and 33 deletions

2
go.mod
View File

@ -25,11 +25,13 @@ require (
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.6.1
github.com/stretchr/testify v1.6.1
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce
github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
go.etcd.io/bbolt v1.3.3
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8
golang.org/x/net v0.0.0-20200528225125-3c3fba18258b // indirect
golang.org/x/sys v0.0.0-20200523222454-059865788121 // indirect
golang.org/x/text v0.3.2 // indirect

4
go.sum
View File

@ -206,6 +206,8 @@ github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
@ -318,4 +320,6 @@ gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@ -8,7 +8,6 @@ import (
"net/http"
"github.com/gorilla/mux"
"github.com/spf13/afero"
"github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/img"
@ -21,7 +20,7 @@ const (
type ImgService interface {
FormatFromExtension(ext string) (img.Format, error)
Resize(ctx context.Context, file afero.File, width, height int, out io.Writer, options ...img.Option) error
Resize(ctx context.Context, in io.Reader, width, height int, out io.Writer, options ...img.Option) error
}
func previewHandler(imgSvc ImgService, enableThumbnails, resizePreview bool) handleFunc {

View File

@ -2,14 +2,15 @@
package img
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"io"
"path/filepath"
"github.com/disintegration/imaging"
"github.com/marusama/semaphore/v2"
"github.com/spf13/afero"
)
// ErrUnsupportedFormat means the given image format is not supported.
@ -17,14 +18,12 @@ var ErrUnsupportedFormat = errors.New("unsupported image format")
// Service
type Service struct {
lowPrioritySem semaphore.Semaphore
highPrioritySem semaphore.Semaphore
sem semaphore.Semaphore
}
func New(workers int) *Service {
return &Service{
lowPrioritySem: semaphore.New(workers),
highPrioritySem: semaphore.New(workers),
sem: semaphore.New(workers),
}
}
@ -75,7 +74,7 @@ func (x Quality) resampleFilter() imaging.ResampleFilter {
case QualityLow:
return imaging.NearestNeighbor
default:
return imaging.Linear
return imaging.Box
}
}
@ -108,13 +107,19 @@ func (s *Service) FormatFromExtension(ext string) (Format, error) {
}
type resizeConfig struct {
prioritized bool
format Format
resizeMode ResizeMode
quality Quality
}
type Option func(*resizeConfig)
func WithFormat(format Format) Option {
return func(config *resizeConfig) {
config.format = format
}
}
func WithMode(mode ResizeMode) Option {
return func(config *resizeConfig) {
config.resizeMode = mode
@ -127,14 +132,19 @@ func WithQuality(quality Quality) Option {
}
}
func WithHighPriority() Option {
return func(config *resizeConfig) {
config.prioritized = true
func (s *Service) Resize(ctx context.Context, in io.Reader, width, height int, out io.Writer, options ...Option) error {
if err := s.sem.Acquire(ctx, 1); err != nil {
return err
}
defer s.sem.Release(1)
format, wrappedReader, err := s.detectFormat(in)
if err != nil {
return err
}
}
func (s *Service) Resize(ctx context.Context, file afero.File, width, height int, out io.Writer, options ...Option) error {
config := resizeConfig{
format: format,
resizeMode: ResizeModeFit,
quality: QualityMedium,
}
@ -142,21 +152,7 @@ func (s *Service) Resize(ctx context.Context, file afero.File, width, height int
option(&config)
}
sem := s.lowPrioritySem
if config.prioritized {
sem = s.highPrioritySem
}
if err := sem.Acquire(ctx, 1); err != nil {
return err
}
defer sem.Release(1)
format, err := s.FormatFromExtension(filepath.Ext(file.Name()))
if err != nil {
return ErrUnsupportedFormat
}
img, err := imaging.Decode(file, imaging.AutoOrientation(true))
img, err := imaging.Decode(wrappedReader, imaging.AutoOrientation(true))
if err != nil {
return err
}
@ -168,5 +164,22 @@ func (s *Service) Resize(ctx context.Context, file afero.File, width, height int
img = imaging.Fit(img, width, height, config.quality.resampleFilter())
}
return imaging.Encode(out, img, format.toImaging())
return imaging.Encode(out, img, config.format.toImaging())
}
func (s *Service) detectFormat(in io.Reader) (Format, io.Reader, error) {
buf := &bytes.Buffer{}
r := io.TeeReader(in, buf)
_, imgFormat, err := image.DecodeConfig(r)
if err != nil {
return 0, nil, fmt.Errorf("%s: %w", err.Error(), ErrUnsupportedFormat)
}
format, err := ParseFormat(imgFormat)
if err != nil {
return 0, nil, ErrUnsupportedFormat
}
return format, io.MultiReader(buf, in), nil
}

398
img/service_test.go Normal file
View File

@ -0,0 +1,398 @@
package img
import (
"bytes"
"context"
"errors"
"image"
"image/gif"
"image/jpeg"
"image/png"
"io"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"golang.org/x/image/bmp"
"golang.org/x/image/tiff"
)
func TestService_Resize(t *testing.T) {
testCases := map[string]struct {
options []Option
width int
height int
source func(t *testing.T) afero.File
matcher func(t *testing.T, reader io.Reader)
wantErr bool
}{
"fill upscale": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 50, 20)
},
matcher: sizeMatcher(100, 100),
},
"fill downscale": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: sizeMatcher(100, 100),
},
"fit upscale": {
options: []Option{WithMode(ResizeModeFit)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 50, 20)
},
matcher: sizeMatcher(50, 20),
},
"fit downscale": {
options: []Option{WithMode(ResizeModeFit)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: sizeMatcher(100, 75),
},
"keep original format": {
options: []Option{},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayPng(t, 200, 150)
},
matcher: formatMatcher(FormatPng),
},
"convert to jpeg": {
options: []Option{WithFormat(FormatJpeg)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: formatMatcher(FormatJpeg),
},
"convert to png": {
options: []Option{WithFormat(FormatPng)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: formatMatcher(FormatPng),
},
"convert to gif": {
options: []Option{WithFormat(FormatGif)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: formatMatcher(FormatGif),
},
"convert to tiff": {
options: []Option{WithFormat(FormatTiff)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: formatMatcher(FormatTiff),
},
"convert to bmp": {
options: []Option{WithFormat(FormatBmp)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: formatMatcher(FormatBmp),
},
"convert to unknown": {
options: []Option{WithFormat(Format(-1))},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: formatMatcher(FormatJpeg),
},
"resize png": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayPng(t, 200, 150)
},
matcher: sizeMatcher(100, 100),
},
"resize gif": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayGif(t, 200, 150)
},
matcher: sizeMatcher(100, 100),
},
"resize tiff": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayTiff(t, 200, 150)
},
matcher: sizeMatcher(100, 100),
},
"resize bmp": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayBmp(t, 200, 150)
},
matcher: sizeMatcher(100, 100),
},
"resize with high quality": {
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityHigh)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: sizeMatcher(100, 100),
},
"resize with medium quality": {
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityMedium)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: sizeMatcher(100, 100),
},
"resize with low quality": {
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: sizeMatcher(100, 100),
},
"resize with unknown quality": {
options: []Option{WithMode(ResizeModeFill), WithQuality(Quality(-1))},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
},
matcher: sizeMatcher(100, 100),
},
"broken file": {
options: []Option{WithMode(ResizeModeFit)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
fs := afero.NewMemMapFs()
file, err := fs.Create("image.jpg")
require.NoError(t, err)
_, err = file.WriteString("this is not an image")
require.NoError(t, err)
return file
},
wantErr: true,
},
}
for name, test := range testCases {
t.Run(name, func(t *testing.T) {
svc := New(1)
source := test.source(t)
defer source.Close()
buf := &bytes.Buffer{}
err := svc.Resize(context.Background(), source, test.width, test.height, buf, test.options...)
if (err != nil) != test.wantErr {
t.Fatalf("GetMarketSpecs() error = %v, wantErr %v", err, test.wantErr)
}
if err != nil {
return
}
test.matcher(t, buf)
})
}
}
func sizeMatcher(width, height int) func(t *testing.T, reader io.Reader) {
return func(t *testing.T, reader io.Reader) {
resizedImg, _, err := image.Decode(reader)
require.NoError(t, err)
require.Equal(t, width, resizedImg.Bounds().Dx())
require.Equal(t, height, resizedImg.Bounds().Dy())
}
}
func formatMatcher(format Format) func(t *testing.T, reader io.Reader) {
return func(t *testing.T, reader io.Reader) {
_, decodedFormat, err := image.DecodeConfig(reader)
require.NoError(t, err)
require.Equal(t, format.String(), decodedFormat)
}
}
func newGrayJpeg(t *testing.T, width, height int) afero.File {
fs := afero.NewMemMapFs()
file, err := fs.Create("image.jpg")
require.NoError(t, err)
img := image.NewGray(image.Rect(0, 0, width, height))
err = jpeg.Encode(file, img, &jpeg.Options{Quality: 90})
require.NoError(t, err)
_, err = file.Seek(0, io.SeekStart)
require.NoError(t, err)
return file
}
func newGrayPng(t *testing.T, width, height int) afero.File {
fs := afero.NewMemMapFs()
file, err := fs.Create("image.png")
require.NoError(t, err)
img := image.NewGray(image.Rect(0, 0, width, height))
err = png.Encode(file, img)
require.NoError(t, err)
_, err = file.Seek(0, io.SeekStart)
require.NoError(t, err)
return file
}
func newGrayGif(t *testing.T, width, height int) afero.File {
fs := afero.NewMemMapFs()
file, err := fs.Create("image.gif")
require.NoError(t, err)
img := image.NewGray(image.Rect(0, 0, width, height))
err = gif.Encode(file, img, nil)
require.NoError(t, err)
_, err = file.Seek(0, io.SeekStart)
require.NoError(t, err)
return file
}
func newGrayTiff(t *testing.T, width, height int) afero.File {
fs := afero.NewMemMapFs()
file, err := fs.Create("image.tiff")
require.NoError(t, err)
img := image.NewGray(image.Rect(0, 0, width, height))
err = tiff.Encode(file, img, nil)
require.NoError(t, err)
_, err = file.Seek(0, io.SeekStart)
require.NoError(t, err)
return file
}
func newGrayBmp(t *testing.T, width, height int) afero.File {
fs := afero.NewMemMapFs()
file, err := fs.Create("image.bmp")
require.NoError(t, err)
img := image.NewGray(image.Rect(0, 0, width, height))
err = bmp.Encode(file, img)
require.NoError(t, err)
_, err = file.Seek(0, io.SeekStart)
require.NoError(t, err)
return file
}
func TestService_FormatFromExtension(t *testing.T) {
testCases := map[string]struct {
ext string
want Format
wantErr error
}{
"jpg": {
ext: ".jpg",
want: FormatJpeg,
},
"jpeg": {
ext: ".jpeg",
want: FormatJpeg,
},
"png": {
ext: ".png",
want: FormatPng,
},
"gif": {
ext: ".gif",
want: FormatGif,
},
"tiff": {
ext: ".tiff",
want: FormatTiff,
},
"bmp": {
ext: ".bmp",
want: FormatBmp,
},
"unknown": {
ext: ".mov",
wantErr: ErrUnsupportedFormat,
},
}
for name, test := range testCases {
t.Run(name, func(t *testing.T) {
svc := New(1)
got, err := svc.FormatFromExtension(test.ext)
require.Truef(t, errors.Is(err, test.wantErr), "error = %v, wantErr %v", err, test.wantErr)
if err != nil {
return
}
require.Equal(t, test.want, got)
})
}
}