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

JBZoo / Image / 30191091610

20 Jul 2026 09:00PM UTC coverage: 89.643%. Remained the same
30191091610

push

github

web-flow
Merge pull request #33 from JBZoo/release/8.0

8.0.0 — PHP 8.3+ floor & lock-step major

528 of 589 relevant lines covered (89.64%)

100.65 hits per line

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

85.94
/src/Image.php
1
<?php
2

3
/**
4
 * JBZoo Toolbox - Image.
5
 *
6
 * This file is part of the JBZoo Toolbox project.
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 *
10
 * @license    MIT
11
 * @copyright  Copyright (C) JBZoo.com, All rights reserved.
12
 * @see        https://github.com/JBZoo/Image
13
 */
14

15
declare(strict_types=1);
16

17
namespace JBZoo\Image;
18

19
use JBZoo\Utils\Filter as VarFilter;
20
use JBZoo\Utils\FS;
21
use JBZoo\Utils\Image as Helper;
22
use JBZoo\Utils\Sys;
23
use JBZoo\Utils\Url;
24

25
use function JBZoo\Utils\isStrEmpty;
26

27
/**
28
 * @SuppressWarnings(PHPMD.TooManyMethods)
29
 * @SuppressWarnings(PHPMD.TooManyPublicMethods)
30
 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
31
 * @SuppressWarnings(PHPMD.ExcessiveClassLength)
32
 */
33
final class Image
34
{
35
    public const LANDSCAPE = 'landscape';
36
    public const PORTRAIT  = 'portrait';
37
    public const SQUARE    = 'square';
38

39
    public const FLIP_HORIZONTAL                           = 2;
40
    public const FLIP_180_COUNTERCLOCKWISE                 = 3;
41
    public const FLIP_VERTICAL                             = 4;
42
    public const FLIP_ROTATE_90_CLOCKWISE_AND_VERTICALLY   = 5;
43
    public const FLIP_ROTATE_90_CLOCKWISE                  = 6;
44
    public const FLIP_ROTATE_90_CLOCKWISE_AND_HORIZONTALLY = 7;
45
    public const FLIP_ROTATE_90_COUNTERCLOCKWISE           = 8;
46

47
    public const DEFAULT_QUALITY = 95;
48
    public const DEFAULT_MIME    = 'image/png';
49

50
    /** GD Resource or bin data. */
51
    private ?\GdImage $image   = null;
52
    private int       $quality = self::DEFAULT_QUALITY;
53
    private array     $exif    = [];
54
    private int       $width   = 0;
55
    private int       $height  = 0;
56
    private ?string   $filename;
57
    private ?string   $orient;
58
    private ?string   $mime;
59

60
    public function __construct(\GdImage|string|null $filename = null, bool $strict = false)
61
    {
62
        Helper::checkGD();
606✔
63

64
        $this->orient   = null;
606✔
65
        $this->filename = null;
606✔
66
        $this->mime     = null;
606✔
67

68
        if (
69
            $filename !== ''
606✔
70
            && \is_string($filename)
606✔
71
            && \ctype_print($filename)
606✔
72
            && FS::isFile($filename)
606✔
73
        ) {
74
            $this->loadFile($filename);
228✔
75
        } elseif ($filename instanceof \GdImage) {
444✔
76
            $this->loadResource($filename);
6✔
77
        } elseif (!isStrEmpty($filename)) {
438✔
78
            $this->loadString($filename, $strict);
12✔
79
        }
80
    }
81

82
    /**
83
     * Destroy image resource.
84
     */
85
    public function __destruct()
86
    {
87
        $this->cleanup();
606✔
88
    }
89

90
    public function getInfo(): array
91
    {
92
        return [
36✔
93
            'filename' => $this->filename,
36✔
94
            'width'    => $this->width,
36✔
95
            'height'   => $this->height,
36✔
96
            'mime'     => $this->mime,
36✔
97
            'quality'  => $this->quality,
36✔
98
            'exif'     => $this->exif,
36✔
99
            'orient'   => $this->orient,
36✔
100
        ];
36✔
101
    }
102

103
    /**
104
     * Get the current width.
105
     */
106
    public function getWidth(): int
107
    {
108
        return $this->width;
84✔
109
    }
110

111
    /**
112
     * Get the current height.
113
     */
114
    public function getHeight(): int
115
    {
116
        return $this->height;
84✔
117
    }
118

119
    /**
120
     * Get the current image resource.
121
     */
122
    public function getImage(): ?\GdImage
123
    {
124
        return $this->image;
72✔
125
    }
126

127
    public function setQuality(int $newQuality): self
128
    {
129
        $this->quality = Helper::quality($newQuality);
6✔
130

131
        return $this;
6✔
132
    }
133

134
    /**
135
     * Save an image. The resulting format will be determined by the file extension.
136
     * @param null|int $quality Output image quality in percents 0-100
137
     */
138
    public function save(?int $quality = null): self
139
    {
140
        $quality ??= $this->quality;
18✔
141

142
        if ($this->filename !== null && $this->filename !== '') {
18✔
143
            if (!$this->internalSave($this->filename, $quality)) {
12✔
144
                throw new Exception("File {$this->filename} not saved");
×
145
            }
146

147
            return $this;
12✔
148
        }
149

150
        throw new Exception('Filename is not defined');
6✔
151
    }
152

153
    /**
154
     * Save an image. The resulting format will be determined by the file extension.
155
     * @param string   $filename If omitted - original file will be overwritten
156
     * @param null|int $quality  Output image quality in percents 0-100
157
     */
158
    public function saveAs(string $filename, ?int $quality = null): self
159
    {
160
        if (isStrEmpty($filename)) {
474✔
161
            throw new Exception('Empty filename to save image');
6✔
162
        }
163

164
        $dir = FS::dirName($filename);
468✔
165
        if (\realpath($dir) !== false && \is_dir($dir)) {
468✔
166
            $this->internalSave($filename, $quality);
462✔
167
        } else {
168
            throw new Exception("Target directory \"{$dir}\" not exists");
6✔
169
        }
170

171
        return $this;
462✔
172
    }
173

174
    public function isGif(): bool
175
    {
176
        return Helper::isGif($this->mime);
78✔
177
    }
178

179
    public function isPng(): bool
180
    {
181
        return Helper::isPng($this->mime);
6✔
182
    }
183

184
    public function isWebp(): bool
185
    {
186
        return Helper::isWebp($this->mime);
6✔
187
    }
188

189
    public function isJpeg(): bool
190
    {
191
        return Helper::isJpeg($this->mime);
6✔
192
    }
193

194
    /**
195
     * Load an image.
196
     * @param string $filename Path to image file
197
     */
198
    public function loadFile(string $filename): self
199
    {
200
        $cleanFilename = FS::clean($filename);
552✔
201
        if (!FS::isFile($cleanFilename)) {
552✔
202
            throw new Exception('Image file not found: ' . $filename);
6✔
203
        }
204

205
        $this->cleanup();
546✔
206
        $this->filename = $cleanFilename;
546✔
207
        $this->loadMeta();
546✔
208

209
        return $this;
546✔
210
    }
211

212
    /**
213
     * Load an image.
214
     * @param null|string $imageString Binary images
215
     */
216
    public function loadString(?string $imageString, bool $strict = false): self
217
    {
218
        if ($imageString === null || $imageString === '') {
18✔
219
            throw new Exception('Image string is empty!');
6✔
220
        }
221

222
        $this->cleanup();
12✔
223
        $this->loadMeta($imageString, $strict);
12✔
224

225
        return $this;
6✔
226
    }
227

228
    /**
229
     * Load image resource.
230
     * @param null|\GdImage|string $imageRes Image GD Resource
231
     */
232
    public function loadResource(\GdImage|string|null $imageRes = null): self
233
    {
234
        if (!$imageRes instanceof \GdImage) {
12✔
235
            throw new Exception('Image is not GD resource!');
6✔
236
        }
237

238
        $this->cleanup();
6✔
239
        $this->loadMeta($imageRes);
6✔
240

241
        return $this;
6✔
242
    }
243

244
    /**
245
     * Clean whole image object.
246
     */
247
    public function cleanup(): self
248
    {
249
        $this->filename = null;
606✔
250

251
        $this->mime    = null;
606✔
252
        $this->width   = 0;
606✔
253
        $this->height  = 0;
606✔
254
        $this->exif    = [];
606✔
255
        $this->orient  = null;
606✔
256
        $this->quality = self::DEFAULT_QUALITY;
606✔
257

258
        $this->destroyImage();
606✔
259

260
        return $this;
606✔
261
    }
262

263
    public function isPortrait(): bool
264
    {
265
        return $this->orient === self::PORTRAIT;
6✔
266
    }
267

268
    public function isLandscape(): bool
269
    {
270
        return $this->orient === self::LANDSCAPE;
6✔
271
    }
272

273
    public function isSquare(): bool
274
    {
275
        return $this->orient === self::SQUARE;
6✔
276
    }
277

278
    /**
279
     * Create an image from scratch.
280
     * @param int               $width  Image width
281
     * @param null|int          $height If omitted - assumed equal to $width
282
     * @param null|array|string $color  Hex color string, array(red, green, blue) or array(red, green, blue, alpha).
283
     *                                  Where red, green, blue - integers 0-255, alpha - integer 0-127
284
     */
285
    public function create(int $width, ?int $height = null, array|string|null $color = null): self
286
    {
287
        $this->cleanup();
18✔
288

289
        $height = (int)$height === 0 ? $width : $height;
18✔
290

291
        $this->width  = VarFilter::int($width);
18✔
292
        $this->height = VarFilter::int($height);
18✔
293

294
        $newImageRes = \imagecreatetruecolor($this->width, $this->height); // @phpstan-ignore-line
18✔
295
        if ($newImageRes !== false) {
18✔
296
            $this->image = $newImageRes;
18✔
297
        } else {
298
            throw new Exception("Can't create empty image resource");
×
299
        }
300

301
        $this->mime = self::DEFAULT_MIME;
18✔
302
        $this->exif = [];
18✔
303

304
        $this->orient = $this->getOrientation();
18✔
305

306
        if ($color !== null) {
18✔
307
            return $this->addFilter('fill', $color);
6✔
308
        }
309

310
        return $this;
12✔
311
    }
312

313
    /**
314
     * Resize an image to the specified dimensions.
315
     */
316
    public function resize(float $width, float $height): self
317
    {
318
        $width  = VarFilter::int($width);
72✔
319
        $height = VarFilter::int($height);
72✔
320

321
        // Generate new GD image
322
        $newImage = \imagecreatetruecolor($width, $height); // @phpstan-ignore-line
72✔
323
        if ($newImage === false) {
72✔
324
            throw new Exception("Can't create new image resource");
×
325
        }
326

327
        if ($this->image === null) {
72✔
328
            throw new Exception('Image resource in not defined');
×
329
        }
330

331
        if ($this->isGif()) {
72✔
332
            // Preserve transparency in GIFs
333
            $transIndex = \imagecolortransparent($this->image);
24✔
334
            $palletSize = \imagecolorstotal($this->image);
24✔
335

336
            if ($transIndex > 0 && $transIndex < $palletSize) {
24✔
337
                $trColor = \imagecolorsforindex($this->image, $transIndex);
×
338

339
                $red   = 0;
×
340
                $green = 0;
×
341
                $blue  = 0;
×
342

343
                $colorsTypeCount = 3;
×
344

345
                if (\count($trColor) >= $colorsTypeCount) { // @phpstan-ignore-line
×
346
                    $red   = VarFilter::int($trColor['red']);
×
347
                    $green = VarFilter::int($trColor['green']);
×
348
                    $blue  = VarFilter::int($trColor['blue']);
×
349
                }
350

351
                $transIndex = (int)\imagecolorallocate($newImage, $red, $green, $blue); // @phpstan-ignore-line
×
352

353
                \imagefill($newImage, 0, 0, $transIndex);
×
354
                \imagecolortransparent($newImage, $transIndex);
×
355
            }
356
        } else {
357
            // Preserve transparency in PNG
358
            Helper::addAlpha($newImage, false);
48✔
359
        }
360

361
        // Resize
362
        \imagecopyresampled($newImage, $this->image, 0, 0, 0, 0, $width, $height, $this->width, $this->height);
72✔
363

364
        // Update meta data
365
        $this->replaceImage($newImage);
72✔
366
        $this->width  = $width;
72✔
367
        $this->height = $height;
72✔
368

369
        return $this;
72✔
370
    }
371

372
    /**
373
     * Best fit (proportionally resize to fit in specified width/height)
374
     * Shrink the image proportionally to fit inside a $width x $height box.
375
     */
376
    public function bestFit(int $maxWidth, int $maxHeight): self
377
    {
378
        // If it already fits, there's nothing to do
379
        if ($this->width <= $maxWidth && $this->height <= $maxHeight) {
18✔
380
            return $this;
6✔
381
        }
382

383
        // Determine aspect ratio
384
        $aspectRatio = $this->height / $this->width;
12✔
385

386
        $width  = $this->width;
12✔
387
        $height = $this->height;
12✔
388

389
        // Make width fit into new dimensions
390
        if ($this->width > $maxWidth) {
12✔
391
            $width  = $maxWidth;
12✔
392
            $height = $width * $aspectRatio;
12✔
393
        }
394

395
        // Make height fit into new dimensions
396
        if ($height > $maxHeight) {
12✔
397
            $height = $maxHeight;
6✔
398
            $width  = $height / $aspectRatio;
6✔
399
        }
400

401
        return $this->resize($width, $height);
12✔
402
    }
403

404
    /**
405
     * Thumbnail.
406
     * This function attempts to get the image to as close to the provided dimensions as possible, and then crops the
407
     * remaining overflow (from the center) to get the image to be the size specified. Useful for generating thumbnails.
408
     * @param null|int $height    If omitted - assumed equal to $width
409
     * @param bool     $topIsZero Force top offset = 0
410
     */
411
    public function thumbnail(int $width, ?int $height = null, bool $topIsZero = false): self
412
    {
413
        $width  = VarFilter::int($width);
24✔
414
        $height = VarFilter::int($height);
24✔
415

416
        // Determine height
417
        $height = $height === 0 ? $width : $height;
24✔
418

419
        // Determine aspect ratios
420
        $currentAspectRatio = $this->height / $this->width;
24✔
421
        $newAspectRatio     = $height / $width;
24✔
422

423
        // Fit to height/width
424
        if ($newAspectRatio > $currentAspectRatio) {
24✔
425
            $this->fitToHeight($height);
12✔
426
        } else {
427
            $this->fitToWidth($width);
12✔
428
        }
429

430
        $left = (int)\floor(($this->width / 2) - ($width / 2));
24✔
431
        $top  = (int)\floor(($this->height / 2) - ($height / 2));
24✔
432

433
        // Return trimmed image
434
        $right  = $width + $left;
24✔
435
        $bottom = $height + $top;
24✔
436

437
        if ($topIsZero) {
24✔
438
            $bottom -= $top;
6✔
439
            $top = 0;
6✔
440
        }
441

442
        return $this->crop($left, $top, $right, $bottom);
24✔
443
    }
444

445
    /**
446
     * Fit to height (proportionally resize to specified height).
447
     */
448
    public function fitToHeight(int $height): self
449
    {
450
        $height = VarFilter::int($height);
18✔
451
        $width  = $height / ($this->height / $this->width);
18✔
452

453
        return $this->resize($width, $height);
18✔
454
    }
455

456
    /**
457
     * Fit to width (proportionally resize to specified width).
458
     */
459
    public function fitToWidth(int $width): self
460
    {
461
        $width  = VarFilter::int($width);
24✔
462
        $height = $width * ($this->height / $this->width);
24✔
463

464
        return $this->resize($width, $height);
24✔
465
    }
466

467
    /**
468
     * Crop an image.
469
     * @param int $left   Left
470
     * @param int $top    Top
471
     * @param int $right  Right
472
     * @param int $bottom Bottom
473
     */
474
    public function crop(int $left, int $top, int $right, int $bottom): self
475
    {
476
        $left   = VarFilter::int($left);
36✔
477
        $top    = VarFilter::int($top);
36✔
478
        $right  = VarFilter::int($right);
36✔
479
        $bottom = VarFilter::int($bottom);
36✔
480

481
        // Determine crop size
482
        if ($right < $left) {
36✔
483
            [$left, $right] = [$right, $left];
6✔
484
        }
485

486
        if ($bottom < $top) {
36✔
487
            [$top, $bottom] = [$bottom, $top];
6✔
488
        }
489

490
        $croppedW = $right - $left;
36✔
491
        $croppedH = $bottom - $top;
36✔
492

493
        // Perform crop
494
        $newImage = \imagecreatetruecolor($croppedW, $croppedH); // @phpstan-ignore-line
36✔
495
        if ($newImage === false) {
36✔
496
            throw new Exception("Can't crop image, imagecreatetruecolor() failed");
×
497
        }
498

499
        Helper::addAlpha($newImage);
36✔
500

501
        if ($this->image instanceof \GdImage) {
36✔
502
            \imagecopyresampled($newImage, $this->image, 0, 0, $left, $top, $croppedW, $croppedH, $croppedW, $croppedH);
36✔
503
        } else {
504
            throw new Exception("Can't crop image, image resource is undefined");
×
505
        }
506

507
        // Update meta data
508
        $this->replaceImage($newImage);
36✔
509
        $this->width  = $croppedW;
36✔
510
        $this->height = $croppedH;
36✔
511

512
        return $this;
36✔
513
    }
514

515
    /**
516
     * Rotates and/or flips an image automatically so the orientation will be correct (based on exif 'Orientation').
517
     */
518
    public function autoOrient(): self
519
    {
520
        if (!\array_key_exists('Orientation', $this->exif)) {
6✔
521
            return $this;
6✔
522
        }
523

524
        $orient = (int)$this->exif['Orientation'];
×
525

526
        if ($orient === self::FLIP_HORIZONTAL) {
×
527
            $this->addFilter('flip', 'x');
×
528
        } elseif ($orient === self::FLIP_180_COUNTERCLOCKWISE) {
×
529
            $this->addFilter('rotate', -180);
×
530
        } elseif ($orient === self::FLIP_VERTICAL) {
×
531
            $this->addFilter('flip', 'y');
×
532
        } elseif ($orient === self::FLIP_ROTATE_90_CLOCKWISE_AND_VERTICALLY) {
×
533
            $this->addFilter('flip', 'y');
×
534
            $this->addFilter('rotate', 90);
×
535
        } elseif ($orient === self::FLIP_ROTATE_90_CLOCKWISE) {
×
536
            $this->addFilter('rotate', 90);
×
537
        } elseif ($orient === self::FLIP_ROTATE_90_CLOCKWISE_AND_HORIZONTALLY) {
×
538
            $this->addFilter('flip', 'x');
×
539
            $this->addFilter('rotate', 90);
×
540
        } elseif ($orient === self::FLIP_ROTATE_90_COUNTERCLOCKWISE) {
×
541
            $this->addFilter('rotate', -90);
×
542
        }
543

544
        return $this;
×
545
    }
546

547
    /**
548
     * Overlay an image on top of another, works with 24-bit PNG alpha-transparency.
549
     * @param Image|string $overlay     An image filename or an Image object
550
     * @param string       $position    center|top|left|bottom|right|top left|top right|bottom left|bottom right
551
     * @param float        $opacity     Overlay opacity 0-1 or 0-100
552
     * @param int          $globOffsetX Horizontal offset in pixels
553
     * @param int          $globOffsetY Vertical offset in pixels
554
     */
555
    public function overlay(
556
        self|string $overlay,
557
        string $position = 'bottom right',
558
        float $opacity = .4,
559
        int $globOffsetX = 0,
560
        int $globOffsetY = 0,
561
    ): self {
562
        // Load overlay image
563
        if (!$overlay instanceof self) {
72✔
564
            $overlay = new self($overlay);
72✔
565
        }
566

567
        // Convert opacity
568
        $opacity     = Helper::opacity($opacity);
72✔
569
        $globOffsetX = VarFilter::int($globOffsetX);
72✔
570
        $globOffsetY = VarFilter::int($globOffsetY);
72✔
571

572
        // Determine position
573
        $offsetCoords = Helper::getInnerCoords(
72✔
574
            $position,
72✔
575
            [$this->width, $this->height],
72✔
576
            [$overlay->getWidth(), $overlay->getHeight()],
72✔
577
            [$globOffsetX, $globOffsetY],
72✔
578
        );
72✔
579

580
        $xOffset = (int)($offsetCoords[0] ?? null);
72✔
581
        $yOffset = (int)($offsetCoords[1] ?? null);
72✔
582

583
        if ($this->image === null) {
72✔
584
            throw new Exception("Can't overlay image, image resource is undefined");
×
585
        }
586

587
        $overlayImage = $overlay->getImage();
72✔
588
        if ($overlayImage === null) {
72✔
589
            throw new Exception("Can't overlay image, overlay image resource is undefined");
×
590
        }
591

592
        // Perform the overlay
593
        Helper::imageCopyMergeAlpha(
72✔
594
            $this->image,
72✔
595
            $overlayImage,
72✔
596
            [$xOffset, $yOffset],
72✔
597
            [0, 0],
72✔
598
            [$overlay->getWidth(), $overlay->getHeight()],
72✔
599
            $opacity,
72✔
600
        );
72✔
601

602
        return $this;
72✔
603
    }
604

605
    /**
606
     * Add filter to current image.
607
     */
608
    public function addFilter(mixed $filter): self
609
    {
610
        $args    = \func_get_args();
258✔
611
        $args[0] = $this->image;
258✔
612

613
        if (\is_string($filter)) {
258✔
614
            if (\method_exists(Filter::class, $filter)) {
252✔
615
                /** @var \Closure $filterFunc */
616
                $filterFunc = [Filter::class, $filter]; // @phpstan-ignore-line
246✔
617
                $newImage   = $filterFunc(...$args);
246✔
618
            } else {
619
                throw new Exception("Undefined Image Filter: {$filter}");
6✔
620
            }
621
        } elseif (\is_callable($filter)) {
6✔
622
            $newImage = $filter(...$args);
6✔
623
        } else {
624
            throw new Exception('Undefined filter type');
×
625
        }
626

627
        if ($newImage instanceof \GdImage) {
246✔
628
            $this->replaceImage($newImage);
78✔
629
        }
630

631
        return $this;
246✔
632
    }
633

634
    /**
635
     * Outputs image as data base64 to use as img src.
636
     *
637
     * @param null|string $format  If omitted or null - format of original file will be used, may be gif|jpg|png
638
     * @param null|int    $quality Output image quality in percents 0-100
639
     */
640
    public function getBase64(?string $format = 'gif', ?int $quality = null, bool $addMime = true): string
641
    {
642
        [$mimeType, $binaryData] = $this->renderBinary($format, $quality);
12✔
643

644
        $result = \base64_encode($binaryData);
6✔
645

646
        if ($addMime) {
6✔
647
            $result = 'data:' . $mimeType . ';base64,' . $result;
6✔
648
        }
649

650
        return $result;
6✔
651
    }
652

653
    /**
654
     * Outputs image as binary data.
655
     *
656
     * @param null|string $format  If omitted or null - format of original file will be used, may be gif|jpg|png
657
     * @param null|int    $quality Output image quality in percents 0-100
658
     */
659
    public function getBinary(?string $format = null, ?int $quality = null): string
660
    {
661
        $result = $this->renderBinary($format, $quality);
6✔
662

663
        return $result[1];
6✔
664
    }
665

666
    /**
667
     * Get relative path to image.
668
     */
669
    public function getPath(): string
670
    {
671
        if ($this->filename === null || $this->filename === '') {
12✔
672
            throw new Exception('Filename is empty');
6✔
673
        }
674

675
        return Url::pathToRel($this->filename);
6✔
676
    }
677

678
    /**
679
     * Get full URL to image (if not CLI mode).
680
     */
681
    public function getUrl(): string
682
    {
683
        $rootPath = Url::root();
12✔
684
        $relPath  = $this->getPath();
12✔
685

686
        return "{$rootPath}/{$relPath}";
6✔
687
    }
688

689
    private function savePng(string $filename, int $quality = self::DEFAULT_QUALITY): bool
690
    {
691
        if ($this->image !== null) {
204✔
692
            return \imagepng(
204✔
693
                $this->image,
204✔
694
                $filename === '' ? null : $filename,
204✔
695
                (int)\round(9 * $quality / 100),
204✔
696
            );
204✔
697
        }
698

699
        throw new Exception('Image resource ins not defined');
×
700
    }
701

702
    private function saveJpeg(string $filename, int $quality = self::DEFAULT_QUALITY): bool
703
    {
704
        if ($this->image !== null) {
258✔
705
            // imageinterlace($this->image, true);
706
            return \imagejpeg(
258✔
707
                $this->image,
258✔
708
                $filename === '' ? null : $filename,
258✔
709
                (int)\round($quality),
258✔
710
            );
258✔
711
        }
712

713
        throw new Exception('Image resource ins not defined');
×
714
    }
715

716
    private function saveGif(string $filename): bool
717
    {
718
        if ($this->image !== null) {
42✔
719
            return \imagegif(
42✔
720
                $this->image,
42✔
721
                $filename === '' ? null : $filename,
42✔
722
            );
42✔
723
        }
724

725
        throw new Exception('Image resource ins not defined');
×
726
    }
727

728
    private function saveWebP(string $filename, int $quality = self::DEFAULT_QUALITY): bool
729
    {
730
        if (!\function_exists('\imagewebp')) {
6✔
731
            throw new Exception('Function imagewebp() is not available. Rebuild your ext-gd for PHP');
×
732
        }
733

734
        if ($this->image !== null) {
6✔
735
            return \imagewebp(
6✔
736
                $this->image,
6✔
737
                $filename === '' ? null : $filename,
6✔
738
                (int)\round($quality),
6✔
739
            );
6✔
740
        }
741

742
        throw new Exception('Image resource ins not defined');
×
743
    }
744

745
    /**
746
     * Save image to file.
747
     */
748
    private function internalSave(string $filename, ?int $quality): bool
749
    {
750
        $quality = $quality > 0 ? $quality : $this->quality;
474✔
751
        $quality = Helper::quality($quality);
474✔
752

753
        $format = \strtolower(FS::ext($filename));
474✔
754
        if (!Helper::isSupportedFormat($format)) {
474✔
755
            $format = $this->mime;
12✔
756
        }
757

758
        $filename = FS::clean($filename);
474✔
759

760
        // Create the image
761
        if ($this->renderImageByFormat($format, $filename, $quality) !== null) {
474✔
762
            $this->loadFile($filename);
474✔
763
            $this->quality = $quality;
474✔
764

765
            return true;
474✔
766
        }
767

768
        return false;
×
769
    }
770

771
    /**
772
     * Render image resource as binary.
773
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
774
     */
775
    private function renderImageByFormat(
776
        ?string $format,
777
        string $filename,
778
        int $quality = self::DEFAULT_QUALITY,
779
    ): ?string {
780
        if ($this->image === null) {
486✔
781
            throw new Exception('Image resource not defined');
×
782
        }
783

784
        $format = $format === null || $format === '' ? $this->mime : $format;
486✔
785

786
        $result = null;
486✔
787
        if (Helper::isJpeg($format)) {
486✔
788
            if ($this->saveJpeg($filename, $quality)) {
258✔
789
                $result = 'image/jpeg';
258✔
790
            }
791
        } elseif (Helper::isPng($format)) {
240✔
792
            if ($this->savePng($filename, $quality)) {
204✔
793
                $result = 'image/png';
204✔
794
            }
795
        } elseif (Helper::isGif($format)) {
48✔
796
            if ($this->saveGif($filename)) {
42✔
797
                $result = 'image/gif';
42✔
798
            }
799
        } elseif (Helper::isWebp($format)) {
6✔
800
            if ($this->saveWebP($filename)) {
6✔
801
                $result = 'image/webp';
6✔
802
            }
803
        } else {
804
            throw new Exception("Undefined format: {$format}");
×
805
        }
806

807
        return $result;
486✔
808
    }
809

810
    /**
811
     * Get metadata of image or base64 string.
812
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
813
     */
814
    private function loadMeta(\GdImage|string|null $image = null, bool $strict = false): self
815
    {
816
        // Gather meta data
817
        if ($image === null && $this->filename !== null && $this->filename !== '') {
552✔
818
            $imageInfo = \getimagesize($this->filename);
546✔
819
            if ($imageInfo !== false) {
546✔
820
                // @phan-suppress-next-line PhanPartialTypeMismatchArgument
821
                $this->image = $this->imageCreate($imageInfo['mime']);
546✔
822
            }
823
        } elseif ($image instanceof \GdImage) {
18✔
824
            $this->image = $image;
6✔
825
            $imageInfo   = [
6✔
826
                '0'    => \imagesx($this->image),
6✔
827
                '1'    => \imagesy($this->image),
6✔
828
                'mime' => self::DEFAULT_MIME,
6✔
829
            ];
6✔
830
        } elseif (\is_string($image)) {
12✔
831
            if ($strict) {
12✔
832
                $cleanedString = \str_replace(
12✔
833
                    ' ',
12✔
834
                    '+',
12✔
835
                    (string)\preg_replace('#^data:image/[^;]+;base64,#', '', $image),
12✔
836
                );
12✔
837

838
                if (\base64_decode($cleanedString, true) === false) {
12✔
839
                    throw new Exception('Invalid image source.');
6✔
840
                }
841
            }
842

843
            $imageBin = Helper::strToBin($image);
6✔
844
            if ($imageBin !== null) {
6✔
845
                $imageInfo = \getimagesizefromstring($imageBin);
6✔
846
                if ($imageInfo === false) {
6✔
847
                    throw new Exception('Invalid image source. Can\'tget image info from string');
×
848
                }
849

850
                $newImage    = \imagecreatefromstring($imageBin);
6✔
851
                $this->image = $newImage !== false ? $newImage : null;
6✔
852
            }
853
        } else {
854
            throw new Exception('Undefined format of source. Only "resource|string" are expected');
×
855
        }
856

857
        // Set internal state
858
        if (isset($imageInfo) && \is_array($imageInfo)) {
546✔
859
            $this->mime   = $imageInfo['mime'];
546✔
860
            $this->width  = $imageInfo['0'];
546✔
861
            $this->height = $imageInfo['1'];
546✔
862
        }
863
        $this->exif   = $this->getExif();
546✔
864
        $this->orient = $this->getOrientation();
546✔
865

866
        // Prepare alpha chanel
867
        if ($this->image !== null) {
546✔
868
            Helper::addAlpha($this->image);
546✔
869
        } else {
870
            throw new Exception('Image resource not defined');
×
871
        }
872

873
        return $this;
546✔
874
    }
875

876
    /**
877
     * Destroy image resource if not empty.
878
     */
879
    private function destroyImage(): void
880
    {
881
        if ($this->image instanceof \GdImage) {
606✔
882
            $this->image = null;
546✔
883
        }
884
    }
885

886
    private function getExif(): array
887
    {
888
        $result = [];
546✔
889

890
        if (
891
            $this->filename !== ''
546✔
892
            && $this->filename !== null
546✔
893
            && Sys::isFunc('exif_read_data')
546✔
894
            && Helper::isJpeg($this->mime)
546✔
895
        ) {
896
            $exif   = \exif_read_data($this->filename);
468✔
897
            $result = $exif === false ? [] : $exif;
468✔
898
        }
899

900
        return $result;
546✔
901
    }
902

903
    /**
904
     * Create image resource.
905
     */
906
    private function imageCreate(?string $format): \GdImage
907
    {
908
        if ($this->filename === '' || $this->filename === null) {
546✔
909
            throw new Exception('Filename is undefined');
×
910
        }
911

912
        if (Helper::isJpeg($format)) {
546✔
913
            $result = \imagecreatefromjpeg($this->filename);
468✔
914
        } elseif (Helper::isPng($format)) {
282✔
915
            $result = \imagecreatefrompng($this->filename);
216✔
916
        } elseif (Helper::isGif($format)) {
66✔
917
            $result = \imagecreatefromgif($this->filename);
60✔
918
        } elseif (\function_exists('imagecreatefromwebp') && Helper::isWebp($format)) {
6✔
919
            $result = \imagecreatefromwebp($this->filename);
6✔
920
        } else {
921
            throw new Exception("Invalid image: {$this->filename}");
×
922
        }
923

924
        if ($result === false) {
546✔
925
            throw new Exception("Can't create new image resource by filename: {$this->filename}; format: {$format}");
×
926
        }
927

928
        return $result;
546✔
929
    }
930

931
    /**
932
     * Get the current orientation.
933
     */
934
    private function getOrientation(): string
935
    {
936
        if ($this->width > $this->height) {
546✔
937
            return self::LANDSCAPE;
492✔
938
        }
939

940
        if ($this->width < $this->height) {
156✔
941
            return self::PORTRAIT;
48✔
942
        }
943

944
        return self::SQUARE;
126✔
945
    }
946

947
    private function replaceImage(\GdImage $newImage): void
948
    {
949
        if (!self::isSameResource($this->image, $newImage)) {
162✔
950
            $this->destroyImage();
156✔
951
            $this->image  = $newImage;
156✔
952
            $this->width  = \imagesx($this->image);
156✔
953
            $this->height = \imagesy($this->image);
156✔
954
        }
955
    }
956

957
    private function renderBinary(?string $format, ?int $quality): array
958
    {
959
        if ($this->image === null) {
18✔
960
            throw new Exception('Image resource not defined');
6✔
961
        }
962

963
        \ob_start();
12✔
964
        $mimeType  = $this->renderImageByFormat($format, '', (int)$quality);
12✔
965
        $imageData = \ob_get_clean();
12✔
966

967
        return [$mimeType, $imageData];
12✔
968
    }
969

970
    private static function isSameResource(?\GdImage $image1 = null, ?\GdImage $image2 = null): bool
971
    {
972
        if ($image1 === null || $image2 === null) {
162✔
973
            return false;
×
974
        }
975

976
        return \spl_object_id($image1) === \spl_object_id($image2);
162✔
977
    }
978
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc