Passed
Push — master ( 60346a...c0091d )
by
unknown
05:09
created

Image   B

Complexity

Total Complexity 48

Size/Duplication

Total Lines 333
Duplicated Lines 0 %

Test Coverage

Coverage 67.39%

Importance

Changes 1
Bugs 1 Features 0
Metric Value
eloc 122
c 1
b 1
f 0
dl 0
loc 333
ccs 93
cts 138
cp 0.6739
rs 8.5599
wmc 48

16 Methods

Rating   Name   Duplication   Size   Complexity  
A getDominantColor() 0 8 2
A maskable() 0 29 2
A getDataUrl() 0 8 2
A resize() 0 18 2
A convert() 0 19 3
A cover() 0 26 3
A manager() 0 26 6
B buildHtmlSrcset() 0 24 8
A getHtmlSizes() 0 14 4
A isImage() 0 7 4
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 1
    private static function manager(): ImageManager
38
    {
39 1
        $driver = null;
40
41
        // ImageMagick is available? (for a future quality option)
42 1
        if (\extension_loaded('imagick') && class_exists('Imagick')) {
43
            $driver = ImagickDriver::class;
44
        }
45
        // Use GD, because it's the faster driver
46 1
        if (\extension_loaded('gd') && \function_exists('gd_info')) {
47 1
            $driver = GdDriver::class;
48
        }
49
50 1
        if ($driver) {
51 1
            return ImageManager::withDriver(
52 1
                $driver,
53 1
                [
54 1
                    'autoOrientation' => true,
55 1
                    'decodeAnimation' => true,
56 1
                    'blendingColor' => 'ffffff',
57 1
                    'strip' => true, // remove metadata
58 1
                ]
59 1
            );
60
        }
61
62
        throw new RuntimeException('PHP GD (or Imagick) extension is required.');
63
    }
64
65
    /**
66
     * Scales down an image Asset to the given width, keeping the aspect ratio.
67
     *
68
     * @throws RuntimeException
69
     */
70 1
    public static function resize(Asset $asset, int $width, int $quality): string
71
    {
72
        try {
73
            // creates image object from source
74 1
            $image = self::manager()->read($asset['content']);
75
            // resizes to $width with constraint the aspect-ratio and unwanted upsizing
76 1
            $image->scaleDown(width: $width);
77
            // return image data
78 1
            return (string) $image->encodeByMediaType(
79 1
                $asset['subtype'],
80
                /** @scrutinizer ignore-type */
81 1
                progressive: true,
82
                /** @scrutinizer ignore-type */
83 1
                interlaced: false,
84 1
                quality: $quality
85 1
            );
86
        } catch (\Exception $e) {
87
            throw new RuntimeException(\sprintf('Asset "%s" can\'t be resized: %s', $asset['path'], $e->getMessage()));
88
        }
89
    }
90
91
    /**
92
     * Crops an image Asset to the given width and height, keeping the aspect ratio.
93
     *
94
     * @throws RuntimeException
95
     */
96 1
    public static function cover(Asset $asset, int $width, int $height, int $quality): string
97
    {
98
        try {
99
            // creates image object from source
100 1
            $image = self::manager()->read($asset['content']);
101
            // turns an animated image (i.e GIF) into a static image
102 1
            if ($image->isAnimated()) {
103
                $image = $image->removeAnimation('25%'); // use 25% to avoid an "empty" frame
104
            }
105
            // crops the image
106 1
            $image->cover(
107 1
                width: $width,
108 1
                height: $height,
109 1
                position: 'center'
110 1
            );
111
            // return image data
112 1
            return (string) $image->encodeByMediaType(
113 1
                $asset['subtype'],
114
                /** @scrutinizer ignore-type */
115 1
                progressive: true,
116
                /** @scrutinizer ignore-type */
117 1
                interlaced: false,
118 1
                quality: $quality
119 1
            );
120
        } catch (\Exception $e) {
121
            throw new RuntimeException(\sprintf('Asset "%s" can\'t be cropped: %s', $asset['path'], $e->getMessage()));
122
        }
123
    }
124
125
    /**
126
     * Makes an image Asset maskable, meaning it can be used as a PWA icon.
127
     *
128
     * @throws RuntimeException
129
     */
130
    public static function maskable(Asset $asset, int $quality, int $padding): string
131
    {
132
        try {
133
            // creates image object from source
134
            $source = self::manager()->read($asset['content']);
135
            // creates a new image with the dominant color as background
136
            // and the size of the original image plus the padding
137
            $image = self::manager()->create(
138
                width: (int) round($asset['width'] * (1 + $padding / 100), 0),
139
                height: (int) round($asset['height'] * (1 + $padding / 100), 0)
140
            )->fill(self::getBackgroundColor($asset));
141
            // inserts the original image in the center
142
            $image->place(
143
                $source,
144
                position: 'center'
145
            );
146
            // scales down the new image to the original image size
147
            $image->scaleDown(width: $asset['width']);
148
            // return image data
149
            return (string) $image->encodeByMediaType(
150
                $asset['subtype'],
151
                /** @scrutinizer ignore-type */
152
                progressive: true,
153
                /** @scrutinizer ignore-type */
154
                interlaced: false,
155
                quality: $quality
156
            );
157
        } catch (\Exception $e) {
158
            throw new RuntimeException(\sprintf('Unable to make Asset "%s" maskable: %s', $asset['path'], $e->getMessage()));
159
        }
160
    }
161
162
    /**
163
     * Converts an image Asset to the target format.
164
     *
165
     * @throws RuntimeException
166
     */
167 1
    public static function convert(Asset $asset, string $format, int $quality): string
168
    {
169
        try {
170 1
            $image = self::manager()->read($asset['content']);
171
172 1
            if (!\function_exists("image$format")) {
173 1
                throw new RuntimeException(\sprintf('Function "image%s" is not available.', $format));
174
            }
175
176
            return (string) $image->encodeByExtension(
177
                $format,
178
                /** @scrutinizer ignore-type */
179
                progressive: true,
180
                /** @scrutinizer ignore-type */
181
                interlaced: false,
182
                quality: $quality
183
            );
184 1
        } catch (\Exception $e) {
185 1
            throw new RuntimeException(\sprintf('Unable to convert "%s" to %s: %s', $asset['path'], $format, $e->getMessage()));
186
        }
187
    }
188
189
    /**
190
     * Returns the Data URL (encoded in Base64).
191
     *
192
     * @throws RuntimeException
193
     */
194 1
    public static function getDataUrl(Asset $asset, int $quality): string
195
    {
196
        try {
197 1
            $image = self::manager()->read($asset['content']);
198
199 1
            return (string) $image->encode(new AutoEncoder(quality: $quality))->toDataUri();
200
        } catch (\Exception $e) {
201
            throw new RuntimeException(\sprintf('Unable to get Data URL of "%s": %s', $asset['path'], $e->getMessage()));
202
        }
203
    }
204
205
    /**
206
     * Returns the dominant RGB color of an image asset.
207
     *
208
     * @throws RuntimeException
209
     */
210 1
    public static function getDominantColor(Asset $asset): string
211
    {
212
        try {
213 1
            $image = self::manager()->read(self::resize($asset, 100, 50));
214
215 1
            return $image->reduceColors(1)->pickColor(0, 0)->toString();
216
        } catch (\Exception $e) {
217
            throw new RuntimeException(\sprintf('Unable to get dominant color of "%s": %s', $asset['path'], $e->getMessage()));
218
        }
219
    }
220
221
    /**
222
     * Returns the background RGB color of an image asset.
223
     *
224
     * @throws RuntimeException
225
     */
226
    public static function getBackgroundColor(Asset $asset): string
227
    {
228
        try {
229
            $image = self::manager()->read(self::resize($asset, 100, 50));
230
231
            return $image->pickColor(0, 0)->toString();
232
        } catch (\Exception $e) {
233
            throw new RuntimeException(\sprintf('Unable to background color of "%s": %s', $asset['path'], $e->getMessage()));
234
        }
235
    }
236
237
    /**
238
     * Returns a Low Quality Image Placeholder (LQIP) as data URL.
239
     *
240
     * @throws RuntimeException
241
     */
242 1
    public static function getLqip(Asset $asset): string
243
    {
244
        try {
245 1
            $image = self::manager()->read(self::resize($asset, 100, 50));
246
247 1
            return (string) $image->blur(50)->encode()->toDataUri();
248
        } catch (\Exception $e) {
249
            throw new RuntimeException(\sprintf('Unable to create LQIP of "%s": %s', $asset['path'], $e->getMessage()));
250
        }
251
    }
252
253
    /**
254
     * Build the `srcset` HTML attribute for responsive images.
255
     * e.g.: `srcset="/img-480.jpg 480w, /img-800.jpg 800w"`.
256
     *
257
     * $widths is an array of widths to include in the `srcset`.
258
     * If $notEmpty is true, the source image is always added to the `srcset`.
259
     *
260
     * @throws RuntimeException
261
     */
262 1
    public static function buildHtmlSrcset(Asset $asset, array $widths, $notEmpty = false): string
263
    {
264 1
        if (!self::isImage($asset)) {
265 1
            throw new RuntimeException(\sprintf('Unable to build "srcset" of "%s": it\'s not an image file.', $asset['path']));
266
        }
267
268 1
        $srcset = '';
269 1
        $widthMax = 0;
270 1
        sort($widths, SORT_NUMERIC);
271 1
        $widths = array_reverse($widths);
272 1
        foreach ($widths as $width) {
273 1
            if ($asset['width'] < $width) {
274 1
                continue;
275
            }
276 1
            $img = $asset->resize($width);
277 1
            $srcset = \sprintf('%s %sw, ', (string) $img, $width) . $srcset;
278 1
            $widthMax = $width;
279
        }
280
        // adds source image
281 1
        if ((!empty($srcset) || $notEmpty) && ($asset['width'] < max($widths) && $asset['width'] != $widthMax)) {
282 1
            $srcset .= \sprintf('%s %sw', (string) $asset, $asset['width']);
283
        }
284
285 1
        return rtrim($srcset, ', ');
286
    }
287
288
    /**
289
     * Returns the value from the `$sizes` array if the class exists, otherwise returns the default size.
290
     */
291 1
    public static function getHtmlSizes(string $class, array $sizes = []): string
292
    {
293 1
        $result = '';
294 1
        $classArray = explode(' ', $class);
295 1
        foreach ($classArray as $class) {
296 1
            if (\array_key_exists($class, $sizes)) {
297 1
                $result = $sizes[$class] . ', ';
298
            }
299
        }
300 1
        if (!empty($result)) {
301 1
            return trim($result, ', ');
302
        }
303
304 1
        return $sizes['default'] ?? '100vw';
305
    }
306
307
    /**
308
     * Checks if an asset is an animated GIF.
309
     */
310 1
    public static function isAnimatedGif(Asset $asset): bool
311
    {
312
        // an animated GIF contains multiple "frames", with each frame having a header made up of:
313
        // 1. a static 4-byte sequence (\x00\x21\xF9\x04)
314
        // 2. 4 variable bytes
315
        // 3. a static 2-byte sequence (\x00\x2C)
316 1
        $count = preg_match_all('#\x00\x21\xF9\x04.{4}\x00[\x2C\x21]#s', (string) $asset['content']);
317
318 1
        return $count > 1;
319
    }
320
321
    /**
322
     * Returns true if asset is a SVG.
323
     */
324 1
    public static function isSVG(Asset $asset): bool
325
    {
326 1
        return \in_array($asset['subtype'], ['image/svg', 'image/svg+xml']) || $asset['ext'] == 'svg';
327
    }
328
329
    /**
330
     * Returns true if asset is an ICO.
331
     */
332 1
    public static function isIco(Asset $asset): bool
333
    {
334 1
        return \in_array($asset['subtype'], ['image/x-icon', 'image/vnd.microsoft.icon']) || $asset['ext'] == 'ico';
335
    }
336
337
    /**
338
     * Asset is a valid image?
339
     */
340 1
    public static function isImage(Asset $asset): bool
341
    {
342 1
        if ($asset['type'] !== 'image' || self::isSVG($asset) || self::isIco($asset)) {
343 1
            return false;
344
        }
345
346 1
        return true;
347
    }
348
349
    /**
350
     * Returns SVG attributes.
351
     *
352
     * @return \SimpleXMLElement|false
353
     */
354 1
    public static function getSvgAttributes(Asset $asset)
355
    {
356 1
        if (!self::isSVG($asset)) {
357
            return false;
358
        }
359
360 1
        if (false === $xml = simplexml_load_string($asset['content'] ?? '')) {
361
            return false;
362
        }
363
364 1
        return $xml->attributes();
365
    }
366
}
367