Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.95% covered (success)
90.95%
191 / 210
57.14% covered (warning)
57.14%
16 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
Image
90.95% covered (success)
90.95%
191 / 210
57.14% covered (warning)
57.14%
16 / 28
73.63
0.00% covered (danger)
0.00%
0 / 1
 __construct
83.33% covered (warning)
83.33%
20 / 24
0.00% covered (danger)
0.00%
0 / 1
7.23
 __destruct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 destroy
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getInstance
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setInstance
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getQuality
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 setQuality
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
8
 getResolution
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 setResolution
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getHeight
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWidth
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getExtension
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMime
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 save
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 send
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 render
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 crop
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 flip
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 filter
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 flatten
89.66% covered (warning)
89.66%
26 / 29
0.00% covered (danger)
0.00%
0 / 1
4.02
 opacity
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
5
 rotate
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
4.25
 scale
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 watermark
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
4.00
 getDataUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 jsonSerialize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isAcceptable
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework Image Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\Image;
11
12use GdImage;
13use InvalidArgumentException;
14use JetBrains\PhpStorm\ArrayShape;
15use JetBrains\PhpStorm\Pure;
16use LogicException;
17use RuntimeException;
18
19/**
20 * Class Image.
21 *
22 * @package image
23 */
24class Image implements \JsonSerializable, \Stringable
25{
26    /**
27     * Path to the image file.
28     */
29    protected string $filename;
30    /**
31     * Image type. One of IMAGETYPE_* constants.
32     */
33    protected int $type;
34    /**
35     * MIME type.
36     */
37    protected string $mime;
38    /**
39     * GdImage instance.
40     */
41    protected GdImage $instance;
42    /**
43     * The image quality/compression level.
44     *
45     * 0 to 9 on PNG, default is 6. 0 to 100 on JPEG, default is 75.
46     * Null to update to the default when getQuality is called.
47     *
48     * @see Image::getQuality()
49     */
50    protected ?int $quality = null;
51
52    /**
53     * Image constructor.
54     *
55     * @param string $filename Path to the image file.
56     * Acceptable types are: GIF, JPEG and PNG
57     *
58     * @throws InvalidArgumentException for invalid file
59     * @throws RuntimeException for unsupported image type of could not get image info
60     */
61    public function __construct(string $filename)
62    {
63        $realpath = \realpath($filename);
64        if ($realpath === false || ! \is_file($realpath) || ! \is_readable($realpath)) {
65            throw new InvalidArgumentException('File does not exists or is not readable: ' . $filename);
66        }
67        $this->filename = $realpath;
68        $info = \getimagesize($this->filename);
69        if ($info === false) {
70            throw new RuntimeException(
71                'Could not get image info from the given filename: ' . $this->filename
72            );
73        }
74        if ( ! (\imagetypes() & $info[2])) {
75            throw new RuntimeException('Unsupported image type: ' . $info[2]);
76        }
77        $this->type = $info[2];
78        $this->mime = $info['mime'];
79        $instance = match ($this->type) {
80            \IMAGETYPE_PNG => \imagecreatefrompng($this->filename),
81            \IMAGETYPE_JPEG => \imagecreatefromjpeg($this->filename),
82            \IMAGETYPE_GIF => \imagecreatefromgif($this->filename),
83            default => throw new RuntimeException('Image type is not acceptable: ' . $this->type),
84        };
85        if ( ! $instance instanceof GdImage) {
86            throw new RuntimeException(
87                "Image of type '{$this->type}' does not returned a GdImage instance"
88            );
89        }
90        $this->instance = $instance;
91    }
92
93    public function __destruct()
94    {
95        $this->destroy();
96    }
97
98    public function __toString() : string
99    {
100        return $this->getDataUrl();
101    }
102
103    /**
104     * Destroys the GdImage instance.
105     *
106     * @return bool
107     */
108    public function destroy() : bool
109    {
110        return \imagedestroy($this->instance);
111    }
112
113    /**
114     * Gets the GdImage instance.
115     *
116     * @return GdImage GdImage instance
117     */
118    #[Pure]
119    public function getInstance() : GdImage
120    {
121        return $this->instance;
122    }
123
124    /**
125     * Sets the GdImage instance.
126     *
127     * @param GdImage $instance GdImage instance
128     *
129     * @return static
130     */
131    public function setInstance(GdImage $instance) : static
132    {
133        $this->instance = $instance;
134        return $this;
135    }
136
137    /**
138     * Gets the image quality/compression level.
139     *
140     * @return int|null An integer for PNG and JPEG types or null for GIF
141     */
142    public function getQuality() : ?int
143    {
144        if ($this->quality === null) {
145            if ($this->type === \IMAGETYPE_PNG) {
146                $this->quality = 6;
147            } elseif ($this->type === \IMAGETYPE_JPEG) {
148                $this->quality = 75;
149            }
150        }
151        return $this->quality;
152    }
153
154    /**
155     * Sets the image quality/compression level.
156     *
157     * @param int $quality The quality/compression level
158     *
159     * @throws LogicException when trying to set a quality value for a GIF image
160     * @throws InvalidArgumentException if the image type is PNG and the value
161     * is not between 0 and 9 or if the image type is JPEG and the value is not
162     * between 0 and 100
163     *
164     * @return static
165     */
166    public function setQuality(int $quality) : static
167    {
168        if ($this->type === \IMAGETYPE_GIF) {
169            throw new LogicException(
170                'GIF images does not receive a quality value'
171            );
172        }
173        if ($this->type === \IMAGETYPE_PNG && ($quality < 0 || $quality > 9)) {
174            throw new InvalidArgumentException(
175                'PNG images must receive a quality value between 0 and 9, ' . $quality . ' given'
176            );
177        }
178        if ($this->type === \IMAGETYPE_JPEG && ($quality < 0 || $quality > 100)) {
179            throw new InvalidArgumentException(
180                'JPEG images must receive a quality value between 0 and 100, ' . $quality . ' given'
181            );
182        }
183        $this->quality = $quality;
184        return $this;
185    }
186
187    /**
188     * Gets the image resolution.
189     *
190     * @throws RuntimeException for image could not get resolution
191     *
192     * @return array<string,int> Returns an array containing two keys, horizontal and
193     * vertical, with integers as values
194     */
195    #[ArrayShape(['horizontal' => 'int', 'vertical' => 'int'])]
196    public function getResolution() : array
197    {
198        $resolution = \imageresolution($this->instance);
199        if ($resolution === false) {
200            throw new RuntimeException('Image could not to get resolution');
201        }
202        return [
203            'horizontal' => $resolution[0], // @phpstan-ignore-line
204            // @phpstan-ignore-next-line
205            'vertical' => $resolution[1],
206        ];
207    }
208
209    /**
210     * Sets the image resolution.
211     *
212     * @param int $horizontal The horizontal resolution in DPI
213     * @param int $vertical The vertical resolution in DPI
214     *
215     * @throws RuntimeException for image could not to set resolution
216     *
217     * @return static
218     */
219    public function setResolution(int $horizontal = 96, int $vertical = 96) : static
220    {
221        $set = \imageresolution($this->instance, $horizontal, $vertical);
222        if ($set === false) {
223            throw new RuntimeException('Image could not to set resolution');
224        }
225        return $this;
226    }
227
228    /**
229     * Gets the image height.
230     *
231     * @return int
232     */
233    #[Pure]
234    public function getHeight() : int
235    {
236        return \imagesy($this->instance);
237    }
238
239    /**
240     * Gets the image width.
241     *
242     * @return int
243     */
244    #[Pure]
245    public function getWidth() : int
246    {
247        return \imagesx($this->instance);
248    }
249
250    /**
251     * Gets the file extension for image type.
252     *
253     * @return false|string a string with the extension corresponding to the
254     * given image type or false on fail
255     */
256    #[Pure]
257    public function getExtension() : string | false
258    {
259        return \image_type_to_extension($this->type);
260    }
261
262    /**
263     * Gets the image MIME type.
264     *
265     * @return string
266     */
267    #[Pure]
268    public function getMime() : string
269    {
270        return $this->mime;
271    }
272
273    /**
274     * Saves the image contents to a given filename.
275     *
276     * @param string|null $filename Optional filename or null to use the original
277     *
278     * @return bool
279     */
280    public function save(string $filename = null) : bool
281    {
282        $filename ??= $this->filename;
283        return match ($this->type) {
284            \IMAGETYPE_PNG => \imagepng($this->instance, $filename, $this->getQuality()),
285            \IMAGETYPE_JPEG => \imagejpeg($this->instance, $filename, $this->getQuality()),
286            \IMAGETYPE_GIF => \imagegif($this->instance, $filename),
287            default => false,
288        };
289    }
290
291    /**
292     * Sends the image contents to the output buffer.
293     *
294     * @return bool
295     */
296    public function send() : bool
297    {
298        if (\in_array($this->type, [\IMAGETYPE_PNG, \IMAGETYPE_GIF], true)) {
299            \imagesavealpha($this->instance, true);
300        }
301        return match ($this->type) {
302            \IMAGETYPE_PNG => \imagepng($this->instance, null, $this->getQuality()),
303            \IMAGETYPE_JPEG => \imagejpeg($this->instance, null, $this->getQuality()),
304            \IMAGETYPE_GIF => \imagegif($this->instance),
305            default => false,
306        };
307    }
308
309    /**
310     * Renders the image contents.
311     *
312     * @throws RuntimeException for image could not be rendered
313     *
314     * @return string The image contents
315     */
316    public function render() : string
317    {
318        \ob_start();
319        $status = $this->send();
320        $contents = \ob_get_clean();
321        if ($status === false || $contents === false) {
322            throw new RuntimeException('Image could not be rendered');
323        }
324        return $contents;
325    }
326
327    /**
328     * Crops the image.
329     *
330     * @param int $width Width in pixels
331     * @param int $height Height in pixels
332     * @param int $marginLeft Margin left in pixels
333     * @param int $marginTop Margin top in pixels
334     *
335     * @throws RuntimeException for image could not to crop
336     *
337     * @return static
338     */
339    public function crop(int $width, int $height, int $marginLeft = 0, int $marginTop = 0) : static
340    {
341        $crop = \imagecrop($this->instance, [
342            'x' => $marginLeft,
343            'y' => $marginTop,
344            'width' => $width,
345            'height' => $height,
346        ]);
347        if ($crop === false) {
348            throw new RuntimeException('Image could not to crop');
349        }
350        $this->instance = $crop;
351        return $this;
352    }
353
354    /**
355     * Flips the image.
356     *
357     * @param string $direction Allowed values are: h or horizontal. v or vertical. b or both.
358     *
359     * @throws InvalidArgumentException for invalid image flip direction
360     * @throws RuntimeException for image could not to flip
361     *
362     * @return static
363     */
364    public function flip(string $direction = 'horizontal') : static
365    {
366        $direction = match ($direction) {
367            'h', 'horizontal' => \IMG_FLIP_HORIZONTAL,
368            'v', 'vertical' => \IMG_FLIP_VERTICAL,
369            'b', 'both' => \IMG_FLIP_BOTH,
370            default => throw new InvalidArgumentException('Invalid image flip direction: ' . $direction),
371        };
372        $flip = \imageflip($this->instance, $direction);
373        if ($flip === false) {
374            throw new RuntimeException('Image could not to flip');
375        }
376        return $this;
377    }
378
379    /**
380     * Applies a filter to the image.
381     *
382     * @param int $type IMG_FILTER_* constants
383     * @param int ...$arguments Arguments for the filter type
384     *
385     * @see https://www.php.net/manual/en/function.imagefilter.php
386     *
387     * @throws RuntimeException for image could not apply the filter
388     *
389     * @return static
390     */
391    public function filter(int $type, int ...$arguments) : static
392    {
393        $filtered = \imagefilter($this->instance, $type, ...$arguments);
394        if ($filtered === false) {
395            throw new RuntimeException('Image could not apply the filter');
396        }
397        return $this;
398    }
399
400    /**
401     * Flattens the image.
402     *
403     * Replaces transparency with an RGB color.
404     *
405     * @param int $red
406     * @param int $green
407     * @param int $blue
408     *
409     * @throws RuntimeException for could not create a true color image, could
410     * not allocate a color or image could not to flatten
411     *
412     * @return static
413     */
414    public function flatten(int $red = 255, int $green = 255, int $blue = 255) : static
415    {
416        \imagesavealpha($this->instance, false);
417        $image = \imagecreatetruecolor($this->getWidth(), $this->getHeight());
418        if ($image === false) {
419            throw new RuntimeException('Could not create a true color image');
420        }
421        $color = \imagecolorallocate($image, $red, $green, $blue);
422        if ($color === false) {
423            throw new RuntimeException('Image could not allocate a color');
424        }
425        \imagefilledrectangle(
426            $image,
427            0,
428            0,
429            $this->getWidth(),
430            $this->getHeight(),
431            $color
432        );
433        $copied = \imagecopy(
434            $image,
435            $this->instance,
436            0,
437            0,
438            0,
439            0,
440            $this->getWidth(),
441            $this->getHeight()
442        );
443        if ($copied === false) {
444            throw new RuntimeException('Image could not to flatten');
445        }
446        $this->instance = $image;
447        return $this;
448    }
449
450    /**
451     * Sets the image opacity level.
452     *
453     * @param int $opacity Opacity percentage: from 0 to 100
454     *
455     * @return static
456     */
457    public function opacity(int $opacity = 100) : static
458    {
459        if ($opacity < 0 || $opacity > 100) {
460            throw new InvalidArgumentException(
461                'Opacity percentage must be between 0 and 100, ' . $opacity . ' given'
462            );
463        }
464        if ($opacity === 100) {
465            \imagealphablending($this->instance, true);
466            return $this;
467        }
468        $opacity = (int) \round(\abs(($opacity * 127 / 100) - 127));
469        \imagelayereffect($this->instance, \IMG_EFFECT_OVERLAY);
470        $color = \imagecolorallocatealpha($this->instance, 127, 127, 127, $opacity);
471        if ($color === false) {
472            throw new RuntimeException('Image could not allocate a color');
473        }
474        \imagefilledrectangle(
475            $this->instance,
476            0,
477            0,
478            $this->getWidth(),
479            $this->getHeight(),
480            $color
481        );
482        \imagesavealpha($this->instance, true);
483        \imagealphablending($this->instance, false);
484        return $this;
485    }
486
487    /**
488     * Rotates the image with a given angle.
489     *
490     * @param float $angle Rotation angle, in degrees. Clockwise direction.
491     *
492     * @throws RuntimeException for image could not allocate a color or could not rotate
493     *
494     * @return static
495     */
496    public function rotate(float $angle) : static
497    {
498        if (\in_array($this->type, [\IMAGETYPE_PNG, \IMAGETYPE_GIF], true)) {
499            \imagealphablending($this->instance, false);
500            \imagesavealpha($this->instance, true);
501            $background = \imagecolorallocatealpha($this->instance, 0, 0, 0, 127);
502        } else {
503            $background = \imagecolorallocate($this->instance, 255, 255, 255);
504        }
505        if ($background === false) {
506            throw new RuntimeException('Image could not allocate a color');
507        }
508        $rotate = \imagerotate($this->instance, -1 * $angle, $background);
509        if ($rotate === false) {
510            throw new RuntimeException('Image could not to rotate');
511        }
512        $this->instance = $rotate;
513        return $this;
514    }
515
516    /**
517     * Scales the image.
518     *
519     * @param int $width Width in pixels
520     * @param int $height Height in pixels. Use -1 to use a proportional height
521     * based on the width.
522     *
523     * @throws RuntimeException for image could not to scale
524     *
525     * @return static
526     */
527    public function scale(int $width, int $height = -1) : static
528    {
529        $scale = \imagescale($this->instance, $width, $height);
530        if ($scale === false) {
531            throw new RuntimeException('Image could not to scale');
532        }
533        $this->instance = $scale;
534        return $this;
535    }
536
537    /**
538     * Adds a watermark to the image.
539     *
540     * @param Image $watermark The image to use as watermark
541     * @param int $horizontalPosition Horizontal position
542     * @param int $verticalPosition Vertical position
543     *
544     * @throws RuntimeException for image could not to create watermark
545     *
546     * @return static
547     */
548    public function watermark(
549        Image $watermark,
550        int $horizontalPosition = 0,
551        int $verticalPosition = 0
552    ) : static {
553        if ($horizontalPosition < 0) {
554            $horizontalPosition = $this->getWidth()
555                - (-1 * $horizontalPosition + $watermark->getWidth());
556        }
557        if ($verticalPosition < 0) {
558            $verticalPosition = $this->getHeight()
559                - (-1 * $verticalPosition + $watermark->getHeight());
560        }
561        $copied = \imagecopy(
562            $this->instance,
563            $watermark->getInstance(),
564            $horizontalPosition,
565            $verticalPosition,
566            0,
567            0,
568            $watermark->getWidth(),
569            $watermark->getHeight()
570        );
571        if ($copied === false) {
572            throw new RuntimeException('Image could not to create watermark');
573        }
574        return $this;
575    }
576
577    /**
578     * Allow embed the image contents in a document.
579     *
580     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs
581     * @see https://datatracker.ietf.org/doc/html/rfc2397
582     *
583     * @return string The image "data" URL
584     */
585    public function getDataUrl() : string
586    {
587        return 'data:' . $this->getMime() . ';base64,' . \base64_encode($this->render());
588    }
589
590    /**
591     * @return string
592     */
593    public function jsonSerialize() : string
594    {
595        return $this->getDataUrl();
596    }
597
598    /**
599     * Indicates if a given filename has an acceptable image type.
600     *
601     * @param string $filename
602     *
603     * @return bool
604     */
605    public static function isAcceptable(string $filename) : bool
606    {
607        $filename = \realpath($filename);
608        if ($filename === false || ! \is_file($filename) || ! \is_readable($filename)) {
609            return false;
610        }
611        $info = \getimagesize($filename);
612        if ($info === false) {
613            return false;
614        }
615        return match ($info[2]) {
616            \IMAGETYPE_PNG, \IMAGETYPE_JPEG, \IMAGETYPE_GIF => true,
617            default => false,
618        };
619    }
620}