From df2557835b2235b48d1ed979abb1a1d42607e96a Mon Sep 17 00:00:00 2001 From: Rob Watson Date: Sat, 25 May 2019 13:46:14 +0200 Subject: [PATCH] Improve handling of non-square avatars (#7025) * Crop avatar before resizing (#1268) Signed-off-by: Rob Watson * Fix spelling error Signed-off-by: Rob Watson --- go.mod | 1 + go.sum | 2 + models/user.go | 22 +-- modules/avatar/avatar.go | 50 +++++ modules/avatar/avatar_test.go | 49 +++++ modules/avatar/testdata/avatar.jpeg | Bin 0 -> 521 bytes modules/avatar/testdata/avatar.png | Bin 0 -> 159 bytes vendor/github.com/oliamb/cutter/.gitignore | 22 +++ vendor/github.com/oliamb/cutter/.travis.yml | 6 + vendor/github.com/oliamb/cutter/LICENSE | 20 ++ vendor/github.com/oliamb/cutter/README.md | 107 +++++++++++ vendor/github.com/oliamb/cutter/cutter.go | 192 ++++++++++++++++++++ vendor/modules.txt | 2 + 13 files changed, 454 insertions(+), 19 deletions(-) create mode 100644 modules/avatar/testdata/avatar.jpeg create mode 100644 modules/avatar/testdata/avatar.png create mode 100644 vendor/github.com/oliamb/cutter/.gitignore create mode 100644 vendor/github.com/oliamb/cutter/.travis.yml create mode 100644 vendor/github.com/oliamb/cutter/LICENSE create mode 100644 vendor/github.com/oliamb/cutter/README.md create mode 100644 vendor/github.com/oliamb/cutter/cutter.go diff --git a/go.mod b/go.mod index d02765fb10f4..299a4b29f949 100644 --- a/go.mod +++ b/go.mod @@ -90,6 +90,7 @@ require ( github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae // indirect github.com/msteinert/pam v0.0.0-20151204160544-02ccfbfaf0cc github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5 + github.com/oliamb/cutter v0.2.2 github.com/philhofer/fwd v1.0.0 // indirect github.com/pkg/errors v0.8.1 // indirect github.com/pquerna/otp v0.0.0-20160912161815-54653902c20e diff --git a/go.sum b/go.sum index 6b0a59d5b513..94d332cbc955 100644 --- a/go.sum +++ b/go.sum @@ -244,6 +244,8 @@ github.com/msteinert/pam v0.0.0-20151204160544-02ccfbfaf0cc h1:z1PgdCCmYYVL0BoJT github.com/msteinert/pam v0.0.0-20151204160544-02ccfbfaf0cc/go.mod h1:np1wUFZ6tyoke22qDJZY40URn9Ae51gX7ljIWXN5TJs= github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5 h1:BvoENQQU+fZ9uukda/RzCAL/191HHwJA5b13R6diVlY= github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/oliamb/cutter v0.2.2 h1:Lfwkya0HHNU1YLnGv2hTkzHfasrSMkgv4Dn+5rmlk3k= +github.com/oliamb/cutter v0.2.2/go.mod h1:4BenG2/4GuRBDbVm/OPahDVqbrOemzpPiG5mi1iryBU= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= diff --git a/models/user.go b/models/user.go index 7c7e81830ec3..f57c5a615d11 100644 --- a/models/user.go +++ b/models/user.go @@ -6,7 +6,6 @@ package models import ( - "bytes" "container/list" "crypto/md5" "crypto/sha256" @@ -14,7 +13,6 @@ import ( "encoding/hex" "errors" "fmt" - "image" // Needed for jpeg support _ "image/jpeg" @@ -39,7 +37,6 @@ import ( "github.com/go-xorm/builder" "github.com/go-xorm/core" "github.com/go-xorm/xorm" - "github.com/nfnt/resize" "golang.org/x/crypto/pbkdf2" "golang.org/x/crypto/ssh" ) @@ -457,23 +454,10 @@ func (u *User) IsPasswordSet() bool { // UploadAvatar saves custom avatar for user. // FIXME: split uploads to different subdirs in case we have massive users. func (u *User) UploadAvatar(data []byte) error { - imgCfg, _, err := image.DecodeConfig(bytes.NewReader(data)) + m, err := avatar.Prepare(data) if err != nil { - return fmt.Errorf("DecodeConfig: %v", err) + return err } - if imgCfg.Width > setting.AvatarMaxWidth { - return fmt.Errorf("Image width is to large: %d > %d", imgCfg.Width, setting.AvatarMaxWidth) - } - if imgCfg.Height > setting.AvatarMaxHeight { - return fmt.Errorf("Image height is to large: %d > %d", imgCfg.Height, setting.AvatarMaxHeight) - } - - img, _, err := image.Decode(bytes.NewReader(data)) - if err != nil { - return fmt.Errorf("Decode: %v", err) - } - - m := resize.Resize(avatar.AvatarSize, avatar.AvatarSize, img, resize.NearestNeighbor) sess := x.NewSession() defer sess.Close() @@ -497,7 +481,7 @@ func (u *User) UploadAvatar(data []byte) error { } defer fw.Close() - if err = png.Encode(fw, m); err != nil { + if err = png.Encode(fw, *m); err != nil { return fmt.Errorf("Encode: %v", err) } diff --git a/modules/avatar/avatar.go b/modules/avatar/avatar.go index f426978b3252..cf3da6df5ed9 100644 --- a/modules/avatar/avatar.go +++ b/modules/avatar/avatar.go @@ -5,13 +5,20 @@ package avatar import ( + "bytes" "fmt" "image" "image/color/palette" + // Enable PNG support: + _ "image/png" "math/rand" "time" + "code.gitea.io/gitea/modules/setting" + "github.com/issue9/identicon" + "github.com/nfnt/resize" + "github.com/oliamb/cutter" ) // AvatarSize returns avatar's size @@ -42,3 +49,46 @@ func RandomImageSize(size int, data []byte) (image.Image, error) { func RandomImage(data []byte) (image.Image, error) { return RandomImageSize(AvatarSize, data) } + +// Prepare accepts a byte slice as input, validates it contains an image of an +// acceptable format, and crops and resizes it appropriately. +func Prepare(data []byte) (*image.Image, error) { + imgCfg, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("DecodeConfig: %v", err) + } + if imgCfg.Width > setting.AvatarMaxWidth { + return nil, fmt.Errorf("Image width is too large: %d > %d", imgCfg.Width, setting.AvatarMaxWidth) + } + if imgCfg.Height > setting.AvatarMaxHeight { + return nil, fmt.Errorf("Image height is too large: %d > %d", imgCfg.Height, setting.AvatarMaxHeight) + } + + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("Decode: %v", err) + } + + if imgCfg.Width != imgCfg.Height { + var newSize, ax, ay int + if imgCfg.Width > imgCfg.Height { + newSize = imgCfg.Height + ax = (imgCfg.Width - imgCfg.Height) / 2 + } else { + newSize = imgCfg.Width + ay = (imgCfg.Height - imgCfg.Width) / 2 + } + + img, err = cutter.Crop(img, cutter.Config{ + Width: newSize, + Height: newSize, + Anchor: image.Point{ax, ay}, + }) + if err != nil { + return nil, err + } + } + + img = resize.Resize(AvatarSize, AvatarSize, img, resize.NearestNeighbor) + return &img, nil +} diff --git a/modules/avatar/avatar_test.go b/modules/avatar/avatar_test.go index 9eff5bc2be94..662d50faddb9 100644 --- a/modules/avatar/avatar_test.go +++ b/modules/avatar/avatar_test.go @@ -5,8 +5,11 @@ package avatar import ( + "io/ioutil" "testing" + "code.gitea.io/gitea/modules/setting" + "github.com/stretchr/testify/assert" ) @@ -17,3 +20,49 @@ func Test_RandomImage(t *testing.T) { _, err = RandomImageSize(0, []byte("gogs@local")) assert.Error(t, err) } + +func Test_PrepareWithPNG(t *testing.T) { + setting.AvatarMaxWidth = 4096 + setting.AvatarMaxHeight = 4096 + + data, err := ioutil.ReadFile("testdata/avatar.png") + assert.NoError(t, err) + + imgPtr, err := Prepare(data) + assert.NoError(t, err) + + assert.Equal(t, 290, (*imgPtr).Bounds().Max.X) + assert.Equal(t, 290, (*imgPtr).Bounds().Max.Y) +} + +func Test_PrepareWithJPEG(t *testing.T) { + setting.AvatarMaxWidth = 4096 + setting.AvatarMaxHeight = 4096 + + data, err := ioutil.ReadFile("testdata/avatar.jpeg") + assert.NoError(t, err) + + imgPtr, err := Prepare(data) + assert.NoError(t, err) + + assert.Equal(t, 290, (*imgPtr).Bounds().Max.X) + assert.Equal(t, 290, (*imgPtr).Bounds().Max.Y) +} + +func Test_PrepareWithInvalidImage(t *testing.T) { + setting.AvatarMaxWidth = 5 + setting.AvatarMaxHeight = 5 + + _, err := Prepare([]byte{}) + assert.EqualError(t, err, "DecodeConfig: image: unknown format") +} +func Test_PrepareWithInvalidImageSize(t *testing.T) { + setting.AvatarMaxWidth = 5 + setting.AvatarMaxHeight = 5 + + data, err := ioutil.ReadFile("testdata/avatar.png") + assert.NoError(t, err) + + _, err = Prepare(data) + assert.EqualError(t, err, "Image width is too large: 10 > 5") +} diff --git a/modules/avatar/testdata/avatar.jpeg b/modules/avatar/testdata/avatar.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..892b7baf78e4f8e8066f26b9b0042bcfefab1c8a GIT binary patch literal 521 zcmb7Am$Yia{;v?mD?Qbha0-8e>%QS}i0dEq8zBLos{0d>4jt8YBB z<)iGuFa2{5BEm)<$~V@~N)02bWQ;YYs*J1akqs^c@4Ro?DK~9wz2_Onhm>;;llfwn z7Soi|@Cj-0RAy|uWX BK?nc< literal 0 HcmV?d00001 diff --git a/modules/avatar/testdata/avatar.png b/modules/avatar/testdata/avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..c0f7922961601b6c812ac62b382d34574c14a4d4 GIT binary patch literal 159 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V6Od#Ih`sfV^(nyK@)?F|CfP6vY8S|xv6<2KrRD=b5UwyNotBhd1gt5g1e`0K#E=} wJ5XHI)5S4F;&Sqz|Nrfo^9~$o5bp`eWLWx`S@8b_%bg&dp00i_>zopr04tR#0RR91 literal 0 HcmV?d00001 diff --git a/vendor/github.com/oliamb/cutter/.gitignore b/vendor/github.com/oliamb/cutter/.gitignore new file mode 100644 index 000000000000..00268614f045 --- /dev/null +++ b/vendor/github.com/oliamb/cutter/.gitignore @@ -0,0 +1,22 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe diff --git a/vendor/github.com/oliamb/cutter/.travis.yml b/vendor/github.com/oliamb/cutter/.travis.yml new file mode 100644 index 000000000000..70e012b81e44 --- /dev/null +++ b/vendor/github.com/oliamb/cutter/.travis.yml @@ -0,0 +1,6 @@ +language: go + +go: + - 1.0 + - 1.1 + - tip diff --git a/vendor/github.com/oliamb/cutter/LICENSE b/vendor/github.com/oliamb/cutter/LICENSE new file mode 100644 index 000000000000..5412782c6e9a --- /dev/null +++ b/vendor/github.com/oliamb/cutter/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014 Olivier Amblet + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/github.com/oliamb/cutter/README.md b/vendor/github.com/oliamb/cutter/README.md new file mode 100644 index 000000000000..b54f9e3616c4 --- /dev/null +++ b/vendor/github.com/oliamb/cutter/README.md @@ -0,0 +1,107 @@ +Cutter +====== + +A Go library to crop images. + +[![Build Status](https://travis-ci.org/oliamb/cutter.png?branch=master)](https://travis-ci.org/oliamb/cutter) +[![GoDoc](https://godoc.org/github.com/oliamb/cutter?status.png)](https://godoc.org/github.com/oliamb/cutter) + +Cutter was initially developped to be able +to crop image resized using github.com/nfnt/resize. + +Usage +----- + +Read the doc on https://godoc.org/github.com/oliamb/cutter + +Import package with + +```go +import "github.com/oliamb/cutter" +``` + +Package cutter provides a function to crop image. + +By default, the original image will be cropped at the +given size from the top left corner. + +```go +croppedImg, err := cutter.Crop(img, cutter.Config{ + Width: 250, + Height: 500, +}) +``` + +Most of the time, the cropped image will share some memory +with the original, so it should be used read only. You must +ask explicitely for a copy if nedded. + +```go +croppedImg, err := cutter.Crop(img, cutter.Config{ + Width: 250, + Height: 500, + Options: cutter.Copy, +}) +``` + +It is possible to specify the top left position: + +```go +croppedImg, err := cutter.Crop(img, cutter.Config{ + Width: 250, + Height: 500, + Anchor: image.Point{100, 100}, + Mode: cutter.TopLeft, // optional, default value +}) +``` + +The Anchor property can represents the center of the cropped image +instead of the top left corner: + +```go +croppedImg, err := cutter.Crop(img, cutter.Config{ + Width: 250, + Height: 500, + Mode: cutter.Centered, +}) +``` + +The default crop use the specified dimension, but it is possible +to use Width and Heigth as a ratio instead. In this case, +the resulting image will be as big as possible to fit the asked ratio +from the anchor position. + +```go +croppedImg, err := cutter.Crop(baseImage, cutter.Config{ + Width: 4, + Height: 3, + Mode: cutter.Centered, + Options: cutter.Ratio&cutter.Copy, // Copy is useless here +}) +``` + +About resize +------------ +This lib only manage crop and won't resize image, but it works great in combination with [github.com/nfnt/resize](https://github.com/nfnt/resize) + +Contributing +------------ +I'd love to see your contributions to Cutter. If you'd like to hack on it: + +- fork the project, +- hack on it, +- ensure tests pass, +- make a pull request + +If you plan to modify the API, let's disscuss it first. + +Licensing +--------- +MIT License, Please see the file called LICENSE. + +Credits +------- +Test Picture: Gopher picture from Heidi Schuyt, http://www.flickr.com/photos/hschuyt/7674222278/, +© copyright Creative Commons(http://creativecommons.org/licenses/by-nc-sa/2.0/) + +Thanks to Urturn(http://www.urturn.com) for the time allocated to develop the library. diff --git a/vendor/github.com/oliamb/cutter/cutter.go b/vendor/github.com/oliamb/cutter/cutter.go new file mode 100644 index 000000000000..29d9d2f75882 --- /dev/null +++ b/vendor/github.com/oliamb/cutter/cutter.go @@ -0,0 +1,192 @@ +/* +Package cutter provides a function to crop image. + +By default, the original image will be cropped at the +given size from the top left corner. + + croppedImg, err := cutter.Crop(img, cutter.Config{ + Width: 250, + Height: 500, + }) + +Most of the time, the cropped image will share some memory +with the original, so it should be used read only. You must +ask explicitely for a copy if nedded. + + croppedImg, err := cutter.Crop(img, cutter.Config{ + Width: 250, + Height: 500, + Options: Copy, + }) + +It is possible to specify the top left position: + + croppedImg, err := cutter.Crop(img, cutter.Config{ + Width: 250, + Height: 500, + Anchor: image.Point{100, 100}, + Mode: TopLeft, // optional, default value + }) + +The Anchor property can represents the center of the cropped image +instead of the top left corner: + + + croppedImg, err := cutter.Crop(img, cutter.Config{ + Width: 250, + Height: 500, + Mode: Centered, + }) + +The default crop use the specified dimension, but it is possible +to use Width and Heigth as a ratio instead. In this case, +the resulting image will be as big as possible to fit the asked ratio +from the anchor position. + + croppedImg, err := cutter.Crop(baseImage, cutter.Config{ + Width: 4, + Height: 3, + Mode: Centered, + Options: Ratio, + }) +*/ +package cutter + +import ( + "image" + "image/draw" +) + +// Config is used to defined +// the way the crop should be realized. +type Config struct { + Width, Height int + Anchor image.Point // The Anchor Point in the source image + Mode AnchorMode // Which point in the resulting image the Anchor Point is referring to + Options Option +} + +// AnchorMode is an enumeration of the position an anchor can represent. +type AnchorMode int + +const ( + // TopLeft defines the Anchor Point + // as the top left of the cropped picture. + TopLeft AnchorMode = iota + // Centered defines the Anchor Point + // as the center of the cropped picture. + Centered = iota +) + +// Option flags to modify the way the crop is done. +type Option int + +const ( + // Ratio flag is use when Width and Height + // must be used to compute a ratio rather + // than absolute size in pixels. + Ratio Option = 1 << iota + // Copy flag is used to enforce the function + // to retrieve a copy of the selected pixels. + // This disable the use of SubImage method + // to compute the result. + Copy = 1 << iota +) + +// An interface that is +// image.Image + SubImage method. +type subImageSupported interface { + SubImage(r image.Rectangle) image.Image +} + +// Crop retrieves an image that is a +// cropped copy of the original img. +// +// The crop is made given the informations provided in config. +func Crop(img image.Image, c Config) (image.Image, error) { + maxBounds := c.maxBounds(img.Bounds()) + size := c.computeSize(maxBounds, image.Point{c.Width, c.Height}) + cr := c.computedCropArea(img.Bounds(), size) + cr = img.Bounds().Intersect(cr) + + if c.Options&Copy == Copy { + return cropWithCopy(img, cr) + } + if dImg, ok := img.(subImageSupported); ok { + return dImg.SubImage(cr), nil + } + return cropWithCopy(img, cr) +} + +func cropWithCopy(img image.Image, cr image.Rectangle) (image.Image, error) { + result := image.NewRGBA(cr) + draw.Draw(result, cr, img, cr.Min, draw.Src) + return result, nil +} + +func (c Config) maxBounds(bounds image.Rectangle) (r image.Rectangle) { + if c.Mode == Centered { + anchor := c.centeredMin(bounds) + w := min(anchor.X-bounds.Min.X, bounds.Max.X-anchor.X) + h := min(anchor.Y-bounds.Min.Y, bounds.Max.Y-anchor.Y) + r = image.Rect(anchor.X-w, anchor.Y-h, anchor.X+w, anchor.Y+h) + } else { + r = image.Rect(c.Anchor.X, c.Anchor.Y, bounds.Max.X, bounds.Max.Y) + } + return +} + +// computeSize retrieve the effective size of the cropped image. +// It is defined by Height, Width, and Ratio option. +func (c Config) computeSize(bounds image.Rectangle, ratio image.Point) (p image.Point) { + if c.Options&Ratio == Ratio { + // Ratio option is on, so we take the biggest size available that fit the given ratio. + if float64(ratio.X)/float64(bounds.Dx()) > float64(ratio.Y)/float64(bounds.Dy()) { + p = image.Point{bounds.Dx(), (bounds.Dx() / ratio.X) * ratio.Y} + } else { + p = image.Point{(bounds.Dy() / ratio.Y) * ratio.X, bounds.Dy()} + } + } else { + p = image.Point{ratio.X, ratio.Y} + } + return +} + +// computedCropArea retrieve the theorical crop area. +// It is defined by Height, Width, Mode and +func (c Config) computedCropArea(bounds image.Rectangle, size image.Point) (r image.Rectangle) { + min := bounds.Min + switch c.Mode { + case Centered: + rMin := c.centeredMin(bounds) + r = image.Rect(rMin.X-size.X/2, rMin.Y-size.Y/2, rMin.X-size.X/2+size.X, rMin.Y-size.Y/2+size.Y) + default: // TopLeft + rMin := image.Point{min.X + c.Anchor.X, min.Y + c.Anchor.Y} + r = image.Rect(rMin.X, rMin.Y, rMin.X+size.X, rMin.Y+size.Y) + } + return +} + +func (c *Config) centeredMin(bounds image.Rectangle) (rMin image.Point) { + if c.Anchor.X == 0 && c.Anchor.Y == 0 { + rMin = image.Point{ + X: bounds.Dx() / 2, + Y: bounds.Dy() / 2, + } + } else { + rMin = image.Point{ + X: c.Anchor.X, + Y: c.Anchor.Y, + } + } + return +} + +func min(a, b int) (r int) { + if a < b { + r = a + } else { + r = b + } + return +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 0013ea356f6d..0085f7bbdadd 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -261,6 +261,8 @@ github.com/mschoch/smat github.com/msteinert/pam # github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5 github.com/nfnt/resize +# github.com/oliamb/cutter v0.2.2 +github.com/oliamb/cutter # github.com/pelletier/go-buffruneio v0.2.0 github.com/pelletier/go-buffruneio # github.com/philhofer/fwd v1.0.0