Completed
Push — master ( 0a065e...fb6930 )
by
unknown
19s queued 11s
created

Image::rotateImage()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

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