Add $image.Process

Which supports all the existing actions: resize, crop, fit, fill.

But it also allows plain format conversions:

```
{{ $img = $img.Process "webp" }}
```

Which will be a simple re-encoding of the source image.

Fixes #11483
This commit is contained in:
Bjørn Erik Pedersen 2023-09-22 15:15:16 +02:00
parent c32094ace1
commit ef0e7149d6
13 changed files with 216 additions and 98 deletions

View file

@ -99,3 +99,26 @@ var reCache = regexpCache{re: make(map[string]*regexp.Regexp)}
func GetOrCompileRegexp(pattern string) (re *regexp.Regexp, err error) {
return reCache.getOrCompileRegexp(pattern)
}
// InSlice checks if a string is an element of a slice of strings
// and returns a boolean value.
func InSlice(arr []string, el string) bool {
for _, v := range arr {
if v == el {
return true
}
}
return false
}
// InSlicEqualFold checks if a string is an element of a slice of strings
// and returns a boolean value.
// It uses strings.EqualFold to compare.
func InSlicEqualFold(arr []string, el string) bool {
for _, v := range arr {
if strings.EqualFold(v, el) {
return true
}
}
return false
}

View file

@ -101,12 +101,41 @@ Example 4: Skips rendering if there's problem accessing a remote resource.
## Image processing methods
The `image` resource implements the [`Resize`], [`Fit`], [`Fill`], [`Crop`], [`Filter`], [`Colors`] and [`Exif`] methods.
The `image` resource implements the [`Process`], [`Resize`], [`Fit`], [`Fill`], [`Crop`], [`Filter`], [`Colors`] and [`Exif`] methods.
{{% note %}}
Metadata (EXIF, IPTC, XMP, etc.) is not preserved during image transformation. Use the [`Exif`] method with the _original_ image to extract EXIF metadata from JPEG or TIFF images.
{{% /note %}}
### Process
{{< new-in "0.119.0" >}}
Process processes the image with the given specification. The specification can contain an optional action, one of `resize`, `crop`, `fit` or `fill`. This means that you can use this method instead of [`Resize`], [`Fit`], [`Fill`], or [`Crop`].
See [Options](#image-processing-options) for available options.
You can also use this method apply image processing that does not need any scaling, e.g. format conversions:
```go-html-template
{{/* Convert the image from JPG to PNG. */}}
{{ $png := $jpg.Process "png" }}
```
Some more examples:
```go-html-template
{{/* Rotate the image 90 degrees counter-clockwise. */}}
{{ $image := $image.Process "r90" }}
{{/* Scaling actions. */}}
{{ $image := $image.Process "resize 600x" }}
{{ $image := $image.Process "crop 600x400" }}
{{ $image := $image.Process "fit 600x400" }}
{{ $image := $image.Process "fill 600x400" }}
```
### Resize
Resize an image to the specified width and/or height.
@ -477,6 +506,7 @@ hugo --gc
[github.com/disintegration/imaging]: <https://github.com/disintegration/imaging#image-resizing>
[Smartcrop]: <https://github.com/muesli/smartcrop#smartcrop>
[Exif]: <https://en.wikipedia.org/wiki/Exif>
[`Process`]: #process
[`Colors`]: #colors
[`Crop`]: #crop
[`Exif`]: #exif

View file

@ -53,18 +53,6 @@ func TCPListen() (net.Listener, *net.TCPAddr, error) {
}
l.Close()
return nil, nil, fmt.Errorf("unable to obtain a valid tcp port: %v", addr)
}
// InStringArray checks if a string is an element of a slice of strings
// and returns a boolean value.
func InStringArray(arr []string, el string) bool {
for _, v := range arr {
if v == el {
return true
}
}
return false
}
// FirstUpper returns a string with the first character as upper case.

View file

@ -20,12 +20,12 @@ import (
"testing"
qt "github.com/frankban/quicktest"
"github.com/gohugoio/hugo/common/hstrings"
"github.com/gohugoio/hugo/config"
"github.com/gohugoio/hugo/resources/kinds"
"github.com/spf13/afero"
"github.com/gohugoio/hugo/helpers"
"github.com/gohugoio/hugo/output"
)
@ -152,7 +152,7 @@ Len Pages: {{ .Kind }} {{ len .Site.RegularPages }} Page Number: {{ .Paginator.P
// There is currently always a JSON output to make it simpler ...
altFormats := lenOut - 1
hasHTML := helpers.InStringArray(outputs, "html")
hasHTML := hstrings.InSlice(outputs, "html")
b.AssertFileContent("public/index.json",
"List JSON",
fmt.Sprintf("Alt formats: %d", altFormats),
@ -205,7 +205,7 @@ Len Pages: {{ .Kind }} {{ len .Site.RegularPages }} Page Number: {{ .Paginator.P
b.Assert(json.RelPermalink(), qt.Equals, "/blog/index.json")
b.Assert(json.Permalink(), qt.Equals, "http://example.com/blog/index.json")
if helpers.InStringArray(outputs, "cal") {
if hstrings.InSlice(outputs, "cal") {
cal := of.Get("calendar")
b.Assert(cal, qt.Not(qt.IsNil))
b.Assert(cal.RelPermalink(), qt.Equals, "/blog/index.ics")

View file

@ -100,6 +100,10 @@ func (e *errorResource) Width() int {
panic(e.ResourceError)
}
func (e *errorResource) Process(spec string) (images.ImageResource, error) {
panic(e.ResourceError)
}
func (e *errorResource) Crop(spec string) (images.ImageResource, error) {
panic(e.ResourceError)
}

View file

@ -31,6 +31,7 @@ import (
color_extractor "github.com/marekm4/color-extractor"
"github.com/gohugoio/hugo/common/hstrings"
"github.com/gohugoio/hugo/common/paths"
"github.com/gohugoio/hugo/identity"
@ -198,75 +199,49 @@ func (i *imageResource) cloneWithUpdates(u *transformationUpdate) (baseResource,
}, nil
}
var imageActions = []string{images.ActionResize, images.ActionCrop, images.ActionFit, images.ActionFill}
// Process processes the image with the given spec.
// The spec can contain an optional action, one of "resize", "crop", "fit" or "fill".
// This makes this method a more flexible version that covers all of Resize, Crop, Fit and Fill,
// but it also supports e.g. format conversions without any resize action.
func (i *imageResource) Process(spec string) (images.ImageResource, error) {
var action string
options := strings.Fields(spec)
for i, p := range options {
if hstrings.InSlicEqualFold(imageActions, p) {
action = p
options = append(options[:i], options[i+1:]...)
break
}
}
return i.processActionOptions(action, options)
}
// Resize resizes the image to the specified width and height using the specified resampling
// filter and returns the transformed image. If one of width or height is 0, the image aspect
// ratio is preserved.
func (i *imageResource) Resize(spec string) (images.ImageResource, error) {
conf, err := i.decodeImageConfig("resize", spec)
if err != nil {
return nil, err
}
return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
return i.Proc.ApplyFiltersFromConfig(src, conf)
})
return i.processActionSpec(images.ActionResize, spec)
}
// Crop the image to the specified dimensions without resizing using the given anchor point.
// Space delimited config, e.g. `200x300 TopLeft`.
func (i *imageResource) Crop(spec string) (images.ImageResource, error) {
conf, err := i.decodeImageConfig("crop", spec)
if err != nil {
return nil, err
}
return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
return i.Proc.ApplyFiltersFromConfig(src, conf)
})
return i.processActionSpec(images.ActionCrop, spec)
}
// Fit scales down the image using the specified resample filter to fit the specified
// maximum width and height.
func (i *imageResource) Fit(spec string) (images.ImageResource, error) {
conf, err := i.decodeImageConfig("fit", spec)
if err != nil {
return nil, err
}
return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
return i.Proc.ApplyFiltersFromConfig(src, conf)
})
return i.processActionSpec(images.ActionFit, spec)
}
// Fill scales the image to the smallest possible size that will cover the specified dimensions,
// crops the resized image to the specified dimensions using the given anchor point.
// Space delimited config, e.g. `200x300 TopLeft`.
func (i *imageResource) Fill(spec string) (images.ImageResource, error) {
conf, err := i.decodeImageConfig("fill", spec)
if err != nil {
return nil, err
}
img, err := i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
return i.Proc.ApplyFiltersFromConfig(src, conf)
})
if err != nil {
return nil, err
}
if conf.Anchor == 0 && img.Width() == 0 || img.Height() == 0 {
// See https://github.com/gohugoio/hugo/issues/7955
// Smartcrop fails silently in some rare cases.
// Fall back to a center fill.
conf.Anchor = gift.CenterAnchor
conf.AnchorStr = "center"
return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
return i.Proc.ApplyFiltersFromConfig(src, conf)
})
}
return img, err
return i.processActionSpec(images.ActionFill, spec)
}
func (i *imageResource) Filter(filters ...any) (images.ImageResource, error) {
@ -286,6 +261,39 @@ func (i *imageResource) Filter(filters ...any) (images.ImageResource, error) {
})
}
func (i *imageResource) processActionSpec(action, spec string) (images.ImageResource, error) {
return i.processActionOptions(action, strings.Fields(spec))
}
func (i *imageResource) processActionOptions(action string, options []string) (images.ImageResource, error) {
conf, err := images.DecodeImageConfig(action, options, i.Proc.Cfg, i.Format)
if err != nil {
return nil, err
}
img, err := i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
return i.Proc.ApplyFiltersFromConfig(src, conf)
})
if err != nil {
return nil, err
}
if action == images.ActionFill {
if conf.Anchor == 0 && img.Width() == 0 || img.Height() == 0 {
// See https://github.com/gohugoio/hugo/issues/7955
// Smartcrop fails silently in some rare cases.
// Fall back to a center fill.
conf.Anchor = gift.CenterAnchor
conf.AnchorStr = "center"
return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
return i.Proc.ApplyFiltersFromConfig(src, conf)
})
}
}
return img, nil
}
// Serialize image processing. The imaging library spins up its own set of Go routines,
// so there is not much to gain from adding more load to the mix. That
// can even have negative effect in low resource scenarios.
@ -362,7 +370,8 @@ 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, i.Format)
options := strings.Fields(spec)
conf, err := images.DecodeImageConfig(action, options, i.Proc.Cfg, i.Format)
if err != nil {
return conf, err
}

View file

@ -84,10 +84,7 @@ func TestImageTransformBasic(t *testing.T) {
fileCache := spec.FileCaches.ImageCache().Fs
assertWidthHeight := func(img images.ImageResource, w, h int) {
c.Helper()
c.Assert(img, qt.Not(qt.IsNil))
c.Assert(img.Width(), qt.Equals, w)
c.Assert(img.Height(), qt.Equals, h)
assertWidthHeight(c, img, w, h)
}
colors, err := image.Colors()
@ -164,6 +161,45 @@ func TestImageTransformBasic(t *testing.T) {
c.Assert(cropped, qt.Equals, croppedAgain)
}
func TestImageProcess(t *testing.T) {
c := qt.New(t)
_, img := fetchSunset(c)
resized, err := img.Process("resiZe 300x200")
c.Assert(err, qt.IsNil)
assertWidthHeight(c, resized, 300, 200)
rotated, err := resized.Process("R90")
c.Assert(err, qt.IsNil)
assertWidthHeight(c, rotated, 200, 300)
converted, err := img.Process("png")
c.Assert(err, qt.IsNil)
c.Assert(converted.MediaType().Type, qt.Equals, "image/png")
checkProcessVsMethod := func(action, spec string) {
var expect images.ImageResource
var err error
switch action {
case images.ActionCrop:
expect, err = img.Crop(spec)
case images.ActionFill:
expect, err = img.Fill(spec)
case images.ActionFit:
expect, err = img.Fit(spec)
case images.ActionResize:
expect, err = img.Resize(spec)
}
c.Assert(err, qt.IsNil)
got, err := img.Process(spec + " " + action)
c.Assert(err, qt.IsNil)
assertWidthHeight(c, got, expect.Width(), expect.Height())
c.Assert(got.MediaType(), qt.Equals, expect.MediaType())
}
checkProcessVsMethod(images.ActionCrop, "300x200 topleFt")
checkProcessVsMethod(images.ActionFill, "300x200 topleft")
checkProcessVsMethod(images.ActionFit, "300x200 png")
checkProcessVsMethod(images.ActionResize, "300x R90")
}
func TestImageTransformFormat(t *testing.T) {
c := qt.New(t)
@ -852,3 +888,10 @@ func BenchmarkResizeParallel(b *testing.B) {
}
})
}
func assertWidthHeight(c *qt.C, img images.ImageResource, w, h int) {
c.Helper()
c.Assert(img, qt.Not(qt.IsNil))
c.Assert(img.Width(), qt.Equals, w)
c.Assert(img.Height(), qt.Equals, h)
}

View file

@ -14,6 +14,7 @@
package images
import (
"errors"
"fmt"
"image/color"
"strconv"
@ -24,13 +25,18 @@ import (
"github.com/gohugoio/hugo/media"
"github.com/mitchellh/mapstructure"
"errors"
"github.com/bep/gowebp/libwebp/webpoptions"
"github.com/disintegration/gift"
)
const (
ActionResize = "resize"
ActionCrop = "crop"
ActionFit = "fit"
ActionFill = "fill"
)
var (
imageFormats = map[string]Format{
".jpg": JPEG,
@ -90,7 +96,6 @@ var hints = map[string]webpoptions.EncodingPreset{
}
var imageFilters = map[string]gift.Resampling{
strings.ToLower("NearestNeighbor"): gift.NearestNeighborResampling,
strings.ToLower("Box"): gift.BoxResampling,
strings.ToLower("Linear"): gift.LinearResampling,
@ -194,23 +199,23 @@ func DecodeConfig(in map[string]any) (*config.ConfigNamespace[ImagingConfig, Ima
return nil, fmt.Errorf("failed to decode media types: %w", err)
}
return ns, nil
}
func DecodeImageConfig(action, config string, defaults *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal], sourceFormat Format) (ImageConfig, error) {
func DecodeImageConfig(action string, options []string, defaults *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal], sourceFormat Format) (ImageConfig, error) {
var (
c ImageConfig = GetDefaultImageConfig(action, defaults)
err error
)
action = strings.ToLower(action)
c.Action = action
if config == "" {
return c, errors.New("image config cannot be empty")
if options == nil {
return c, errors.New("image options cannot be empty")
}
parts := strings.Fields(config)
for _, part := range parts {
for _, part := range options {
part = strings.ToLower(part)
if part == smartCropIdentifier {
@ -272,19 +277,21 @@ func DecodeImageConfig(action, config string, defaults *config.ConfigNamespace[I
}
switch c.Action {
case "crop", "fill", "fit":
case ActionCrop, ActionFill, ActionFit:
if c.Width == 0 || c.Height == 0 {
return c, errors.New("must provide Width and Height")
}
case "resize":
case ActionResize:
if c.Width == 0 && c.Height == 0 {
return c, errors.New("must provide Width or Height")
}
default:
return c, fmt.Errorf("BUG: unknown action %q encountered while decoding image configuration", c.Action)
if c.Width != 0 || c.Height != 0 {
return c, errors.New("width or height are not supported for this action")
}
}
if c.FilterStr == "" {
if action != "" && c.FilterStr == "" {
c.FilterStr = defaults.Config.Imaging.ResampleFilter
c.Filter = defaults.Config.ResampleFilter
}
@ -293,7 +300,7 @@ func DecodeImageConfig(action, config string, defaults *config.ConfigNamespace[I
c.Hint = webpoptions.EncodingPresetPhoto
}
if c.AnchorStr == "" {
if action != "" && c.AnchorStr == "" {
c.AnchorStr = defaults.Config.Imaging.Anchor
c.Anchor = defaults.Config.Anchor
}
@ -391,7 +398,7 @@ func (i ImageConfig) GetKey(format Format) string {
k += "_" + i.FilterStr
if strings.EqualFold(i.Action, "fill") || strings.EqualFold(i.Action, "crop") {
if i.Action == ActionFill || i.Action == ActionCrop {
k += "_" + anchor
}
@ -437,7 +444,6 @@ func (i *ImagingConfigInternal) Compile(externalCfg *ImagingConfig) error {
i.ResampleFilter = filter
return nil
}
// ImagingConfig contains default image processing configuration. This will be fetched
@ -487,7 +493,6 @@ func (cfg *ImagingConfig) init() error {
}
type ExifConfig struct {
// Regexp matching the Exif fields you want from the (massive) set of Exif info
// available. As we cache this info to disk, this is for performance and
// disk space reasons more than anything.

View file

@ -106,7 +106,7 @@ func TestDecodeImageConfig(t *testing.T) {
if err != nil {
t.Fatal(err)
}
result, err := DecodeImageConfig(this.action, this.in, cfg, PNG)
result, err := DecodeImageConfig(this.action, strings.Fields(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)

View file

@ -14,6 +14,7 @@
package images
import (
"errors"
"fmt"
"image"
"image/color"
@ -35,8 +36,6 @@ import (
"golang.org/x/image/bmp"
"golang.org/x/image/tiff"
"errors"
"github.com/gohugoio/hugo/common/hugio"
)
@ -245,7 +244,11 @@ func (p *ImageProcessor) ApplyFiltersFromConfig(src image.Image, conf ImageConfi
case "fit":
filters = append(filters, gift.ResizeToFit(conf.Width, conf.Height, conf.Filter))
default:
return nil, fmt.Errorf("unsupported action: %q", conf.Action)
}
if len(filters) == 0 {
return p.resolveSrc(src, conf.TargetFormat), nil
}
img, err := p.doFilter(src, conf.TargetFormat, filters...)
@ -260,8 +263,17 @@ func (p *ImageProcessor) Filter(src image.Image, filters ...gift.Filter) (image.
return p.doFilter(src, 0, filters...)
}
func (p *ImageProcessor) doFilter(src image.Image, targetFormat Format, filters ...gift.Filter) (image.Image, error) {
func (p *ImageProcessor) resolveSrc(src image.Image, targetFormat Format) image.Image {
if giph, ok := src.(Giphy); ok {
g := giph.GIF()
if len(g.Image) < 2 || (targetFormat == 0 || targetFormat != GIF) {
src = g.Image[0]
}
}
return src
}
func (p *ImageProcessor) doFilter(src image.Image, targetFormat Format, filters ...gift.Filter) (image.Image, error) {
filter := gift.New(filters...)
if giph, ok := src.(Giphy); ok {

View file

@ -33,6 +33,9 @@ type ImageResourceOps interface {
// Width returns the width of the Image.
Width() int
// Process applies the given image processing options to the image.
Process(spec string) (ImageResource, error)
// Crop an image to match the given dimensions without resizing.
// You must provide both width and height.
// Use the anchor option to change the crop box anchor point.

View file

@ -14,6 +14,7 @@
package page
import (
"errors"
"fmt"
"os"
"path"
@ -23,8 +24,7 @@ import (
"strings"
"time"
"errors"
"github.com/gohugoio/hugo/common/hstrings"
"github.com/gohugoio/hugo/common/maps"
"github.com/gohugoio/hugo/helpers"
"github.com/gohugoio/hugo/resources/kinds"
@ -396,7 +396,6 @@ func (l PermalinkExpander) toSliceFunc(cut string) func(s []string) []string {
}
return s[n1:n2]
}
}
var permalinksKindsSupport = []string{kinds.KindPage, kinds.KindSection, kinds.KindTaxonomy, kinds.KindTerm}
@ -425,7 +424,7 @@ func DecodePermalinksConfig(m map[string]any) (map[string]map[string]string, err
// [permalinks.key]
// xyz = ???
if helpers.InStringArray(permalinksKindsSupport, k) {
if hstrings.InSlice(permalinksKindsSupport, k) {
// TODO: warn if we overwrite an already set value
for k2, v2 := range v {
switch v2 := v2.(type) {

View file

@ -195,6 +195,10 @@ func (r resourceAdapter) cloneTo(targetPath string) resource.Resource {
return &r
}
func (r *resourceAdapter) Process(spec string) (images.ImageResource, error) {
return r.getImageOps().Process(spec)
}
func (r *resourceAdapter) Crop(spec string) (images.ImageResource, error) {
return r.getImageOps().Crop(spec)
}
@ -287,7 +291,6 @@ func (r resourceAdapter) Transform(t ...ResourceTransformation) (ResourceTransfo
}
func (r resourceAdapter) TransformWithContext(ctx context.Context, t ...ResourceTransformation) (ResourceTransformer, error) {
r.resourceTransformations = &resourceTransformations{
transformations: append(r.transformations, t...),
}
@ -459,7 +462,6 @@ func (r *resourceAdapter) transform(publish, setContent bool) error {
errMsg = ". Check your Hugo installation; you need the extended version to build SCSS/SASS with transpiler set to 'libsass'."
} else if tr.Key().Name == "tocss-dart" {
errMsg = ". You need dart-sass-embedded in your system $PATH."
} else if tr.Key().Name == "babel" {
errMsg = ". You need to install Babel, see https://gohugo.io/hugo-pipes/babel/"
}