Completed
Pull Request — master (#22)
by Chad
01:03
created

Image::resize()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 5
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 4
1
<?php
2
3
namespace TraderInteractive\Util;
4
5
use InvalidArgumentException;
6
use TraderInteractive\Util;
7
8
final class Image
9
{
10
    /**
11
     * @var string
12
     */
13
    const DEFAULT_COLOR = 'white';
14
15
    /**
16
     * @var bool
17
     */
18
    const DEFAULT_UPSIZE = false;
19
20
    /**
21
     * @var bool
22
     */
23
    const DEFAULT_BESTFIT = false;
24
25
    /**
26
     * @var int
27
     */
28
    const DEFAULT_MAX_WIDTH = 10000;
29
30
    /**
31
     * @var int
32
     */
33
    const DEFAULT_MAX_HEIGHT = 10000;
34
35
    /**
36
     * @var bool
37
     */
38
    const DEFAULT_BLUR_BACKGROUND = false;
39
40
    /**
41
     * @var float
42
     */
43
    const DEFAULT_BLUR_VALUE = 15.0;
44
45
    /**
46
     * @var array
47
     */
48
    const DEFAULT_OPTIONS = [
49
        'color' => self::DEFAULT_COLOR,
50
        'upsize' => self::DEFAULT_UPSIZE,
51
        'bestfit' => self::DEFAULT_BESTFIT,
52
        'maxWidth' => self::DEFAULT_MAX_WIDTH,
53
        'maxHeight' => self::DEFAULT_MAX_HEIGHT,
54
        'blurBackground' => self::DEFAULT_BLUR_BACKGROUND,
55
        'blurValue' => self::DEFAULT_BLUR_VALUE,
56
    ];
57
58
    /**
59
     * Calls @see resizeMulti() with $boxWidth and $boxHeight as a single element in $boxSizes
60
     */
61
    public static function resize(\Imagick $source, int $boxWidth, int $boxHeight, array $options = []) : \Imagick
62
    {
63
        $results = self::resizeMulti($source, [['width' => $boxWidth, 'height' => $boxHeight]], $options);
64
        return $results[0];
65
    }
66
67
    /**
68
     * resizes images into a bounding box. Maintains aspect ratio, extra space filled with given color.
69
     *
70
     * @param \Imagick $source source image to resize. Will not modify
71
     * @param array $boxSizes resulting bounding boxes. Each value should be an array with width and height, both
72
     *                        integers
73
     * @param array $options options
74
     *     string color (default white) background color. Any supported from
75
     *         http://www.imagemagick.org/script/color.php#color_names
76
     *     bool upsize (default false) true to upsize the original image or false to upsize just the bounding box
77
     *     bool bestfit (default false) true to resize with the best fit option.
78
     *     int maxWidth (default 10000) max width allowed for $boxWidth
79
     *     int maxHeight (default 10000) max height allowed for $boxHeight
80
     *     bool blurBackground (default false) true to create a composite resized image placed over an enlarged blurred
81
     *                         image of the original.
82
     *
83
     * @return array array of \Imagick objects resized. Keys maintained from $boxSizes
84
     *
85
     * @throws InvalidArgumentException if $options["color"] was not a string
86
     * @throws InvalidArgumentException if $options["upsize"] was not a bool
87
     * @throws InvalidArgumentException if $options["bestfit"] was not a bool
88
     * @throws InvalidArgumentException if $options["maxWidth"] was not an int
89
     * @throws InvalidArgumentException if $options["maxHeight"] was not an int
90
     * @throws InvalidArgumentException if a width in a $boxSizes value was not an int
91
     * @throws InvalidArgumentException if a height in a $boxSizes value was not an int
92
     * @throws InvalidArgumentException if a $boxSizes width was not between 0 and $options["maxWidth"]
93
     * @throws InvalidArgumentException if a $boxSizes height was not between 0 and $options["maxHeight"]
94
     * @throws \Exception
95
     */
96
    public static function resizeMulti(\Imagick $source, array $boxSizes, array $options = []) : array
97
    {
98
        //algorithm inspired from http://today.java.net/pub/a/today/2007/04/03/perils-of-image-getscaledinstance.html
99
        //use of 2x2 binning is arguably the best quality one will get downsizing and is what lots of hardware does in
100
        //the photography field, while being reasonably fast. Upsizing is more subjective but you can't get much
101
        //better than bicubic which is what is used here.
102
103
        $options = $options + self::DEFAULT_OPTIONS;
104
        $color = $options['color'];
105
        Util::ensure(true, is_string($color), InvalidArgumentException::class, ['$options["color"] was not a string']);
106
107
        $upsize = $options['upsize'];
108
        Util::ensure(true, is_bool($upsize), InvalidArgumentException::class, ['$options["upsize"] was not a bool']);
109
110
        $bestfit = $options['bestfit'];
111
        Util::ensure(true, is_bool($bestfit), InvalidArgumentException::class, ['$options["bestfit"] was not a bool']);
112
113
        $blurBackground = $options['blurBackground'];
114
        Util::ensure(
115
            true,
116
            is_bool($blurBackground),
117
            InvalidArgumentException::class,
118
            ['$options["blurBackground"] was not a bool']
119
        );
120
121
        $blurValue = $options['blurValue'];
122
        Util::ensure(
123
            true,
124
            is_float($blurValue),
125
            InvalidArgumentException::class,
126
            ['$options["blurValue"] was not a float']
127
        );
128
        $maxWidth = $options['maxWidth'];
129
        Util::ensure(true, is_int($maxWidth), InvalidArgumentException::class, ['$options["maxWidth"] was not an int']);
130
131
        $maxHeight = $options['maxHeight'];
132
        Util::ensure(
133
            true,
134
            is_int($maxHeight),
135
            InvalidArgumentException::class,
136
            ['$options["maxHeight"] was not an int']
137
        );
138
139
        foreach ($boxSizes as $boxSizeKey => $boxSize) {
140
            if (!isset($boxSize['width']) || !is_int($boxSize['width'])) {
141
                throw new InvalidArgumentException('a width in a $boxSizes value was not an int');
142
            }
143
144
            if (!isset($boxSize['height']) || !is_int($boxSize['height'])) {
145
                throw new InvalidArgumentException('a height in a $boxSizes value was not an int');
146
            }
147
148
            if ($boxSize['width'] > $maxWidth || $boxSize['width'] <= 0) {
149
                throw new InvalidArgumentException('a $boxSizes width was not between 0 and $options["maxWidth"]');
150
            }
151
152
            if ($boxSize['height'] > $maxHeight || $boxSize['height'] <= 0) {
153
                throw new InvalidArgumentException('a $boxSizes height was not between 0 and $options["maxHeight"]');
154
            }
155
        }
156
157
        $results = [];
158
        $cloneCache = [];
159
        foreach ($boxSizes as $boxSizeKey => $boxSize) {
160
            $boxWidth = $boxSize['width'];
161
            $boxHeight = $boxSize['height'];
162
163
            $clone = clone $source;
164
165
            $orientation = $clone->getImageOrientation();
166
            switch ($orientation) {
167
                case \Imagick::ORIENTATION_BOTTOMRIGHT:
168
                    $clone->rotateimage('#fff', 180);
169
                    $clone->stripImage();
170
                    break;
171
                case \Imagick::ORIENTATION_RIGHTTOP:
172
                    $clone->rotateimage('#fff', 90);
173
                    $clone->stripImage();
174
                    break;
175
                case \Imagick::ORIENTATION_LEFTBOTTOM:
176
                    $clone->rotateimage('#fff', -90);
177
                    $clone->stripImage();
178
                    break;
179
            }
180
181
            $width = $clone->getImageWidth();
182
            $height = $clone->getImageHeight();
183
184
            //ratio over 1 is horizontal, under 1 is vertical
185
            $boxRatio = $boxWidth / $boxHeight;
186
            //height should be positive since I didnt find a way you could get zero into imagick
187
            $originalRatio = $width / $height;
188
189
            $targetWidth = null;
0 ignored issues
show
Unused Code introduced by
$targetWidth is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
190
            $targetHeight = null;
0 ignored issues
show
Unused Code introduced by
$targetHeight is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
191
            $targetX = null;
0 ignored issues
show
Unused Code introduced by
$targetX is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
192
            $targetY = null;
0 ignored issues
show
Unused Code introduced by
$targetY is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
193
            if ($width < $boxWidth && $height < $boxHeight && !$upsize) {
194
                $targetWidth = $width;
195
                $targetHeight = $height;
196
                $targetX = ($boxWidth - $width) / 2;
197
                $targetY = ($boxHeight - $height) / 2;
198
            } else {
199
                //if box is more vertical than original
200
                if ($boxRatio < $originalRatio) {
201
                    $targetWidth = $boxWidth;
202
                    $targetHeight = (int)((double)$boxWidth / $originalRatio);
203
                    $targetX = 0;
204
                    $targetY = ($boxHeight - $targetHeight) / 2;
205
                } else {
206
                    $targetWidth = (int)((double)$boxHeight * $originalRatio);
207
                    $targetHeight = $boxHeight;
208
                    $targetX = ($boxWidth - $targetWidth) / 2;
209
                    $targetY = 0;
210
                }
211
            }
212
213
            //do iterative downsize by halfs (2x2 binning is a common name) on dimensions that are bigger than target
214
            //width and height
215
            while (true) {
216
                $widthReduced = false;
217
                $widthIsHalf = false;
218
                if ($width > $targetWidth) {
219
                    $width = (int)($width / 2);
220
                    $widthReduced = true;
221
                    $widthIsHalf = true;
222
                    if ($width < $targetWidth) {
223
                        $width = $targetWidth;
224
                        $widthIsHalf = false;
225
                    }
226
                }
227
228
                $heightReduced = false;
229
                $heightIsHalf = false;
230
                if ($height > $targetHeight) {
231
                    $height = (int)($height / 2);
232
                    $heightReduced = true;
233
                    $heightIsHalf = true;
234
                    if ($height < $targetHeight) {
235
                        $height = $targetHeight;
236
                        $heightIsHalf = false;
237
                    }
238
                }
239
240
                if (!$widthReduced && !$heightReduced) {
241
                    break;
242
                }
243
244
                $cacheKey = "{$width}x{$height}";
245
                if (isset($cloneCache[$cacheKey])) {
246
                    $clone = clone $cloneCache[$cacheKey];
247
                    continue;
248
                }
249
250
                if ($clone->resizeImage($width, $height, \Imagick::FILTER_BOX, 1.0) !== true) {
251
                    //cumbersome to test
252
                    throw new \Exception('Imagick::resizeImage() did not return true');//@codeCoverageIgnore
253
                }
254
255
                if ($widthIsHalf && $heightIsHalf) {
256
                    $cloneCache[$cacheKey] = clone $clone;
257
                }
258
            }
259
260
            if ($upsize && ($width < $targetWidth || $height < $targetHeight)) {
261
                if ($clone->resizeImage($targetWidth, $targetHeight, \Imagick::FILTER_CUBIC, 1.0, $bestfit) !== true) {
262
                    //cumbersome to test
263
                    throw new \Exception('Imagick::resizeImage() did not return true');//@codeCoverageIgnore
264
                }
265
            }
266
267
            //put image in box
268
            $canvas = self::getBackgroundCanvas($source, $color, $blurBackground, $blurValue, $boxWidth, $boxHeight);
269
            if ($canvas->compositeImage($clone, \Imagick::COMPOSITE_ATOP, $targetX, $targetY) !== true) {
270
                //cumbersome to test
271
                throw new \Exception('Imagick::compositeImage() did not return true');//@codeCoverageIgnore
272
            }
273
274
            //reason we are not supporting the options in self::write() here is because format, and strip headers are
275
            //only relevant once written Imagick::stripImage() doesnt even have an effect until written
276
            //also the user can just call that function with the resultant $canvas
277
            $results[$boxSizeKey] = $canvas;
278
        }
279
280
        return $results;
281
    }
282
283
    private static function getBackgroundCanvas(
284
        \Imagick $source,
285
        string $color,
286
        bool $blurBackground,
287
        float $blurValue,
288
        int $boxWidth,
289
        int $boxHeight
290
    ) : \Imagick {
291
        if ($blurBackground) {
292
            return self::getBlurredBackgroundCanvas($source, $blurValue, $boxWidth, $boxHeight);
293
        }
294
295
        return self::getColoredBackgroundCanvas($color, $boxWidth, $boxHeight);
296
    }
297
298
    private static function getColoredBackgroundCanvas(string $color, int $boxWidth, int $boxHeight)
299
    {
300
        $canvas = new \Imagick();
301
        $imageCreated = $canvas->newImage($boxWidth, $boxHeight, $color);
302
        Util::ensure(true, $imageCreated, 'Imagick::newImage() did not return true');
303
        return $canvas;
304
    }
305
306
    private static function getBlurredBackgroundCanvas(
307
        \Imagick $source,
308
        float $blurValue,
309
        int $boxWidth,
310
        int $boxHeight
311
    ) : \Imagick {
312
        $canvas = clone $source;
313
        $canvas->resizeImage($boxWidth, $boxHeight, \Imagick::FILTER_BOX, $blurValue, false);
314
        return $canvas;
315
    }
316
317
    /**
318
     * write $source to $destPath with $options applied
319
     *
320
     * @param \Imagick $source source image. Will not modify
321
     * @param string $destPath destination image path
322
     * @param array $options options
323
     *     string format        (default jpeg) Any from http://www.imagemagick.org/script/formats.php#supported
324
     *     int    directoryMode (default 0777) chmod mode for any parent directories created
325
     *     int    fileMode      (default 0777) chmod mode for the resized image file
326
     *     bool   stripHeaders  (default true) whether to strip headers (exif, etc). Is only reflected in $destPath,
327
     *                                         not returned clone
328
     *
329
     * @return void
330
     *
331
     * @throws InvalidArgumentException if $destPath was not a string
332
     * @throws InvalidArgumentException if $options["format"] was not a string
333
     * @throws InvalidArgumentException if $options["directoryMode"] was not an int
334
     * @throws InvalidArgumentException if $options["fileMode"] was not an int
335
     * @throws InvalidArgumentException if $options["stripHeaders"] was not a bool
336
     * @throws \Exception
337
     */
338
    public static function write(\Imagick $source, string $destPath, array $options = [])
339
    {
340
        $format = 'jpeg';
341
        if (array_key_exists('format', $options)) {
342
            $format = $options['format'];
343
            if (!is_string($format)) {
344
                throw new InvalidArgumentException('$options["format"] was not a string');
345
            }
346
        }
347
348
        $directoryMode = 0777;
349
        if (array_key_exists('directoryMode', $options)) {
350
            $directoryMode = $options['directoryMode'];
351
            if (!is_int($directoryMode)) {
352
                throw new InvalidArgumentException('$options["directoryMode"] was not an int');
353
            }
354
        }
355
356
        $fileMode = 0777;
357
        if (array_key_exists('fileMode', $options)) {
358
            $fileMode = $options['fileMode'];
359
            if (!is_int($fileMode)) {
360
                throw new InvalidArgumentException('$options["fileMode"] was not an int');
361
            }
362
        }
363
364
        $stripHeaders = true;
365
        if (array_key_exists('stripHeaders', $options)) {
366
            $stripHeaders = $options['stripHeaders'];
367
            if ($stripHeaders !== false && $stripHeaders !== true) {
368
                throw new InvalidArgumentException('$options["stripHeaders"] was not a bool');
369
            }
370
        }
371
372
        $destDir = dirname($destPath);
373
        if (!is_dir($destDir)) {
374
            $oldUmask = umask(0);
375
            if (!mkdir($destDir, $directoryMode, true)) {
376
                //cumbersome to test
377
                throw new \Exception('mkdir() returned false');//@codeCoverageIgnore
378
            }
379
380
            umask($oldUmask);
381
        }
382
383
        $clone = clone $source;
384
385
        if ($clone->setImageFormat($format) !== true) {
386
            //cumbersome to test
387
            throw new \Exception('Imagick::setImageFormat() did not return true');//@codeCoverageIgnore
388
        }
389
390
        if ($stripHeaders && $clone->stripImage() !== true) {
391
            //cumbersome to test
392
            throw new \Exception('Imagick::stripImage() did not return true');//@codeCoverageIgnore
393
        }
394
395
        if ($clone->writeImage($destPath) !== true) {
396
            //cumbersome to test
397
            throw new \Exception('Imagick::writeImage() did not return true');//@codeCoverageIgnore
398
        }
399
400
        if (!chmod($destPath, $fileMode)) {
401
            //cumbersome to test
402
            throw new \Exception('chmod() returned false');//@codeCoverageIgnore
403
        }
404
    }
405
406
    /**
407
     * Strips the headers (exif, etc) from an image at the given path.
408
     *
409
     * @param string $path The image path.
410
     * @return void
411
     * @throws InvalidArgumentException if $path is not a string
412
     * @throws \Exception if there is a failure stripping the headers
413
     * @throws \Exception if there is a failure writing the image back to path
414
     */
415
    public static function stripHeaders(string $path)
416
    {
417
        $imagick = new \Imagick($path);
418
        if ($imagick->stripImage() !== true) {
419
            //cumbersome to test
420
            throw new \Exception('Imagick::stripImage() did not return true');//@codeCoverageIgnore
421
        }
422
423
        if ($imagick->writeImage($path) !== true) {
424
            //cumbersome to test
425
            throw new \Exception('Imagick::writeImage() did not return true');//@codeCoverageIgnore
426
        }
427
    }
428
}
429