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

Cecilapp / Cecil / 28904473978

07 Jul 2026 10:57PM UTC coverage: 82.356%. First build
28904473978

Pull #2433

github

web-flow
Merge 2561d4000 into 83e885979
Pull Request #2433: feat: add mobile image variant sources to pictures

125 of 149 new or added lines in 3 files covered. (83.89%)

3776 of 4585 relevant lines covered (82.36%)

0.83 hits per line

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

75.71
/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 the asset path for a mobile image variant.
252
     */
253
    public static function buildMobileAssetPath(string $assetPath, string $mobileSuffix): string
254
    {
255
        if (!str_starts_with($mobileSuffix, '.')) {
1✔
256
            $mobileSuffix = ".{$mobileSuffix}";
1✔
257
        }
258

259
        $pathInfo = pathinfo($assetPath);
1✔
260
        $extension = empty($pathInfo['extension']) ? '' : '.' . $pathInfo['extension'];
1✔
261

262
        return rtrim($pathInfo['dirname'], '/') . '/' . $pathInfo['filename'] . $mobileSuffix . $extension;
1✔
263
    }
264

265
    /**
266
     * Builds dark color-scheme source attributes for an image.
267
     *
268
     * @param array<string> $formats
269
     * @param array{
270
     *   responsive?: mixed,
271
     *   widths?: array<int>,
272
     *   densities?: array<float|int>,
273
     *   sizes?: ?string,
274
     *   width1x?: ?int,
275
     *   assetOptions?: array<mixed>,
276
     *   fallbackAsUrl?: bool
277
     * } $options
278
     *
279
     * @return array<array<string, string>>
280
     */
281
    public static function buildDarkSourceAttributes(
282
        Builder $builder,
283
        Asset $asset,
284
        string $darkSuffix,
285
        array $formats,
286
        array $options = []
287
    ): array {
288
        if (empty($darkSuffix)) {
1✔
289
            return [];
×
290
        }
291

292
        $responsive = $options['responsive'] ?? false;
1✔
293
        $widths = $options['widths'] ?? [];
1✔
294
        $densities = $options['densities'] ?? [];
1✔
295
        $sizes = $options['sizes'] ?? null;
1✔
296
        $width1x = $options['width1x'] ?? null;
1✔
297
        $assetOptions = $options['assetOptions'] ?? [];
1✔
298
        $fallbackAsUrl = (bool) ($options['fallbackAsUrl'] ?? false);
1✔
299

300
        $darkAssetPath = self::buildDarkAssetPath($asset['_path'], $darkSuffix);
1✔
301
        $assetDark = new Asset($builder, $darkAssetPath, array_merge(['ignore_missing' => true], $assetOptions));
1✔
302
        if ($assetDark->isMissing()) {
1✔
303
            $builder->getLogger()->warning(\sprintf(
1✔
304
                'Dark variant "%s" not found for image "%s".',
1✔
305
                $darkAssetPath,
1✔
306
                $asset['_path']
1✔
307
            ));
1✔
308

309
            return [];
1✔
310
        }
311
        $darkSources = [];
1✔
312
        foreach ($formats as $format) {
1✔
313
            try {
314
                $assetDarkConverted = $assetDark->convert($format);
1✔
315
                if ($responsive === true || $responsive === 'width') {
1✔
316
                    $darkSrcset = !empty($widths) ? self::buildHtmlSrcsetW($assetDarkConverted, $widths) : '';
1✔
317
                } elseif ($responsive === 'density') {
×
318
                    $darkSrcset = !empty($densities)
×
319
                        ? self::buildHtmlSrcsetX($assetDarkConverted, $width1x ?? $assetDark['width'], $densities)
×
320
                        : '';
×
321
                } else {
322
                    $darkSrcset = '';
×
323
                }
324
                $darkSourceAttributes = [
1✔
325
                    'media'  => '(prefers-color-scheme: dark)',
1✔
326
                    'type'   => "image/$format",
1✔
327
                    'srcset' => empty($darkSrcset) ? (string) $assetDarkConverted : $darkSrcset,
1✔
328
                ];
1✔
329
                if (!empty($sizes)) {
1✔
330
                    $darkSourceAttributes['sizes'] = $sizes;
1✔
331
                }
332
                $darkSources[] = $darkSourceAttributes;
1✔
333
            } catch (\Exception $e) {
×
334
                $builder->getLogger()->warning($e->getMessage());
×
335
            }
336
        }
337
        $darkFallbackSrcset = $fallbackAsUrl ? (string) new Url($builder, $assetDark) : (string) $assetDark;
1✔
338
        if (($responsive === true || $responsive === 'width') && !empty($widths)) {
1✔
339
            try {
340
                $darkResponsiveSrcset = self::buildHtmlSrcsetW($assetDark, $widths);
1✔
341
                if (!empty($darkResponsiveSrcset)) {
1✔
342
                    $darkFallbackSrcset = $darkResponsiveSrcset;
1✔
343
                }
344
            } catch (\Exception $e) {
×
345
                $builder->getLogger()->warning($e->getMessage());
×
346
            }
347
        }
348
        $darkFallbackSourceAttributes = [
1✔
349
            'media'  => '(prefers-color-scheme: dark)',
1✔
350
            'srcset' => $darkFallbackSrcset,
1✔
351
        ];
1✔
352
        if (!empty($sizes)) {
1✔
353
            $darkFallbackSourceAttributes['sizes'] = $sizes;
1✔
354
        }
355
        $darkSources[] = $darkFallbackSourceAttributes;
1✔
356

357
        return $darkSources;
1✔
358
    }
359

360
    /**
361
     * Builds mobile source attributes for an image.
362
     *
363
     * @param array<string> $formats
364
     * @param array{
365
     *   responsive?: mixed,
366
     *   widths?: array<int>,
367
     *   densities?: array<float|int>,
368
     *   sizes?: ?string,
369
     *   width1x?: ?int,
370
     *   assetOptions?: array<mixed>,
371
     *   fallbackAsUrl?: bool,
372
     *   media?: string
373
     * } $options
374
     *
375
     * @return array<array<string, string>>
376
     */
377
    public static function buildMobileSourceAttributes(
378
        Builder $builder,
379
        Asset $asset,
380
        string $mobileSuffix,
381
        array $formats,
382
        array $options = []
383
    ): array {
384
        if (empty($mobileSuffix)) {
1✔
NEW
385
            return [];
×
386
        }
387

388
        $responsive = $options['responsive'] ?? false;
1✔
389
        $widths = $options['widths'] ?? [];
1✔
390
        $densities = $options['densities'] ?? [];
1✔
391
        $sizes = $options['sizes'] ?? null;
1✔
392
        $width1x = $options['width1x'] ?? null;
1✔
393
        $assetOptions = $options['assetOptions'] ?? [];
1✔
394
        $fallbackAsUrl = (bool) ($options['fallbackAsUrl'] ?? false);
1✔
395
        $media = (string) ($options['media'] ?? '(max-width: 767px)');
1✔
396

397
        $mobileAssetPath = self::buildMobileAssetPath($asset['_path'], $mobileSuffix);
1✔
398
        $assetMobile = new Asset($builder, $mobileAssetPath, array_merge(['ignore_missing' => true], $assetOptions));
1✔
399
        if ($assetMobile->isMissing()) {
1✔
400
            $builder->getLogger()->warning(\sprintf(
1✔
401
                'Mobile variant "%s" not found for image "%s".',
1✔
402
                $mobileAssetPath,
1✔
403
                $asset['_path']
1✔
404
            ));
1✔
405

406
            return [];
1✔
407
        }
408

409
        $mobileSources = [];
1✔
410
        foreach ($formats as $format) {
1✔
411
            try {
412
                $assetMobileConverted = $assetMobile->convert($format);
1✔
413
                if ($responsive === true || $responsive === 'width') {
1✔
414
                    $mobileSrcset = !empty($widths) ? self::buildHtmlSrcsetW($assetMobileConverted, $widths) : '';
1✔
NEW
415
                } elseif ($responsive === 'density') {
×
NEW
416
                    $mobileSrcset = !empty($densities)
×
NEW
417
                        ? self::buildHtmlSrcsetX($assetMobileConverted, $width1x ?? $assetMobile['width'], $densities)
×
NEW
418
                        : '';
×
419
                } else {
NEW
420
                    $mobileSrcset = '';
×
421
                }
422
                $mobileSourceAttributes = [
1✔
423
                    'media'  => $media,
1✔
424
                    'type'   => "image/$format",
1✔
425
                    'srcset' => empty($mobileSrcset) ? (string) $assetMobileConverted : $mobileSrcset,
1✔
426
                ];
1✔
427
                if (!empty($sizes)) {
1✔
428
                    $mobileSourceAttributes['sizes'] = $sizes;
1✔
429
                }
430
                $mobileSources[] = $mobileSourceAttributes;
1✔
NEW
431
            } catch (\Exception $e) {
×
NEW
432
                $builder->getLogger()->warning($e->getMessage());
×
433
            }
434
        }
435

436
        $mobileFallbackSrcset = $fallbackAsUrl ? (string) new Url($builder, $assetMobile) : (string) $assetMobile;
1✔
437
        if (($responsive === true || $responsive === 'width') && !empty($widths)) {
1✔
438
            try {
439
                $mobileResponsiveSrcset = self::buildHtmlSrcsetW($assetMobile, $widths);
1✔
440
                if (!empty($mobileResponsiveSrcset)) {
1✔
441
                    $mobileFallbackSrcset = $mobileResponsiveSrcset;
1✔
442
                }
NEW
443
            } catch (\Exception $e) {
×
NEW
444
                $builder->getLogger()->warning($e->getMessage());
×
445
            }
446
        }
447
        $mobileFallbackSourceAttributes = [
1✔
448
            'media'  => $media,
1✔
449
            'srcset' => $mobileFallbackSrcset,
1✔
450
        ];
1✔
451
        if (!empty($sizes)) {
1✔
452
            $mobileFallbackSourceAttributes['sizes'] = $sizes;
1✔
453
        }
454
        $mobileSources[] = $mobileFallbackSourceAttributes;
1✔
455

456
        return $mobileSources;
1✔
457
    }
458

459
    /**
460
     * Build the `srcset` HTML attribute for responsive images, based on widths.
461
     * e.g.: `srcset="/img-480.jpg 480w, /img-800.jpg 800w"`.
462
     *
463
     * @param array $widths   An array of widths to include in the `srcset`
464
     * @param bool  $notEmpty If true the source image is always added to the `srcset`
465
     *
466
     * @throws RuntimeException
467
     */
468
    public static function buildHtmlSrcsetW(Asset $asset, array $widths, $notEmpty = false): string
469
    {
470
        if (!self::isImage($asset)) {
1✔
471
            throw new RuntimeException(\sprintf('Unable to build "srcset" of "%s": it\'s not an image file.', $asset['path']));
1✔
472
        }
473

474
        $srcset = [];
1✔
475
        $widthMax = 0;
1✔
476
        sort($widths, SORT_NUMERIC);
1✔
477
        $widths = array_reverse($widths);
1✔
478
        foreach ($widths as $width) {
1✔
479
            if ($asset['width'] < $width) {
1✔
480
                continue;
1✔
481
            }
482
            $img = $asset->resize($width);
1✔
483
            array_unshift($srcset, \sprintf('%s %sw', (string) $img, $width));
1✔
484
            $widthMax = $width;
1✔
485
        }
486
        // adds source image
487
        if ((!empty($srcset) || $notEmpty) && ($asset['width'] < max($widths) && $asset['width'] != $widthMax)) {
1✔
488
            $srcset[] = \sprintf('%s %sw', (string) $asset, $asset['width']);
1✔
489
        }
490

491
        return implode(', ', $srcset);
1✔
492
    }
493

494
    /**
495
     * Alias of buildHtmlSrcsetW for backward compatibility.
496
     */
497
    public static function buildHtmlSrcset(Asset $asset, array $widths, $notEmpty = false): string
498
    {
499
        return self::buildHtmlSrcsetW($asset, $widths, $notEmpty);
1✔
500
    }
501

502
    /**
503
     * Build the `srcset` HTML attribute for responsive images, based on pixel ratios.
504
     * e.g.: `srcset="/img-1x.jpg 1.0x, /img-2x.jpg 2.0x"`.
505
     *
506
     * @param int   $width1x  The width of the 1x image
507
     * @param array $ratios   An array of pixel ratios to include in the `srcset`
508
     *
509
     * @throws RuntimeException
510
     */
511
    public static function buildHtmlSrcsetX(Asset $asset, int $width1x, array $ratios): string
512
    {
513
        if (!self::isImage($asset)) {
1✔
514
            throw new RuntimeException(\sprintf('Unable to build "srcset" of "%s": it\'s not an image file.', $asset['path']));
×
515
        }
516

517
        $srcset = [];
1✔
518
        sort($ratios, SORT_NUMERIC);
1✔
519
        $ratios = array_reverse($ratios);
1✔
520
        foreach ($ratios as $ratio) {
1✔
521
            if ($ratio <= 1) {
1✔
522
                continue;
1✔
523
            }
524
            $width = (int) round($width1x * $ratio, 0);
1✔
525
            if ($asset['width'] < $width) {
1✔
526
                continue;
1✔
527
            }
528
            $img = $asset->resize($width);
1✔
529
            array_unshift($srcset, \sprintf('%s %dx', (string) $img, $ratio));
1✔
530
        }
531
        // adds 1x image
532
        array_unshift($srcset, \sprintf('%s 1x', (string) $asset->resize($width1x)));
1✔
533

534
        return implode(', ', $srcset);
1✔
535
    }
536

537
    /**
538
     * Returns the value from the `$sizes` array if the class exists, otherwise returns the default size.
539
     */
540
    public static function getHtmlSizes(string $class, array $sizes = []): string
541
    {
542
        $result = '';
1✔
543
        $classArray = explode(' ', $class);
1✔
544
        foreach ($classArray as $class) {
1✔
545
            if (\array_key_exists($class, $sizes)) {
1✔
546
                $result = $sizes[$class] . ', ';
1✔
547
            }
548
        }
549
        if (!empty($result)) {
1✔
550
            return trim($result, ', ');
1✔
551
        }
552

553
        return $sizes['default'] ?? '100vw';
1✔
554
    }
555

556
    /**
557
     * Checks if an asset is an animated GIF.
558
     */
559
    public static function isAnimatedGif(Asset $asset): bool
560
    {
561
        // an animated GIF contains multiple "frames", with each frame having a header made up of:
562
        // 1. a static 4-byte sequence (\x00\x21\xF9\x04)
563
        // 2. 4 variable bytes
564
        // 3. a static 2-byte sequence (\x00\x2C)
565
        $count = preg_match_all('#\x00\x21\xF9\x04.{4}\x00[\x2C\x21]#s', (string) $asset['content']);
1✔
566

567
        return $count > 1;
1✔
568
    }
569

570
    /**
571
     * Returns true if asset is a SVG.
572
     */
573
    public static function isSVG(Asset $asset): bool
574
    {
575
        return \in_array($asset['subtype'], ['image/svg', 'image/svg+xml']) || $asset['ext'] == 'svg';
1✔
576
    }
577

578
    /**
579
     * Returns true if asset is an ICO.
580
     */
581
    public static function isIco(Asset $asset): bool
582
    {
583
        return \in_array($asset['subtype'], ['image/x-icon', 'image/vnd.microsoft.icon']) || $asset['ext'] == 'ico';
1✔
584
    }
585

586
    /**
587
     * Asset is a valid image?
588
     */
589
    public static function isImage(Asset $asset): bool
590
    {
591
        if ($asset['type'] !== 'image' || self::isSVG($asset) || self::isIco($asset)) {
1✔
592
            return false;
1✔
593
        }
594

595
        return true;
1✔
596
    }
597

598
    /**
599
     * Returns SVG attributes.
600
     *
601
     * @return \SimpleXMLElement|false
602
     */
603
    public static function getSvgAttributes(Asset $asset)
604
    {
605
        if (!self::isSVG($asset)) {
1✔
606
            return false;
×
607
        }
608

609
        if (false === $xml = simplexml_load_string($asset['content'] ?? '')) {
1✔
610
            return false;
×
611
        }
612

613
        return $xml->attributes();
1✔
614
    }
615
}
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