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

Cecilapp / Cecil / 16418663342

21 Jul 2025 01:42PM UTC coverage: 81.954%. First build
16418663342

Pull #2203

github

web-flow
Merge 8b92b286c into 60eb2aabd
Pull Request #2203: Apply fixes from StyleCI

0 of 2 new or added lines in 1 file covered. (0.0%)

3120 of 3807 relevant lines covered (81.95%)

0.82 hits per line

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

70.33
/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($asset['subtype'], /** @scrutinizer ignore-type */ progressive: true, /** @scrutinizer ignore-type */ interlaced: false, quality: $quality);
1✔
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
    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
            $image = self::manager()->read($asset['content']);
1✔
90
            // turns an animated image (i.e GIF) into a static image
91
            if ($image->isAnimated()) {
1✔
92
                $image = $image->removeAnimation('25%'); // use 25% to avoid an "empty" frame
×
93
            }
94
            // crops the image
95
            $image->cover(width: $width, height: $height, position: $position);
1✔
96
            // return image data
97
            return (string) $image->encodeByMediaType($asset['subtype'], /** @scrutinizer ignore-type */ progressive: true, /** @scrutinizer ignore-type */ interlaced: false, quality: $quality);
1✔
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(
×
NEW
115
                width: (int) round($asset['width'] * (1 + 15 / 100), 0),
×
NEW
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
    public static function convert(Asset $asset, string $format, int $quality): string
135
    {
136
        try {
137
            $image = self::manager()->read($asset['content']);
1✔
138

139
            if (!\function_exists("image$format")) {
1✔
140
                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);
1✔
144
        } catch (\Exception $e) {
×
145
            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
    public static function getDataUrl(Asset $asset, int $quality): string
155
    {
156
        try {
157
            $image = self::manager()->read($asset['content']);
1✔
158

159
            return (string) $image->encode(new AutoEncoder(quality: $quality))->toDataUri();
1✔
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
    public static function getDominantColor(Asset $asset): string
171
    {
172
        try {
173
            $image = self::manager()->read(self::resize($asset, 100, 50));
1✔
174

175
            return $image->reduceColors(1)->pickColor(0, 0)->toString();
1✔
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
    public static function getLqip(Asset $asset): string
187
    {
188
        try {
189
            $image = self::manager()->read(self::resize($asset, 100, 50));
1✔
190

191
            return (string) $image->blur(50)->encode()->toDataUri();
1✔
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
    public static function buildSrcset(Asset $asset, array $widths): string
204
    {
205
        if (!self::isImage($asset)) {
1✔
206
            throw new RuntimeException(\sprintf('can\'t build "srcset" of "%s": it\'s not an image file.', $asset['path']));
1✔
207
        }
208

209
        $srcset = '';
1✔
210
        $widthMax = 0;
1✔
211
        sort($widths, SORT_NUMERIC);
1✔
212
        $widths = array_reverse($widths);
1✔
213
        foreach ($widths as $width) {
1✔
214
            if ($asset['width'] < $width) {
1✔
215
                continue;
1✔
216
            }
217
            $img = $asset->resize($width);
1✔
218
            $srcset .= \sprintf('%s %sw, ', (string) $img, $width);
1✔
219
            $widthMax = $width;
1✔
220
        }
221
        // adds source image
222
        if (!empty($srcset) && ($asset['width'] < max($widths) && $asset['width'] != $widthMax)) {
1✔
223
            $srcset .= \sprintf('%s %sw', (string) $asset, $asset['width']);
1✔
224
        }
225

226
        return rtrim($srcset, ', ');
1✔
227
    }
228

229
    /**
230
     * Returns the value of the "sizes" attribute corresponding to the configured class.
231
     */
232
    public static function getSizes(string $class, array $sizes = []): string
233
    {
234
        $result = '';
1✔
235
        $classArray = explode(' ', $class);
1✔
236
        foreach ($classArray as $class) {
1✔
237
            if (\array_key_exists($class, $sizes)) {
1✔
238
                $result = $sizes[$class] . ', ';
1✔
239
            }
240
        }
241
        if (!empty($result)) {
1✔
242
            return trim($result, ', ');
1✔
243
        }
244

245
        return $sizes['default'] ?? '100vw';
1✔
246
    }
247

248
    /**
249
     * Checks if an asset is an animated GIF.
250
     */
251
    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
        $count = preg_match_all('#\x00\x21\xF9\x04.{4}\x00[\x2C\x21]#s', (string) $asset['content']);
1✔
258

259
        return $count > 1;
1✔
260
    }
261

262
    /**
263
     * Returns true if asset is a SVG.
264
     */
265
    public static function isSVG(Asset $asset): bool
266
    {
267
        return \in_array($asset['subtype'], ['image/svg', 'image/svg+xml']) || $asset['ext'] == 'svg';
1✔
268
    }
269

270
    /**
271
     * Returns true if asset is an ICO.
272
     */
273
    public static function isIco(Asset $asset): bool
274
    {
275
        return \in_array($asset['subtype'], ['image/x-icon', 'image/vnd.microsoft.icon']) || $asset['ext'] == 'ico';
1✔
276
    }
277

278
    /**
279
     * Asset is a valid image?
280
     */
281
    public static function isImage(Asset $asset): bool
282
    {
283
        if ($asset['type'] !== 'image' || self::isSVG($asset) || self::isIco($asset)) {
1✔
284
            return false;
1✔
285
        }
286

287
        return true;
1✔
288
    }
289

290
    /**
291
     * Returns SVG attributes.
292
     *
293
     * @return \SimpleXMLElement|false
294
     */
295
    public static function getSvgAttributes(Asset $asset)
296
    {
297
        if (!self::isSVG($asset)) {
1✔
298
            return false;
×
299
        }
300

301
        if (false === $xml = simplexml_load_string($asset['content'] ?? '')) {
1✔
302
            return false;
×
303
        }
304

305
        return $xml->attributes();
1✔
306
    }
307
}
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