diff --git a/data.go b/data.go index 0545f8b72..54f58a61f 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. @@ -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 after any +// cropping or resizing has been performed. +// // 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 103e20f65..df4da7fff 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,40 @@ func transformImage(m image.Image, opt Options) image.Image { return m } + +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 + croppedImg := imaging.Crop(img, image.Rect(minX, minY, maxX+1, maxY+1)) + return croppedImg +} diff --git a/transform_test.go b/transform_test.go index b22afabc2..253c69b41 100644 --- a/transform_test.go +++ b/transform_test.go @@ -375,3 +375,183 @@ func TestTransformImage(t *testing.T) { } } } + +func TestTrimBordersOfSameColor(t *testing.T) { + w := color.NRGBA{255, 255, 255, 255} + r := color.NRGBA{255, 0, 0, 255} + src := newImage(4, 4, + w, w, w, w, + w, r, r, w, + w, r, r, w, + w, w, w, w, + ) + + want := newImage(2, 2, + r, r, + r, r, + ) + + got := trimEdges(src) + // Compare pixel data + if !compareImages(got, want) { + t.Errorf("trimEdges() pixel data does not match expected result") + } +} + +func TestTrimEdgesSingleColorImage(t *testing.T) { + // Create an 8x8 image filled with a single color (white) + src := newImage(8, 8, color.NRGBA{255, 255, 255, 255}) + + // The expected result should be the same as the source image + want := src + + // Apply the trimEdges function + got := trimEdges(src) + + // Compare pixel data + if !compareImages(got, want) { + t.Errorf("trimEdges() pixel data does not match expected result") + } +} + +func TestTrimEdgesCircle(t *testing.T) { + // Define colors for better readability + w := color.NRGBA{255, 255, 255, 255} + r := color.NRGBA{255, 0, 0, 255} + + // Create a 9x9 image with a white background and a larger red circle in the center + src := newImage(9, 9, + w, w, w, w, w, w, w, w, w, + w, w, w, r, r, r, w, w, w, + w, w, r, r, r, r, r, w, w, + w, r, r, r, r, r, r, r, w, + w, r, r, r, r, r, r, r, w, + w, r, r, r, r, r, r, r, w, + w, w, r, r, r, r, r, w, w, + w, w, w, r, r, r, w, w, w, + w, w, w, w, w, w, w, w, w, + ) + + // Expected result: a trimmed 7x7 image containing only the circle + want := newImage(7, 7, + w, w, r, r, r, w, w, + w, r, r, r, r, r, w, + r, r, r, r, r, r, r, + r, r, r, r, r, r, r, + r, r, r, r, r, r, r, + w, r, r, r, r, r, w, + w, w, r, r, r, w, w, + ) + + // Apply the trimEdges function + got := trimEdges(src) + + // Compare pixel data + if !compareImages(got, want) { + t.Errorf("trimEdges() pixel data does not match expected result") + } +} + +func TestTrimEdgesUnevenVerticalRectangle(t *testing.T) { + // Define colors for better readability + w := color.NRGBA{255, 255, 255, 255} + r := color.NRGBA{255, 0, 0, 255} + + // Create a 9x5 image with a white background and a red diagonal shape + src := newImage(5, 9, + w, w, w, w, w, + w, w, w, r, w, + w, w, r, w, w, + w, r, w, w, w, + w, r, w, w, w, + w, r, w, w, w, + w, w, r, w, w, + w, w, w, r, w, + w, w, w, w, w, + ) + + // Expected result: a trimmed 5x5 image containing only the diagonal shape + want := newImage(3, 7, + w, w, r, + w, r, w, + r, w, w, + r, w, w, + r, w, w, + w, r, w, + w, w, r, + ) + + // Apply the trimEdges function + got := trimEdges(src) + + // Compare pixel data + if !compareImages(got, want) { + t.Errorf("trimEdges() pixel data does not match expected result") + } +} + +func compareImages(img1, img2 image.Image) bool { + bounds1 := img1.Bounds() + bounds2 := img2.Bounds() + if !bounds1.Eq(bounds2) { + return false + } + + for y := bounds1.Min.Y; y < bounds1.Max.Y; y++ { + for x := bounds1.Min.X; x < bounds1.Max.X; x++ { + r1, g1, b1, a1 := img1.At(x, y).RGBA() + r2, g2, b2, a2 := img2.At(x, y).RGBA() + if r1 != r2 || g1 != g2 || b1 != b2 || a1 != a2 { + return false + } + } + } + return true +} + +func TestTrimEdgesUneven(t *testing.T) { + // Define colors for better readability + w := color.NRGBA{255, 255, 255, 255} // white + r := color.NRGBA{255, 0, 0, 255} // red + + // Create a 6x4 image with a white background and a red inner rectangle + src := newImage(4, 6, + w, w, w, w, + w, w, r, w, + w, r, r, w, + w, r, r, w, + w, w, w, w, + w, w, w, w, + ) + + // Expected result: a trimmed 2x3 image containing only the red rectangle + want := newImage(2, 3, + w, r, + r, r, + r, r, + ) + + // Apply the trimEdges function + got := trimEdges(src) + + // Compare pixel data + if !compareImages(got, want) { + t.Errorf("trimEdges() pixel data does not match expected result") + } +} + +func TestTrimEdgesEmptyImage(t *testing.T) { + // Create an empty image (0x0 dimensions) + src := newImage(0, 0) + + // The expected result should also be an empty image + want := src + + // Apply the trimEdges function + got := trimEdges(src) + + // Compare pixel data + if !compareImages(got, want) { + t.Errorf("trimEdges() for empty image returned %v, want %v", got.Bounds(), want.Bounds()) + } +}