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

aplus-framework / image / 23565458672

25 Mar 2026 09:37PM UTC coverage: 90.783% (+0.4%) from 90.385%
23565458672

push

github

natanfelles
Update user guide

197 of 217 relevant lines covered (90.78%)

4.12 hits per line

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

90.78
/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);
32✔
64
        if ($realpath === false || !\is_file($realpath) || !\is_readable($realpath)) {
32✔
65
            throw new InvalidArgumentException('File does not exists or is not readable: ' . $filename);
1✔
66
        }
67
        $this->filename = $realpath;
32✔
68
        $info = \getimagesize($this->filename);
32✔
69
        if ($info === false) {
32✔
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])) {
32✔
75
            throw new RuntimeException('Unsupported image type: ' . $info[2]);
×
76
        }
77
        $this->type = $info[2];
32✔
78
        $this->mime = $info['mime'];
32✔
79
        $instance = match ($this->type) {
32✔
80
            \IMAGETYPE_PNG => \imagecreatefrompng($this->filename),
32✔
81
            \IMAGETYPE_JPEG => \imagecreatefromjpeg($this->filename),
2✔
82
            \IMAGETYPE_GIF => \imagecreatefromgif($this->filename),
2✔
83
            \IMAGETYPE_AVIF => \imagecreatefromavif($this->filename),
2✔
84
            default => throw new RuntimeException('Image type is not acceptable: ' . $this->type),
1✔
85
        };
32✔
86
        if (!$instance instanceof GdImage) {
32✔
87
            throw new RuntimeException(
×
88
                "Image of type '{$this->type}' does not returned a GdImage instance"
×
89
            );
×
90
        }
91
        $this->instance = $instance;
32✔
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->type === \IMAGETYPE_PNG) {
20✔
132
                $this->quality = 6;
15✔
133
            } elseif ($this->type === \IMAGETYPE_JPEG) {
5✔
134
                $this->quality = 75;
2✔
135
            } elseif ($this->type === \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->type === \IMAGETYPE_GIF) {
4✔
158
            throw new LogicException(
1✔
159
                'GIF images does not receive a quality value'
1✔
160
            );
1✔
161
        }
162
        if ($this->type === \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->type === \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->type === \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->type);
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
    /**
268
     * Saves the image contents to a given filename.
269
     *
270
     * @param string|null $filename Optional filename or null to use the original
271
     *
272
     * @return bool
273
     */
274
    public function save(?string $filename = null) : bool
275
    {
276
        $filename ??= $this->filename;
4✔
277
        return match ($this->type) {
4✔
278
            \IMAGETYPE_PNG => \imagepng($this->instance, $filename, $this->getQuality()),
1✔
279
            \IMAGETYPE_JPEG => \imagejpeg($this->instance, $filename, $this->getQuality()),
1✔
280
            \IMAGETYPE_GIF => \imagegif($this->instance, $filename),
1✔
281
            \IMAGETYPE_AVIF => \imageavif($this->instance, $filename, $this->getQuality()),
1✔
282
            default => false,
4✔
283
        };
4✔
284
    }
285

286
    /**
287
     * Sends the image contents to the output buffer.
288
     *
289
     * @return bool
290
     */
291
    public function send() : bool
292
    {
293
        if (\in_array($this->type, [\IMAGETYPE_PNG, \IMAGETYPE_GIF, \IMAGETYPE_AVIF], true)) {
16✔
294
            \imagesavealpha($this->instance, true);
15✔
295
        }
296
        return match ($this->type) {
16✔
297
            \IMAGETYPE_PNG => \imagepng($this->instance, null, $this->getQuality()),
13✔
298
            \IMAGETYPE_JPEG => \imagejpeg($this->instance, null, $this->getQuality()),
1✔
299
            \IMAGETYPE_GIF => \imagegif($this->instance),
1✔
300
            \IMAGETYPE_AVIF => \imageavif($this->instance, null, $this->getQuality()),
1✔
301
            default => false,
16✔
302
        };
16✔
303
    }
304

305
    /**
306
     * Renders the image contents.
307
     *
308
     * @throws RuntimeException for image could not be rendered
309
     *
310
     * @return string The image contents
311
     */
312
    public function render() : string
313
    {
314
        \ob_start();
16✔
315
        $status = $this->send();
16✔
316
        $contents = \ob_get_clean();
16✔
317
        if ($status === false || $contents === false) {
16✔
318
            throw new RuntimeException('Image could not be rendered');
×
319
        }
320
        return $contents;
16✔
321
    }
322

323
    /**
324
     * Crops the image.
325
     *
326
     * @param int $width Width in pixels
327
     * @param int $height Height in pixels
328
     * @param int $marginLeft Margin left in pixels
329
     * @param int $marginTop Margin top in pixels
330
     *
331
     * @throws RuntimeException for image could not to crop
332
     *
333
     * @return static
334
     */
335
    public function crop(int $width, int $height, int $marginLeft = 0, int $marginTop = 0) : static
336
    {
337
        $crop = \imagecrop($this->instance, [
1✔
338
            'x' => $marginLeft,
1✔
339
            'y' => $marginTop,
1✔
340
            'width' => $width,
1✔
341
            'height' => $height,
1✔
342
        ]);
1✔
343
        if ($crop === false) {
1✔
344
            throw new RuntimeException('Image could not to crop');
×
345
        }
346
        $this->instance = $crop;
1✔
347
        return $this;
1✔
348
    }
349

350
    /**
351
     * Flips the image.
352
     *
353
     * @param string $direction Allowed values are: h or horizontal. v or vertical. b or both.
354
     *
355
     * @throws InvalidArgumentException for invalid image flip direction
356
     * @throws RuntimeException for image could not to flip
357
     *
358
     * @return static
359
     */
360
    public function flip(string $direction = 'horizontal') : static
361
    {
362
        $direction = match ($direction) {
3✔
363
            'h', 'horizontal' => \IMG_FLIP_HORIZONTAL,
1✔
364
            'v', 'vertical' => \IMG_FLIP_VERTICAL,
1✔
365
            'b', 'both' => \IMG_FLIP_BOTH,
1✔
366
            default => throw new InvalidArgumentException('Invalid image flip direction: ' . $direction),
×
367
        };
3✔
368
        $flip = \imageflip($this->instance, $direction);
3✔
369
        if ($flip === false) {
3✔
370
            throw new RuntimeException('Image could not to flip');
×
371
        }
372
        return $this;
3✔
373
    }
374

375
    /**
376
     * Applies a filter to the image.
377
     *
378
     * @param int $type IMG_FILTER_* constants
379
     * @param int ...$arguments Arguments for the filter type
380
     *
381
     * @see https://www.php.net/manual/en/function.imagefilter.php
382
     *
383
     * @throws RuntimeException for image could not apply the filter
384
     *
385
     * @return static
386
     */
387
    public function filter(int $type, int ...$arguments) : static
388
    {
389
        $filtered = \imagefilter($this->instance, $type, ...$arguments);
1✔
390
        if ($filtered === false) {
1✔
391
            throw new RuntimeException('Image could not apply the filter');
×
392
        }
393
        return $this;
1✔
394
    }
395

396
    /**
397
     * Flattens the image.
398
     *
399
     * Replaces transparency with an RGB color.
400
     *
401
     * @param int $red
402
     * @param int $green
403
     * @param int $blue
404
     *
405
     * @throws RuntimeException for could not create a true color image, could
406
     * not allocate a color or image could not to flatten
407
     *
408
     * @return static
409
     */
410
    public function flatten(int $red = 255, int $green = 255, int $blue = 255) : static
411
    {
412
        \imagesavealpha($this->instance, false);
1✔
413
        $image = \imagecreatetruecolor($this->getWidth(), $this->getHeight());
1✔
414
        if ($image === false) {
1✔
415
            throw new RuntimeException('Could not create a true color image');
×
416
        }
417
        $color = \imagecolorallocate($image, $red, $green, $blue);
1✔
418
        if ($color === false) {
1✔
419
            throw new RuntimeException('Image could not allocate a color');
×
420
        }
421
        \imagefilledrectangle(
1✔
422
            $image,
1✔
423
            0,
1✔
424
            0,
1✔
425
            $this->getWidth(),
1✔
426
            $this->getHeight(),
1✔
427
            $color
1✔
428
        );
1✔
429
        $copied = \imagecopy(
1✔
430
            $image,
1✔
431
            $this->instance,
1✔
432
            0,
1✔
433
            0,
1✔
434
            0,
1✔
435
            0,
1✔
436
            $this->getWidth(),
1✔
437
            $this->getHeight()
1✔
438
        );
1✔
439
        if ($copied === false) {
1✔
440
            throw new RuntimeException('Image could not to flatten');
×
441
        }
442
        $this->instance = $image;
1✔
443
        return $this;
1✔
444
    }
445

446
    /**
447
     * Sets the image opacity level.
448
     *
449
     * @param int $opacity Opacity percentage: from 0 to 100
450
     *
451
     * @return static
452
     */
453
    public function opacity(int $opacity = 100) : static
454
    {
455
        if ($opacity < 0 || $opacity > 100) {
3✔
456
            throw new InvalidArgumentException(
1✔
457
                'Opacity percentage must be between 0 and 100, ' . $opacity . ' given'
1✔
458
            );
1✔
459
        }
460
        if ($opacity === 100) {
2✔
461
            \imagealphablending($this->instance, true);
1✔
462
            return $this;
1✔
463
        }
464
        $opacity = (int) \round(\abs(($opacity * 127 / 100) - 127));
1✔
465
        \imagelayereffect($this->instance, \IMG_EFFECT_OVERLAY);
1✔
466
        $color = \imagecolorallocatealpha($this->instance, 127, 127, 127, $opacity);
1✔
467
        if ($color === false) {
1✔
468
            throw new RuntimeException('Image could not allocate a color');
×
469
        }
470
        \imagefilledrectangle(
1✔
471
            $this->instance,
1✔
472
            0,
1✔
473
            0,
1✔
474
            $this->getWidth(),
1✔
475
            $this->getHeight(),
1✔
476
            $color
1✔
477
        );
1✔
478
        \imagesavealpha($this->instance, true);
1✔
479
        \imagealphablending($this->instance, false);
1✔
480
        return $this;
1✔
481
    }
482

483
    /**
484
     * Rotates the image with a given angle.
485
     *
486
     * @param float $angle Rotation angle, in degrees. Clockwise direction.
487
     *
488
     * @throws RuntimeException for image could not allocate a color or could not rotate
489
     *
490
     * @return static
491
     */
492
    public function rotate(float $angle) : static
493
    {
494
        if (\in_array($this->type, [\IMAGETYPE_PNG, \IMAGETYPE_GIF, \IMAGETYPE_AVIF], true)) {
1✔
495
            \imagealphablending($this->instance, false);
1✔
496
            \imagesavealpha($this->instance, true);
1✔
497
            $background = \imagecolorallocatealpha($this->instance, 0, 0, 0, 127);
1✔
498
        } else {
499
            $background = \imagecolorallocate($this->instance, 255, 255, 255);
×
500
        }
501
        if ($background === false) {
1✔
502
            throw new RuntimeException('Image could not allocate a color');
×
503
        }
504
        $rotate = \imagerotate($this->instance, -1 * $angle, $background);
1✔
505
        if ($rotate === false) {
1✔
506
            throw new RuntimeException('Image could not to rotate');
×
507
        }
508
        $this->instance = $rotate;
1✔
509
        return $this;
1✔
510
    }
511

512
    /**
513
     * Scales the image.
514
     *
515
     * @param int $width Width in pixels
516
     * @param int $height Height in pixels. Use -1 to use a proportional height
517
     * based on the width.
518
     *
519
     * @throws RuntimeException for image could not to scale
520
     *
521
     * @return static
522
     */
523
    public function scale(int $width, int $height = -1) : static
524
    {
525
        $scale = \imagescale($this->instance, $width, $height);
2✔
526
        if ($scale === false) {
2✔
527
            throw new RuntimeException('Image could not to scale');
×
528
        }
529
        $this->instance = $scale;
2✔
530
        return $this;
2✔
531
    }
532

533
    /**
534
     * Adds a watermark to the image.
535
     *
536
     * @param Image $watermark The image to use as watermark
537
     * @param int $horizontalPosition Horizontal position
538
     * @param int $verticalPosition Vertical position
539
     *
540
     * @throws RuntimeException for image could not to create watermark
541
     *
542
     * @return static
543
     */
544
    public function watermark(
545
        Image $watermark,
546
        int $horizontalPosition = 0,
547
        int $verticalPosition = 0
548
    ) : static {
549
        if ($horizontalPosition < 0) {
1✔
550
            $horizontalPosition = $this->getWidth()
1✔
551
                - (-1 * $horizontalPosition + $watermark->getWidth());
1✔
552
        }
553
        if ($verticalPosition < 0) {
1✔
554
            $verticalPosition = $this->getHeight()
1✔
555
                - (-1 * $verticalPosition + $watermark->getHeight());
1✔
556
        }
557
        $copied = \imagecopy(
1✔
558
            $this->instance,
1✔
559
            $watermark->getInstance(),
1✔
560
            $horizontalPosition,
1✔
561
            $verticalPosition,
1✔
562
            0,
1✔
563
            0,
1✔
564
            $watermark->getWidth(),
1✔
565
            $watermark->getHeight()
1✔
566
        );
1✔
567
        if ($copied === false) {
1✔
568
            throw new RuntimeException('Image could not to create watermark');
×
569
        }
570
        return $this;
1✔
571
    }
572

573
    /**
574
     * Allow embed the image contents in a document.
575
     *
576
     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs
577
     * @see https://datatracker.ietf.org/doc/html/rfc2397
578
     *
579
     * @return string The image "data" URL
580
     */
581
    public function getDataUrl() : string
582
    {
583
        return 'data:' . $this->getMime() . ';base64,' . \base64_encode($this->render());
2✔
584
    }
585

586
    /**
587
     * @return string
588
     */
589
    public function jsonSerialize() : string
590
    {
591
        return $this->getDataUrl();
1✔
592
    }
593

594
    /**
595
     * Indicates if a given filename has an acceptable image type.
596
     *
597
     * @param string $filename
598
     *
599
     * @return bool
600
     */
601
    public static function isAcceptable(string $filename) : bool
602
    {
603
        $filename = \realpath($filename);
1✔
604
        if ($filename === false || !\is_file($filename) || !\is_readable($filename)) {
1✔
605
            return false;
1✔
606
        }
607
        $info = \getimagesize($filename);
1✔
608
        if ($info === false) {
1✔
609
            return false;
1✔
610
        }
611
        return match ($info[2]) {
1✔
612
            \IMAGETYPE_PNG, \IMAGETYPE_JPEG, \IMAGETYPE_GIF, \IMAGETYPE_AVIF => true,
1✔
613
            default => false,
1✔
614
        };
1✔
615
    }
616
}
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