Image Version Component

By Tom Maiaroto (tom_m)
Generate image thumbnail versions dynamically from a source image. Also includes a helper to call the component and generate full HTML code to render the image in a view template.
Want to make multiple version of an image? Don't want to do it from a behavior or store any data in a database? This component (and optional companion helper) will let you generate thumbnails from source images (png/jpg/gif) on the fly. The component itself has a method that returns a path for you. The helper returns full HTML code to display an image in your view.

The great thing is the image version isn't generated every time the page loads. It will only be created when the source image is newer than the image version being requested...Or when you call the flushVersion() method and delete image versions.

This component and helper was inspired by the imagecache plugin that was cleverly crafted for Drupal. The code is much shorter and should hopefully provide a quick lightweight solution that will work for many situations.

Requirements: GD Library and php modules to work with GD. Most servers have this. I also tried to be as careful as possible to make it PHP4 compliant. There was one case in particular that I resisted the temptation to use a PHP5 specific function.

/controllers/components/image_version.php

Download code
<?php
/** Image Version Component 
 * 
 * A custom component for automagically creating thumbnail versions of any image within your app.
 * Example controller use:
 * $images = $this->{$this->modelClass}->find('first');     
 * $this->set('clear', $this->ImageVersion->flushVersion($images['Piece']['file'], array(150, 75), true));
 * $this->set('thumbnail', $this->ImageVersion->version($images['Piece']['file'], array(150, 75)));
 *     (that would clean out the entire folder 150x75 and then make a thumbnail again and return a path to $thumbnail for the view)
 *
 * @link            http://www.concepthue.com
 * @author            Tom Maiaroto
 * @modifiedby        Tom
 * @lastmodified    2008-09-25 01:00:00
 * @license            http://www.opensource.org/licenses/mit-license.php The MIT License
 */
class ImageVersionComponent extends Object {
/**
 * Components
 *
 * @return void
 */
    //var $components = array('Session');
    
var $controller;
/**
 * Startup
 *
 * @param object $controller
 * @return void
 */
    
function initialize(&$controller) {
        
$this->controller $controller;            
    }
    
    
/**
     * Returns a path to the generated thumbnail.
     * It will only generate a thumbnail for an image if the source is newer than the thumbnail,
     * or if the thumbnail doesn't exist yet.
     * 
     * Note: Changing the quality later on after a thumbnail is already generated would have 
     * no effect. Original source images would have to be updated (re-uploaded or modified via
     * "touch" command or some other means). Or the existing thumbnail would have to be destroyed
     * manually or with the flushVersions() method below.
     *  
     * @param $image String[required] Location of the source image.
     * @param $size Array[optional] Size of the thumbnail. Default: 75x75
     * @param $thumbQuality Int[optional] Quality of the thumbnail. Default: 85%
     * 
     * @return String path to thumbnail image.
     */
    
function version($source=null$thumbSize=array(7575), $thumbQuality=85$crop=false) {
        
// if no source provided, don't do anything
        
if(empty($source)): return false; endif;
        
        
$webroot = new Folder(WWW_ROOT);
        
$this->webRoot $webroot->path;
        
        
// set the size
        
$thumb_size_x $thumbSize[0];
        
$thumb_size_y $thumbSize[1];
                        
        
// round the thumbnail quality in case someone provided a decimal
        
$thumbQuality ceil($thumbQuality);
        
// or if a value was entered beyond the extremes
        
if($thumbQuality 100): $thumbQuality 100; endif;
        if(
$thumbQuality 0): $thumbQuality 0; endif;
        
        
// get full path of source file    (note: a beginning slash doesn't matter, the File class handles that I believe)
        
$originalFile = new File($this->webRoot $source);    
        
$source $originalFile->Folder->path.DS.$originalFile->name().'.'.$originalFile->ext();
        
// if the source file doesn't exist, don't do anything
        
if(!file_exists($source)): return false; endif;
        
        
// get the destination where the new file will be saved (including file name)        
        
$pathToSave $this->createPath($originalFile->Folder->path.DS.$thumbSize[0].'x'.$thumbSize[1]);                    
        
$dest $originalFile->Folder->path.DS.$thumb_size_x.'x'.$thumb_size_y.DS.$originalFile->name().'.'.$originalFile->ext();                                        
        
            
// First make sure it's an image that we can use (bmp support isn't added, but could be)
        
switch($originalFile->ext()):
            case 
'jpg':
            case 
'jpeg':
            case 
'gif':
            case 
'png':
            break;
            default:
                return 
false;
            break;
        endswitch;

        
// Then see if the size version already exists and if so, is it older than our source image?
        
if(file_exists($originalFile->Folder->path.DS.$thumb_size_x.'x'.$thumb_size_y.DS.$originalFile->name().'.'.$originalFile->ext())):
            
$existingFile = new File($dest);
            if( 
date('YmdHis'$existingFile->lastChange()) > date('YmdHis'$originalFile->lastChange()) ):
                
// if it's newer than the source, return the path. the source hasn't updated, so we don't need a new thumbnail.
                
return substr(strstr($existingFile->Folder->path.DS.$existingFile->name().'.'.$existingFile->ext(), 'webroot'), 7);                
            endif;
        endif;
            
        
// Get source image dimensions
        
$size getimagesize($source);
        
$width $size[0];
        
$height $size[1];
        
// $x and $y here are the image source offsets
        
$x NULL;
        
$y NULL;
        
        
// don't allow new width or height to be greater than the original (Thanks to TimThumb for thinking about something I didn't)
        
if( $thumb_size_x $width ) { $thumb_size_x $width; }
        if( 
$thumb_size_y $height ) { $thumb_size_y $height; }    
        
// generate new w/h if not provided (cool, idiot proofing)
        
if( $thumb_size_x && !$thumb_size_y ) {
            
$thumb_size_y $height * ( $thumb_size_x $width );
        }
        elseif(
$thumb_size_y && !$thumb_size_x) {
            
$thumb_size_x $width * ( $thumb_size_y $height );
        }
        elseif(!
$thumb_size_x && !$thumb_size_y) {
            
$thumb_size_x $width;
            
$thumb_size_y $height;
        }
                
        
// If the thumbnail is square        
        
if($thumbSize[0] == $thumbSize[1]) {
            if(
$width $height) {
                
$x ceil(($width $height) / );
                
$width $height;
            } elseif(
$height $width) {
                
$y ceil(($height $width) / 2);
                
$height $width;
            }     
        
// else if the thumbnail is rectangular, don't stretch it
        
} else {
            
// if we aren't cropping then keep aspect ratio and contain image within the specified size
            
if($crop === false) {
                
$ratio_orig $width/$height;
                if (
$thumb_size_x/$thumb_size_y $ratio_orig) {
                   
$thumb_size_x $thumb_size_y*$ratio_orig;
                } else {
                   
$thumb_size_y $thumb_size_x/$ratio_orig;
                }
            }            
            
// if we are cropping...
            
if($crop === true) {
                
// Next 10 lines. Big thanks to: TimThumb script created by Tim McDaniels and Darren Hoyt with tweaks by Ben Gillbanks (http://code.google.com/p/timthumb/)
                // I would reccommend TimThumb to anyone (and myself if I didn't have this thing nearly complete).
                
$cmp_x $width  $thumb_size_x;
                
$cmp_y $height $thumb_size_y;
                
// calculate x or y coordinate and width or height of source
                
if ( $cmp_x $cmp_y ) {
                    
$width round( ( $width $cmp_x $cmp_y ) );
                    
$x round( ( $width - ( $width $cmp_x $cmp_y ) ) / );
                }
                elseif ( 
$cmp_y $cmp_x ) {
                    
$height round( ( $height $cmp_y $cmp_x ) );
                    
$y round( ( $height - ( $height $cmp_y $cmp_x ) ) / );
                }
                
/////        
            
}
        }
        
        switch(
$originalFile->ext()):
        case 
'png':
            
// Create PNG
            
if($thumbQuality != 0):
                
$thumbQuality ceil(($thumbQuality 10)); // 0-9 is the range for png    
            
endif;                
            
$new_im ImageCreatetruecolor($thumb_size_x,$thumb_size_y);
            
$im imagecreatefrompng($source);
            
imagecopyresampled($new_im,$im,0,0,$x,$y,$thumb_size_x,$thumb_size_y,$width,$height);    
                        
            
imagepng($new_im,$dest,$thumbQuality);
            
            
$outputPath = new File($dest);
            
$finalPath substr(strstr($outputPath->Folder->path.DS.$outputPath->name().'.'.$outputPath->ext(), 'webroot'), 7);
        break;
        
        case 
'gif':
            
// Create GIF        
            
$new_im ImageCreatetruecolor($thumb_size_x,$thumb_size_y);
            
$im imagecreatefromgif($source);
            
imagecopyresampled($new_im,$im,0,0,$x,$y,$thumb_size_x,$thumb_size_y,$width,$height);    
            
imagegif($new_im,$dest); // no quality setting
            
            
$outputPath = new File($dest);
            
$finalPath substr(strstr($outputPath->Folder->path.DS.$outputPath->name().'.'.$outputPath->ext(), 'webroot'), 7);
        break;
        
        case 
'jpg':
        case 
'jpeg':
        default:
            
// Create JPG        
            
$new_im ImageCreatetruecolor($thumb_size_x,$thumb_size_y);
            
$im imagecreatefromjpeg($source);
            
imagecopyresampled($new_im,$im,0,0,$x,$y,$thumb_size_x,$thumb_size_y,$width,$height);    
            
imagejpeg($new_im,$dest,$thumbQuality);
            
            
$outputPath = new File($dest);            
            
$finalPath substr(strstr($outputPath->Folder->path.DS.$outputPath->name().'.'.$outputPath->ext(), 'webroot'), 7);
            
// PHP 5.3.0 would allow for a true flag as the third argument in strstr()...
            // which would take out "webroot" so substr() wasn't required, but for the PHP 4 people...            
        
break;        
        endswitch;
            
        return 
$finalPath;        
    }

/**
* Deletes a single thumbnail or a directory of thumbnail versions created by the component.
* Useful during development, or when changing the crop flag or dimensions often to keep tidy.
* Maybe say a hypothetical CMS has an admin option for a user to change the thumbnail size of
* a profile photo...well, we might want to run this to clean out the old versions right?
* Or when a record was deleted containing an image that has a version...afterDelete()...
*    
* @param $source String[required] Location of a source image.
* @param $thumbSize Array[optional] Size of the thumbnail. Default: 75x75
* @param $clearAll Boolean[optional] Clear all the thumbnails in the same directory. Default: false

* @return
*/
    
function flushVersion($source=null$thumbSize=array(7575), $clearAll=false) {
        if((
is_null($source)) || (!is_string($source))): return false; endif;
        
$webroot = new Folder(WWW_ROOT);
            
// take off any beginning slashes (webroot has a trailing one)
            
if(substr($source01) == '/'):
                
$source substr($source1);
            endif;
                        
            
$pathToFile $webroot->path $source;
            
$file = new File($pathToFile);
                    
            
//debug($file->Folder->path.DS.$thumbSize[0].'x'.$thumbSize[1].DS.$file->name);
            // REMOVE THE FILE (doesn't matter if we remove the directory too later on)
            
if(file_exists($file->Folder->path.DS.$thumbSize[0].'x'.$thumbSize[1])):
                if(
unlink($file->Folder->path.DS.$thumbSize[0].'x'.$thumbSize[1].DS.$file->name)):
                    
//debug('The file was deleted.');    
                
else:
                    
//debug('The file could not be deleted.');
                
endif;
            endif;        
        
        
// IF SPECIFIED, REMOVE THE DIRECTORY AND ALL FILES IN IT
        
if($clearAll === true):
            if(
$webroot->delete($file->Folder->path.DS.$thumbSize[0].'x'.$thumbSize[1])):
                
//debug('All files in the folder: '.$file->Folder->path.DS.$thumbSize[0].'x'.$thumbSize[1].' have been deleted including the folder.');
            
else:
                
//debug('The folder: '.$file->Folder->path.DS.$thumbSize[0].'x'.$thumbSize[1].' and its files could not be deleted.');
            
endif;
        endif;    
        return;    
    }
    
/**
 * Pass a full path like /var/www/htdocs/app/webroot/files
 * Don't include trailing slash.
 * 
 * @param $path String[optional]
 * @return String Path.
 */
    
function createPath($path null) {
        
//$path = $this->webRoot . 'files' . DS . $path;
            
$directories explode('/'$path);
            
$root '';    
                
// looks to see if a slash was included in the path to begin with and if so it removes it
                
if($directories[0] == '') {
                        
array_shift($directories);
                }
            foreach(
$directories as $directory) {
                if(!
file_exists($root .DS$directory)) { 
                    
mkdir($root .DS$directory);    
                }
                    
$root $root .DS$directory;
            }
        
// put a trailing slash on
        
$root $root DS;
        return 
$root;
    }    
}
?>

...Then the optional helper...

/views/helpers/image_version.php

Download code
<?php
/**
 * Image Version Helper class to embed thumbnail images on a page.
 * 
 * @link            http://www.concepthue.com
 * @author            Tom Maiaroto
 * @modifiedby        Tom
 * @lastmodified    2008-10-04 16:11:00
 * @license            http://www.opensource.org/licenses/mit-license.php The MIT License
 */
class ImageVersionHelper extends AppHelper {

    var 
$helpers = array('Html');
    var 
$component;

    
/**
     * Returns a block of HTML code that embeds a thumbnail image into a page.
     * It uses the built in CakePHP HTML helper image method for additional options.
     *  
     * @param $image String[required] Location of the source image.
     * @param $size Array[optional] Size of the thumbnail. Default: 75x75
     * @param $thumbQuality Int[optional] Quality of the thumbnail. Default: 85%
     * @param $options Object[optional] An array of options, same as Html->image() helper.
     * 
     * @return HTML string including image tag and src attribute, along with any additional options.
     */
    
function version($image=null$size=array(7575), $thumbQuality=85$crop=false$options=array()) {
        
// remove a slash if one was added accidentally. it doesn't matter either way now.
        // we're always going from the webroot to cover any image in the cake app (typically).
        
if(substr($image01) == '/'): $image substr($image1); endif;
        
        
// init the component, if it hasn't been initialized    
        
if(!$this->component):
            
$this->component =& ClassRegistry::init('ImageVersionComponent''Component');
        endif;
        
        
$outputImage $this->component->version($image$size$thumbQuality$crop);

        
$link $this->Html->image($outputImage$options);        

        return 
$this->output("$link");    
    }
    
    
/**
    * Deletes a single version thumbnail and/or deletes the entire directory of versions.
    *
    * @param $source String[required] Location of the source image.
    * @param $size Array[optional] Image version.
    * @param $clearAll Boolean[optional] Specify whether or not to remove all versions in a folder.
    * @return
    */
    
function flushVersion($source=null$size=array(75,75), $clearAll=false) {
        if((
is_null($source)) || (!is_string($source))): return false; endif;        
        
// init the component, if it hasn't been initialized
        
if(!$this->component):
            
$this->component =& ClassRegistry::init('ImageVersionComponent''Component');
        endif;
        
$flush $this->component->flushVersion($source$size$clearAll);
        return;
    }
}
?>

That should do it. Just include the component in your controller and/or the helper. You can include the helper without including the component, but you MUST have the component in the components folder. The helper will init it. I didn't want to write the code out twice... I could have just made it a helper strictly, but I also wanted flexibility.

Example Component Usage

Download code
// Example controller method...don't forget to include the component.
var $components = array('ImageVersion');

function view() {
   $image = $this->Model->find('first');
   $clear = $this->ImageVersion->flushVersion($image['Model']['file'], array(65, 50), true);
   $this->set('thumbnail', $this->ImageVersion->version($image['Model']['file'], array(65, 50)));
}

// Example view template
<img src="<?php echo $image?>" />



Example Helper Usage

Download code
// In a view template...I have a Model with a file column that has a path to the source image.
// Of course be sure to put in your controller var $helpers = array('ImageVersion'); 
<?php 
$modelName 
Inflector::singularize($this->name); 
foreach (${
strtolower($this->name)} as $entry): 
        
// echo $entry[$modelName]['file']; 
    
echo $imageVersion->flushVersion($entry[$modelName]['file'], array(7575));    
    echo 
$imageVersion->version($entry[$modelName]['file'], array(7575), 90true);
endforeach;
?>

The component and helper are both well commented, but basically you're passing in the source image path (anything from the webroot of your application) and then an array with X and Y sizes, then optionally, quality and if you want to crop or not (true/false). In the example above I also call the flushVersion() method which actually deletes the files I'm creating each time. I would recommend making such a call during development, or on a link to have perhaps a control for a visitor to clear the images.

The resizing is smart. It will fit the image to the dimensions unless you specify true to crop, then it will go by the largest dimension and crop the shorter. If the image version desired is square, it will also resize and crop appropriately.

 

Comments 810

CakePHP Team Comments Author Comments
 

Question

1 Creating a transparent png thumbnail?

Great work for this components, but i found that thumbnail generated from a transparent png loss it transparent background and replace by black color.

Any fix?
Posted Nov 2, 2008 by Tan Jeong Shiun
 

Comment

2 Thanks! Will fix.

Great work for this components, but i found that thumbnail generated from a transparent png loss it transparent background and replace by black color.

Any fix?

Thanks for pointing that out...I'll figure it out and post a fix when I get a free moment. Should be under the " case 'png': " part somewhere, perhaps PHP had a simple setting. I know you can change the background color...but I don't immediately know if it can be transparent. I'm sure it can. More on this later...

edit: You can probably add something like the following to right above where the case for png creates the image.
imagealphablending($new_im, false);
imagesavealpha($new_im, true);
Posted Nov 6, 2008 by Tom Maiaroto
 

Comment

3 Note about GD and bad jpgs

Just a quick note about GD and PHP settings... I recently ran into a problem where there were some jpg images that weren't closed properly and there was some unexpected end of file type warnings when opening...BUT they did open in applications on my computer. They even displayed in the web browser. The problem was that GD saw the error and spit out blank black (the background color) image versions / thumbnails.

To solve this, I had to change a php.ini setting:
gd.jpeg_ignore_warning
Had to be set to 1.

For shared hosting I know this may be problematic, perhaps try ini_set() in the component somewhere.

Otherwise, you'll have to take all those "bad" jpg files and re-save them in some image editing program that doesn't make a mess of them.
Posted Nov 6, 2008 by Tom Maiaroto
 

Comment

4 About webroot

Just a little comment to point out a problem I had.

When returning path, you may have trouble if you have your webroot defined somewhere else, i.e. "public_html" (for CPanel in my case) instead of 'webroot':
 
$finalPath = substr(strstr($outputPath->Folder->path.DS.$outputPath->name().'.'.$outputPath->ext(), 'webroot'), 7); 

I corrected this using

 
$finalPath = substr(strstr($outputPath->Folder->path.DS.$outputPath->name().'.'.$outputPath->ext(), WEBROOT_URL), strlen(WEBROOT_URL)); 
where WEBROOT_URL is a custom defined var constant by me, in all ocurrences.

Anyway, great job, it works great!

cheers
Posted Nov 7, 2008 by Mariano
 

Comment

5 A small note...

Be sure that your memory setting in php.ini is high enough if you're making thumbnails from large images. A 2.6MB JPEG did not resize for me with a default 32M memory limit in php.ini on a server with 256MB RAM. I don't know exactly how much memory it takes or if having a server with more RAM changes things, but setting the memory limit to 64M in php.ini did the trick. Alternatively, you might want to limit the size of images being uploaded to your application for many other reasons as well.
Posted Mar 8, 2009 by Tom Maiaroto
 

Comment

6 Headers already sent

I get the error 'headers already sent' when I insert the component in $components. The error points to image_version.php:277 (end of file) and controller.php, line 640 (convience wrapper for header).

I use Cake 1.2.2.8120....
Posted May 16, 2009 by henrique
 

Comment

7 Case-sensitive file extensions

I noticed that images with upper-case file extensions wouldn't be processed. Therefore I changed the following line (80) in app/controllers/components/image_version.php:
switch($originalFile->ext()):
into this:
switch(strtolower($originalFile->ext())):
Posted Aug 3, 2009 by Joel Pettersson
 

Comment

8 Good call

I noticed that images with upper-case file extensions wouldn't be processed. Therefore I changed the following line (80) in app/controllers/components/image_version.php:
switch($originalFile->ext()):
into this:
switch(strtolower($originalFile->ext())):

Good call! Thanks! I just ran into that problem.
Posted Aug 3, 2009 by Tom Maiaroto
 

Comment

9 Does not work on Windows servers

Just an FYI for those who develop on a Windows platform. This component and helper will not work as-is. There are issues with the DS constant as well as Windows root paths ("C:\" instead of "/"). Since I develop on my
Windows PC and deploy to Linux, it's of enough benefit to me to rework these files to be more cross-platform compatible. I will re-post when finished but wanted to provide a heads-up for anyone stopping by here first.

UPDATE: I made some changes to the component file (left the helper alone) and posted the new file on my blog here: http://www.decapite.net/web-development/cakephp-image-version-component-update
Posted Nov 8, 2009 by Kevin DeCapite
 

Question

10 Help with createPath function

Hi, i'm having a problem with createPath function, this message is show "Warning (2): mkdir() [function.mkdir]: Invalid argument [APP\controllers\components\image_version.php, line 270]", how a can resolve this problem, my upload dir is "webroot/files/imagens/portfolios/100x100"
Posted Jan 18, 2010 by Matheus
 

Comment

11 Re: Help with createPath function

@Matheus

What is the argument that you pass to mkdir()? Also, if you downloaded my updated component file, the mkdir() function that's called in createPath() occurs on line 288. In my file, line 270 is a comment line. So if you've changed the code around, I'm not sure I'll be able to help much.
Posted Jan 18, 2010 by Kevin DeCapite
 

Comment

12 Re: Help with createPath function

@Kevin DeCapite

Hi, I update Image Version Component and worked. Thx for help
Posted Jan 18, 2010 by Matheus