root/fImage.php

Revision 758, 37.1 kB (checked in by wbond, 1 week ago)

Fixed ticket #397 - fFile, fImage and fDirectory all handle re-used filenames better and produce more usable exceptions when trying to perform actions on objects that have been deleted

InternalBackwardsCompatibilityBreak - fFilesystem::hookExceptionMap() was changed to fFilesystem::hookDeletedMap() and fFilesystem::updateExceptionMap() was changed to fFilesystem::updateDeletedMap()

LineHide Line Numbers
1 <?php
2 /**
3  * Represents an image on the filesystem, also provides image manipulation functionality
4  *
5  * @copyright  Copyright (c) 2007-2010 Will Bond, others
6  * @author     Will Bond [wb] <will@flourishlib.com>
7  * @license    http://flourishlib.com/license
8  *
9  * @package    Flourish
10  * @link       http://flourishlib.com/fImage
11  *
12  * @version    1.0.0b19
13  * @changes    1.0.0b19  Updated for the new fFile API [wb-imarc, 2010-03-05]
14  * @changes    1.0.0b18  Fixed a bug in ::saveChanges() that would incorrectly cause new filenames to be created, added the $overwrite parameter to ::saveChanges(), added the $allow_upsizing parameter to ::resize() [wb, 2010-03-03]
15  * @changes    1.0.0b17  Fixed a couple of bug with using ImageMagick on Windows and BSD machines [wb, 2010-03-02]
16  * @changes    1.0.0b16  Fixed some bugs with GD not properly handling transparent backgrounds and desaturation of .gif files [wb, 2009-10-27]
17  * @changes    1.0.0b15  Added ::getDimensions() [wb, 2009-08-07]
18  * @changes    1.0.0b14  Performance updates for checking image type and compatiblity [wb, 2009-07-31]
19  * @changes    1.0.0b13  Updated class to work even if the file extension is wrong or not present, ::saveChanges() detects files that aren't writable [wb, 2009-07-29]
20  * @changes    1.0.0b12  Fixed a bug where calling ::saveChanges() after unserializing would throw an exception related to the image processor [wb, 2009-05-27]
21  * @changes    1.0.0b11  Added a ::crop() method [wb, 2009-05-27]
22  * @changes    1.0.0b10  Fixed a bug with GD not saving changes to files ending in .jpeg [wb, 2009-03-18]
23  * @changes    1.0.0b9   Changed ::processWithGD() to explicitly free the image resource [wb, 2009-03-18]
24  * @changes    1.0.0b8   Updated for new fCore API [wb, 2009-02-16]
25  * @changes    1.0.0b7   Changed @ error suppression operator to `error_reporting()` calls [wb, 2009-01-26]
26  * @changes    1.0.0b6   Fixed ::cropToRatio() and ::resize() to always return the object even if nothing is to be done [wb, 2009-01-05]
27  * @changes    1.0.0b5   Added check to see if exec() is disabled, which causes ImageMagick to not work [wb, 2009-01-03]
28  * @changes    1.0.0b4   Fixed ::saveChanges() to not delete the image if no changes have been made [wb, 2008-12-18]
29  * @changes    1.0.0b3   Fixed a bug with $jpeg_quality in ::saveChanges() from 1.0.0b2 [wb, 2008-12-16]
30  * @changes    1.0.0b2   Changed some int casts to round() to fix ::resize() dimension issues [wb, 2008-12-11]
31  * @changes    1.0.0b    The initial implementation [wb, 2007-12-19]
32  */
33 class fImage extends fFile
34 {
35     // The following constants allow for nice looking callbacks to static methods
36     const create                  = 'fImage::create';
37     const getCompatibleMimetypes  = 'fImage::getCompatibleMimetypes';
38     const isImageCompatible       = 'fImage::isImageCompatible';
39     const reset                   = 'fImage::reset';
40     const setImageMagickDirectory = 'fImage::setImageMagickDirectory';
41     const setImageMagickTempDir   = 'fImage::setImageMagickTempDir';
42    
43    
44     /**
45     * If we are using the ImageMagick processor, this stores the path to the binaries
46     *
47     * @var string
48     */
49     static private $imagemagick_dir = NULL;
50    
51     /**
52     * A custom tmp path to use for ImageMagick
53     *
54     * @var string
55     */
56     static private $imagemagick_temp_dir = NULL;
57    
58     /**
59     * The processor to use for the image manipulation
60     *
61     * @var string
62     */
63     static private $processor = NULL;
64    
65    
66     /**
67     * Checks to make sure we can get to and execute the ImageMagick convert binary
68     *
69     * @param  string $path  The path to ImageMagick on the filesystem
70     * @return void
71     */
72     static private function checkImageMagickBinary($path)
73     {
74         // Make sure we can execute the convert binary
75         if (self::isSafeModeExecDirRestricted($path)) {
76             throw new fEnvironmentException(
77                 'Safe mode is turned on and the ImageMagick convert binary is not in the directory defined by the safe_mode_exec_dir ini setting or safe_mode_exec_dir is not set - safe_mode_exec_dir is currently %s.',
78                 ini_get('safe_mode_exec_dir')
79             );
80         }
81        
82         if (self::isOpenBaseDirRestricted($path)) {
83             exec($path . 'convert -version', $executable);
84         } else {
85             $executable = is_executable($path . (fCore::checkOS('windows') ? 'convert.exe' : 'convert'));
86         }
87        
88         if (!$executable) {
89             throw new fEnvironmentException(
90                 'The ImageMagick convert binary located in the directory %s does not exist or is not executable',
91                 $path
92             );
93         }
94     }
95    
96    
97     /**
98     * Creates an image on the filesystem and returns an object representing it
99     *
100     * This operation will be reverted by a filesystem transaction being rolled
101     * back.
102     *
103     * @throws fValidationException  When no image was specified or when the image already exists
104     *
105     * @param  string $file_path  The path to the new image
106     * @param  string $contents   The contents to write to the image
107     * @return fImage
108     */
109     static public function create($file_path, $contents)
110     {
111         if (empty($file_path)) {
112             throw new fValidationException('No filename was specified');
113         }
114        
115         if (file_exists($file_path)) {
116             throw new fValidationException(
117                 'The image specified, %s, already exists',
118                 $file_path
119             );
120         }
121        
122         $directory = fFilesystem::getPathInfo($file_path, 'dirname');
123         if (!is_writable($directory)) {
124             throw new fEnvironmentException(
125                 'The file path specified, %s, is inside of a directory that is not writable',
126                 $file_path
127             );
128         }
129        
130         file_put_contents($file_path, $contents);
131        
132         $image = new fImage($file_path);
133        
134         fFilesystem::recordCreate($image);
135        
136         return $image;
137     }
138    
139    
140     /**
141     * Determines what processor to use for image manipulation
142     *
143     * @return void
144     */
145     static private function determineProcessor()
146     {
147         // Determine what processor to use
148         if (self::$processor === NULL) {
149            
150             // Look for imagemagick first since it can handle more than GD
151             try {
152                
153                 // If exec is disabled we can't use imagemagick
154                 if (in_array('exec', explode(',', ini_get('disable_functions')))) {
155                     throw new Exception();   
156                 }
157                
158                 if (fCore::checkOS('windows')) {
159                    
160                         $win_search = 'dir /B "C:\Program Files\ImageMagick*" 2> NUL';
161                         exec($win_search, $win_output);
162                         $win_output = trim(join("\n", $win_output));
163                          
164                         if (!$win_output || stripos($win_output, 'File not found') !== FALSE) {
165                             throw new Exception();
166                         }
167                          
168                         $path = 'C:\\Program Files\\' . $win_output . '\\';
169                        
170                 } elseif (fCore::checkOS('linux', 'bsd', 'solaris', 'osx')) {
171                    
172                     $found = FALSE;
173                    
174                     if (fCore::checkOS('solaris')) {
175                         $locations = array(
176                             '/opt/local/bin/',
177                             '/opt/bin/',
178                             '/opt/csw/bin/'
179                         );
180                        
181                     } else {
182                         $locations = array(
183                             '/usr/local/bin/',
184                             '/usr/bin/'
185                         );
186                     }
187                    
188                     foreach($locations as $location) {
189                         if (self::isSafeModeExecDirRestricted($location)) {
190                             continue;
191                         }
192                         if (self::isOpenBaseDirRestricted($location)) {
193                             exec($location . 'convert -version', $output);
194                             if ($output) {
195                                 $found = TRUE;
196                                 $path  = $location;
197                                 break;
198                             }
199                         } elseif (is_executable($location . 'convert')) {
200                             $found = TRUE;
201                             $path  = $location;
202                             break;
203                         }
204                     }
205                    
206                     // We have no fallback in solaris
207                     if (!$found && fCore::checkOS('solaris')) {
208                         throw new Exception();
209                     }
210                    
211                     // On linux and bsd can try whereis
212                     if (!$found && fCore::checkOS('linux', 'freebsd')) {
213                         $nix_search = 'whereis -b convert';
214                         exec($nix_search, $nix_output);
215                         $nix_output = trim(str_replace('convert:', '', join("\n", $nix_output)));
216                        
217                         if (!$nix_output) {
218                             throw new Exception();
219                         }
220                    
221                         $path = preg_replace('#^(.*)convert$#i', '\1', $nix_output);
222                     }
223                    
224                     // OSX has a different whereis command
225                     if (!$found && fCore::checkOS('osx', 'netbsd', 'openbsd')) {
226                         $osx_search = 'whereis convert';
227                         exec($osx_search, $osx_output);
228                         $osx_output = trim(join("\n", $osx_output));
229                        
230                         if (!$osx_output) {
231                             throw new Exception();
232                         }
233                    
234                         if (preg_match('#^(.*)convert#i', $osx_output, $matches)) {
235                             $path = $matches[1];
236                         }
237                     }
238                    
239                 } else {
240                     $path = NULL;
241                 }
242                
243                 self::checkImageMagickBinary($path);
244                
245                 self::$imagemagick_dir = $path;
246                 self::$processor = 'imagemagick';
247                
248             } catch (Exception $e) {
249                
250                 // Look for GD last since it does not support tiff files
251                 if (function_exists('gd_info')) {
252                    
253                     self::$processor = 'gd';
254                
255                 } else {
256                     self::$processor = 'none';
257                 }
258             }
259         }
260     }
261    
262    
263     /**
264     * Returns an array of acceptable mime types for the processor that was detected
265     *
266     * @internal
267      *
268     * @return array  The mime types that the detected image processor can manipulate
269     */
270     static public function getCompatibleMimetypes()
271     {
272         self::determineProcessor();
273        
274         $mimetypes = array('image/gif', 'image/jpeg', 'image/pjpeg', 'image/png');
275        
276         if (self::$processor == 'imagemagick') {
277             $mimetypes[] = 'image/tiff';
278         }
279        
280         return $mimetypes;
281     }
282    
283    
284     /**
285     * Gets the dimensions and type of an image stored on the filesystem
286     *
287     * The `'type'` key will have one of the following values:
288     *
289     *  - `{null}` (File type is not supported)
290     *  - `'jpg'`
291     *  - `'gif'`
292     *  - `'png'`
293     *  - `'tif'`
294     *
295     * @throws fValidationException  When the file specified is not an image
296     *
297     * @param  string $image_path  The path to the image to get stats for
298     * @param  string $element     The element to retrieve: `'type'`, `'width'`, `'height'`
299     * @return mixed  An associative array: `'type' => {mixed}, 'width' => {integer}, 'height' => {integer}`, or the element specified
300     */
301     static protected function getInfo($image_path, $element=NULL)
302     {
303         $extension = strtolower(fFilesystem::getPathInfo($image_path, 'extension'));
304         if (!in_array($extension, array('jpg', 'jpeg', 'png', 'gif', 'tif', 'tiff'))) {
305             $type = self::getImageType($image_path);
306             if ($type === NULL) {
307                 throw new fValidationException(
308                     'The file specified, %s, does not appear to be an image',
309                     $image_path
310                 );
311             }   
312         }
313        
314         $old_level  = error_reporting(error_reporting() & ~E_WARNING);
315         $image_info = getimagesize($image_path);
316         error_reporting($old_level);
317        
318         if ($image_info == FALSE) {
319             throw new fValidationException(
320                 'The file specified, %s, is not an image',
321                 $image_path
322             );
323         }
324        
325         $valid_elements = array('type', 'width', 'height');
326         if ($element !== NULL && !in_array($element, $valid_elements)) {
327             throw new fProgrammerException(
328                 'The element specified, %1$s, is invalid. Must be one of: %2$s.',
329                 $element,
330                 join(', ', $valid_elements)
331             );
332         }
333        
334         $types = array(IMAGETYPE_GIF     => 'gif',
335                        IMAGETYPE_JPEG    => 'jpg',
336                        IMAGETYPE_PNG     => 'png',
337                        IMAGETYPE_TIFF_II => 'tif',
338                        IMAGETYPE_TIFF_MM => 'tif');
339        
340         $output           = array();
341         $output['width']  = $image_info[0];
342         $output['height'] = $image_info[1];
343         if (isset($types[$image_info[2]])) {
344             $output['type'] = $types[$image_info[2]];
345         } else {
346             $output['type'] = NULL;
347         }
348        
349         if ($element !== NULL) {
350             return $output[$element];
351         }
352        
353         return $output;
354     }
355    
356    
357     /**
358     * Gets the image type from a file by looking at the file contents
359     *
360     * @param  string $image  The image path to get the type for
361     * @return string|NULL  The type of the image - `'jpg'`, `'gif'`, `'png'` or `'tif'` - NULL if not one of those 
362     */
363     static private function getImageType($image)
364     {
365         $handle   = fopen($image, 'r');
366         $contents = fread($handle, 12);
367         fclose($handle);
368        
369         $_0_8 = substr($contents, 0, 8);
370         $_0_4 = substr($contents, 0, 4);
371         $_6_4 = substr($contents, 6, 4);
372        
373         if ($_0_4 == "MM\x00\x2A" || $_0_4 == "II\x2A\x00") {
374             return 'tif';   
375         }
376        
377         if ($_0_8 == "\x89PNG\x0D\x0A\x1A\x0A") {
378             return 'png';   
379         }
380        
381         if ($_0_4 == 'GIF8') {
382             return 'gif';   
383         }
384        
385         if ($_6_4 == 'JFIF' || $_6_4 == 'Exif') {
386             return 'jpg';   
387         }
388        
389         return NULL;   
390     }
391    
392    
393     /**
394     * Checks to make sure the class can handle the image file specified
395     *
396     * @internal
397      *
398     * @throws fValidationException  When the image specified does not exist
399     *
400     * @param  string $image  The image to check for incompatibility
401     * @return boolean  If the image is compatible with the detected image processor
402     */
403     static public function isImageCompatible($image)
404     {
405         self::determineProcessor();
406        
407         if (!file_exists($image)) {
408             throw new fValidationException(
409                 'The image specified, %s, does not exist',
410                 $image
411             );
412         }
413        
414         $type = self::getImageType($image);
415    
416         if ($type === NULL || ($type == 'tif' && self::$processor == 'gd')) {
417             return FALSE;
418         }
419        
420         return TRUE;
421     }
422    
423    
424     /**
425     * Checks if the path specified is restricted by open basedir
426     *
427     * @param  string $path  The path to check
428     * @return boolean  If the path is restricted by the `open_basedir` ini setting
429     */
430     static private function isOpenBaseDirRestricted($path)
431     {
432         if (ini_get('open_basedir')) {
433             $open_basedirs = explode((fCore::checkOS('windows')) ? ';' : ':', ini_get('open_basedir'));
434             $found = FALSE;
435            
436             foreach ($open_basedirs as $open_basedir) {
437                 if (strpos($path, $open_basedir) === 0) {
438                     $found = TRUE;
439                 }
440             }
441            
442             if (!$found) {
443                 return TRUE;
444             }
445         }
446        
447         return FALSE;
448     }
449    
450    
451     /**
452     * Checks if the path specified is restricted by the safe mode exec dir restriction
453     *
454     * @param  string $path  The path to check
455     * @return boolean  If the path is restricted by the `safe_mode_exec_dir` ini setting
456     */
457     static private function isSafeModeExecDirRestricted($path)
458     {
459         if (!in_array(strtolower(ini_get('safe_mode')), array('0', '', 'off'))) {
460             $exec_dir = ini_get('safe_mode_exec_dir');
461             if (!$exec_dir || stripos($path, $exec_dir) === FALSE) {
462                 return TRUE;
463             }
464         }
465         return FALSE;
466     }
467    
468    
469     /**
470     * Resets the configuration of the class
471     *
472     * @internal
473      *
474     * @return void
475     */
476     static public function reset()
477     {
478         self::$imagemagick_dir      = NULL;
479         self::$imagemagick_temp_dir = NULL;
480         self::$processor            = NULL;   
481     }
482    
483    
484     /**
485     * Sets the directory the ImageMagick binary is installed in and tells the class to use ImageMagick even if GD is installed
486     *
487     * @param  string $directory  The directory ImageMagick is installed in
488     * @return void
489     */
490     static public function setImageMagickDirectory($directory)
491     {
492         $directory = fDirectory::makeCanonical($directory);
493        
494         self::checkImageMagickBinary($directory);
495        
496         self::$imagemagick_dir = $directory;
497         self::$processor = 'imagemagick';
498     }
499    
500    
501     /**
502     * Sets a custom directory to use for the ImageMagick temporary files
503     *
504     * @param  string $temp_dir  The directory to use for the ImageMagick temp dir
505     * @return void
506     */
507     static public function setImageMagickTempDir($temp_dir)
508     {
509         $temp_dir = new fDirectory($temp_dir);
510         if (!$temp_dir->isWritable()) {
511             throw new fEnvironmentException(
512                 'The ImageMagick temp directory specified, %s, does not appear to be writable',
513                 $temp_dir->getPath()
514             );
515         }
516         self::$imagemagick_temp_dir = $temp_dir->getPath();
517     }
518    
519    
520     /**
521     * The modifications to perform on the image when it is saved
522     *
523     * @var array
524     */
525     private $pending_modifications = array();
526    
527    
528     /**
529     * Creates an object to represent an image on the filesystem
530     *
531     * @throws fValidationException  When no image was specified, when the image does not exist or when the path specified is not an image
532     *
533     * @param  string  $file_path    The path to the image
534     * @param  boolean $skip_checks  If file checks should be skipped, which improves performance, but may cause undefined behavior - only skip these if they are duplicated elsewhere
535     * @return fImage
536     */
537     public function __construct($file_path, $skip_checks=FALSE)
538     {
539         self::determineProcessor();
540        
541         parent::__construct($file_path, $skip_checks);
542        
543         if (!self::isImageCompatible($file_path)) {
544             $valid_image_types = array('GIF', 'JPG', 'PNG');
545             if (self::$processor == 'imagemagick') {
546                 $valid_image_types[] = 'TIF';
547             }
548             throw new fValidationException(
549                 'The image specified, %1$s, is not a valid %2$s file',
550                 $file_path,
551                 fGrammar::joinArray($valid_image_types, 'or')
552             );
553         }
554     }
555    
556    
557     /**
558     * Crops the image by the exact pixel dimensions specified
559     *
560     * The crop does not occur until ::saveChanges() is called.
561     *
562     * @param  numeric $crop_from_x  The number of pixels from the left of the image to start the crop from
563     * @param  numeric $crop_from_y  The number of pixels from the top of the image to start the crop from
564     * @param  numeric $new_width    The width in pixels to crop the image to
565     * @param  numeric $new_height   The height in pixels to crop the image to
566     * @return fImage  The image object, to allow for method chaining
567     */
568     public function crop($crop_from_x, $crop_from_y, $new_width, $new_height)
569     {
570         $this->tossIfDeleted();
571        
572         // Get the original dimensions for our parameter checking
573         $dim = $this->getCurrentDimensions();
574         $orig_width  = $dim['width'];
575         $orig_height = $dim['height'];
576        
577         // Make sure the user input is valid
578         if (!is_numeric($crop_from_x) || $crop_from_x < 0 || $crop_from_x > $orig_width - 1) {
579             throw new fProgrammerException(
580                 'The crop-from x specified, %s, is not a number, is less than zero, or would result in a zero-width image',
581                 $crop_from_x
582             );
583         }
584         if (!is_numeric($crop_from_y) || $crop_from_y < 0 || $crop_from_y > $orig_height - 1) {
585             throw new fProgrammerException(
586                 'The crop-from y specified, %s, is not a number, is less than zero, or would result in a zero-height image',
587                 $crop_from_y
588             );
589         }
590        
591         if (!is_numeric($new_width) || $new_width <= 0 || $crop_from_x + $new_width > $orig_width) {
592             throw new fProgrammerException(
593                 'The new width specified, %1$s, is not a number, is less than or equal to zero, or is larger than can be cropped with the specified crop-from x of %2$s',
594                 $new_width,
595                 $crop_from_x
596             );
597         }
598         if (!is_numeric($new_height) || $new_height <= 0 || $crop_from_y + $new_height > $orig_height) {
599             throw new fProgrammerException(
600                 'The new height specified, %1$s, is not a number, is less than or equal to zero, or is larger than can be cropped with the specified crop-from y of %2$s',
601                 $new_height,
602                 $crop_from_y
603             );
604         }
605        
606         // If nothing changed, don't even record the modification
607         if ($orig_width == $new_width && $orig_height == $new_height) {
608             return $this;
609         }
610        
611         // Record what we are supposed to do
612         $this->pending_modifications[] = array(
613             'operation'  => 'crop',
614             'start_x'    => $crop_from_x,
615             'start_y'    => $crop_from_y,
616             'width'      => $new_width,
617             'height'     => $new_height,
618             'old_width'  => $orig_width,
619             'old_height' => $orig_height
620         );
621        
622         return $this;
623     }
624    
625        
626     /**
627     * Crops the biggest area possible from the center of the image that matches the ratio provided
628     *
629     * The crop does not occur until ::saveChanges() is called.
630     *
631     * @param  numeric $ratio_width   The width ratio to crop the image to
632     * @param  numeric $ratio_height  The height ratio to crop the image to
633     * @return fImage  The image object, to allow for method chaining
634     */
635     public function cropToRatio($ratio_width, $ratio_height)
636     {
637         $this->tossIfDeleted();
638        
639         // Make sure the user input is valid
640         if ((!is_numeric($ratio_width) && $ratio_width !== NULL) || $ratio_width < 0) {
641             throw new fProgrammerException(
642                 'The ratio width specified, %s, is not a number or is less than or equal to zero',
643                 $ratio_width
644             );
645         }
646         if ((!is_numeric($ratio_height) && $ratio_height !== NULL) || $ratio_height < 0) {
647             throw new fProgrammerException(
648                 'The ratio height specified, %s, is not a number or is less than or equal to zero',
649                 $ratio_height
650             );
651         }
652        
653         // Get the new dimensions
654         $dim = $this->getCurrentDimensions();
655         $orig_width  = $dim['width'];
656         $orig_height = $dim['height'];
657        
658         $orig_ratio = $orig_width / $orig_height;
659         $new_ratio  = $ratio_width / $ratio_height;
660            
661         if ($orig_ratio > $new_ratio) {
662             $new_height = $orig_height;
663             $new_width  = round($new_ratio * $new_height);
664         } else {
665             $new_width  = $orig_width;
666             $new_height = round($new_width / $new_ratio);
667         }
668            
669         // Figure out where to crop from
670         $crop_from_x = floor(($orig_width - $new_width) / 2);
671         $crop_from_y = floor(($orig_height - $new_height) / 2);
672        
673         $crop_from_x = ($crop_from_x < 0) ? 0 : $crop_from_x;
674         $crop_from_y = ($crop_from_y < 0) ? 0 : $crop_from_y;
675            
676         // If nothing changed, don't even record the modification
677         if ($orig_width == $new_width && $orig_height == $new_height) {
678             return $this;
679         }
680        
681         // Record what we are supposed to do
682         $this->pending_modifications[] = array(
683             'operation'  => 'crop',
684             'start_x'    => $crop_from_x,
685             'start_y'    => $crop_from_y,
686             'width'      => $new_width,
687             'height'     => $new_height,
688             'old_width'  => $orig_width,
689             'old_height' => $orig_height
690         );
691        
692         return $this;
693     }
694    
695    
696     /**
697     * Converts the image to grayscale
698     *
699     * Desaturation does not occur until ::saveChanges() is called.
700     *
701     * @return fImage  The image object, to allow for method chaining
702     */
703     public function desaturate()
704     {
705         $this->tossIfDeleted();
706        
707         $dim = $this->getCurrentDimensions();
708        
709         // Record what we are supposed to do
710         $this->pending_modifications[] = array(
711             'operation'  => 'desaturate',
712             'width'      => $dim['width'],
713             'height'     => $dim['height']
714         );
715        
716         return $this;
717     }
718    
719    
720     /**
721     * Gets the dimensions of the image as of the last modification
722     *
723     * @return array  An associative array: `'width' => {integer}, 'height' => {integer}`
724     */
725     private function getCurrentDimensions()
726     {
727         if (empty($this->pending_modifications)) {
728             $output = self::getInfo($this->file);
729             unset($output['type']);
730        
731         } else {
732             $last_modification = $this->pending_modifications[sizeof($this->pending_modifications)-1];
733             $output['width']  = $last_modification['width'];
734             $output['height'] = $last_modification['height'];
735         }
736        
737         return $output;
738     }
739    
740    
741     /**
742     * Returns the width and height of the image as a two element array
743     *
744     * @return array  In the format `0 => (integer) {width}, 1 => (integer) {height}`
745     */
746     public function getDimensions()
747     {
748         $info = self::getInfo($this->file);
749         return array($info['width'], $info['height']);
750     }
751    
752    
753     /**
754     * Returns the height of the image
755     *
756     * @return integer  The height of the image in pixels
757     */
758     public function getHeight()
759     {
760         return self::getInfo($this->file, 'height');
761     }
762    
763    
764     /**
765     * Returns the type of the image
766     *
767     * @return string  The type of the image: `'jpg'`, `'gif'`, `'png'`, `'tif'`
768     */
769     public function getType()
770     {
771         return self::getImageType($this->file);
772     }
773    
774    
775     /**
776     * Returns the width of the image
777     *
778     * @return integer  The width of the image in pixels
779     */
780     public function getWidth()
781     {
782         return self::getInfo($this->file, 'width');
783     }
784    
785    
786     /**
787     * Checks if the current image is an animated gif
788     *
789     * @return boolean  If the image is an animated gif
790     */
791     private function isAnimatedGif()
792     {
793         $type = self::getImageType($this->file);
794         if ($type == 'gif') {
795             if (preg_match('#\x00\x21\xF9\x04.{4}\x00\x2C#s', file_get_contents($this->file))) {
796                 return TRUE;
797             }
798         }
799         return FALSE;
800     }
801    
802    
803     /**
804     * Processes the current image using GD
805     *
806     * @param  string  $output_file   The file to save the image to
807     * @param  integer $jpeg_quality  The JPEG quality to use
808     * @return void
809     */
810     private function processWithGD($output_file, $jpeg_quality)
811     {
812         $type       = self::getImageType($this->file);
813         $save_alpha = FALSE;
814        
815         $path_info = fFilesystem::getPathInfo($output_file);
816         $new_type  = $path_info['extension'];
817         $new_type  = ($type == 'jpeg') ? 'jpg' : $type;
818        
819         if (!in_array($new_type, array('gif', 'jpg', 'png'))) {
820             $new_type = $type;   
821         }
822        
823         switch ($type) {
824             case 'gif':
825                 $gd_res = imagecreatefromgif($this->file);
826                 $save_alpha = TRUE;
827                 break;
828             case 'jpg':
829                 $gd_res = imagecreatefromjpeg($this->file);
830                 break;
831             case 'png':
832                 $gd_res = imagecreatefrompng($this->file);
833                 $save_alpha = TRUE;
834                 break;
835         }
836        
837        
838         foreach ($this->pending_modifications as $mod) {
839            
840             $new_gd_res = imagecreatetruecolor($mod['width'], $mod['height']);
841             if ($save_alpha) {
842                 imagealphablending($new_gd_res, FALSE);
843                 imagesavealpha($new_gd_res, TRUE);
844                 if ($new_type == 'gif') {
845                     $transparent = imagecolorallocatealpha($new_gd_res, 255, 255, 255, 127);
846                     imagefilledrectangle($new_gd_res, 0, 0, $mod['width'], $mod['height'], $transparent);
847                     imagecolortransparent($new_gd_res, $transparent);
848                 }
849             }
850            
851             // Perform the resize operation
852             if ($mod['operation'] == 'resize') {
853                
854                 imagecopyresampled($new_gd_res,       $gd_res,
855                                    0,                 0,
856                                    0,                 0,
857                                    $mod['width'],     $mod['height'],
858                                    $mod['old_width'], $mod['old_height']);
859                
860             // Perform the crop operation
861             } elseif ($mod['operation'] == 'crop') {
862            
863                 imagecopyresampled($new_gd_res,       $gd_res,
864                                    0,                 0,
865                                    $mod['start_x'],   $mod['start_y'],
866                                    $mod['width'],     $mod['height'],
867                                    $mod['width'],     $mod['height']);
868                
869             // Perform the desaturate operation
870             } elseif ($mod['operation'] == 'desaturate') {
871            
872                 // Create a palette of grays
873                 $grays = array();
874                 for ($i=0; $i < 256; $i++) {
875                     $grays[$i] = imagecolorallocate($new_gd_res, $i, $i, $i);
876                 }
877                 $transparent = imagecolorallocatealpha($new_gd_res, 255, 255, 255, 127);
878                
879                 // Loop through every pixel and convert the rgb values to grays
880                 for ($x=0; $x < $mod['width']; $x++) {
881                     for ($y=0; $y < $mod['height']; $y++) {
882                        
883                         $color = imagecolorat($gd_res, $x, $y);
884                         if ($type != 'gif') {
885                             $red   = ($color >> 16) & 0xFF;
886                             $green = ($color >> 8) & 0xFF;
887                             $blue  = $color & 0xFF;
888                             if ($save_alpha) {
889                                 $alpha = ($color >> 24) & 0x7F;
890                             }
891                         } else {
892                             $color_info = imagecolorsforindex($gd_res, $color);
893                             $red   = $color_info['red'];
894                             $green = $color_info['green'];
895                             $blue  = $color_info['blue'];
896                             $alpha = $color_info['alpha'];
897                         }
898                        
899                         if (!$save_alpha || $alpha != 127) {
900                            
901                             // Get the appropriate gray (http://en.wikipedia.org/wiki/YIQ)
902                             $yiq = round(($red * 0.299) + ($green * 0.587) + ($blue * 0.114));
903                            
904                             if (!$save_alpha || $alpha == 0) {
905                                 $new_color = $grays[$yiq];   
906                             } else {
907                                 $new_color = imagecolorallocatealpha($new_gd_res, $yiq, $yiq, $yiq, $alpha);   
908                             }
909                            
910                         } else {
911                             $new_color = $transparent;
912                         }
913                        
914                         imagesetpixel($new_gd_res, $x, $y, $new_color);
915                     }
916                 }
917             }
918            
919             imagedestroy($gd_res);
920                
921             $gd_res = $new_gd_res;   
922         }
923        
924         // Save the file
925         switch ($new_type) {
926             case 'gif':
927                 imagetruecolortopalette($gd_res, TRUE, 256);
928                 imagegif($gd_res, $output_file);
929                 break;
930             case 'jpg':
931                 imagejpeg($gd_res, $output_file, $jpeg_quality);
932                 break;
933             case 'png':
934                 imagepng($gd_res, $output_file);
935                 break;
936         }
937        
938         imagedestroy($gd_res);
939     }
940    
941    
942     /**
943     * Processes the current image using ImageMagick
944     *
945     * @param  string  $output_file   The file to save the image to
946     * @param  integer $jpeg_quality  The JPEG quality to use
947     * @return void
948     */
949     private function processWithImageMagick($output_file, $jpeg_quality)
950     {
951         $type = self::getImageType($this->file);
952         if (fCore::checkOS('windows')) {
953             $command_line  = str_replace(' ', '" "', self::$imagemagick_dir . 'convert.exe');
954         } else {
955             $command_line  = escapeshellarg(self::$imagemagick_dir . 'convert');
956         }
957        
958         if (self::$imagemagick_temp_dir) {
959             $command_line .= ' -set registry:temporary-path ' . escapeshellarg(self::$imagemagick_temp_dir) . ' ';
960         }
961        
962         $command_line .= ' ' . escapeshellarg($this->file) . ' ';
963        
964         // Animated gifs need to be coalesced
965         if ($this->isAnimatedGif()) {
966             $command_line .= ' -coalesce ';
967         }
968        
969         // TIFF files should be set to a depth of 8
970         if ($type == 'tif') {
971             $command_line .= ' -depth 8 ';
972         }
973        
974         foreach ($this->pending_modifications as $mod) {
975            
976             // Perform the resize operation
977             if ($mod['operation'] == 'resize') {
978                 $command_line .= ' -resize "' . $mod['width'] . 'x' . $mod['height'];
979                 if ($mod['old_width'] < $mod['width'] || $mod['old_height'] < $mod['height']) {
980                     $command_line .= '<';
981                 }
982                 $command_line .= '" ';
983                
984             // Perform the crop operation
985             } elseif ($mod['operation'] == 'crop') {
986                 $command_line .= ' -crop ' . $mod['width'] . 'x' . $mod['height'];
987                 $command_line .= '+' . $mod['start_x'] . '+' . $mod['start_y'];
988                 $command_line .= ' -repage ' . $mod['width'] . 'x' . $mod['height'] . '+0+0 ';
989                
990             // Perform the desaturate operation
991             } elseif ($mod['operation'] == 'desaturate') {
992                 $command_line .= ' -colorspace GRAY ';
993             }
994         }
995        
996         // Default to the RGB colorspace
997         if (strpos($command_line, ' -colorspace ')) {
998             $command_line .= ' -colorspace RGB ';
999         }
1000        
1001         // Set up jpeg compression
1002         $path_info = fFilesystem::getPathInfo($output_file);
1003         $new_type = $path_info['extension'];
1004         $new_type = ($new_type == 'jpeg') ? 'jpg' : $new_type;
1005        
1006         if (!in_array($new_type, array('gif', 'jpg', 'png'))) {
1007             $new_type = $type;   
1008         }
1009        
1010         if ($new_type == 'jpg') {
1011             $command_line .= ' -compress JPEG -quality ' . $jpeg_quality . ' ';
1012         }
1013        
1014         $command_line .= ' ' . escapeshellarg($new_type . ':' . $output_file);
1015        
1016         exec($command_line);
1017     }
1018    
1019    
1020     /**
1021     * Sets the image to be resized proportionally to a specific size canvas
1022     *
1023     * Will only size down an image. This method uses resampling to ensure the
1024     * resized image is smooth in aappearance. Resizing does not occur until
1025     * ::saveChanges() is called.
1026     *
1027     * @param  integer $canvas_width    The width of the canvas to fit the image on, `0` for no constraint
1028     * @param  integer $canvas_height   The height of the canvas to fit the image on, `0` for no constraint
1029     * @param  boolean $allow_upsizing  If the image is smaller than the desired canvas, the image will be increased in size
1030     * @return fImage  The image object, to allow for method chaining
1031     */
1032     public function resize($canvas_width, $canvas_height, $allow_upsizing=FALSE)
1033     {
1034         $this->tossIfDeleted();
1035        
1036         // Make sure the user input is valid
1037         if ((!is_int($canvas_width) && $canvas_width !== NULL) || $canvas_width < 0) {
1038             throw new fProgrammerException(
1039                 'The canvas width specified, %s, is not an integer or is less than zero',
1040                 $canvas_width
1041             );
1042         }
1043         if ((!is_int($canvas_height) && $canvas_height !== NULL) || $canvas_height < 0) {
1044             throw new fProgrammerException(
1045                 'The canvas height specified, %s is not an integer or is less than zero',
1046                 $canvas_height
1047             );
1048         }
1049         if ($canvas_width == 0 && $canvas_height == 0) {
1050             throw new fProgrammerException(
1051                 'The canvas width and canvas height are both zero, so no resizing will occur'
1052             );
1053         }
1054        
1055         // Calculate what the new dimensions will be
1056         $dim = $this->getCurrentDimensions();
1057         $orig_width  = $dim['width'];
1058         $orig_height = $dim['height'];
1059        
1060         if ($canvas_width == 0) {
1061             $new_height = $canvas_height;
1062             $new_width  = round(($new_height/$orig_height) * $orig_width);
1063        
1064         } elseif ($canvas_height == 0) {
1065             $new_width  = $canvas_width;
1066             $new_height = round(($new_width/$orig_width) * $orig_height);
1067        
1068         } else {
1069             $orig_ratio   = $orig_width/$orig_height;
1070             $canvas_ratio = $canvas_width/$canvas_height;
1071            
1072             if ($canvas_ratio > $orig_ratio) {
1073                 $new_height = $canvas_height;
1074                 $new_width  = round($orig_ratio * $new_height);
1075             } else {
1076                 $new_width  = $canvas_width;
1077                 $new_height = round($new_width / $orig_ratio);
1078             }
1079         }
1080        
1081         // If the size did not change, don't even record the modification
1082         $same_size   = $orig_width == $new_width || $orig_height == $new_height;
1083         $wont_change = ($orig_width < $new_width || $orig_height < $new_height) && !$allow_upsizing;
1084         if ($same_size || $wont_change) {
1085             return $this;
1086         }
1087        
1088         // Record what we are supposed to do
1089         $this->pending_modifications[] = array(
1090             'operation'  => 'resize',
1091             'width'      => $new_width,
1092             'height'     => $new_height,
1093             'old_width'  => $orig_width,
1094             'old_height' => $orig_height
1095         );
1096        
1097         return $this;
1098     }
1099    
1100    
1101     /**
1102     * Saves any changes to the image
1103     *
1104     * If the file type is different than the current one, removes the current
1105     * file once the new one is created.
1106     *
1107     * This operation will be reverted by a filesystem transaction being rolled
1108     * back. If a transaction is in progress and the new image type causes a
1109     * new file to be created, the old file will not be deleted until the
1110     * transaction is committed.
1111     *
1112     * @param  string  $new_image_type  The new file format for the image: 'NULL` (no change), `'jpg'`, `'gif'`, `'png'`
1113     * @param  integer $jpeg_quality    The quality setting to use for JPEG images - this may be ommitted
1114     * @param  boolean $overwrite       If an existing file with the same name and extension should be overwritten
1115     * @param  string  :$new_image_type
1116     * @param  boolean :$overwrite
1117     * @return fImage  The image object, to allow for method chaining
1118     */
1119     public function saveChanges($new_image_type=NULL, $jpeg_quality=90, $overwrite=FALSE)
1120     {
1121         // This allows ommitting the $jpeg_quality parameter, which is very useful for non-jpegs
1122         $args = func_get_args();
1123         if (count($args) == 2 && is_bool($args[1])) {
1124             $overwrite    = $args[1];
1125             $jpeg_quality = 90;
1126         }
1127        
1128         $this->tossIfDeleted();
1129         self::determineProcessor();
1130        
1131         if (self::$processor == 'none') {
1132             throw new fEnvironmentException(
1133                 "The changes to the image can't be saved because neither the GD extension or ImageMagick appears to be installed on the server"
1134             );
1135         }
1136        
1137         $type = self::getImageType($this->file);
1138         if ($type == 'tif' && self::$processor == 'gd') {
1139             throw new fEnvironmentException(
1140                 'The image specified, %s, is a TIFF file and the GD extension can not handle TIFF files. Please install ImageMagick if you wish to manipulate TIFF files.',
1141                 $this->file
1142             );
1143         }
1144        
1145         $valid_image_types = array('jpg', 'gif', 'png');
1146         if ($new_image_type !== NULL && !in_array($new_image_type, $valid_image_types)) {
1147             throw new fProgrammerException(
1148                 'The new image type specified, %1$s, is invalid. Must be one of: %2$s.',
1149                 $new_image_type,
1150                 join(', ', $valid_image_types)
1151             );
1152         }
1153        
1154         if (is_numeric($jpeg_quality)) {
1155             $jpeg_quality = (int) round($jpeg_quality);
1156         }
1157        
1158         if (!is_integer($jpeg_quality) || $jpeg_quality < 1 || $jpeg_quality > 100) {
1159             throw new fProgrammerException(
1160                 'The JPEG quality specified, %1$s, is either not an integer, less than %2$s or greater than %3$s.',
1161                 $jpeg_quality,
1162                 1,
1163                 100
1164             );   
1165         }
1166        
1167         if ($new_image_type && fFilesystem::getPathInfo($this->file, 'extension') != $new_image_type) {
1168             if ($overwrite) {
1169                 $path_info   = fFilesystem::getPathInfo($this->file);
1170                 $output_file = $path_info['dirname'] . $path_info['filename'] . '.' . $new_image_type;
1171             } else {
1172                 $output_file = fFilesystem::makeUniqueName($this->file, $new_image_type);
1173             }
1174            
1175             if (file_exists($output_file)) {
1176                 if (!is_writable($output_file)) {
1177                     throw new fEnvironmentException(
1178                         'Changes to the image can not be saved because the file, %s, is not writable',
1179                         $output_file
1180                     );
1181                 }
1182                
1183             } else {
1184                 $output_dir = dirname($output_file);
1185                 if (!is_writable($output_dir)) {
1186                     throw new fEnvironmentException(
1187                         'Changes to the image can not be saved because the directory to save the new file, %s, is not writable',
1188                         $output_dir
1189                     );
1190                 }
1191             }
1192            
1193         } else {
1194             $output_file = $this->file;
1195             if (!is_writable($output_file)) {
1196                 throw new fEnvironmentException(
1197                     'Changes to the image can not be saved because the file, %s, is not writable',
1198                     $output_file
1199                 );   
1200             }
1201         }
1202        
1203         // If we don't have any changes and no name change, just exit
1204         if (!$this->pending_modifications && $output_file == $this->file) {
1205             return $this;
1206         }
1207        
1208         // Wrap changes to the image into the filesystem transaction
1209         if ($output_file == $this->file && fFilesystem::isInsideTransaction()) {
1210             fFilesystem::recordWrite($this);
1211         }
1212        
1213         if (self::$processor == 'gd') {
1214             $this->processWithGD($output_file, $jpeg_quality);
1215         } elseif (self::$processor == 'imagemagick') {
1216             $this->processWithImageMagick($output_file, $jpeg_quality);
1217         }
1218        
1219         $old_file = $this->file;
1220         fFilesystem::updateFilenameMap($this->file, $output_file);
1221        
1222         // If we created a new image, delete the old one
1223         if ($output_file != $old_file) {
1224             $old_image = new fImage($old_file);
1225             $old_image->delete();
1226         }
1227        
1228         $this->pending_modifications = array();
1229        
1230         return $this;
1231     }
1232 }
1233  
1234  
1235  
1236 /**
1237  * Copyright (c) 2007-2010 Will Bond <will@flourishlib.com>, others
1238  *
1239  * Permission is hereby granted, free of charge, to any person obtaining a copy
1240  * of this software and associated documentation files (the "Software"), to deal
1241  * in the Software without restriction, including without limitation the rights
1242  * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
1243  * copies of the Software, and to permit persons to whom the Software is
1244  * furnished to do so, subject to the following conditions:
1245  *
1246  * The above copyright notice and this permission notice shall be included in
1247  * all copies or substantial portions of the Software.
1248  *
1249  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1250  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1251  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1252  * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1253  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
1254  * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
1255  * THE SOFTWARE.
1256  */