Repository: gohugoio/hugo (language: go)

Issue:
Improve image filters API
Given:

```html
{{ $img := $original.Resize "400x300 q55" }}
{{ $img = $img | images.Filter (images.GaussianBlur 6) (images.Pixelate 8) }}
```

There are currently some problems with the above:

1. The image gets encoded twice
2. There is no way to control the encoding (e.g. quality) for the second operation.
3. If you want the end result encode to different formats, you need to repeat the image processing for each target format (e.g. JPEG and WEBP)

I suggest that we 

* add a target options argument to `images.Filter` which can be a string (e.g. `webp`) or a map (which also allows setting quality settings)
* Export the resize functions (Resize, Fill, Fit) as filters.

This way you can do:

```
{{ $img = $img | images.Filter (images.Resize 400 300)  (images.GaussianBlur 6) (images.Pixelate 8) (images.EncodeTo "webp")  }}
```

I'm tempted to also use the same `images.Filter` to return a slice if multiple `images.EncodeTo` arguments is passed, e.g.:

```
{{ $imgages := $img | images.Filter (images.Resize 400 300)  (images.GaussianBlur 6) (images.Pixelate 8) (images.EncodeTo "webp")  (images.EncodeTo "jpeg")  }}
```

But it's probably best to just add a new function, e.g.:

```
{{ $imgages := $img | images.FilterAll (images.Resize 400 300)  (images.GaussianBlur 6) (images.Pixelate 8) (images.EncodeTo "webp")  (images.EncodeTo "jpeg")  }}
```

Feedback welcome, @jmooring @moorereason @onedrawingperday and gang.





Discussion / hints:
> The image gets encoded twice

Yes. I've been using the image filters to colorise some thumbnails and I noticed the resized yet not colorised thumbnails under the `resourceDir`.

>But it's probably best to just add a new function, e.g.:
>
>`{{ $imgages := $img | images.FilterAll (images.Resize 400 300)  (images.GaussianBlur 6) (images.Pixelate 8) (images.EncodeTo "webp")  (images.EncodeTo "jpeg")  }}`

I agree that it is  better to add the new function `images.FilterAll` instead of adding arguments to the existing one.


Can Rotate be exported as well?

I assume the operations are performed in the sequence I provide. So if I wanted to (for whatever reason) I could encode, then rotate, then resize?

This behaves kind of like pipes, but not quite. Just wondering if users would expect something like this instead?

```
{{ $img = $img | images.Resize 400 300 | images.Rotate 270 | images.EncodeTo "webp" }}
```


>Can Rotate be exported as well?

Yes it can.

>I assume the operations are performed in the sequence I provide. 

Yes.

>{{ $img = $img | images.Resize 400 300 | images.Rotate 270 | images.EncodeTo "webp" }}

Yes, that would be nice, and I will think long and hard about it. That said, the nice thing about thinking about the filters as lists is that you can reuse them, e.g.:

```html
{{ $commonFilters := slice  (images.GaussianBlur 6) (images.Pixelate 8) }}
{{ $image := $img.Filter (images.Rotate 90) $commonFilters }}

```

But maybe we can get to do both with some trickery ...
Regarding the proposed `images.EncodeTo` method please consider adding a setting for image quality.

For example 

`images.EncodeTo "q70 webp"`



May I add some more infos as I was the creator of this topic [IMAGES / WEBP just convert solely](https://discourse.gohugo.io/t/images-webp-just-convert-solely/32653)

I like this issue as it adresses the core problem, as the Image processing API probably should be improved.

I think combining these all things into one function "`image.process`" would be clever. Then all (resize, filter, convert, quality, edit) functuions can be combined into one function which applies what needs to be done. Also the name `resize` is a little confusing because it does way more then resizing which can me misunderstood.



`{{ $img := $original.Process "400x300 q55" }}`

basically is:

`{{ $img := $original.Resize "400x300" }}` 
&&
`{{ $img := $original.Compress "400x300 q55" }}` OR `{{ $img := $original.Optimize "400x300 q55" }}`

I think people (like myself) want more flexibility like:
1. just resizing
2. just changing quality
3. just converting
4. just applying filters

I for myself can think of using this image.processor in this way:

`{{ $img := $original.Process "680x800 q100 webp" }}`
OR
`{{ $img := $original.Process "q100 webp" }}`

The processor can automatically detect what should be done:
1. if dimensions/numbers are given like `680x` or `680x800` = RESIZE
2. if quality or filesize is given like `q100` or `70kb` = COMPRESSION / OPTIMIZATION
3. if a known mimetype is given like `webp`, `jpeg`, `avif`, `png` = CONVERT / DE- & ENCODING
4. filters etc etc etc

For the mentioned filters:
I like them they in my vision can be realised like this:

I want the same picture resized in quality 70 and in these three formats: `webp`, `png`, `avif` and some filter should be applied

`{{ $imgTemp := $original.ImageTemplate "400x300 q70  GaussianBlur6 Pixelate8" }}`
`{{ $img1 := $imgTemp.Process "webp" }}`
`{{ $img2 := $imgTemp.Process "png" }}`
`{{ $img3 := $imgTemp.Process "avif" }}` (ok realised AVIF is not available due to no native go implementation)

The function **`ImageTemplate`** shall keep the resized and decoded original-Image in Cache and just encodes it on the final `$imgTemp.Process` call. Just then the filters and settings apply. The good thing about this: you also just have to decode the image once.

You also should be able to overwrite other settings from the ImageTemplate like:
`{{ $imgTemp := $original.ImageTemplate "400x300 q70  GaussianBlur6 Pixelate8" }}`
`{{ $img1 := $imgTemp.Process "webp q100" }}`
`{{ $img2 := $imgTemp.Process "png  GaussianBlur0" }}`
`{{ $img3 := $imgTemp.Process "1200x avif Pixelate2" }}`

Goal is it to have all needed informations at the end, when the **`.Process`** function is called and all functions intelligently applied that are needed, but also be able just to convert or just to apply a single function is needed.
So all settings are optional, but at least one argument must be passed to the function **`.Process`**

To also provide my example with the overlopaded <picture> tag:

```
{{ $img  := resources.Get "/images/picture.jpg" }}
{{ $imgTemp := $img.ImageTemplate "680x800 q70" }}
{{ $imgJpeg = $img.Convert "jpeg" }} //will not reencode as already JPG/JPEG, so just resize and q=70 applies
{{ $imgWebp = $img.Convert "webp" }}

<picture>
    <source srcset="{{ $imgWebp.RelPermalink }}" type="{{ $imgWebp.Type }}">
    <img src="{{ $imgJpeg.RelPermalink }}" type="{{ $imgJpeg.Type }}" width="{{ $imgJpeg.Width }}" height="{{ $imgJpeg.Height }}">
</picture>
```

The expected output would be:

```
<picture>
    <source srcset="/images/picture-680x800-q70.webp" type="image/webp">
    <img src="/images/picture-680x800-q70.jpg" type="image/jpeg" width="640" height="800">
</picture>
```

Fix patch:
diff --git a/common/hstrings/strings.go b/common/hstrings/strings.go
index 2fd791f43ba..88df97607cf 100644
--- a/common/hstrings/strings.go
+++ b/common/hstrings/strings.go
@@ -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
+}
diff --git a/docs/content/en/content-management/image-processing/index.md b/docs/content/en/content-management/image-processing/index.md
index 63c9d4d2fa2..124cadc68eb 100644
--- a/docs/content/en/content-management/image-processing/index.md
+++ b/docs/content/en/content-management/image-processing/index.md
@@ -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
diff --git a/docs/content/en/functions/images/index.md b/docs/content/en/functions/images/index.md
index fcd0796e3fe..2b106714ea7 100644
--- a/docs/content/en/functions/images/index.md
+++ b/docs/content/en/functions/images/index.md
@@ -12,6 +12,28 @@ toc: true
 
 See [images.Filter](#filter) for how to apply these filters to an image.
 
+## Process
+
+{{< new-in "0.119.0" >}}
+
+{{% funcsig %}}
+images.Overlay SRC SPEC
+{{% /funcsig %}}
+
+A general purpose image processing function.
+
+This filter has all the same options as the [Process](/content-management/image-processing/#process) method, but using it as a filter may be more effective if you need to apply multiple filters to an image:
+
+```go-html-template
+{{ $filters := slice 
+  images.Grayscale
+  (images.GaussianBlur 8)
+  (images.Process "resize 200x jpg q30") 
+}}
+{{ $img = $img | images.Filter $filters }}
+```
+
+
 ## Overlay
 
 {{% funcsig %}}
@@ -36,6 +58,8 @@ The above will overlay `$logo` in the upper left corner of `$img` (at position `
 
 ## Opacity
 
+{{< new-in "0.119.0" >}}
+
 {{% funcsig %}}
 images.Opacity SRC OPACITY
 {{% /funcsig %}}
@@ -47,6 +71,15 @@ The OPACITY parameter must be in range (0, 1).
 {{ $img := $img.Filter (images.Opacity 0.5 )}}
 ```
 
+Note that target format must support transparency, e.g. PNG. If the source image is e.g. JPG, the most effective way would be to combine it with the [`Process`] filter:
+
+```go-html-template
+{{ $png := $jpg.Filter 
+  (images.Opacity 0.5)
+  (images.Process "png") 
+}}
+```
+
 ## Text
 
 Using the `Text` filter, you can add text to an image.
@@ -237,3 +270,5 @@ images.ImageConfig PATH
 favicon.ico: {{ .Width }} x {{ .Height }}
 {{ end }}
 ```
+
+[`Process`]: #process
\ No newline at end of file
diff --git a/helpers/general.go b/helpers/general.go
index f8f273397dd..e484b92f096 100644
--- a/helpers/general.go
+++ b/helpers/general.go
@@ -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.
diff --git a/resources/errorResource.go b/resources/errorResource.go
index c8c32dfc313..d94207b79e8 100644
--- a/resources/errorResource.go
+++ b/resources/errorResource.go
@@ -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)
 }
diff --git a/resources/image.go b/resources/image.go
index ad2f9de3227..9a24fd21c20 100644
--- a/resources/image.go
+++ b/resources/image.go
@@ -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,79 +199,45 @@ 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) {
+	action, options := i.resolveActionOptions(spec)
+	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) {
-	conf := images.GetDefaultImageConfig("filter", i.Proc.Cfg)
+	var conf images.ImageConfig
 
 	var gfilters []gift.Filter
 
@@ -278,12 +245,108 @@ func (i *imageResource) Filter(filters ...any) (images.ImageResource, error) {
 		gfilters = append(gfilters, images.ToFilters(f)...)
 	}
 
+	var (
+		targetFormat images.Format
+		configSet    bool
+	)
+	for _, f := range gfilters {
+		f = images.UnwrapFilter(f)
+		if specProvider, ok := f.(images.ImageProcessSpecProvider); ok {
+			action, options := i.resolveActionOptions(specProvider.ImageProcessSpec())
+			var err error
+			conf, err = images.DecodeImageConfig(action, options, i.Proc.Cfg, i.Format)
+			if err != nil {
+				return nil, err
+			}
+			configSet = true
+			if conf.TargetFormat != 0 {
+				targetFormat = conf.TargetFormat
+				// We only support one target format, but prefer the last one,
+				// so we keep going.
+			}
+		}
+	}
+
+	if !configSet {
+		conf = images.GetDefaultImageConfig("filter", i.Proc.Cfg)
+	}
+
+	conf.Action = "filter"
 	conf.Key = identity.HashString(gfilters)
-	conf.TargetFormat = i.Format
+	conf.TargetFormat = targetFormat
+	if conf.TargetFormat == 0 {
+		conf.TargetFormat = i.Format
+	}
 
 	return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
-		return i.Proc.Filter(src, gfilters...)
+		filters := gfilters
+		for j, f := range gfilters {
+			f = images.UnwrapFilter(f)
+			if specProvider, ok := f.(images.ImageProcessSpecProvider); ok {
+				processSpec := specProvider.ImageProcessSpec()
+				action, options := i.resolveActionOptions(processSpec)
+				conf, err := images.DecodeImageConfig(action, options, i.Proc.Cfg, i.Format)
+				if err != nil {
+					return nil, err
+				}
+				pFilters, err := i.Proc.FiltersFromConfig(src, conf)
+				if err != nil {
+					return nil, err
+				}
+				// Replace the filter with the new filters.
+				// This slice will be empty if this is just a format conversion.
+				filters = append(filters[:j], append(pFilters, filters[j+1:]...)...)
+
+			}
+		}
+		return i.Proc.Filter(src, filters...)
+	})
+}
+
+func (i *imageResource) resolveActionOptions(spec string) (string, []string) {
+	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 action, options
+}
+
+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,
@@ -362,7 +425,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
 	}
diff --git a/resources/images/config.go b/resources/images/config.go
index a3ca0c3597a..186f8fa6bce 100644
--- a/resources/images/config.go
+++ b/resources/images/config.go
@@ -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.
diff --git a/resources/images/filters.go b/resources/images/filters.go
index 63e90d2ada9..dca8ff0e8fc 100644
--- a/resources/images/filters.go
+++ b/resources/images/filters.go
@@ -30,6 +30,15 @@ const filterAPIVersion = 0
 
 type Filters struct{}
 
+func (*Filters) Process(spec any) gift.Filter {
+	return filter{
+		Options: newFilterOpts(spec),
+		Filter: processFilter{
+			spec: cast.ToString(spec),
+		},
+	}
+}
+
 // Overlay creates a filter that overlays src at position x y.
 func (*Filters) Overlay(src ImageSource, x, y any) gift.Filter {
 	return filter{
diff --git a/resources/images/image.go b/resources/images/image.go
index 672e8578f21..1637d0cf8b0 100644
--- a/resources/images/image.go
+++ b/resources/images/image.go
@@ -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"
 )
 
@@ -202,7 +201,7 @@ func (p *ImageProcessor) DecodeExif(r io.Reader) (*exif.ExifInfo, error) {
 	return p.exifDecoder.Decode(r)
 }
 
-func (p *ImageProcessor) ApplyFiltersFromConfig(src image.Image, conf ImageConfig) (image.Image, error) {
+func (p *ImageProcessor) FiltersFromConfig(src image.Image, conf ImageConfig) ([]gift.Filter, error) {
 	var filters []gift.Filter
 
 	if conf.Rotate != 0 {
@@ -245,7 +244,19 @@ 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)
+
+	}
+	return filters, nil
+}
+
+func (p *ImageProcessor) ApplyFiltersFromConfig(src image.Image, conf ImageConfig) (image.Image, error) {
+	filters, err := p.FiltersFromConfig(src, conf)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(filters) == 0 {
+		return p.resolveSrc(src, conf.TargetFormat), nil
 	}
 
 	img, err := p.doFilter(src, conf.TargetFormat, filters...)
@@ -260,8 +271,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 {
@@ -384,6 +404,15 @@ func imageConfigFromImage(img image.Image) image.Config {
 	return image.Config{Width: b.Max.X, Height: b.Max.Y}
 }
 
+// UnwrapFilter unwraps the given filter if it is a filter wrapper.
+func UnwrapFilter(in gift.Filter) gift.Filter {
+	if f, ok := in.(filter); ok {
+		return f.Filter
+	}
+	return in
+}
+
+// ToFilters converts the given input to a slice of gift.Filter.
 func ToFilters(in any) []gift.Filter {
 	switch v := in.(type) {
 	case []gift.Filter:
diff --git a/resources/images/image_resource.go b/resources/images/image_resource.go
index dcd2b47416b..be40418b1fd 100644
--- a/resources/images/image_resource.go
+++ b/resources/images/image_resource.go
@@ -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.
diff --git a/resources/images/process.go b/resources/images/process.go
new file mode 100644
index 00000000000..984ac3c8f4e
--- /dev/null
+++ b/resources/images/process.go
@@ -0,0 +1,43 @@
+// Copyright 2023 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.
+
+package images
+
+import (
+	"image"
+	"image/draw"
+
+	"github.com/disintegration/gift"
+)
+
+var _ ImageProcessSpecProvider = (*processFilter)(nil)
+
+type ImageProcessSpecProvider interface {
+	ImageProcessSpec() string
+}
+
+type processFilter struct {
+	spec string
+}
+
+func (f processFilter) Draw(dst draw.Image, src image.Image, options *gift.Options) {
+	panic("not supported")
+}
+
+func (f processFilter) Bounds(srcBounds image.Rectangle) image.Rectangle {
+	panic("not supported")
+}
+
+func (f processFilter) ImageProcessSpec() string {
+	return f.spec
+}
diff --git a/resources/page/permalinks.go b/resources/page/permalinks.go
index ee9608dac32..4577f524050 100644
--- a/resources/page/permalinks.go
+++ b/resources/page/permalinks.go
@@ -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) {
diff --git a/resources/transform.go b/resources/transform.go
index eb7d8fb0b3c..0c38345ad1b 100644
--- a/resources/transform.go
+++ b/resources/transform.go
@@ -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/"
 				}


Test patch:
diff --git a/hugolib/site_output_test.go b/hugolib/site_output_test.go
index ce415a8242f..c2a14c3ebc6 100644
--- a/hugolib/site_output_test.go
+++ b/hugolib/site_output_test.go
@@ -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")
diff --git a/resources/image_test.go b/resources/image_test.go
index 751ef3f5d18..96cc07b3b20 100644
--- a/resources/image_test.go
+++ b/resources/image_test.go
@@ -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)
+}
diff --git a/resources/images/config_test.go b/resources/images/config_test.go
index 2e0d6635d92..86f70c1bfd7 100644
--- a/resources/images/config_test.go
+++ b/resources/images/config_test.go
@@ -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)
diff --git a/resources/integration_test.go b/resources/integration_test.go
index 2075079dcc2..5570b15d669 100644
--- a/resources/integration_test.go
+++ b/resources/integration_test.go
@@ -73,7 +73,6 @@ anigif: {{ $anigif.RelPermalink }}|{{ $anigif.Width }}|{{ $anigif.Height }}|{{ $
 	b.Build()
 
 	assertImages()
-
 }
 
 func TestSVGError(t *testing.T) {
@@ -98,7 +97,6 @@ Width: {{ $svg.Width }}
 
 	b.Assert(err, qt.IsNotNil)
 	b.Assert(err.Error(), qt.Contains, `error calling Width: this method is only available for raster images. To determine if an image is SVG, you can do {{ if eq .MediaType.SubType "svg" }}{{ end }}`)
-
 }
 
 // Issue 10255.
@@ -137,5 +135,36 @@ iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAA
 		b.AssertFileCount("public/images", 1)
 		b.Build()
 	}
+}
+
+func TestProcessFilter(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- assets/images/pixel.png --
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
+-- layouts/index.html --
+{{ $pixel := resources.Get "images/pixel.png" }}
+{{ $filters := slice (images.GaussianBlur 6) (images.Pixelate 8) (images.Process "jpg") }}
+{{ $image := $pixel.Filter $filters }}
+jpg|RelPermalink: {{ $image.RelPermalink }}|MediaType: {{ $image.MediaType }}|Width: {{ $image.Width }}|Height: {{ $image.Height }}|
+{{ $filters := slice (images.GaussianBlur 6) (images.Pixelate 8) (images.Process "jpg resize 20x30") }}
+{{ $image := $pixel.Filter $filters }}
+resize 1|RelPermalink: {{ $image.RelPermalink }}|MediaType: {{ $image.MediaType }}|Width: {{ $image.Width }}|Height: {{ $image.Height }}|
+{{ $image := $pixel.Filter $filters }}
+resize 2|RelPermalink: {{ $image.RelPermalink }}|MediaType: {{ $image.MediaType }}|Width: {{ $image.Width }}|Height: {{ $image.Height }}|
+
+`
+
+	b := hugolib.NewIntegrationTestBuilder(
+		hugolib.IntegrationTestConfig{
+			T:           t,
+			TxtarString: files,
+		}).Build()
 
+	b.AssertFileContent("public/index.html",
+		"jpg|RelPermalink: /images/pixel_hu8aa3346827e49d756ff4e630147c42b5_70_filter_17010532266664966692.jpg|MediaType: image/jpeg|Width: 1|Height: 1|",
+		"resize 1|RelPermalink: /images/pixel_hu8aa3346827e49d756ff4e630147c42b5_70_filter_6707036659822075562.jpg|MediaType: image/jpeg|Width: 20|Height: 30|",
+		"resize 2|RelPermalink: /images/pixel_hu8aa3346827e49d756ff4e630147c42b5_70_filter_6707036659822075562.jpg|MediaType: image/jpeg|Width: 20|Height: 30|",
+	)
 }