diff --git a/Gopkg.lock b/Gopkg.lock index 182d8e555..763afa570 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -206,6 +206,15 @@ packages = ["."] revision = "b4575eea38cca1123ec2dc90c26529b5c5acfcff" +[[projects]] + branch = "master" + name = "github.com/muesli/smartcrop" + packages = [ + ".", + "options" + ] + revision = "1db484956b9ef929344e51701299a017beefdaaa" + [[projects]] name = "github.com/nicksnyder/go-i18n" packages = [ @@ -320,6 +329,8 @@ name = "golang.org/x/image" packages = [ "bmp", + "draw", + "math/f64", "riff", "tiff", "tiff/lzw", @@ -381,6 +392,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "c80ffe69d34005d8d72a87cc491ce1d9c91272e4b7f8fbd22d4fda8973fa8556" + inputs-digest = "ce63da7f660e0ba60a8ae81f5808f8e685b2055169838fbc3c4d5c418e58b3d1" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index b07a41f7c..9c872585a 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -15,7 +15,7 @@ [[constraint]] branch = "master" name = "github.com/bep/gitmap" - + [[constraint]] name = "github.com/chaseadamsio/goorgeous" version = "^1.1.0" @@ -135,3 +135,9 @@ [[constraint]] name = "github.com/gobwas/glob" version = "0.2.2" + + +[[constraint]] + name = "github.com/muesli/smartcrop" + branch = "master" + diff --git a/resource/image.go b/resource/image.go index 7ec65f3bc..c9ee90bf1 100644 --- a/resource/image.go +++ b/resource/image.go @@ -35,7 +35,6 @@ import ( _ "image/png" "github.com/disintegration/imaging" - // Import webp codec "sync" @@ -56,6 +55,9 @@ type Imaging struct { // Resample filter used. See https://github.com/disintegration/imaging ResampleFilter string + + // The anchor used in Fill. Default is "smart", i.e. Smart Crop. + Anchor string } const ( @@ -157,6 +159,9 @@ func (i *Image) Fit(spec string) (*Image, error) { // Space delimited config: 200x300 TopLeft func (i *Image) Fill(spec string) (*Image, error) { return i.doWithImageConfig("fill", spec, func(src image.Image, conf imageConfig) (image.Image, error) { + if conf.AnchorStr == smartCropIdentifier { + return smartCrop(src, conf.Width, conf.Height, conf.Anchor, conf.Filter) + } return imaging.Fill(src, conf.Width, conf.Height, conf.Anchor, conf.Filter), nil }) } @@ -206,6 +211,13 @@ func (i *Image) doWithImageConfig(action, spec string, f func(src image.Image, c conf.Filter = imageFilters[conf.FilterStr] } + if conf.AnchorStr == "" { + conf.AnchorStr = i.imaging.Anchor + if !strings.EqualFold(conf.AnchorStr, smartCropIdentifier) { + conf.Anchor = anchorPositions[conf.AnchorStr] + } + } + key := i.relTargetPathForRel(i.filenameFromConfig(conf), false) return i.spec.imageCache.getOrCreate(i, key, func(resourceCacheFilename string) (*Image, error) { @@ -248,18 +260,22 @@ func (i imageConfig) key() string { if i.Rotate != 0 { k += "_r" + strconv.Itoa(i.Rotate) } - k += "_" + i.FilterStr + "_" + i.AnchorStr + anchor := i.AnchorStr + if anchor == smartCropIdentifier { + anchor = anchor + strconv.Itoa(smartCropVersionNumber) + } + + k += "_" + i.FilterStr + + if strings.EqualFold(i.Action, "fill") { + k += "_" + anchor + } + return k } -var defaultImageConfig = imageConfig{ - Action: "", - Anchor: imaging.Center, - AnchorStr: strings.ToLower("Center"), -} - func newImageConfig(width, height, quality, rotate int, filter, anchor string) imageConfig { - c := defaultImageConfig + var c imageConfig c.Width = width c.Height = height @@ -287,7 +303,7 @@ func newImageConfig(width, height, quality, rotate int, filter, anchor string) i func parseImageConfig(config string) (imageConfig, error) { var ( - c = defaultImageConfig + c imageConfig err error ) @@ -299,7 +315,9 @@ func parseImageConfig(config string) (imageConfig, error) { for _, part := range parts { part = strings.ToLower(part) - if pos, ok := anchorPositions[part]; ok { + if part == smartCropIdentifier { + c.AnchorStr = smartCropIdentifier + } else if pos, ok := anchorPositions[part]; ok { c.Anchor = pos c.AnchorStr = part } else if filter, ok := imageFilters[part]; ok { @@ -561,8 +579,19 @@ func decodeImaging(m map[string]interface{}) (Imaging, error) { return i, err } - if i.Quality <= 0 || i.Quality > 100 { + if i.Quality == 0 { i.Quality = defaultJPEGQuality + } else if i.Quality < 0 || i.Quality > 100 { + return i, errors.New("JPEG quality must be a number between 1 and 100") + } + + if i.Anchor == "" || strings.EqualFold(i.Anchor, smartCropIdentifier) { + i.Anchor = smartCropIdentifier + } else { + i.Anchor = strings.ToLower(i.Anchor) + if _, found := anchorPositions[i.Anchor]; !found { + return i, errors.New("invalid anchor value in imaging config") + } } if i.ResampleFilter == "" { diff --git a/resource/image_test.go b/resource/image_test.go index bf097b319..de706b0ac 100644 --- a/resource/image_test.go +++ b/resource/image_test.go @@ -82,13 +82,13 @@ func TestImageTransform(t *testing.T) { assert.Equal(200, resizedAndRotated.Height()) assertFileCache(assert, image.spec.Fs, resizedAndRotated.RelPermalink(), 125, 200) - assert.Equal("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_resize_q75_box_center.jpg", resized.RelPermalink()) + assert.Equal("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_resize_q68_linear.jpg", resized.RelPermalink()) assert.Equal(300, resized.Width()) assert.Equal(200, resized.Height()) fitted, err := resized.Fit("50x50") assert.NoError(err) - assert.Equal("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_0bda5208a94b50a6e643ad139e0dfa2f.jpg", fitted.RelPermalink()) + assert.Equal("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_625708021e2bb281c9f1002f88e4753f.jpg", fitted.RelPermalink()) assert.Equal(50, fitted.Width()) assert.Equal(31, fitted.Height()) @@ -96,17 +96,24 @@ func TestImageTransform(t *testing.T) { fittedAgain, _ := fitted.Fit("10x20") fittedAgain, err = fittedAgain.Fit("10x20") assert.NoError(err) - assert.Equal("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_6b3034f4ca91823700bd9ff7a12acf2e.jpg", fittedAgain.RelPermalink()) + assert.Equal("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_3f65ba24dc2b7fba0f56d7f104519157.jpg", fittedAgain.RelPermalink()) assert.Equal(10, fittedAgain.Width()) assert.Equal(6, fittedAgain.Height()) filled, err := image.Fill("200x100 bottomLeft") assert.NoError(err) - assert.Equal("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x100_fill_q75_box_bottomleft.jpg", filled.RelPermalink()) + assert.Equal("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x100_fill_q68_linear_bottomleft.jpg", filled.RelPermalink()) assert.Equal(200, filled.Width()) assert.Equal(100, filled.Height()) assertFileCache(assert, image.spec.Fs, filled.RelPermalink(), 200, 100) + smart, err := image.Fill("200x100 smart") + assert.NoError(err) + assert.Equal(fmt.Sprintf("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x100_fill_q68_linear_smart%d.jpg", smartCropVersionNumber), smart.RelPermalink()) + assert.Equal(200, smart.Width()) + assert.Equal(100, smart.Height()) + assertFileCache(assert, image.spec.Fs, smart.RelPermalink(), 200, 100) + // Check cache filledAgain, err := image.Fill("200x100 bottomLeft") assert.NoError(err) @@ -126,12 +133,12 @@ func TestImageTransformLongFilename(t *testing.T) { assert.NoError(err) assert.NotNil(resized) assert.Equal(200, resized.Width()) - assert.Equal("/a/_hu59e56ffff1bc1d8d122b1403d34e039f_90587_fd0f8b23902abcf4092b68783834f7fe.jpg", resized.RelPermalink()) + assert.Equal("/a/_hu59e56ffff1bc1d8d122b1403d34e039f_90587_65b757a6e14debeae720fe8831f0a9bc.jpg", resized.RelPermalink()) resized, err = resized.Resize("100x") assert.NoError(err) assert.NotNil(resized) assert.Equal(100, resized.Width()) - assert.Equal("/a/_hu59e56ffff1bc1d8d122b1403d34e039f_90587_5f399e62910070692b3034a925f1b2d7.jpg", resized.RelPermalink()) + assert.Equal("/a/_hu59e56ffff1bc1d8d122b1403d34e039f_90587_c876768085288f41211f768147ba2647.jpg", resized.RelPermalink()) } func TestDecodeImaging(t *testing.T) { @@ -139,6 +146,7 @@ func TestDecodeImaging(t *testing.T) { m := map[string]interface{}{ "quality": 42, "resampleFilter": "NearestNeighbor", + "anchor": "topLeft", } imaging, err := decodeImaging(m) @@ -146,6 +154,37 @@ func TestDecodeImaging(t *testing.T) { assert.NoError(err) assert.Equal(42, imaging.Quality) assert.Equal("nearestneighbor", imaging.ResampleFilter) + assert.Equal("topleft", imaging.Anchor) + + m = map[string]interface{}{} + + imaging, err = decodeImaging(m) + assert.NoError(err) + assert.Equal(defaultJPEGQuality, imaging.Quality) + assert.Equal("box", imaging.ResampleFilter) + assert.Equal("smart", imaging.Anchor) + + _, err = decodeImaging(map[string]interface{}{ + "quality": 123, + }) + assert.Error(err) + + _, err = decodeImaging(map[string]interface{}{ + "resampleFilter": "asdf", + }) + assert.Error(err) + + _, err = decodeImaging(map[string]interface{}{ + "anchor": "asdf", + }) + assert.Error(err) + + imaging, err = decodeImaging(map[string]interface{}{ + "anchor": "Smart", + }) + assert.NoError(err) + assert.Equal("smart", imaging.Anchor) + } func TestImageWithMetadata(t *testing.T) { diff --git a/resource/smartcrop.go b/resource/smartcrop.go new file mode 100644 index 000000000..201262436 --- /dev/null +++ b/resource/smartcrop.go @@ -0,0 +1,80 @@ +// Copyright 2017-present The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package resource + +import ( + "image" + + "github.com/disintegration/imaging" + "github.com/muesli/smartcrop" +) + +const ( + // Do not change. + smartCropIdentifier = "smart" + + // This is just a increment, starting on 1. If Smart Crop improves its cropping, we + // need a way to trigger a re-generation of the crops in the wild, so increment this. + smartCropVersionNumber = 1 +) + +// Needed by smartcrop +type imagingResizer struct { + filter imaging.ResampleFilter +} + +func (r imagingResizer) Resize(img image.Image, width, height uint) image.Image { + return imaging.Resize(img, int(width), int(height), r.filter) +} + +func newSmartCropAnalyzer(filter imaging.ResampleFilter) smartcrop.Analyzer { + return smartcrop.NewAnalyzer(imagingResizer{filter: filter}) +} + +func smartCrop(img image.Image, width, height int, anchor imaging.Anchor, filter imaging.ResampleFilter) (*image.NRGBA, error) { + + if width <= 0 || height <= 0 { + return &image.NRGBA{}, nil + } + + srcBounds := img.Bounds() + srcW := srcBounds.Dx() + srcH := srcBounds.Dy() + + if srcW <= 0 || srcH <= 0 { + return &image.NRGBA{}, nil + } + + if srcW == width && srcH == height { + return imaging.Clone(img), nil + } + + smart := newSmartCropAnalyzer(filter) + + rect, err := smart.FindBestCrop(img, width, height) + + if err != nil { + return nil, err + } + + b := img.Bounds().Intersect(rect) + + cropped, err := imaging.Crop(img, b), nil + if err != nil { + return nil, err + } + + return imaging.Resize(cropped, width, height, filter), nil + +} diff --git a/resource/testhelpers_test.go b/resource/testhelpers_test.go index 03a6d6134..2b543ab64 100644 --- a/resource/testhelpers_test.go +++ b/resource/testhelpers_test.go @@ -25,6 +25,15 @@ func newTestResourceSpecForBaseURL(assert *require.Assertions, baseURL string) * cfg := viper.New() cfg.Set("baseURL", baseURL) cfg.Set("resourceDir", "/res") + + imagingCfg := map[string]interface{}{ + "resampleFilter": "linear", + "quality": 68, + "anchor": "left", + } + + cfg.Set("imaging", imagingCfg) + fs := hugofs.NewMem(cfg) s, err := helpers.NewPathSpec(fs, cfg)