From 9e073d5c4962956c90c4fc31e209b1f85202b63f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Fri, 22 Sep 2023 15:15:16 +0200 Subject: [PATCH] Add $image.Process 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 #11483 --- common/hstrings/strings.go | 23 ++++ .../image-processing/index.md | 32 ++++- helpers/general.go | 12 -- hugolib/site_output_test.go | 6 +- resources/errorResource.go | 4 + resources/image.go | 109 ++++++++++-------- resources/image_test.go | 51 +++++++- resources/images/config.go | 39 ++++--- resources/images/config_test.go | 2 +- resources/images/image.go | 20 +++- resources/images/image_resource.go | 3 + resources/page/permalinks.go | 7 +- resources/transform.go | 6 +- 13 files changed, 216 insertions(+), 98 deletions(-) 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]: [Smartcrop]: [Exif]: +[`Process`]: #process [`Colors`]: #colors [`Crop`]: #crop [`Exif`]: #exif 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/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/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..5b030fde97d 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,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) { @@ -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. @@ -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 } 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.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/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/images/image.go b/resources/images/image.go index 672e8578f21..714d0e26d90 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" ) @@ -245,7 +244,11 @@ 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) + + } + + if len(filters) == 0 { + return p.resolveSrc(src, conf.TargetFormat), nil } img, err := p.doFilter(src, conf.TargetFormat, filters...) @@ -260,8 +263,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 { 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/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/" }