From d24b586830b4f8cf2a99b41ce4ca3e7418437289 Mon Sep 17 00:00:00 2001 From: Justin Martin Date: Wed, 24 Oct 2012 15:28:39 -0700 Subject: [PATCH] NEW: Enable multiple image manipulation back-ends on the Image class --- filesystem/GD.php | 70 +++++--- filesystem/ImagickBackend.php | 266 +++++++++++++++++++++++++++++++ model/Image.php | 86 +++++----- model/Image_Backend.php | 122 ++++++++++++++ tests/model/GDImageTest.php | 28 ++++ tests/model/ImageTest.php | 58 +++---- tests/model/ImagickImageTest.php | 29 ++++ 7 files changed, 573 insertions(+), 86 deletions(-) create mode 100644 filesystem/ImagickBackend.php create mode 100644 model/Image_Backend.php create mode 100644 tests/model/GDImageTest.php create mode 100644 tests/model/ImagickImageTest.php diff --git a/filesystem/GD.php b/filesystem/GD.php index abb1d0a92..04cd58431 100644 --- a/filesystem/GD.php +++ b/filesystem/GD.php @@ -4,7 +4,7 @@ * @package framework * @subpackage filesystem */ -class GD extends Object { +class GDBackend extends Object implements Image_Backend { protected $gd, $width, $height; protected $quality; @@ -28,29 +28,48 @@ class GD extends Object { // We use getimagesize instead of extension checking, because sometimes extensions are wrong. list($width, $height, $type, $attr) = getimagesize($filename); switch($type) { - case 1: if(function_exists('imagecreatefromgif')) $this->setGD(imagecreatefromgif($filename)); break; - case 2: if(function_exists('imagecreatefromjpeg')) $this->setGD(imagecreatefromjpeg($filename)); break; - case 3: if(function_exists('imagecreatefrompng')) { - $img = imagecreatefrompng($filename); - imagesavealpha($img, true); // save alphablending setting (important) - $this->setGD($img); + case 1: + if(function_exists('imagecreatefromgif')) + $this->setImageResource(imagecreatefromgif($filename)); + break; + case 2: + if(function_exists('imagecreatefromjpeg')) + $this->setImageResource(imagecreatefromjpeg($filename)); + break; + case 3: + if(function_exists('imagecreatefrompng')) { + $img = imagecreatefrompng($filename); + imagesavealpha($img, true); // save alphablending setting (important) + $this->setImageResource($img); + } break; - } } } $this->quality = self::$default_quality; parent::__construct(); } + + public function setImageResource($resource) { + $this->gd = $resource; + $this->width = imagesx($resource); + $this->height = imagesy($resource); + } public function setGD($gd) { - $this->gd = $gd; - $this->width = imagesx($gd); - $this->height = imagesy($gd); + Deprecation::notice('3.1', 'Use GD::setImageResource instead', + Deprecation::SCOPE_CLASS); + return $this->setImageResource($gd); + } + + public function getImageResource() { + return $this->gd; } public function getGD() { - return $this->gd; + Deprecation::notice('3.1', 'GD::getImageResource instead', + Deprecation::SCOPE_CLASS); + return $this->getImageResource(); } /** @@ -106,7 +125,7 @@ class GD extends Object { imagecopyresampled($newGD, $this->gd, 0,0, $srcX, $srcY, $width, $height, $srcWidth, $srcHeight); } $output = clone $this; - $output->setGD($newGD); + $output->setImageResource($newGD); return $output; } @@ -121,10 +140,21 @@ class GD extends Object { return $gd; } - public function hasGD() { + /** + * hasImageResource + * + * @return boolean + */ + public function hasImageResource() { return $this->gd ? true : false; } + public function hasGD() { + Deprecation::notice('3.1', 'GD::hasImageResource instead', + Deprecation::SCOPE_CLASS); + return $this->hasImageResource(); + } + /** * Resize an image, skewing it as necessary. @@ -153,7 +183,7 @@ class GD extends Object { imagecopyresampled($newGD, $this->gd, 0,0, 0, 0, $width, $height, $this->width, $this->height); $output = clone $this; - $output->setGD($newGD); + $output->setImageResource($newGD); return $output; } @@ -175,7 +205,7 @@ class GD extends Object { $newGD = $this->rotatePixelByPixel($angle); } $output = clone $this; - $output->setGD($newGD); + $output->setImageResource($newGD); return $output; } @@ -237,7 +267,7 @@ class GD extends Object { imagecopyresampled($newGD, $this->gd, 0, 0, $left, $top, $width, $height, $width, $height); $output = clone $this; - $output->setGD($newGD); + $output->setImageResource($newGD); return $output; } @@ -356,7 +386,7 @@ class GD extends Object { $destWidth, $destHeight, $this->width, $this->height); } $output = clone $this; - $output->setGD($newGD); + $output->setImageResource($newGD); return $output; } @@ -388,7 +418,7 @@ class GD extends Object { } $output = clone $this; - $output->setGD($newGD); + $output->setImageResource($newGD); return $output; } @@ -429,4 +459,4 @@ class GD extends Object { } } - +class_alias("GDBackend", "GD"); diff --git a/filesystem/ImagickBackend.php b/filesystem/ImagickBackend.php new file mode 100644 index 000000000..b59f57a17 --- /dev/null +++ b/filesystem/ImagickBackend.php @@ -0,0 +1,266 @@ +valid()) return; + + $width = round($width); + $height = round($height); + + $geometry = $this->getImageGeometry(); + + // Check that a resize is actually necessary. + if ($width == $geometry["width"] && $height == $geometry["height"]) { + return $this; + } + + if(!$width && !$height) user_error("No dimensions given", E_USER_ERROR); + if(!$width) user_error("Width not given", E_USER_ERROR); + if(!$height) user_error("Height not given", E_USER_ERROR); + + $new = clone $this; + $new->resizeImage($width, $height, self::FILTER_LANCZOS, 1); + + return $new; + } + + /** + * resizeRatio + * + * @param int $width + * @param int $height + * @return Image_Backend + */ + public function resizeRatio($maxWidth, $maxHeight, $useAsMinimum = false) { + if(!$this->valid()) return; + + $geometry = $this->getImageGeometry(); + + $widthRatio = $maxWidth / $geometry["width"]; + $heightRatio = $maxHeight / $geometry["height"]; + + if( $widthRatio < $heightRatio ) + return $useAsMinimum ? $this->resizeByHeight( $maxHeight ) : $this->resizeByWidth( $maxWidth ); + else + return $useAsMinimum ? $this->resizeByWidth( $maxWidth ) : $this->resizeByHeight( $maxHeight ); + } + + /** + * resizeByWidth + * + * @param int $width + * @return Image_Backend + */ + public function resizeByWidth($width) { + if(!$this->valid()) return; + + $geometry = $this->getImageGeometry(); + + $heightScale = $width / $geometry["width"]; + return $this->resize( $width, $heightScale * $geometry["height"] ); + } + + /** + * resizeByHeight + * + * @param int $height + * @return Image_Backend + */ + public function resizeByHeight($height) { + if(!$this->valid()) return; + + $geometry = $this->getImageGeometry(); + + $scale = $height / $geometry["height"]; + return $this->resize( $scale * $geometry["width"], $height ); + } + + /** + * paddedResize + * + * @param int $width + * @param int $height + * @return Image_Backend + */ + public function paddedResize($width, $height, $backgroundColor = "FFFFFF") { + if(!$this->valid()) return; + + $width = round($width); + $height = round($height); + $geometry = $this->getImageGeometry(); + + // Check that a resize is actually necessary. + if ($width == $geometry["width"] && $height == $geometry["height"]) { + return $this; + } + + $new = clone $this; + $new->setBackgroundColor($backgroundColor); + + $destAR = $width / $height; + if ($geometry["width"] > 0 && $geometry["height"] > 0) { + // We can't divide by zero theres something wrong. + + $srcAR = $geometry["width"] / $geometry["height"]; + + // Destination narrower than the source + if($destAR > $srcAR) { + $destY = 0; + $destHeight = $height; + + $destWidth = round( $height * $srcAR ); + $destX = round( ($width - $destWidth) / 2 ); + + // Destination shorter than the source + } else { + $destX = 0; + $destWidth = $width; + + $destHeight = round( $width / $srcAR ); + $destY = round( ($height - $destHeight) / 2 ); + } + + $new->extentImage($width, $height, $destX, $destY); + } + + return $new; + } + + /** + * croppedResize + * + * @param int $width + * @param int $height + * @return Image_Backend + */ + public function croppedResize($width, $height) { + if(!$this->valid()) return; + + $width = round($width); + $height = round($height); + $geometry = $this->getImageGeometry(); + + // Check that a resize is actually necessary. + if ($width == $geometry["width"] && $height == $geometry["height"]) { + return $this; + } + + $new = clone $this; + $new->setBackgroundColor($backgroundColor); + + $destAR = $width / $height; + if ($geometry["width"] > 0 && $geometry["height"] > 0) { + // We can't divide by zero theres something wrong. + + $srcAR = $this->width / $this->height; + + // Destination narrower than the source + if($destAR < $srcAR) { + $srcY = 0; + $srcHeight = $this->height; + + $srcWidth = round( $this->height * $destAR ); + $srcX = round( ($this->width - $srcWidth) / 2 ); + + // Destination shorter than the source + } else { + $srcX = 0; + $srcWidth = $this->width; + + $srcHeight = round( $this->width / $destAR ); + $srcY = round( ($this->height - $srcHeight) / 2 ); + } + + $new->extentImage($width, $height, $destX, $destY); + } + + return $new; + } +} diff --git a/model/Image.php b/model/Image.php index 883501465..810c456ae 100644 --- a/model/Image.php +++ b/model/Image.php @@ -12,6 +12,8 @@ class Image extends File { const ORIENTATION_PORTRAIT = 1; const ORIENTATION_LANDSCAPE = 2; + static $backend = "GDBackend"; + static $casting = array( 'Tag' => 'HTMLText', ); @@ -59,6 +61,14 @@ class Image extends File { */ public static $asset_preview_height = 200; + public static function set_backend($backend) { + self::$backend = $backend; + } + + public static function get_backend() { + return self::$backend; + } + /** * Set up template methods to access the transformations generated by 'generate' methods. */ @@ -195,32 +205,32 @@ class Image extends File { return $this->getFormattedImage('SetRatioSize', $width, $height); } - public function generateSetRatioSize(GD $gd, $width, $height) { - return $gd->resizeRatio($width, $height); + public function generateSetRatioSize(Image_Backend $backend, $width, $height) { + return $backend->resizeRatio($width, $height); } /** * Resize this Image by width, keeping aspect ratio. Use in templates with $SetWidth. - * @return GD + * @return Image_Backend */ - public function generateSetWidth(GD $gd, $width) { - return $gd->resizeByWidth($width); + public function generateSetWidth(Image_Backend $backend, $width) { + return $backend->resizeByWidth($width); } /** * Resize this Image by height, keeping aspect ratio. Use in templates with $SetHeight. - * @return GD + * @return Image_Backend */ - public function generateSetHeight(GD $gd, $height){ - return $gd->resizeByHeight($height); + public function generateSetHeight(Image_Backend $backend, $height){ + return $backend->resizeByHeight($height); } /** * Resize this Image by both width and height, using padded resize. Use in templates with $SetSize. - * @return GD + * @return Image_Backend */ - public function generateSetSize(GD $gd, $width, $height) { - return $gd->paddedResize($width, $height); + public function generateSetSize(Image_Backend $backend, $width, $height) { + return $backend->paddedResize($width, $height); } public function CMSThumbnail() { @@ -229,38 +239,38 @@ class Image extends File { /** * Resize this image for the CMS. Use in templates with $CMSThumbnail. - * @return GD + * @return Image_Backend */ - public function generateCMSThumbnail(GD $gd) { - return $gd->paddedResize($this->stat('cms_thumbnail_width'),$this->stat('cms_thumbnail_height')); + public function generateCMSThumbnail(Image_Backend $backend) { + return $backend->paddedResize($this->stat('cms_thumbnail_width'),$this->stat('cms_thumbnail_height')); } /** * Resize this image for preview in the Asset section. Use in templates with $AssetLibraryPreview. - * @return GD + * @return Image_Backend */ - public function generateAssetLibraryPreview(GD $gd) { - return $gd->paddedResize($this->stat('asset_preview_width'),$this->stat('asset_preview_height')); + public function generateAssetLibraryPreview(Image_Backend $backend) { + return $backend->paddedResize($this->stat('asset_preview_width'),$this->stat('asset_preview_height')); } /** * Resize this image for thumbnail in the Asset section. Use in templates with $AssetLibraryThumbnail. - * @return GD + * @return Image_Backend */ - public function generateAssetLibraryThumbnail(GD $gd) { - return $gd->paddedResize($this->stat('asset_thumbnail_width'),$this->stat('asset_thumbnail_height')); + public function generateAssetLibraryThumbnail(Image_Backend $backend) { + return $backend->paddedResize($this->stat('asset_thumbnail_width'),$this->stat('asset_thumbnail_height')); } /** * Resize this image for use as a thumbnail in a strip. Use in templates with $StripThumbnail. - * @return GD + * @return Image_Backend */ - public function generateStripThumbnail(GD $gd) { - return $gd->croppedResize($this->stat('strip_thumbnail_width'),$this->stat('strip_thumbnail_height')); + public function generateStripThumbnail(Image_Backend $backend) { + return $backend->croppedResize($this->stat('strip_thumbnail_width'),$this->stat('strip_thumbnail_height')); } - public function generatePaddedImage(GD $gd, $width, $height) { - return $gd->paddedResize($width, $height); + public function generatePaddedImage(Image_Backend $backend, $width, $height) { + return $backend->paddedResize($width, $height); } /** @@ -312,16 +322,18 @@ class Image extends File { */ public function generateFormattedImage($format, $arg1 = null, $arg2 = null) { $cacheFile = $this->cacheFilename($format, $arg1, $arg2); - - $gd = new GD(Director::baseFolder()."/" . $this->Filename); - if($gd->hasGD()){ + $backend = Injector::inst()->createWithArgs(self::$backend, array( + Director::baseFolder()."/" . $this->Filename + )); + + if($backend->hasImageResource()) { $generateFunc = "generate$format"; if($this->hasMethod($generateFunc)){ - $gd = $this->$generateFunc($gd, $arg1, $arg2); - if($gd){ - $gd->writeTo(Director::baseFolder()."/" . $cacheFile); + $backend = $this->$generateFunc($backend, $arg1, $arg2); + if($backend){ + $backend->writeTo(Director::baseFolder()."/" . $cacheFile); } } else { @@ -334,12 +346,12 @@ class Image extends File { * Generate a resized copy of this image with the given width & height. * Use in templates with $ResizedImage. */ - public function generateResizedImage($gd, $width, $height) { - if(is_numeric($gd) || !$gd){ + public function generateResizedImage(Image_Backend $backend, $width, $height) { + if(!$backend){ user_error("Image::generateFormattedImage - generateResizedImage is being called by legacy code" - . " or gd is not set.",E_USER_WARNING); + . " or Image::\$backend is not set.",E_USER_WARNING); }else{ - return $gd->resize($width, $height); + return $backend->resize($width, $height); } } @@ -347,8 +359,8 @@ class Image extends File { * Generate a resized copy of this image with the given width & height, cropping to maintain aspect ratio. * Use in templates with $CroppedImage */ - public function generateCroppedImage($gd, $width, $height) { - return $gd->croppedResize($width, $height); + public function generateCroppedImage(Image_Backend $backend, $width, $height) { + return $backend->croppedResize($width, $height); } /** diff --git a/model/Image_Backend.php b/model/Image_Backend.php new file mode 100644 index 000000000..e4642d0cd --- /dev/null +++ b/model/Image_Backend.php @@ -0,0 +1,122 @@ +markTestSkipped("The GD extension is required"); + $this->skipTest = true; + parent::setUp(); + return; + } + + parent::setUp(); + + Image::set_backend("GDBackend"); + + // Create a test files for each of the fixture references + $fileIDs = $this->allFixtureIDs('Image'); + foreach($fileIDs as $fileID) { + $file = DataObject::get_by_id('Image', $fileID); + + $image = imagecreatetruecolor(300,300); + + imagepng($image, BASE_PATH."/{$file->Filename}"); + imagedestroy($image); + + $file->write(); + } + } +} diff --git a/tests/model/ImageTest.php b/tests/model/ImageTest.php index b451ff492..20626065b 100644 --- a/tests/model/ImageTest.php +++ b/tests/model/ImageTest.php @@ -7,10 +7,20 @@ class ImageTest extends SapphireTest { static $fixture_file = 'ImageTest.yml'; - + + protected $origBackend; + public function setUp() { + if(get_class($this) == "ImageTest") + $this->skipTest = true; + parent::setUp(); + if($this->skipTest) + return; + + $this->origBackend = Image::get_backend(); + if(!file_exists(ASSETS_PATH)) mkdir(ASSETS_PATH); // Create a test folders for each of the fixture references @@ -21,18 +31,28 @@ class ImageTest extends SapphireTest { if(!file_exists(BASE_PATH."/$folder->Filename")) mkdir(BASE_PATH."/$folder->Filename"); } - - // Create a test files for each of the fixture references + } + + public function tearDown() { + Image::set_backend($this->origBackend); + + /* Remove the test files that we've created */ $fileIDs = $this->allFixtureIDs('Image'); foreach($fileIDs as $fileID) { $file = DataObject::get_by_id('Image', $fileID); - $image = imagecreatetruecolor(300,300); - - imagepng($image, BASE_PATH."/$file->Filename"); - imagedestroy($image); - - $file->write(); + if($file && file_exists(BASE_PATH."/$file->Filename")) unlink(BASE_PATH."/$file->Filename"); } + + /* Remove the test folders that we've crated */ + $folderIDs = $this->allFixtureIDs('Folder'); + foreach($folderIDs as $folderID) { + $folder = DataObject::get_by_id('Folder', $folderID); + if($folder && file_exists(BASE_PATH."/$folder->Filename")) { + Filesystem::removeFolder(BASE_PATH."/$folder->Filename"); + } + } + + parent::tearDown(); } public function testGetTagWithTitle() { @@ -61,26 +81,6 @@ class ImageTest extends SapphireTest { $this->assertEquals($expected, $actual); } - public function tearDown() { - /* Remove the test files that we've created */ - $fileIDs = $this->allFixtureIDs('Image'); - foreach($fileIDs as $fileID) { - $file = DataObject::get_by_id('Image', $fileID); - if($file && file_exists(BASE_PATH."/$file->Filename")) unlink(BASE_PATH."/$file->Filename"); - } - - /* Remove the test folders that we've crated */ - $folderIDs = $this->allFixtureIDs('Folder'); - foreach($folderIDs as $folderID) { - $folder = DataObject::get_by_id('Folder', $folderID); - if($folder && file_exists(BASE_PATH."/$folder->Filename")) { - Filesystem::removeFolder(BASE_PATH."/$folder->Filename"); - } - } - - parent::tearDown(); - } - public function testMultipleGenerateManipulationCalls() { $image = $this->objFromFixture('Image', 'imageWithoutTitle'); diff --git a/tests/model/ImagickImageTest.php b/tests/model/ImagickImageTest.php new file mode 100644 index 000000000..40d616c34 --- /dev/null +++ b/tests/model/ImagickImageTest.php @@ -0,0 +1,29 @@ +markTestSkipped("The Imagick extension is not available."); + $this->skipTest = true; + parent::setUp(); + return; + } + + parent::setUp(); + + Image::set_backend("ImagickBackend"); + + // Create a test files for each of the fixture references + $fileIDs = $this->allFixtureIDs('Image'); + foreach($fileIDs as $fileID) { + $file = DataObject::get_by_id('Image', $fileID); + + $image = new Imagick(); + + $image->newImage(300,300, new ImagickPixel("white")); + $image->setImageFormat("png"); + $image->writeImage(BASE_PATH."/{$file->Filename}"); + + $file->write(); + } + } +}