Image   C
last analyzed

Complexity

Total Complexity 57

Size/Duplication

Total Lines 350
Duplicated Lines 0 %

Importance

Changes 5
Bugs 3 Features 0
Metric Value
eloc 130
c 5
b 3
f 0
dl 0
loc 350
rs 5.04
wmc 57

17 Methods

Rating   Name   Duplication   Size   Complexity  
A manager() 0 26 6
A getDominantColor() 0 8 2
A maskable() 0 26 2
A buildHtmlSrcset() 0 3 1
A getHtmlSizes() 0 14 4
B buildHtmlSrcsetW() 0 24 8
A buildHtmlSrcsetX() 0 24 5
A isImage() 0 7 4
A getDataUrl() 0 8 2
B resize() 0 29 8
A convert() 0 19 3
A getBackgroundColor() 0 8 2
A getLqip() 0 8 2
A isIco() 0 3 2
A getSvgAttributes() 0 11 3
A isAnimatedGif() 0 9 1
A isSVG() 0 3 2

How to fix   Complexity   

Complex Class

Complex classes like Image often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Image, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * This file is part of Cecil.
5
 *
6
 * (c) Arnaud Ligny <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
declare(strict_types=1);
13
14
namespace Cecil\Asset;
15
16
use Cecil\Asset;
17
use Cecil\Exception\RuntimeException;
18
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
19
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
20
use Intervention\Image\Encoders\AutoEncoder;
21
use Intervention\Image\ImageManager;
22
23
/**
24
 * Image Asset class.
25
 *
26
 * Provides methods to manipulate images, such as resizing, cropping, converting,
27
 * and generating data URLs.
28
 *
29
 * This class uses the Intervention Image library to handle image processing.
30
 * It supports both GD and Imagick drivers, depending on the available PHP extensions.
31
 */
32
class Image
33
{
34
    /**
35
     * Create new manager instance with available driver.
36
     */
37
    private static function manager(): ImageManager
38
    {
39
        $driver = null;
40
41
        // ImageMagick is available? (for a future quality option)
42
        if (\extension_loaded('imagick') && class_exists('Imagick')) {
43
            $driver = ImagickDriver::class;
44
        }
45
        // Use GD, because it's the faster driver
46
        if (\extension_loaded('gd') && \function_exists('gd_info')) {
47
            $driver = GdDriver::class;
48
        }
49
50
        if ($driver) {
51
            return ImageManager::withDriver(
52
                $driver,
53
                [
54
                    'autoOrientation' => true,
55
                    'decodeAnimation' => true,
56
                    'blendingColor' => 'ffffff',
57
                    'strip' => true, // remove metadata
58
                ]
59
            );
60
        }
61
62
        throw new RuntimeException('PHP GD (or Imagick) extension is required.');
63
    }
64
65
    /**
66
     * Resizes an image Asset to the given width or/and height.
67
     *
68
     * @throws RuntimeException
69
     */
70
    public static function resize(Asset $asset, ?int $width, ?int $height, int $quality, ?bool $rmAnimation): string
71
    {
72
        try {
73
            $image = self::manager()->read($asset['content']);
74
75
            if ($rmAnimation && $image->isAnimated()) {
76
                $image = $image->removeAnimation('25%'); // use 25% to avoid an "empty" frame
77
            }
78
79
            if ($width && $height) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $height of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
Bug Best Practice introduced by
The expression $width of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
80
                $image->cover(width: $width, height: $height, position: 'center');
81
            } elseif ($width) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $width of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
82
                $image->scale(width: $width);
83
            } elseif ($height) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $height of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
84
                $image->scale(height: $height);
85
            } else {
86
                throw new RuntimeException('Width or height must be specified.');
87
            }
88
89
            return (string) $image->encodeByMediaType(
90
                $asset['subtype'],
91
                /** @scrutinizer ignore-type */
92
                progressive: true,
93
                /** @scrutinizer ignore-type */
94
                interlaced: false,
95
                quality: $quality
96
            );
97
        } catch (\Exception $e) {
98
            throw new RuntimeException(\sprintf('Asset "%s" can\'t be resized: %s.', $asset['path'], $e->getMessage()));
99
        }
100
    }
101
102
    /**
103
     * Makes an image Asset maskable, meaning it can be used as a PWA icon.
104
     *
105
     * @throws RuntimeException
106
     */
107
    public static function maskable(Asset $asset, int $quality, int $padding): string
108
    {
109
        try {
110
            $source = self::manager()->read($asset['content']);
111
112
            // creates a new image with the dominant color as background
113
            // and the size of the original image plus the padding
114
            $image = self::manager()->create(
115
                width: (int) round($asset['width'] * (1 + $padding / 100), 0),
116
                height: (int) round($asset['height'] * (1 + $padding / 100), 0)
117
            )->fill(self::getBackgroundColor($asset));
118
            // inserts the original image in the center
119
            $image->place($source, position: 'center');
120
121
            $image->scaleDown(width: $asset['width']);
122
123
            return (string) $image->encodeByMediaType(
124
                $asset['subtype'],
125
                /** @scrutinizer ignore-type */
126
                progressive: true,
127
                /** @scrutinizer ignore-type */
128
                interlaced: false,
129
                quality: $quality
130
            );
131
        } catch (\Exception $e) {
132
            throw new RuntimeException(\sprintf('Unable to make Asset "%s" maskable: %s.', $asset['path'], $e->getMessage()));
133
        }
134
    }
135
136
    /**
137
     * Converts an image Asset to the target format.
138
     *
139
     * @throws RuntimeException
140
     */
141
    public static function convert(Asset $asset, string $format, int $quality): string
142
    {
143
        try {
144
            if (!\function_exists("image$format")) {
145
                throw new RuntimeException(\sprintf('Function "image%s" is not available.', $format));
146
            }
147
148
            $image = self::manager()->read($asset['content']);
149
150
            return (string) $image->encodeByExtension(
151
                $format,
152
                /** @scrutinizer ignore-type */
153
                progressive: true,
154
                /** @scrutinizer ignore-type */
155
                interlaced: false,
156
                quality: $quality
157
            );
158
        } catch (\Exception $e) {
159
            throw new RuntimeException(\sprintf('Unable to convert "%s" to %s: %s.', $asset['path'], $format, $e->getMessage()));
160
        }
161
    }
162
163
    /**
164
     * Returns the Data URL (encoded in Base64).
165
     *
166
     * @throws RuntimeException
167
     */
168
    public static function getDataUrl(Asset $asset, int $quality): string
169
    {
170
        try {
171
            $image = self::manager()->read($asset['content']);
172
173
            return (string) $image->encode(new AutoEncoder(quality: $quality))->toDataUri();
174
        } catch (\Exception $e) {
175
            throw new RuntimeException(\sprintf('Unable to get Data URL of "%s": %s.', $asset['path'], $e->getMessage()));
176
        }
177
    }
178
179
    /**
180
     * Returns the dominant RGB color of an image asset.
181
     *
182
     * @throws RuntimeException
183
     */
184
    public static function getDominantColor(Asset $asset): string
185
    {
186
        try {
187
            $image = self::manager()->read(self::resize($asset, 100, 50));
188
189
            return $image->reduceColors(1)->pickColor(0, 0)->toString();
190
        } catch (\Exception $e) {
191
            throw new RuntimeException(\sprintf('Unable to get dominant color of "%s": %s.', $asset['path'], $e->getMessage()));
192
        }
193
    }
194
195
    /**
196
     * Returns the background RGB color of an image asset.
197
     *
198
     * @throws RuntimeException
199
     */
200
    public static function getBackgroundColor(Asset $asset): string
201
    {
202
        try {
203
            $image = self::manager()->read(self::resize($asset, 100, 50));
204
205
            return $image->pickColor(0, 0)->toString();
206
        } catch (\Exception $e) {
207
            throw new RuntimeException(\sprintf('Unable to get background color of "%s": %s.', $asset['path'], $e->getMessage()));
208
        }
209
    }
210
211
    /**
212
     * Returns a Low Quality Image Placeholder (LQIP) as data URL.
213
     *
214
     * @throws RuntimeException
215
     */
216
    public static function getLqip(Asset $asset): string
217
    {
218
        try {
219
            $image = self::manager()->read(self::resize($asset, 100, 50));
220
221
            return (string) $image->blur(50)->encode()->toDataUri();
222
        } catch (\Exception $e) {
223
            throw new RuntimeException(\sprintf('Unable to create LQIP of "%s": %s.', $asset['path'], $e->getMessage()));
224
        }
225
    }
226
227
    /**
228
     * Build the `srcset` HTML attribute for responsive images, based on widths.
229
     * e.g.: `srcset="/img-480.jpg 480w, /img-800.jpg 800w"`.
230
     *
231
     * @param array $widths   An array of widths to include in the `srcset`
232
     * @param bool  $notEmpty If true the source image is always added to the `srcset`
233
     *
234
     * @throws RuntimeException
235
     */
236
    public static function buildHtmlSrcsetW(Asset $asset, array $widths, $notEmpty = false): string
237
    {
238
        if (!self::isImage($asset)) {
239
            throw new RuntimeException(\sprintf('Unable to build "srcset" of "%s": it\'s not an image file.', $asset['path']));
240
        }
241
242
        $srcset = [];
243
        $widthMax = 0;
244
        sort($widths, SORT_NUMERIC);
245
        $widths = array_reverse($widths);
246
        foreach ($widths as $width) {
247
            if ($asset['width'] < $width) {
248
                continue;
249
            }
250
            $img = $asset->resize($width);
251
            array_unshift($srcset, \sprintf('%s %sw', (string) $img, $width));
252
            $widthMax = $width;
253
        }
254
        // adds source image
255
        if ((!empty($srcset) || $notEmpty) && ($asset['width'] < max($widths) && $asset['width'] != $widthMax)) {
256
            $srcset[] = \sprintf('%s %sw', (string) $asset, $asset['width']);
257
        }
258
259
        return implode(', ', $srcset);
260
    }
261
262
    /**
263
     * Alias of buildHtmlSrcsetW for backward compatibility.
264
     */
265
    public static function buildHtmlSrcset(Asset $asset, array $widths, $notEmpty = false): string
266
    {
267
        return self::buildHtmlSrcsetW($asset, $widths, $notEmpty);
268
    }
269
270
    /**
271
     * Build the `srcset` HTML attribute for responsive images, based on pixel ratios.
272
     * e.g.: `srcset="/img-1x.jpg 1.0x, /img-2x.jpg 2.0x"`.
273
     *
274
     * @param int   $width1x  The width of the 1x image
275
     * @param array $ratios   An array of pixel ratios to include in the `srcset`
276
     *
277
     * @throws RuntimeException
278
     */
279
    public static function buildHtmlSrcsetX(Asset $asset, int $width1x, array $ratios): string
280
    {
281
        if (!self::isImage($asset)) {
282
            throw new RuntimeException(\sprintf('Unable to build "srcset" of "%s": it\'s not an image file.', $asset['path']));
283
        }
284
285
        $srcset = [];
286
        sort($ratios, SORT_NUMERIC);
287
        $ratios = array_reverse($ratios);
288
        foreach ($ratios as $ratio) {
289
            if ($ratio <= 1) {
290
                continue;
291
            }
292
            $width = (int) round($width1x * $ratio, 0);
293
            if ($asset['width'] < $width) {
294
                continue;
295
            }
296
            $img = $asset->resize($width);
297
            array_unshift($srcset, \sprintf('%s %dx', (string) $img, $ratio));
298
        }
299
        // adds 1x image
300
        array_unshift($srcset, \sprintf('%s 1x', (string) $asset->resize($width1x)));
301
302
        return implode(', ', $srcset);
303
    }
304
305
    /**
306
     * Returns the value from the `$sizes` array if the class exists, otherwise returns the default size.
307
     */
308
    public static function getHtmlSizes(string $class, array $sizes = []): string
309
    {
310
        $result = '';
311
        $classArray = explode(' ', $class);
312
        foreach ($classArray as $class) {
313
            if (\array_key_exists($class, $sizes)) {
314
                $result = $sizes[$class] . ', ';
315
            }
316
        }
317
        if (!empty($result)) {
318
            return trim($result, ', ');
319
        }
320
321
        return $sizes['default'] ?? '100vw';
322
    }
323
324
    /**
325
     * Checks if an asset is an animated GIF.
326
     */
327
    public static function isAnimatedGif(Asset $asset): bool
328
    {
329
        // an animated GIF contains multiple "frames", with each frame having a header made up of:
330
        // 1. a static 4-byte sequence (\x00\x21\xF9\x04)
331
        // 2. 4 variable bytes
332
        // 3. a static 2-byte sequence (\x00\x2C)
333
        $count = preg_match_all('#\x00\x21\xF9\x04.{4}\x00[\x2C\x21]#s', (string) $asset['content']);
334
335
        return $count > 1;
336
    }
337
338
    /**
339
     * Returns true if asset is a SVG.
340
     */
341
    public static function isSVG(Asset $asset): bool
342
    {
343
        return \in_array($asset['subtype'], ['image/svg', 'image/svg+xml']) || $asset['ext'] == 'svg';
344
    }
345
346
    /**
347
     * Returns true if asset is an ICO.
348
     */
349
    public static function isIco(Asset $asset): bool
350
    {
351
        return \in_array($asset['subtype'], ['image/x-icon', 'image/vnd.microsoft.icon']) || $asset['ext'] == 'ico';
352
    }
353
354
    /**
355
     * Asset is a valid image?
356
     */
357
    public static function isImage(Asset $asset): bool
358
    {
359
        if ($asset['type'] !== 'image' || self::isSVG($asset) || self::isIco($asset)) {
360
            return false;
361
        }
362
363
        return true;
364
    }
365
366
    /**
367
     * Returns SVG attributes.
368
     *
369
     * @return \SimpleXMLElement|false
370
     */
371
    public static function getSvgAttributes(Asset $asset)
372
    {
373
        if (!self::isSVG($asset)) {
374
            return false;
375
        }
376
377
        if (false === $xml = simplexml_load_string($asset['content'] ?? '')) {
378
            return false;
379
        }
380
381
        return $xml->attributes();
382
    }
383
}
384