The Zend Framework includes a class called Zend_Form_Element_File for creating a file upload within a form. It uses Zend_File_Transfer for receiving the file, but the whole process lacks some methods for the often required image upload.

What is easily possible

Let’s begin with the things that are possible. You can specify several validators for your file to check for file extensions or maximum file size. That’s stuff which is required for all files and therefore it is included. You may also specify a target directory.

<?php
// part of my form class (Default_Form_Photo::init)
$photo = new Zend_Form_Element_File('photo');
$photo->setLabel('Photo')
      ->setDestination(Zend_Registry::get('config')->paths->backend->images->profile);
// ensure only one file
$photo->addValidator('Count', false, 1);
// max 2MB
$photo->addValidator('Size', false, 2097152)
      ->setMaxFileSize(2097152);
// only JPEG, PNG, or GIF
$photo->addValidator('Extension', false, 'jpg,png,gif');
$photo->setValueDisabled(true);
$this->addElement($photo, 'photo');

Renaming a file according to your needs

Renaming a file according to your needs is also possible, even though (often) not as easily as the other stuff. You need to add a filter after initialising, because you usually do not know the filename at runtime. At least when you upload a profile picture you often want to give it a name like the username or the user’s id.

Therefore, you have to add the Rename-filter in your controller when a file is uploaded

<?php
// part of my controller after an upload (Default_UserController)
if ($photo->getElement('photo')->isUploaded()) {
    $extension = pathinfo($photo->getElement('photo')->getValue(), PATHINFO_EXTENSION); 
  
    $photo->getElement('photo')->addFilter('Rename', array(
        'target' => $user->getUsername() . '.' . $extension,
        'overwrite' => true
    ));
  
    if ($photo->getElement('photo')->receive()) {
        $profile = $user->getProfile();
        $profile->setPicture($photo->getElement('photo')->getValue());
        $profile->save();
    }
}

This filter will rename the upload (only one file is allowed) according to the rule in target.

Resizing an image

The difficult part with Zend is resizing the image. Of course you can do this in your controller after you received the upload, but this is not very nice style. As Zend supports filters, we better program a new filter for this task. I called it Skoch_Filter_File_Resize:

<?php
/**
 * @see Zend_Filter_Interface
 */
require_once 'Zend/Filter/Interface.php';

/**
 * Resizes a given file and saves the created file
 *
 * @category   Skoch
 * @package    Skoch_Filter
 */
class Skoch_Filter_File_Resize implements Zend_Filter_Interface
{
    protected $_width = null;
    protected $_height = null;
    protected $_keepRatio = true;
    protected $_keepSmaller = true;
    protected $_directory = null;
    protected $_adapter = 'Skoch_Filter_File_Resize_Adapter_Gd';
    
    /**
     * Create a new resize filter with the given options
     *
     * @param Zend_Config|array $options Some options. You may specify: width, 
     * height, keepRatio, keepSmaller (do not resize image if it is smaller than
     * expected), directory (save thumbnail to another directory),
     * adapter (the name or an instance of the desired adapter)
     * @return Skoch_Filter_File_Resize An instance of this filter
     */
    public function __construct($options = array())
    {
        if ($options instanceof Zend_Config) {
            $options = $options->toArray();
        } elseif (!is_array($options)) {
            require_once 'Zend/Filter/Exception.php';
            throw new Zend_Filter_Exception('Invalid options argument provided to filter');
        }
        
        if (!isset($options['width']) && !isset($options['height'])) {
            require_once 'Zend/Filter/Exception.php';
            throw new Zend_Filter_Exception('At least one of width or height must be defined');
        }
        
        if (isset($options['width'])) {
            $this->_width = $options['width'];
        }
        if (isset($options['height'])) {
            $this->_height = $options['height'];
        }
        if (isset($options['keepRatio'])) {
            $this->_keepRatio = $options['keepRatio'];
        }
        if (isset($options['keepSmaller'])) {
            $this->_keepSmaller = $options['keepSmaller'];
        }
        if (isset($options['directory'])) {
            $this->_directory = $options['directory'];
        }
        if (isset($options['adapter'])) {
            if ($options['adapter'] instanceof Skoch_Filter_File_Resize_Adapter_Abstract) {
                $this->_adapter = $options['adapter'];
            } else {
                $name = $options['adapter'];
                if (substr($name, 0, 33) != 'Skoch_Filter_File_Resize_Adapter_') {
                    $name = 'Skoch_Filter_File_Resize_Adapter_' . ucfirst(strtolower($name));
                }
                $this->_adapter = $name;
            }
        }
        
        $this->_prepareAdapter();
    }
    
    /**
     * Instantiate the adapter if it is not already an instance
     *
     * @return void
     */
    protected function _prepareAdapter()
    {
        if ($this->_adapter instanceof Skoch_Filter_File_Resize_Adapter_Abstract) {
            return;
        } else {
            $this->_adapter = new $this->_adapter();
        }
    }
    
    /**
     * Defined by Zend_Filter_Interface
     *
     * Resizes the file $value according to the defined settings
     *
     * @param  string $value Full path of file to change
     * @return string The filename which has been set, or false when there were errors
     */
    public function filter($value)
    {
        if ($this->_directory) {
            $target = $this->_directory . '/' . basename($value);
        } else {
            $target = $value;
        }
        
        return $this->_adapter->resize($this->_width, $this->_height,
            $this->_keepRatio, $value, $target, $this->_keepSmaller);
    }
}

Adapter classes

As you might see this file also requires an adapter to ensure you can use the filter with both GD and Imagick. Thus, we need an abstract class and the implementation classes:

<?php
/**
 * Resizes a given file and saves the created file
 *
 * @category   Skoch
 * @package    Skoch_Filter
 */
abstract class Skoch_Filter_File_Resize_Adapter_Abstract
{
    abstract public function resize($width, $height, $keepRatio, $file, $target, $keepSmaller = true);
    
    protected function _calculateWidth($oldWidth, $oldHeight, $width, $height)
    {
        // now we need the resize factor
        // use the bigger one of both and apply them on both
        $factor = max(($oldWidth/$width), ($oldHeight/$height));
        return array($oldWidth/$factor, $oldHeight/$factor);
    }
}

gd implementation

<?php
require_once 'Skoch/Filter/File/Resize/Adapter/Abstract.php';

/**
 * Resizes a given file with the gd adapter and saves the created file
 *
 * @category   Skoch
 * @package    Skoch_Filter
 */
class Skoch_Filter_File_Resize_Adapter_Gd extends
    Skoch_Filter_File_Resize_Adapter_Abstract
{
    public function resize($width, $height, $keepRatio, $file, $target, $keepSmaller = true)
    {
        list($oldWidth, $oldHeight, $type) = getimagesize($file);
        
        switch ($type) {
            case IMAGETYPE_PNG:
                $source = imagecreatefrompng($file);
                break;
            case IMAGETYPE_JPEG:
                $source = imagecreatefromjpeg($file);
                break;
            case IMAGETYPE_GIF:
                $source = imagecreatefromgif($file);
                break;
        }
        
        if (!$keepSmaller || $oldWidth > $width || $oldHeight > $height) {
            if ($keepRatio) {
                list($width, $height) = $this->_calculateWidth($oldWidth, $oldHeight, $width, $height);
            }
        } else {
            $width = $oldWidth;
            $height = $oldHeight;
        }
        
        $thumb = imagecreatetruecolor($width, $height);
        
        imagealphablending($thumb, false);
        imagesavealpha($thumb, true);
        
        imagecopyresampled($thumb, $source, 0, 0, 0, 0, $width, $height, $oldWidth, $oldHeight);
        
        switch ($type) {
            case IMAGETYPE_PNG:
                imagepng($thumb, $target);
                break;
            case IMAGETYPE_JPEG:
                imagejpeg($thumb, $target);
                break;
            case IMAGETYPE_GIF:
                imagegif($thumb, $target);
                break;
        }
        return $target;
    }
}

Using the filter

This filter can now be attached to your Zend_Form_Element_File instance and will then resize the image to produce a thumbnail:

<?php
$photo->addFilter(new Skoch_Filter_File_Resize(array(
    'width' => 200,
    'height' => 300,
    'keepRatio' => true,
)));

You may specify several options invoking the filter. As you see in my code, I used with, height and keepRatio resulting in two maximum sizes. The image will then be resized so that it fits both of the lengths, but the aspect ratio will be kept. The whole list of options:

  • width: The maximum width of the resized image
  • height: The maximum height of the resized image
  • keepRatio: Keep the aspect ratio and do not resize to both width and height (usually expected)
  • keepSmaller: Do not resize if the image is already smaller than the given sizes
  • directory: Set a directory to store the thumbnail in. If nothing is given, the normal image will be overwritten. This will usually be used when you produce thumbnails in different sizes.
  • adapter: The adapter to use for resizing. You may specify a string or an instance of an adapter.

Now it’s easily possible to resize an uploaded image. To automatically load the classes, you need to add an option to your application.ini.

<?php
autoloaderNamespaces[] = "Skoch_"

Multiple thumbnails

Often you want to create several thumbnails in different sizes. This can be done by using a so called filter chain and the directory option of the Skoch_Filter_File_Resize.

If you specify directory, the value of setDestination() will not be considered anymore. Thus, you have to pass the full path to the directory option.

<?php
$filterChain = new Zend_Filter();
// Create one big image with at most 600x300 pixel
$filterChain->appendFilter(new Skoch_Filter_File_Resize(array(
    'width' => 600,
    'height' => 300,
    'keepRatio' => true,
)));
// Create a medium image with at most 500x200 pixels
$filterChain->appendFilter(new Skoch_Filter_File_Resize(array(
    'directory' => '/var/www/skoch/upload/medium',
    'width' => 500,
    'height' => 200,
    'keepRatio' => true,
)));
// Rename the file, of course this should not be a fixed string in real applications
$multiResize->addFilter('Rename', 'users_upload');
// Add the filter chain with both resize rules
$multiResize->addFilter($filterChain);

Download

You can download the library and a tiny example from my github repository.

Caveats

If you want to use the directory option together with renaming, make sure to add the Resize-filter after the Rename-filter to ensure that Resize gets the new filename and will save the thumbnail with the new filename. Otherwise you might get this structure:

/img/gallery/stefan/thumbs/Spain_1000.png
/img/gallery/stefan/1234.png

Where you probably do not want to have the filename Spain_1000.png on your server ;) So don’t forget to add Resize after Rename.