Image::manager()   A
last analyzed

Complexity

Conditions 6
Paths 8

Size

Total Lines 26
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

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