1<?php
2// +-----------------------------------------------------------------------+
3// | This file is part of Piwigo.                                          |
4// |                                                                       |
5// | For copyright and license information, please view the COPYING.txt    |
6// | file that was distributed with this source code.                      |
7// +-----------------------------------------------------------------------+
8
9// +-----------------------------------------------------------------------+
10// |                           Image Interface                             |
11// +-----------------------------------------------------------------------+
12
13// Define all needed methods for image class
14interface imageInterface
15{
16  function get_width();
17
18  function get_height();
19
20  function set_compression_quality($quality);
21
22  function crop($width, $height, $x, $y);
23
24  function strip();
25
26  function rotate($rotation);
27
28  function resize($width, $height);
29
30  function sharpen($amount);
31
32  function compose($overlay, $x, $y, $opacity);
33
34  function write($destination_filepath);
35}
36
37// +-----------------------------------------------------------------------+
38// |                          Main Image Class                             |
39// +-----------------------------------------------------------------------+
40
41class pwg_image
42{
43  var $image;
44  var $library = '';
45  var $source_filepath = '';
46  static $ext_imagick_version = '';
47
48  function __construct($source_filepath, $library=null)
49  {
50    $this->source_filepath = $source_filepath;
51
52    trigger_notify('load_image_library', array(&$this) );
53
54    if (is_object($this->image))
55    {
56      return; // A plugin may have load its own library
57    }
58
59    $extension = strtolower(get_extension($source_filepath));
60
61    if (!in_array($extension, array('jpg', 'jpeg', 'png', 'gif')))
62    {
63      die('[Image] unsupported file extension');
64    }
65
66    if (!($this->library = self::get_library($library, $extension)))
67    {
68      die('No image library available on your server.');
69    }
70
71    $class = 'image_'.$this->library;
72    $this->image = new $class($source_filepath);
73  }
74
75  // Unknow methods will be redirected to image object
76  function __call($method, $arguments)
77  {
78    return call_user_func_array(array($this->image, $method), $arguments);
79  }
80
81  // Piwigo resize function
82  function pwg_resize($destination_filepath, $max_width, $max_height, $quality, $automatic_rotation=true, $strip_metadata=false, $crop=false, $follow_orientation=true)
83  {
84    $starttime = get_moment();
85
86    // width/height
87    $source_width  = $this->image->get_width();
88    $source_height = $this->image->get_height();
89
90    $rotation = null;
91    if ($automatic_rotation)
92    {
93      $rotation = self::get_rotation_angle($this->source_filepath);
94    }
95    $resize_dimensions = self::get_resize_dimensions($source_width, $source_height, $max_width, $max_height, $rotation, $crop, $follow_orientation);
96
97    // testing on height is useless in theory: if width is unchanged, there
98    // should be no resize, because width/height ratio is not modified.
99    if ($resize_dimensions['width'] == $source_width and $resize_dimensions['height'] == $source_height)
100    {
101      // the image doesn't need any resize! We just copy it to the destination
102      copy($this->source_filepath, $destination_filepath);
103      return $this->get_resize_result($destination_filepath, $resize_dimensions['width'], $resize_dimensions['height'], $starttime);
104    }
105
106    $this->image->set_compression_quality($quality);
107
108    if ($strip_metadata)
109    {
110      // we save a few kilobytes. For example a thumbnail with metadata weights 25KB, without metadata 7KB.
111      $this->image->strip();
112    }
113
114    if (isset($resize_dimensions['crop']))
115    {
116      $this->image->crop($resize_dimensions['crop']['width'], $resize_dimensions['crop']['height'], $resize_dimensions['crop']['x'], $resize_dimensions['crop']['y']);
117    }
118
119    $this->image->resize($resize_dimensions['width'], $resize_dimensions['height']);
120
121    if (!empty($rotation))
122    {
123      $this->image->rotate($rotation);
124    }
125
126    $this->image->write($destination_filepath);
127
128    // everything should be OK if we are here!
129    return $this->get_resize_result($destination_filepath, $resize_dimensions['width'], $resize_dimensions['height'], $starttime);
130  }
131
132  static function get_resize_dimensions($width, $height, $max_width, $max_height, $rotation=null, $crop=false, $follow_orientation=true)
133  {
134    $rotate_for_dimensions = false;
135    if (isset($rotation) and in_array(abs($rotation), array(90, 270)))
136    {
137      $rotate_for_dimensions = true;
138    }
139
140    if ($rotate_for_dimensions)
141    {
142      list($width, $height) = array($height, $width);
143    }
144
145    if ($crop)
146    {
147      $x = 0;
148      $y = 0;
149
150      if ($width < $height and $follow_orientation)
151      {
152        list($max_width, $max_height) = array($max_height, $max_width);
153      }
154
155      $img_ratio = $width / $height;
156      $dest_ratio = $max_width / $max_height;
157
158      if($dest_ratio > $img_ratio)
159      {
160        $destHeight = round($width * $max_height / $max_width);
161        $y = round(($height - $destHeight) / 2 );
162        $height = $destHeight;
163      }
164      elseif ($dest_ratio < $img_ratio)
165      {
166        $destWidth = round($height * $max_width / $max_height);
167        $x = round(($width - $destWidth) / 2 );
168        $width = $destWidth;
169      }
170    }
171
172    $ratio_width  = $width / $max_width;
173    $ratio_height = $height / $max_height;
174    $destination_width = $width;
175    $destination_height = $height;
176
177    // maximal size exceeded ?
178    if ($ratio_width > 1 or $ratio_height > 1)
179    {
180      if ($ratio_width < $ratio_height)
181      {
182        $destination_width = round($width / $ratio_height);
183        $destination_height = $max_height;
184      }
185      else
186      {
187        $destination_width = $max_width;
188        $destination_height = round($height / $ratio_width);
189      }
190    }
191
192    if ($rotate_for_dimensions)
193    {
194      list($destination_width, $destination_height) = array($destination_height, $destination_width);
195    }
196
197    $result = array(
198      'width' => $destination_width,
199      'height'=> $destination_height,
200      );
201
202    if ($crop and ($x or $y))
203    {
204      $result['crop'] = array(
205        'width' => $width,
206        'height' => $height,
207        'x' => $x,
208        'y' => $y,
209        );
210    }
211    return $result;
212  }
213
214  static function get_rotation_angle($source_filepath)
215  {
216    list($width, $height, $type) = getimagesize($source_filepath);
217    if (IMAGETYPE_JPEG != $type)
218    {
219      return null;
220    }
221
222    if (!function_exists('exif_read_data'))
223    {
224      return null;
225    }
226
227    $rotation = 0;
228
229    $exif = @exif_read_data($source_filepath);
230
231    if (isset($exif['Orientation']) and preg_match('/^\s*(\d)/', $exif['Orientation'], $matches))
232    {
233      $orientation = $matches[1];
234      if (in_array($orientation, array(3, 4)))
235      {
236        $rotation = 180;
237      }
238      elseif (in_array($orientation, array(5, 6)))
239      {
240        $rotation = 270;
241      }
242      elseif (in_array($orientation, array(7, 8)))
243      {
244        $rotation = 90;
245      }
246    }
247
248    return $rotation;
249  }
250
251  static function get_rotation_code_from_angle($rotation_angle)
252  {
253    switch($rotation_angle)
254    {
255      case 0:   return 0;
256      case 90:  return 1;
257      case 180: return 2;
258      case 270: return 3;
259    }
260  }
261
262  static function get_rotation_angle_from_code($rotation_code)
263  {
264    switch($rotation_code%4)
265    {
266      case 0: return 0;
267      case 1: return 90;
268      case 2: return 180;
269      case 3: return 270;
270    }
271  }
272
273  /** Returns a normalized convolution kernel for sharpening*/
274  static function get_sharpen_matrix($amount)
275  {
276    // Amount should be in the range of 48-10
277    $amount = round(abs(-48 + ($amount * 0.38)), 2);
278
279    $matrix = array(
280      array(-1,   -1,    -1),
281      array(-1, $amount, -1),
282      array(-1,   -1,    -1),
283      );
284
285    $norm = array_sum(array_map('array_sum', $matrix));
286
287    for ($i=0; $i<3; $i++)
288    {
289      for ($j=0; $j<3; $j++)
290      {
291        $matrix[$i][$j] /= $norm;
292      }
293    }
294
295    return $matrix;
296  }
297
298  private function get_resize_result($destination_filepath, $width, $height, $time=null)
299  {
300    return array(
301      'source'      => $this->source_filepath,
302      'destination' => $destination_filepath,
303      'width'       => $width,
304      'height'      => $height,
305      'size'        => floor(filesize($destination_filepath) / 1024).' KB',
306      'time'        => $time ? number_format((get_moment() - $time) * 1000, 2, '.', ' ').' ms' : null,
307      'library'     => $this->library,
308    );
309  }
310
311  static function is_imagick()
312  {
313    return (extension_loaded('imagick') and class_exists('Imagick'));
314  }
315
316  static function is_ext_imagick()
317  {
318    global $conf;
319
320    if (!function_exists('exec'))
321    {
322      return false;
323    }
324    @exec($conf['ext_imagick_dir'].'convert -version', $returnarray);
325    if (is_array($returnarray) and !empty($returnarray[0]) and preg_match('/ImageMagick/i', $returnarray[0]))
326    {
327      if (preg_match('/Version: ImageMagick (\d+\.\d+\.\d+-?\d*)/', $returnarray[0], $match))
328      {
329        self::$ext_imagick_version = $match[1];
330      }
331      return true;
332    }
333    return false;
334  }
335
336  static function is_gd()
337  {
338    return function_exists('gd_info');
339  }
340
341  static function get_library($library=null, $extension=null)
342  {
343    global $conf;
344
345    if (is_null($library))
346    {
347      $library = $conf['graphics_library'];
348    }
349
350    // Choose image library
351    switch (strtolower($library))
352    {
353      case 'auto':
354      case 'imagick':
355        if ($extension != 'gif' and self::is_imagick())
356        {
357          return 'imagick';
358        }
359      case 'ext_imagick':
360        if ($extension != 'gif' and self::is_ext_imagick())
361        {
362          return 'ext_imagick';
363        }
364      case 'gd':
365        if (self::is_gd())
366        {
367          return 'gd';
368        }
369      default:
370        if ($library != 'auto')
371        {
372          // Requested library not available. Try another library
373          return self::get_library('auto', $extension);
374        }
375    }
376    return false;
377  }
378
379  function destroy()
380  {
381    if (method_exists($this->image, 'destroy'))
382    {
383      return $this->image->destroy();
384    }
385    return true;
386  }
387}
388
389// +-----------------------------------------------------------------------+
390// |                   Class for Imagick extension                         |
391// +-----------------------------------------------------------------------+
392
393class image_imagick implements imageInterface
394{
395  var $image;
396
397  function __construct($source_filepath)
398  {
399    // A bug cause that Imagick class can not be extended
400    $this->image = new Imagick($source_filepath);
401  }
402
403  function get_width()
404  {
405    return $this->image->getImageWidth();
406  }
407
408  function get_height()
409  {
410    return $this->image->getImageHeight();
411  }
412
413  function set_compression_quality($quality)
414  {
415    return $this->image->setImageCompressionQuality($quality);
416  }
417
418  function crop($width, $height, $x, $y)
419  {
420    return $this->image->cropImage($width, $height, $x, $y);
421  }
422
423  function strip()
424  {
425    return $this->image->stripImage();
426  }
427
428  function rotate($rotation)
429  {
430    $this->image->rotateImage(new ImagickPixel(), -$rotation);
431    $this->image->setImageOrientation(Imagick::ORIENTATION_TOPLEFT);
432    return true;
433  }
434
435  function resize($width, $height)
436  {
437    $this->image->setInterlaceScheme(Imagick::INTERLACE_LINE);
438
439    // TODO need to explain this condition
440    if ($this->get_width()%2 == 0
441        && $this->get_height()%2 == 0
442        && $this->get_width() > 3*$width)
443    {
444      $this->image->scaleImage($this->get_width()/2, $this->get_height()/2);
445    }
446
447    return $this->image->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 0.9);
448  }
449
450  function sharpen($amount)
451  {
452    $m = pwg_image::get_sharpen_matrix($amount);
453    return  $this->image->convolveImage($m);
454  }
455
456  function compose($overlay, $x, $y, $opacity)
457  {
458    $ioverlay = $overlay->image->image;
459    /*if ($ioverlay->getImageAlphaChannel() !== Imagick::ALPHACHANNEL_OPAQUE)
460    {
461      // Force the image to have an alpha channel
462      $ioverlay->setImageAlphaChannel(Imagick::ALPHACHANNEL_OPAQUE);
463    }*/
464
465    global $dirty_trick_xrepeat;
466    if ( !isset($dirty_trick_xrepeat) && $opacity < 100)
467    {// NOTE: Using setImageOpacity will destroy current alpha channels!
468      $ioverlay->evaluateImage(Imagick::EVALUATE_MULTIPLY, $opacity / 100, Imagick::CHANNEL_ALPHA);
469      $dirty_trick_xrepeat = true;
470    }
471
472    return $this->image->compositeImage($ioverlay, Imagick::COMPOSITE_DISSOLVE, $x, $y);
473  }
474
475  function write($destination_filepath)
476  {
477    // use 4:2:2 chroma subsampling (reduce file size by 20-30% with "almost" no human perception)
478    $this->image->setSamplingFactors( array(2,1) );
479    return $this->image->writeImage($destination_filepath);
480  }
481}
482
483// +-----------------------------------------------------------------------+
484// |            Class for ImageMagick external installation                |
485// +-----------------------------------------------------------------------+
486
487class image_ext_imagick implements imageInterface
488{
489  var $imagickdir = '';
490  var $source_filepath = '';
491  var $width = '';
492  var $height = '';
493  var $commands = array();
494
495  function __construct($source_filepath)
496  {
497    global $conf;
498    $this->source_filepath = $source_filepath;
499    $this->imagickdir = $conf['ext_imagick_dir'];
500
501    if (strpos(@$_SERVER['SCRIPT_FILENAME'], '/kunden/') === 0)  // 1and1
502    {
503      @putenv('MAGICK_THREAD_LIMIT=1');
504    }
505
506    $command = $this->imagickdir.'identify -format "%wx%h" "'.realpath($source_filepath).'"';
507    @exec($command, $returnarray);
508    if(!is_array($returnarray) or empty($returnarray[0]) or !preg_match('/^(\d+)x(\d+)$/', $returnarray[0], $match))
509    {
510      die("[External ImageMagick] Corrupt image\n" . var_export($returnarray, true));
511    }
512
513    $this->width = $match[1];
514    $this->height = $match[2];
515  }
516
517  function add_command($command, $params=null)
518  {
519    $this->commands[$command] = $params;
520  }
521
522  function get_width()
523  {
524    return $this->width;
525  }
526
527  function get_height()
528  {
529    return $this->height;
530  }
531
532  function crop($width, $height, $x, $y)
533  {
534    $this->width = $width;
535    $this->height = $height;
536
537    $this->add_command('crop', $width.'x'.$height.'+'.$x.'+'.$y);
538    return true;
539  }
540
541  function strip()
542  {
543    $this->add_command('strip');
544    return true;
545  }
546
547  function rotate($rotation)
548  {
549    if (empty($rotation))
550    {
551      return true;
552    }
553
554    if ($rotation==90 || $rotation==270)
555    {
556      $tmp = $this->width;
557      $this->width = $this->height;
558      $this->height = $tmp;
559    }
560    $this->add_command('rotate', -$rotation);
561    $this->add_command('orient', 'top-left');
562    return true;
563  }
564
565  function set_compression_quality($quality)
566  {
567    $this->add_command('quality', $quality);
568    return true;
569  }
570
571  function resize($width, $height)
572  {
573    $this->width = $width;
574    $this->height = $height;
575
576    $this->add_command('filter', 'Lanczos');
577    $this->add_command('resize', $width.'x'.$height.'!');
578    return true;
579  }
580
581  function sharpen($amount)
582  {
583    $m = pwg_image::get_sharpen_matrix($amount);
584
585    $param ='convolve "'.count($m).':';
586    foreach ($m as $line)
587    {
588      $param .= ' ';
589      $param .= implode(',', $line);
590    }
591    $param .= '"';
592    $this->add_command('morphology', $param);
593    return true;
594  }
595
596  function compose($overlay, $x, $y, $opacity)
597  {
598    $param = 'compose dissolve -define compose:args='.$opacity;
599    $param .= ' '.escapeshellarg(realpath($overlay->image->source_filepath));
600    $param .= ' -gravity NorthWest -geometry +'.$x.'+'.$y;
601    $param .= ' -composite';
602    $this->add_command($param);
603    return true;
604  }
605
606  function write($destination_filepath)
607  {
608    global $logger;
609
610    $this->add_command('interlace', 'line'); // progressive rendering
611    // use 4:2:2 chroma subsampling (reduce file size by 20-30% with "almost" no human perception)
612    //
613    // option deactivated for Piwigo 2.4.1, it doesn't work fo old versions
614    // of ImageMagick, see bug:2672. To reactivate once we have a better way
615    // to detect IM version and when we know which version supports this
616    // option
617    //
618    if (version_compare(pwg_image::$ext_imagick_version, '6.6') > 0)
619    {
620      $this->add_command('sampling-factor', '4:2:2' );
621    }
622
623    $exec = $this->imagickdir.'convert';
624    $exec .= ' "'.realpath($this->source_filepath).'"';
625
626    foreach ($this->commands as $command => $params)
627    {
628      $exec .= ' -'.$command;
629      if (!empty($params))
630      {
631        $exec .= ' '.$params;
632      }
633    }
634
635    $dest = pathinfo($destination_filepath);
636    $exec .= ' "'.realpath($dest['dirname']).'/'.$dest['basename'].'" 2>&1';
637    $logger->debug($exec, 'i.php');
638    @exec($exec, $returnarray);
639
640    if (is_array($returnarray) && (count($returnarray)>0) )
641    {
642      $logger->error('', 'i.php', $returnarray);
643      foreach ($returnarray as $line)
644        trigger_error($line, E_USER_WARNING);
645    }
646    return is_array($returnarray);
647  }
648}
649
650// +-----------------------------------------------------------------------+
651// |                       Class for GD library                            |
652// +-----------------------------------------------------------------------+
653
654class image_gd implements imageInterface
655{
656  var $image;
657  var $quality = 95;
658
659  function __construct($source_filepath)
660  {
661    $gd_info = gd_info();
662    $extension = strtolower(get_extension($source_filepath));
663
664    if (in_array($extension, array('jpg', 'jpeg')))
665    {
666      $this->image = imagecreatefromjpeg($source_filepath);
667    }
668    else if ($extension == 'png')
669    {
670      $this->image = imagecreatefrompng($source_filepath);
671    }
672    elseif ($extension == 'gif' and $gd_info['GIF Read Support'] and $gd_info['GIF Create Support'])
673    {
674      $this->image = imagecreatefromgif($source_filepath);
675    }
676    else
677    {
678      die('[Image GD] unsupported file extension');
679    }
680  }
681
682  function get_width()
683  {
684    return imagesx($this->image);
685  }
686
687  function get_height()
688  {
689    return imagesy($this->image);
690  }
691
692  function crop($width, $height, $x, $y)
693  {
694    $dest = imagecreatetruecolor($width, $height);
695
696    imagealphablending($dest, false);
697    imagesavealpha($dest, true);
698    if (function_exists('imageantialias'))
699    {
700      imageantialias($dest, true);
701    }
702
703    $result = imagecopymerge($dest, $this->image, 0, 0, $x, $y, $width, $height, 100);
704
705    if ($result !== false)
706    {
707      imagedestroy($this->image);
708      $this->image = $dest;
709    }
710    else
711    {
712      imagedestroy($dest);
713    }
714    return $result;
715  }
716
717  function strip()
718  {
719    return true;
720  }
721
722  function rotate($rotation)
723  {
724    $dest = imagerotate($this->image, $rotation, 0);
725    imagedestroy($this->image);
726    $this->image = $dest;
727    return true;
728  }
729
730  function set_compression_quality($quality)
731  {
732    $this->quality = $quality;
733    return true;
734  }
735
736  function resize($width, $height)
737  {
738    $dest = imagecreatetruecolor($width, $height);
739
740    imagealphablending($dest, false);
741    imagesavealpha($dest, true);
742    if (function_exists('imageantialias'))
743    {
744      imageantialias($dest, true);
745    }
746
747    $result = imagecopyresampled($dest, $this->image, 0, 0, 0, 0, $width, $height, $this->get_width(), $this->get_height());
748
749    if ($result !== false)
750    {
751      imagedestroy($this->image);
752      $this->image = $dest;
753    }
754    else
755    {
756      imagedestroy($dest);
757    }
758    return $result;
759  }
760
761  function sharpen($amount)
762  {
763    $m = pwg_image::get_sharpen_matrix($amount);
764    return imageconvolution($this->image, $m, 1, 0);
765  }
766
767  function compose($overlay, $x, $y, $opacity)
768  {
769    $ioverlay = $overlay->image->image;
770    /* A replacement for php's imagecopymerge() function that supports the alpha channel
771    See php bug #23815:  http://bugs.php.net/bug.php?id=23815 */
772
773    $ow = imagesx($ioverlay);
774    $oh = imagesy($ioverlay);
775
776		// Create a new blank image the site of our source image
777		$cut = imagecreatetruecolor($ow, $oh);
778
779		// Copy the blank image into the destination image where the source goes
780		imagecopy($cut, $this->image, 0, 0, $x, $y, $ow, $oh);
781
782		// Place the source image in the destination image
783		imagecopy($cut, $ioverlay, 0, 0, 0, 0, $ow, $oh);
784		imagecopymerge($this->image, $cut, $x, $y, 0, 0, $ow, $oh, $opacity);
785    imagedestroy($cut);
786    return true;
787  }
788
789  function write($destination_filepath)
790  {
791    $extension = strtolower(get_extension($destination_filepath));
792
793    if ($extension == 'png')
794    {
795      imagepng($this->image, $destination_filepath);
796    }
797    elseif ($extension == 'gif')
798    {
799      imagegif($this->image, $destination_filepath);
800    }
801    else
802    {
803      imagejpeg($this->image, $destination_filepath, $this->quality);
804    }
805  }
806
807  function destroy()
808  {
809    imagedestroy($this->image);
810  }
811}
812
813?>