1<?php
2
3/**
4 +-----------------------------------------------------------------------+
5 | This file is part of the Roundcube Webmail client                     |
6 |                                                                       |
7 | Copyright (C) The Roundcube Dev Team                                  |
8 | Copyright (C) Kolab Systems AG                                        |
9 |                                                                       |
10 | Licensed under the GNU General Public License version 3 or            |
11 | any later version with exceptions for skins & plugins.                |
12 | See the README file for a full license statement.                     |
13 |                                                                       |
14 | PURPOSE:                                                              |
15 |   Image resizer and converter                                         |
16 +-----------------------------------------------------------------------+
17 | Author: Thomas Bruederli <roundcube@gmail.com>                        |
18 | Author: Aleksander Machniak <alec@alec.pl>                            |
19 +-----------------------------------------------------------------------+
20*/
21
22/**
23 * Image resizer and converter
24 *
25 * @package    Framework
26 * @subpackage Utils
27 */
28class rcube_image
29{
30    const TYPE_GIF = 1;
31    const TYPE_JPG = 2;
32    const TYPE_PNG = 3;
33    const TYPE_TIF = 4;
34
35    /** @var array Image file type to extension map */
36    public static $extensions = [
37        self::TYPE_GIF => 'gif',
38        self::TYPE_JPG => 'jpg',
39        self::TYPE_PNG => 'png',
40        self::TYPE_TIF => 'tif',
41    ];
42
43    /** @var string Image file location */
44    private $image_file;
45
46
47    /**
48     * Class constructor
49     *
50     * @param string $filename Image file name/path
51     */
52    function __construct($filename)
53    {
54        $this->image_file = $filename;
55    }
56
57    /**
58     * Get image properties.
59     *
60     * @return array|null Hash array with image props like type, width, height
61     */
62    public function props()
63    {
64        $gd_type  = null;
65        $channels = null;
66        $width    = null;
67        $height   = null;
68
69        // use GD extension
70        if (function_exists('getimagesize') && ($imsize = @getimagesize($this->image_file))) {
71            $width   = $imsize[0];
72            $height  = $imsize[1];
73            $gd_type = $imsize[2];
74            $type    = image_type_to_extension($gd_type, false);
75
76            if (isset($imsize['channels'])) {
77                $channels = $imsize['channels'];
78            }
79        }
80
81        // use ImageMagick
82        if (empty($type) && ($data = $this->identify())) {
83            list($type, $width, $height) = $data;
84            $channels = null;
85        }
86
87        if (!empty($type)) {
88            return [
89                'type'     => $type,
90                'gd_type'  => $gd_type,
91                'width'    => $width,
92                'height'   => $height,
93                'channels' => $channels,
94            ];
95        }
96    }
97
98    /**
99     * Resize image to a given size. Use only to shrink an image.
100     * If an image is smaller than specified size it will be not resized.
101     *
102     * @param int    $size           Max width/height size
103     * @param string $filename       Output filename
104     * @param bool   $browser_compat Convert to image type displayable by any browser
105     *
106     * @return string|false Output type on success, False on failure
107     */
108    public function resize($size, $filename = null, $browser_compat = false)
109    {
110        $result  = false;
111        $rcube   = rcube::get_instance();
112        $convert = self::getCommand('im_convert_path');
113        $props   = $this->props();
114
115        if (empty($props)) {
116            return false;
117        }
118
119        if (!$filename) {
120            $filename = $this->image_file;
121        }
122
123        // use Imagemagick
124        if ($convert || class_exists('Imagick', false)) {
125            $p['out'] = $filename;
126            $p['in']  = $this->image_file;
127            $type     = $props['type'];
128
129            if (!$type && ($data = $this->identify())) {
130                $type = $data[0];
131            }
132
133            $type = strtr($type, ["jpeg" => "jpg", "tiff" => "tif", "ps" => "eps", "ept" => "eps"]);
134            $p['intype'] = $type;
135
136            // convert to an image format every browser can display
137            if ($browser_compat && !in_array($type, ['jpg', 'gif', 'png'])) {
138                $type = 'jpg';
139            }
140
141            // If only one dimension is greater than the limit convert doesn't
142            // work as expected, we need to calculate new dimensions
143            $scale = $size / max($props['width'], $props['height']);
144
145            // if file is smaller than the limit, we do nothing
146            // but copy original file to destination file
147            if ($scale >= 1 && $p['intype'] == $type) {
148                $result = ($this->image_file == $filename || copy($this->image_file, $filename)) ? '' : false;
149            }
150            else {
151                $valid_types = "bmp,eps,gif,jp2,jpg,png,svg,tif";
152
153                if (in_array($type, explode(',', $valid_types))) { // Valid type?
154                    if ($scale >= 1) {
155                        $width  = $props['width'];
156                        $height = $props['height'];
157                    }
158                    else {
159                        $width  = intval($props['width']  * $scale);
160                        $height = intval($props['height'] * $scale);
161                    }
162
163                    // use ImageMagick in command line
164                    if ($convert) {
165                        $p += [
166                            'type'    => $type,
167                            'quality' => 75,
168                            'size'    => $width . 'x' . $height,
169                        ];
170
171                        $result = rcube::exec($convert
172                            . ' 2>&1 -flatten -auto-orient -colorspace sRGB -strip'
173                            . ' -quality {quality} -resize {size} {intype}:{in} {type}:{out}', $p);
174                    }
175                    // use PHP's Imagick class
176                    else {
177                        try {
178                            $image = new Imagick($this->image_file);
179
180                            try {
181                                // it throws exception on formats not supporting these features
182                                $image->setImageBackgroundColor('white');
183                                $image->setImageAlphaChannel(11);
184                                $image->mergeImageLayers(Imagick::LAYERMETHOD_FLATTEN);
185                            }
186                            catch (Exception $e) {
187                                // ignore errors
188                            }
189
190                            $image->setImageColorspace(Imagick::COLORSPACE_SRGB);
191                            $image->setImageCompressionQuality(75);
192                            $image->setImageFormat($type);
193                            $image->stripImage();
194                            $image->scaleImage($width, $height);
195
196                            if ($image->writeImage($filename)) {
197                                $result = '';
198                            }
199                        }
200                        catch (Exception $e) {
201                            rcube::raise_error($e, true, false);
202                        }
203                    }
204                }
205            }
206
207            if ($result === '') {
208                @chmod($filename, 0600);
209                return $type;
210            }
211        }
212
213        // do we have enough memory? (#1489937)
214        if (strtoupper(substr(PHP_OS, 0, 3)) == 'WIN' && !$this->mem_check($props)) {
215            return false;
216        }
217
218        // use GD extension
219        if ($props['gd_type']) {
220            if ($props['gd_type'] == IMAGETYPE_JPEG && function_exists('imagecreatefromjpeg')) {
221                $image = imagecreatefromjpeg($this->image_file);
222                $type  = 'jpg';
223            }
224            else if ($props['gd_type'] == IMAGETYPE_GIF && function_exists('imagecreatefromgif')) {
225                $image = imagecreatefromgif($this->image_file);
226                $type  = 'gif';
227            }
228            else if ($props['gd_type'] == IMAGETYPE_PNG && function_exists('imagecreatefrompng')) {
229                $image = imagecreatefrompng($this->image_file);
230                $type  = 'png';
231            }
232            else {
233                // @TODO: print error to the log?
234                return false;
235            }
236
237            if ($image === false) {
238                return false;
239            }
240
241            $scale = $size / max($props['width'], $props['height']);
242
243            // Imagemagick resize is implemented in shrinking mode (see -resize argument above)
244            // we do the same here, if an image is smaller than specified size
245            // we do nothing but copy original file to destination file
246            if ($scale >= 1) {
247                $result = $this->image_file == $filename || copy($this->image_file, $filename);
248            }
249            else {
250                $width     = intval($props['width']  * $scale);
251                $height    = intval($props['height'] * $scale);
252                $new_image = imagecreatetruecolor($width, $height);
253
254                if ($new_image === false) {
255                    return false;
256                }
257
258                // Fix transparency of gif/png image
259                if ($props['gd_type'] != IMAGETYPE_JPEG) {
260                    imagealphablending($new_image, false);
261                    imagesavealpha($new_image, true);
262                    $transparent = imagecolorallocatealpha($new_image, 255, 255, 255, 127);
263                    imagefilledrectangle($new_image, 0, 0, $width, $height, $transparent);
264                }
265
266                imagecopyresampled($new_image, $image, 0, 0, 0, 0, $width, $height, $props['width'], $props['height']);
267                $image = $new_image;
268
269                // fix orientation of image if EXIF data exists and specifies orientation (GD strips the EXIF data)
270                if ($this->image_file && $type == 'jpg' && function_exists('exif_read_data')) {
271                    $exif = @exif_read_data($this->image_file);
272                    if ($exif && !empty($exif['Orientation'])) {
273                        switch ($exif['Orientation']) {
274                            case 3:
275                                $image = imagerotate($image, 180, 0);
276                                break;
277                            case 6:
278                                $image = imagerotate($image, -90, 0);
279                                break;
280                            case 8:
281                                $image = imagerotate($image, 90, 0);
282                                break;
283                        }
284                    }
285                }
286
287                if ($props['gd_type'] == IMAGETYPE_JPEG) {
288                    $result = imagejpeg($image, $filename, 75);
289                }
290                elseif($props['gd_type'] == IMAGETYPE_GIF) {
291                    $result = imagegif($image, $filename);
292                }
293                elseif($props['gd_type'] == IMAGETYPE_PNG) {
294                    $result = imagepng($image, $filename, 6, PNG_ALL_FILTERS);
295                }
296            }
297
298            if ($result) {
299                @chmod($filename, 0600);
300                return $type;
301            }
302        }
303
304        // @TODO: print error to the log?
305        return false;
306    }
307
308    /**
309     * Convert image to a given type
310     *
311     * @param int    $type     Destination file type (see class constants)
312     * @param string $filename Output filename (if empty, original file will be used
313     *                         and filename extension will be modified)
314     *
315     * @return bool True on success, False on failure
316     */
317    public function convert($type, $filename = null)
318    {
319        $rcube   = rcube::get_instance();
320        $convert = self::getCommand('im_convert_path');
321
322        if (!$filename) {
323            $filename = $this->image_file;
324
325            // modify extension
326            if ($extension = self::$extensions[$type]) {
327                $filename = preg_replace('/\.[^.]+$/', '', $filename) . '.' . $extension;
328            }
329        }
330
331        // use ImageMagick in command line
332        if ($convert) {
333            $p['in']   = $this->image_file;
334            $p['out']  = $filename;
335            $p['type'] = self::$extensions[$type];
336
337            $result = rcube::exec($convert . ' 2>&1 -colorspace sRGB -strip -flatten -quality 75 {in} {type}:{out}', $p);
338
339            if ($result === '') {
340                chmod($filename, 0600);
341                return true;
342            }
343        }
344
345        // use PHP's Imagick class
346        if (class_exists('Imagick', false)) {
347            try {
348                $image = new Imagick($this->image_file);
349
350                $image->setImageColorspace(Imagick::COLORSPACE_SRGB);
351                $image->setImageCompressionQuality(75);
352                $image->setImageFormat(self::$extensions[$type]);
353                $image->stripImage();
354
355                if ($image->writeImage($filename)) {
356                    @chmod($filename, 0600);
357                    return true;
358                }
359            }
360            catch (Exception $e) {
361                rcube::raise_error($e, true, false);
362            }
363        }
364
365        // use GD extension (TIFF isn't supported)
366        $props = $this->props();
367
368        // do we have enough memory? (#1489937)
369        if (strtoupper(substr(PHP_OS, 0, 3)) == 'WIN' && !$this->mem_check($props)) {
370            return false;
371        }
372
373        if ($props['gd_type']) {
374            if ($props['gd_type'] == IMAGETYPE_JPEG && function_exists('imagecreatefromjpeg')) {
375                $image = imagecreatefromjpeg($this->image_file);
376            }
377            else if ($props['gd_type'] == IMAGETYPE_GIF && function_exists('imagecreatefromgif')) {
378                $image = imagecreatefromgif($this->image_file);
379            }
380            else if ($props['gd_type'] == IMAGETYPE_PNG && function_exists('imagecreatefrompng')) {
381                $image = imagecreatefrompng($this->image_file);
382            }
383            else {
384                // @TODO: print error to the log?
385                return false;
386            }
387
388            if ($type == self::TYPE_JPG) {
389                $result = imagejpeg($image, $filename, 75);
390            }
391            else if ($type == self::TYPE_GIF) {
392                $result = imagegif($image, $filename);
393            }
394            else if ($type == self::TYPE_PNG) {
395                $result = imagepng($image, $filename, 6, PNG_ALL_FILTERS);
396            }
397
398            if (!empty($result)) {
399                @chmod($filename, 0600);
400                return true;
401            }
402        }
403
404        // @TODO: print error to the log?
405        return false;
406    }
407
408    /**
409     * Checks if image format conversion is supported (for specified mimetype).
410     *
411     * @param string $mimetype Mimetype name
412     *
413     * @return bool True if specified format can be converted to another format
414     */
415    public static function is_convertable($mimetype = null)
416    {
417        $rcube = rcube::get_instance();
418
419        // @TODO: check if specified mimetype is really supported
420        return class_exists('Imagick', false) || self::getCommand('im_convert_path');
421    }
422
423    /**
424     * ImageMagick based image properties read.
425     */
426    private function identify()
427    {
428        $rcube = rcube::get_instance();
429
430        // use ImageMagick in command line
431        if ($cmd = self::getCommand('im_identify_path')) {
432            $args = ['in' => $this->image_file, 'format' => "%m %[fx:w] %[fx:h]"];
433            $id   = rcube::exec($cmd . ' 2>/dev/null -format {format} {in}', $args);
434
435            if ($id) {
436                return explode(' ', strtolower($id));
437            }
438        }
439
440        // use PHP's Imagick class
441        if (class_exists('Imagick', false)) {
442            try {
443                $image = new Imagick($this->image_file);
444
445                return [
446                    strtolower($image->getImageFormat()),
447                    $image->getImageWidth(),
448                    $image->getImageHeight(),
449                ];
450            }
451            catch (Exception $e) {
452                // ignore
453            }
454        }
455    }
456
457    /**
458     * Check if we have enough memory to load specified image
459     *
460     * @param array Hash array with image props like channels, width, height
461     *
462     * @return bool True if there's enough memory to process the image, False otherwise
463     */
464    private function mem_check($props)
465    {
466        // image size is unknown, we can't calculate required memory
467        if (!$props['width']) {
468            return true;
469        }
470
471        // channels: CMYK - 4, RGB - 3
472        $multip = ($props['channels'] ?: 3) + 1;
473
474        // calculate image size in memory (in bytes)
475        $size = $props['width'] * $props['height'] * $multip;
476
477        return rcube_utils::mem_check($size);
478    }
479
480    /**
481     * Get the configured command and make sure it is safe to use.
482     * We cannot trust configuration, and escapeshellcmd() is useless.
483     *
484     * @param string $opt_name Configuration option name
485     *
486     * @return bool|string The command or False if not set or invalid
487     */
488    private static function getCommand($opt_name)
489    {
490        static $error = [];
491
492        $cmd = rcube::get_instance()->config->get($opt_name);
493
494        if (empty($cmd)) {
495            return false;
496        }
497
498        if (preg_match('/^(convert|identify)(\.exe)?$/i', $cmd)) {
499            return $cmd;
500        }
501
502        // Executable must exist, also disallow network shares on Windows
503        if ($cmd[0] != "\\" && file_exists($cmd)) {
504            return $cmd;
505        }
506
507        if (empty($error[$opt_name])) {
508            rcube::raise_error("Invalid $opt_name: $cmd", true, false);
509            $error[$opt_name] = true;
510        }
511
512        return false;
513    }
514}
515