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

Cecilapp / Cecil / 28905531080

07 Jul 2026 11:20PM UTC coverage: 82.327%. First build
28905531080

Pull #2433

github

web-flow
Merge 0b76b1298 into 5c035cbc3
Pull Request #2433: feat: add mobile image variant sources to pictures

127 of 153 new or added lines in 3 files covered. (83.01%)

3778 of 4589 relevant lines covered (82.33%)

0.83 hits per line

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

75.3
/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 ($mobileSuffix === '') {
1✔
NEW
256
            return $assetPath;
×
257
        }
258
        if (!str_starts_with($mobileSuffix, '.')) {
1✔
259
            $mobileSuffix = ".{$mobileSuffix}";
1✔
260
        }
261

262
        $pathInfo = pathinfo($assetPath);
1✔
263
        $extension = empty($pathInfo['extension']) ? '' : '.' . $pathInfo['extension'];
1✔
264

265
        return rtrim($pathInfo['dirname'], '/') . '/' . $pathInfo['filename'] . $mobileSuffix . $extension;
1✔
266
    }
267

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

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

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

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

360
        return $darkSources;
1✔
361
    }
362

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

391
        $responsive = $options['responsive'] ?? false;
1✔
392
        $widths = $options['widths'] ?? [];
1✔
393
        $densities = $options['densities'] ?? [];
1✔
394
        $sizes = $options['sizes'] ?? null;
1✔
395
        $width1x = $options['width1x'] ?? null;
1✔
396
        $assetOptions = $options['assetOptions'] ?? [];
1✔
397
        $fallbackAsUrl = (bool) ($options['fallbackAsUrl'] ?? false);
1✔
398
        $media = (string) ($options['media'] ?? '(max-width: 767px)');
1✔
399
        if ($media === '') {
1✔
NEW
400
            $media = '(max-width: 767px)';
×
401
        }
402

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

412
            return [];
1✔
413
        }
414

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

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

462
        return $mobileSources;
1✔
463
    }
464

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

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

497
        return implode(', ', $srcset);
1✔
498
    }
499

500
    /**
501
     * Alias of buildHtmlSrcsetW for backward compatibility.
502
     */
503
    public static function buildHtmlSrcset(Asset $asset, array $widths, $notEmpty = false): string
504
    {
505
        return self::buildHtmlSrcsetW($asset, $widths, $notEmpty);
1✔
506
    }
507

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

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

540
        return implode(', ', $srcset);
1✔
541
    }
542

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

559
        return $sizes['default'] ?? '100vw';
1✔
560
    }
561

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

573
        return $count > 1;
1✔
574
    }
575

576
    /**
577
     * Returns true if asset is a SVG.
578
     */
579
    public static function isSVG(Asset $asset): bool
580
    {
581
        return \in_array($asset['subtype'], ['image/svg', 'image/svg+xml']) || $asset['ext'] == 'svg';
1✔
582
    }
583

584
    /**
585
     * Returns true if asset is an ICO.
586
     */
587
    public static function isIco(Asset $asset): bool
588
    {
589
        return \in_array($asset['subtype'], ['image/x-icon', 'image/vnd.microsoft.icon']) || $asset['ext'] == 'ico';
1✔
590
    }
591

592
    /**
593
     * Asset is a valid image?
594
     */
595
    public static function isImage(Asset $asset): bool
596
    {
597
        if ($asset['type'] !== 'image' || self::isSVG($asset) || self::isIco($asset)) {
1✔
598
            return false;
1✔
599
        }
600

601
        return true;
1✔
602
    }
603

604
    /**
605
     * Returns SVG attributes.
606
     *
607
     * @return \SimpleXMLElement|false
608
     */
609
    public static function getSvgAttributes(Asset $asset)
610
    {
611
        if (!self::isSVG($asset)) {
1✔
612
            return false;
×
613
        }
614

615
        if (false === $xml = simplexml_load_string($asset['content'] ?? '')) {
1✔
616
            return false;
×
617
        }
618

619
        return $xml->attributes();
1✔
620
    }
621
}
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