diff --git a/data.go b/data.go index 0545f8b..284da45 100644 --- a/data.go +++ b/data.go @@ -30,6 +30,7 @@ const ( optCropWidth = "cw" optCropHeight = "ch" optSmartCrop = "sc" + optTrim = "trim" ) // URLError reports a malformed URL error. @@ -80,6 +81,9 @@ type Options struct { // Automatically find good crop points based on image content. SmartCrop bool + + // If true, automatically trim pixels of the same color around the edges + Trim bool } func (o Options) String() string { @@ -123,6 +127,9 @@ func (o Options) String() string { if o.SmartCrop { opts = append(opts, optSmartCrop) } + if o.Trim { + opts = append(opts, optTrim) + } sort.Strings(opts) return strings.Join(opts, ",") } @@ -132,7 +139,7 @@ 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 != "" || o.CropX != 0 || o.CropY != 0 || o.CropWidth != 0 || o.CropHeight != 0 + return o.Width != 0 || o.Height != 0 || o.Rotate != 0 || o.FlipHorizontal || o.FlipVertical || o.Quality != 0 || o.Format != "" || o.CropX != 0 || o.CropY != 0 || o.CropWidth != 0 || o.CropHeight != 0 || o.Trim } // ParseOptions parses str as a list of comma separated transformation options. @@ -207,7 +214,7 @@ func (o Options) transform() bool { // // # Format // -// The "jpeg", "png", and "tiff" options can be used to specify the desired +// The "jpeg", "png", and "tiff" options can be used to specify the desired // image format of the proxied image. // // # Signature @@ -219,6 +226,13 @@ func (o Options) transform() bool { // See https://github.com/willnorris/imageproxy/blob/master/docs/url-signing.md // for examples of generating signatures. // +// # Trim +// +// The "trim" option will automatically trim pixels of the same color around +// the edges of the image. This is useful for removing borders from images +// that have been resized or cropped. The trim option is applied before other +// options such as cropping or resizing. +// // Examples // // 0x0 - no resizing @@ -251,6 +265,8 @@ func ParseOptions(str string) Options { options.Format = opt case opt == optSmartCrop: options.SmartCrop = true + case opt == optTrim: + options.Trim = true case strings.HasPrefix(opt, optRotatePrefix): value := strings.TrimPrefix(opt, optRotatePrefix) options.Rotate, _ = strconv.Atoi(value) diff --git a/transform.go b/transform.go index 103e20f..96b0c56 100644 --- a/transform.go +++ b/transform.go @@ -267,6 +267,11 @@ func transformImage(m image.Image, opt Options) image.Image { timer := prometheus.NewTimer(metricTransformationDuration) defer timer.ObserveDuration() + // trim + if opt.Trim { + m = trimEdges(m) + } + // Parse crop and resize parameters before applying any transforms. // This is to ensure that any percentage-based values are based off the // size of the original image. @@ -311,3 +316,41 @@ func transformImage(m image.Image, opt Options) image.Image { return m } + +// trimEdges returns a new image with solid color borders of the image removed. +// The pixel at the top left corner is used to match the border color. +func trimEdges(img image.Image) image.Image { + bounds := img.Bounds() + minX, minY, maxX, maxY := bounds.Max.X, bounds.Max.Y, bounds.Min.X, bounds.Min.Y + + // Get the color of the first pixel (top-left corner) + baseColor := img.At(bounds.Min.X, bounds.Min.Y) + + // Check each pixel and find the bounding box of non-matching pixels + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + if img.At(x, y) != baseColor { // Non-matching pixel + if x < minX { + minX = x + } + if y < minY { + minY = y + } + if x > maxX { + maxX = x + } + if y > maxY { + maxY = y + } + } + } + } + + // If no non-matching pixels are found, return the original image + if minX >= maxX || minY >= maxY { + return img + } + + // Crop the image to the bounding box of non-matching pixels + return imaging.Crop(img, image.Rect(minX, minY, maxX+1, maxY+1)) +} diff --git a/transform_test.go b/transform_test.go index b22afab..6321a75 100644 --- a/transform_test.go +++ b/transform_test.go @@ -375,3 +375,77 @@ func TestTransformImage(t *testing.T) { } } } + +func TestTrimEdges(t *testing.T) { + x := color.NRGBA{255, 255, 255, 255} + o := color.NRGBA{0, 0, 0, 255} + + tests := []struct { + name string + src image.Image // source image to transform + want image.Image // expected transformed image + }{ + { + name: "empty", + src: newImage(0, 0), + want: newImage(0, 0), // same as src + }, + { + name: "solid", + src: newImage(8, 8, x), + want: newImage(8, 8, x), // same as src + }, + { + name: "square", + src: newImage(4, 4, + x, x, x, x, + x, o, o, x, + x, o, o, x, + x, x, x, x, + ), + want: newImage(2, 2, + o, o, + o, o, + ), + }, + { + name: "diamond", + src: newImage(5, 5, + x, x, x, x, x, + x, x, o, x, x, + x, o, o, o, x, + x, x, o, x, x, + x, x, x, x, x, + ), + want: newImage(3, 3, + x, o, x, + o, o, o, + x, o, x, + ), + }, + { + name: "irregular", + src: newImage(5, 5, + x, o, x, x, x, + x, o, o, x, x, + x, o, o, x, x, + x, x, x, x, x, + x, x, x, x, x, + ), + want: newImage(2, 3, + o, x, + o, o, + o, o, + ), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := trimEdges(tt.src) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("trimEdges() returned image %#v, want %#v", got, tt.want) + } + }) + } +}