diff --git a/transform.go b/transform.go index e4fea27..63a6811 100644 --- a/transform.go +++ b/transform.go @@ -21,9 +21,11 @@ import ( _ "image/gif" // register gif format "image/jpeg" "image/png" + "io" "math" "github.com/disintegration/imaging" + "github.com/rwcarlsen/goexif/exif" "golang.org/x/image/tiff" // register tiff format _ "golang.org/x/image/webp" // register webp format "willnorris.com/go/gifresize" @@ -32,6 +34,9 @@ import ( // default compression quality of resized jpegs const defaultQuality = 95 +// maximum distance into image to look for EXIF tags +const maxExifSize = 1 << 20 + // resample filter used when resizing images var resampleFilter = imaging.Lanczos @@ -50,6 +55,15 @@ func Transform(img []byte, opt Options) ([]byte, error) { return nil, err } + // apply EXIF orientation for jpeg and tiff source images. Read at most + // up to maxExifSize looking for EXIF tags. + if format == "jpeg" || format == "tiff" { + r := io.LimitReader(bytes.NewReader(img), maxExifSize) + if exifOpt := exifOrientation(r); exifOpt.transform() { + m = transformImage(m, exifOpt) + } + } + // encode webp and tiff as jpeg by default if format == "tiff" || format == "webp" { format = "jpeg" @@ -187,6 +201,57 @@ func cropParams(m image.Image, opt Options) (x0, y0, x1, y1 int, crop bool) { return x0, y0, x1, y1, true } +// read EXIF orientation tag from r and adjust opt to orient image correctly. +func exifOrientation(r io.Reader) (opt Options) { + // Exif Orientation Tag values + // http://sylvana.net/jpegcrop/exif_orientation.html + const ( + topLeftSide = 1 + topRightSide = 2 + bottomRightSide = 3 + bottomLeftSide = 4 + leftSideTop = 5 + rightSideTop = 6 + rightSideBottom = 7 + leftSideBottom = 8 + ) + + ex, err := exif.Decode(r) + if err != nil { + return opt + } + tag, err := ex.Get(exif.Orientation) + if err != nil { + return opt + } + orient, err := tag.Int(0) + if err != nil { + return opt + } + + switch orient { + case topLeftSide: + // do nothing + case topRightSide: + opt.FlipHorizontal = true + case bottomRightSide: + opt.Rotate = 180 + case bottomLeftSide: + opt.FlipVertical = true + case leftSideTop: + opt.Rotate = 90 + opt.FlipVertical = true + case rightSideTop: + opt.Rotate = -90 + case rightSideBottom: + opt.Rotate = 90 + opt.FlipHorizontal = true + case leftSideBottom: + opt.Rotate = 90 + } + return opt +} + // transformImage modifies the image m based on the transformations specified // in opt. func transformImage(m image.Image, opt Options) image.Image { diff --git a/transform_test.go b/transform_test.go index f9f597f..12b9797 100644 --- a/transform_test.go +++ b/transform_test.go @@ -16,6 +16,7 @@ package imageproxy import ( "bytes" + "encoding/base64" "image" "image/color" "image/draw" @@ -39,7 +40,7 @@ var ( // newImage creates a new NRGBA image with the specified dimensions and pixel // color data. If the length of pixels is 1, the entire image is filled with // that color. -func newImage(w, h int, pixels ...color.NRGBA) image.Image { +func newImage(w, h int, pixels ...color.Color) image.Image { m := image.NewNRGBA(image.Rect(0, 0, w, h)) if len(pixels) == 1 { draw.Draw(m, m.Bounds(), &image.Uniform{pixels[0]}, image.ZP, draw.Src) @@ -145,6 +146,77 @@ func TestTransform(t *testing.T) { } } +// Test that each of the eight EXIF orientations is applied to the transformed +// image appropriately. +func TestTransform_EXIF(t *testing.T) { + ref := newImage(2, 2, red, green, blue, yellow) + + // reference image encoded as TIF, with each of the 8 EXIF orientations + // applied in reverse and the EXIF tag set. When orientation is + // applied, each should display as the ref image. + tests := []string{ + "SUkqAAgAAAAOAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwAEAAAAtgAAAAMBAwABAAAACAAAAAYBAwABAAAAAgAAABEBBAABAAAAzgAAABIBAwABAAAAAQAAABUBAwABAAAABAAAABYBAwABAAAAAgAAABcBBAABAAAAGQAAABoBBQABAAAAvgAAABsBBQABAAAAxgAAACgBAwABAAAAAgAAAFIBAwABAAAAAgAAAAAAAAAIAAgACAAIAEgAAAABAAAASAAAAAEAAAB4nPrPwPAfDBn+////n+E/IAAA//9DzAj4AA==", // Orientation=1 + "SUkqAAgAAAAOAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwAEAAAAtgAAAAMBAwABAAAACAAAAAYBAwABAAAAAgAAABEBBAABAAAAzgAAABIBAwABAAAAAgAAABUBAwABAAAABAAAABYBAwABAAAAAgAAABcBBAABAAAAGQAAABoBBQABAAAAvgAAABsBBQABAAAAxgAAACgBAwABAAAAAgAAAFIBAwABAAAAAgAAAAAAAAAIAAgACAAIAEgAAAABAAAASAAAAAEAAAB4nGL4z/D/PwPD////GcAUIAAA//9HyAj4AA==", // Orientation=2 + "SUkqAAgAAAAOAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwAEAAAAtgAAAAMBAwABAAAACAAAAAYBAwABAAAAAgAAABEBBAABAAAAzgAAABIBAwABAAAAAwAAABUBAwABAAAABAAAABYBAwABAAAAAgAAABcBBAABAAAAFwAAABoBBQABAAAAvgAAABsBBQABAAAAxgAAACgBAwABAAAAAgAAAFIBAwABAAAAAgAAAAAAAAAIAAgACAAIAEgAAAABAAAASAAAAAEAAAB4nPr/n+E/AwOY/A9iAAIAAP//T8AI+AA=", // Orientation=3 + "SUkqAAgAAAAOAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwAEAAAAtgAAAAMBAwABAAAACAAAAAYBAwABAAAAAgAAABEBBAABAAAAzgAAABIBAwABAAAABAAAABUBAwABAAAABAAAABYBAwABAAAAAgAAABcBBAABAAAAGgAAABoBBQABAAAAvgAAABsBBQABAAAAxgAAACgBAwABAAAAAgAAAFIBAwABAAAAAgAAAAAAAAAIAAgACAAIAEgAAAABAAAASAAAAAEAAAB4nGJg+P///3+G//8ZGP6DICAAAP//S8QI+A==", // Orientation=4 + "SUkqAAgAAAAOAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwAEAAAAtgAAAAMBAwABAAAACAAAAAYBAwABAAAAAgAAABEBBAABAAAAzgAAABIBAwABAAAABQAAABUBAwABAAAABAAAABYBAwABAAAAAgAAABcBBAABAAAAGAAAABoBBQABAAAAvgAAABsBBQABAAAAxgAAACgBAwABAAAAAgAAAFIBAwABAAAAAgAAAAAAAAAIAAgACAAIAEgAAAABAAAASAAAAAEAAAB4nPrPwABC/xn+M/wHkYAAAAD//0PMCPg=", // Orientation=5 + "SUkqAAgAAAAOAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwAEAAAAtgAAAAMBAwABAAAACAAAAAYBAwABAAAAAgAAABEBBAABAAAAzgAAABIBAwABAAAABgAAABUBAwABAAAABAAAABYBAwABAAAAAgAAABcBBAABAAAAGAAAABoBBQABAAAAvgAAABsBBQABAAAAxgAAACgBAwABAAAAAgAAAFIBAwABAAAAAgAAAAAAAAAIAAgACAAIAEgAAAABAAAASAAAAAEAAAB4nGL4z/D/PwgzMIDQf0AAAAD//0vECPg=", // Orientation=6 + "SUkqAAgAAAAOAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwAEAAAAtgAAAAMBAwABAAAACAAAAAYBAwABAAAAAgAAABEBBAABAAAAzgAAABIBAwABAAAABwAAABUBAwABAAAABAAAABYBAwABAAAAAgAAABcBBAABAAAAFgAAABoBBQABAAAAvgAAABsBBQABAAAAxgAAACgBAwABAAAAAgAAAFIBAwABAAAAAgAAAAAAAAAIAAgACAAIAEgAAAABAAAASAAAAAEAAAB4nPr/nwECGf7/BxGAAAAA//9PwAj4", // Orientation=7 + "SUkqAAgAAAAOAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwAEAAAAtgAAAAMBAwABAAAACAAAAAYBAwABAAAAAgAAABEBBAABAAAAzgAAABIBAwABAAAACAAAABUBAwABAAAABAAAABYBAwABAAAAAgAAABcBBAABAAAAFQAAABoBBQABAAAAvgAAABsBBQABAAAAxgAAACgBAwABAAAAAgAAAFIBAwABAAAAAgAAAAAAAAAIAAgACAAIAEgAAAABAAAASAAAAAEAAAB4nGJg+P//P4QAQ0AAAAD//0fICPgA", // Orientation=8 + } + + for _, src := range tests { + in, err := base64.StdEncoding.DecodeString(src) + if err != nil { + t.Errorf("error decoding source: %v", err) + } + out, err := Transform(in, Options{Height: -1, Width: -1, Format: "tiff"}) + if err != nil { + t.Errorf("Transform(%q) returned error: %v", src, err) + } + d, _, err := image.Decode(bytes.NewReader(out)) + if err != nil { + t.Errorf("error decoding transformed image: %v", err) + } + + // construct new image with same colors as decoded image for easy comparison + got := newImage(2, 2, d.At(0, 0), d.At(1, 0), d.At(0, 1), d.At(1, 1)) + if want := ref; !reflect.DeepEqual(got, want) { + t.Errorf("Transform(%v) returned image %#v, want %#v", src, got, want) + } + } +} + +// Test that EXIF orientation and any additional transforms don't conflict. +// This is tested with orientation=7, which involves both a rotation and a +// flip, combined with an additional rotation transform. +func TestTransform_EXIF_Rotate(t *testing.T) { + // base64-encoded TIF image (2x2 yellow green blue red) with EXIF + // orientation=7. When orientation applied, displays as (2x2 red green + // blue yellow). + src := "SUkqAAgAAAAOAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwAEAAAAtgAAAAMBAwABAAAACAAAAAYBAwABAAAAAgAAABEBBAABAAAAzgAAABIBAwABAAAABwAAABUBAwABAAAABAAAABYBAwABAAAAAgAAABcBBAABAAAAFgAAABoBBQABAAAAvgAAABsBBQABAAAAxgAAACgBAwABAAAAAgAAAFIBAwABAAAAAgAAAAAAAAAIAAgACAAIAEgAAAABAAAASAAAAAEAAAB4nPr/nwECGf7/BxGAAAAA//9PwAj4" + + in, err := base64.StdEncoding.DecodeString(src) + if err != nil { + t.Errorf("error decoding source: %v", err) + } + out, err := Transform(in, Options{Rotate: 90, Format: "tiff"}) + if err != nil { + t.Errorf("Transform(%q) returned error: %v", src, err) + } + d, _, err := image.Decode(bytes.NewReader(out)) + if err != nil { + t.Errorf("error decoding transformed image: %v", err) + } + + // construct new image with same colors as decoded image for easy comparison + got := newImage(2, 2, d.At(0, 0), d.At(1, 0), d.At(0, 1), d.At(1, 1)) + want := newImage(2, 2, green, yellow, red, blue) + if !reflect.DeepEqual(got, want) { + t.Errorf("Transform(%v) returned image %#v, want %#v", src, got, want) + } +} + func TestTransformImage(t *testing.T) { // ref is a 2x2 reference image containing four colors ref := newImage(2, 2, red, green, blue, yellow)