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

aplus-framework / image / 23719357060

29 Mar 2026 09:20PM UTC coverage: 93.443% (+0.8%) from 92.683%
23719357060

push

github

natanfelles
Make Image::setInstance method protected

228 of 244 relevant lines covered (93.44%)

12.89 hits per line

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

93.44
/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
        $this->setFilename($filename);
72✔
64
        $info = \getimagesize($this->getFilename());
72✔
65
        if ($info === false) {
72✔
66
            throw new RuntimeException(
2✔
67
                'Could not get image info from the given filename: ' . $this->getFilename()
2✔
68
            );
2✔
69
        }
70
        if (!(\imagetypes() & $info[2])) {
72✔
71
            throw new RuntimeException('Unsupported image type: ' . $info[2]);
×
72
        }
73
        $this->setType($info[2]);
72✔
74
        $this->setMime($info['mime']);
72✔
75
        $instance = match ($this->getType()) {
72✔
76
            \IMAGETYPE_PNG => \imagecreatefrompng($this->getFilename()),
72✔
77
            \IMAGETYPE_JPEG => \imagecreatefromjpeg($this->getFilename()),
8✔
78
            \IMAGETYPE_GIF => \imagecreatefromgif($this->getFilename()),
6✔
79
            \IMAGETYPE_AVIF => \imagecreatefromavif($this->getFilename()),
6✔
80
            default => throw new RuntimeException('Image type is not acceptable: ' . $this->getType()),
2✔
81
        };
72✔
82
        if (!$instance instanceof GdImage) {
72✔
83
            throw new RuntimeException("Image of type '{$this->getType()}' does not returned a GdImage instance");
×
84
        }
85
        $this->setInstance($instance);
72✔
86
    }
87

88
    public function __toString() : string
89
    {
90
        return $this->getDataUrl();
2✔
91
    }
92

93
    public function getFilename() : string
94
    {
95
        return $this->filename;
72✔
96
    }
97

98
    protected function setFilename(string $filename) : static
99
    {
100
        $realpath = \realpath($filename);
72✔
101
        if ($realpath === false || !\is_file($realpath) || !\is_readable($realpath)) {
72✔
102
            throw new InvalidArgumentException('File does not exists or is not readable: ' . $filename);
2✔
103
        }
104
        $this->filename = $realpath;
72✔
105
        return $this;
72✔
106
    }
107

108
    /**
109
     * Gets the GdImage instance.
110
     *
111
     * @return GdImage GdImage instance
112
     */
113
    #[Pure]
114
    public function getInstance() : GdImage
115
    {
116
        return $this->instance;
48✔
117
    }
118

119
    /**
120
     * Sets the GdImage instance.
121
     *
122
     * @param GdImage $instance GdImage instance
123
     *
124
     * @return static
125
     */
126
    protected function setInstance(GdImage $instance) : static
127
    {
128
        $this->instance = $instance;
72✔
129
        return $this;
72✔
130
    }
131

132
    /**
133
     * Gets the image quality/compression level.
134
     *
135
     * @return int|null An integer for AVIF, JPEG and PNG types or null for GIF
136
     */
137
    public function getQuality() : ?int
138
    {
139
        if ($this->quality === null) {
44✔
140
            if ($this->isType(\IMAGETYPE_PNG)) {
44✔
141
                $this->quality = 6;
32✔
142
            } elseif ($this->isType(\IMAGETYPE_JPEG)) {
12✔
143
                $this->quality = 75;
6✔
144
            } elseif ($this->isType(\IMAGETYPE_AVIF)) {
6✔
145
                $this->quality = 52;
4✔
146
            }
147
        }
148
        return $this->quality;
44✔
149
    }
150

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

190
    /**
191
     * Gets the image resolution.
192
     *
193
     * @throws RuntimeException for image could not get resolution
194
     *
195
     * @return array<string,int> Returns an array containing two keys, horizontal and
196
     * vertical, with integers as values
197
     */
198
    #[ArrayShape(['horizontal' => 'int', 'vertical' => 'int'])]
199
    public function getResolution() : array
200
    {
201
        $resolution = \imageresolution($this->getInstance());
2✔
202
        if ($resolution === false) {
2✔
203
            throw new RuntimeException('Image could not to get resolution');
×
204
        }
205
        return [
2✔
206
            'horizontal' => $resolution[0], // @phpstan-ignore-line
2✔
207
            // @phpstan-ignore-next-line
208
            'vertical' => $resolution[1],
2✔
209
        ];
2✔
210
    }
211

212
    /**
213
     * Sets the image resolution.
214
     *
215
     * @param int $horizontal The horizontal resolution in DPI
216
     * @param int $vertical The vertical resolution in DPI
217
     *
218
     * @throws RuntimeException for image could not to set resolution
219
     *
220
     * @return static
221
     */
222
    public function setResolution(int $horizontal = 96, int $vertical = 96) : static
223
    {
224
        $set = \imageresolution($this->getInstance(), $horizontal, $vertical);
2✔
225
        if ($set === false) {
2✔
226
            throw new RuntimeException('Image could not to set resolution');
×
227
        }
228
        return $this;
2✔
229
    }
230

231
    /**
232
     * Gets the image height.
233
     *
234
     * @return int
235
     */
236
    #[Pure]
237
    public function getHeight() : int
238
    {
239
        return \imagesy($this->getInstance());
8✔
240
    }
241

242
    /**
243
     * Gets the image width.
244
     *
245
     * @return int
246
     */
247
    #[Pure]
248
    public function getWidth() : int
249
    {
250
        return \imagesx($this->getInstance());
8✔
251
    }
252

253
    /**
254
     * Gets the file extension for image type.
255
     *
256
     * @return false|string a string with the extension corresponding to the
257
     * given image type or false on fail
258
     */
259
    #[Pure]
260
    public function getExtension() : false | string
261
    {
262
        return \image_type_to_extension($this->getType());
2✔
263
    }
264

265
    /**
266
     * Gets the image MIME type.
267
     *
268
     * @return string
269
     */
270
    #[Pure]
271
    public function getMime() : string
272
    {
273
        return $this->mime;
6✔
274
    }
275

276
    protected function setMime(string $mime) : static
277
    {
278
        $this->mime = $mime;
72✔
279
        return $this;
72✔
280
    }
281

282
    /**
283
     * Gets the image type.
284
     *
285
     * @return int
286
     */
287
    public function getType() : int
288
    {
289
        return $this->type;
72✔
290
    }
291

292
    protected function setType(int $type) : static
293
    {
294
        $this->type = $type;
72✔
295
        return $this;
72✔
296
    }
297

298
    public function isType(int $type) : bool
299
    {
300
        return $this->getType() === $type;
46✔
301
    }
302

303
    /**
304
     * Creates a new Image based in the current instance.
305
     *
306
     * @param int|string $type The new Image type
307
     * @param string $filename The filename where the new Image will be placed
308
     */
309
    public function create(int | string $type, string $filename) : Image
310
    {
311
        $created = match ($type) {
2✔
312
            \IMAGETYPE_PNG, 'png' => \imagepng($this->getInstance(), $filename),
2✔
313
            \IMAGETYPE_JPEG, 'jpeg' => \imagejpeg($this->getInstance(), $filename),
2✔
314
            \IMAGETYPE_GIF, 'gif' => \imagegif($this->getInstance(), $filename),
2✔
315
            \IMAGETYPE_AVIF, 'avif' => \imageavif($this->getInstance(), $filename),
2✔
316
            default => false,
2✔
317
        };
2✔
318
        if($created === false) {
2✔
319
            throw new RuntimeException('Image could not be created');
2✔
320
        }
321
        return new Image($filename);
2✔
322
    }
323

324
    /**
325
     * Saves the image contents to a given filename.
326
     *
327
     * @param string|null $filename Optional filename or null to use the original
328
     *
329
     * @return bool
330
     */
331
    public function save(?string $filename = null) : bool
332
    {
333
        $filename ??= $this->getFilename();
10✔
334
        return match ($this->getType()) {
10✔
335
            \IMAGETYPE_PNG => \imagepng($this->getInstance(), $filename, $this->getQuality()),
4✔
336
            \IMAGETYPE_JPEG => \imagejpeg($this->getInstance(), $filename, $this->getQuality()),
2✔
337
            \IMAGETYPE_GIF => \imagegif($this->getInstance(), $filename),
2✔
338
            \IMAGETYPE_AVIF => \imageavif($this->getInstance(), $filename, $this->getQuality()),
2✔
339
            default => false,
10✔
340
        };
10✔
341
    }
342

343
    /**
344
     * Sends the image contents to the output buffer.
345
     *
346
     * @return bool
347
     */
348
    public function send() : bool
349
    {
350
        if ($this->hasAlpha()) {
36✔
351
            \imagesavealpha($this->getInstance(), true);
32✔
352
        }
353
        return match ($this->getType()) {
36✔
354
            \IMAGETYPE_PNG => \imagepng($this->getInstance(), null, $this->getQuality()),
28✔
355
            \IMAGETYPE_JPEG => \imagejpeg($this->getInstance(), null, $this->getQuality()),
4✔
356
            \IMAGETYPE_GIF => \imagegif($this->getInstance()),
2✔
357
            \IMAGETYPE_AVIF => \imageavif($this->getInstance(), null, $this->getQuality()),
2✔
358
            default => false,
36✔
359
        };
36✔
360
    }
361

362
    /**
363
     * Renders the image contents.
364
     *
365
     * @throws RuntimeException for image could not be rendered
366
     *
367
     * @return string The image contents
368
     */
369
    public function render() : string
370
    {
371
        \ob_start();
36✔
372
        $status = $this->send();
36✔
373
        $contents = \ob_get_clean();
36✔
374
        if ($status === false || $contents === false) {
36✔
375
            throw new RuntimeException('Image could not be rendered');
×
376
        }
377
        return $contents;
36✔
378
    }
379

380
    /**
381
     * Crops the image.
382
     *
383
     * @param int $width Width in pixels
384
     * @param int $height Height in pixels
385
     * @param int $marginLeft Margin left in pixels
386
     * @param int $marginTop Margin top in pixels
387
     *
388
     * @throws RuntimeException for image could not to crop
389
     *
390
     * @return static
391
     */
392
    public function crop(int $width, int $height, int $marginLeft = 0, int $marginTop = 0) : static
393
    {
394
        $crop = \imagecrop($this->getInstance(), [
2✔
395
            'x' => $marginLeft,
2✔
396
            'y' => $marginTop,
2✔
397
            'width' => $width,
2✔
398
            'height' => $height,
2✔
399
        ]);
2✔
400
        if ($crop === false) {
2✔
401
            throw new RuntimeException('Image could not to crop');
×
402
        }
403
        $this->setInstance($crop);
2✔
404
        return $this;
2✔
405
    }
406

407
    /**
408
     * Flips the image.
409
     *
410
     * @param string $direction Allowed values are: h or horizontal. v or vertical. b or both.
411
     *
412
     * @throws InvalidArgumentException for invalid image flip direction
413
     * @throws RuntimeException for image could not to flip
414
     *
415
     * @return static
416
     */
417
    public function flip(string $direction = 'horizontal') : static
418
    {
419
        $direction = match ($direction) {
8✔
420
            'h', 'horizontal' => \IMG_FLIP_HORIZONTAL,
2✔
421
            'v', 'vertical' => \IMG_FLIP_VERTICAL,
2✔
422
            'b', 'both' => \IMG_FLIP_BOTH,
2✔
423
            default => throw new InvalidArgumentException('Invalid image flip direction: ' . $direction),
2✔
424
        };
8✔
425
        $flip = \imageflip($this->getInstance(), $direction);
6✔
426
        if ($flip === false) {
6✔
427
            throw new RuntimeException('Image could not to flip');
×
428
        }
429
        return $this;
6✔
430
    }
431

432
    /**
433
     * Applies a filter to the image.
434
     *
435
     * @param int $type IMG_FILTER_* constants
436
     * @param int ...$arguments Arguments for the filter type
437
     *
438
     * @see https://www.php.net/manual/en/function.imagefilter.php
439
     *
440
     * @throws RuntimeException for image could not apply the filter
441
     *
442
     * @return static
443
     */
444
    public function filter(int $type, int ...$arguments) : static
445
    {
446
        $filtered = \imagefilter($this->getInstance(), $type, ...$arguments);
2✔
447
        if ($filtered === false) {
2✔
448
            throw new RuntimeException('Image could not apply the filter');
×
449
        }
450
        return $this;
2✔
451
    }
452

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

503
    /**
504
     * Sets the image opacity level.
505
     *
506
     * @param int $opacity Opacity percentage: from 0 to 100
507
     *
508
     * @return static
509
     */
510
    public function opacity(int $opacity = 100) : static
511
    {
512
        if ($opacity < 0 || $opacity > 100) {
6✔
513
            throw new InvalidArgumentException(
2✔
514
                'Opacity percentage must be between 0 and 100, ' . $opacity . ' given'
2✔
515
            );
2✔
516
        }
517
        if ($opacity === 100) {
4✔
518
            \imagealphablending($this->getInstance(), true);
2✔
519
            return $this;
2✔
520
        }
521
        $opacity = (int) \round(\abs(($opacity * 127 / 100) - 127));
2✔
522
        \imagelayereffect($this->getInstance(), \IMG_EFFECT_OVERLAY);
2✔
523
        $color = \imagecolorallocatealpha($this->getInstance(), 127, 127, 127, $opacity);
2✔
524
        if ($color === false) {
2✔
525
            throw new RuntimeException('Image could not allocate a color');
×
526
        }
527
        \imagefilledrectangle(
2✔
528
            $this->getInstance(),
2✔
529
            0,
2✔
530
            0,
2✔
531
            $this->getWidth(),
2✔
532
            $this->getHeight(),
2✔
533
            $color
2✔
534
        );
2✔
535
        \imagesavealpha($this->getInstance(), true);
2✔
536
        \imagealphablending($this->getInstance(), false);
2✔
537
        return $this;
2✔
538
    }
539

540
    /**
541
     * Rotates the image with a given angle.
542
     *
543
     * @param float $angle Rotation angle, in degrees. Clockwise direction.
544
     *
545
     * @throws RuntimeException for image could not allocate a color or could not rotate
546
     *
547
     * @return static
548
     */
549
    public function rotate(float $angle) : static
550
    {
551
        $background = $this->allocateBackground();
4✔
552
        if ($background === false) {
4✔
553
            throw new RuntimeException('Image could not allocate a color');
×
554
        }
555
        $rotate = \imagerotate($this->getInstance(), -1 * $angle, $background);
4✔
556
        if ($rotate === false) {
4✔
557
            throw new RuntimeException('Image could not to rotate');
×
558
        }
559
        $this->setInstance($rotate);
4✔
560
        return $this;
4✔
561
    }
562

563
    protected function allocateBackground() : false | int
564
    {
565
        if ($this->hasAlpha()) {
4✔
566
            \imagealphablending($this->getInstance(), false);
2✔
567
            \imagesavealpha($this->getInstance(), true);
2✔
568
            return \imagecolorallocatealpha($this->getInstance(), 0, 0, 0, 127);
2✔
569
        }
570
        return \imagecolorallocate($this->getInstance(), 255, 255, 255);
2✔
571
    }
572

573
    public function hasAlpha() : bool
574
    {
575
        return \in_array(
36✔
576
            $this->getType(),
36✔
577
            [
36✔
578
                \IMAGETYPE_PNG,
36✔
579
                \IMAGETYPE_GIF,
36✔
580
                \IMAGETYPE_AVIF,
36✔
581
            ],
36✔
582
            true
36✔
583
        );
36✔
584
    }
585

586
    /**
587
     * Scales the image.
588
     *
589
     * @param int $width Width in pixels
590
     * @param int $height Height in pixels. Use -1 to use a proportional height
591
     * based on the width.
592
     *
593
     * @throws RuntimeException for image could not to scale
594
     *
595
     * @return static
596
     */
597
    public function scale(int $width, int $height = -1) : static
598
    {
599
        $scale = \imagescale($this->getInstance(), $width, $height);
4✔
600
        if ($scale === false) {
4✔
601
            throw new RuntimeException('Image could not to scale');
×
602
        }
603
        $this->setInstance($scale);
4✔
604
        return $this;
4✔
605
    }
606

607
    /**
608
     * Adds a watermark to the image.
609
     *
610
     * @param Image $watermark The image to use as watermark
611
     * @param int $horizontalPosition Horizontal position
612
     * @param int $verticalPosition Vertical position
613
     *
614
     * @throws RuntimeException for image could not to create watermark
615
     *
616
     * @return static
617
     */
618
    public function watermark(
619
        Image $watermark,
620
        int $horizontalPosition = 0,
621
        int $verticalPosition = 0
622
    ) : static {
623
        if ($horizontalPosition < 0) {
2✔
624
            $horizontalPosition = $this->getWidth()
2✔
625
                - (-1 * $horizontalPosition + $watermark->getWidth());
2✔
626
        }
627
        if ($verticalPosition < 0) {
2✔
628
            $verticalPosition = $this->getHeight()
2✔
629
                - (-1 * $verticalPosition + $watermark->getHeight());
2✔
630
        }
631
        $copied = \imagecopy(
2✔
632
            $this->getInstance(),
2✔
633
            $watermark->getInstance(),
2✔
634
            $horizontalPosition,
2✔
635
            $verticalPosition,
2✔
636
            0,
2✔
637
            0,
2✔
638
            $watermark->getWidth(),
2✔
639
            $watermark->getHeight()
2✔
640
        );
2✔
641
        if ($copied === false) {
2✔
642
            throw new RuntimeException('Image could not to create watermark');
×
643
        }
644
        return $this;
2✔
645
    }
646

647
    /**
648
     * Allow embed the image contents in a document.
649
     *
650
     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs
651
     * @see https://datatracker.ietf.org/doc/html/rfc2397
652
     *
653
     * @return string The image "data" URL
654
     */
655
    public function getDataUrl() : string
656
    {
657
        return 'data:' . $this->getMime() . ';base64,' . \base64_encode($this->render());
4✔
658
    }
659

660
    /**
661
     * @return string
662
     */
663
    public function jsonSerialize() : string
664
    {
665
        return $this->getDataUrl();
2✔
666
    }
667

668
    /**
669
     * Indicates if a given filename has an acceptable image type.
670
     *
671
     * @param string $filename
672
     *
673
     * @return bool
674
     */
675
    public static function isAcceptable(string $filename) : bool
676
    {
677
        $filename = \realpath($filename);
2✔
678
        if ($filename === false || !\is_file($filename) || !\is_readable($filename)) {
2✔
679
            return false;
2✔
680
        }
681
        $info = \getimagesize($filename);
2✔
682
        if ($info === false) {
2✔
683
            return false;
2✔
684
        }
685
        return match ($info[2]) {
2✔
686
            \IMAGETYPE_PNG, \IMAGETYPE_JPEG, \IMAGETYPE_GIF, \IMAGETYPE_AVIF => true,
2✔
687
            default => false,
2✔
688
        };
2✔
689
    }
690
}
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