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

Cecilapp / Cecil / 16981867923

15 Aug 2025 03:02AM UTC coverage: 80.769% (-1.0%) from 81.783%
16981867923

push

github

ArnaudLigny
refactor: improve OG and Twitter image handling

Update logic to better handle Open Graph image dimensions, allowing square images or 600x315 px, and adjust Twitter card type based on image aspect ratio. This enhances compatibility and provides clearer deprecation warnings for unsupported image sizes.

3108 of 3848 relevant lines covered (80.77%)

0.81 hits per line

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

59.32
/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✔
82
        } catch (\Exception $e) {
×
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']);
×
97
            // turns an animated image (i.e GIF) into a static image
98
            if ($image->isAnimated()) {
×
99
                $image = $image->removeAnimation('25%'); // use 25% to avoid an "empty" frame
×
100
            }
101
            // crops the image
102
            $image->cover(
×
103
                width: $width,
×
104
                height: $height,
×
105
                position: 'center'
×
106
            );
×
107
            // return image data
108
            return (string) $image->encodeByMediaType(
×
109
                $asset['subtype'],
×
110
                /** @scrutinizer ignore-type */
111
                progressive: true,
×
112
                /** @scrutinizer ignore-type */
113
                interlaced: false,
×
114
                quality: $quality
×
115
            );
×
116
        } catch (\Exception $e) {
×
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(
×
134
                width: (int) round($asset['width'] * (1 + $padding / 100), 0),
×
135
                height: (int) round($asset['height'] * (1 + $padding / 100), 0)
×
136
            )->fill(self::getDominantColor($asset));
×
137
            // inserts the original image in the center
138
            $image->place(
×
139
                $source,
×
140
                position: 'center'
×
141
            );
×
142
            // scales down the new image to the original image size
143
            $image->scaleDown(width: $asset['width']);
×
144
            // return image data
145
            return (string) $image->encodeByMediaType(
×
146
                $asset['subtype'],
×
147
                /** @scrutinizer ignore-type */
148
                progressive: true,
×
149
                /** @scrutinizer ignore-type */
150
                interlaced: false,
×
151
                quality: $quality
×
152
            );
×
153
        } catch (\Exception $e) {
×
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✔
180
        } catch (\Exception $e) {
×
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✔
196
        } catch (\Exception $e) {
×
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✔
212
        } catch (\Exception $e) {
×
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✔
228
        } catch (\Exception $e) {
×
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✔
334
            return false;
×
335
        }
336

337
        if (false === $xml = simplexml_load_string($asset['content'] ?? '')) {
1✔
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