On the fly image resizing in Lithium

Recently I’ve been playing around with the very promising PHP framework Lithium. In fact we decided to use it at work for some project as well. Documentation is a bit scarce and disorganized, and there aren’t a whole lot of content on the web about it, so I figured i might as well share some of my discoveries.

This is not a full tutorial, I am building on the source for Lithiums creator Nate Abeles photoblog exampleand just exending it to handle on the fly resizing.
Note: ImageMagick is required!

The reason ImageMagick is used and not GD is because of several reasons:

  • GD requires you to unpack and read the entire image into memory in the PHP process. For big images this will be a memory hog. (can be several gigabytes for 10-20M jpgs)
  • GD has an awkward format where ImageMagick is a more straight forward readable one liner
  • ImageMagick scales better in terms of file type support. (Supports more than 100 major image formats)

The only reason to use GD in my view is if you are on shared hosting and absolutely can not get ImageMagick available.

To the code

Open app/config/routes.php and you find this route handling the image requests and find this code somewhere in it:

<?php
Router::connect('/photos/view/{:id:[0-9a-f]{24}}.jpg', array(), function($request) {
	return new Response(array(
		'headers' => array('Content-type' => 'image/jpeg'),
		'body' => Photo::first($request->id)->file->getBytes()
	));
});

We will change this so we achieve the following effect:

  • Map images to /image/{id}.{type}
  • Cache images to files even original is in MongoDB
  • The previous means copying /image/4cc9953fb6ada15424050000.jpg to webroot/image/4cc9953fb6ada15424050000.jpg
  • Allow resizing by url mapping /image/{id}_{width}x{height}.{type} which will create the file on the file system and serve it

First we will modify the route quite a bit, follow the inline comments in this gist:

<?php
/**
Define an anonymous function that we will pass to the router instead of linking to a controller action
The logic is quite simple:
Call the version() method on the Photo model class with $request->id (MongoId for image) and a set of options. This is passed as an array to allow adding more options later.
Finally just return a Response object with the image data as body (this is what version() returns) and the appropriate content type for the file ending.
This method is limited, supports few formats etc but its a good start
*/
$imageSizer = function($request) {
    $contentTypeMappings = array(
        'jpg' => 'image/jpeg',
        'jpeg' => 'image/jpeg',
        'png' => 'image/png',
        'gif' => 'image/gif'
    );
    // Generate file based image of this
    $imageBody = Photo::version($request->id, array(
        'width' => $request->width,
        'height' => $request->height
    ));
    return new Response(array(
        'headers' => array('Content-type' => $contentTypeMappings[$request->type]),
        'body' => $imageBody
    ));
};
/**
This is a little bit more complicated.
We state that the handler for when this route is matched is the anonymous function we've declared and
we set up a pattern to match our two cases of image urls — both with and without size information.
The regex is quite simple even if it looks complex:
^/image/ <- begin with /image/
(?P<foo>) is for setting up a named capture group. This equals doing {:foo:{pattern}} in Lithium.
So we have 1 capture group named {id} that have to match by 24 signs (mongoid), and an optional part "_{width}x{height}" and finally the filtype.
Im unsure if the keys array can be handled some other way, but it failed for me without it.
*/
$imageHandlingOptions = array(
    'handler' => $imageSizer,
    'pattern' => '@^/image/(?P<id>[0-9a-f]{24})(_(?P<width>[0-9]*)x(?P<height>[0-9]*)?)\.(?<type>[a-z]{2,4})@',
    'keys' => array('id'=>'id', 'width'=>'width', 'height'=>'height', 'type'=>'type')
);
/**
Finally we connect this as a route. The regex sent as the first param here is overriden by the more complex one we have defined in the options array.
*/
Router::connect('/image/{:id:[0-9a-f]{24}}.jpg', array(), $imageHandlingOptions)

Now you have some code that matches image urls and fires up a closure that tries to get a version of the image from the Photo model. But we don’t have that method yet, so lets create it!

<?php
class Photo extends \lithium\data\Model {
    /** 
     * Generate a cached version under webroot
     * @param string $id The image id as in mongodb
     * @param array $options Possible values are
     *   width
     *   height
     * @return mixed
     */
    public static function version($id, $options = array()) {
        if (!$id)
            return false;
        // This is the same as Photo::first($id) when called from inside itself
        $self = static::first($id);
        return ($self)
            ? $self->generateVersion($options)
            : false;
    }
    /**
     * Generate a cached version under webroot
     * @param Document $self The document from the db
     * @param array $options Possible values are
     * @return mixed
     */
    public function generateVersion($self, $options = array()) {
        // This is quite naive, it would fail at .jpeg for example. Be more elaborate on production code!
        $type = substr($self->file->file['filename'], -3);
        $path = LITHIUM_APP_PATH . "/webroot/image/{$self->_id}";
        $originalPath = $path . "." . $type;
        // Always create the original variant if it doesnt exist yet. It is needed for resize
        if (!file_exists($originalPath))
            file_put_contents($originalPath, $self->file->getBytes());
        
        if (isset($options['width']) && isset($options['height'])) {
            $width = (int) $options['width'];
            $height = (int) $options['height'];
            
            $path .= "_{$width}x{$height}.{$type}";
    
            // This requires imagemagick and access to system calls. 
            // It is possible to use gd but it is a much worse alternative so please dont.
            $convertCommand = "convert $originalPath -resize {$width}x{$height}\> $path";
            shell_exec($convertCommand);
            // Return data of the resized version
            return file_get_contents($path);
        }
        // If no width/height were set, just return data of the original
        return $self->file->getBytes();
    }
}

With that code hooked into your model you should be up and running. Try uploading an image and accessing it using the correct pattern. Simply build an image tag like this in your views: <img src=”/image/4cc9953fb6ada15424050000_300x300.jpg” alt=”My image” />

Now this code is not perfect, it lacks quite a lot of error detection and file type support, but combined with the photo blog setup its a robust start to an image handling system. Also, there are other strategies for solving this, and the first that came to mind was to filter access to image file endings registered in the Media class and handle it on the fly at render time. The reason i chose this method instead of that is that while on the fly resizing is great, there are many situations where you know all the way that you need a set of fixed sizes. In such scenarios its probably better to be able to call the version() method from other places.