Add webp image encoding support

Fixes #5924
This commit is contained in:
Bjørn Erik Pedersen 2021-04-07 16:49:34 +02:00
parent 509d39fa6d
commit 33d5f80592
No known key found for this signature in database
GPG key ID: 330E6E2BD4859D8F
15 changed files with 344 additions and 117 deletions

View file

@ -120,7 +120,11 @@ func GetDependencyList() []string {
} }
if IsExtended { if IsExtended {
deps = append(deps, formatDep("github.com/sass/libsass", "3.6.4")) deps = append(
deps,
formatDep("github.com/sass/libsass", "3.6.4"),
formatDep("github.com/webmproject/libwebp", "v1.2.0"),
)
} }
bi, ok := debug.ReadBuildInfo() bi, ok := debug.ReadBuildInfo()

View file

@ -167,14 +167,28 @@ For color codes, see https://www.google.com/search?q=color+picker
**Note** that you also set a default background color to use, see [Image Processing Config](#image-processing-config). **Note** that you also set a default background color to use, see [Image Processing Config](#image-processing-config).
### JPEG Quality ### JPEG and Webp Quality
Only relevant for JPEG images, values 1 to 100 inclusive, higher is better. Default is 75. Only relevant for JPEG and Webp images, values 1 to 100 inclusive, higher is better. Default is 75.
```go ```go
{{ $image.Resize "600x q50" }} {{ $image.Resize "600x q50" }}
``` ```
{{< new-in "0.83.0" >}} Webp support was added in Hugo 0.83.0.
### Hint {{< new-in "0.83.0" >}}
Hint about what type of image this is. Currently only used when encoding to Webp.
Default value is `photo`.
Valid values are `picture`, `photo`, `drawing`, `icon`, or `text`.
```go
{{ $image.Resize "600x webp drawing" }}
```
### Rotate ### Rotate
Rotates an image by the given angle counter-clockwise. The rotation will be performed first to get the dimensions correct. The main use of this is to be able to manually correct for [EXIF orientation](https://github.com/golang/go/issues/4341) of JPEG images. Rotates an image by the given angle counter-clockwise. The rotation will be performed first to get the dimensions correct. The main use of this is to be able to manually correct for [EXIF orientation](https://github.com/golang/go/issues/4341) of JPEG images.
@ -258,9 +272,14 @@ You can configure an `imaging` section in `config.toml` with default image proce
# See https://github.com/disintegration/imaging # See https://github.com/disintegration/imaging
resampleFilter = "box" resampleFilter = "box"
# Default JPEG quality setting. Default is 75. # Default JPEG or WEBP quality setting. Default is 75.
quality = 75 quality = 75
# Default hint about what type of image. Currently only used for Webp encoding.
# Default is "photo".
# Valid values are "picture", "photo", "drawing", "icon", or "text".
hint = "photo"
# Anchor used when cropping pictures. # Anchor used when cropping pictures.
# Default is "smart" which does Smart Cropping, using https://github.com/muesli/smartcrop # Default is "smart" which does Smart Cropping, using https://github.com/muesli/smartcrop
# Smart Cropping is content aware and tries to find the best crop for each image. # Smart Cropping is content aware and tries to find the best crop for each image.

3
go.mod
View file

@ -13,6 +13,7 @@ require (
github.com/bep/gitmap v1.1.2 github.com/bep/gitmap v1.1.2
github.com/bep/godartsass v0.12.0 github.com/bep/godartsass v0.12.0
github.com/bep/golibsass v0.7.0 github.com/bep/golibsass v0.7.0
github.com/bep/gowebp v0.1.0 // indirect
github.com/bep/tmc v0.5.1 github.com/bep/tmc v0.5.1
github.com/cli/safeexec v1.0.0 github.com/cli/safeexec v1.0.0
github.com/disintegration/gift v1.2.1 github.com/disintegration/gift v1.2.1
@ -59,7 +60,7 @@ require (
github.com/yuin/goldmark v1.3.2 github.com/yuin/goldmark v1.3.2
github.com/yuin/goldmark-highlighting v0.0.0-20200307114337-60d527fdb691 github.com/yuin/goldmark-highlighting v0.0.0-20200307114337-60d527fdb691
gocloud.dev v0.20.0 gocloud.dev v0.20.0
golang.org/x/image v0.0.0-20191214001246-9130b4cfad52 golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4 golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/text v0.3.5 golang.org/x/text v0.3.5

16
go.sum
View file

@ -134,6 +134,20 @@ github.com/bep/godartsass v0.12.0 h1:VvGLA4XpXUjKvp53SI05YFLhRFJ78G+Ybnlaz6Oul7E
github.com/bep/godartsass v0.12.0/go.mod h1:nXQlHHk4H1ghUk6n/JkYKG5RD43yJfcfp5aHRqT/pc4= github.com/bep/godartsass v0.12.0/go.mod h1:nXQlHHk4H1ghUk6n/JkYKG5RD43yJfcfp5aHRqT/pc4=
github.com/bep/golibsass v0.7.0 h1:/ocxgtPZ5rgp7FA+mktzyent+fAg82tJq4iMsTMBAtA= github.com/bep/golibsass v0.7.0 h1:/ocxgtPZ5rgp7FA+mktzyent+fAg82tJq4iMsTMBAtA=
github.com/bep/golibsass v0.7.0/go.mod h1:DL87K8Un/+pWUS75ggYv41bliGiolxzDKWJAq3eJ1MA= github.com/bep/golibsass v0.7.0/go.mod h1:DL87K8Un/+pWUS75ggYv41bliGiolxzDKWJAq3eJ1MA=
github.com/bep/gowebp v0.0.0-20210408171434-03ecbe0b5d53 h1:bTIhFx2ZEAZD74LwuVdrdZ4070bE9UE5oR5NTBYLtVs=
github.com/bep/gowebp v0.0.0-20210408171434-03ecbe0b5d53/go.mod h1:ZhFodwdiFp8ehGJpF4LdPl6unxZm9lLFjxD3z2h2AgI=
github.com/bep/gowebp v0.0.0-20210409123354-5e38121e4f6b h1:LLrQFlG0VSxmyz3izTUQnPOGf7Mjiy7wlEu2sDLA+qg=
github.com/bep/gowebp v0.0.0-20210409123354-5e38121e4f6b/go.mod h1:ZhFodwdiFp8ehGJpF4LdPl6unxZm9lLFjxD3z2h2AgI=
github.com/bep/gowebp v0.0.0-20210410152255-50a32861b5a2 h1:uEpPD0fLZs5IjgF/96LqWHUNY9Pr/0KqLWIQ4gJnYhY=
github.com/bep/gowebp v0.0.0-20210410152255-50a32861b5a2/go.mod h1:ZhFodwdiFp8ehGJpF4LdPl6unxZm9lLFjxD3z2h2AgI=
github.com/bep/gowebp v0.0.0-20210410161412-b86a3337b39f h1:hvhG2nwoIvHhFnL8GnYtOquHE6dG+mHwthugLqf4spY=
github.com/bep/gowebp v0.0.0-20210410161412-b86a3337b39f/go.mod h1:ZhFodwdiFp8ehGJpF4LdPl6unxZm9lLFjxD3z2h2AgI=
github.com/bep/gowebp v0.0.0-20210411110227-3a211f6b6461 h1:5HLIo8LF4iKFdxPBDo9CO8oTac18mAx7FJsQG6MNbCU=
github.com/bep/gowebp v0.0.0-20210411110227-3a211f6b6461/go.mod h1:ZhFodwdiFp8ehGJpF4LdPl6unxZm9lLFjxD3z2h2AgI=
github.com/bep/gowebp v0.0.0-20210411155607-38d8f20d562b h1:VIW6UmIG4ogbswbDFBjVm6/7j9I5i0GouDJ2USn/NUI=
github.com/bep/gowebp v0.0.0-20210411155607-38d8f20d562b/go.mod h1:ZhFodwdiFp8ehGJpF4LdPl6unxZm9lLFjxD3z2h2AgI=
github.com/bep/gowebp v0.1.0 h1:4/iQpfnxHyXs3x/aTxMMdOpLEQQhFmF6G7EieWPTQyo=
github.com/bep/gowebp v0.1.0/go.mod h1:ZhFodwdiFp8ehGJpF4LdPl6unxZm9lLFjxD3z2h2AgI=
github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI= github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI=
github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0= github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
@ -566,6 +580,8 @@ golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMx
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20191214001246-9130b4cfad52 h1:2fktqPPvDiVEEVT/vSTeoUPXfmRxRaGy6GU8jypvEn0= golang.org/x/image v0.0.0-20191214001246-9130b4cfad52 h1:2fktqPPvDiVEEVT/vSTeoUPXfmRxRaGy6GU8jypvEn0=
golang.org/x/image v0.0.0-20191214001246-9130b4cfad52/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191214001246-9130b4cfad52/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb h1:fqpd0EBDzlHRCjiphRR5Zo/RSWWQlWv34418dnEixWk=
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=

View file

@ -236,10 +236,10 @@ SUNSET2: {{ $resized2.RelPermalink }}/{{ $resized2.Width }}/Lat: {{ $resized2.Ex
// Check the file cache // Check the file cache
b.AssertImage(200, 200, "resources/_gen/images/bundle/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x200_resize_q75_box.jpg") b.AssertImage(200, 200, "resources/_gen/images/bundle/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x200_resize_q75_box.jpg")
b.AssertFileContent("resources/_gen/images/bundle/sunset_7645215769587362592.json", b.AssertFileContent("resources/_gen/images/bundle/sunset_3166614710256882113.json",
"DateTimeDigitized|time.Time", "PENTAX") "DateTimeDigitized|time.Time", "PENTAX")
b.AssertImage(123, 234, "resources/_gen/images/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_123x234_resize_q75_box.jpg") b.AssertImage(123, 234, "resources/_gen/images/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_123x234_resize_q75_box.jpg")
b.AssertFileContent("resources/_gen/images/sunset_7645215769587362592.json", b.AssertFileContent("resources/_gen/images/sunset_3166614710256882113.json",
"DateTimeDigitized|time.Time", "PENTAX") "DateTimeDigitized|time.Time", "PENTAX")
// TODO(bep) add this as a default assertion after Build()? // TODO(bep) add this as a default assertion after Build()?

View file

@ -180,6 +180,7 @@ var (
GIFType = newMediaType("image", "gif", []string{"gif"}) GIFType = newMediaType("image", "gif", []string{"gif"})
TIFFType = newMediaType("image", "tiff", []string{"tif", "tiff"}) TIFFType = newMediaType("image", "tiff", []string{"tif", "tiff"})
BMPType = newMediaType("image", "bmp", []string{"bmp"}) BMPType = newMediaType("image", "bmp", []string{"bmp"})
WEBPType = newMediaType("image", "webp", []string{"webp"})
// Common video types // Common video types
AVIType = newMediaType("video", "x-msvideo", []string{"avi"}) AVIType = newMediaType("video", "x-msvideo", []string{"avi"})
@ -214,6 +215,7 @@ var DefaultTypes = Types{
TOMLType, TOMLType,
PNGType, PNGType,
JPEGType, JPEGType,
WEBPType,
AVIType, AVIType,
MPEGType, MPEGType,
MP4Type, MP4Type,

View file

@ -55,7 +55,7 @@ func TestDefaultTypes(t *testing.T) {
} }
c.Assert(len(DefaultTypes), qt.Equals, 26) c.Assert(len(DefaultTypes), qt.Equals, 27)
} }
func TestGetByType(t *testing.T) { func TestGetByType(t *testing.T) {

View file

@ -207,7 +207,7 @@ func (i *imageResource) Fill(spec string) (resource.Image, error) {
} }
func (i *imageResource) Filter(filters ...interface{}) (resource.Image, error) { func (i *imageResource) Filter(filters ...interface{}) (resource.Image, error) {
conf := i.Proc.GetDefaultImageConfig("filter") conf := images.GetDefaultImageConfig("filter", i.Proc.Cfg)
var gfilters []gift.Filter var gfilters []gift.Filter
@ -299,28 +299,11 @@ func (i *imageResource) doWithImageConfig(conf images.ImageConfig, f func(src im
} }
func (i *imageResource) decodeImageConfig(action, spec string) (images.ImageConfig, error) { func (i *imageResource) decodeImageConfig(action, spec string) (images.ImageConfig, error) {
conf, err := images.DecodeImageConfig(action, spec, i.Proc.Cfg.Cfg) conf, err := images.DecodeImageConfig(action, spec, i.Proc.Cfg, i.Format)
if err != nil { if err != nil {
return conf, err return conf, err
} }
// default to the source format
if conf.TargetFormat == 0 {
conf.TargetFormat = i.Format
}
if conf.Quality <= 0 && conf.TargetFormat.RequiresDefaultQuality() {
// We need a quality setting for all JPEGs
conf.Quality = i.Proc.Cfg.Cfg.Quality
}
if conf.BgColor == nil && conf.TargetFormat != i.Format {
if i.Format.SupportsTransparency() && !conf.TargetFormat.SupportsTransparency() {
conf.BgColor = i.Proc.Cfg.BgColor
conf.BgColorStr = i.Proc.Cfg.Cfg.BgColor
}
}
return conf, nil return conf, nil
} }
@ -360,15 +343,16 @@ func (i *imageResource) setBasePath(conf images.ImageConfig) {
func (i *imageResource) getImageMetaCacheTargetPath() string { func (i *imageResource) getImageMetaCacheTargetPath() string {
const imageMetaVersionNumber = 1 // Increment to invalidate the meta cache const imageMetaVersionNumber = 1 // Increment to invalidate the meta cache
cfg := i.getSpec().imaging.Cfg.Cfg cfgHash := i.getSpec().imaging.Cfg.CfgHash
df := i.getResourcePaths().relTargetDirFile df := i.getResourcePaths().relTargetDirFile
if fi := i.getFileInfo(); fi != nil { if fi := i.getFileInfo(); fi != nil {
df.dir = filepath.Dir(fi.Meta().Path()) df.dir = filepath.Dir(fi.Meta().Path())
} }
p1, _ := helpers.FileAndExt(df.file) p1, _ := helpers.FileAndExt(df.file)
h, _ := i.hash() h, _ := i.hash()
idStr := helpers.HashString(h, i.size(), imageMetaVersionNumber, cfg) idStr := helpers.HashString(h, i.size(), imageMetaVersionNumber, cfgHash)
return path.Join(df.dir, fmt.Sprintf("%s_%s.json", p1, idStr)) p := path.Join(df.dir, fmt.Sprintf("%s_%s.json", p1, idStr))
return p
} }
func (i *imageResource) relTargetPathFromConfig(conf images.ImageConfig) dirFile { func (i *imageResource) relTargetPathFromConfig(conf images.ImageConfig) dirFile {

View file

@ -0,0 +1,41 @@
// Copyright 2019 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.
// +build extended
package resources
import (
"testing"
"github.com/gohugoio/hugo/media"
qt "github.com/frankban/quicktest"
)
func TestImageResizeWebP(t *testing.T) {
c := qt.New(t)
image := fetchImage(c, "sunset.webp")
c.Assert(image.MediaType(), qt.Equals, media.WEBPType)
c.Assert(image.RelPermalink(), qt.Equals, "/a/sunset.webp")
c.Assert(image.ResourceType(), qt.Equals, "image")
c.Assert(image.Exif(), qt.IsNil)
resized, err := image.Resize("123x")
c.Assert(err, qt.IsNil)
c.Assert(image.MediaType(), qt.Equals, media.WEBPType)
c.Assert(resized.RelPermalink(), qt.Equals, "/a/sunset_hu36ee0b61ba924719ad36da960c273f96_59826_123x0_resize_q68_h2_linear.webp")
c.Assert(resized.Width(), qt.Equals, 123)
}

View file

@ -14,23 +14,22 @@
package images package images
import ( import (
"errors"
"fmt" "fmt"
"image/color" "image/color"
"strconv" "strconv"
"strings" "strings"
"github.com/gohugoio/hugo/helpers"
"github.com/pkg/errors"
"github.com/bep/gowebp/libwebp/webpoptions"
"github.com/disintegration/gift" "github.com/disintegration/gift"
"github.com/mitchellh/mapstructure" "github.com/mitchellh/mapstructure"
) )
const (
defaultJPEGQuality = 75
defaultResampleFilter = "box"
defaultBgColor = "ffffff"
)
var ( var (
imageFormats = map[string]Format{ imageFormats = map[string]Format{
".jpg": JPEG, ".jpg": JPEG,
@ -40,6 +39,7 @@ var (
".tiff": TIFF, ".tiff": TIFF,
".bmp": BMP, ".bmp": BMP,
".gif": GIF, ".gif": GIF,
".webp": WEBP,
} }
// Add or increment if changes to an image format's processing requires // Add or increment if changes to an image format's processing requires
@ -65,6 +65,15 @@ var anchorPositions = map[string]gift.Anchor{
strings.ToLower("BottomRight"): gift.BottomRightAnchor, strings.ToLower("BottomRight"): gift.BottomRightAnchor,
} }
// These encoding hints are currently only relevant for Webp.
var hints = map[string]webpoptions.EncodingPreset{
"picture": webpoptions.EncodingPresetPicture,
"photo": webpoptions.EncodingPresetPhoto,
"drawing": webpoptions.EncodingPresetDrawing,
"icon": webpoptions.EncodingPresetIcon,
"text": webpoptions.EncodingPresetText,
}
var imageFilters = map[string]gift.Resampling{ var imageFilters = map[string]gift.Resampling{
strings.ToLower("NearestNeighbor"): gift.NearestNeighborResampling, strings.ToLower("NearestNeighbor"): gift.NearestNeighborResampling,
@ -89,63 +98,71 @@ func ImageFormatFromExt(ext string) (Format, bool) {
return f, found return f, found
} }
func DecodeConfig(m map[string]interface{}) (ImagingConfig, error) { const (
var i Imaging defaultJPEGQuality = 75
var ic ImagingConfig defaultResampleFilter = "box"
if err := mapstructure.WeakDecode(m, &i); err != nil { defaultBgColor = "ffffff"
return ic, err defaultHint = "photo"
} )
if i.Quality == 0 { var defaultImaging = Imaging{
i.Quality = defaultJPEGQuality ResampleFilter: defaultResampleFilter,
} else if i.Quality < 0 || i.Quality > 100 { BgColor: defaultBgColor,
return ic, errors.New("JPEG quality must be a number between 1 and 100") Hint: defaultHint,
} Quality: defaultJPEGQuality,
if i.BgColor != "" {
i.BgColor = strings.TrimPrefix(i.BgColor, "#")
} else {
i.BgColor = defaultBgColor
}
var err error
ic.BgColor, err = hexStringToColor(i.BgColor)
if err != nil {
return ic, err
}
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 ic, errors.New("invalid anchor value in imaging config")
}
}
if i.ResampleFilter == "" {
i.ResampleFilter = defaultResampleFilter
} else {
filter := strings.ToLower(i.ResampleFilter)
_, found := imageFilters[filter]
if !found {
return ic, fmt.Errorf("%q is not a valid resample filter", filter)
}
i.ResampleFilter = filter
}
if strings.TrimSpace(i.Exif.IncludeFields) == "" && strings.TrimSpace(i.Exif.ExcludeFields) == "" {
// Don't change this for no good reason. Please don't.
i.Exif.ExcludeFields = "GPS|Exif|Exposure[M|P|B]|Contrast|Resolution|Sharp|JPEG|Metering|Sensing|Saturation|ColorSpace|Flash|WhiteBalance"
}
ic.Cfg = i
return ic, nil
} }
func DecodeImageConfig(action, config string, defaults Imaging) (ImageConfig, error) { func DecodeConfig(m map[string]interface{}) (ImagingConfig, error) {
if m == nil {
m = make(map[string]interface{})
}
i := ImagingConfig{
Cfg: defaultImaging,
CfgHash: helpers.HashString(m),
}
if err := mapstructure.WeakDecode(m, &i.Cfg); err != nil {
return i, err
}
if err := i.Cfg.init(); err != nil {
return i, err
}
var err error
i.BgColor, err = hexStringToColor(i.Cfg.BgColor)
if err != nil {
return i, err
}
if i.Cfg.Anchor != "" && i.Cfg.Anchor != smartCropIdentifier {
anchor, found := anchorPositions[i.Cfg.Anchor]
if !found {
return i, errors.Errorf("invalid anchor value %q in imaging config", i.Anchor)
}
i.Anchor = anchor
} else {
i.Cfg.Anchor = smartCropIdentifier
}
filter, found := imageFilters[i.Cfg.ResampleFilter]
if !found {
return i, fmt.Errorf("%q is not a valid resample filter", filter)
}
i.ResampleFilter = filter
if strings.TrimSpace(i.Cfg.Exif.IncludeFields) == "" && strings.TrimSpace(i.Cfg.Exif.ExcludeFields) == "" {
// Don't change this for no good reason. Please don't.
i.Cfg.Exif.ExcludeFields = "GPS|Exif|Exposure[M|P|B]|Contrast|Resolution|Sharp|JPEG|Metering|Sensing|Saturation|ColorSpace|Flash|WhiteBalance"
}
return i, nil
}
func DecodeImageConfig(action, config string, defaults ImagingConfig, sourceFormat Format) (ImageConfig, error) {
var ( var (
c ImageConfig c ImageConfig = GetDefaultImageConfig(action, defaults)
err error err error
) )
@ -167,6 +184,8 @@ func DecodeImageConfig(action, config string, defaults Imaging) (ImageConfig, er
} else if filter, ok := imageFilters[part]; ok { } else if filter, ok := imageFilters[part]; ok {
c.Filter = filter c.Filter = filter
c.FilterStr = part c.FilterStr = part
} else if hint, ok := hints[part]; ok {
c.Hint = hint
} else if part[0] == '#' { } else if part[0] == '#' {
c.BgColorStr = part[1:] c.BgColorStr = part[1:]
c.BgColor, err = hexStringToColor(c.BgColorStr) c.BgColor, err = hexStringToColor(c.BgColorStr)
@ -181,6 +200,7 @@ func DecodeImageConfig(action, config string, defaults Imaging) (ImageConfig, er
if c.Quality < 1 || c.Quality > 100 { if c.Quality < 1 || c.Quality > 100 {
return c, errors.New("quality ranges from 1 to 100 inclusive") return c, errors.New("quality ranges from 1 to 100 inclusive")
} }
c.qualitySetForImage = true
} else if part[0] == 'r' { } else if part[0] == 'r' {
c.Rotate, err = strconv.Atoi(part[1:]) c.Rotate, err = strconv.Atoi(part[1:])
if err != nil { if err != nil {
@ -219,14 +239,33 @@ func DecodeImageConfig(action, config string, defaults Imaging) (ImageConfig, er
} }
if c.FilterStr == "" { if c.FilterStr == "" {
c.FilterStr = defaults.ResampleFilter c.FilterStr = defaults.Cfg.ResampleFilter
c.Filter = imageFilters[c.FilterStr] c.Filter = defaults.ResampleFilter
}
if c.Hint == 0 {
c.Hint = webpoptions.EncodingPresetPhoto
} }
if c.AnchorStr == "" { if c.AnchorStr == "" {
c.AnchorStr = defaults.Anchor c.AnchorStr = defaults.Cfg.Anchor
if !strings.EqualFold(c.AnchorStr, smartCropIdentifier) { c.Anchor = defaults.Anchor
c.Anchor = anchorPositions[c.AnchorStr] }
// default to the source format
if c.TargetFormat == 0 {
c.TargetFormat = sourceFormat
}
if c.Quality <= 0 && c.TargetFormat.RequiresDefaultQuality() {
// We need a quality setting for all JPEGs and WEBPs.
c.Quality = defaults.Cfg.Quality
}
if c.BgColor == nil && c.TargetFormat != sourceFormat {
if sourceFormat.SupportsTransparency() && !c.TargetFormat.SupportsTransparency() {
c.BgColor = defaults.BgColor
c.BgColorStr = defaults.Cfg.BgColor
} }
} }
@ -235,7 +274,7 @@ func DecodeImageConfig(action, config string, defaults Imaging) (ImageConfig, er
// ImageConfig holds configuration to create a new image from an existing one, resize etc. // ImageConfig holds configuration to create a new image from an existing one, resize etc.
type ImageConfig struct { type ImageConfig struct {
// This defines the output format of the output image. It defaults to the source format // This defines the output format of the output image. It defaults to the source format.
TargetFormat Format TargetFormat Format
Action string Action string
@ -244,9 +283,10 @@ type ImageConfig struct {
Key string Key string
// Quality ranges from 1 to 100 inclusive, higher is better. // Quality ranges from 1 to 100 inclusive, higher is better.
// This is only relevant for JPEG images. // This is only relevant for JPEG and WEBP images.
// Default is 75. // Default is 75.
Quality int Quality int
qualitySetForImage bool // Whether the above is set for this image.
// Rotate rotates an image by the given angle counter-clockwise. // Rotate rotates an image by the given angle counter-clockwise.
// The rotation will be performed first. // The rotation will be performed first.
@ -260,6 +300,10 @@ type ImageConfig struct {
BgColor color.Color BgColor color.Color
BgColorStr string BgColorStr string
// Hint about what type of picture this is. Used to optimize encoding
// when target is set to webp.
Hint webpoptions.EncodingPreset
Width int Width int
Height int Height int
@ -279,7 +323,8 @@ func (i ImageConfig) GetKey(format Format) string {
if i.Action != "" { if i.Action != "" {
k += "_" + i.Action k += "_" + i.Action
} }
if i.Quality > 0 { // This slightly odd construct is here to preserve the old image keys.
if i.qualitySetForImage || i.TargetFormat.RequiresDefaultQuality() {
k += "_q" + strconv.Itoa(i.Quality) k += "_q" + strconv.Itoa(i.Quality)
} }
if i.Rotate != 0 { if i.Rotate != 0 {
@ -289,6 +334,10 @@ func (i ImageConfig) GetKey(format Format) string {
k += "_bg" + i.BgColorStr k += "_bg" + i.BgColorStr
} }
if i.TargetFormat == WEBP {
k += "_h" + strconv.Itoa(int(i.Hint))
}
anchor := i.AnchorStr anchor := i.AnchorStr
if anchor == smartCropIdentifier { if anchor == smartCropIdentifier {
anchor = anchor + strconv.Itoa(smartCropVersionNumber) anchor = anchor + strconv.Itoa(smartCropVersionNumber)
@ -312,10 +361,16 @@ func (i ImageConfig) GetKey(format Format) string {
} }
type ImagingConfig struct { type ImagingConfig struct {
BgColor color.Color BgColor color.Color
Hint webpoptions.EncodingPreset
ResampleFilter gift.Resampling
Anchor gift.Anchor
// Config as provided by the user. // Config as provided by the user.
Cfg Imaging Cfg Imaging
// Hash of the config map provided by the user.
CfgHash string
} }
// Imaging contains default image processing configuration. This will be fetched // Imaging contains default image processing configuration. This will be fetched
@ -324,9 +379,15 @@ type Imaging struct {
// Default image quality setting (1-100). Only used for JPEG images. // Default image quality setting (1-100). Only used for JPEG images.
Quality int Quality int
// Resample filter to use in resize operations.. // Resample filter to use in resize operations.
ResampleFilter string ResampleFilter string
// Hint about what type of image this is.
// Currently only used when encoding to Webp.
// Default is "photo".
// Valid values are "picture", "photo", "drawing", "icon", or "text".
Hint string
// The anchor to use in Fill. Default is "smart", i.e. Smart Crop. // The anchor to use in Fill. Default is "smart", i.e. Smart Crop.
Anchor string Anchor string
@ -336,6 +397,19 @@ type Imaging struct {
Exif ExifConfig Exif ExifConfig
} }
func (cfg *Imaging) init() error {
if cfg.Quality < 0 || cfg.Quality > 100 {
return errors.New("image quality must be a number between 1 and 100")
}
cfg.BgColor = strings.ToLower(strings.TrimPrefix(cfg.BgColor, "#"))
cfg.Anchor = strings.ToLower(cfg.Anchor)
cfg.ResampleFilter = strings.ToLower(cfg.ResampleFilter)
cfg.Hint = strings.ToLower(cfg.Hint)
return nil
}
type ExifConfig struct { type ExifConfig struct {
// Regexp matching the Exif fields you want from the (massive) set of Exif info // Regexp matching the Exif fields you want from the (massive) set of Exif info

View file

@ -42,7 +42,6 @@ func TestDecodeConfig(t *testing.T) {
imagingConfig, err = DecodeConfig(m) imagingConfig, err = DecodeConfig(m)
c.Assert(err, qt.IsNil) c.Assert(err, qt.IsNil)
imaging = imagingConfig.Cfg imaging = imagingConfig.Cfg
c.Assert(imaging.Quality, qt.Equals, defaultJPEGQuality)
c.Assert(imaging.ResampleFilter, qt.Equals, "box") c.Assert(imaging.ResampleFilter, qt.Equals, "box")
c.Assert(imaging.Anchor, qt.Equals, "smart") c.Assert(imaging.Anchor, qt.Equals, "smart")
@ -84,18 +83,22 @@ func TestDecodeImageConfig(t *testing.T) {
in string in string
expect interface{} expect interface{}
}{ }{
{"300x400", newImageConfig(300, 400, 0, 0, "", "", "")}, {"300x400", newImageConfig(300, 400, 75, 0, "box", "smart", "")},
{"300x400 #fff", newImageConfig(300, 400, 0, 0, "", "", "fff")}, {"300x400 #fff", newImageConfig(300, 400, 75, 0, "box", "smart", "fff")},
{"100x200 bottomRight", newImageConfig(100, 200, 0, 0, "", "BottomRight", "")}, {"100x200 bottomRight", newImageConfig(100, 200, 75, 0, "box", "BottomRight", "")},
{"10x20 topleft Lanczos", newImageConfig(10, 20, 0, 0, "Lanczos", "topleft", "")}, {"10x20 topleft Lanczos", newImageConfig(10, 20, 75, 0, "Lanczos", "topleft", "")},
{"linear left 10x r180", newImageConfig(10, 0, 0, 180, "linear", "left", "")}, {"linear left 10x r180", newImageConfig(10, 0, 75, 180, "linear", "left", "")},
{"x20 riGht Cosine q95", newImageConfig(0, 20, 95, 0, "cosine", "right", "")}, {"x20 riGht Cosine q95", newImageConfig(0, 20, 95, 0, "cosine", "right", "")},
{"", false}, {"", false},
{"foo", false}, {"foo", false},
} { } {
result, err := DecodeImageConfig("resize", this.in, Imaging{}) cfg, err := DecodeConfig(nil)
if err != nil {
t.Fatal(err)
}
result, err := DecodeImageConfig("resize", this.in, cfg, PNG)
if b, ok := this.expect.(bool); ok && !b { if b, ok := this.expect.(bool); ok && !b {
if err == nil { if err == nil {
t.Errorf("[%d] parseImageConfig didn't return an expected error", i) t.Errorf("[%d] parseImageConfig didn't return an expected error", i)
@ -112,11 +115,13 @@ func TestDecodeImageConfig(t *testing.T) {
} }
func newImageConfig(width, height, quality, rotate int, filter, anchor, bgColor string) ImageConfig { func newImageConfig(width, height, quality, rotate int, filter, anchor, bgColor string) ImageConfig {
var c ImageConfig var c ImageConfig = GetDefaultImageConfig("resize", ImagingConfig{})
c.Action = "resize" c.TargetFormat = PNG
c.Hint = 2
c.Width = width c.Width = width
c.Height = height c.Height = height
c.Quality = quality c.Quality = quality
c.qualitySetForImage = quality != 75
c.Rotate = rotate c.Rotate = rotate
c.BgColorStr = bgColor c.BgColorStr = bgColor
c.BgColor, _ = hexStringToColor(bgColor) c.BgColor, _ = hexStringToColor(bgColor)
@ -130,10 +135,14 @@ func newImageConfig(width, height, quality, rotate int, filter, anchor, bgColor
} }
if anchor != "" { if anchor != "" {
anchor = strings.ToLower(anchor) if anchor == smartCropIdentifier {
if v, ok := anchorPositions[anchor]; ok {
c.Anchor = v
c.AnchorStr = anchor c.AnchorStr = anchor
} else {
anchor = strings.ToLower(anchor)
if v, ok := anchorPositions[anchor]; ok {
c.Anchor = v
c.AnchorStr = anchor
}
} }
} }

View file

@ -23,6 +23,9 @@ import (
"io" "io"
"sync" "sync"
"github.com/bep/gowebp/libwebp/webpoptions"
"github.com/gohugoio/hugo/resources/images/webp"
"github.com/gohugoio/hugo/media" "github.com/gohugoio/hugo/media"
"github.com/gohugoio/hugo/resources/images/exif" "github.com/gohugoio/hugo/resources/images/exif"
@ -89,6 +92,15 @@ func (i *Image) EncodeTo(conf ImageConfig, img image.Image, w io.Writer) error {
case BMP: case BMP:
return bmp.Encode(w, img) return bmp.Encode(w, img)
case WEBP:
return webp.Encode(
w,
img, webpoptions.EncodingOptions{
Quality: conf.Quality,
EncodingPreset: webpoptions.EncodingPreset(conf.Hint),
UseSharpYuv: true,
},
)
default: default:
return errors.New("format not supported") return errors.New("format not supported")
} }
@ -229,10 +241,11 @@ func (p *ImageProcessor) Filter(src image.Image, filters ...gift.Filter) (image.
return dst, nil return dst, nil
} }
func (p *ImageProcessor) GetDefaultImageConfig(action string) ImageConfig { func GetDefaultImageConfig(action string, defaults ImagingConfig) ImageConfig {
return ImageConfig{ return ImageConfig{
Action: action, Action: action,
Quality: p.Cfg.Cfg.Quality, Hint: defaults.Hint,
Quality: defaults.Cfg.Quality,
} }
} }
@ -250,11 +263,13 @@ const (
GIF GIF
TIFF TIFF
BMP BMP
WEBP
) )
// RequiresDefaultQuality returns if the default quality needs to be applied to images of this format // RequiresDefaultQuality returns if the default quality needs to be applied to
// images of this format.
func (f Format) RequiresDefaultQuality() bool { func (f Format) RequiresDefaultQuality() bool {
return f == JPEG return f == JPEG || f == WEBP
} }
// SupportsTransparency reports whether it supports transparency in any form. // SupportsTransparency reports whether it supports transparency in any form.
@ -281,6 +296,8 @@ func (f Format) MediaType() media.Type {
return media.TIFFType return media.TIFFType
case BMP: case BMP:
return media.BMPType return media.BMPType
case WEBP:
return media.WEBPType
default: default:
panic(fmt.Sprintf("%d is not a valid image format", f)) panic(fmt.Sprintf("%d is not a valid image format", f))
} }

View file

@ -0,0 +1,30 @@
// Copyright 2021 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.
// +build extended
package webp
import (
"image"
"io"
"github.com/bep/gowebp/libwebp"
"github.com/bep/gowebp/libwebp/webpoptions"
)
// Encode writes the Image m to w in Webp format with the given
// options.
func Encode(w io.Writer, m image.Image, o webpoptions.EncodingOptions) error {
return libwebp.Encode(w, m, o)
}

View file

@ -0,0 +1,30 @@
// Copyright 2021 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.
// +build !extended
package webp
import (
"image"
"io"
"github.com/gohugoio/hugo/common/herrors"
"github.com/bep/gowebp/libwebp/webpoptions"
)
// Encode is only available in the extended version.
func Encode(w io.Writer, m image.Image, o webpoptions.EncodingOptions) error {
return herrors.ErrFeatureNotAvailable
}

BIN
resources/testdata/sunset.webp vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB