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

JBZoo / Image / 5498983095

pending completion
5498983095

push

github

web-flow
PhpCsFixer issues (#29)

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

481 of 540 relevant lines covered (89.07%)

71.83 hits per line

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

85.75
/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();
404✔
63

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

68
        if (
69
            $filename !== ''
404✔
70
            && \is_string($filename)
404✔
71
            && \ctype_print($filename)
404✔
72
            && FS::isFile($filename)
404✔
73
        ) {
74
            $this->loadFile($filename);
152✔
75
        } elseif ($filename instanceof \GdImage) {
296✔
76
            $this->loadResource($filename);
4✔
77
        } elseif (!isStrEmpty($filename)) {
292✔
78
            $this->loadString($filename, $strict);
8✔
79
        }
80
    }
81

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

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

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

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

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

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

131
        return $this;
4✔
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;
12✔
141

142
        if ($this->filename !== null && $this->filename !== '') {
12✔
143
            $this->internalSave($this->filename, $quality);
8✔
144

145
            return $this;
8✔
146
        }
147

148
        throw new Exception('Filename is not defined');
4✔
149
    }
150

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

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

169
        return $this;
308✔
170
    }
171

172
    public function isGif(): bool
173
    {
174
        return Helper::isGif($this->mime);
52✔
175
    }
176

177
    public function isPng(): bool
178
    {
179
        return Helper::isPng($this->mime);
4✔
180
    }
181

182
    public function isWebp(): bool
183
    {
184
        return Helper::isWebp($this->mime);
4✔
185
    }
186

187
    public function isJpeg(): bool
188
    {
189
        return Helper::isJpeg($this->mime);
4✔
190
    }
191

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

203
        $this->cleanup();
364✔
204
        $this->filename = $cleanFilename;
364✔
205
        $this->loadMeta();
364✔
206

207
        return $this;
364✔
208
    }
209

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

220
        $this->cleanup();
8✔
221
        $this->loadMeta($imageString, $strict);
8✔
222

223
        return $this;
4✔
224
    }
225

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

236
        $this->cleanup();
4✔
237
        $this->loadMeta($imageRes);
4✔
238

239
        return $this;
4✔
240
    }
241

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

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

256
        $this->destroyImage();
404✔
257

258
        return $this;
404✔
259
    }
260

261
    public function isPortrait(): bool
262
    {
263
        return $this->orient === self::PORTRAIT;
4✔
264
    }
265

266
    public function isLandscape(): bool
267
    {
268
        return $this->orient === self::LANDSCAPE;
4✔
269
    }
270

271
    public function isSquare(): bool
272
    {
273
        return $this->orient === self::SQUARE;
4✔
274
    }
275

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

287
        $height = (int)$height === 0 ? $width : $height;
12✔
288

289
        $this->width  = VarFilter::int($width);
12✔
290
        $this->height = VarFilter::int($height);
12✔
291

292
        $newImageRes = \imagecreatetruecolor($this->width, $this->height);
12✔
293
        if ($newImageRes !== false) {
12✔
294
            $this->image = $newImageRes;
12✔
295
        } else {
296
            throw new Exception("Can't create empty image resource");
×
297
        }
298

299
        $this->mime = self::DEFAULT_MIME;
12✔
300
        $this->exif = [];
12✔
301

302
        $this->orient = $this->getOrientation();
12✔
303

304
        if ($color !== null) {
12✔
305
            return $this->addFilter('fill', $color);
4✔
306
        }
307

308
        return $this;
8✔
309
    }
310

311
    /**
312
     * Resize an image to the specified dimensions.
313
     * @phan-suppress PhanPossiblyFalseTypeArgumentInternal
314
     */
315
    public function resize(float $width, float $height): self
316
    {
317
        $width  = VarFilter::int($width);
48✔
318
        $height = VarFilter::int($height);
48✔
319

320
        // Generate new GD image
321
        $newImage = \imagecreatetruecolor($width, $height);
48✔
322
        if ($newImage === false) {
48✔
323
            throw new Exception("Can't create new image resource");
×
324
        }
325

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

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

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

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

342
                $colorsTypeCount = 3;
×
343

344
                if (\count($trColor) >= $colorsTypeCount) {
×
345
                    $red   = VarFilter::int($trColor['red']);
×
346
                    $green = VarFilter::int($trColor['green']);
×
347
                    $blue  = VarFilter::int($trColor['blue']);
×
348
                }
349

350
                $transIndex = (int)\imagecolorallocate($newImage, $red, $green, $blue);
×
351

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

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

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

368
        return $this;
48✔
369
    }
370

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

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

385
        $width  = $this->width;
8✔
386
        $height = $this->height;
8✔
387

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

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

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

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

415
        // Determine height
416
        $height = $height === 0 ? $width : $height;
16✔
417

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

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

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

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

436
        if ($topIsZero) {
16✔
437
            $bottom -= $top;
4✔
438
            $top = 0;
4✔
439
        }
440

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

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

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

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

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

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

480
        // Determine crop size
481
        if ($right < $left) {
24✔
482
            [$left, $right] = [$right, $left];
4✔
483
        }
484

485
        if ($bottom < $top) {
24✔
486
            [$top, $bottom] = [$bottom, $top];
4✔
487
        }
488

489
        $croppedW = $right - $left;
24✔
490
        $croppedH = $bottom - $top;
24✔
491

492
        // Perform crop
493
        $newImage = \imagecreatetruecolor($croppedW, $croppedH);
24✔
494
        if ($newImage === false) {
24✔
495
            throw new Exception("Can't crop image, imagecreatetruecolor() failed");
×
496
        }
497

498
        Helper::addAlpha($newImage);
24✔
499

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

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

511
        return $this;
24✔
512
    }
513

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

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

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

543
        return $this;
×
544
    }
545

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

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

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

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

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

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

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

601
        return $this;
48✔
602
    }
603

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

612
        if (\is_string($filter)) {
172✔
613
            if (\method_exists(Filter::class, $filter)) {
168✔
614
                /** @var \Closure $filterFunc */
615
                $filterFunc = [Filter::class, $filter];
164✔
616
                $newImage   = $filterFunc(...$args);
164✔
617
            } else {
618
                throw new Exception("Undefined Image Filter: {$filter}");
164✔
619
            }
620
        } elseif (\is_callable($filter)) {
4✔
621
            $newImage = $filter(...$args);
4✔
622
        } else {
623
            throw new Exception('Undefined filter type');
×
624
        }
625

626
        if ($newImage instanceof \GdImage) {
164✔
627
            $this->replaceImage($newImage);
52✔
628
        }
629

630
        return $this;
164✔
631
    }
632

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

643
        $result = \base64_encode($binaryData);
4✔
644

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

649
        return $result;
4✔
650
    }
651

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

662
        return $result[1];
4✔
663
    }
664

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

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

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

685
        return "{$rootPath}/{$relPath}";
4✔
686
    }
687

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

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

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

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

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

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

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

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

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

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

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

757
        $filename = FS::clean($filename);
316✔
758

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

764
            return true;
316✔
765
        }
766

767
        return false;
×
768
    }
769

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

783
        $format = $format === null || $format === '' ? $this->mime : $format;
324✔
784

785
        $result = null;
324✔
786
        if (Helper::isJpeg($format)) {
324✔
787
            if ($this->saveJpeg($filename, $quality)) {
172✔
788
                $result = 'image/jpeg';
172✔
789
            }
790
        } elseif (Helper::isPng($format)) {
160✔
791
            if ($this->savePng($filename, $quality)) {
136✔
792
                $result = 'image/png';
136✔
793
            }
794
        } elseif (Helper::isGif($format)) {
32✔
795
            if ($this->saveGif($filename)) {
28✔
796
                $result = 'image/gif';
28✔
797
            }
798
        } elseif (Helper::isWebp($format)) {
4✔
799
            if ($this->saveWebP($filename)) {
4✔
800
                $result = 'image/webp';
4✔
801
            }
802
        } else {
803
            throw new Exception("Undefined format: {$format}");
×
804
        }
805

806
        return $result;
324✔
807
    }
808

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

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

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

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

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

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

872
        return $this;
364✔
873
    }
874

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

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

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

900
        return $result;
364✔
901
    }
902

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

912
        if (Helper::isJpeg($format)) {
364✔
913
            $result = \imagecreatefromjpeg($this->filename);
312✔
914
        } elseif (Helper::isPng($format)) {
188✔
915
            $result = \imagecreatefrompng($this->filename);
144✔
916
        } elseif (Helper::isGif($format)) {
44✔
917
            $result = \imagecreatefromgif($this->filename);
40✔
918
        } elseif (\function_exists('imagecreatefromwebp') && Helper::isWebp($format)) {
4✔
919
            $result = \imagecreatefromwebp($this->filename);
4✔
920
        } else {
921
            throw new Exception("Invalid image: {$this->filename}");
×
922
        }
923

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

928
        return $result;
364✔
929
    }
930

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

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

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

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

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

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

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

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

976
        return \spl_object_id($image1) === \spl_object_id($image2);
108✔
977
    }
978
}
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

© 2025 Coveralls, Inc