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) { func GetOrCompileRegexp(pattern string) (re *regexp.Regexp, err error) {
return reCache.getOrCompileRegexp(pattern) 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 ## 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 %}} {{% 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. 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 %}} {{% /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
Resize an image to the specified width and/or height. 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> [github.com/disintegration/imaging]: <https://github.com/disintegration/imaging#image-resizing>
[Smartcrop]: <https://github.com/muesli/smartcrop#smartcrop> [Smartcrop]: <https://github.com/muesli/smartcrop#smartcrop>
[Exif]: <https://en.wikipedia.org/wiki/Exif> [Exif]: <https://en.wikipedia.org/wiki/Exif>
[`Process`]: #process
[`Colors`]: #colors [`Colors`]: #colors
[`Crop`]: #crop [`Crop`]: #crop
[`Exif`]: #exif [`Exif`]: #exif

View file

@ -53,18 +53,6 @@ func TCPListen() (net.Listener, *net.TCPAddr, error) {
} }
l.Close() l.Close()
return nil, nil, fmt.Errorf("unable to obtain a valid tcp port: %v", addr) 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. // FirstUpper returns a string with the first character as upper case.

View file

@ -20,12 +20,12 @@ import (
"testing" "testing"
qt "github.com/frankban/quicktest" qt "github.com/frankban/quicktest"
"github.com/gohugoio/hugo/common/hstrings"
"github.com/gohugoio/hugo/config" "github.com/gohugoio/hugo/config"
"github.com/gohugoio/hugo/resources/kinds" "github.com/gohugoio/hugo/resources/kinds"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/gohugoio/hugo/helpers"
"github.com/gohugoio/hugo/output" "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 ... // There is currently always a JSON output to make it simpler ...
altFormats := lenOut - 1 altFormats := lenOut - 1
hasHTML := helpers.InStringArray(outputs, "html") hasHTML := hstrings.InSlice(outputs, "html")
b.AssertFileContent("public/index.json", b.AssertFileContent("public/index.json",
"List JSON", "List JSON",
fmt.Sprintf("Alt formats: %d", altFormats), 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.RelPermalink(), qt.Equals, "/blog/index.json")
b.Assert(json.Permalink(), qt.Equals, "http://example.com/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") cal := of.Get("calendar")
b.Assert(cal, qt.Not(qt.IsNil)) b.Assert(cal, qt.Not(qt.IsNil))
b.Assert(cal.RelPermalink(), qt.Equals, "/blog/index.ics") b.Assert(cal.RelPermalink(), qt.Equals, "/blog/index.ics")

View file

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

View file

@ -31,6 +31,7 @@ import (
color_extractor "github.com/marekm4/color-extractor" color_extractor "github.com/marekm4/color-extractor"
"github.com/gohugoio/hugo/common/hstrings"
"github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/common/paths"
"github.com/gohugoio/hugo/identity" "github.com/gohugoio/hugo/identity"
@ -198,75 +199,49 @@ func (i *imageResource) cloneWithUpdates(u *transformationUpdate) (baseResource,
}, nil }, 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 // 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 // filter and returns the transformed image. If one of width or height is 0, the image aspect
// ratio is preserved. // ratio is preserved.
func (i *imageResource) Resize(spec string) (images.ImageResource, error) { func (i *imageResource) Resize(spec string) (images.ImageResource, error) {
conf, err := i.decodeImageConfig("resize", spec) return i.processActionSpec(images.ActionResize, spec)
if err != nil {
return nil, err
}
return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
return i.Proc.ApplyFiltersFromConfig(src, conf)
})
} }
// Crop the image to the specified dimensions without resizing using the given anchor point. // Crop the image to the specified dimensions without resizing using the given anchor point.
// Space delimited config, e.g. `200x300 TopLeft`. // Space delimited config, e.g. `200x300 TopLeft`.
func (i *imageResource) Crop(spec string) (images.ImageResource, error) { func (i *imageResource) Crop(spec string) (images.ImageResource, error) {
conf, err := i.decodeImageConfig("crop", spec) return i.processActionSpec(images.ActionCrop, spec)
if err != nil {
return nil, err
}
return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
return i.Proc.ApplyFiltersFromConfig(src, conf)
})
} }
// Fit scales down the image using the specified resample filter to fit the specified // Fit scales down the image using the specified resample filter to fit the specified
// maximum width and height. // maximum width and height.
func (i *imageResource) Fit(spec string) (images.ImageResource, error) { func (i *imageResource) Fit(spec string) (images.ImageResource, error) {
conf, err := i.decodeImageConfig("fit", spec) return i.processActionSpec(images.ActionFit, spec)
if err != nil {
return nil, err
}
return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
return i.Proc.ApplyFiltersFromConfig(src, conf)
})
} }
// Fill scales the image to the smallest possible size that will cover the specified dimensions, // 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. // crops the resized image to the specified dimensions using the given anchor point.
// Space delimited config, e.g. `200x300 TopLeft`. // Space delimited config, e.g. `200x300 TopLeft`.
func (i *imageResource) Fill(spec string) (images.ImageResource, error) { func (i *imageResource) Fill(spec string) (images.ImageResource, error) {
conf, err := i.decodeImageConfig("fill", spec) return i.processActionSpec(images.ActionFill, 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
} }
func (i *imageResource) Filter(filters ...any) (images.ImageResource, error) { 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, // 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 // so there is not much to gain from adding more load to the mix. That
// can even have negative effect in low resource scenarios. // 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) { 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 { if err != nil {
return conf, err return conf, err
} }

View file

@ -84,10 +84,7 @@ func TestImageTransformBasic(t *testing.T) {
fileCache := spec.FileCaches.ImageCache().Fs fileCache := spec.FileCaches.ImageCache().Fs
assertWidthHeight := func(img images.ImageResource, w, h int) { assertWidthHeight := func(img images.ImageResource, w, h int) {
c.Helper() assertWidthHeight(c, img, w, h)
c.Assert(img, qt.Not(qt.IsNil))
c.Assert(img.Width(), qt.Equals, w)
c.Assert(img.Height(), qt.Equals, h)
} }
colors, err := image.Colors() colors, err := image.Colors()
@ -164,6 +161,45 @@ func TestImageTransformBasic(t *testing.T) {
c.Assert(cropped, qt.Equals, croppedAgain) 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) { func TestImageTransformFormat(t *testing.T) {
c := qt.New(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 package images
import ( import (
"errors"
"fmt" "fmt"
"image/color" "image/color"
"strconv" "strconv"
@ -24,13 +25,18 @@ import (
"github.com/gohugoio/hugo/media" "github.com/gohugoio/hugo/media"
"github.com/mitchellh/mapstructure" "github.com/mitchellh/mapstructure"
"errors"
"github.com/bep/gowebp/libwebp/webpoptions" "github.com/bep/gowebp/libwebp/webpoptions"
"github.com/disintegration/gift" "github.com/disintegration/gift"
) )
const (
ActionResize = "resize"
ActionCrop = "crop"
ActionFit = "fit"
ActionFill = "fill"
)
var ( var (
imageFormats = map[string]Format{ imageFormats = map[string]Format{
".jpg": JPEG, ".jpg": JPEG,
@ -90,7 +96,6 @@ var hints = map[string]webpoptions.EncodingPreset{
} }
var imageFilters = map[string]gift.Resampling{ var imageFilters = map[string]gift.Resampling{
strings.ToLower("NearestNeighbor"): gift.NearestNeighborResampling, strings.ToLower("NearestNeighbor"): gift.NearestNeighborResampling,
strings.ToLower("Box"): gift.BoxResampling, strings.ToLower("Box"): gift.BoxResampling,
strings.ToLower("Linear"): gift.LinearResampling, 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 nil, fmt.Errorf("failed to decode media types: %w", err)
} }
return ns, nil 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 ( var (
c ImageConfig = GetDefaultImageConfig(action, defaults) c ImageConfig = GetDefaultImageConfig(action, defaults)
err error err error
) )
action = strings.ToLower(action)
c.Action = action c.Action = action
if config == "" { if options == nil {
return c, errors.New("image config cannot be empty") return c, errors.New("image options cannot be empty")
} }
parts := strings.Fields(config) for _, part := range options {
for _, part := range parts {
part = strings.ToLower(part) part = strings.ToLower(part)
if part == smartCropIdentifier { if part == smartCropIdentifier {
@ -272,19 +277,21 @@ func DecodeImageConfig(action, config string, defaults *config.ConfigNamespace[I
} }
switch c.Action { switch c.Action {
case "crop", "fill", "fit": case ActionCrop, ActionFill, ActionFit:
if c.Width == 0 || c.Height == 0 { if c.Width == 0 || c.Height == 0 {
return c, errors.New("must provide Width and Height") return c, errors.New("must provide Width and Height")
} }
case "resize": case ActionResize:
if c.Width == 0 && c.Height == 0 { if c.Width == 0 && c.Height == 0 {
return c, errors.New("must provide Width or Height") return c, errors.New("must provide Width or Height")
} }
default: 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.FilterStr = defaults.Config.Imaging.ResampleFilter
c.Filter = defaults.Config.ResampleFilter c.Filter = defaults.Config.ResampleFilter
} }
@ -293,7 +300,7 @@ func DecodeImageConfig(action, config string, defaults *config.ConfigNamespace[I
c.Hint = webpoptions.EncodingPresetPhoto c.Hint = webpoptions.EncodingPresetPhoto
} }
if c.AnchorStr == "" { if action != "" && c.AnchorStr == "" {
c.AnchorStr = defaults.Config.Imaging.Anchor c.AnchorStr = defaults.Config.Imaging.Anchor
c.Anchor = defaults.Config.Anchor c.Anchor = defaults.Config.Anchor
} }
@ -391,7 +398,7 @@ func (i ImageConfig) GetKey(format Format) string {
k += "_" + i.FilterStr k += "_" + i.FilterStr
if strings.EqualFold(i.Action, "fill") || strings.EqualFold(i.Action, "crop") { if i.Action == ActionFill || i.Action == ActionCrop {
k += "_" + anchor k += "_" + anchor
} }
@ -437,7 +444,6 @@ func (i *ImagingConfigInternal) Compile(externalCfg *ImagingConfig) error {
i.ResampleFilter = filter i.ResampleFilter = filter
return nil return nil
} }
// ImagingConfig contains default image processing configuration. This will be fetched // ImagingConfig contains default image processing configuration. This will be fetched
@ -487,7 +493,6 @@ func (cfg *ImagingConfig) init() error {
} }
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
// available. As we cache this info to disk, this is for performance and // available. As we cache this info to disk, this is for performance and
// disk space reasons more than anything. // disk space reasons more than anything.

View file

@ -106,7 +106,7 @@ func TestDecodeImageConfig(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) 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 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)

View file

@ -14,6 +14,7 @@
package images package images
import ( import (
"errors"
"fmt" "fmt"
"image" "image"
"image/color" "image/color"
@ -35,8 +36,6 @@ import (
"golang.org/x/image/bmp" "golang.org/x/image/bmp"
"golang.org/x/image/tiff" "golang.org/x/image/tiff"
"errors"
"github.com/gohugoio/hugo/common/hugio" "github.com/gohugoio/hugo/common/hugio"
) )
@ -245,7 +244,11 @@ func (p *ImageProcessor) ApplyFiltersFromConfig(src image.Image, conf ImageConfi
case "fit": case "fit":
filters = append(filters, gift.ResizeToFit(conf.Width, conf.Height, conf.Filter)) filters = append(filters, gift.ResizeToFit(conf.Width, conf.Height, conf.Filter))
default: 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...) 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...) 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...) filter := gift.New(filters...)
if giph, ok := src.(Giphy); ok { if giph, ok := src.(Giphy); ok {

View file

@ -33,6 +33,9 @@ type ImageResourceOps interface {
// Width returns the width of the Image. // Width returns the width of the Image.
Width() int 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. // Crop an image to match the given dimensions without resizing.
// You must provide both width and height. // You must provide both width and height.
// Use the anchor option to change the crop box anchor point. // Use the anchor option to change the crop box anchor point.

View file

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

View file

@ -195,6 +195,10 @@ func (r resourceAdapter) cloneTo(targetPath string) resource.Resource {
return &r 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) { func (r *resourceAdapter) Crop(spec string) (images.ImageResource, error) {
return r.getImageOps().Crop(spec) 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) { func (r resourceAdapter) TransformWithContext(ctx context.Context, t ...ResourceTransformation) (ResourceTransformer, error) {
r.resourceTransformations = &resourceTransformations{ r.resourceTransformations = &resourceTransformations{
transformations: append(r.transformations, t...), 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'." 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" { } else if tr.Key().Name == "tocss-dart" {
errMsg = ". You need dart-sass-embedded in your system $PATH." errMsg = ". You need dart-sass-embedded in your system $PATH."
} else if tr.Key().Name == "babel" { } else if tr.Key().Name == "babel" {
errMsg = ". You need to install Babel, see https://gohugo.io/hugo-pipes/babel/" errMsg = ". You need to install Babel, see https://gohugo.io/hugo-pipes/babel/"
} }