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

Cecilapp / Cecil / 16421248601

21 Jul 2025 03:28PM UTC coverage: 81.954%. Remained the same
16421248601

push

github

ArnaudLigny
fix: maskable asset padding

0 of 3 new or added lines in 2 files covered. (0.0%)

2 existing lines in 2 files now uncovered.

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 PWA icon.
105
     *
106
     * @throws RuntimeException
107
     */
108
    public static function maskable(Asset $asset, int $quality, int $padding = 20): 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 as background
114
            // and the size of the original image plus the padding
115
            $image = self::manager()->create(
×
NEW
116
                width: (int) round($asset['width'] * (1 + $padding / 100), 0),
×
NEW
117
                height: (int) round($asset['height'] * (1 + $padding / 100), 0)
×
UNCOV
118
            )->fill(self::getDominantColor($asset));
×
119
            // inserts the original image in the center
120
            $image->place($source, position: 'center');
×
121
            // scales down the new image to the original image size
122
            $image->scaleDown(width: $asset['width']);
×
123
            // return image data
124
            return (string) $image->encodeByMediaType($asset['subtype'], /** @scrutinizer ignore-type */ progressive: true, /** @scrutinizer ignore-type */ interlaced: false, quality: $quality);
×
125
        } catch (\Exception $e) {
×
126
            throw new RuntimeException(\sprintf('Can\'t make Asset "%s" maskable: %s', $asset['path'], $e->getMessage()));
×
127
        }
128
    }
129

130
    /**
131
     * Converts an image Asset to the target format.
132
     *
133
     * @throws RuntimeException
134
     */
135
    public static function convert(Asset $asset, string $format, int $quality): string
136
    {
137
        try {
138
            $image = self::manager()->read($asset['content']);
1✔
139

140
            if (!\function_exists("image$format")) {
1✔
141
                throw new RuntimeException(\sprintf('Function "image%s" is not available.', $format));
×
142
            }
143

144
            return (string) $image->encodeByExtension($format, /** @scrutinizer ignore-type */ progressive: true, /** @scrutinizer ignore-type */ interlaced: false, quality: $quality);
1✔
145
        } catch (\Exception $e) {
×
146
            throw new RuntimeException(\sprintf('Not able to convert "%s" to %s: %s', $asset['path'], $format, $e->getMessage()));
×
147
        }
148
    }
149

150
    /**
151
     * Returns the Data URL (encoded in Base64).
152
     *
153
     * @throws RuntimeException
154
     */
155
    public static function getDataUrl(Asset $asset, int $quality): string
156
    {
157
        try {
158
            $image = self::manager()->read($asset['content']);
1✔
159

160
            return (string) $image->encode(new AutoEncoder(quality: $quality))->toDataUri();
1✔
161
        } catch (\Exception $e) {
×
162
            throw new RuntimeException(\sprintf('Can\'t get Data URL of "%s": %s', $asset['path'], $e->getMessage()));
×
163
        }
164
    }
165

166
    /**
167
     * Returns the dominant RGB color of an image asset.
168
     *
169
     * @throws RuntimeException
170
     */
171
    public static function getDominantColor(Asset $asset): string
172
    {
173
        try {
174
            $image = self::manager()->read(self::resize($asset, 100, 50));
1✔
175

176
            return $image->reduceColors(1)->pickColor(0, 0)->toString();
1✔
177
        } catch (\Exception $e) {
×
178
            throw new RuntimeException(\sprintf('Can\'t get dominant color of "%s": %s', $asset['path'], $e->getMessage()));
×
179
        }
180
    }
181

182
    /**
183
     * Returns a Low Quality Image Placeholder (LQIP) as data URL.
184
     *
185
     * @throws RuntimeException
186
     */
187
    public static function getLqip(Asset $asset): string
188
    {
189
        try {
190
            $image = self::manager()->read(self::resize($asset, 100, 50));
1✔
191

192
            return (string) $image->blur(50)->encode()->toDataUri();
1✔
193
        } catch (\Exception $e) {
×
194
            throw new RuntimeException(\sprintf('can\'t create LQIP of "%s": %s', $asset['path'], $e->getMessage()));
×
195
        }
196
    }
197

198
    /**
199
     * Build the `srcset` attribute for responsive images.
200
     * e.g.: `srcset="/img-480.jpg 480w, /img-800.jpg 800w"`.
201
     *
202
     * @throws RuntimeException
203
     */
204
    public static function buildSrcset(Asset $asset, array $widths): string
205
    {
206
        if (!self::isImage($asset)) {
1✔
207
            throw new RuntimeException(\sprintf('can\'t build "srcset" of "%s": it\'s not an image file.', $asset['path']));
1✔
208
        }
209

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

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

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

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

249
    /**
250
     * Checks if an asset is an animated GIF.
251
     */
252
    public static function isAnimatedGif(Asset $asset): bool
253
    {
254
        // an animated GIF contains multiple "frames", with each frame having a header made up of:
255
        // 1. a static 4-byte sequence (\x00\x21\xF9\x04)
256
        // 2. 4 variable bytes
257
        // 3. a static 2-byte sequence (\x00\x2C)
258
        $count = preg_match_all('#\x00\x21\xF9\x04.{4}\x00[\x2C\x21]#s', (string) $asset['content']);
1✔
259

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

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

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

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

288
        return true;
1✔
289
    }
290

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

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

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