• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Cecilapp / Cecil / 16429319276

21 Jul 2025 10:12PM UTC coverage: 81.83% (-0.1%) from 81.954%
16429319276

push

github

ArnaudLigny
refactor: better maskable filter

11 of 25 new or added lines in 3 files covered. (44.0%)

76 existing lines in 3 files now uncovered.

3139 of 3836 relevant lines covered (81.83%)

0.82 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

70.34
/src/Assets/Image.php
1
<?php
2

3
/**
4
 * This file is part of Cecil.
5
 *
6
 * (c) Arnaud Ligny <arnaud@ligny.fr>
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
    private static function manager(): ImageManager
34
    {
35
        $driver = null;
1✔
36

37
        // ImageMagick is available? (for a future quality option)
38
        if (\extension_loaded('imagick') && class_exists('Imagick')) {
1✔
39
            $driver = ImagickDriver::class;
1✔
40
        }
41
        // Use GD, because it's the faster driver
42
        if (\extension_loaded('gd') && \function_exists('gd_info')) {
1✔
43
            $driver = GdDriver::class;
1✔
44
        }
45

46
        if ($driver) {
1✔
47
            return ImageManager::withDriver(
1✔
48
                $driver,
1✔
49
                [
1✔
50
                    'autoOrientation' => true,
1✔
51
                    'decodeAnimation' => true,
1✔
52
                    'blendingColor' => 'ffffff',
1✔
53
                    'strip' => true, // remove metadata
1✔
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
    public static function resize(Asset $asset, int $width, int $quality): string
67
    {
68
        try {
69
            // creates image object from source
70
            $image = self::manager()->read($asset['content']);
1✔
71
            // resizes to $width with constraint the aspect-ratio and unwanted upsizing
72
            $image->scaleDown(width: $width);
1✔
73
            // return image data
74
            return (string) $image->encodeByMediaType(
1✔
75
                $asset['subtype'],
1✔
76
                /** @scrutinizer ignore-type */
77
                progressive: true,
1✔
78
                /** @scrutinizer ignore-type */
79
                interlaced: false,
1✔
80
                quality: $quality
1✔
81
            );
1✔
UNCOV
82
        } catch (\Exception $e) {
×
UNCOV
83
            throw new RuntimeException(\sprintf('Asset "%s" can\'t be resized: %s', $asset['path'], $e->getMessage()));
×
84
        }
85
    }
86

87
    /**
88
     * Crops an image Asset to the given width and height, keeping the aspect ratio.
89
     *
90
     * @throws RuntimeException
91
     */
92
    public static function cover(Asset $asset, int $width, int $height, int $quality): string
93
    {
94
        try {
95
            // creates image object from source
96
            $image = self::manager()->read($asset['content']);
1✔
97
            // turns an animated image (i.e GIF) into a static image
98
            if ($image->isAnimated()) {
1✔
99
                $image = $image->removeAnimation('25%'); // use 25% to avoid an "empty" frame
×
100
            }
101
            // crops the image
102
            $image->cover(
1✔
103
                width: $width,
1✔
104
                height: $height,
1✔
105
                position: 'center'
1✔
106
            );
1✔
107
            // return image data
108
            return (string) $image->encodeByMediaType(
1✔
109
                $asset['subtype'],
1✔
110
                /** @scrutinizer ignore-type */
111
                progressive: true,
1✔
112
                /** @scrutinizer ignore-type */
113
                interlaced: false,
1✔
114
                quality: $quality
1✔
115
            );
1✔
UNCOV
116
        } catch (\Exception $e) {
×
UNCOV
117
            throw new RuntimeException(\sprintf('Asset "%s" can\'t be cropped: %s', $asset['path'], $e->getMessage()));
×
118
        }
119
    }
120

121
    /**
122
     * Makes an image Asset maskable, meaning it can be used as a PWA icon.
123
     *
124
     * @throws RuntimeException
125
     */
126
    public static function maskable(Asset $asset, int $quality, int $padding): string
127
    {
128
        try {
129
            // creates image object from source
130
            $source = self::manager()->read($asset['content']);
×
131
            // creates a new image with the dominant color as background
132
            // and the size of the original image plus the padding
133
            $image = self::manager()->create(
×
UNCOV
134
                width: (int) round($asset['width'] * (1 + $padding / 100), 0),
×
135
                height: (int) round($asset['height'] * (1 + $padding / 100), 0)
×
UNCOV
136
            )->fill(self::getDominantColor($asset));
×
137
            // inserts the original image in the center
NEW
138
            $image->place(
×
NEW
139
                $source,
×
NEW
140
                position: 'center'
×
NEW
141
            );
×
142
            // scales down the new image to the original image size
UNCOV
143
            $image->scaleDown(width: $asset['width']);
×
144
            // return image data
NEW
145
            return (string) $image->encodeByMediaType(
×
NEW
146
                $asset['subtype'],
×
147
                /** @scrutinizer ignore-type */
NEW
148
                progressive: true,
×
149
                /** @scrutinizer ignore-type */
NEW
150
                interlaced: false,
×
NEW
151
                quality: $quality
×
NEW
152
            );
×
UNCOV
153
        } catch (\Exception $e) {
×
UNCOV
154
            throw new RuntimeException(\sprintf('Can\'t make Asset "%s" maskable: %s', $asset['path'], $e->getMessage()));
×
155
        }
156
    }
157

158
    /**
159
     * Converts an image Asset to the target format.
160
     *
161
     * @throws RuntimeException
162
     */
163
    public static function convert(Asset $asset, string $format, int $quality): string
164
    {
165
        try {
166
            $image = self::manager()->read($asset['content']);
1✔
167

168
            if (!\function_exists("image$format")) {
1✔
169
                throw new RuntimeException(\sprintf('Function "image%s" is not available.', $format));
×
170
            }
171

172
            return (string) $image->encodeByExtension(
1✔
173
                $format,
1✔
174
                /** @scrutinizer ignore-type */
175
                progressive: true,
1✔
176
                /** @scrutinizer ignore-type */
177
                interlaced: false,
1✔
178
                quality: $quality
1✔
179
            );
1✔
UNCOV
180
        } catch (\Exception $e) {
×
UNCOV
181
            throw new RuntimeException(\sprintf('Not able to convert "%s" to %s: %s', $asset['path'], $format, $e->getMessage()));
×
182
        }
183
    }
184

185
    /**
186
     * Returns the Data URL (encoded in Base64).
187
     *
188
     * @throws RuntimeException
189
     */
190
    public static function getDataUrl(Asset $asset, int $quality): string
191
    {
192
        try {
193
            $image = self::manager()->read($asset['content']);
1✔
194

195
            return (string) $image->encode(new AutoEncoder(quality: $quality))->toDataUri();
1✔
UNCOV
196
        } catch (\Exception $e) {
×
UNCOV
197
            throw new RuntimeException(\sprintf('Can\'t get Data URL of "%s": %s', $asset['path'], $e->getMessage()));
×
198
        }
199
    }
200

201
    /**
202
     * Returns the dominant RGB color of an image asset.
203
     *
204
     * @throws RuntimeException
205
     */
206
    public static function getDominantColor(Asset $asset): string
207
    {
208
        try {
209
            $image = self::manager()->read(self::resize($asset, 100, 50));
1✔
210

211
            return $image->reduceColors(1)->pickColor(0, 0)->toString();
1✔
UNCOV
212
        } catch (\Exception $e) {
×
UNCOV
213
            throw new RuntimeException(\sprintf('Can\'t get dominant color of "%s": %s', $asset['path'], $e->getMessage()));
×
214
        }
215
    }
216

217
    /**
218
     * Returns a Low Quality Image Placeholder (LQIP) as data URL.
219
     *
220
     * @throws RuntimeException
221
     */
222
    public static function getLqip(Asset $asset): string
223
    {
224
        try {
225
            $image = self::manager()->read(self::resize($asset, 100, 50));
1✔
226

227
            return (string) $image->blur(50)->encode()->toDataUri();
1✔
UNCOV
228
        } catch (\Exception $e) {
×
UNCOV
229
            throw new RuntimeException(\sprintf('can\'t create LQIP of "%s": %s', $asset['path'], $e->getMessage()));
×
230
        }
231
    }
232

233
    /**
234
     * Build the `srcset` attribute for responsive images.
235
     * e.g.: `srcset="/img-480.jpg 480w, /img-800.jpg 800w"`.
236
     *
237
     * @throws RuntimeException
238
     */
239
    public static function buildSrcset(Asset $asset, array $widths): string
240
    {
241
        if (!self::isImage($asset)) {
1✔
242
            throw new RuntimeException(\sprintf('can\'t build "srcset" of "%s": it\'s not an image file.', $asset['path']));
1✔
243
        }
244

245
        $srcset = '';
1✔
246
        $widthMax = 0;
1✔
247
        sort($widths, SORT_NUMERIC);
1✔
248
        $widths = array_reverse($widths);
1✔
249
        foreach ($widths as $width) {
1✔
250
            if ($asset['width'] < $width) {
1✔
251
                continue;
1✔
252
            }
253
            $img = $asset->resize($width);
1✔
254
            $srcset .= \sprintf('%s %sw, ', (string) $img, $width);
1✔
255
            $widthMax = $width;
1✔
256
        }
257
        // adds source image
258
        if (!empty($srcset) && ($asset['width'] < max($widths) && $asset['width'] != $widthMax)) {
1✔
259
            $srcset .= \sprintf('%s %sw', (string) $asset, $asset['width']);
1✔
260
        }
261

262
        return rtrim($srcset, ', ');
1✔
263
    }
264

265
    /**
266
     * Returns the value of the "sizes" attribute corresponding to the configured class.
267
     */
268
    public static function getSizes(string $class, array $sizes = []): string
269
    {
270
        $result = '';
1✔
271
        $classArray = explode(' ', $class);
1✔
272
        foreach ($classArray as $class) {
1✔
273
            if (\array_key_exists($class, $sizes)) {
1✔
274
                $result = $sizes[$class] . ', ';
1✔
275
            }
276
        }
277
        if (!empty($result)) {
1✔
278
            return trim($result, ', ');
1✔
279
        }
280

281
        return $sizes['default'] ?? '100vw';
1✔
282
    }
283

284
    /**
285
     * Checks if an asset is an animated GIF.
286
     */
287
    public static function isAnimatedGif(Asset $asset): bool
288
    {
289
        // an animated GIF contains multiple "frames", with each frame having a header made up of:
290
        // 1. a static 4-byte sequence (\x00\x21\xF9\x04)
291
        // 2. 4 variable bytes
292
        // 3. a static 2-byte sequence (\x00\x2C)
293
        $count = preg_match_all('#\x00\x21\xF9\x04.{4}\x00[\x2C\x21]#s', (string) $asset['content']);
1✔
294

295
        return $count > 1;
1✔
296
    }
297

298
    /**
299
     * Returns true if asset is a SVG.
300
     */
301
    public static function isSVG(Asset $asset): bool
302
    {
303
        return \in_array($asset['subtype'], ['image/svg', 'image/svg+xml']) || $asset['ext'] == 'svg';
1✔
304
    }
305

306
    /**
307
     * Returns true if asset is an ICO.
308
     */
309
    public static function isIco(Asset $asset): bool
310
    {
311
        return \in_array($asset['subtype'], ['image/x-icon', 'image/vnd.microsoft.icon']) || $asset['ext'] == 'ico';
1✔
312
    }
313

314
    /**
315
     * Asset is a valid image?
316
     */
317
    public static function isImage(Asset $asset): bool
318
    {
319
        if ($asset['type'] !== 'image' || self::isSVG($asset) || self::isIco($asset)) {
1✔
320
            return false;
1✔
321
        }
322

323
        return true;
1✔
324
    }
325

326
    /**
327
     * Returns SVG attributes.
328
     *
329
     * @return \SimpleXMLElement|false
330
     */
331
    public static function getSvgAttributes(Asset $asset)
332
    {
333
        if (!self::isSVG($asset)) {
1✔
UNCOV
334
            return false;
×
335
        }
336

337
        if (false === $xml = simplexml_load_string($asset['content'] ?? '')) {
1✔
UNCOV
338
            return false;
×
339
        }
340

341
        return $xml->attributes();
1✔
342
    }
343
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc