• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

aplus-framework / image / 23569813676

25 Mar 2026 11:32PM UTC coverage: 91.736% (+1.0%) from 90.783%
23569813676

push

github

natanfelles
Add Image::hasAlpha method

11 of 11 new or added lines in 1 file covered. (100.0%)

13 existing lines in 1 file now uncovered.

222 of 242 relevant lines covered (91.74%)

5.17 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.74
/src/Image.php
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
 */
10
namespace Framework\Image;
11

12
use GdImage;
13
use InvalidArgumentException;
14
use JetBrains\PhpStorm\ArrayShape;
15
use JetBrains\PhpStorm\Pure;
16
use LogicException;
17
use RuntimeException;
18

19
/**
20
 * Class Image.
21
 *
22
 * @package image
23
 */
24
class 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: AVIF, 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);
33✔
64
        if ($realpath === false || !\is_file($realpath) || !\is_readable($realpath)) {
33✔
65
            throw new InvalidArgumentException('File does not exists or is not readable: ' . $filename);
1✔
66
        }
67
        $this->filename = $realpath;
33✔
68
        $info = \getimagesize($this->filename);
33✔
69
        if ($info === false) {
33✔
70
            throw new RuntimeException(
1✔
71
                'Could not get image info from the given filename: ' . $this->filename
1✔
72
            );
1✔
73
        }
74
        if (!(\imagetypes() & $info[2])) {
33✔
75
            throw new RuntimeException('Unsupported image type: ' . $info[2]);
×
76
        }
77
        $this->setType($info[2]);
33✔
78
        $this->setMime($info['mime']);
33✔
79
        $instance = match ($this->getType()) {
33✔
80
            \IMAGETYPE_PNG => \imagecreatefrompng($this->filename),
33✔
81
            \IMAGETYPE_JPEG => \imagecreatefromjpeg($this->filename),
3✔
82
            \IMAGETYPE_GIF => \imagecreatefromgif($this->filename),
3✔
83
            \IMAGETYPE_AVIF => \imagecreatefromavif($this->filename),
3✔
84
            default => throw new RuntimeException('Image type is not acceptable: ' . $this->getType()),
1✔
85
        };
33✔
86
        if (!$instance instanceof GdImage) {
33✔
87
            throw new RuntimeException(
×
88
                "Image of type '{$this->getType()}' does not returned a GdImage instance"
×
89
            );
×
90
        }
91
        $this->instance = $instance;
33✔
92
    }
93

94
    public function __toString() : string
95
    {
96
        return $this->getDataUrl();
1✔
97
    }
98

99
    /**
100
     * Gets the GdImage instance.
101
     *
102
     * @return GdImage GdImage instance
103
     */
104
    #[Pure]
105
    public function getInstance() : GdImage
106
    {
107
        return $this->instance;
2✔
108
    }
109

110
    /**
111
     * Sets the GdImage instance.
112
     *
113
     * @param GdImage $instance GdImage instance
114
     *
115
     * @return static
116
     */
117
    public function setInstance(GdImage $instance) : static
118
    {
119
        $this->instance = $instance;
1✔
120
        return $this;
1✔
121
    }
122

123
    /**
124
     * Gets the image quality/compression level.
125
     *
126
     * @return int|null An integer for AVIF, JPEG and PNG types or null for GIF
127
     */
128
    public function getQuality() : ?int
129
    {
130
        if ($this->quality === null) {
20✔
131
            if ($this->isType(\IMAGETYPE_PNG)) {
20✔
132
                $this->quality = 6;
15✔
133
            } elseif ($this->isType(\IMAGETYPE_JPEG)) {
5✔
134
                $this->quality = 75;
2✔
135
            } elseif ($this->isType(\IMAGETYPE_AVIF)) {
3✔
136
                $this->quality = 52;
2✔
137
            }
138
        }
139
        return $this->quality;
20✔
140
    }
141

142
    /**
143
     * Sets the image quality/compression level.
144
     *
145
     * @param int $quality The quality/compression level
146
     *
147
     * @throws LogicException when trying to set a quality value for a GIF image
148
     * @throws InvalidArgumentException if the image type is PNG and the value
149
     * is not between 0 and 9, if the image type is JPEG and the value is not
150
     * between 0 and 100 or if the image type is AVIF and the value is not
151
     * between 0 and 100
152
     *
153
     * @return static
154
     */
155
    public function setQuality(int $quality) : static
156
    {
157
        if ($this->isType(\IMAGETYPE_GIF)) {
4✔
158
            throw new LogicException(
1✔
159
                'GIF images does not receive a quality value'
1✔
160
            );
1✔
161
        }
162
        if ($this->isType(\IMAGETYPE_PNG) && ($quality < 0 || $quality > 9)) {
3✔
163
            throw new InvalidArgumentException(
1✔
164
                'PNG images must receive a quality value between 0 and 9, ' . $quality . ' given'
1✔
165
            );
1✔
166
        }
167
        if ($this->isType(\IMAGETYPE_JPEG) && ($quality < 0 || $quality > 100)) {
3✔
168
            throw new InvalidArgumentException(
1✔
169
                'JPEG images must receive a quality value between 0 and 100, ' . $quality . ' given'
1✔
170
            );
1✔
171
        }
172
        if ($this->isType(\IMAGETYPE_AVIF) && ($quality < 0 || $quality > 100)) {
3✔
173
            throw new InvalidArgumentException(
1✔
174
                'AVIF images must receive a quality value between 0 and 100, ' . $quality . ' given'
1✔
175
            );
1✔
176
        }
177
        $this->quality = $quality;
3✔
178
        return $this;
3✔
179
    }
180

181
    /**
182
     * Gets the image resolution.
183
     *
184
     * @throws RuntimeException for image could not get resolution
185
     *
186
     * @return array<string,int> Returns an array containing two keys, horizontal and
187
     * vertical, with integers as values
188
     */
189
    #[ArrayShape(['horizontal' => 'int', 'vertical' => 'int'])]
190
    public function getResolution() : array
191
    {
192
        $resolution = \imageresolution($this->instance);
1✔
193
        if ($resolution === false) {
1✔
194
            throw new RuntimeException('Image could not to get resolution');
×
195
        }
196
        return [
1✔
197
            'horizontal' => $resolution[0], // @phpstan-ignore-line
1✔
198
            // @phpstan-ignore-next-line
199
            'vertical' => $resolution[1],
1✔
200
        ];
1✔
201
    }
202

203
    /**
204
     * Sets the image resolution.
205
     *
206
     * @param int $horizontal The horizontal resolution in DPI
207
     * @param int $vertical The vertical resolution in DPI
208
     *
209
     * @throws RuntimeException for image could not to set resolution
210
     *
211
     * @return static
212
     */
213
    public function setResolution(int $horizontal = 96, int $vertical = 96) : static
214
    {
215
        $set = \imageresolution($this->instance, $horizontal, $vertical);
1✔
216
        if ($set === false) {
1✔
217
            throw new RuntimeException('Image could not to set resolution');
×
218
        }
219
        return $this;
1✔
220
    }
221

222
    /**
223
     * Gets the image height.
224
     *
225
     * @return int
226
     */
227
    #[Pure]
228
    public function getHeight() : int
229
    {
230
        return \imagesy($this->instance);
4✔
231
    }
232

233
    /**
234
     * Gets the image width.
235
     *
236
     * @return int
237
     */
238
    #[Pure]
239
    public function getWidth() : int
240
    {
241
        return \imagesx($this->instance);
4✔
242
    }
243

244
    /**
245
     * Gets the file extension for image type.
246
     *
247
     * @return false|string a string with the extension corresponding to the
248
     * given image type or false on fail
249
     */
250
    #[Pure]
251
    public function getExtension() : false | string
252
    {
253
        return \image_type_to_extension($this->getType());
1✔
254
    }
255

256
    /**
257
     * Gets the image MIME type.
258
     *
259
     * @return string
260
     */
261
    #[Pure]
262
    public function getMime() : string
263
    {
264
        return $this->mime;
3✔
265
    }
266

267
    protected function setMime(string $mime) : static
268
    {
269
        $this->mime = $mime;
33✔
270
        return $this;
33✔
271
    }
272

273
    /**
274
     * Gets the image type.
275
     *
276
     * @return int
277
     */
278
    public function getType() : int
279
    {
280
        return $this->type;
33✔
281
    }
282

283
    protected function setType(int $type) : static
284
    {
285
        $this->type = $type;
33✔
286
        return $this;
33✔
287
    }
288

289
    public function isType(int $type) : bool
290
    {
291
        return $this->getType() === $type;
21✔
292
    }
293

294
    /**
295
     * Creates a new Image based in the current instance.
296
     *
297
     * @param int|string $type The new Image type
298
     * @param string $filename The filename where the new Image will be placed
299
     */
300
    public function create(int | string $type, string $filename) : Image
301
    {
302
        $created = match ($type) {
1✔
303
            \IMAGETYPE_PNG, 'png' => \imagepng($this->instance, $filename),
1✔
304
            \IMAGETYPE_JPEG, 'jpeg' => \imagejpeg($this->instance, $filename),
1✔
305
            \IMAGETYPE_GIF, 'gif' => \imagegif($this->instance, $filename),
1✔
306
            \IMAGETYPE_AVIF, 'avif' => \imageavif($this->instance, $filename),
1✔
307
            default => false,
1✔
308
        };
1✔
309
        if($created === false) {
1✔
310
            throw new RuntimeException('Image could not be created');
1✔
311
        }
312
        return new Image($filename);
1✔
313
    }
314

315
    /**
316
     * Saves the image contents to a given filename.
317
     *
318
     * @param string|null $filename Optional filename or null to use the original
319
     *
320
     * @return bool
321
     */
322
    public function save(?string $filename = null) : bool
323
    {
324
        $filename ??= $this->filename;
4✔
325
        return match ($this->getType()) {
4✔
326
            \IMAGETYPE_PNG => \imagepng($this->instance, $filename, $this->getQuality()),
1✔
327
            \IMAGETYPE_JPEG => \imagejpeg($this->instance, $filename, $this->getQuality()),
1✔
328
            \IMAGETYPE_GIF => \imagegif($this->instance, $filename),
1✔
329
            \IMAGETYPE_AVIF => \imageavif($this->instance, $filename, $this->getQuality()),
1✔
330
            default => false,
4✔
331
        };
4✔
332
    }
333

334
    /**
335
     * Sends the image contents to the output buffer.
336
     *
337
     * @return bool
338
     */
339
    public function send() : bool
340
    {
341
        if ($this->hasAlpha()) {
16✔
342
            \imagesavealpha($this->instance, true);
15✔
343
        }
344
        return match ($this->getType()) {
16✔
345
            \IMAGETYPE_PNG => \imagepng($this->instance, null, $this->getQuality()),
13✔
346
            \IMAGETYPE_JPEG => \imagejpeg($this->instance, null, $this->getQuality()),
1✔
347
            \IMAGETYPE_GIF => \imagegif($this->instance),
1✔
348
            \IMAGETYPE_AVIF => \imageavif($this->instance, null, $this->getQuality()),
1✔
349
            default => false,
16✔
350
        };
16✔
351
    }
352

353
    /**
354
     * Renders the image contents.
355
     *
356
     * @throws RuntimeException for image could not be rendered
357
     *
358
     * @return string The image contents
359
     */
360
    public function render() : string
361
    {
362
        \ob_start();
16✔
363
        $status = $this->send();
16✔
364
        $contents = \ob_get_clean();
16✔
365
        if ($status === false || $contents === false) {
16✔
366
            throw new RuntimeException('Image could not be rendered');
×
367
        }
368
        return $contents;
16✔
369
    }
370

371
    /**
372
     * Crops the image.
373
     *
374
     * @param int $width Width in pixels
375
     * @param int $height Height in pixels
376
     * @param int $marginLeft Margin left in pixels
377
     * @param int $marginTop Margin top in pixels
378
     *
379
     * @throws RuntimeException for image could not to crop
380
     *
381
     * @return static
382
     */
383
    public function crop(int $width, int $height, int $marginLeft = 0, int $marginTop = 0) : static
384
    {
385
        $crop = \imagecrop($this->instance, [
1✔
386
            'x' => $marginLeft,
1✔
387
            'y' => $marginTop,
1✔
388
            'width' => $width,
1✔
389
            'height' => $height,
1✔
390
        ]);
1✔
391
        if ($crop === false) {
1✔
UNCOV
392
            throw new RuntimeException('Image could not to crop');
×
393
        }
394
        $this->instance = $crop;
1✔
395
        return $this;
1✔
396
    }
397

398
    /**
399
     * Flips the image.
400
     *
401
     * @param string $direction Allowed values are: h or horizontal. v or vertical. b or both.
402
     *
403
     * @throws InvalidArgumentException for invalid image flip direction
404
     * @throws RuntimeException for image could not to flip
405
     *
406
     * @return static
407
     */
408
    public function flip(string $direction = 'horizontal') : static
409
    {
410
        $direction = match ($direction) {
3✔
411
            'h', 'horizontal' => \IMG_FLIP_HORIZONTAL,
1✔
412
            'v', 'vertical' => \IMG_FLIP_VERTICAL,
1✔
413
            'b', 'both' => \IMG_FLIP_BOTH,
1✔
UNCOV
414
            default => throw new InvalidArgumentException('Invalid image flip direction: ' . $direction),
×
415
        };
3✔
416
        $flip = \imageflip($this->instance, $direction);
3✔
417
        if ($flip === false) {
3✔
UNCOV
418
            throw new RuntimeException('Image could not to flip');
×
419
        }
420
        return $this;
3✔
421
    }
422

423
    /**
424
     * Applies a filter to the image.
425
     *
426
     * @param int $type IMG_FILTER_* constants
427
     * @param int ...$arguments Arguments for the filter type
428
     *
429
     * @see https://www.php.net/manual/en/function.imagefilter.php
430
     *
431
     * @throws RuntimeException for image could not apply the filter
432
     *
433
     * @return static
434
     */
435
    public function filter(int $type, int ...$arguments) : static
436
    {
437
        $filtered = \imagefilter($this->instance, $type, ...$arguments);
1✔
438
        if ($filtered === false) {
1✔
UNCOV
439
            throw new RuntimeException('Image could not apply the filter');
×
440
        }
441
        return $this;
1✔
442
    }
443

444
    /**
445
     * Flattens the image.
446
     *
447
     * Replaces transparency with an RGB color.
448
     *
449
     * @param int $red
450
     * @param int $green
451
     * @param int $blue
452
     *
453
     * @throws RuntimeException for could not create a true color image, could
454
     * not allocate a color or image could not to flatten
455
     *
456
     * @return static
457
     */
458
    public function flatten(int $red = 255, int $green = 255, int $blue = 255) : static
459
    {
460
        \imagesavealpha($this->instance, false);
1✔
461
        $image = \imagecreatetruecolor($this->getWidth(), $this->getHeight());
1✔
462
        if ($image === false) {
1✔
UNCOV
463
            throw new RuntimeException('Could not create a true color image');
×
464
        }
465
        $color = \imagecolorallocate($image, $red, $green, $blue);
1✔
466
        if ($color === false) {
1✔
UNCOV
467
            throw new RuntimeException('Image could not allocate a color');
×
468
        }
469
        \imagefilledrectangle(
1✔
470
            $image,
1✔
471
            0,
1✔
472
            0,
1✔
473
            $this->getWidth(),
1✔
474
            $this->getHeight(),
1✔
475
            $color
1✔
476
        );
1✔
477
        $copied = \imagecopy(
1✔
478
            $image,
1✔
479
            $this->instance,
1✔
480
            0,
1✔
481
            0,
1✔
482
            0,
1✔
483
            0,
1✔
484
            $this->getWidth(),
1✔
485
            $this->getHeight()
1✔
486
        );
1✔
487
        if ($copied === false) {
1✔
UNCOV
488
            throw new RuntimeException('Image could not to flatten');
×
489
        }
490
        $this->instance = $image;
1✔
491
        return $this;
1✔
492
    }
493

494
    /**
495
     * Sets the image opacity level.
496
     *
497
     * @param int $opacity Opacity percentage: from 0 to 100
498
     *
499
     * @return static
500
     */
501
    public function opacity(int $opacity = 100) : static
502
    {
503
        if ($opacity < 0 || $opacity > 100) {
3✔
504
            throw new InvalidArgumentException(
1✔
505
                'Opacity percentage must be between 0 and 100, ' . $opacity . ' given'
1✔
506
            );
1✔
507
        }
508
        if ($opacity === 100) {
2✔
509
            \imagealphablending($this->instance, true);
1✔
510
            return $this;
1✔
511
        }
512
        $opacity = (int) \round(\abs(($opacity * 127 / 100) - 127));
1✔
513
        \imagelayereffect($this->instance, \IMG_EFFECT_OVERLAY);
1✔
514
        $color = \imagecolorallocatealpha($this->instance, 127, 127, 127, $opacity);
1✔
515
        if ($color === false) {
1✔
UNCOV
516
            throw new RuntimeException('Image could not allocate a color');
×
517
        }
518
        \imagefilledrectangle(
1✔
519
            $this->instance,
1✔
520
            0,
1✔
521
            0,
1✔
522
            $this->getWidth(),
1✔
523
            $this->getHeight(),
1✔
524
            $color
1✔
525
        );
1✔
526
        \imagesavealpha($this->instance, true);
1✔
527
        \imagealphablending($this->instance, false);
1✔
528
        return $this;
1✔
529
    }
530

531
    /**
532
     * Rotates the image with a given angle.
533
     *
534
     * @param float $angle Rotation angle, in degrees. Clockwise direction.
535
     *
536
     * @throws RuntimeException for image could not allocate a color or could not rotate
537
     *
538
     * @return static
539
     */
540
    public function rotate(float $angle) : static
541
    {
542
        if ($this->hasAlpha()) {
1✔
543
            \imagealphablending($this->instance, false);
1✔
544
            \imagesavealpha($this->instance, true);
1✔
545
            $background = \imagecolorallocatealpha($this->instance, 0, 0, 0, 127);
1✔
546
        } else {
UNCOV
547
            $background = \imagecolorallocate($this->instance, 255, 255, 255);
×
548
        }
549
        if ($background === false) {
1✔
UNCOV
550
            throw new RuntimeException('Image could not allocate a color');
×
551
        }
552
        $rotate = \imagerotate($this->instance, -1 * $angle, $background);
1✔
553
        if ($rotate === false) {
1✔
UNCOV
554
            throw new RuntimeException('Image could not to rotate');
×
555
        }
556
        $this->instance = $rotate;
1✔
557
        return $this;
1✔
558
    }
559

560
    public function hasAlpha() : bool
561
    {
562
        return \in_array(
16✔
563
            $this->getType(),
16✔
564
            [
16✔
565
                \IMAGETYPE_PNG,
16✔
566
                \IMAGETYPE_GIF,
16✔
567
                \IMAGETYPE_AVIF,
16✔
568
            ],
16✔
569
            true
16✔
570
        );
16✔
571
    }
572

573
    /**
574
     * Scales the image.
575
     *
576
     * @param int $width Width in pixels
577
     * @param int $height Height in pixels. Use -1 to use a proportional height
578
     * based on the width.
579
     *
580
     * @throws RuntimeException for image could not to scale
581
     *
582
     * @return static
583
     */
584
    public function scale(int $width, int $height = -1) : static
585
    {
586
        $scale = \imagescale($this->instance, $width, $height);
2✔
587
        if ($scale === false) {
2✔
UNCOV
588
            throw new RuntimeException('Image could not to scale');
×
589
        }
590
        $this->instance = $scale;
2✔
591
        return $this;
2✔
592
    }
593

594
    /**
595
     * Adds a watermark to the image.
596
     *
597
     * @param Image $watermark The image to use as watermark
598
     * @param int $horizontalPosition Horizontal position
599
     * @param int $verticalPosition Vertical position
600
     *
601
     * @throws RuntimeException for image could not to create watermark
602
     *
603
     * @return static
604
     */
605
    public function watermark(
606
        Image $watermark,
607
        int $horizontalPosition = 0,
608
        int $verticalPosition = 0
609
    ) : static {
610
        if ($horizontalPosition < 0) {
1✔
611
            $horizontalPosition = $this->getWidth()
1✔
612
                - (-1 * $horizontalPosition + $watermark->getWidth());
1✔
613
        }
614
        if ($verticalPosition < 0) {
1✔
615
            $verticalPosition = $this->getHeight()
1✔
616
                - (-1 * $verticalPosition + $watermark->getHeight());
1✔
617
        }
618
        $copied = \imagecopy(
1✔
619
            $this->instance,
1✔
620
            $watermark->getInstance(),
1✔
621
            $horizontalPosition,
1✔
622
            $verticalPosition,
1✔
623
            0,
1✔
624
            0,
1✔
625
            $watermark->getWidth(),
1✔
626
            $watermark->getHeight()
1✔
627
        );
1✔
628
        if ($copied === false) {
1✔
UNCOV
629
            throw new RuntimeException('Image could not to create watermark');
×
630
        }
631
        return $this;
1✔
632
    }
633

634
    /**
635
     * Allow embed the image contents in a document.
636
     *
637
     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs
638
     * @see https://datatracker.ietf.org/doc/html/rfc2397
639
     *
640
     * @return string The image "data" URL
641
     */
642
    public function getDataUrl() : string
643
    {
644
        return 'data:' . $this->getMime() . ';base64,' . \base64_encode($this->render());
2✔
645
    }
646

647
    /**
648
     * @return string
649
     */
650
    public function jsonSerialize() : string
651
    {
652
        return $this->getDataUrl();
1✔
653
    }
654

655
    /**
656
     * Indicates if a given filename has an acceptable image type.
657
     *
658
     * @param string $filename
659
     *
660
     * @return bool
661
     */
662
    public static function isAcceptable(string $filename) : bool
663
    {
664
        $filename = \realpath($filename);
1✔
665
        if ($filename === false || !\is_file($filename) || !\is_readable($filename)) {
1✔
666
            return false;
1✔
667
        }
668
        $info = \getimagesize($filename);
1✔
669
        if ($info === false) {
1✔
670
            return false;
1✔
671
        }
672
        return match ($info[2]) {
1✔
673
            \IMAGETYPE_PNG, \IMAGETYPE_JPEG, \IMAGETYPE_GIF, \IMAGETYPE_AVIF => true,
1✔
674
            default => false,
1✔
675
        };
1✔
676
    }
677
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc