Image::isIco()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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