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

codeigniter4 / CodeIgniter4 / 14588496992

22 Apr 2025 06:46AM UTC coverage: 84.37% (-0.03%) from 84.395%
14588496992

push

github

web-flow
feat: rewrite `ImageMagickHandler` to rely solely on the PHP `imagick` extension (#9526)

* feat: rewrite ImageMagickHandler to rely solely on the PHP imagick extension

* apply the code suggestions from the review

Co-authored-by: John Paul E. Balandan, CPA <paulbalandan@gmail.com>

---------

Co-authored-by: John Paul E. Balandan, CPA <paulbalandan@gmail.com>

152 of 171 new or added lines in 2 files covered. (88.89%)

7 existing lines in 3 files now uncovered.

20842 of 24703 relevant lines covered (84.37%)

190.79 hits per line

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

88.5
/system/Images/Handlers/ImageMagickHandler.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\Images\Handlers;
15

16
use CodeIgniter\Images\Exceptions\ImageException;
17
use Config\Images;
18
use Imagick;
19
use ImagickDraw;
20
use ImagickDrawException;
21
use ImagickException;
22
use ImagickPixel;
23
use ImagickPixelException;
24

25
/**
26
 * Image handler for Imagick extension.
27
 */
28
class ImageMagickHandler extends BaseHandler
29
{
30
    /**
31
     * Stores Imagick instance.
32
     *
33
     * @var Imagick|null
34
     */
35
    protected $resource;
36

37
    /**
38
     * Constructor.
39
     *
40
     * @param Images $config
41
     *
42
     * @throws ImageException
43
     */
44
    public function __construct($config = null)
45
    {
46
        parent::__construct($config);
35✔
47

48
        if (! extension_loaded('imagick')) {
35✔
NEW
49
            throw ImageException::forMissingExtension('IMAGICK');  // @codeCoverageIgnore
×
50
        }
51
    }
52

53
    /**
54
     * Loads the image for manipulation.
55
     *
56
     * @return void
57
     *
58
     * @throws ImageException
59
     */
60
    protected function ensureResource()
61
    {
62
        if (! $this->resource instanceof Imagick) {
28✔
63
            // Verify that we have a valid image
64
            $this->image();
28✔
65

66
            try {
67
                $this->resource = new Imagick();
28✔
68
                $this->resource->readImage($this->image()->getPathname());
28✔
69

70
                // Check for valid image
71
                if ($this->resource->getImageWidth() === 0 || $this->resource->getImageHeight() === 0) {
28✔
NEW
72
                    throw ImageException::forInvalidImageCreate($this->image()->getPathname());
×
73
                }
74

75
                $this->supportedFormatCheck();
28✔
NEW
76
            } catch (ImagickException $e) {
×
NEW
77
                throw ImageException::forInvalidImageCreate($e->getMessage());
×
78
            }
79
        }
80
    }
81

82
    /**
83
     * Handles all the grunt work of resizing, etc.
84
     *
85
     * @param string $action  Type of action to perform
86
     * @param int    $quality Quality setting for Imagick operations
87
     *
88
     * @return $this
89
     *
90
     * @throws ImageException
91
     */
92
    protected function process(string $action, int $quality = 100)
93
    {
94
        $this->image();
11✔
95

96
        $this->ensureResource();
11✔
97

98
        try {
99
            switch ($action) {
100
                case 'resize':
11✔
101
                    $this->resource->resizeImage(
5✔
102
                        $this->width,
5✔
103
                        $this->height,
5✔
104
                        Imagick::FILTER_LANCZOS,
5✔
105
                        0,
5✔
106
                    );
5✔
107
                    break;
5✔
108

109
                case 'crop':
10✔
110
                    $width  = $this->width;
10✔
111
                    $height = $this->height;
10✔
112
                    $xAxis  = $this->xAxis ?? 0;
10✔
113
                    $yAxis  = $this->yAxis ?? 0;
10✔
114

115
                    $this->resource->cropImage(
10✔
116
                        $width,
10✔
117
                        $height,
10✔
118
                        $xAxis,
10✔
119
                        $yAxis,
10✔
120
                    );
10✔
121

122
                    // Reset canvas to cropped size
123
                    $this->resource->setImagePage(0, 0, 0, 0);
10✔
124
                    break;
10✔
125
            }
126

127
            // Handle transparency for supported image types
128
            if (in_array($this->image()->imageType, $this->supportTransparency, true)
11✔
129
                && $this->resource->getImageAlphaChannel() === Imagick::ALPHACHANNEL_UNDEFINED) {
11✔
130
                $this->resource->setImageAlphaChannel(Imagick::ALPHACHANNEL_OPAQUE);
11✔
131
            }
NEW
132
        } catch (ImagickException) {
×
NEW
133
            throw ImageException::forImageProcessFailed();
×
134
        }
135

136
        return $this;
11✔
137
    }
138

139
    /**
140
     * Handles the actual resizing of the image.
141
     *
142
     * @return ImageMagickHandler
143
     *
144
     * @throws ImagickException
145
     */
146
    public function _resize(bool $maintainRatio = false)
147
    {
148
        if ($maintainRatio) {
8✔
149
            // If maintaining a ratio, we need a custom approach
150
            $this->ensureResource();
3✔
151

152
            // Use thumbnailImage which preserves an aspect ratio
153
            $this->resource->thumbnailImage($this->width, $this->height, true);
3✔
154

155
            return $this;
3✔
156
        }
157

158
        // Use the common process() method for normal resizing
159
        return $this->process('resize');
5✔
160
    }
161

162
    /**
163
     * Crops the image.
164
     *
165
     * @return $this
166
     *
167
     * @throws ImagickException
168
     */
169
    public function _crop()
170
    {
171
        // Use the common process() method for cropping
172
        $result = $this->process('crop');
10✔
173

174
        // Handle a case where crop dimensions exceed the original image size
175
        if ($this->resource instanceof Imagick) {
10✔
176
            $imgWidth  = $this->resource->getImageWidth();
10✔
177
            $imgHeight = $this->resource->getImageHeight();
10✔
178

179
            if ($this->xAxis >= $imgWidth || $this->yAxis >= $imgHeight) {
10✔
180
                // Create transparent background
181
                $background = new Imagick();
2✔
182
                $background->newImage($this->width, $this->height, new ImagickPixel('transparent'));
2✔
183
                $background->setImageFormat($this->resource->getImageFormat());
2✔
184

185
                // Composite our image on the background
186
                $background->compositeImage($this->resource, Imagick::COMPOSITE_OVER, 0, 0);
2✔
187

188
                // Replace our resource
189
                $this->resource = $background;
2✔
190
            }
191
        }
192

193
        return $result;
10✔
194
    }
195

196
    /**
197
     * Handles the rotation of an image resource.
198
     * Doesn't save the image, but replaces the current resource.
199
     *
200
     * @return $this
201
     *
202
     * @throws ImagickException
203
     */
204
    protected function _rotate(int $angle)
205
    {
206
        $this->ensureResource();
3✔
207

208
        // Create transparent background
209
        $this->resource->setImageBackgroundColor(new ImagickPixel('transparent'));
3✔
210
        $this->resource->rotateImage(new ImagickPixel('transparent'), $angle);
3✔
211

212
        // Reset canvas dimensions
213
        $this->resource->setImagePage($this->resource->getImageWidth(), $this->resource->getImageHeight(), 0, 0);
3✔
214

215
        return $this;
3✔
216
    }
217

218
    /**
219
     * Flattens transparencies, default white background
220
     *
221
     * @return $this
222
     *
223
     * @throws ImagickException|ImagickPixelException
224
     */
225
    protected function _flatten(int $red = 255, int $green = 255, int $blue = 255)
226
    {
227
        $this->ensureResource();
1✔
228

229
        // Create background
230
        $bg = new ImagickPixel("rgb({$red},{$green},{$blue})");
1✔
231

232
        // Create a new canvas with the background color
233
        $canvas = new Imagick();
1✔
234
        $canvas->newImage(
1✔
235
            $this->resource->getImageWidth(),
1✔
236
            $this->resource->getImageHeight(),
1✔
237
            $bg,
1✔
238
            $this->resource->getImageFormat(),
1✔
239
        );
1✔
240

241
        // Composite our image on the background
242
        $canvas->compositeImage(
1✔
243
            $this->resource,
1✔
244
            Imagick::COMPOSITE_OVER,
1✔
245
            0,
1✔
246
            0,
1✔
247
        );
1✔
248

249
        // Replace our resource with the flattened version
250
        $this->resource->clear();
1✔
251
        $this->resource = $canvas;
1✔
252

253
        return $this;
1✔
254
    }
255

256
    /**
257
     * Flips an image along its vertical or horizontal axis.
258
     *
259
     * @return $this
260
     *
261
     * @throws ImagickException
262
     */
263
    protected function _flip(string $direction)
264
    {
265
        $this->ensureResource();
5✔
266

267
        if ($direction === 'horizontal') {
5✔
268
            $this->resource->flopImage();
3✔
269
        } else {
270
            $this->resource->flipImage();
2✔
271
        }
272

273
        return $this;
5✔
274
    }
275

276
    /**
277
     * Get a driver version
278
     *
279
     * @return string
280
     */
281
    public function getVersion()
282
    {
283
        $version = Imagick::getVersion();
1✔
284

285
        if (preg_match('/ImageMagick\s+(\d+\.\d+\.\d+)/', $version['versionString'], $matches)) {
1✔
286
            return $matches[1];
1✔
287
        }
288

NEW
289
        return '';
×
290
    }
291

292
    /**
293
     * Check if a given image format is supported
294
     *
295
     * @return void
296
     *
297
     * @throws ImageException
298
     */
299
    protected function supportedFormatCheck()
300
    {
301
        if (! $this->resource instanceof Imagick) {
28✔
NEW
302
            return;
×
303
        }
304

305
        switch ($this->image()->imageType) {
28✔
306
            case IMAGETYPE_WEBP:
28✔
307
                if (! in_array('WEBP', Imagick::queryFormats(), true)) {
4✔
NEW
308
                    throw ImageException::forInvalidImageCreate(lang('images.webpNotSupported'));
×
309
                }
310
                break;
4✔
311
        }
312
    }
313

314
    /**
315
     * Saves any changes that have been made to the file. If no new filename is
316
     * provided, the existing image is overwritten; otherwise a copy of the
317
     * file is made at $target.
318
     *
319
     * Example:
320
     *    $image->resize(100, 200, true)
321
     *          ->save();
322
     *
323
     * @param non-empty-string|null $target
324
     *
325
     * @throws ImagickException
326
     */
327
    public function save(?string $target = null, int $quality = 90): bool
328
    {
329
        $original = $target;
7✔
330
        $target   = ($target === null || $target === '') ? $this->image()->getPathname() : $target;
7✔
331

332
        // If no new resource has been created, then we're
333
        // simply copy the existing one.
334
        if (! $this->resource instanceof Imagick && $quality === 100) {
7✔
335
            if ($original === null) {
1✔
336
                return true;
1✔
337
            }
338

339
            $name = basename($target);
×
340
            $path = pathinfo($target, PATHINFO_DIRNAME);
×
341

342
            return $this->image()->copy($path, $name);
×
343
        }
344

345
        $this->ensureResource();
6✔
346

347
        $this->resource->setImageCompressionQuality($quality);
6✔
348

349
        if ($target !== null) {
6✔
350
            $extension = pathinfo($target, PATHINFO_EXTENSION);
6✔
351
            $this->resource->setImageFormat($extension);
6✔
352
        }
353

354
        try {
355
            $result = $this->resource->writeImage($target);
6✔
356

357
            chmod($target, $this->filePermissions);
6✔
358

359
            $this->resource->clear();
6✔
360
            $this->resource = null;
6✔
361

362
            return $result;
6✔
NEW
363
        } catch (ImagickException) {
×
NEW
364
            throw ImageException::forSaveFailed();
×
365
        }
366
    }
367

368
    /**
369
     * Handler-specific method for overlaying text on an image.
370
     *
371
     * @throws ImagickDrawException|ImagickException|ImagickPixelException
372
     */
373
    protected function _text(string $text, array $options = [])
374
    {
375
        $this->ensureResource();
3✔
376

377
        $draw = new ImagickDraw();
3✔
378

379
        if (isset($options['fontPath'])) {
3✔
NEW
380
            $draw->setFont($options['fontPath']);
×
381
        }
382

383
        if (isset($options['fontSize'])) {
3✔
384
            $draw->setFontSize($options['fontSize']);
3✔
385
        }
386

387
        if (isset($options['color'])) {
3✔
388
            $color = $options['color'];
3✔
389

390
            // Shorthand hex, #f00
391
            if (strlen($color) === 3) {
3✔
NEW
392
                $color = implode('', array_map(str_repeat(...), str_split($color), [2, 2, 2]));
×
393
            }
394

395
            [$r, $g, $b] = sscanf("#{$color}", '#%02x%02x%02x');
3✔
396
            $opacity     = $options['opacity'] ?? 1.0;
3✔
397
            $draw->setFillColor(new ImagickPixel("rgba({$r},{$g},{$b},{$opacity})"));
3✔
398
        }
399

400
        // Calculate text positioning
401
        $imgWidth  = $this->resource->getImageWidth();
3✔
402
        $imgHeight = $this->resource->getImageHeight();
3✔
403
        $xAxis     = 0;
3✔
404
        $yAxis     = 0;
3✔
405

406
        // Default padding
407
        $padding = $options['padding'] ?? 0;
3✔
408

409
        if (isset($options['hAlign'])) {
3✔
410
            $hOffset = $options['hOffset'] ?? 0;
3✔
411

412
            switch ($options['hAlign']) {
3✔
413
                case 'left':
3✔
NEW
414
                    $xAxis = $hOffset + $padding;
×
NEW
415
                    $draw->setTextAlignment(Imagick::ALIGN_LEFT);
×
UNCOV
416
                    break;
×
417

418
                case 'center':
3✔
419
                    $xAxis = $imgWidth / 2 + $hOffset;
2✔
420
                    $draw->setTextAlignment(Imagick::ALIGN_CENTER);
2✔
421
                    break;
2✔
422

423
                case 'right':
1✔
424
                    $xAxis = $imgWidth - $hOffset - $padding;
1✔
425
                    $draw->setTextAlignment(Imagick::ALIGN_RIGHT);
1✔
426
                    break;
1✔
427
            }
428
        }
429

430
        if (isset($options['vAlign'])) {
3✔
431
            $vOffset = $options['vOffset'] ?? 0;
3✔
432

433
            switch ($options['vAlign']) {
3✔
434
                case 'top':
3✔
NEW
435
                    $yAxis = $vOffset + $padding + ($options['fontSize'] ?? 16);
×
NEW
436
                    break;
×
437

438
                case 'middle':
3✔
439
                    $yAxis = $imgHeight / 2 + $vOffset;
1✔
440
                    break;
1✔
441

442
                case 'bottom':
2✔
443
                    // Note: Vertical offset is inverted for bottom alignment as per original implementation
444
                    $yAxis = $vOffset < 0 ? $imgHeight + $vOffset - $padding : $imgHeight - $vOffset - $padding;
2✔
445
                    break;
2✔
446
            }
447
        }
448

449
        if (isset($options['withShadow'])) {
3✔
450
            $shadow = clone $draw;
3✔
451

452
            if (isset($options['shadowColor'])) {
3✔
453
                $shadowColor = $options['shadowColor'];
3✔
454

455
                // Shorthand hex, #f00
456
                if (strlen($shadowColor) === 3) {
3✔
NEW
457
                    $shadowColor = implode('', array_map(str_repeat(...), str_split($shadowColor), [2, 2, 2]));
×
458
                }
459

460
                [$sr, $sg, $sb] = sscanf("#{$shadowColor}", '#%02x%02x%02x');
3✔
461
                $shadow->setFillColor(new ImagickPixel("rgb({$sr},{$sg},{$sb})"));
3✔
462
            } else {
NEW
463
                $shadow->setFillColor(new ImagickPixel('rgba(0,0,0,0.5)'));
×
464
            }
465

466
            $offset = $options['shadowOffset'] ?? 3;
3✔
467

468
            $this->resource->annotateImage(
3✔
469
                $shadow,
3✔
470
                $xAxis + $offset,
3✔
471
                $yAxis + $offset,
3✔
472
                0,
3✔
473
                $text,
3✔
474
            );
3✔
475
        }
476

477
        // Draw the main text
478
        $this->resource->annotateImage(
3✔
479
            $draw,
3✔
480
            $xAxis,
3✔
481
            $yAxis,
3✔
482
            0,
3✔
483
            $text,
3✔
484
        );
3✔
485
    }
486

487
    /**
488
     * Return the width of an image.
489
     *
490
     * @return int
491
     *
492
     * @throws ImagickException
493
     */
494
    public function _getWidth()
495
    {
496
        $this->ensureResource();
22✔
497

498
        return $this->resource->getImageWidth();
22✔
499
    }
500

501
    /**
502
     * Return the height of an image.
503
     *
504
     * @return int
505
     *
506
     * @throws ImagickException
507
     */
508
    public function _getHeight()
509
    {
510
        $this->ensureResource();
21✔
511

512
        return $this->resource->getImageHeight();
21✔
513
    }
514

515
    /**
516
     * Reads the EXIF information from the image and modifies the orientation
517
     * so that displays correctly in the browser. This is especially an issue
518
     * with images taken by smartphones who always store the image up-right,
519
     * but set the orientation flag to display it correctly.
520
     *
521
     * @param bool $silent If true, will ignore exceptions when PHP doesn't support EXIF.
522
     *
523
     * @return $this
524
     */
525
    public function reorient(bool $silent = false)
526
    {
527
        $orientation = $this->getEXIF('Orientation', $silent);
2✔
528

529
        return match ($orientation) {
2✔
530
            2       => $this->flip('horizontal'),
2✔
531
            3       => $this->rotate(180),
2✔
532
            4       => $this->rotate(180)->flip('horizontal'),
2✔
533
            5       => $this->rotate(90)->flip('horizontal'),
2✔
534
            6       => $this->rotate(90),
2✔
535
            7       => $this->rotate(270)->flip('horizontal'),
2✔
536
            8       => $this->rotate(270),
2✔
537
            default => $this,
2✔
538
        };
2✔
539
    }
540
}
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