Repository: gohugoio/hugo (language: go)

Issue:
Crop images without resizing
At the moment Hugo has image processing methods for `Fit`, `Fill`, `Resize`, and `Filter`. I think it would be very useful to have the ability to `Crop` an image without resizing (using the disintegration/gift `CropToSize` method). This would use `Anchor` in exactly the same way as the existing `Fill` method to position the crop.

I've already implemented this locally and if anybody else would find this useful I will add tests, update the docs and submit a PR.



Discussion / hints:
>I think it would be very useful to have the ability to Crop an image without resizing 

Yes, we should probably add this, but in what situations would you use this and not `Fill`?
My use case is that I have several hundred very large detailed images (generative art) and I wanted to create smaller thumbnail / summary images that just showed a part of the image but without resizing so none of the detail is lost.
@johnsvenn OK, a pull request would be welcomed. Just put new documentation in the /docs folder of this repo.
Thanks @bep  

Will do.

Fix patch:
diff --git a/docs/content/en/content-management/image-processing/index.md b/docs/content/en/content-management/image-processing/index.md
index 0211f6fcd0f..5b829367569 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 `<project-dir>/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 5e02317c6b6..181e97f1dea 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" }}
 <figure style="padding: 0.25rem; margin: 2rem 0; background-color: #cccc">
diff --git a/resources/errorResource.go b/resources/errorResource.go
index 705547d4c62..a7f99c2b73f 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 1eedbad91e1..3a790a217f3 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/images/config.go b/resources/images/config.go
index a8b5412d65d..f9a3fa09d82 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 4cbdc895f07..66ee9dda15e 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 c96f3d49523..987c230a8c2 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 0569fb35e2a..67f5c1461c5 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)
 }


Test patch:
diff --git a/resources/image_test.go b/resources/image_test.go
index ad8c42bd7b7..e85fe9b9abb 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) {