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

Cecilapp / Cecil / 26393676511

25 May 2026 09:32AM UTC coverage: 82.342%. First build
26393676511

Pull #2380

github

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

83 of 93 new or added lines in 3 files covered. (89.25%)

3502 of 4253 relevant lines covered (82.34%)

0.83 hits per line

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

73.4
/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{
255
     *   responsive?: mixed,
256
     *   widths?: array<int>,
257
     *   densities?: array<float|int>,
258
     *   sizes?: ?string,
259
     *   width1x?: ?int,
260
     *   assetOptions?: array<mixed>,
261
     *   fallbackAsUrl?: bool
262
     * } $options
263
     *
264
     * @return array<array<string, string>>
265
     */
266
    public static function buildDarkSourceAttributes(
267
        Builder $builder,
268
        Asset $asset,
269
        string $darkSuffix,
270
        array $formats,
271
        array $options = []
272
    ): array {
273
        if (empty($darkSuffix)) {
1✔
NEW
274
            return [];
×
275
        }
276

277
        $responsive = $options['responsive'] ?? false;
1✔
278
        $widths = $options['widths'] ?? [];
1✔
279
        $densities = $options['densities'] ?? [];
1✔
280
        $sizes = $options['sizes'] ?? null;
1✔
281
        $width1x = $options['width1x'] ?? null;
1✔
282
        $assetOptions = $options['assetOptions'] ?? [];
1✔
283
        $fallbackAsUrl = (bool) ($options['fallbackAsUrl'] ?? false);
1✔
284

285
        $darkAssetPath = self::buildDarkAssetPath($asset['path'], $darkSuffix);
1✔
286
        $assetDark = new Asset($builder, $darkAssetPath, array_merge(['ignore_missing' => true], $assetOptions));
1✔
287
        if ($assetDark->isMissing()) {
1✔
288
            $builder->getLogger()->warning(\sprintf(
1✔
289
                'Dark variant "%s" not found for image "%s".',
1✔
290
                $darkAssetPath,
1✔
291
                $asset['path']
1✔
292
            ));
1✔
293

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

342
        return $darkSources;
1✔
343
    }
344

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

360
        $srcset = [];
1✔
361
        $widthMax = 0;
1✔
362
        sort($widths, SORT_NUMERIC);
1✔
363
        $widths = array_reverse($widths);
1✔
364
        foreach ($widths as $width) {
1✔
365
            if ($asset['width'] < $width) {
1✔
366
                continue;
1✔
367
            }
368
            $img = $asset->resize($width);
1✔
369
            array_unshift($srcset, \sprintf('%s %sw', (string) $img, $width));
1✔
370
            $widthMax = $width;
1✔
371
        }
372
        // adds source image
373
        if ((!empty($srcset) || $notEmpty) && ($asset['width'] < max($widths) && $asset['width'] != $widthMax)) {
1✔
374
            $srcset[] = \sprintf('%s %sw', (string) $asset, $asset['width']);
1✔
375
        }
376

377
        return implode(', ', $srcset);
1✔
378
    }
379

380
    /**
381
     * Alias of buildHtmlSrcsetW for backward compatibility.
382
     */
383
    public static function buildHtmlSrcset(Asset $asset, array $widths, $notEmpty = false): string
384
    {
385
        return self::buildHtmlSrcsetW($asset, $widths, $notEmpty);
1✔
386
    }
387

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

403
        $srcset = [];
1✔
404
        sort($ratios, SORT_NUMERIC);
1✔
405
        $ratios = array_reverse($ratios);
1✔
406
        foreach ($ratios as $ratio) {
1✔
407
            if ($ratio <= 1) {
1✔
408
                continue;
1✔
409
            }
410
            $width = (int) round($width1x * $ratio, 0);
1✔
411
            if ($asset['width'] < $width) {
1✔
412
                continue;
1✔
413
            }
414
            $img = $asset->resize($width);
1✔
415
            array_unshift($srcset, \sprintf('%s %dx', (string) $img, $ratio));
1✔
416
        }
417
        // adds 1x image
418
        array_unshift($srcset, \sprintf('%s 1x', (string) $asset->resize($width1x)));
1✔
419

420
        return implode(', ', $srcset);
1✔
421
    }
422

423
    /**
424
     * Returns the value from the `$sizes` array if the class exists, otherwise returns the default size.
425
     */
426
    public static function getHtmlSizes(string $class, array $sizes = []): string
427
    {
428
        $result = '';
1✔
429
        $classArray = explode(' ', $class);
1✔
430
        foreach ($classArray as $class) {
1✔
431
            if (\array_key_exists($class, $sizes)) {
1✔
432
                $result = $sizes[$class] . ', ';
1✔
433
            }
434
        }
435
        if (!empty($result)) {
1✔
436
            return trim($result, ', ');
1✔
437
        }
438

439
        return $sizes['default'] ?? '100vw';
1✔
440
    }
441

442
    /**
443
     * Checks if an asset is an animated GIF.
444
     */
445
    public static function isAnimatedGif(Asset $asset): bool
446
    {
447
        // an animated GIF contains multiple "frames", with each frame having a header made up of:
448
        // 1. a static 4-byte sequence (\x00\x21\xF9\x04)
449
        // 2. 4 variable bytes
450
        // 3. a static 2-byte sequence (\x00\x2C)
451
        $count = preg_match_all('#\x00\x21\xF9\x04.{4}\x00[\x2C\x21]#s', (string) $asset['content']);
1✔
452

453
        return $count > 1;
1✔
454
    }
455

456
    /**
457
     * Returns true if asset is a SVG.
458
     */
459
    public static function isSVG(Asset $asset): bool
460
    {
461
        return \in_array($asset['subtype'], ['image/svg', 'image/svg+xml']) || $asset['ext'] == 'svg';
1✔
462
    }
463

464
    /**
465
     * Returns true if asset is an ICO.
466
     */
467
    public static function isIco(Asset $asset): bool
468
    {
469
        return \in_array($asset['subtype'], ['image/x-icon', 'image/vnd.microsoft.icon']) || $asset['ext'] == 'ico';
1✔
470
    }
471

472
    /**
473
     * Asset is a valid image?
474
     */
475
    public static function isImage(Asset $asset): bool
476
    {
477
        if ($asset['type'] !== 'image' || self::isSVG($asset) || self::isIco($asset)) {
1✔
478
            return false;
1✔
479
        }
480

481
        return true;
1✔
482
    }
483

484
    /**
485
     * Returns SVG attributes.
486
     *
487
     * @return \SimpleXMLElement|false
488
     */
489
    public static function getSvgAttributes(Asset $asset)
490
    {
491
        if (!self::isSVG($asset)) {
1✔
492
            return false;
×
493
        }
494

495
        if (false === $xml = simplexml_load_string($asset['content'] ?? '')) {
1✔
496
            return false;
×
497
        }
498

499
        return $xml->attributes();
1✔
500
    }
501
}
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