Passed
Push — master ( fd5985...79e9ae )
by
unknown
05:57
created

Image::maskable()   A

Complexity

Conditions 2
Paths 6

Size

Total Lines 18
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 2
eloc 11
c 1
b 1
f 0
nc 6
nop 2
dl 0
loc 18
ccs 0
cts 11
cp 0
crap 6
rs 9.9
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\Assets;
15
16
use Cecil\Exception\RuntimeException;
17
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
18
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
19
use Intervention\Image\Encoders\AutoEncoder;
20
use Intervention\Image\ImageManager;
21
22
/**
23
 * Image class.
24
 *
25
 * Provides methods to manipulate images, such as resizing, cropping, converting,
26
 * and generating data URLs.
27
 */
28
class Image
29
{
30
    /**
31
     * Create new manager instance with available driver.
32
     */
33 1
    private static function manager(): ImageManager
34
    {
35 1
        $driver = null;
36
37
        // ImageMagick is available? (for a future quality option)
38 1
        if (\extension_loaded('imagick') && class_exists('Imagick')) {
39
            $driver = ImagickDriver::class;
40
        }
41
        // Use GD, because it's the faster driver
42 1
        if (\extension_loaded('gd') && \function_exists('gd_info')) {
43 1
            $driver = GdDriver::class;
44
        }
45
46 1
        if ($driver) {
47 1
            return ImageManager::withDriver(
48 1
                $driver,
49 1
                [
50 1
                    'autoOrientation' => true,
51 1
                    'decodeAnimation' => true,
52 1
                    'blendingColor' => 'ffffff',
53 1
                    'strip' => true, // remove metadata
54 1
                ]
55 1
            );
56
        }
57
58
        throw new RuntimeException('PHP GD (or Imagick) extension is required.');
59
    }
60
61
    /**
62
     * Scales down an image Asset to the given width, keeping the aspect ratio.
63
     *
64
     * @throws RuntimeException
65
     */
66 1
    public static function resize(Asset $asset, int $width, int $quality): string
67
    {
68
        try {
69
            // creates image object from source
70 1
            $image = self::manager()->read($asset['content']);
71
            // resizes to $width with constraint the aspect-ratio and unwanted upsizing
72 1
            $image->scaleDown(width: $width);
73
            // return image data
74 1
            return (string) $image->encodeByMediaType($asset['subtype'], /** @scrutinizer ignore-type */ progressive: true, /** @scrutinizer ignore-type */ interlaced: false, quality: $quality);
75
        } catch (\Exception $e) {
76
            throw new RuntimeException(\sprintf('Asset "%s" can\'t be resized: %s', $asset['path'], $e->getMessage()));
77
        }
78
    }
79
80
    /**
81
     * Crops an image Asset to the given width and height, keeping the aspect ratio.
82
     *
83
     * @throws RuntimeException
84
     */
85 1
    public static function cover(Asset $asset, int $width, int $height, string $position, int $quality): string
86
    {
87
        try {
88
            // creates image object from source
89 1
            $image = self::manager()->read($asset['content']);
90
            // turns an animated image (i.e GIF) into a static image
91 1
            if ($image->isAnimated()) {
92
                $image = $image->removeAnimation('25%'); // use 25% to avoid an "empty" frame
93
            }
94
            // crops the image
95 1
            $image->cover(width: $width, height: $height, position: $position);
96
            // return image data
97 1
            return (string) $image->encodeByMediaType($asset['subtype'], /** @scrutinizer ignore-type */ progressive: true, /** @scrutinizer ignore-type */ interlaced: false, quality: $quality);
98
        } catch (\Exception $e) {
99
            throw new RuntimeException(\sprintf('Asset "%s" can\'t be cropped: %s', $asset['path'], $e->getMessage()));
100
        }
101
    }
102
103
    /**
104
     * Makes an image Asset maskable, meaning it can be used as a web app icon.
105
     *
106
     * @throws RuntimeException
107
     */
108
    public static function maskable(Asset $asset, int $quality): string
109
    {
110
        try {
111
            // creates image object from source
112
            $source = self::manager()->read($asset['content']);
113
            // creates a new image with the dominant color and adds a 15% margin
114
            $image = self::manager()->create(
115
                width: (int) round($asset['width'] * (1 + 15 / 100), 0),
116
                height: (int) round($asset['height'] * (1 + 15 / 100), 0)
117
            )->fill(self::getDominantColor($asset));
118
            // inserts the original image in the center
119
            $image->place($source, position: 'center');
120
            // scales down the new image to the original image size
121
            $image->scaleDown(width: $asset['width']);
122
            // return image data
123
            return (string) $image->encodeByMediaType($asset['subtype'], /** @scrutinizer ignore-type */ progressive: true, /** @scrutinizer ignore-type */ interlaced: false, quality: $quality);
124
        } catch (\Exception $e) {
125
            throw new RuntimeException(\sprintf('Can\'t make Asset "%s" maskable: %s', $asset['path'], $e->getMessage()));
126
        }
127
    }
128
129
    /**
130
     * Converts an image Asset to the target format.
131
     *
132
     * @throws RuntimeException
133
     */
134 1
    public static function convert(Asset $asset, string $format, int $quality): string
135
    {
136
        try {
137 1
            $image = self::manager()->read($asset['content']);
138
139 1
            if (!\function_exists("image$format")) {
140 1
                throw new RuntimeException(\sprintf('Function "image%s" is not available.', $format));
141
            }
142
143
            return (string) $image->encodeByExtension($format, /** @scrutinizer ignore-type */ progressive: true, /** @scrutinizer ignore-type */ interlaced: false, quality: $quality);
144 1
        } catch (\Exception $e) {
145 1
            throw new RuntimeException(\sprintf('Not able to convert "%s" to %s: %s', $asset['path'], $format, $e->getMessage()));
146
        }
147
    }
148
149
    /**
150
     * Returns the Data URL (encoded in Base64).
151
     *
152
     * @throws RuntimeException
153
     */
154 1
    public static function getDataUrl(Asset $asset, int $quality): string
155
    {
156
        try {
157 1
            $image = self::manager()->read($asset['content']);
158
159 1
            return (string) $image->encode(new AutoEncoder(quality: $quality))->toDataUri();
160
        } catch (\Exception $e) {
161
            throw new RuntimeException(\sprintf('Can\'t get Data URL of "%s": %s', $asset['path'], $e->getMessage()));
162
        }
163
    }
164
165
    /**
166
     * Returns the dominant RGB color of an image asset.
167
     *
168
     * @throws RuntimeException
169
     */
170 1
    public static function getDominantColor(Asset $asset): string
171
    {
172
        try {
173 1
            $image = self::manager()->read(self::resize($asset, 100, 50));
174
175 1
            return $image->reduceColors(1)->pickColor(0, 0)->toString();
176
        } catch (\Exception $e) {
177
            throw new RuntimeException(\sprintf('Can\'t get dominant color of "%s": %s', $asset['path'], $e->getMessage()));
178
        }
179
    }
180
181
    /**
182
     * Returns a Low Quality Image Placeholder (LQIP) as data URL.
183
     *
184
     * @throws RuntimeException
185
     */
186 1
    public static function getLqip(Asset $asset): string
187
    {
188
        try {
189 1
            $image = self::manager()->read(self::resize($asset, 100, 50));
190
191 1
            return (string) $image->blur(50)->encode()->toDataUri();
192
        } catch (\Exception $e) {
193
            throw new RuntimeException(\sprintf('can\'t create LQIP of "%s": %s', $asset['path'], $e->getMessage()));
194
        }
195
    }
196
197
    /**
198
     * Build the `srcset` attribute for responsive images.
199
     * e.g.: `srcset="/img-480.jpg 480w, /img-800.jpg 800w"`.
200
     *
201
     * @throws RuntimeException
202
     */
203 1
    public static function buildSrcset(Asset $asset, array $widths): string
204
    {
205 1
        if (!self::isImage($asset)) {
206 1
            throw new RuntimeException(\sprintf('can\'t build "srcset" of "%s": it\'s not an image file.', $asset['path']));
207
        }
208
209 1
        $srcset = '';
210 1
        $widthMax = 0;
211 1
        sort($widths, SORT_NUMERIC);
212 1
        $widths = array_reverse($widths);
213 1
        foreach ($widths as $width) {
214 1
            if ($asset['width'] < $width) {
215 1
                continue;
216
            }
217 1
            $img = $asset->resize($width);
218 1
            $srcset .= \sprintf('%s %sw, ', (string) $img, $width);
219 1
            $widthMax = $width;
220
        }
221
        // adds source image
222 1
        if (!empty($srcset) && ($asset['width'] < max($widths) && $asset['width'] != $widthMax)) {
223 1
            $srcset .= \sprintf('%s %sw', (string) $asset, $asset['width']);
224
        }
225
226 1
        return rtrim($srcset, ', ');
227
    }
228
229
    /**
230
     * Returns the value of the "sizes" attribute corresponding to the configured class.
231
     */
232 1
    public static function getSizes(string $class, array $sizes = []): string
233
    {
234 1
        $result = '';
235 1
        $classArray = explode(' ', $class);
236 1
        foreach ($classArray as $class) {
237 1
            if (\array_key_exists($class, $sizes)) {
238 1
                $result = $sizes[$class] . ', ';
239
            }
240
        }
241 1
        if (!empty($result)) {
242 1
            return trim($result, ', ');
243
        }
244
245 1
        return $sizes['default'] ?? '100vw';
246
    }
247
248
    /**
249
     * Checks if an asset is an animated GIF.
250
     */
251 1
    public static function isAnimatedGif(Asset $asset): bool
252
    {
253
        // an animated GIF contains multiple "frames", with each frame having a header made up of:
254
        // 1. a static 4-byte sequence (\x00\x21\xF9\x04)
255
        // 2. 4 variable bytes
256
        // 3. a static 2-byte sequence (\x00\x2C)
257 1
        $count = preg_match_all('#\x00\x21\xF9\x04.{4}\x00[\x2C\x21]#s', (string) $asset['content']);
258
259 1
        return $count > 1;
260
    }
261
262
    /**
263
     * Returns true if asset is a SVG.
264
     */
265 1
    public static function isSVG(Asset $asset): bool
266
    {
267 1
        return \in_array($asset['subtype'], ['image/svg', 'image/svg+xml']) || $asset['ext'] == 'svg';
268
    }
269
270
    /**
271
     * Returns true if asset is an ICO.
272
     */
273 1
    public static function isIco(Asset $asset): bool
274
    {
275 1
        return \in_array($asset['subtype'], ['image/x-icon', 'image/vnd.microsoft.icon']) || $asset['ext'] == 'ico';
276
    }
277
278
    /**
279
     * Asset is a valid image?
280
     */
281 1
    public static function isImage(Asset $asset): bool
282
    {
283 1
        if ($asset['type'] !== 'image' || self::isSVG($asset) || self::isIco($asset)) {
284 1
            return false;
285
        }
286
287 1
        return true;
288
    }
289
290
    /**
291
     * Returns SVG attributes.
292
     *
293
     * @return \SimpleXMLElement|false
294
     */
295 1
    public static function getSvgAttributes(Asset $asset)
296
    {
297 1
        if (!self::isSVG($asset)) {
298
            return false;
299
        }
300
301 1
        if (false === $xml = simplexml_load_string($asset['content'] ?? '')) {
302
            return false;
303
        }
304
305 1
        return $xml->attributes();
306
    }
307
}
308