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

Cecilapp / Cecil / 20014922591

08 Dec 2025 02:39AM UTC coverage: 81.525% (-0.7%) from 82.265%
20014922591

Pull #2258

github

web-flow
Merge cb14138c1 into d9749ffbe
Pull Request #2258: refactor Asset

77 of 86 new or added lines in 2 files covered. (89.53%)

40 existing lines in 4 files now uncovered.

3261 of 4000 relevant lines covered (81.53%)

0.82 hits per line

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

61.59
/src/Asset/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\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;
1✔
40

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

50
        if ($driver) {
1✔
51
            return ImageManager::withDriver(
1✔
52
                $driver,
1✔
53
                [
1✔
54
                    'autoOrientation' => true,
1✔
55
                    'decodeAnimation' => true,
1✔
56
                    'blendingColor' => 'ffffff',
1✔
57
                    'strip' => true, // remove metadata
1✔
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
    public static function resize(Asset $asset, int $width, int $quality): string
71
    {
72
        try {
73
            // creates image object from source
74
            $image = self::manager()->read($asset['content']);
1✔
75
            // resizes to $width with constraint the aspect-ratio and unwanted upsizing
76
            $image->scaleDown(width: $width);
1✔
77
            // return image data
78
            return (string) $image->encodeByMediaType(
1✔
79
                $asset['subtype'],
1✔
80
                /** @scrutinizer ignore-type */
81
                progressive: true,
1✔
82
                /** @scrutinizer ignore-type */
83
                interlaced: false,
1✔
84
                quality: $quality
1✔
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
    public static function cover(Asset $asset, int $width, int $height, int $quality): string
97
    {
98
        try {
99
            // creates image object from source
UNCOV
100
            $image = self::manager()->read($asset['content']);
×
101
            // turns an animated image (i.e GIF) into a static image
UNCOV
102
            if ($image->isAnimated()) {
×
103
                $image = $image->removeAnimation('25%'); // use 25% to avoid an "empty" frame
×
104
            }
105
            // crops the image
UNCOV
106
            $image->cover(
×
UNCOV
107
                width: $width,
×
UNCOV
108
                height: $height,
×
UNCOV
109
                position: 'center'
×
UNCOV
110
            );
×
111
            // return image data
UNCOV
112
            return (string) $image->encodeByMediaType(
×
UNCOV
113
                $asset['subtype'],
×
114
                /** @scrutinizer ignore-type */
UNCOV
115
                progressive: true,
×
116
                /** @scrutinizer ignore-type */
UNCOV
117
                interlaced: false,
×
UNCOV
118
                quality: $quality
×
UNCOV
119
            );
×
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
    public static function convert(Asset $asset, string $format, int $quality): string
168
    {
169
        try {
170
            $image = self::manager()->read($asset['content']);
1✔
171

172
            if (!\function_exists("image$format")) {
1✔
173
                throw new RuntimeException(\sprintf('Function "image%s" is not available.', $format));
×
174
            }
175

176
            return (string) $image->encodeByExtension(
1✔
177
                $format,
1✔
178
                /** @scrutinizer ignore-type */
179
                progressive: true,
1✔
180
                /** @scrutinizer ignore-type */
181
                interlaced: false,
1✔
182
                quality: $quality
1✔
183
            );
1✔
184
        } catch (\Exception $e) {
×
185
            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
    public static function getDataUrl(Asset $asset, int $quality): string
195
    {
196
        try {
197
            $image = self::manager()->read($asset['content']);
1✔
198

199
            return (string) $image->encode(new AutoEncoder(quality: $quality))->toDataUri();
1✔
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
    public static function getDominantColor(Asset $asset): string
211
    {
212
        try {
213
            $image = self::manager()->read(self::resize($asset, 100, 50));
1✔
214

215
            return $image->reduceColors(1)->pickColor(0, 0)->toString();
1✔
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
    public static function getLqip(Asset $asset): string
243
    {
244
        try {
245
            $image = self::manager()->read(self::resize($asset, 100, 50));
1✔
246

247
            return (string) $image->blur(50)->encode()->toDataUri();
1✔
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, based on widths.
255
     * e.g.: `srcset="/img-480.jpg 480w, /img-800.jpg 800w"`.
256
     *
257
     * @param array $widths   An array of widths to include in the `srcset`
258
     * @param bool  $notEmpty If true the source image is always added to the `srcset`
259
     *
260
     * @throws RuntimeException
261
     */
262
    public static function buildHtmlSrcsetW(Asset $asset, array $widths, $notEmpty = false): string
263
    {
264
        if (!self::isImage($asset)) {
1✔
265
            throw new RuntimeException(\sprintf('Unable to build "srcset" of "%s": it\'s not an image file.', $asset['path']));
1✔
266
        }
267

268
        $srcset = [];
1✔
269
        $widthMax = 0;
1✔
270
        sort($widths, SORT_NUMERIC);
1✔
271
        $widths = array_reverse($widths);
1✔
272
        foreach ($widths as $width) {
1✔
273
            if ($asset['width'] < $width) {
1✔
274
                continue;
1✔
275
            }
276
            $img = $asset->resize($width);
1✔
277
            array_unshift($srcset, \sprintf('%s %sw', (string) $img, $width));
1✔
278
            $widthMax = $width;
1✔
279
        }
280
        // adds source image
281
        if ((!empty($srcset) || $notEmpty) && ($asset['width'] < max($widths) && $asset['width'] != $widthMax)) {
1✔
282
            $srcset[] = \sprintf('%s %sw', (string) $asset, $asset['width']);
1✔
283
        }
284

285
        return implode(', ', $srcset);
1✔
286
    }
287

288
    /**
289
     * Alias of buildHtmlSrcsetW for backward compatibility.
290
     */
291
    public static function buildHtmlSrcset(Asset $asset, array $widths, $notEmpty = false): string
292
    {
293
        return self::buildHtmlSrcsetW($asset, $widths, $notEmpty);
1✔
294
    }
295

296
    /**
297
     * Build the `srcset` HTML attribute for responsive images, based on pixel ratios.
298
     * e.g.: `srcset="/img-1x.jpg 1.0x, /img-2x.jpg 2.0x"`.
299
     *
300
     * @param int   $width1x  The width of the 1x image
301
     * @param array $ratios   An array of pixel ratios to include in the `srcset`
302
     *
303
     * @throws RuntimeException
304
     */
305
    public static function buildHtmlSrcsetX(Asset $asset, int $width1x, array $ratios): string
306
    {
307
        if (!self::isImage($asset)) {
1✔
308
            throw new RuntimeException(\sprintf('Unable to build "srcset" of "%s": it\'s not an image file.', $asset['path']));
×
309
        }
310

311
        $srcset = [];
1✔
312
        sort($ratios, SORT_NUMERIC);
1✔
313
        $ratios = array_reverse($ratios);
1✔
314
        foreach ($ratios as $ratio) {
1✔
315
            if ($ratio <= 1) {
1✔
316
                continue;
1✔
317
            }
318
            $width = (int) round($width1x * $ratio, 0);
1✔
319
            if ($asset['width'] < $width) {
1✔
320
                continue;
1✔
321
            }
322
            $img = $asset->resize($width);
1✔
323
            array_unshift($srcset, \sprintf('%s %dx', (string) $img, $ratio));
1✔
324
        }
325
        // adds 1x image
326
        array_unshift($srcset, \sprintf('%s 1x', (string) $asset->resize($width1x)));
1✔
327

328
        return implode(', ', $srcset);
1✔
329
    }
330

331
    /**
332
     * Returns the value from the `$sizes` array if the class exists, otherwise returns the default size.
333
     */
334
    public static function getHtmlSizes(string $class, array $sizes = []): string
335
    {
336
        $result = '';
1✔
337
        $classArray = explode(' ', $class);
1✔
338
        foreach ($classArray as $class) {
1✔
339
            if (\array_key_exists($class, $sizes)) {
1✔
340
                $result = $sizes[$class] . ', ';
1✔
341
            }
342
        }
343
        if (!empty($result)) {
1✔
344
            return trim($result, ', ');
1✔
345
        }
346

347
        return $sizes['default'] ?? '100vw';
1✔
348
    }
349

350
    /**
351
     * Checks if an asset is an animated GIF.
352
     */
353
    public static function isAnimatedGif(Asset $asset): bool
354
    {
355
        // an animated GIF contains multiple "frames", with each frame having a header made up of:
356
        // 1. a static 4-byte sequence (\x00\x21\xF9\x04)
357
        // 2. 4 variable bytes
358
        // 3. a static 2-byte sequence (\x00\x2C)
359
        $count = preg_match_all('#\x00\x21\xF9\x04.{4}\x00[\x2C\x21]#s', (string) $asset['content']);
1✔
360

361
        return $count > 1;
1✔
362
    }
363

364
    /**
365
     * Returns true if asset is a SVG.
366
     */
367
    public static function isSVG(Asset $asset): bool
368
    {
369
        return \in_array($asset['subtype'], ['image/svg', 'image/svg+xml']) || $asset['ext'] == 'svg';
1✔
370
    }
371

372
    /**
373
     * Returns true if asset is an ICO.
374
     */
375
    public static function isIco(Asset $asset): bool
376
    {
377
        return \in_array($asset['subtype'], ['image/x-icon', 'image/vnd.microsoft.icon']) || $asset['ext'] == 'ico';
1✔
378
    }
379

380
    /**
381
     * Asset is a valid image?
382
     */
383
    public static function isImage(Asset $asset): bool
384
    {
385
        if ($asset['type'] !== 'image' || self::isSVG($asset) || self::isIco($asset)) {
1✔
386
            return false;
1✔
387
        }
388

389
        return true;
1✔
390
    }
391

392
    /**
393
     * Returns SVG attributes.
394
     *
395
     * @return \SimpleXMLElement|false
396
     */
397
    public static function getSvgAttributes(Asset $asset)
398
    {
399
        if (!self::isSVG($asset)) {
1✔
400
            return false;
×
401
        }
402

403
        if (false === $xml = simplexml_load_string($asset['content'] ?? '')) {
1✔
404
            return false;
×
405
        }
406

407
        return $xml->attributes();
1✔
408
    }
409
}
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