Skip to content

Commit

Permalink
Add images.Process filter
Browse files Browse the repository at this point in the history
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 gohugoio#8439
  • Loading branch information
bep committed Sep 23, 2023
1 parent 9e073d5 commit 80db9b2
Show file tree
Hide file tree
Showing 5 changed files with 165 additions and 15 deletions.
79 changes: 67 additions & 12 deletions resources/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -245,22 +237,85 @@ 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

for _, f := range filters {
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))
}
Expand Down
9 changes: 9 additions & 0 deletions resources/images/filters.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
19 changes: 18 additions & 1 deletion resources/images/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
43 changes: 43 additions & 0 deletions resources/images/process.go
Original file line number Diff line number Diff line change
@@ -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
}
30 changes: 28 additions & 2 deletions resources/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ anigif: {{ $anigif.RelPermalink }}|{{ $anigif.Width }}|{{ $anigif.Height }}|{{ $
b.Build()

assertImages()

}

func TestSVGError(t *testing.T) {
Expand All @@ -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.
Expand Down Expand Up @@ -137,5 +135,33 @@ 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|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|RelPermalink: /images/pixel_hu8aa3346827e49d756ff4e630147c42b5_70_filter_6707036659822075562.jpg|MediaType: image/jpeg|Width: 20|Height: 30|",
)
}

0 comments on commit 80db9b2

Please sign in to comment.