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

Cecilapp / Cecil / 20978740576

14 Jan 2026 01:16AM UTC coverage: 82.551%. First build
20978740576

Pull #2277

github

web-flow
Merge 4e15fecb4 into e1a0943e9
Pull Request #2277: refactor: rebuilding images processing

30 of 47 new or added lines in 4 files covered. (63.83%)

3288 of 3983 relevant lines covered (82.55%)

0.83 hits per line

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

72.31
/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
     * 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']);
1✔
78

79
            if ($rmAnimation && $image->isAnimated()) {
1✔
80
                $image = $image->removeAnimation('25%'); // use 25% to avoid an "empty" frame
×
81
            }
82

83
            $resize = function (?int $width, ?int $height) use ($image) {
1✔
84
                if ($width !== null && $height !== null) {
1✔
85
                    return $image->cover(width: $width, height: $height, position: 'center');
1✔
86
                }
87
                if ($width !== null) {
1✔
88
                    return $image->scale(width: $width);
1✔
89
                }
90
                if ($height !== null) {
1✔
91
                    return $image->scale(height: $height);
1✔
92
                }
NEW
93
                throw new RuntimeException('Width or height must be specified.');
×
94
            };
1✔
95
            $image = $resize($width, $height);
1✔
96

97
            return (string) $image->encodeByMediaType(
1✔
98
                $asset['subtype'],
1✔
99
                /** @scrutinizer ignore-type */
100
                progressive: true,
1✔
101
                /** @scrutinizer ignore-type */
102
                interlaced: false,
1✔
103
                quality: $quality
1✔
104
            );
1✔
105
        } catch (\Exception $e) {
×
NEW
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
NEW
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) {
×
NEW
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")) {
1✔
153
                throw new RuntimeException(\sprintf('Function "image%s" is not available.', $format));
×
154
            }
155

156
            $image = self::manager()->read($asset['content']);
1✔
157

158
            return (string) $image->encodeByExtension(
1✔
159
                $format,
1✔
160
                /** @scrutinizer ignore-type */
161
                progressive: true,
1✔
162
                /** @scrutinizer ignore-type */
163
                interlaced: false,
1✔
164
                quality: $quality
1✔
165
            );
1✔
166
        } catch (\Exception $e) {
×
NEW
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']);
1✔
180

181
            return (string) $image->encode(new AutoEncoder(quality: $quality))->toDataUri();
1✔
182
        } catch (\Exception $e) {
×
NEW
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));
1✔
196

197
            return $image->reduceColors(1)->pickColor(0, 0)->toString();
1✔
198
        } catch (\Exception $e) {
×
NEW
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) {
×
NEW
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));
1✔
228

229
            return (string) $image->blur(50)->encode()->toDataUri();
1✔
230
        } catch (\Exception $e) {
×
NEW
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)) {
1✔
247
            throw new RuntimeException(\sprintf('Unable to build "srcset" of "%s": it\'s not an image file.', $asset['path']));
1✔
248
        }
249

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

267
        return implode(', ', $srcset);
1✔
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);
1✔
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)) {
1✔
290
            throw new RuntimeException(\sprintf('Unable to build "srcset" of "%s": it\'s not an image file.', $asset['path']));
×
291
        }
292

293
        $srcset = [];
1✔
294
        sort($ratios, SORT_NUMERIC);
1✔
295
        $ratios = array_reverse($ratios);
1✔
296
        foreach ($ratios as $ratio) {
1✔
297
            if ($ratio <= 1) {
1✔
298
                continue;
1✔
299
            }
300
            $width = (int) round($width1x * $ratio, 0);
1✔
301
            if ($asset['width'] < $width) {
1✔
302
                continue;
1✔
303
            }
304
            $img = $asset->resize($width);
1✔
305
            array_unshift($srcset, \sprintf('%s %dx', (string) $img, $ratio));
1✔
306
        }
307
        // adds 1x image
308
        array_unshift($srcset, \sprintf('%s 1x', (string) $asset->resize($width1x)));
1✔
309

310
        return implode(', ', $srcset);
1✔
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 = '';
1✔
319
        $classArray = explode(' ', $class);
1✔
320
        foreach ($classArray as $class) {
1✔
321
            if (\array_key_exists($class, $sizes)) {
1✔
322
                $result = $sizes[$class] . ', ';
1✔
323
            }
324
        }
325
        if (!empty($result)) {
1✔
326
            return trim($result, ', ');
1✔
327
        }
328

329
        return $sizes['default'] ?? '100vw';
1✔
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']);
1✔
342

343
        return $count > 1;
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';
1✔
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';
1✔
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)) {
1✔
368
            return false;
1✔
369
        }
370

371
        return true;
1✔
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)) {
1✔
382
            return false;
×
383
        }
384

385
        if (false === $xml = simplexml_load_string($asset['content'] ?? '')) {
1✔
386
            return false;
×
387
        }
388

389
        return $xml->attributes();
1✔
390
    }
391
}
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