Passed
Push — development ( 319958...42896e )
by Spuds
01:19 queued 20s
created

ImageMagick   F

Complexity

Total Complexity 81

Size/Duplication

Total Lines 603
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 223
c 2
b 0
f 0
dl 0
loc 603
rs 2
wmc 81

16 Methods

Rating   Name   Duplication   Size   Complexity  
A canUse() 0 3 1
A createImageFromFile() 0 28 5
A createImageFromWeb() 0 30 4
A __construct() 0 10 2
A _setImage() 0 14 2
C autoRotate() 0 58 12
A generateTextImage() 0 38 4
A hasWebpSupport() 0 5 1
A checkOpacityPixelInspection() 0 31 5
A checkOpacityChannel() 0 22 5
A getTransparency() 0 38 6
A __destruct() 0 13 3
A resizeGifImage() 0 16 2
A getOrientation() 0 12 2
C resizeImage() 0 63 13
C output() 0 71 14

How to fix   Complexity   

Complex Class

Complex classes like ImageMagick often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use ImageMagick, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * This file deals with low-level graphics operations performed on images,
5
 * specifically as needed for the ImageMagick library
6
 *
7
 * @package   ElkArte Forum
8
 * @copyright ElkArte Forum contributors
9
 * @license   BSD http://opensource.org/licenses/BSD-3-Clause (see accompanying LICENSE.txt file)
10
 *
11
 * @version 2.0 dev
12
 *
13
 */
14
15
namespace ElkArte\Graphics\Manipulators;
16
17
use ElkArte\Graphics\Image;
18
use Exception;
19
use Imagick;
20
use ImagickException;
21
use ImagickPixel;
22
23
/**
24
 * Class ImageMagick
25
 *
26
 * Note: This class will load and save an animated gif, however, any manipulation will remove said animation.
27
 * It currently only provides validation/inspection functions (should you want to keep the animation intact).
28
 *
29
 * @package ElkArte\Graphics
30
 */
31
class ImageMagick extends AbstractManipulator
32
{
33
	/** @var Imagick */
34
	protected $_image;
35
36
	/**
37
	 * ImageMagick constructor.
38
	 *
39
	 * @param string $image
40
	 */
41
	public function __construct($image)
42
	{
43
		$this->_fileName = $image;
44
45
		try
46
		{
47
			$this->memoryCheck();
48
		}
49
		catch (Exception)
50
		{
51
			// Just pass through
52
		}
53
	}
54
55
	/**
56
	 * Checks whether the ImageMagick class is present.
57
	 *
58
	 * @return bool Whether the ImageMagick extension is available.
59
	 */
60
	public static function canUse()
61
	{
62
		return class_exists(Imagick::class, false);
63
	}
64
65
	/**
66
	 * Loads an image file into the image engine for processing
67
	 *
68
	 * @return bool
69
	 */
70
	public function createImageFromFile()
71
	{
72
		$this->setImageDimensions();
73
74
		if ($this->imageDimensions[2] === IMAGETYPE_WEBP && !$this->hasWebpSupport())
75
		{
76
			return false;
77
		}
78
79
		if (isset(Image::DEFAULT_FORMATS[$this->imageDimensions[2]]))
80
		{
81
			try
82
			{
83
				$this->_image = new Imagick($this->_fileName);
84
			}
85
			catch (Exception)
86
			{
87
				return false;
88
			}
89
		}
90
		else
91
		{
92
			return false;
93
		}
94
95
		$this->_setImage();
96
97
		return true;
98
	}
99
100
	/**
101
	 * Sets the image sizes.
102
	 */
103
	protected function _setImage(): void
104
	{
105
		// Update the image size values
106
		$this->_image->setFirstIterator();
107
108
		try
109
		{
110
			$this->_width = $this->imageDimensions[0] = $this->_image->getImageWidth();
111
			$this->_height = $this->imageDimensions[1] = $this->_image->getImageHeight();
112
		}
113
		catch (ImagickException)
114
		{
115
			$this->_width = $this->imageDimensions[0] ?? 0;
116
			$this->_height = $this->imageDimensions[1] ?? 0;
117
		}
118
	}
119
120
	/**
121
	 * Loads an image from a web address into the image engine for processing
122
	 *
123
	 * @return bool
124
	 */
125
	public function createImageFromWeb()
126
	{
127
		require_once(SUBSDIR . '/Package.subs.php');
128
		$image_data = fetch_web_data($this->_fileName);
129
		if ($image_data === false)
130
		{
131
			return false;
132
		}
133
134
		$this->setImageDimensions('string', $image_data);
135
		if (isset(Image::DEFAULT_FORMATS[$this->imageDimensions[2]]))
136
		{
137
			try
138
			{
139
				$this->_image = new Imagick();
140
				$this->_image->readImageBlob($image_data);
141
			}
142
			catch (ImagickException)
143
			{
144
				return false;
145
			}
146
		}
147
		else
148
		{
149
			return false;
150
		}
151
152
		$this->_setImage();
153
154
		return true;
155
	}
156
157
	/**
158
	 * Resize an image proportionally to fit within the defined max_width and max_height limits
159
	 *
160
	 * What it does:
161
	 *
162
	 * - Will do nothing to the image if the file fits within the size limits
163
	 *
164
	 * @param int|null $max_width The maximum allowed width
165
	 * @param int|null $max_height The maximum allowed height
166
	 * @param bool $strip Whether to have IM strip EXIF data as GD will
167
	 * @param bool $force_resize = false Whether to override defaults and resize it
168
	 * @param bool $thumbnail True if creating a simple thumbnail
169
	 *
170
	 * @return bool Whether resize was successful.
171
	 */
172
	public function resizeImage($max_width = null, $max_height = null, $strip = false, $force_resize = false, $thumbnail = false)
173
	{
174
		$success = true;
175
176
		// No image, no further
177
		if (empty($this->_image))
178
		{
179
			return false;
180
		}
181
182
		// Set the input and output image size
183
		$src_width = $this->_width;
184
		$src_height = $this->_height;
185
186
		// Allow for a re-encode to the same size
187
		$max_width = $max_width ?? $src_width;
188
		$max_height = $max_height ?? $src_height;
189
190
		// Determine whether to resize to max width or to max height (depending on the limits.)
191
		[$dst_width, $dst_height] = $this->imageRatio($max_width, $max_height);
192
193
		// Don't bother resizing if it's already smaller...
194
		if (!empty($dst_width) && !empty($dst_height) && ($dst_width < $src_width || $dst_height < $src_height || $force_resize))
195
		{
196
			try
197
			{
198
				if ($thumbnail)
199
				{
200
					$success = $this->_image->thumbnailImage($dst_width, $dst_height, true);
0 ignored issues
show
Bug introduced by
$dst_width of type double is incompatible with the type integer expected by parameter $columns of Imagick::thumbnailImage(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

200
					$success = $this->_image->thumbnailImage(/** @scrutinizer ignore-type */ $dst_width, $dst_height, true);
Loading history...
Bug introduced by
$dst_height of type double is incompatible with the type integer expected by parameter $rows of Imagick::thumbnailImage(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

200
					$success = $this->_image->thumbnailImage($dst_width, /** @scrutinizer ignore-type */ $dst_height, true);
Loading history...
201
				}
202
				elseif ($this->_image->getNumberImages() > 1)
203
				{
204
					// Animated GIFs are a special case, they need to be resized individually
205
					$success = $this->resizeGifImage($dst_width, $dst_height);
0 ignored issues
show
Bug introduced by
$dst_width of type double is incompatible with the type integer expected by parameter $dst_width of ElkArte\Graphics\Manipul...agick::resizeGifImage(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

205
					$success = $this->resizeGifImage(/** @scrutinizer ignore-type */ $dst_width, $dst_height);
Loading history...
Bug introduced by
$dst_height of type double is incompatible with the type integer expected by parameter $dst_height of ElkArte\Graphics\Manipul...agick::resizeGifImage(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

205
					$success = $this->resizeGifImage($dst_width, /** @scrutinizer ignore-type */ $dst_height);
Loading history...
206
				}
207
				else
208
				{
209
					$success = $this->_image->resizeImage($dst_width, $dst_height, Imagick::FILTER_LANCZOS, .9891, true);
0 ignored issues
show
Bug introduced by
$dst_height of type double is incompatible with the type integer expected by parameter $rows of Imagick::resizeImage(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

209
					$success = $this->_image->resizeImage($dst_width, /** @scrutinizer ignore-type */ $dst_height, Imagick::FILTER_LANCZOS, .9891, true);
Loading history...
Bug introduced by
$dst_width of type double is incompatible with the type integer expected by parameter $columns of Imagick::resizeImage(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

209
					$success = $this->_image->resizeImage(/** @scrutinizer ignore-type */ $dst_width, $dst_height, Imagick::FILTER_LANCZOS, .9891, true);
Loading history...
210
				}
211
			}
212
			catch (ImagickException)
213
			{
214
				return false;
215
			}
216
217
			$this->_setImage();
218
		}
219
220
		// Remove EXIF / ICC data?
221
		if ($strip)
222
		{
223
			try
224
			{
225
				$success = $this->_image->stripImage() && $success;
226
			}
227
			catch (ImagickException)
228
			{
229
				return $success;
230
			}
231
232
		}
233
234
		return $success;
235
	}
236
237
	/**
238
	 * Resizes a GIF image to the specified dimensions, adjusting each frame individually.
239
	 *
240
	 * @param int $dst_width The desired width of the resized image.
241
	 * @param int $dst_height The desired height of the resized image.
242
	 * @return bool Indicates whether the resizing operation was successful for all frames.
243
	 */
244
	public function resizeGifImage($dst_width, $dst_height)
245
	{
246
		$success = true;
247
248
		// Explode the gif so each frame is a full image
249
		$this->_image = $this->_image->coalesceImages();
250
251
		// Resize every frame individually
252
		foreach ($this->_image as $frame)
253
		{
254
			$success .= $frame->resizeImage($dst_width, $dst_height, \Imagick::FILTER_LANCZOS, .9891, true);
255
		}
256
257
		$this->_image = $this->_image->deconstructImages();
258
259
		return $success;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $success also could return the type string which is incompatible with the documented return type boolean.
Loading history...
260
	}
261
262
	/**
263
	 * Output the image resource to a file in a chosen format
264
	 *
265
	 * @param string $file_name where to save the image, if '' output to screen
266
	 * @param int $preferred_format jpg,png,gif, etc
267
	 * @param int $quality the jpg image quality
268
	 *
269
	 * @return bool
270
	 */
271
	public function output($file_name = '', $preferred_format = IMAGETYPE_JPEG, $quality = 85)
272
	{
273
		// An unknown type
274
		if (!isset(Image::DEFAULT_FORMATS[$preferred_format]))
275
		{
276
			return false;
277
		}
278
279
		switch ($preferred_format)
280
		{
281
			case IMAGETYPE_GIF:
282
				$success = $this->_image->setImageFormat('gif');
283
				break;
284
			case IMAGETYPE_PNG:
285
				// Save a few bytes the only way, realistically, we can
286
				$this->_image->setOption('png:compression-level', '9');
287
				$this->_image->setOption('png:exclude-chunk', 'all');
288
				$success = $this->_image->setImageFormat('png');
289
				break;
290
			case IMAGETYPE_WBMP:
291
				$success = $this->_image->setImageFormat('wbmp');
292
				break;
293
			case IMAGETYPE_BMP:
294
				$success = $this->_image->setImageFormat('bmp');
295
				break;
296
			case IMAGETYPE_WEBP:
297
				$this->_image->setImageCompressionQuality($quality);
298
				$success = $this->_image->setImageFormat('webp');
299
				break;
300
			default:
301
				$this->_image->borderImage('white', 0, 0);
302
				$this->_image->setImageCompression(Imagick::COMPRESSION_JPEG);
303
				$this->_image->setImageCompressionQuality($quality);
304
				$success = $this->_image->setImageFormat('jpeg');
305
				break;
306
		}
307
308
		try
309
		{
310
			if ($success)
311
			{
312
				// Screen or file, your choice
313
				if (empty($file_name))
314
				{
315
					echo $this->_image->getImagesBlob();
316
				}
317
				elseif ($preferred_format === IMAGETYPE_GIF && $this->_image->getNumberImages() !== 0)
318
				{
319
					// Write all animated gif frames
320
					$success = $this->_image->writeImages($file_name, true);
321
				}
322
				else
323
				{
324
					$success = $this->_image->writeImage($file_name);
325
				}
326
			}
327
		}
328
		catch (Exception)
329
		{
330
			return false;
331
		}
332
333
		// Update the sizes array to the output file
334
		if ($success && $file_name !== '')
335
		{
336
			$this->_fileName = $file_name;
337
			$this->imageDimensions[2] = $preferred_format;
338
			$this->_setImage();
339
		}
340
341
		return $success;
342
	}
343
344
	/**
345
	 * Autorotate an image based on its EXIF Orientation tag.
346
	 *
347
	 * What it does:
348
	 *
349
	 * - Checks exif data for orientation flag and rotates image so its proper
350
	 * - Updates orientation flag if rotation was required
351
	 *
352
	 * @return bool
353
	 */
354
	public function autoRotate()
355
	{
356
		try
357
		{
358
			switch ($this->orientation)
359
			{
360
				// 0 & 1 Not set or Normal
361
				case Imagick::ORIENTATION_UNDEFINED:
362
				case Imagick::ORIENTATION_TOPLEFT:
363
					break;
364
				// 2 Mirror image, Normal orientation
365
				case Imagick::ORIENTATION_TOPRIGHT:
366
					$this->_image->flopImage();
367
					break;
368
				// 3 Normal image, rotated 180
369
				case Imagick::ORIENTATION_BOTTOMRIGHT:
370
					$this->_image->rotateImage(new ImagickPixel('#00000000'), 180);
371
					break;
372
				// 4 Mirror image, rotated 180
373
				case Imagick::ORIENTATION_BOTTOMLEFT:
374
					$this->_image->flipImage();
375
					break;
376
				// 5 Mirror image, rotated 90 CCW
377
				case Imagick::ORIENTATION_LEFTTOP:
378
					$this->_image->rotateImage(new ImagickPixel('#00000000'), 90);
379
					$this->_image->flopImage();
380
					break;
381
				// 6 Normal image, rotated 90 CCW
382
				case Imagick::ORIENTATION_RIGHTTOP:
383
					$this->_image->rotateImage(new ImagickPixel('#00000000'), 90);
384
					break;
385
				// 7 Mirror image, rotated 90 CW
386
				case Imagick::ORIENTATION_RIGHTBOTTOM:
387
					$this->_image->rotateImage(new ImagickPixel('#00000000'), -90);
388
					$this->_image->flopImage();
389
					break;
390
				// 8 Normal image, rotated 90 CW
391
				case Imagick::ORIENTATION_LEFTBOTTOM:
392
					$this->_image->rotateImage(new ImagickPixel('#00000000'), -90);
393
					break;
394
			}
395
396
			// Now that it's auto-rotated, make sure the EXIF data is correctly updated
397
			if ($this->orientation >= 2)
398
			{
399
				$this->_image->setImageOrientation(Imagick::ORIENTATION_TOPLEFT);
400
				$this->orientation = 1;
401
				$this->_setImage();
402
			}
403
404
			$success = true;
405
		}
406
		catch (Exception)
407
		{
408
			$success = false;
409
		}
410
411
		return $success;
412
	}
413
414
	/**
415
	 * Returns the ORIENTATION constant for an image
416
	 *
417
	 * @return int
418
	 */
419
	public function getOrientation()
420
	{
421
		try
422
		{
423
			$this->orientation = $this->_image->getImageOrientation();
424
		}
425
		catch (ImagickException)
426
		{
427
			$this->orientation = 0;
428
		}
429
430
		return $this->orientation;
431
	}
432
433
	/**
434
	 * Returns if the image has any alpha pixels.
435
	 *
436
	 * @return bool
437
	 */
438
	public function getTransparency()
439
	{
440
		// No image, return false
441
		if (empty($this->_image))
442
		{
443
			return false;
444
		}
445
446
		$checkImage = clone $this->_image;
447
448
		try
449
		{
450
			// Scale down large images to reduce processing time
451
			if ($this->_width > 1024 || $this->_height > 1024)
452
			{
453
				// This is only used to look for transparency, it is not intended to be a quality image.
454
				$scaleValue = $this->imageScaleFactor(800);
455
				$checkImage->scaleImage($scaleValue[0], $scaleValue[1], true);
456
			}
457
		}
458
		catch (ImagickException)
459
		{
460
			$checkImage->clear();
461
462
			return true;
463
		}
464
465
		// First attempt by looking at the channel statistics (faster)
466
		$transparent = $this->checkOpacityChannel($checkImage);
467
		if ($transparent !== null)
468
		{
469
			// Failing channel stats?, resort to pixel inspection
470
			$transparent = $this->checkOpacityPixelInspection($checkImage);
471
		}
472
473
		$checkImage->clear();
474
475
		return $transparent;
476
	}
477
478
	/**
479
	 * Does pixel by pixel inspection to determine if any have an alpha value < 1
480
	 *
481
	 * - Any pixel alpha < 1 is not perfectly opaque.
482
	 * - Resizes images > 1024x1024 to reduce pixel count
483
	 * - Used as a backup function should checkOpacityChannel() fail
484
	 *
485
	 * @param Imagick $checkImage
486
	 * @return bool
487
	 */
488
	public function checkOpacityPixelInspection($checkImage): bool
489
	{
490
		$checkImage = $checkImage ?? clone $this->_image;
491
492
		try
493
		{
494
			$transparency = false;
495
496
			$pixel_iterator = $checkImage->getPixelIterator();
497
498
			// Look at each one, or until we find the first alpha pixel
499
			foreach ($pixel_iterator as $pixels)
500
			{
501
				foreach ($pixels as $pixel)
502
				{
503
					$color = $pixel->getColor();
504
					if ($color['a'] < 1)
505
					{
506
						$transparency = true;
507
						break 2;
508
					}
509
				}
510
			}
511
		}
512
		catch (ImagickException)
513
		{
514
			// We don't know what it is, so don't mess with it
515
			return true;
516
		}
517
518
		return $transparency;
519
	}
520
521
	/**
522
	 * Attempts to use imagick getImageChannelMean to determine alpha/opacity channel statistics
523
	 *
524
	 * - An opaque image will have 0 standard deviation and a mean of 1 (65535)
525
	 * - If failure returns null, otherwise bool
526
	 *
527
	 * @param Imagick $checkImage
528
	 * @return bool|null
529
	 */
530
	public function checkOpacityChannel($checkImage): ?bool
531
	{
532
		$checkImage = $checkImage ?? clone $this->_image;
533
534
		try
535
		{
536
			$transparent = true;
537
			$stats = $checkImage->getImageChannelMean(Imagick::CHANNEL_OPACITY);
538
539
			// If mean = 65535 and std = 0, then its perfectly opaque.
540
			$mean = (int) $stats['mean'];
541
			if (($mean === 65535 || $mean === 0) && (int) $stats['standardDeviation'] === 0)
542
			{
543
				$transparent = false;
544
			}
545
		}
546
		catch (ImagickException)
547
		{
548
			$transparent = null;
549
		}
550
551
		return $transparent;
552
	}
553
554
	/**
555
	 * Function to generate an image containing some text.
556
	 * Attempts to adjust font size to fit within bounds
557
	 *
558
	 * @param string $text The text the image should contain
559
	 * @param int $width Width of the final image
560
	 * @param int $height Height of the image
561
	 * @param string $format Type of the image (valid types are png, jpeg, gif)
562
	 *
563
	 * @return bool|string The image or false on error
564
	 */
565
	public function generateTextImage($text, $width = 100, $height = 75, $format = 'png')
566
	{
567
		global $settings;
568
569
		try
570
		{
571
			$this->_image = new Imagick();
572
			$this->_image->newImage($width, $height, new ImagickPixel('white'));
573
			$this->_image->setImageFormat($format);
574
575
			// 28pt is ~2em given default font stack
576
			$font_size = 28;
577
578
			$draw = new \ImagickDraw();
579
			$draw->setStrokeColor(new ImagickPixel("rgba(100%, 100%, 100%, 0)"));
580
			$draw->setFillColor(new ImagickPixel('#A9A9A9'));
581
			$draw->setStrokeWidth(1);
582
			$draw->setTextAlignment(Imagick::ALIGN_CENTER);
583
			$draw->setFont($settings['default_theme_dir'] . '/fonts/OpenSans.ttf');
584
585
			// Make sure the text will fit the allowed space
586
			do
587
			{
588
				$draw->setFontSize($font_size);
589
				$metric = $this->_image->queryFontMetrics($draw, $text);
590
				$text_width = (int) $metric['textWidth'];
591
			} while ($text_width > $width && $font_size-- > 1);
592
593
			// Place text in center of block
594
			$this->_image->annotateImage($draw, $width / 2, $height / 2 + $font_size / 4, 0, $text);
595
			$image = $this->_image->getImageBlob();
596
			$this->__destruct();
597
598
			return $image;
599
		}
600
		catch (Exception)
601
		{
602
			return false;
603
		}
604
	}
605
606
	/**
607
	 * Check if this installation supports webP
608
	 *
609
	 * @return bool
610
	 */
611
	public function hasWebpSupport(): bool
612
	{
613
		$check = Imagick::queryformats();
614
615
		return in_array('WEBP', $check, true);
616
	}
617
618
	/**
619
	 * CLean up
620
	 */
621
	public function __destruct()
622
	{
623
		if (!is_object($this->_image))
624
		{
625
			return;
626
		}
627
628
		if (!$this->_image instanceof Imagick)
0 ignored issues
show
introduced by
$this->_image is always a sub-type of Imagick.
Loading history...
629
		{
630
			return;
631
		}
632
633
		$this->_image->clear();
634
	}
635
}
636