Skip to content

Commit

Permalink
Add $image.Process
Browse files Browse the repository at this point in the history
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 gohugoio#11483
  • Loading branch information
bep committed Sep 23, 2023
1 parent f9b3c0f commit 9e073d5
Show file tree
Hide file tree
Showing 13 changed files with 216 additions and 98 deletions.
23 changes: 23 additions & 0 deletions common/hstrings/strings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
32 changes: 31 additions & 1 deletion docs/content/en/content-management/image-processing/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
12 changes: 0 additions & 12 deletions helpers/general.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions hugolib/site_output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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")
Expand Down
4 changes: 4 additions & 0 deletions resources/errorResource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
109 changes: 59 additions & 50 deletions resources/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -198,75 +199,49 @@ 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) {
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
// 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) {
Expand All @@ -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,
// so there is not much to gain from adding more load to the mix. That
// can even have negative effect in low resource scenarios.
Expand Down Expand Up @@ -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) {
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
}
Expand Down
51 changes: 47 additions & 4 deletions resources/image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
}
Loading

0 comments on commit 9e073d5

Please sign in to comment.