From 6a246d1152dbd5bc0ac4ce8101c89838a3cba7ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Sat, 23 Sep 2023 11:45:17 +0200 Subject: [PATCH] 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 --- docs/content/en/functions/images/index.md | 35 ++++++++++ resources/image.go | 79 +++++++++++++++++++---- resources/images/filters.go | 9 +++ resources/images/image.go | 19 +++++- resources/images/process.go | 43 ++++++++++++ resources/integration_test.go | 33 +++++++++- 6 files changed, 203 insertions(+), 15 deletions(-) create mode 100644 resources/images/process.go 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/resources/image.go b/resources/image.go index 5b030fde97d..9a24fd21c20 100644 --- a/resources/image.go +++ b/resources/image.go @@ -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)) } 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 714d0e26d90..1637d0cf8b0 100644 --- a/resources/images/image.go +++ b/resources/images/image.go @@ -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: 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/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|", + ) }