mirror of
https://github.com/gohugoio/hugo.git
synced 2024-12-26 02:31:47 +00:00
resource: Add smart cropping
This commit `smart` as a new and default anchor in `Fill`. So: ```html {{ $image.Fill "200x200" }} ``` Is, with default configuration, the same as: ```html {{ $image.Fill "200x200" "smart" }} ``` You can change this default in your `config.toml`: ```toml [imaging] [imaging] resampleFilter = "box" quality = 68 anchor = "Smart" ``` Fixes #4375
This commit is contained in:
parent
084cf4191b
commit
722086b4ed
6 changed files with 194 additions and 20 deletions
13
Gopkg.lock
generated
13
Gopkg.lock
generated
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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 == "" {
|
||||
|
|
|
@ -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) {
|
||||
|
|
80
resource/smartcrop.go
Normal file
80
resource/smartcrop.go
Normal file
|
@ -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
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue