Add images.Process filter

This allows for constructs like:

```
{{ $filters := slice (images.GaussianBlur 8) (images.Grayscale) (images.Process "jpg q30 resize 200x") }}
{{ $img = $img | images.Filter $filters }}
```

Note that the `action` option in `images.Process` is optional (`resize` in the example above), so you can use the above to just set the target format, e.g.:

```
{{ $filters := slice (images.GaussianBlur 8) (images.Grayscale) (images.Process "jpg") }}
{{ $img = $img | images.Filter $filters }}
```

Fixes #8439
This commit is contained in:
Bjørn Erik Pedersen 2023-09-23 11:45:17 +02:00
parent ef0e7149d6
commit 6a246d1152
6 changed files with 204 additions and 16 deletions

View file

@ -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

View file

@ -206,15 +206,7 @@ var imageActions = []string{images.ActionResize, images.ActionCrop, images.Actio
// 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
}
}
action, options := i.resolveActionOptions(spec)
return i.processActionOptions(action, options)
}
@ -245,7 +237,7 @@ func (i *imageResource) Fill(spec string) (images.ImageResource, error) {
}
func (i *imageResource) Filter(filters ...any) (images.ImageResource, error) {
conf := images.GetDefaultImageConfig("filter", i.Proc.Cfg)
var conf images.ImageConfig
var gfilters []gift.Filter
@ -253,14 +245,77 @@ 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))
}

View file

@ -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{

View file

@ -201,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 {
@ -246,6 +246,14 @@ func (p *ImageProcessor) ApplyFiltersFromConfig(src image.Image, conf ImageConfi
default:
}
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
@ -396,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:

View file

@ -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
}

View file

@ -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|",
)
}