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

Image::getColoredBackgroundCanvas()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 7
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 3
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 array
42
     */
43
    const DEFAULT_OPTIONS = [
44
        'color' => self::DEFAULT_COLOR,
45
        'upsize' => self::DEFAULT_UPSIZE,
46
        'bestfit' => self::DEFAULT_BESTFIT,
47
        'maxWidth' => self::DEFAULT_MAX_WIDTH,
48
        'maxHeight' => self::DEFAULT_MAX_HEIGHT,
49
        'blurBackground' => self::DEFAULT_BLUR_BACKGROUND,
50
    ];
51
52
    /**
53
     * Calls @see resizeMulti() with $boxWidth and $boxHeight as a single element in $boxSizes
54
     */
55
    public static function resize(\Imagick $source, int $boxWidth, int $boxHeight, array $options = []) : \Imagick
56
    {
57
        $results = self::resizeMulti($source, [['width' => $boxWidth, 'height' => $boxHeight]], $options);
58
        return $results[0];
59
    }
60
61
    /**
62
     * resizes images into a bounding box. Maintains aspect ratio, extra space filled with given color.
63
     *
64
     * @param \Imagick $source source image to resize. Will not modify
65
     * @param array $boxSizes resulting bounding boxes. Each value should be an array with width and height, both
66
     *                        integers
67
     * @param array $options options
68
     *     string color (default white) background color. Any supported from
69
     *         http://www.imagemagick.org/script/color.php#color_names
70
     *     bool upsize (default false) true to upsize the original image or false to upsize just the bounding box
71
     *     bool bestfit (default false) true to resize with the best fit option.
72
     *     int maxWidth (default 10000) max width allowed for $boxWidth
73
     *     int maxHeight (default 10000) max height allowed for $boxHeight
74
     *     bool blurBackground (default false) true to create a composite resized image placed over an enlarged blurred
75
     *                         image of the original.
76
     *
77
     * @return array array of \Imagick objects resized. Keys maintained from $boxSizes
78
     *
79
     * @throws InvalidArgumentException if $options["color"] was not a string
80
     * @throws InvalidArgumentException if $options["upsize"] was not a bool
81
     * @throws InvalidArgumentException if $options["bestfit"] was not a bool
82
     * @throws InvalidArgumentException if $options["maxWidth"] was not an int
83
     * @throws InvalidArgumentException if $options["maxHeight"] was not an int
84
     * @throws InvalidArgumentException if a width in a $boxSizes value was not an int
85
     * @throws InvalidArgumentException if a height in a $boxSizes value was not an int
86
     * @throws InvalidArgumentException if a $boxSizes width was not between 0 and $options["maxWidth"]
87
     * @throws InvalidArgumentException if a $boxSizes height was not between 0 and $options["maxHeight"]
88
     * @throws \Exception
89
     */
90
    public static function resizeMulti(\Imagick $source, array $boxSizes, array $options = []) : array
91
    {
92
        //algorithm inspired from http://today.java.net/pub/a/today/2007/04/03/perils-of-image-getscaledinstance.html
93
        //use of 2x2 binning is arguably the best quality one will get downsizing and is what lots of hardware does in
94
        //the photography field, while being reasonably fast. Upsizing is more subjective but you can't get much
95
        //better than bicubic which is what is used here.
96
97
        $options = $options + self::DEFAULT_OPTIONS;
98
        $color = $options['color'];
99
        Util::ensure(true, is_string($color), InvalidArgumentException::class, ['$options["color"] was not a string']);
100
101
        $upsize = $options['upsize'];
102
        Util::ensure(true, is_bool($upsize), InvalidArgumentException::class, ['$options["upsize"] was not a bool']);
103
104
        $bestfit = $options['bestfit'];
105
        Util::ensure(true, is_bool($bestfit), InvalidArgumentException::class, ['$options["bestfit"] was not a bool']);
106
107
        $blurBackground = $options['blurBackground'];
108
        Util::ensure(
109
            true,
110
            is_bool($blurBackground),
111
            InvalidArgumentException::class,
112
            ['$options["blurBackground"] was not a bool']
113
        );
114
115
        $maxWidth = $options['maxWidth'];
116
        Util::ensure(true, is_int($maxWidth), InvalidArgumentException::class, ['$options["maxWidth"] was not an int']);
117
118
        $maxHeight = $options['maxHeight'];
119
        Util::ensure(
120
            true,
121
            is_int($maxHeight),
122
            InvalidArgumentException::class,
123
            ['$options["maxHeight"] was not an int']
124
        );
125
126
        foreach ($boxSizes as $boxSizeKey => $boxSize) {
127
            if (!isset($boxSize['width']) || !is_int($boxSize['width'])) {
128
                throw new InvalidArgumentException('a width in a $boxSizes value was not an int');
129
            }
130
131
            if (!isset($boxSize['height']) || !is_int($boxSize['height'])) {
132
                throw new InvalidArgumentException('a height in a $boxSizes value was not an int');
133
            }
134
135
            if ($boxSize['width'] > $maxWidth || $boxSize['width'] <= 0) {
136
                throw new InvalidArgumentException('a $boxSizes width was not between 0 and $options["maxWidth"]');
137
            }
138
139
            if ($boxSize['height'] > $maxHeight || $boxSize['height'] <= 0) {
140
                throw new InvalidArgumentException('a $boxSizes height was not between 0 and $options["maxHeight"]');
141
            }
142
        }
143
144
        $results = [];
145
        $cloneCache = [];
146
        foreach ($boxSizes as $boxSizeKey => $boxSize) {
147
            $boxWidth = $boxSize['width'];
148
            $boxHeight = $boxSize['height'];
149
150
            $clone = clone $source;
151
152
            $orientation = $clone->getImageOrientation();
153
            switch ($orientation) {
154
                case \Imagick::ORIENTATION_BOTTOMRIGHT:
155
                    $clone->rotateimage('#fff', 180);
156
                    $clone->stripImage();
157
                    break;
158
                case \Imagick::ORIENTATION_RIGHTTOP:
159
                    $clone->rotateimage('#fff', 90);
160
                    $clone->stripImage();
161
                    break;
162
                case \Imagick::ORIENTATION_LEFTBOTTOM:
163
                    $clone->rotateimage('#fff', -90);
164
                    $clone->stripImage();
165
                    break;
166
            }
167
168
            $width = $clone->getImageWidth();
169
            $height = $clone->getImageHeight();
170
171
            //ratio over 1 is horizontal, under 1 is vertical
172
            $boxRatio = $boxWidth / $boxHeight;
173
            //height should be positive since I didnt find a way you could get zero into imagick
174
            $originalRatio = $width / $height;
175
176
            $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...
177
            $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...
178
            $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...
179
            $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...
180
            if ($width < $boxWidth && $height < $boxHeight && !$upsize) {
181
                $targetWidth = $width;
182
                $targetHeight = $height;
183
                $targetX = ($boxWidth - $width) / 2;
184
                $targetY = ($boxHeight - $height) / 2;
185
            } else {
186
                //if box is more vertical than original
187
                if ($boxRatio < $originalRatio) {
188
                    $targetWidth = $boxWidth;
189
                    $targetHeight = (int)((double)$boxWidth / $originalRatio);
190
                    $targetX = 0;
191
                    $targetY = ($boxHeight - $targetHeight) / 2;
192
                } else {
193
                    $targetWidth = (int)((double)$boxHeight * $originalRatio);
194
                    $targetHeight = $boxHeight;
195
                    $targetX = ($boxWidth - $targetWidth) / 2;
196
                    $targetY = 0;
197
                }
198
            }
199
200
            //do iterative downsize by halfs (2x2 binning is a common name) on dimensions that are bigger than target
201
            //width and height
202
            while (true) {
203
                $widthReduced = false;
204
                $widthIsHalf = false;
205
                if ($width > $targetWidth) {
206
                    $width = (int)($width / 2);
207
                    $widthReduced = true;
208
                    $widthIsHalf = true;
209
                    if ($width < $targetWidth) {
210
                        $width = $targetWidth;
211
                        $widthIsHalf = false;
212
                    }
213
                }
214
215
                $heightReduced = false;
216
                $heightIsHalf = false;
217
                if ($height > $targetHeight) {
218
                    $height = (int)($height / 2);
219
                    $heightReduced = true;
220
                    $heightIsHalf = true;
221
                    if ($height < $targetHeight) {
222
                        $height = $targetHeight;
223
                        $heightIsHalf = false;
224
                    }
225
                }
226
227
                if (!$widthReduced && !$heightReduced) {
228
                    break;
229
                }
230
231
                $cacheKey = "{$width}x{$height}";
232
                if (isset($cloneCache[$cacheKey])) {
233
                    $clone = clone $cloneCache[$cacheKey];
234
                    continue;
235
                }
236
237
                if ($clone->resizeImage($width, $height, \Imagick::FILTER_BOX, 1.0) !== true) {
238
                    //cumbersome to test
239
                    throw new \Exception('Imagick::resizeImage() did not return true');//@codeCoverageIgnore
240
                }
241
242
                if ($widthIsHalf && $heightIsHalf) {
243
                    $cloneCache[$cacheKey] = clone $clone;
244
                }
245
            }
246
247
            if ($upsize && ($width < $targetWidth || $height < $targetHeight)) {
248
                if ($clone->resizeImage($targetWidth, $targetHeight, \Imagick::FILTER_CUBIC, 1.0, $bestfit) !== true) {
249
                    //cumbersome to test
250
                    throw new \Exception('Imagick::resizeImage() did not return true');//@codeCoverageIgnore
251
                }
252
            }
253
254
            //put image in box
255
            $canvas = self::getBackgroundCanvas($source, $color, $blurBackground, $boxWidth, $boxHeight);
256
            if ($canvas->compositeImage($clone, \Imagick::COMPOSITE_ATOP, $targetX, $targetY) !== true) {
257
                //cumbersome to test
258
                throw new \Exception('Imagick::compositeImage() did not return true');//@codeCoverageIgnore
259
            }
260
261
            //reason we are not supporting the options in self::write() here is because format, and strip headers are
262
            //only relevant once written Imagick::stripImage() doesnt even have an effect until written
263
            //also the user can just call that function with the resultant $canvas
264
            $results[$boxSizeKey] = $canvas;
265
        }
266
267
        return $results;
268
    }
269
270
    private static function getBackgroundCanvas(
271
        \Imagick $source,
272
        string $color,
273
        bool $blurBackground,
274
        int $boxWidth,
275
        int $boxHeight
276
    ) : \Imagick {
277
        if ($blurBackground) {
278
            return self::getBlurredBackgroundCanvas($source, $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
        int $boxWidth,
295
        int $boxHeight
296
    ) : \Imagick {
297
        $canvas = clone $source;
298
        $canvas->resizeImage($boxWidth, $boxHeight, \Imagick::FILTER_BOX, 15.0, false);
299
        return $canvas;
300
    }
301
302
    /**
303
     * write $source to $destPath with $options applied
304
     *
305
     * @param \Imagick $source source image. Will not modify
306
     * @param string $destPath destination image path
307
     * @param array $options options
308
     *     string format        (default jpeg) Any from http://www.imagemagick.org/script/formats.php#supported
309
     *     int    directoryMode (default 0777) chmod mode for any parent directories created
310
     *     int    fileMode      (default 0777) chmod mode for the resized image file
311
     *     bool   stripHeaders  (default true) whether to strip headers (exif, etc). Is only reflected in $destPath,
312
     *                                         not returned clone
313
     *
314
     * @return void
315
     *
316
     * @throws InvalidArgumentException if $destPath was not a string
317
     * @throws InvalidArgumentException if $options["format"] was not a string
318
     * @throws InvalidArgumentException if $options["directoryMode"] was not an int
319
     * @throws InvalidArgumentException if $options["fileMode"] was not an int
320
     * @throws InvalidArgumentException if $options["stripHeaders"] was not a bool
321
     * @throws \Exception
322
     */
323
    public static function write(\Imagick $source, string $destPath, array $options = [])
324
    {
325
        $format = 'jpeg';
326
        if (array_key_exists('format', $options)) {
327
            $format = $options['format'];
328
            if (!is_string($format)) {
329
                throw new InvalidArgumentException('$options["format"] was not a string');
330
            }
331
        }
332
333
        $directoryMode = 0777;
334
        if (array_key_exists('directoryMode', $options)) {
335
            $directoryMode = $options['directoryMode'];
336
            if (!is_int($directoryMode)) {
337
                throw new InvalidArgumentException('$options["directoryMode"] was not an int');
338
            }
339
        }
340
341
        $fileMode = 0777;
342
        if (array_key_exists('fileMode', $options)) {
343
            $fileMode = $options['fileMode'];
344
            if (!is_int($fileMode)) {
345
                throw new InvalidArgumentException('$options["fileMode"] was not an int');
346
            }
347
        }
348
349
        $stripHeaders = true;
350
        if (array_key_exists('stripHeaders', $options)) {
351
            $stripHeaders = $options['stripHeaders'];
352
            if ($stripHeaders !== false && $stripHeaders !== true) {
353
                throw new InvalidArgumentException('$options["stripHeaders"] was not a bool');
354
            }
355
        }
356
357
        $destDir = dirname($destPath);
358
        if (!is_dir($destDir)) {
359
            $oldUmask = umask(0);
360
            if (!mkdir($destDir, $directoryMode, true)) {
361
                //cumbersome to test
362
                throw new \Exception('mkdir() returned false');//@codeCoverageIgnore
363
            }
364
365
            umask($oldUmask);
366
        }
367
368
        $clone = clone $source;
369
370
        if ($clone->setImageFormat($format) !== true) {
371
            //cumbersome to test
372
            throw new \Exception('Imagick::setImageFormat() did not return true');//@codeCoverageIgnore
373
        }
374
375
        if ($stripHeaders && $clone->stripImage() !== true) {
376
            //cumbersome to test
377
            throw new \Exception('Imagick::stripImage() did not return true');//@codeCoverageIgnore
378
        }
379
380
        if ($clone->writeImage($destPath) !== true) {
381
            //cumbersome to test
382
            throw new \Exception('Imagick::writeImage() did not return true');//@codeCoverageIgnore
383
        }
384
385
        if (!chmod($destPath, $fileMode)) {
386
            //cumbersome to test
387
            throw new \Exception('chmod() returned false');//@codeCoverageIgnore
388
        }
389
    }
390
391
    /**
392
     * Strips the headers (exif, etc) from an image at the given path.
393
     *
394
     * @param string $path The image path.
395
     * @return void
396
     * @throws InvalidArgumentException if $path is not a string
397
     * @throws \Exception if there is a failure stripping the headers
398
     * @throws \Exception if there is a failure writing the image back to path
399
     */
400
    public static function stripHeaders(string $path)
401
    {
402
        $imagick = new \Imagick($path);
403
        if ($imagick->stripImage() !== true) {
404
            //cumbersome to test
405
            throw new \Exception('Imagick::stripImage() did not return true');//@codeCoverageIgnore
406
        }
407
408
        if ($imagick->writeImage($path) !== true) {
409
            //cumbersome to test
410
            throw new \Exception('Imagick::writeImage() did not return true');//@codeCoverageIgnore
411
        }
412
    }
413
}
414