I've written an image processing library for downscaling, sharpening, compressing and caching images using PHP and ImageMagick.
Having recently been a chef in the kitchen of procedural and vague spaghetti code, I would appreciate feedback on:
- Code quality and appropriateness
- Code clarity
- Whether the structure is suitably extensible
- Comments
The library is called AIR (Aloof Image Resizer), as it's an internal project at a company called Aloof.
It is made to be used on the fly, by requesting a large image through the path to the script. The following would display /images/some-big-picture.jpg
, resized to 100px wide, with a proportional height, sharpened and compressed:
<img src="/lib/air/w100/images/some-big-picture.jpg">
You can checkout the whole project at github, where there's also a formatted readme.
Air.php:
<?php
/* Aloof Image Resizer */
/* Requires ImageMagick */
require_once('config.php');
class Air {
protected $path_to_air;
protected $width = 0;
protected $height = 0;
protected $source_img;
protected $source_img_sys;
protected $sharpening;
protected $quality;
protected $imagick_instance;
protected $cache_fullpath;
protected $source_img_size;
public function __construct() {
if ( ! defined('__DIR__')) {
define('__DIR__', dirname(__FILE__));
}
$this->path_to_air = __DIR__;
$this->path_to_air_http = dirname($_SERVER['SCRIPT_NAME']);
if( ! AirConfig::$air_enabled) {
$this->set_options();
$this->validate_img();
$this->bypass();
}
if( ! class_exists('Imagick', false)) {
$this->error('Imagick class not available on this environment. Either install ImageMagick or set AirConfig::$air_enabled to false in config.php.');
}
$this->cache_fullpath = $this->path_to_air . '/' . AirConfig::$cache_location;
// Procedure
$this->set_options();
$this->validate_options();
$this->validate_img();
$this->check_bypass(); // may finish
$this->get_or_generate_image(); // will finish
}
/**
* Parses the URL to set variables.
*
* @return void
*/
protected function set_options() {
$options_url = $_GET['options'];
$options_parts = explode('-', $options_url);
$options = array();
foreach($options_parts as $key => $value) {
$options[$value[0]] = substr($value, 1);
}
if(isset($options['w'])) {
$this->width = (int)$options['w'];
}
if(isset($options['h'])) {
$this->height = (int)$options['h'];
}
$this->sharpening = AirConfig::$sharpening;
if(isset($options['s'])) {
$this->sharpening = (float)$options['s'];
}
$this->quality = AirConfig::$quality;
if(isset($options['q'])) {
// cap quality value at 100
$this->quality = min( (int)$options['q'] , 100 );
}
// set file path
if(isset($_GET['file'])) {
$this->source_img = '/' . $_GET['file'];
$this->source_img_sys = str_replace('//', '/', $_SERVER['DOCUMENT_ROOT'] . '/' . $_GET['file']);
}
}
/**
* Check that Air has enough options to work with - either width or height need to be specified as a minimun.
* If validation fails, the script will terminate with an error message.
*
* @return void
*/
protected function validate_options() {
if($this->width === 0 && $this->height === 0) {
$this->error('No size specified.');
}
}
/**
* Checks that Air can work with the requested image.
*
* @return void
*/
protected function validate_img() {
if(empty($this->source_img)) {
$this->error('No source file specified.');
}
if( ! file_exists($this->source_img_sys)) {
$this->error('Source file does not exist: ' . $this->source_img);
}
try {
$this->source_img_size = getimagesize($this->source_img_sys);
} catch(Exception $e) {
$this->error('Invalid source file. ' . $e->getMessage);
}
}
/**
* If no resizing is necessary, the source image will be outputted and the script will terminate.
*
* @return void
*/
protected function check_bypass() {
if(
($this->width > 0 && $this->source_img_size[0] <= $this->width) ||
($this->height > 0 && $this->source_img_size[1] <= $this->height)
) {
$this->bypass();
}
}
/**
* Output the source file without any processing.
*
* @return void
*/
protected function bypass() {
$this->output_image_file($this->source_img_sys, $this->source_img_size['mime']);
}
/**
* If this image has been cached before with the same options, output it from cache and terminate the script.
* If it doesn't exist in the cache, hand over to $this->generate.
*
* @return void
*/
protected function get_or_generate_image() {
if(file_exists($this->cache_fullpath . '/' . $this->get_cache_filename())) {
$this->output_from_cache($this->get_cache_filename());
} else {
$this->generate();
}
}
/**
* The main image processing method:
* - Resizes requested image to the requested dimensions,
* - Applies sharpening if $this->sharpening is not 0,
* - Saves the result in the cache,
* - Outputs the image
*
* @return void
*/
protected function generate() {
$this->imagick_instance = new Imagick($this->source_img_sys);
if($this->width || $this->height) {
$this->imagick_instance->scaleImage($this->width, $this->height);
}
if($this->sharpening) {
$this->imagick_instance->sharpenImage(0, $this->sharpening);
}
$image_format = $this->imagick_instance->getImageFormat();
if($image_format === 'JPG' || $image_format === 'JPEG') {
$this->imagick_instance->setImageCompression(Imagick::COMPRESSION_JPEG);
$this->imagick_instance->setImageCompressionQuality($this->quality);
}
$this->cache_img();
$this->output_from_imagick();
}
/**
* Image names in the cache are an md5 hash of the source path and requested settings.
*
* @return string The cache filename for this request.
*/
protected function get_cache_filename() {
return md5($this->source_img . '-' . $this->width . '-' . $this->height . '-' . $this->sharpening . '-' . $this->quality);
}
/**
* Writes result of ImageMagick conversion to the cache.
*
* @return void
*/
protected function cache_img() {
if( ! is_dir($this->cache_fullpath)) {
mkdir($this->cache_fullpath, 0755);
}
$this->imagick_instance->writeImage($this->cache_fullpath . '/' . $this->get_cache_filename());
}
/**
* Sets appropriate mime-type header, outputs result of ImageMagick conversion to the browser, and terminates the script.
*
* @return void
*/
protected function output_from_imagick() {
header('Content-Type: image/' . $this->imagick_instance->getImageFormat());
echo $this->imagick_instance;
die();
}
/**
* Gets path and mime type for a cached file, and hands over to $this->output_image_file, where the script will be terminated.
*
* @param string $filename The filename of a file in the cache to output. Typically, this is the result of $this->get_cache_filename().
* @return void
*/
protected function output_from_cache($filename) {
$cache_filepath = $this->cache_fullpath . '/' . $filename;
$image_data = getimagesize($cache_filepath);
$this->output_image_file($cache_filepath, $image_data['mime']);
}
/**
* Outputs an image from a file and terminates the script.
*
* @param string $path The full system path to the file.
* @param string $mime_type The mime type header for the file, e.g. 'image/jpg'
* @return void
*/
protected function output_image_file($path, $mime_type) {
header('Content-Type: ' . $mime_type);
echo readfile($path);
die();
}
/**
* Logs an error message to error.log, outputs the error image (depending on configuration) and terminates the script.
*
* @param string $message The error message.
* @return void
*/
protected function error($message = 'Unknown error') {
error_log( date('Y-m-d h:i:s ') . '(' . $_GET['file'] . ') ' . $message . PHP_EOL , 3, 'error.log');
if(AirConfig::$display_errors) {
header("Cache-Control: no-cache, must-revalidate");
header("Expires: Sat, 10 Aug 1989 23:30:00 GMT");
header('Location: ' . $this->path_to_air_http . '/error.gif');
exit;
}
}
}
?>
defaults.php:
<?php
class AirDefaults {
// Do not edit this file to modify default values - override these default settings in config.php
// If set to false, raw source files will be output without any processing.
public static $air_enabled = true;
// Relative to the air directory
public static $cache_location = 'cache';
// output error.gif to highlight errornous requests?
public static $display_errors = true;
// sharpening amount (sigma). Radius is calculated automatically.
public static $sharpening = 0.6;
// jpeg compression quality: 1-100.
public static $quality = 85;
}
?>
config.php:
<?php
require_once('defaults.php');
class AirConfig extends AirDefaults {
// paste and edit lines from defaults.php here to override settings
}
?>
index.php:
<?php
require_once('Air.php');
require_once('config.php');
$air = new Air();
?>
.htaccess:
# Prevent other scripts from interfering with AIR
php_value auto_prepend_file none
php_value auto_append_file none
# Pretty URLs
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ([0-9a-z\-\.]+)/(.*) index.php?options=$1&file=$2 [L]
</IfModule>