From 7732da9f93503c1a723d6ac5bb77da206cb0fa0e Mon Sep 17 00:00:00 2001 From: John Elliott Date: Tue, 22 Feb 2022 16:50:23 +0000 Subject: [PATCH] Allow images to be cropped without being resized Introduces the Crop method for image processing which implements gift.CropToSize. Also allows a smartCrop without resizing, and updates the documentation. Fixes #9499 --- .../image-processing/index.md | 24 ++++++++++++++----- docs/layouts/shortcodes/imgproc.html | 4 +++- resources/errorResource.go | 4 ++++ resources/image.go | 13 ++++++++++ resources/image_test.go | 16 +++++++++++++ resources/images/config.go | 2 +- resources/images/image.go | 15 ++++++++++++ resources/resource/resourcetypes.go | 1 + resources/transform.go | 4 ++++ 9 files changed, 75 insertions(+), 8 deletions(-) diff --git a/docs/content/en/content-management/image-processing/index.md b/docs/content/en/content-management/image-processing/index.md index 0211f6fcd..5b8293675 100644 --- a/docs/content/en/content-management/image-processing/index.md +++ b/docs/content/en/content-management/image-processing/index.md @@ -39,7 +39,7 @@ The `image` resource can also be retrieved from a [global resource]({{< relref " ## Image Processing Methods -The `image` resource implements the `Resize`, `Fit`, `Fill`, and `Filter` methods, each returning a transformed image using the specified dimensions and processing options. +The `image` resource implements the `Resize`, `Fit`, `Fill`, `Crop`, and `Filter` methods, each returning a transformed image using the specified dimensions and processing options. {{% note %}} Metadata (EXIF, IPTC, XMP, etc.) is not preserved during image transformation. Use the [`Exif`](#exif) method with the _original_ image to extract EXIF metadata from JPEG or TIFF images. @@ -70,12 +70,20 @@ Scale down the image to fit the given dimensions while maintaining aspect ratio. ### Fill -Resize and crop the image to match the given dimensions. Both height and width are required. +Crop and resize the image to match the given dimensions. Both height and width are required. ```go {{ $image := $resource.Fill "600x400" }} ``` +### Crop + +Crop the image to match the given dimensions without resizing. Both height and width are required. + +```go +{{ $image := $resource.Crop "400x400" }} +``` + ### Filter Apply one or more filters to your image. See [Image Filters](/functions/images/#image-filters) for a full list. @@ -203,7 +211,7 @@ Rotates an image by the given angle counter-clockwise. The rotation will be perf ### Anchor -Only relevant for the `Fill` method. This is useful for thumbnail generation where the main motive is located in, say, the left corner. +Only relevant for the `Crop` and `Fill` methods. This is useful for thumbnail generation where the main motive is located in, say, the left corner. Valid values are `Smart`, `Center`, `TopLeft`, `Top`, `TopRight`, `Left`, `Right`, `BottomLeft`, `Bottom`, `BottomRight`. @@ -249,6 +257,8 @@ _The photo of the sunset used in the examples below is Copyright [Bjørn Erik Pe {{< imgproc sunset Fit "90x90" />}} +{{< imgproc sunset Crop "250x250 center" />}} + {{< imgproc sunset Resize "300x q10" />}} This is the shortcode used in the examples above: @@ -286,7 +296,7 @@ quality = 75 # Valid values are "picture", "photo", "drawing", "icon", or "text". hint = "photo" -# Anchor used when cropping pictures. +# Anchor used when cropping pictures with either .Fill or .Crop # Default is "smart" which does Smart Cropping, using https://github.com/muesli/smartcrop # Smart Cropping is content aware and tries to find the best crop for each image. # Valid values are Smart, Center, TopLeft, Top, TopRight, Left, Right, BottomLeft, Bottom, BottomRight @@ -323,12 +333,14 @@ disableLatLong = false ## Smart Cropping of Images -By default, Hugo will use [Smartcrop](https://github.com/muesli/smartcrop), a library created by [muesli](https://github.com/muesli), when cropping images with `.Fill`. You can set the anchor point manually, but in most cases the smart option will make a good choice. And we will work with the library author to improve this in the future. +By default, Hugo will use [Smartcrop](https://github.com/muesli/smartcrop), a library created by [muesli](https://github.com/muesli), when cropping images with `.Fill` or `.Crop`. You can set the anchor point manually, but in most cases the smart option will make a good choice. And we will work with the library author to improve this in the future. -An example using the sunset image from above: +Examples using the sunset image from above: {{< imgproc sunset Fill "200x200 smart" />}} +{{< imgproc sunset Crop "200x200 smart" />}} + ## Image Processing Performance Consideration Processed images are stored below `/resources` (can be set with `resourceDir` config setting). This folder is deliberately placed in the project, as it is recommended to check these into source control as part of the project. These images are not "Hugo fast" to generate, but once generated they can be reused. diff --git a/docs/layouts/shortcodes/imgproc.html b/docs/layouts/shortcodes/imgproc.html index 5e02317c6..181e97f1d 100644 --- a/docs/layouts/shortcodes/imgproc.html +++ b/docs/layouts/shortcodes/imgproc.html @@ -7,8 +7,10 @@ {{ .Scratch.Set "image" ($original.Resize $options) }} {{ else if eq $command "Fill"}} {{ .Scratch.Set "image" ($original.Fill $options) }} +{{ else if eq $command "Crop"}} +{{ .Scratch.Set "image" ($original.Crop $options) }} {{ else }} -{{ errorf "Invalid image processing command: Must be one of Fit, Fill or Resize."}} +{{ errorf "Invalid image processing command: Must be one of Crop, Fit, Fill or Resize."}} {{ end }} {{ $image := .Scratch.Get "image" }}
diff --git a/resources/errorResource.go b/resources/errorResource.go index 705547d4c..a7f99c2b7 100644 --- a/resources/errorResource.go +++ b/resources/errorResource.go @@ -100,6 +100,10 @@ func (e *errorResource) Width() int { panic(e.error) } +func (e *errorResource) Crop(spec string) (resource.Image, error) { + panic(e.error) +} + func (e *errorResource) Fill(spec string) (resource.Image, error) { panic(e.error) } diff --git a/resources/image.go b/resources/image.go index 1eedbad91..3a790a217 100644 --- a/resources/image.go +++ b/resources/image.go @@ -181,6 +181,19 @@ func (i *imageResource) Resize(spec string) (resource.Image, error) { }) } +// Crop the image to the specified dimensions without resizing using the given anchor point. +// Space delimited config: 200x300 TopLeft +func (i *imageResource) Crop(spec string) (resource.Image, 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) + }) +} + // Fit scales down the image using the specified resample filter to fit the specified // maximum width and height. func (i *imageResource) Fit(spec string) (resource.Image, error) { diff --git a/resources/image_test.go b/resources/image_test.go index ad8c42bd7..e85fe9b9a 100644 --- a/resources/image_test.go +++ b/resources/image_test.go @@ -137,6 +137,22 @@ func TestImageTransformBasic(t *testing.T) { filledAgain, err := image.Fill("200x100 bottomLeft") c.Assert(err, qt.IsNil) c.Assert(filled, eq, filledAgain) + + cropped, err := image.Crop("300x300 topRight") + c.Assert(err, qt.IsNil) + c.Assert(cropped.RelPermalink(), qt.Equals, "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x300_crop_q68_linear_topright.jpg") + assertWidthHeight(cropped, 300, 300) + + smartcropped, err := image.Crop("200x200 smart") + c.Assert(err, qt.IsNil) + c.Assert(smartcropped.RelPermalink(), qt.Equals, fmt.Sprintf("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x200_crop_q68_linear_smart%d.jpg", 1)) + assertWidthHeight(smartcropped, 200, 200) + + // Check cache + croppedAgain, err := image.Crop("300x300 topRight") + c.Assert(err, qt.IsNil) + c.Assert(cropped, eq, croppedAgain) + } func TestImageTransformFormat(t *testing.T) { diff --git a/resources/images/config.go b/resources/images/config.go index a8b5412d6..f9a3fa09d 100644 --- a/resources/images/config.go +++ b/resources/images/config.go @@ -364,7 +364,7 @@ func (i ImageConfig) GetKey(format Format) string { k += "_" + i.FilterStr - if strings.EqualFold(i.Action, "fill") { + if strings.EqualFold(i.Action, "fill") || strings.EqualFold(i.Action, "crop") { k += "_" + anchor } diff --git a/resources/images/image.go b/resources/images/image.go index 4cbdc895f..66ee9dda1 100644 --- a/resources/images/image.go +++ b/resources/images/image.go @@ -207,6 +207,21 @@ func (p *ImageProcessor) ApplyFiltersFromConfig(src image.Image, conf ImageConfi switch conf.Action { case "resize": filters = append(filters, gift.Resize(conf.Width, conf.Height, conf.Filter)) + case "crop": + if conf.AnchorStr == smartCropIdentifier { + bounds, err := p.smartCrop(src, conf.Width, conf.Height, conf.Filter) + if err != nil { + return nil, err + } + + // First crop using the bounds returned by smartCrop. + filters = append(filters, gift.Crop(bounds)) + // Then center crop the image to get an image the desired size without resizing. + filters = append(filters, gift.CropToSize(conf.Width, conf.Height, gift.CenterAnchor)) + + } else { + filters = append(filters, gift.CropToSize(conf.Width, conf.Height, conf.Anchor)) + } case "fill": if conf.AnchorStr == smartCropIdentifier { bounds, err := p.smartCrop(src, conf.Width, conf.Height, conf.Filter) diff --git a/resources/resource/resourcetypes.go b/resources/resource/resourcetypes.go index c96f3d495..987c230a8 100644 --- a/resources/resource/resourcetypes.go +++ b/resources/resource/resourcetypes.go @@ -62,6 +62,7 @@ type Image interface { type ImageOps interface { Height() int Width() int + Crop(spec string) (Image, error) Fill(spec string) (Image, error) Fit(spec string) (Image, error) Resize(spec string) (Image, error) diff --git a/resources/transform.go b/resources/transform.go index 0569fb35e..67f5c1461 100644 --- a/resources/transform.go +++ b/resources/transform.go @@ -176,6 +176,10 @@ func (r *resourceAdapter) Data() interface{} { return r.target.Data() } +func (r *resourceAdapter) Crop(spec string) (resource.Image, error) { + return r.getImageOps().Crop(spec) +} + func (r *resourceAdapter) Fill(spec string) (resource.Image, error) { return r.getImageOps().Fill(spec) }