diff --git a/common/hugo/hugo.go b/common/hugo/hugo.go index 6e94a8a33..d8d1c7c12 100644 --- a/common/hugo/hugo.go +++ b/common/hugo/hugo.go @@ -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() diff --git a/docs/content/en/content-management/image-processing/index.md b/docs/content/en/content-management/image-processing/index.md index a432b9851..8cd00210f 100644 --- a/docs/content/en/content-management/image-processing/index.md +++ b/docs/content/en/content-management/image-processing/index.md @@ -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 781b53c12..187842a8c 100644 --- 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 a19984430..d2b71844e 100644 --- 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= diff --git a/hugolib/image_test.go b/hugolib/image_test.go index 1d1520460..0dacf2a33 100644 --- a/hugolib/image_test.go +++ b/hugolib/image_test.go @@ -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()? diff --git a/media/mediaType.go b/media/mediaType.go index a35d80e3e..164ad5fd2 100644 --- a/media/mediaType.go +++ b/media/mediaType.go @@ -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, diff --git a/media/mediaType_test.go b/media/mediaType_test.go index e44ab27ec..6bc42b3d4 100644 --- a/media/mediaType_test.go +++ b/media/mediaType_test.go @@ -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) { diff --git a/resources/image.go b/resources/image.go index 0396c2208..edf05639f 100644 --- a/resources/image.go +++ b/resources/image.go @@ -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 index 000000000..9fd9304d9 --- /dev/null +++ b/resources/image_extended_test.go @@ -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) +} diff --git a/resources/images/config.go b/resources/images/config.go index 7b2ade29f..a4942688b 100644 --- a/resources/images/config.go +++ b/resources/images/config.go @@ -14,23 +14,22 @@ 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 } -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 - } +const ( + defaultJPEGQuality = 75 + defaultResampleFilter = "box" + defaultBgColor = "ffffff" + defaultHint = "photo" +) - 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") - } - - 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 +var defaultImaging = Imaging{ + ResampleFilter: defaultResampleFilter, + BgColor: defaultBgColor, + Hint: defaultHint, + Quality: defaultJPEGQuality, } -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 ( - 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 diff --git a/resources/images/config_test.go b/resources/images/config_test.go index 2a0de9ec0..7b2459250 100644 --- a/resources/images/config_test.go +++ b/resources/images/config_test.go @@ -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 + } } } diff --git a/resources/images/image.go b/resources/images/image.go index b71321244..db7d566a7 100644 --- a/resources/images/image.go +++ b/resources/images/image.go @@ -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 index 000000000..d7443ff23 --- /dev/null +++ b/resources/images/webp/webp.go @@ -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 index 000000000..4209eb41a --- /dev/null +++ b/resources/images/webp/webp_notavailable.go @@ -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 index 000000000..4365e7b9f Binary files /dev/null and b/resources/testdata/sunset.webp differ