diff --git a/data.go b/data.go index 6ff581b..bd4fef4 100644 --- a/data.go +++ b/data.go @@ -34,6 +34,10 @@ const ( optSignaturePrefix = "s" optSizeDelimiter = "x" optScaleUp = "scaleUp" + optCropWidth = "cw" + optCropHeight = "ch" + optCropX = "cx" + optCropY = "cy" ) // URLError reports a malformed URL error. @@ -75,6 +79,12 @@ type Options struct { // Desired image format. Valid values are "jpeg", "png". Format string + + // Crop rectangle params + CropWidth int + CropHeight int + CropX int + CropY int } func (o Options) String() string { @@ -103,6 +113,18 @@ func (o Options) String() string { if o.Format != "" { opts = append(opts, o.Format) } + if o.CropWidth != 0 { + opts = append(opts, fmt.Sprintf("%s%d", string(optCropWidth), o.CropWidth)) + } + if o.CropHeight != 0 { + opts = append(opts, fmt.Sprintf("%s%d", string(optCropHeight), o.CropHeight)) + } + if o.CropX != 0 { + opts = append(opts, fmt.Sprintf("%s%d", string(optCropX), o.CropX)) + } + if o.CropY != 0 { + opts = append(opts, fmt.Sprintf("%s%d", string(optCropY), o.CropY)) + } return strings.Join(opts, ",") } @@ -111,13 +133,29 @@ func (o Options) String() string { // the presence of other fields (like Fit). A non-empty Format value is // assumed to involve a transformation. func (o Options) transform() bool { - return o.Width != 0 || o.Height != 0 || o.Rotate != 0 || o.FlipHorizontal || o.FlipVertical || o.Quality != 0 || o.Format != "" + return o.Width != 0 || o.Height != 0 || o.Rotate != 0 || o.FlipHorizontal || o.FlipVertical || o.Quality != 0 || o.Format != "" || (o.CropHeight != 0 && o.CropWidth != 0) } // ParseOptions parses str as a list of comma separated transformation options. // The options can be specified in in order, with duplicate options overwriting // previous values. // +// Rectangle Crop +// +// There are four options controlling rectangle crop: +// +// cx{x} - X coordinate of top left rectangle corner +// cy{y} - Y coordinate of top left rectangle corner +// cw{width} - rectangle width +// ch{height} - rectangle height +// +// ch and cw are required to enable crop and they must be positive integers. If +// the rectangle is larger than the image, crop will not be applied. If the +// rectangle does not fit the image in any of the dimensions, it will be moved +// to produce an image of given size. Crop is applied before any other +// transformations. If the rectangle is smaller than the requested resize and +// scaleUp is disabled, the image will be of the same size as the rectangle. +// // Size and Cropping // // The size option takes the general form "{width}x{height}", where width and @@ -176,17 +214,19 @@ func (o Options) transform() bool { // // Examples // -// 0x0 - no resizing -// 200x - 200 pixels wide, proportional height -// 0.15x - 15% original width, proportional height -// x100 - 100 pixels tall, proportional width -// 100x150 - 100 by 150 pixels, cropping as needed -// 100 - 100 pixels square, cropping as needed -// 150,fit - scale to fit 150 pixels square, no cropping -// 100,r90 - 100 pixels square, rotated 90 degrees -// 100,fv,fh - 100 pixels square, flipped horizontal and vertical -// 200x,q80 - 200 pixels wide, proportional height, 80% quality -// 200x,png - 200 pixels wide, converted to PNG format +// 0x0 - no resizing +// 200x - 200 pixels wide, proportional height +// 0.15x - 15% original width, proportional height +// x100 - 100 pixels tall, proportional width +// 100x150 - 100 by 150 pixels, cropping as needed +// 100 - 100 pixels square, cropping as needed +// 150,fit - scale to fit 150 pixels square, no cropping +// 100,r90 - 100 pixels square, rotated 90 degrees +// 100,fv,fh - 100 pixels square, flipped horizontal and vertical +// 200x,q80 - 200 pixels wide, proportional height, 80% quality +// 200x,png - 200 pixels wide, converted to PNG format +// cw100,ch200 - crop fragment that starts at (0,0), is 100px wide and 200px tall +// cw100,ch200,cx10,cy20 - crop fragment that start at (10,20) is 100px wide and 200px tall func ParseOptions(str string) Options { var options Options @@ -212,6 +252,18 @@ func ParseOptions(str string) Options { options.Quality, _ = strconv.Atoi(value) case strings.HasPrefix(opt, optSignaturePrefix): options.Signature = strings.TrimPrefix(opt, optSignaturePrefix) + case strings.HasPrefix(opt, optCropHeight): + value := strings.TrimPrefix(opt, optCropHeight) + options.CropHeight, _ = strconv.Atoi(value) + case strings.HasPrefix(opt, optCropWidth): + value := strings.TrimPrefix(opt, optCropWidth) + options.CropWidth, _ = strconv.Atoi(value) + case strings.HasPrefix(opt, optCropX): + value := strings.TrimPrefix(opt, optCropX) + options.CropX, _ = strconv.Atoi(value) + case strings.HasPrefix(opt, optCropY): + value := strings.TrimPrefix(opt, optCropY) + options.CropY, _ = strconv.Atoi(value) case strings.Contains(opt, optSizeDelimiter): size := strings.SplitN(opt, optSizeDelimiter, 2) if w := size[0]; w != "" { @@ -250,7 +302,7 @@ func (r Request) String() string { // NewRequest parses an http.Request into an imageproxy Request. Options and // the remote image URL are specified in the request path, formatted as: // /{options}/{remote_url}. Options may be omitted, so a request path may -// simply contian /{remote_url}. The remote URL must be an absolute "http" or +// simply contain /{remote_url}. The remote URL must be an absolute "http" or // "https" URL, should not be URL encoded, and may contain a query string. // // Assuming an imageproxy server running on localhost, the following are all diff --git a/data_test.go b/data_test.go index 5f79321..4bddc68 100644 --- a/data_test.go +++ b/data_test.go @@ -31,13 +31,21 @@ func TestOptions_String(t *testing.T) { "0x0", }, { - Options{1, 2, true, 90, true, true, 80, "", false, ""}, + Options{1, 2, true, 90, true, true, 80, "", false, "", 0, 0, 0, 0}, "1x2,fit,r90,fv,fh,q80", }, { - Options{0.15, 1.3, false, 45, false, false, 95, "c0ffee", false, "png"}, + Options{0.15, 1.3, false, 45, false, false, 95, "c0ffee", false, "png", 0, 0, 0, 0}, "0.15x1.3,r45,q95,sc0ffee,png", }, + { + Options{0.15, 1.3, false, 45, false, false, 95, "c0ffee", false, "", 100, 200, 0, 0}, + "0.15x1.3,r45,q95,sc0ffee,cw100,ch200", + }, + { + Options{0.15, 1.3, false, 45, false, false, 95, "c0ffee", false, "png", 100, 200, 300, 400}, + "0.15x1.3,r45,q95,sc0ffee,png,cw100,ch200,cx300,cy400", + }, } for i, tt := range tests { @@ -85,9 +93,20 @@ func TestParseOptions(t *testing.T) { // mix of valid and invalid flags {"FOO,1,BAR,r90,BAZ", Options{Width: 1, Height: 1, Rotate: 90}}, - // all flags, in different orders - {"q70,1x2,fit,r90,fv,fh,sc0ffee,png", Options{1, 2, true, 90, true, true, 70, "c0ffee", false, "png"}}, - {"r90,fh,sc0ffee,png,q90,1x2,fv,fit", Options{1, 2, true, 90, true, true, 90, "c0ffee", false, "png"}}, + // flags, in different orders + {"q70,1x2,fit,r90,fv,fh,sc0ffee,png", Options{1, 2, true, 90, true, true, 70, "c0ffee", false, "png", 0, 0, 0, 0}}, + {"r90,fh,sc0ffee,png,q90,1x2,fv,fit", Options{1, 2, true, 90, true, true, 90, "c0ffee", false, "png", 0, 0, 0, 0}}, + + // all flags, in different orders with crop + {"q70,cw100,cx300,1x2,fit,ch200,r90,fv,cy400,fh,sc0ffee,png", Options{1, 2, true, 90, true, true, 70, "c0ffee", false, "png", 100, 200, 300, 400}}, + {"cy400,r90,cx300,fh,sc0ffee,png,cw100,q90,ch200,1x2,fv,fit", Options{1, 2, true, 90, true, true, 90, "c0ffee", false, "png", 100, 200, 300, 400}}, + + // all flags, in different orders with crop & different resizes + {"q70,cw100,cx300,x2,fit,ch200,r90,fv,cy400,fh,sc0ffee,png", Options{0, 2, true, 90, true, true, 70, "c0ffee", false, "png", 100, 200, 300, 400}}, + {"cy400,r90,cx300,fh,sc0ffee,png,cw100,q90,ch200,1x,fv,fit", Options{1, 0, true, 90, true, true, 90, "c0ffee", false, "png", 100, 200, 300, 400}}, + {"cy400,r90,cx300,fh,sc0ffee,png,cw100,q90,ch200,cx,fv,fit", Options{0, 0, true, 90, true, true, 90, "c0ffee", false, "png", 100, 200, 0, 400}}, + {"cy400,r90,cx300,fh,sc0ffee,png,cw100,q90,ch200,cx,fv,fit,123x321", Options{123, 321, true, 90, true, true, 90, "c0ffee", false, "png", 100, 200, 0, 400}}, + {"123x321,cy400,r90,cx300,fh,sc0ffee,png,cw100,q90,ch200,cx,fv,fit", Options{123, 321, true, 90, true, true, 90, "c0ffee", false, "png", 100, 200, 0, 400}}, } for _, tt := range tests { diff --git a/transform.go b/transform.go index d550909..2a398fd 100644 --- a/transform.go +++ b/transform.go @@ -21,6 +21,7 @@ import ( _ "image/gif" // register gif format "image/jpeg" "image/png" + "math" "github.com/disintegration/imaging" _ "golang.org/x/image/webp" // register webp format @@ -126,9 +127,44 @@ func resizeParams(m image.Image, opt Options) (w, h int, resize bool) { return w, h, true } +// cropParams calculates crop rectangle parameters to keep it in image bounds +func cropParams(m image.Image, opt Options) (x0, y0, x1, y1 int, crop bool) { + // crop params not set + if opt.CropHeight <= 0 || opt.CropWidth <= 0 { + return 0, 0, 0, 0, false + } + + imgW := m.Bounds().Max.X - m.Bounds().Min.X + imgH := m.Bounds().Max.Y - m.Bounds().Min.Y + + x0 = opt.CropX + y0 = opt.CropY + + // crop rectangle out of image bounds horizontally + // -> moved to point (image_width - rectangle_width) or 0, whichever is larger + if opt.CropX > imgW || opt.CropX+opt.CropWidth > imgW { + x0 = int(math.Max(0, float64(imgW-opt.CropWidth))) + } + // crop rectangle out of image bounds vertically + // -> moved to point (image_height - rectangle_height) or 0, whichever is larger + if opt.CropY > imgH || opt.CropY+opt.CropHeight > imgH { + y0 = int(math.Max(0, float64(imgH-opt.CropHeight))) + } + + // make rectangle fit the image + x1 = int(math.Min(float64(imgW), float64(opt.CropX+opt.CropWidth))) + y1 = int(math.Min(float64(imgH), float64(opt.CropY+opt.CropHeight))) + + return x0, y0, x1, y1, true +} + // transformImage modifies the image m based on the transformations specified // in opt. func transformImage(m image.Image, opt Options) image.Image { + // crop if needed + if x0, y0, x1, y1, crop := cropParams(m, opt); crop { + m = imaging.Crop(m, image.Rect(x0, y0, x1, y1)) + } // resize if needed if w, h, resize := resizeParams(m, opt); resize { if opt.Fit { diff --git a/transform_test.go b/transform_test.go index 757daa8..d99fdc6 100644 --- a/transform_test.go +++ b/transform_test.go @@ -74,6 +74,29 @@ func TestResizeParams(t *testing.T) { } } +func TestCropParams(t *testing.T) { + src := image.NewNRGBA(image.Rect(0, 0, 64, 128)) + tests := []struct { + opt Options + x0, y0, x1, y1 int + crop bool + }{ + {Options{CropHeight: 0, CropWidth: 10}, 0, 0, 0, 0, false}, + {Options{CropHeight: 10, CropWidth: 0}, 0, 0, 0, 0, false}, + {Options{CropHeight: -1, CropWidth: -1}, 0, 0, 0, 0, false}, + {Options{CropWidth: 50, CropHeight: 100}, 0, 0, 50, 100, true}, + {Options{CropWidth: 100, CropHeight: 100}, 0, 0, 64, 100, true}, + {Options{CropX: 50, CropY: 100, CropWidth: 50, CropHeight: 100}, 14, 28, 64, 128, true}, + {Options{CropX: 50, CropY: 100, CropWidth: 100, CropHeight: 150}, 0, 0, 64, 128, true}, + } + for _, tt := range tests { + x0, y0, x1, y1, crop := cropParams(src, tt.opt) + if x0 != tt.x0 || y0 != tt.y0 || x1 != tt.x1 || y1 != tt.y1 || crop != tt.crop { + t.Errorf("cropParams(%v) returned (%d,%d,%d,%d,%t), want (%d,%d,%d,%d,%t)", tt.opt, x0, y0, x1, y1, crop, tt.x0, tt.y0, tt.x1, tt.y1, tt.crop) + } + } +} + func TestTransform(t *testing.T) { src := newImage(2, 2, red, green, blue, yellow) @@ -124,6 +147,9 @@ func TestTransformImage(t *testing.T) { // ref is a 2x2 reference image containing four colors ref := newImage(2, 2, red, green, blue, yellow) + // cropRef is a 4x4 image with four colors, each in 2x2 quarter + cropRef := newImage(4, 4, red, red, green, green, red, red, green, green, blue, blue, yellow, yellow, blue, blue, yellow, yellow) + // use simpler filter while testing that won't skew colors resampleFilter = imaging.Box @@ -221,11 +247,33 @@ func TestTransformImage(t *testing.T) { Options{Width: 2, Height: 1, Fit: true, FlipHorizontal: true, Rotate: 90}, newImage(1, 2, red, blue), }, + + // crop + { // quarter ((0, 0), (2, 2)) -> red + cropRef, + Options{CropHeight: 2, CropWidth: 2}, + newImage(2, 2, red, red, red, red), + }, + { // quarter ((2, 0), (4, 2)) -> green + cropRef, + Options{CropHeight: 2, CropWidth: 2, CropX: 2}, + newImage(2, 2, green, green, green, green), + }, + { // quarter ((0, 2), (2, 4)) -> blue + cropRef, + Options{CropHeight: 2, CropWidth: 2, CropX: 0, CropY: 2}, + newImage(2, 2, blue, blue, blue, blue), + }, + { // quarter ((2, 2), (4, 4)) -> yellow + cropRef, + Options{CropHeight: 2, CropWidth: 2, CropX: 2, CropY: 2}, + newImage(2, 2, yellow, yellow, yellow, yellow), + }, } for _, tt := range tests { if got := transformImage(tt.src, tt.opt); !reflect.DeepEqual(got, tt.want) { - t.Errorf("trasformImage(%v, %v) returned image %#v, want %#v", tt.src, tt.opt, got, tt.want) + t.Errorf("transformImage(%v, %v) returned image %#v, want %#v", tt.src, tt.opt, got, tt.want) } } }