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

Cecilapp / Cecil / 26379391043

25 May 2026 02:01AM UTC coverage: 82.3%. First build
26379391043

Pull #2380

github

web-flow
Merge be2269850 into 7f418d6c8
Pull Request #2380: Deduplicate dark-variant image `<source>` generation across Parsedown and Twig renderer

73 of 83 new or added lines in 3 files covered. (87.95%)

3492 of 4243 relevant lines covered (82.3%)

0.83 hits per line

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

72.38
/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\Builder;
18
use Cecil\Exception\RuntimeException;
19
use Cecil\Url;
20
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
21
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
22
use Intervention\Image\Drivers\Vips\Driver as VipsDriver;
23
use Intervention\Image\Encoders\AutoEncoder;
24
use Intervention\Image\ImageManager;
25

26
/**
27
 * Image Asset class.
28
 *
29
 * Provides methods to manipulate images, such as resizing, cropping, converting,
30
 * and generating data URLs.
31
 *
32
 * This class uses the Intervention Image library to handle image processing.
33
 * It supports GD, Imagick and libvips drivers, depending on available extensions.
34
 */
35
class Image
36
{
37
    /**
38
     * Create new manager instance with available driver.
39
     */
40
    private static function manager(): ImageManager
41
    {
42
        $driver = null;
1✔
43
        // Use GD first to keep driver capabilities aligned with GD-based format checks in convert().
44
        if (\extension_loaded('gd') && \function_exists('gd_info')) {
1✔
45
            $driver = GdDriver::class;
1✔
46
        } elseif (\extension_loaded('imagick') && class_exists('Imagick')) {
×
47
            // ImageMagick fallback.
48
            $driver = ImagickDriver::class;
×
49
        } elseif (\extension_loaded('vips') && class_exists('Jcupitt\Vips\Config') && class_exists(VipsDriver::class)) {
×
50
            // libvips fallback.
51
            $driver = VipsDriver::class;
×
52
        }
53

54
        if ($driver) {
1✔
55
            return ImageManager::withDriver(
1✔
56
                $driver,
1✔
57
                [
1✔
58
                    'autoOrientation' => true,
1✔
59
                    'decodeAnimation' => true,
1✔
60
                    'blendingColor' => 'ffffff',
1✔
61
                    'strip' => true, // remove metadata
1✔
62
                ]
1✔
63
            );
1✔
64
        }
65

66
        throw new RuntimeException('PHP GD or Imagick extension is required, or Vips support via ext-vips/jcupitt-vips and intervention/image-driver-vips.');
×
67
    }
68

69
    /**
70
     * Resizes an image Asset to the given width or/and height.
71
     *
72
     * If both width and height are provided, the image is cropped to fit the dimensions.
73
     * If only one dimension is provided, the image is scaled proportionally.
74
     * The $rmAnimation parameter can be set to true to remove animations from animated images (e.g., GIFs).
75
     *
76
     * @throws RuntimeException
77
     */
78
    public static function resize(Asset $asset, ?int $width = null, ?int $height = null, int $quality = 75, bool $rmAnimation = false): string
79
    {
80
        try {
81
            $image = self::manager()->read($asset['content']);
1✔
82

83
            if ($rmAnimation && $image->isAnimated()) {
1✔
84
                $image = $image->removeAnimation('25%'); // use 25% to avoid an "empty" frame
×
85
            }
86

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

101
            return (string) $image->encodeByMediaType(
1✔
102
                $asset['subtype'],
1✔
103
                /** @scrutinizer ignore-type */
104
                progressive: true,
1✔
105
                /** @scrutinizer ignore-type */
106
                interlaced: false,
1✔
107
                quality: $quality
1✔
108
            );
1✔
109
        } catch (\Exception $e) {
×
110
            throw new RuntimeException(\sprintf('Asset "%s" can\'t be resized: %s.', $asset['path'], $e->getMessage()));
×
111
        }
112
    }
113

114
    /**
115
     * Makes an image Asset maskable, meaning it can be used as a PWA icon.
116
     *
117
     * @throws RuntimeException
118
     */
119
    public static function maskable(Asset $asset, int $quality, int $padding): string
120
    {
121
        try {
122
            $source = self::manager()->read($asset['content']);
×
123

124
            // creates a new image with the dominant color as background
125
            // and the size of the original image plus the padding
126
            $image = self::manager()->create(
×
127
                width: (int) round($asset['width'] * (1 + $padding / 100), 0),
×
128
                height: (int) round($asset['height'] * (1 + $padding / 100), 0)
×
129
            )->fill(self::getBackgroundColor($asset));
×
130
            // inserts the original image in the center
131
            $image->place($source, position: 'center');
×
132

133
            $image->scaleDown(width: $asset['width']);
×
134

135
            return (string) $image->encodeByMediaType(
×
136
                $asset['subtype'],
×
137
                /** @scrutinizer ignore-type */
138
                progressive: true,
×
139
                /** @scrutinizer ignore-type */
140
                interlaced: false,
×
141
                quality: $quality
×
142
            );
×
143
        } catch (\Exception $e) {
×
144
            throw new RuntimeException(\sprintf('Unable to make Asset "%s" maskable: %s.', $asset['path'], $e->getMessage()));
×
145
        }
146
    }
147

148
    /**
149
     * Converts an image Asset to the target format.
150
     *
151
     * @throws RuntimeException
152
     */
153
    public static function convert(Asset $asset, string $format, int $quality): string
154
    {
155
        try {
156
            if (!\function_exists("image$format")) {
1✔
157
                throw new RuntimeException(\sprintf('Function "image%s" is not available.', $format));
×
158
            }
159

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

162
            return (string) $image->encodeByExtension(
1✔
163
                $format,
1✔
164
                /** @scrutinizer ignore-type */
165
                progressive: true,
1✔
166
                /** @scrutinizer ignore-type */
167
                interlaced: false,
1✔
168
                quality: $quality
1✔
169
            );
1✔
170
        } catch (\Exception $e) {
×
171
            throw new RuntimeException(\sprintf('Unable to convert "%s" to %s: %s.', $asset['path'], $format, $e->getMessage()));
×
172
        }
173
    }
174

175
    /**
176
     * Returns the Data URL (encoded in Base64).
177
     *
178
     * @throws RuntimeException
179
     */
180
    public static function getDataUrl(Asset $asset, int $quality): string
181
    {
182
        try {
183
            $image = self::manager()->read($asset['content']);
1✔
184

185
            return (string) $image->encode(new AutoEncoder(quality: $quality))->toDataUri();
1✔
186
        } catch (\Exception $e) {
×
187
            throw new RuntimeException(\sprintf('Unable to get Data URL of "%s": %s.', $asset['path'], $e->getMessage()));
×
188
        }
189
    }
190

191
    /**
192
     * Returns the dominant RGB color of an image asset.
193
     *
194
     * @throws RuntimeException
195
     */
196
    public static function getDominantColor(Asset $asset): string
197
    {
198
        try {
199
            $image = self::manager()->read(self::resize($asset, 100, 50));
1✔
200

201
            return $image->reduceColors(1)->pickColor(0, 0)->toString();
1✔
202
        } catch (\Exception $e) {
×
203
            throw new RuntimeException(\sprintf('Unable to get dominant color of "%s": %s.', $asset['path'], $e->getMessage()));
×
204
        }
205
    }
206

207
    /**
208
     * Returns the background RGB color of an image asset.
209
     *
210
     * @throws RuntimeException
211
     */
212
    public static function getBackgroundColor(Asset $asset): string
213
    {
214
        try {
215
            $image = self::manager()->read(self::resize($asset, 100, 50));
×
216

217
            return $image->pickColor(0, 0)->toString();
×
218
        } catch (\Exception $e) {
×
219
            throw new RuntimeException(\sprintf('Unable to get background color of "%s": %s.', $asset['path'], $e->getMessage()));
×
220
        }
221
    }
222

223
    /**
224
     * Returns a Low Quality Image Placeholder (LQIP) as data URL.
225
     *
226
     * @throws RuntimeException
227
     */
228
    public static function getLqip(Asset $asset): string
229
    {
230
        try {
231
            $image = self::manager()->read(self::resize($asset, 100, 50));
1✔
232

233
            return (string) $image->blur(50)->encode()->toDataUri();
1✔
234
        } catch (\Exception $e) {
×
235
            throw new RuntimeException(\sprintf('Unable to create LQIP of "%s": %s.', $asset['path'], $e->getMessage()));
×
236
        }
237
    }
238

239
    /**
240
     * Builds the asset path for a dark color-scheme image variant.
241
     */
242
    public static function buildDarkAssetPath(string $assetPath, string $darkSuffix): string
243
    {
244
        $pathInfo = pathinfo($assetPath);
1✔
245
        $extension = empty($pathInfo['extension']) ? '' : '.' . $pathInfo['extension'];
1✔
246

247
        return rtrim($pathInfo['dirname'], '/') . '/' . $pathInfo['filename'] . $darkSuffix . $extension;
1✔
248
    }
249

250
    /**
251
     * Builds dark color-scheme source attributes for an image.
252
     *
253
     * @param array<string> $formats
254
     * @param array<int>    $widths
255
     * @param array<float>  $densities
256
     * @param array<mixed>  $assetOptions
257
     *
258
     * @return array<array<string, string>>
259
     */
260
    public static function buildDarkSourceAttributes(
261
        Builder $builder,
262
        Asset $asset,
263
        string $darkSuffix,
264
        array $formats,
265
        mixed $responsive,
266
        array $widths = [],
267
        array $densities = [],
268
        ?string $sizes = null,
269
        ?int $width1x = null,
270
        array $assetOptions = [],
271
        bool $fallbackAsUrl = false
272
    ): array {
273
        if (empty($darkSuffix)) {
1✔
NEW
274
            return [];
×
275
        }
276
        $darkAssetPath = self::buildDarkAssetPath($asset['path'], $darkSuffix);
1✔
277
        $assetDark = new Asset($builder, $darkAssetPath, array_merge(['ignore_missing' => true], $assetOptions));
1✔
278
        if ($assetDark->isMissing()) {
1✔
279
            $builder->getLogger()->warning(\sprintf(
1✔
280
                'Dark variant "%s" not found for image "%s".',
1✔
281
                $darkAssetPath,
1✔
282
                $asset['path']
1✔
283
            ));
1✔
284

285
            return [];
1✔
286
        }
287
        $darkSources = [];
1✔
288
        foreach ($formats as $format) {
1✔
289
            try {
290
                $assetDarkConverted = $assetDark->convert($format);
1✔
291
                if ($responsive === true || $responsive === 'width') {
1✔
292
                    $darkSrcset = !empty($widths) ? self::buildHtmlSrcsetW($assetDarkConverted, $widths) : '';
1✔
NEW
293
                } elseif ($responsive === 'density') {
×
NEW
294
                    $darkSrcset = !empty($densities)
×
NEW
295
                        ? self::buildHtmlSrcsetX($assetDarkConverted, $width1x ?? $assetDark['width'], $densities)
×
NEW
296
                        : '';
×
297
                } else {
NEW
298
                    $darkSrcset = '';
×
299
                }
300
                $darkSourceAttributes = [
1✔
301
                    'media'  => '(prefers-color-scheme: dark)',
1✔
302
                    'type'   => "image/$format",
1✔
303
                    'srcset' => empty($darkSrcset) ? (string) $assetDarkConverted : $darkSrcset,
1✔
304
                ];
1✔
305
                if (!empty($sizes)) {
1✔
306
                    $darkSourceAttributes['sizes'] = $sizes;
1✔
307
                }
308
                $darkSources[] = $darkSourceAttributes;
1✔
NEW
309
            } catch (\Exception $e) {
×
NEW
310
                $builder->getLogger()->warning($e->getMessage());
×
311
            }
312
        }
313
        $darkFallbackSrcset = $fallbackAsUrl ? (string) new Url($builder, $assetDark) : (string) $assetDark;
1✔
314
        if (($responsive === true || $responsive === 'width') && !empty($widths)) {
1✔
315
            try {
316
                $darkResponsiveSrcset = self::buildHtmlSrcsetW($assetDark, $widths);
1✔
317
                if (!empty($darkResponsiveSrcset)) {
1✔
318
                    $darkFallbackSrcset = $darkResponsiveSrcset;
1✔
319
                }
NEW
320
            } catch (\Exception $e) {
×
NEW
321
                $builder->getLogger()->warning($e->getMessage());
×
322
            }
323
        }
324
        $darkFallbackSourceAttributes = [
1✔
325
            'media'  => '(prefers-color-scheme: dark)',
1✔
326
            'srcset' => $darkFallbackSrcset,
1✔
327
        ];
1✔
328
        if (!empty($sizes)) {
1✔
329
            $darkFallbackSourceAttributes['sizes'] = $sizes;
1✔
330
        }
331
        $darkSources[] = $darkFallbackSourceAttributes;
1✔
332

333
        return $darkSources;
1✔
334
    }
335

336
    /**
337
     * Build the `srcset` HTML attribute for responsive images, based on widths.
338
     * e.g.: `srcset="/img-480.jpg 480w, /img-800.jpg 800w"`.
339
     *
340
     * @param array $widths   An array of widths to include in the `srcset`
341
     * @param bool  $notEmpty If true the source image is always added to the `srcset`
342
     *
343
     * @throws RuntimeException
344
     */
345
    public static function buildHtmlSrcsetW(Asset $asset, array $widths, $notEmpty = false): string
346
    {
347
        if (!self::isImage($asset)) {
1✔
348
            throw new RuntimeException(\sprintf('Unable to build "srcset" of "%s": it\'s not an image file.', $asset['path']));
1✔
349
        }
350

351
        $srcset = [];
1✔
352
        $widthMax = 0;
1✔
353
        sort($widths, SORT_NUMERIC);
1✔
354
        $widths = array_reverse($widths);
1✔
355
        foreach ($widths as $width) {
1✔
356
            if ($asset['width'] < $width) {
1✔
357
                continue;
1✔
358
            }
359
            $img = $asset->resize($width);
1✔
360
            array_unshift($srcset, \sprintf('%s %sw', (string) $img, $width));
1✔
361
            $widthMax = $width;
1✔
362
        }
363
        // adds source image
364
        if ((!empty($srcset) || $notEmpty) && ($asset['width'] < max($widths) && $asset['width'] != $widthMax)) {
1✔
365
            $srcset[] = \sprintf('%s %sw', (string) $asset, $asset['width']);
1✔
366
        }
367

368
        return implode(', ', $srcset);
1✔
369
    }
370

371
    /**
372
     * Alias of buildHtmlSrcsetW for backward compatibility.
373
     */
374
    public static function buildHtmlSrcset(Asset $asset, array $widths, $notEmpty = false): string
375
    {
376
        return self::buildHtmlSrcsetW($asset, $widths, $notEmpty);
1✔
377
    }
378

379
    /**
380
     * Build the `srcset` HTML attribute for responsive images, based on pixel ratios.
381
     * e.g.: `srcset="/img-1x.jpg 1.0x, /img-2x.jpg 2.0x"`.
382
     *
383
     * @param int   $width1x  The width of the 1x image
384
     * @param array $ratios   An array of pixel ratios to include in the `srcset`
385
     *
386
     * @throws RuntimeException
387
     */
388
    public static function buildHtmlSrcsetX(Asset $asset, int $width1x, array $ratios): string
389
    {
390
        if (!self::isImage($asset)) {
1✔
391
            throw new RuntimeException(\sprintf('Unable to build "srcset" of "%s": it\'s not an image file.', $asset['path']));
×
392
        }
393

394
        $srcset = [];
1✔
395
        sort($ratios, SORT_NUMERIC);
1✔
396
        $ratios = array_reverse($ratios);
1✔
397
        foreach ($ratios as $ratio) {
1✔
398
            if ($ratio <= 1) {
1✔
399
                continue;
1✔
400
            }
401
            $width = (int) round($width1x * $ratio, 0);
1✔
402
            if ($asset['width'] < $width) {
1✔
403
                continue;
1✔
404
            }
405
            $img = $asset->resize($width);
1✔
406
            array_unshift($srcset, \sprintf('%s %dx', (string) $img, $ratio));
1✔
407
        }
408
        // adds 1x image
409
        array_unshift($srcset, \sprintf('%s 1x', (string) $asset->resize($width1x)));
1✔
410

411
        return implode(', ', $srcset);
1✔
412
    }
413

414
    /**
415
     * Returns the value from the `$sizes` array if the class exists, otherwise returns the default size.
416
     */
417
    public static function getHtmlSizes(string $class, array $sizes = []): string
418
    {
419
        $result = '';
1✔
420
        $classArray = explode(' ', $class);
1✔
421
        foreach ($classArray as $class) {
1✔
422
            if (\array_key_exists($class, $sizes)) {
1✔
423
                $result = $sizes[$class] . ', ';
1✔
424
            }
425
        }
426
        if (!empty($result)) {
1✔
427
            return trim($result, ', ');
1✔
428
        }
429

430
        return $sizes['default'] ?? '100vw';
1✔
431
    }
432

433
    /**
434
     * Checks if an asset is an animated GIF.
435
     */
436
    public static function isAnimatedGif(Asset $asset): bool
437
    {
438
        // an animated GIF contains multiple "frames", with each frame having a header made up of:
439
        // 1. a static 4-byte sequence (\x00\x21\xF9\x04)
440
        // 2. 4 variable bytes
441
        // 3. a static 2-byte sequence (\x00\x2C)
442
        $count = preg_match_all('#\x00\x21\xF9\x04.{4}\x00[\x2C\x21]#s', (string) $asset['content']);
1✔
443

444
        return $count > 1;
1✔
445
    }
446

447
    /**
448
     * Returns true if asset is a SVG.
449
     */
450
    public static function isSVG(Asset $asset): bool
451
    {
452
        return \in_array($asset['subtype'], ['image/svg', 'image/svg+xml']) || $asset['ext'] == 'svg';
1✔
453
    }
454

455
    /**
456
     * Returns true if asset is an ICO.
457
     */
458
    public static function isIco(Asset $asset): bool
459
    {
460
        return \in_array($asset['subtype'], ['image/x-icon', 'image/vnd.microsoft.icon']) || $asset['ext'] == 'ico';
1✔
461
    }
462

463
    /**
464
     * Asset is a valid image?
465
     */
466
    public static function isImage(Asset $asset): bool
467
    {
468
        if ($asset['type'] !== 'image' || self::isSVG($asset) || self::isIco($asset)) {
1✔
469
            return false;
1✔
470
        }
471

472
        return true;
1✔
473
    }
474

475
    /**
476
     * Returns SVG attributes.
477
     *
478
     * @return \SimpleXMLElement|false
479
     */
480
    public static function getSvgAttributes(Asset $asset)
481
    {
482
        if (!self::isSVG($asset)) {
1✔
483
            return false;
×
484
        }
485

486
        if (false === $xml = simplexml_load_string($asset['content'] ?? '')) {
1✔
487
            return false;
×
488
        }
489

490
        return $xml->attributes();
1✔
491
    }
492
}
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