diff --git a/README.md b/README.md index e362039..438bb43 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Halfshell uses a JSON file for configuration. An example is shown below: "processors": { "default": { "image_compression_quality": 85, - "maintain_aspect_ratio": true, + "crop_mode": "fit", "max_blur_radius_percentage": 0, "max_image_height": 0, "max_image_width": 1000 @@ -87,7 +87,7 @@ This will start the server on port 8080, and service requests whose path begins http://localhost:8080/users/joe/default.jpg?w=100&h=100 http://localhost:8080/blog/posts/announcement.jpg?w=600&h=200 -The image_host named group in the route pattern match (e.g., `^/users(?P/.*)$`) gets extracted as the request path for the source. In this instance, the file “joe/default.jpg” is requested from the “my-company-profile-photos” S3 bucket. The processor resizes the image to a width and height of 100. Since the maintain_aspect_ratio setting is set to true, the image will have a maximum width and height of 100, but may be smaller in one dimension in order to maintain the aspect ratio. +The image_host named group in the route pattern match (e.g., `^/users(?P/.*)$`) gets extracted as the request path for the source. In this instance, the file “joe/default.jpg” is requested from the “my-company-profile-photos” S3 bucket. The processor resizes the image to a width and height of 100. ### Server @@ -139,12 +139,6 @@ Values from a processor named `default` will be inherited by all other processor The compression quality to use for JPEG images. -##### maintain_aspect_ratio - -If this is set to true, the resized images will always maintain the original -aspect ratio. When set to false, the image will be stretched to fit the width -and height requested. - ##### default_image_width In the absence of a width parameter in the request, use this as image width. A @@ -204,4 +198,4 @@ Run `make format` before sending any pull requests. ### Questions? -File an issue or send an email to rafik@oysterbooks.com. \ No newline at end of file +File an issue or send an email to rafik@oysterbooks.com. diff --git a/examples/filesystem_config.json b/examples/filesystem_config.json index e5381d9..fbab201 100644 --- a/examples/filesystem_config.json +++ b/examples/filesystem_config.json @@ -19,7 +19,7 @@ "processors": { "default": { "image_compression_quality": 85, - "maintain_aspect_ratio": true, + "crop_mode": "fit", "max_blur_radius_percentage": 0, "max_image_height": 0, "max_image_width": 1000 diff --git a/examples/s3_config.json b/examples/s3_config.json index 8313b5b..f971a94 100644 --- a/examples/s3_config.json +++ b/examples/s3_config.json @@ -20,7 +20,7 @@ "processors": { "default": { "image_compression_quality": 85, - "maintain_aspect_ratio": true, + "crop_mode": "fill", "max_blur_radius_percentage": 0, "max_image_height": 0, "max_image_width": 1000 diff --git a/halfshell/config.go b/halfshell/config.go index 476f596..7f783f4 100644 --- a/halfshell/config.go +++ b/halfshell/config.go @@ -67,12 +67,17 @@ type SourceConfig struct { type ProcessorConfig struct { Name string ImageCompressionQuality uint64 - MaintainAspectRatio bool + DefaultCropMode string + DefaultBorderRadius uint64 DefaultImageHeight uint64 DefaultImageWidth uint64 + DefaultBGColor string MaxImageHeight uint64 MaxImageWidth uint64 MaxBlurRadiusPercentage float64 + + // DEPRECATED + MaintainAspectRatio bool } // Parses a JSON configuration file and returns a pointer to a new Config object. @@ -170,13 +175,24 @@ func (c *configParser) parseProcessorConfig(processorName string) *ProcessorConf return &ProcessorConfig{ Name: processorName, ImageCompressionQuality: c.uintForKeypath("processors.%s.image_compression_quality", processorName), - MaintainAspectRatio: c.boolForKeypath("processors.%s.maintain_aspect_ratio", processorName), + DefaultCropMode: c.stringForKeypath("processors.%s.default_crop_mode", processorName), + DefaultBorderRadius: c.uintForKeypath("processors.%s.default_border_radius", processorName), DefaultImageHeight: c.uintForKeypath("processors.%s.default_image_height", processorName), DefaultImageWidth: c.uintForKeypath("processors.%s.default_image_width", processorName), + DefaultBGColor: c.stringForKeypath("processors.%s.default_bg_color", processorName), MaxImageHeight: c.uintForKeypath("processors.%s.max_image_height", processorName), MaxImageWidth: c.uintForKeypath("processors.%s.max_image_width", processorName), MaxBlurRadiusPercentage: c.floatForKeypath("processors.%s.max_blur_radius_percentage", processorName), + + // DEPRECATED + MaintainAspectRatio: c.boolForKeypath("processors.%s.maintain_aspect_ratio", processorName), + } + + if config.MaintainAspectRatio { + config.DefaultCropMode = "fill" } + + return config } func (c *configParser) valueForKeypath(valueType reflect.Kind, keypathFormat string, v ...interface{}) interface{} { diff --git a/halfshell/image_processor.go b/halfshell/image_processor.go index fb222e9..d0b6e51 100644 --- a/halfshell/image_processor.go +++ b/halfshell/image_processor.go @@ -25,6 +25,7 @@ import ( "math" "strings" + "github.com/oysterbooks/halfshell/halfshell/util" "github.com/rafikk/imagick/imagick" ) @@ -37,8 +38,11 @@ type ImageProcessor interface { // ImageProcessorOptions specify the request parameters for the processing // operation. type ImageProcessorOptions struct { - Dimensions ImageDimensions - BlurRadius float64 + Dimensions ImageDimensions + BlurRadius float64 + CropMode string + BorderRadius uint64 + BGColor string } type imageProcessor struct { @@ -75,7 +79,13 @@ func (ip *imageProcessor) ProcessImage(image *Image, request *ImageProcessorOpti return nil } - if !scaleModified && !blurModified { + radiusModified, err := ip.radiusWand(wand, request) + if err != nil { + ip.Logger.Warnf("Error applying radius: %s", err) + return nil + } + + if !scaleModified && !blurModified && !radiusModified { processedImage.Bytes = image.Bytes } else { processedImage.Bytes = wand.GetImageBlob() @@ -89,8 +99,9 @@ func (ip *imageProcessor) ProcessImage(image *Image, request *ImageProcessorOpti func (ip *imageProcessor) scaleWand(wand *imagick.MagickWand, request *ImageProcessorOptions) (modified bool, err error) { currentDimensions := ImageDimensions{uint64(wand.GetImageWidth()), uint64(wand.GetImageHeight())} newDimensions := ip.getScaledDimensions(currentDimensions, request) + requestedDimensions := request.Dimensions - if newDimensions == currentDimensions { + if newDimensions == currentDimensions && newDimensions == requestedDimensions { return false, nil } @@ -99,6 +110,13 @@ func (ip *imageProcessor) scaleWand(wand *imagick.MagickWand, request *ImageProc return true, err } + if request.CropMode == "fill" { + if err = ip.cropImage(newDimensions, request.Dimensions, wand); err != nil { + ip.Logger.Warnf("ImageMagick error cropping image: %s", err) + return true, err + } + } + if err = wand.SetImageInterpolateMethod(imagick.INTERPOLATE_PIXEL_BICUBIC); err != nil { ip.Logger.Warnf("ImageMagick error setting interpoliation method: %s", err) return true, err @@ -140,6 +158,66 @@ func (ip *imageProcessor) blurWand(wand *imagick.MagickWand, request *ImageProce return false, nil } +func (ip *imageProcessor) radiusWand(wand *imagick.MagickWand, request *ImageProcessorOptions) (modified bool, err error) { + radiusInt := util.FirstUInt(request.BorderRadius, ip.Config.DefaultBorderRadius, 0) + if radiusInt == 0 { + return + } + radius := float64(radiusInt) + + bgColor := util.FirstString(request.BGColor, ip.Config.DefaultBGColor, "white") + + widthI := wand.GetImageWidth() + heightI := wand.GetImageHeight() + widthF := float64(widthI) + heightF := float64(heightI) + + canvas := imagick.NewMagickWand() + defer canvas.Destroy() + + transparent := imagick.NewPixelWand() + defer transparent.Destroy() + + bg := imagick.NewPixelWand() + defer bg.Destroy() + + mask := imagick.NewDrawingWand() + defer mask.Destroy() + + border := imagick.NewDrawingWand() + defer border.Destroy() + + transparent.SetColor("none") + if !bg.SetColor(bgColor) { + bg.SetColor("bg") + } + + canvas.NewImage(widthI, heightI, transparent) + + mask.SetFillColor(bg) + mask.RoundRectangle(0, 0, widthF, heightF, radius, radius) + canvas.DrawImage(mask) + + canvas.CompositeImage(wand, imagick.COMPOSITE_OP_SRC_IN, 0, 0) + canvas.OpaquePaintImage(transparent, bg, 0, false) + + border.SetFillColor(transparent) + border.SetStrokeColor(bg) + + // XXX: Implement optimal stroke width depending on the circle radius. See: + // http://www.imagemagick.org/Usage/antialiasing/ + border.SetStrokeWidth(1.5) + + border.RoundRectangle(0, 0, widthF, heightF, radius, radius) + canvas.DrawImage(border) + + canvas.SetImageFormat(wand.GetImageFormat()) + + err = wand.SetImage(canvas) + modified = true + return +} + func (ip *imageProcessor) getScaledDimensions(currentDimensions ImageDimensions, request *ImageProcessorOptions) ImageDimensions { requestDimensions := request.Dimensions if requestDimensions.Width == 0 && requestDimensions.Height == 0 { @@ -151,37 +229,60 @@ func (ip *imageProcessor) getScaledDimensions(currentDimensions ImageDimensions, } func (ip *imageProcessor) scaleToRequestedDimensions(currentDimensions, requestedDimensions ImageDimensions, request *ImageProcessorOptions) ImageDimensions { + if requestedDimensions.Width == 0 && requestedDimensions.Height == 0 { + return currentDimensions + } + imageAspectRatio := currentDimensions.AspectRatio() - if requestedDimensions.Width > 0 && requestedDimensions.Height > 0 { - requestedAspectRatio := requestedDimensions.AspectRatio() - ip.Logger.Infof("Requested image ratio %f, image ratio %f, %v", requestedAspectRatio, imageAspectRatio, ip.Config.MaintainAspectRatio) - if !ip.Config.MaintainAspectRatio { - // If we're not asked to maintain the aspect ratio, give them what they want - return requestedDimensions - } + // No height was specified, thus image proportions should be retained. + if requestedDimensions.Width > 0 && requestedDimensions.Height == 0 { + height := ip.getAspectScaledHeight(imageAspectRatio, requestedDimensions.Width) + return ImageDimensions{requestedDimensions.Width, height} + } + + // No width was specified, thus image proportions should be retained. + if requestedDimensions.Height > 0 && requestedDimensions.Width == 0 { + width := ip.getAspectScaledWidth(imageAspectRatio, requestedDimensions.Height) + return ImageDimensions{width, requestedDimensions.Height} + } + // The "stretch" crop mode is a NOOP, hence it's the default. + cropMode := util.FirstString(request.CropMode, ip.Config.DefaultCropMode, "stretch") + if cropMode == "stretch" { + return requestedDimensions + } + + // The "fit" crop mode retains the aspect ration while at least filling the + // bounds requested. No cropping will occur. + if cropMode == "fit" { + requestedAspectRatio := requestedDimensions.AspectRatio() if requestedAspectRatio > imageAspectRatio { - // The requested aspect ratio is wider than the image's natural ratio. - // Thus means the height is the restraining dimension, so unset the - // width and let the height determine the dimensions. return ip.scaleToRequestedDimensions(currentDimensions, ImageDimensions{0, requestedDimensions.Height}, request) } else if requestedAspectRatio < imageAspectRatio { return ip.scaleToRequestedDimensions(currentDimensions, ImageDimensions{requestedDimensions.Width, 0}, request) - } else { - return requestedDimensions } + return requestedDimensions } - if requestedDimensions.Width > 0 { - return ImageDimensions{requestedDimensions.Width, ip.getAspectScaledHeight(imageAspectRatio, requestedDimensions.Width)} - } - - if requestedDimensions.Height > 0 { - return ImageDimensions{ip.getAspectScaledWidth(imageAspectRatio, requestedDimensions.Height), requestedDimensions.Height} + // The "fill" crop mode will use the exact width/height and crop out the parts + // that bleed out of the edges. + // + // Cropping does occur (handled elsewhere). The new dimensions defined here + // ensure that clipping occurs on smallest edges possible. This is done by + // bounding to the larger of the two axes. + if cropMode == "fill" { + requestedAspectRatio := requestedDimensions.AspectRatio() + if requestedAspectRatio < imageAspectRatio { + return ip.scaleToRequestedDimensions(currentDimensions, ImageDimensions{0, requestedDimensions.Height}, request) + } else if requestedAspectRatio > imageAspectRatio { + return ip.scaleToRequestedDimensions(currentDimensions, ImageDimensions{requestedDimensions.Width, 0}, request) + } + return requestedDimensions } - return currentDimensions + // Unsupported crop modes are a NOOP. + return requestedDimensions } func (ip *imageProcessor) clampDimensionsToMaxima(dimensions ImageDimensions, request *ImageProcessorOptions) ImageDimensions { @@ -198,6 +299,16 @@ func (ip *imageProcessor) clampDimensionsToMaxima(dimensions ImageDimensions, re return dimensions } +func (ip *imageProcessor) cropImage(currentDimensions ImageDimensions, requestedDimensions ImageDimensions, wand *imagick.MagickWand) (err error) { + err = wand.CropImage( + uint(requestedDimensions.Width), + uint(requestedDimensions.Height), + int((currentDimensions.Width-requestedDimensions.Width)/2), + int((currentDimensions.Height-requestedDimensions.Height)/2), + ) + return +} + func (ip *imageProcessor) getAspectScaledHeight(aspectRatio float64, width uint64) uint64 { return uint64(math.Floor(float64(width)/aspectRatio + 0.5)) } diff --git a/halfshell/route.go b/halfshell/route.go index e60c29d..729ee95 100644 --- a/halfshell/route.go +++ b/halfshell/route.go @@ -69,9 +69,15 @@ func (p *Route) SourceAndProcessorOptionsForRequest(r *http.Request) ( width, _ := strconv.ParseUint(r.FormValue("w"), 10, 32) height, _ := strconv.ParseUint(r.FormValue("h"), 10, 32) blurRadius, _ := strconv.ParseFloat(r.FormValue("blur"), 64) + borderRadius, _ := strconv.ParseUint(r.FormValue("border_radius"), 10, 32) + cropMode := r.FormValue("crop_mode") + bgColor := r.FormValue("bg_color") return &ImageSourceOptions{Path: path}, &ImageProcessorOptions{ - Dimensions: ImageDimensions{width, height}, - BlurRadius: blurRadius, + Dimensions: ImageDimensions{width, height}, + BlurRadius: blurRadius, + BorderRadius: borderRadius, + CropMode: cropMode, + BGColor: bgColor, } } diff --git a/halfshell/util/strings.go b/halfshell/util/strings.go new file mode 100644 index 0000000..4aa3466 --- /dev/null +++ b/halfshell/util/strings.go @@ -0,0 +1,19 @@ +package util + +func FirstString(str ...string) (s string) { + for _, s := range str { + if s != "" { + return s + } + } + return s +} + +func FirstUInt(ints ...uint64) (n uint64) { + for _, n := range ints { + if n > 0 { + return n + } + } + return n +}