Add webp image encoding support
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Wed, 7 Apr 2021 14:49:34 +0000 (16:49 +0200)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Thu, 15 Apr 2021 15:22:55 +0000 (17:22 +0200)
Fixes #5924

15 files changed:
common/hugo/hugo.go
docs/content/en/content-management/image-processing/index.md
go.mod
go.sum
hugolib/image_test.go
media/mediaType.go
media/mediaType_test.go
resources/image.go
resources/image_extended_test.go [new file with mode: 0644]
resources/images/config.go
resources/images/config_test.go
resources/images/image.go
resources/images/webp/webp.go [new file with mode: 0644]
resources/images/webp/webp_notavailable.go [new file with mode: 0644]
resources/testdata/sunset.webp [new file with mode: 0644]

index 6e94a8a33d734c2f17a8f812ccd36126f9d43e2c..d8d1c7c1221727a4cb6e382e0f5e275c2d87c448 100644 (file)
@@ -120,7 +120,11 @@ func GetDependencyList() []string {
        }
 
        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()
index a432b9851aa4b37e9d414387e97e363619292d1f..8cd00210f32d0447b17b8fe8202013f01a2fe65c 100644 (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).
 
-### 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
 {{ $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
 
 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
 resampleFilter = "box"
 
-# Default JPEG quality setting. Default is 75.
+# Default JPEG or WEBP quality setting. Default is 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.
 # 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.
diff --git a/go.mod b/go.mod
index 781b53c12d01ab0b519c84f3a43498deafd68b25..187842a8c117c5d8962bb0212e90ec7de36ca644 100644 (file)
--- a/go.mod
+++ b/go.mod
@@ -13,6 +13,7 @@ require (
        github.com/bep/gitmap v1.1.2
        github.com/bep/godartsass v0.12.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/cli/safeexec v1.0.0
        github.com/disintegration/gift v1.2.1
@@ -59,7 +60,7 @@ require (
        github.com/yuin/goldmark v1.3.2
        github.com/yuin/goldmark-highlighting v0.0.0-20200307114337-60d527fdb691
        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/sync v0.0.0-20210220032951-036812b2e83c
        golang.org/x/text v0.3.5
diff --git a/go.sum b/go.sum
index a19984430eb23e881549ad5866db9109ca9becfe..d2b71844e7c51e23bae6a5ad03c8c89c3e060598 100644 (file)
--- a/go.sum
+++ b/go.sum
@@ -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/golibsass v0.7.0 h1:/ocxgtPZ5rgp7FA+mktzyent+fAg82tJq4iMsTMBAtA=
 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/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0=
 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-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-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-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
 golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
index 1d152046056912297402093e4dfcb92830a2335d..0dacf2a33e099706c240c6315eddb04e5bf4a429 100644 (file)
@@ -236,10 +236,10 @@ SUNSET2: {{ $resized2.RelPermalink }}/{{ $resized2.Width }}/Lat: {{ $resized2.Ex
        // Check the file cache
        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")
        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")
 
        // TODO(bep) add this as a default assertion after Build()?
index a35d80e3e9439a92d98811d8c523a1a807d00da2..164ad5fd29277a276da3786956fbc7f1ae662b86 100644 (file)
@@ -180,6 +180,7 @@ var (
        GIFType  = newMediaType("image", "gif", []string{"gif"})
        TIFFType = newMediaType("image", "tiff", []string{"tif", "tiff"})
        BMPType  = newMediaType("image", "bmp", []string{"bmp"})
+       WEBPType = newMediaType("image", "webp", []string{"webp"})
 
        // Common video types
        AVIType  = newMediaType("video", "x-msvideo", []string{"avi"})
@@ -214,6 +215,7 @@ var DefaultTypes = Types{
        TOMLType,
        PNGType,
        JPEGType,
+       WEBPType,
        AVIType,
        MPEGType,
        MP4Type,
index e44ab27ec6ec9be57728f17bd277874831b177d7..6bc42b3d4aebbbc05ffc20803893ec71a6d9c5dc 100644 (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) {
index 0396c2208e8768a68636013787e4f48252c71f2e..edf05639f97c219bbfad6f3cd4c8f39f40aaf0fc 100644 (file)
@@ -207,7 +207,7 @@ func (i *imageResource) Fill(spec string) (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
 
@@ -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) {
-       conf, err := images.DecodeImageConfig(action, spec, i.Proc.Cfg.Cfg)
+       conf, err := images.DecodeImageConfig(action, spec, i.Proc.Cfg, i.Format)
        if err != nil {
                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
 }
 
@@ -360,15 +343,16 @@ func (i *imageResource) setBasePath(conf images.ImageConfig) {
 func (i *imageResource) getImageMetaCacheTargetPath() string {
        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
        if fi := i.getFileInfo(); fi != nil {
                df.dir = filepath.Dir(fi.Meta().Path())
        }
        p1, _ := helpers.FileAndExt(df.file)
        h, _ := i.hash()
-       idStr := helpers.HashString(h, i.size(), imageMetaVersionNumber, cfg)
-       return path.Join(df.dir, fmt.Sprintf("%s_%s.json", p1, idStr))
+       idStr := helpers.HashString(h, i.size(), imageMetaVersionNumber, cfgHash)
+       p := path.Join(df.dir, fmt.Sprintf("%s_%s.json", p1, idStr))
+       return p
 }
 
 func (i *imageResource) relTargetPathFromConfig(conf images.ImageConfig) dirFile {
diff --git a/resources/image_extended_test.go b/resources/image_extended_test.go
new file mode 100644 (file)
index 0000000..9fd9304
--- /dev/null
@@ -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)
+}
index 7b2ade29f6a64ee892bd409cbf5788472e45372b..a4942688be77c51e9ba0945316e34204f387e0d8 100644 (file)
 package images
 
 import (
-       "errors"
        "fmt"
        "image/color"
        "strconv"
        "strings"
 
+       "github.com/gohugoio/hugo/helpers"
+
+       "github.com/pkg/errors"
+
+       "github.com/bep/gowebp/libwebp/webpoptions"
+
        "github.com/disintegration/gift"
 
        "github.com/mitchellh/mapstructure"
 )
 
-const (
-       defaultJPEGQuality    = 75
-       defaultResampleFilter = "box"
-       defaultBgColor        = "ffffff"
-)
-
 var (
        imageFormats = map[string]Format{
                ".jpg":  JPEG,
@@ -40,6 +39,7 @@ var (
                ".tiff": TIFF,
                ".bmp":  BMP,
                ".gif":  GIF,
+               ".webp": WEBP,
        }
 
        // 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,
 }
 
+// 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{
 
        strings.ToLower("NearestNeighbor"):   gift.NearestNeighborResampling,
@@ -89,63 +98,71 @@ func ImageFormatFromExt(ext string) (Format, bool) {
        return f, found
 }
 
+const (
+       defaultJPEGQuality    = 75
+       defaultResampleFilter = "box"
+       defaultBgColor        = "ffffff"
+       defaultHint           = "photo"
+)
+
+var defaultImaging = Imaging{
+       ResampleFilter: defaultResampleFilter,
+       BgColor:        defaultBgColor,
+       Hint:           defaultHint,
+       Quality:        defaultJPEGQuality,
+}
+
 func DecodeConfig(m map[string]interface{}) (ImagingConfig, error) {
-       var i Imaging
-       var ic ImagingConfig
-       if err := mapstructure.WeakDecode(m, &i); err != nil {
-               return ic, err
+       if m == nil {
+               m = make(map[string]interface{})
        }
 
-       if i.Quality == 0 {
-               i.Quality = defaultJPEGQuality
-       } else if i.Quality < 0 || i.Quality > 100 {
-               return ic, errors.New("JPEG quality must be a number between 1 and 100")
+       i := ImagingConfig{
+               Cfg:     defaultImaging,
+               CfgHash: helpers.HashString(m),
        }
 
-       if i.BgColor != "" {
-               i.BgColor = strings.TrimPrefix(i.BgColor, "#")
-       } else {
-               i.BgColor = defaultBgColor
+       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
-       ic.BgColor, err = hexStringToColor(i.BgColor)
+       i.BgColor, err = hexStringToColor(i.Cfg.BgColor)
        if err != nil {
-               return ic, err
+               return i, 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.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
        }
 
-       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
+       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.Exif.IncludeFields) == "" && strings.TrimSpace(i.Exif.ExcludeFields) == "" {
+       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.Exif.ExcludeFields = "GPS|Exif|Exposure[M|P|B]|Contrast|Resolution|Sharp|JPEG|Metering|Sensing|Saturation|ColorSpace|Flash|WhiteBalance"
+               i.Cfg.Exif.ExcludeFields = "GPS|Exif|Exposure[M|P|B]|Contrast|Resolution|Sharp|JPEG|Metering|Sensing|Saturation|ColorSpace|Flash|WhiteBalance"
        }
 
-       ic.Cfg = i
-
-       return ic, nil
+       return i, nil
 }
 
-func DecodeImageConfig(action, config string, defaults Imaging) (ImageConfig, error) {
+func DecodeImageConfig(action, config string, defaults ImagingConfig, sourceFormat Format) (ImageConfig, error) {
        var (
-               c   ImageConfig
+               c   ImageConfig = GetDefaultImageConfig(action, defaults)
                err error
        )
 
@@ -167,6 +184,8 @@ func DecodeImageConfig(action, config string, defaults Imaging) (ImageConfig, er
                } else if filter, ok := imageFilters[part]; ok {
                        c.Filter = filter
                        c.FilterStr = part
+               } else if hint, ok := hints[part]; ok {
+                       c.Hint = hint
                } else if part[0] == '#' {
                        c.BgColorStr = part[1:]
                        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 {
                                return c, errors.New("quality ranges from 1 to 100 inclusive")
                        }
+                       c.qualitySetForImage = true
                } else if part[0] == 'r' {
                        c.Rotate, err = strconv.Atoi(part[1:])
                        if err != nil {
@@ -219,14 +239,33 @@ func DecodeImageConfig(action, config string, defaults Imaging) (ImageConfig, er
        }
 
        if c.FilterStr == "" {
-               c.FilterStr = defaults.ResampleFilter
-               c.Filter = imageFilters[c.FilterStr]
+               c.FilterStr = defaults.Cfg.ResampleFilter
+               c.Filter = defaults.ResampleFilter
+       }
+
+       if c.Hint == 0 {
+               c.Hint = webpoptions.EncodingPresetPhoto
        }
 
        if c.AnchorStr == "" {
-               c.AnchorStr = defaults.Anchor
-               if !strings.EqualFold(c.AnchorStr, smartCropIdentifier) {
-                       c.Anchor = anchorPositions[c.AnchorStr]
+               c.AnchorStr = defaults.Cfg.Anchor
+               c.Anchor = defaults.Anchor
+       }
+
+       // 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.
 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
 
        Action string
@@ -244,9 +283,10 @@ type ImageConfig struct {
        Key string
 
        // 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.
-       Quality int
+       Quality            int
+       qualitySetForImage bool // Whether the above is set for this image.
 
        // Rotate rotates an image by the given angle counter-clockwise.
        // The rotation will be performed first.
@@ -260,6 +300,10 @@ type ImageConfig struct {
        BgColor    color.Color
        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
        Height int
 
@@ -279,7 +323,8 @@ func (i ImageConfig) GetKey(format Format) string {
        if 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)
        }
        if i.Rotate != 0 {
@@ -289,6 +334,10 @@ func (i ImageConfig) GetKey(format Format) string {
                k += "_bg" + i.BgColorStr
        }
 
+       if i.TargetFormat == WEBP {
+               k += "_h" + strconv.Itoa(int(i.Hint))
+       }
+
        anchor := i.AnchorStr
        if anchor == smartCropIdentifier {
                anchor = anchor + strconv.Itoa(smartCropVersionNumber)
@@ -312,10 +361,16 @@ func (i ImageConfig) GetKey(format Format) string {
 }
 
 type ImagingConfig struct {
-       BgColor color.Color
+       BgColor        color.Color
+       Hint           webpoptions.EncodingPreset
+       ResampleFilter gift.Resampling
+       Anchor         gift.Anchor
 
        // Config as provided by the user.
        Cfg Imaging
+
+       // Hash of the config map provided by the user.
+       CfgHash string
 }
 
 // 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.
        Quality int
 
-       // Resample filter to use in resize operations..
+       // Resample filter to use in resize operations.
        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.
        Anchor string
 
@@ -336,6 +397,19 @@ type Imaging struct {
        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 {
 
        // Regexp matching the Exif fields you want from the (massive) set of Exif info
index 2a0de9ec0594e7731acc8350fc345dce07ba8b2f..7b2459250cbeeafe8f4cb712ef152840143aa0d2 100644 (file)
@@ -42,7 +42,6 @@ func TestDecodeConfig(t *testing.T) {
        imagingConfig, err = DecodeConfig(m)
        c.Assert(err, qt.IsNil)
        imaging = imagingConfig.Cfg
-       c.Assert(imaging.Quality, qt.Equals, defaultJPEGQuality)
        c.Assert(imaging.ResampleFilter, qt.Equals, "box")
        c.Assert(imaging.Anchor, qt.Equals, "smart")
 
@@ -84,18 +83,22 @@ func TestDecodeImageConfig(t *testing.T) {
                in     string
                expect interface{}
        }{
-               {"300x400", newImageConfig(300, 400, 0, 0, "", "", "")},
-               {"300x400 #fff", newImageConfig(300, 400, 0, 0, "", "", "fff")},
-               {"100x200 bottomRight", newImageConfig(100, 200, 0, 0, "", "BottomRight", "")},
-               {"10x20 topleft Lanczos", newImageConfig(10, 20, 0, 0, "Lanczos", "topleft", "")},
-               {"linear left 10x r180", newImageConfig(10, 0, 0, 180, "linear", "left", "")},
+               {"300x400", newImageConfig(300, 400, 75, 0, "box", "smart", "")},
+               {"300x400 #fff", newImageConfig(300, 400, 75, 0, "box", "smart", "fff")},
+               {"100x200 bottomRight", newImageConfig(100, 200, 75, 0, "box", "BottomRight", "")},
+               {"10x20 topleft Lanczos", newImageConfig(10, 20, 75, 0, "Lanczos", "topleft", "")},
+               {"linear left 10x r180", newImageConfig(10, 0, 75, 180, "linear", "left", "")},
                {"x20 riGht Cosine q95", newImageConfig(0, 20, 95, 0, "cosine", "right", "")},
 
                {"", 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 err == nil {
                                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 {
-       var c ImageConfig
-       c.Action = "resize"
+       var c ImageConfig = GetDefaultImageConfig("resize", ImagingConfig{})
+       c.TargetFormat = PNG
+       c.Hint = 2
        c.Width = width
        c.Height = height
        c.Quality = quality
+       c.qualitySetForImage = quality != 75
        c.Rotate = rotate
        c.BgColorStr = bgColor
        c.BgColor, _ = hexStringToColor(bgColor)
@@ -130,10 +135,14 @@ func newImageConfig(width, height, quality, rotate int, filter, anchor, bgColor
        }
 
        if anchor != "" {
-               anchor = strings.ToLower(anchor)
-               if v, ok := anchorPositions[anchor]; ok {
-                       c.Anchor = v
+               if anchor == smartCropIdentifier {
                        c.AnchorStr = anchor
+               } else {
+                       anchor = strings.ToLower(anchor)
+                       if v, ok := anchorPositions[anchor]; ok {
+                               c.Anchor = v
+                               c.AnchorStr = anchor
+                       }
                }
        }
 
index b713212449c5ea8b5b6f3c2357cae576db552e02..db7d566a7d9aadf0a4d8ebb9a8ffe3aaba6a186f 100644 (file)
@@ -23,6 +23,9 @@ import (
        "io"
        "sync"
 
+       "github.com/bep/gowebp/libwebp/webpoptions"
+       "github.com/gohugoio/hugo/resources/images/webp"
+
        "github.com/gohugoio/hugo/media"
        "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:
                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:
                return errors.New("format not supported")
        }
@@ -229,10 +241,11 @@ func (p *ImageProcessor) Filter(src image.Image, filters ...gift.Filter) (image.
        return dst, nil
 }
 
-func (p *ImageProcessor) GetDefaultImageConfig(action string) ImageConfig {
+func GetDefaultImageConfig(action string, defaults ImagingConfig) ImageConfig {
        return ImageConfig{
                Action:  action,
-               Quality: p.Cfg.Cfg.Quality,
+               Hint:    defaults.Hint,
+               Quality: defaults.Cfg.Quality,
        }
 }
 
@@ -250,11 +263,13 @@ const (
        GIF
        TIFF
        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 {
-       return f == JPEG
+       return f == JPEG || f == WEBP
 }
 
 // SupportsTransparency reports whether it supports transparency in any form.
@@ -281,6 +296,8 @@ func (f Format) MediaType() media.Type {
                return media.TIFFType
        case BMP:
                return media.BMPType
+       case WEBP:
+               return media.WEBPType
        default:
                panic(fmt.Sprintf("%d is not a valid image format", f))
        }
diff --git a/resources/images/webp/webp.go b/resources/images/webp/webp.go
new file mode 100644 (file)
index 0000000..d7443ff
--- /dev/null
@@ -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)
+}
diff --git a/resources/images/webp/webp_notavailable.go b/resources/images/webp/webp_notavailable.go
new file mode 100644 (file)
index 0000000..4209eb4
--- /dev/null
@@ -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
+}
diff --git a/resources/testdata/sunset.webp b/resources/testdata/sunset.webp
new file mode 100644 (file)
index 0000000..4365e7b
Binary files /dev/null and b/resources/testdata/sunset.webp differ