diff --git a/magick.go b/magick.go index 6a5a3b2..030059f 100644 --- a/magick.go +++ b/magick.go @@ -283,6 +283,22 @@ func (im *MagickImage) Height() int { return (int)(im.Image.rows) } +// VirtualCanvasWidth returns the virtual canvas width. +// +// This can be different than the image's width for certain formats, such as +// GIF. See PlusRepage() for more information. +func (im *MagickImage) VirtualCanvasWidth() int { + return (int)(im.Image.page.width) +} + +// VirtualCanvasHeight returns the virtual canvas height. +// +// This can be different than the image's height for certain formats, such as +// GIF. See PlusRepage() for more information. +func (im *MagickImage) VirtualCanvasHeight() int { + return (int)(im.Image.page.height) +} + // Type returns the underlying encoding or "magick" of the image as a string func (im *MagickImage) Type() (t string) { return strings.Trim(string(C.GoBytes(unsafe.Pointer(&im.Image.magick), 4096)), "\x00") @@ -318,6 +334,31 @@ func (im *MagickImage) SetProperty(prop, value string) (err error) { return } +// PlusRepage runs the same logic as the +repage command line argument. +// +// This removes the virtual canvas metadata. It resets the canvas to match what +// you cropped. +// +// After cropping you may find that the section you cropped is present at the +// location you cropped it at surrounded on all sides by empty space/pixels. +// This apparently occurs when the image format allows positioning sections of +// the image via offsets, such as GIF. +// +// See http://www.imagemagick.org/script/command-line-options.php#repage and +// http://www.imagemagick.org/Usage/crop/#crop_page +// +// The latter says: "Always use "+repage" after any 'crop' like operation. +// Unless you actually need to preserve that info." This means it might be +// useful for Crop() to call this function. +func (im *MagickImage) PlusRepage() { + geom := C.CString("0x0+0+0") + defer C.free(unsafe.Pointer(geom)) + + // +repage is a command line option that has no direct API equivalent. See + // MagickWand/operation.c for the implementation. I copy that here. + C.ParseAbsoluteGeometry(geom, &im.Image.page) +} + // ParseGeometryToRectangleInfo converts from a geometry string (WxH+X+Y) into a Magick // RectangleInfo that contains the individual properties func (im *MagickImage) ParseGeometryToRectangleInfo(geometry string) (info C.RectangleInfo, err error) { @@ -458,6 +499,24 @@ func (im *MagickImage) Strip() (err error) { return } +// AutoOrient adjusts an image so that its orientation is suitable for +// viewing (i.e. top-left orientation). +func (im *MagickImage) AutoOrient() error { + exception := C.AcquireExceptionInfo() + defer C.DestroyExceptionInfo(exception) + + newImage := C.AutoOrientImage(im.Image, im.Image.orientation, exception) + + failed := C.CheckException(exception) + if failed == C.MagickTrue { + return ErrorFromExceptionInfo(exception) + } + + im.ReplaceImage(newImage) + + return nil +} + // ToBlob takes a (transformed) MagickImage and returns a byte slice in the format you specify with extension. // Magick uses the extension to transform the image in to the proper encoding (e.g. "jpg", "png") func (im *MagickImage) ToBlob(extension string) (blob []byte, err error) { diff --git a/magick_test.go b/magick_test.go index ab11620..fff87be 100644 --- a/magick_test.go +++ b/magick_test.go @@ -1,11 +1,13 @@ package magick import ( - "github.com/bmizerany/assert" + "fmt" "io/ioutil" "log" "os" "testing" + + "github.com/bmizerany/assert" ) func setupImage(t *testing.T) (image *MagickImage) { @@ -170,6 +172,82 @@ func TestCrop(t *testing.T) { assert.T(t, err != nil) } +// The use case for +repage is that we need to reset an image's canvas so only +// the cropped portion is present. Cropping and not using +repage leaves the +// canvas the same size, but with only the cropped portion's pixels remaining. +// I believe this is only the case for certain image formats such as GIF and +// PNG. +// +// Test to demonstrate the problem and show how PlusRepage() solves it. +func TestPlusRepage(t *testing.T) { + filename := "test/heart_original.png" + image, err := NewFromFile(filename) + if err != nil { + t.Errorf("NewFromFile(%s) error = %s", filename, err) + return + } + + origWidth := image.Width() + origHeight := image.Height() + origVirtualCanvasWidth := image.VirtualCanvasWidth() + origVirtualCanvasHeight := image.VirtualCanvasHeight() + + wantedWidth := 100 + wantedHeight := 100 + geom := fmt.Sprintf("%dx%d!+10+10", wantedWidth, wantedHeight) + if err := image.Crop(geom); err != nil { + t.Errorf("image.Crop(%s) error = %s", geom, err) + _ = image.Destroy() + return + } + + if image.Width() != wantedWidth || image.Height() != wantedHeight { + t.Errorf("image width x height after cropping is %dx%d, wanted %dx%d", + image.Width(), image.Height(), origWidth, origHeight) + _ = image.Destroy() + return + } + + // The virtual canvas width x height should currently be as it was. The + // cropped region will be surrounded by empty space if we save it now. + if image.VirtualCanvasWidth() != origVirtualCanvasWidth || + image.VirtualCanvasHeight() != origVirtualCanvasHeight { + t.Errorf("virtual canvas width x height after cropping is %dx%d, wanted %dx%d", + image.VirtualCanvasWidth(), image.VirtualCanvasHeight(), + origVirtualCanvasWidth, origVirtualCanvasHeight) + _ = image.Destroy() + return + } + + // Reset the virtual canvas using +repage. This removes virtual canvas + // information. The effect of removing it means the image, once saved, will + // have the dimensions of the image region we cropped. + image.PlusRepage() + + // Check both sets of dimensions again. The image size should still be the + // same. The virtual canvas size should be 0x0 since we removed information + // about it. + + if image.Width() != wantedWidth || image.Height() != wantedHeight { + t.Errorf("image width x height after cropping is %dx%d, wanted %dx%d", + image.Width(), image.Height(), origWidth, origHeight) + _ = image.Destroy() + return + } + + if image.VirtualCanvasWidth() != 0 || image.VirtualCanvasHeight() != 0 { + t.Errorf("virtual canvas width x height after +repage is %dx%d, wanted %dx%d", + image.VirtualCanvasWidth(), image.VirtualCanvasHeight(), 0, 0) + _ = image.Destroy() + return + } + + if err := image.Destroy(); err != nil { + t.Errorf("image.Destroy() error = %s", err) + return + } +} + func TestShadow(t *testing.T) { image := setupImage(t) err := image.Shadow("#000", 75, 2, 0, 0) @@ -330,3 +408,56 @@ func TestFullStack(t *testing.T) { assert.T(t, err == nil) } } + +func TestAutoOrientNeedsToRotate(t *testing.T) { + // This file is set so the heart appears to need to be rotated 90 degrees + // clockwise. It has EXIF orientation set to indicate that. + // + // I created it from test/heart_original.png this way: + // convert -rotate 270 heart_original.png heart_rotated.jpg + // exiftool -Orientation=6 -n heart_rotated.jpg + filename := "test/heart_rotated.jpg" + + image, err := NewFromFile(filename) + if err != nil { + t.Errorf("NewFromFile(%s) = %s", filename, err) + return + } + + err = image.AutoOrient() + if err != nil { + _ = image.Destroy() + t.Errorf("AutoOrient() = %s", err) + return + } + + err = image.Destroy() + if err != nil { + t.Errorf("Destroy() = %s", err) + return + } +} + +func TestAutoOrientNoNeedToRotate(t *testing.T) { + // This file does not need to change. + filename := "test/heart_original.png" + + image, err := NewFromFile(filename) + if err != nil { + t.Errorf("NewFromFile(%s) = %s", filename, err) + return + } + + err = image.AutoOrient() + if err != nil { + _ = image.Destroy() + t.Errorf("AutoOrient() = %s", err) + return + } + + err = image.Destroy() + if err != nil { + t.Errorf("Destroy() = %s", err) + return + } +} diff --git a/test/heart_rotated.jpg b/test/heart_rotated.jpg new file mode 100644 index 0000000..ae9f16f Binary files /dev/null and b/test/heart_rotated.jpg differ