mirror of
https://github.com/willnorris/imageproxy.git
synced 2026-07-03 16:50:59 +02:00
parent
3211eeb13b
commit
1b70c6afd0
4 changed files with 174 additions and 19 deletions
78
data.go
78
data.go
|
|
@ -34,6 +34,10 @@ const (
|
||||||
optSignaturePrefix = "s"
|
optSignaturePrefix = "s"
|
||||||
optSizeDelimiter = "x"
|
optSizeDelimiter = "x"
|
||||||
optScaleUp = "scaleUp"
|
optScaleUp = "scaleUp"
|
||||||
|
optCropWidth = "cw"
|
||||||
|
optCropHeight = "ch"
|
||||||
|
optCropX = "cx"
|
||||||
|
optCropY = "cy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// URLError reports a malformed URL error.
|
// URLError reports a malformed URL error.
|
||||||
|
|
@ -75,6 +79,12 @@ type Options struct {
|
||||||
|
|
||||||
// Desired image format. Valid values are "jpeg", "png".
|
// Desired image format. Valid values are "jpeg", "png".
|
||||||
Format string
|
Format string
|
||||||
|
|
||||||
|
// Crop rectangle params
|
||||||
|
CropWidth int
|
||||||
|
CropHeight int
|
||||||
|
CropX int
|
||||||
|
CropY int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o Options) String() string {
|
func (o Options) String() string {
|
||||||
|
|
@ -103,6 +113,18 @@ func (o Options) String() string {
|
||||||
if o.Format != "" {
|
if o.Format != "" {
|
||||||
opts = append(opts, 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, ",")
|
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
|
// the presence of other fields (like Fit). A non-empty Format value is
|
||||||
// assumed to involve a transformation.
|
// assumed to involve a transformation.
|
||||||
func (o Options) transform() bool {
|
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.
|
// ParseOptions parses str as a list of comma separated transformation options.
|
||||||
// The options can be specified in in order, with duplicate options overwriting
|
// The options can be specified in in order, with duplicate options overwriting
|
||||||
// previous values.
|
// 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
|
// Size and Cropping
|
||||||
//
|
//
|
||||||
// The size option takes the general form "{width}x{height}", where width and
|
// The size option takes the general form "{width}x{height}", where width and
|
||||||
|
|
@ -176,17 +214,19 @@ func (o Options) transform() bool {
|
||||||
//
|
//
|
||||||
// Examples
|
// Examples
|
||||||
//
|
//
|
||||||
// 0x0 - no resizing
|
// 0x0 - no resizing
|
||||||
// 200x - 200 pixels wide, proportional height
|
// 200x - 200 pixels wide, proportional height
|
||||||
// 0.15x - 15% original width, proportional height
|
// 0.15x - 15% original width, proportional height
|
||||||
// x100 - 100 pixels tall, proportional width
|
// x100 - 100 pixels tall, proportional width
|
||||||
// 100x150 - 100 by 150 pixels, cropping as needed
|
// 100x150 - 100 by 150 pixels, cropping as needed
|
||||||
// 100 - 100 pixels square, cropping as needed
|
// 100 - 100 pixels square, cropping as needed
|
||||||
// 150,fit - scale to fit 150 pixels square, no cropping
|
// 150,fit - scale to fit 150 pixels square, no cropping
|
||||||
// 100,r90 - 100 pixels square, rotated 90 degrees
|
// 100,r90 - 100 pixels square, rotated 90 degrees
|
||||||
// 100,fv,fh - 100 pixels square, flipped horizontal and vertical
|
// 100,fv,fh - 100 pixels square, flipped horizontal and vertical
|
||||||
// 200x,q80 - 200 pixels wide, proportional height, 80% quality
|
// 200x,q80 - 200 pixels wide, proportional height, 80% quality
|
||||||
// 200x,png - 200 pixels wide, converted to PNG format
|
// 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 {
|
func ParseOptions(str string) Options {
|
||||||
var options Options
|
var options Options
|
||||||
|
|
||||||
|
|
@ -212,6 +252,18 @@ func ParseOptions(str string) Options {
|
||||||
options.Quality, _ = strconv.Atoi(value)
|
options.Quality, _ = strconv.Atoi(value)
|
||||||
case strings.HasPrefix(opt, optSignaturePrefix):
|
case strings.HasPrefix(opt, optSignaturePrefix):
|
||||||
options.Signature = strings.TrimPrefix(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):
|
case strings.Contains(opt, optSizeDelimiter):
|
||||||
size := strings.SplitN(opt, optSizeDelimiter, 2)
|
size := strings.SplitN(opt, optSizeDelimiter, 2)
|
||||||
if w := size[0]; w != "" {
|
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
|
// NewRequest parses an http.Request into an imageproxy Request. Options and
|
||||||
// the remote image URL are specified in the request path, formatted as:
|
// the remote image URL are specified in the request path, formatted as:
|
||||||
// /{options}/{remote_url}. Options may be omitted, so a request path may
|
// /{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.
|
// "https" URL, should not be URL encoded, and may contain a query string.
|
||||||
//
|
//
|
||||||
// Assuming an imageproxy server running on localhost, the following are all
|
// Assuming an imageproxy server running on localhost, the following are all
|
||||||
|
|
|
||||||
29
data_test.go
29
data_test.go
|
|
@ -31,13 +31,21 @@ func TestOptions_String(t *testing.T) {
|
||||||
"0x0",
|
"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",
|
"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",
|
"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 {
|
for i, tt := range tests {
|
||||||
|
|
@ -85,9 +93,20 @@ func TestParseOptions(t *testing.T) {
|
||||||
// mix of valid and invalid flags
|
// mix of valid and invalid flags
|
||||||
{"FOO,1,BAR,r90,BAZ", Options{Width: 1, Height: 1, Rotate: 90}},
|
{"FOO,1,BAR,r90,BAZ", Options{Width: 1, Height: 1, Rotate: 90}},
|
||||||
|
|
||||||
// all flags, in different orders
|
// flags, in different orders
|
||||||
{"q70,1x2,fit,r90,fv,fh,sc0ffee,png", Options{1, 2, true, 90, true, true, 70, "c0ffee", false, "png"}},
|
{"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"}},
|
{"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 {
|
for _, tt := range tests {
|
||||||
|
|
|
||||||
36
transform.go
36
transform.go
|
|
@ -21,6 +21,7 @@ import (
|
||||||
_ "image/gif" // register gif format
|
_ "image/gif" // register gif format
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
"image/png"
|
"image/png"
|
||||||
|
"math"
|
||||||
|
|
||||||
"github.com/disintegration/imaging"
|
"github.com/disintegration/imaging"
|
||||||
_ "golang.org/x/image/webp" // register webp format
|
_ "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
|
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
|
// transformImage modifies the image m based on the transformations specified
|
||||||
// in opt.
|
// in opt.
|
||||||
func transformImage(m image.Image, opt Options) image.Image {
|
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
|
// resize if needed
|
||||||
if w, h, resize := resizeParams(m, opt); resize {
|
if w, h, resize := resizeParams(m, opt); resize {
|
||||||
if opt.Fit {
|
if opt.Fit {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestTransform(t *testing.T) {
|
||||||
src := newImage(2, 2, red, green, blue, yellow)
|
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 is a 2x2 reference image containing four colors
|
||||||
ref := newImage(2, 2, red, green, blue, yellow)
|
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
|
// use simpler filter while testing that won't skew colors
|
||||||
resampleFilter = imaging.Box
|
resampleFilter = imaging.Box
|
||||||
|
|
||||||
|
|
@ -221,11 +247,33 @@ func TestTransformImage(t *testing.T) {
|
||||||
Options{Width: 2, Height: 1, Fit: true, FlipHorizontal: true, Rotate: 90},
|
Options{Width: 2, Height: 1, Fit: true, FlipHorizontal: true, Rotate: 90},
|
||||||
newImage(1, 2, red, blue),
|
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 {
|
for _, tt := range tests {
|
||||||
if got := transformImage(tt.src, tt.opt); !reflect.DeepEqual(got, tt.want) {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue