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

Cecilapp / Cecil / 20994419343

14 Jan 2026 12:40PM UTC coverage: 82.575% (-0.03%) from 82.608%
20994419343

push

github

ArnaudLigny
fix: refine image resize logic in Asset class

Updated the resize method to handle cases where only width or height is specified, and to return the original image if neither is provided. This improves the accuracy of image resizing checks.

4 of 5 new or added lines in 1 file covered. (80.0%)

1 existing line in 1 file now uncovered.

3303 of 4000 relevant lines covered (82.58%)

0.83 hits per line

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

79.74
/src/Asset.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;
15

16
use Cecil\Asset\Image;
17
use Cecil\Builder;
18
use Cecil\Cache;
19
use Cecil\Collection\Page\Page;
20
use Cecil\Config;
21
use Cecil\Exception\ConfigException;
22
use Cecil\Exception\RuntimeException;
23
use Cecil\Url;
24
use Cecil\Util;
25
use Cecil\Util\ImageOptimizer as Optimizer;
26
use MatthiasMullie\Minify;
27
use ScssPhp\ScssPhp\Compiler;
28
use ScssPhp\ScssPhp\OutputStyle;
29
use wapmorgan\Mp3Info\Mp3Info;
30

31
/**
32
 * Asset class.
33
 *
34
 * Represents an asset (file) in the Cecil project.
35
 * Handles file locating, content reading, compiling, minifying, fingerprinting,
36
 * resizing images, and more.
37
 */
38
class Asset implements \ArrayAccess
39
{
40
    public const IMAGE_THUMB = 'thumbnails';
41

42
    /** @var Builder */
43
    protected $builder;
44

45
    /** @var Config */
46
    protected $config;
47

48
    /** @var array */
49
    protected $data = [];
50

51
    /** @var array Cache tags */
52
    protected $cacheTags = [];
53

54
    /**
55
     * Creates an Asset from a file path, an array of files path or an URL.
56
     * Options:
57
     * [
58
     *     'filename' => <string>,
59
     *     'leading_slash' => <bool>
60
     *     'ignore_missing' => <bool>,
61
     *     'fingerprint' => <bool>,
62
     *     'minify' => <bool>,
63
     *     'optimize' => <bool>,
64
     *     'fallback' => <string>,
65
     *     'useragent' => <string>,
66
     * ]
67
     *
68
     * @param Builder      $builder
69
     * @param string|array $paths
70
     * @param array|null   $options
71
     *
72
     * @throws RuntimeException
73
     */
74
    public function __construct(Builder $builder, string|array $paths, array|null $options = null)
75
    {
76
        $this->builder = $builder;
1✔
77
        $this->config = $builder->getConfig();
1✔
78
        $paths = \is_array($paths) ? $paths : [$paths];
1✔
79
        // checks path(s)
80
        array_walk($paths, function ($path) {
1✔
81
            // must be a string
82
            if (!\is_string($path)) {
1✔
83
                throw new RuntimeException(\sprintf('The path of an asset must be a string ("%s" given).', \gettype($path)));
×
84
            }
85
            // can't be empty
86
            if (empty($path)) {
1✔
87
                throw new RuntimeException('The path of an asset can\'t be empty.');
×
88
            }
89
            // can't be relative
90
            if (substr($path, 0, 2) == '..') {
1✔
91
                throw new RuntimeException(\sprintf('The path of asset "%s" is wrong: it must be directly relative to `assets` or `static` directory, or a remote URL.', $path));
×
92
            }
93
        });
1✔
94
        $this->data = [
1✔
95
            'file'     => '',    // absolute file path
1✔
96
            'files'    => [],    // array of absolute files path
1✔
97
            'missing'  => false, // if file not found but missing allowed: 'missing' is true
1✔
98
            '_path'    => '',    // original path
1✔
99
            'path'     => '',    // public path
1✔
100
            'url'      => null,  // URL if it's a remote file
1✔
101
            'ext'      => '',    // file extension
1✔
102
            'type'     => '',    // file type (e.g.: image, audio, video, etc.)
1✔
103
            'subtype'  => '',    // file media type (e.g.: image/png, audio/mp3, etc.)
1✔
104
            'size'     => 0,     // file size (in bytes)
1✔
105
            'width'    => null,  // width (in pixels)
1✔
106
            'height'   => null,  // height (in pixels)
1✔
107
            'exif'     => [],    // image exif data
1✔
108
            'duration' => null,  // audio or video duration
1✔
109
            'content'  => '',    // file content
1✔
110
            'hash'     => '',    // file content hash (md5)
1✔
111
        ];
1✔
112

113
        // handles options
114
        $options = array_merge(
1✔
115
            [
1✔
116
                'filename'       => '',
1✔
117
                'leading_slash'  => true,
1✔
118
                'ignore_missing' => false,
1✔
119
                'fingerprint'    => $this->config->isEnabled('assets.fingerprint'),
1✔
120
                'minify'         => $this->config->isEnabled('assets.minify'),
1✔
121
                'optimize'       => $this->config->isEnabled('assets.images.optimize'),
1✔
122
                'fallback'       => '',
1✔
123
                'useragent'      => (string) $this->config->get('assets.remote.useragent.default'),
1✔
124
            ],
1✔
125
            \is_array($options) ? $options : []
1✔
126
        );
1✔
127

128
        // cache for "locate file(s)"
129
        $cache = new Cache($this->builder, 'assets');
1✔
130
        $locateCacheKey = \sprintf('%s_locate__%s__%s', $options['filename'] ?: implode('_', $paths), $this->builder->getBuildId(), $this->builder->getVersion());
1✔
131

132
        // locate file(s) and get content
133
        if (!$cache->has($locateCacheKey)) {
1✔
134
            $pathsCount = \count($paths);
1✔
135
            for ($i = 0; $i < $pathsCount; $i++) {
1✔
136
                try {
137
                    $this->data['missing'] = false;
1✔
138
                    $locate = $this->locateFile($paths[$i], $options['fallback'], $options['useragent']);
1✔
139
                    $file = $locate['file'];
1✔
140
                    $path = $locate['path'];
1✔
141
                    $type = Util\File::getMediaType($file)[0];
1✔
142
                    if ($i > 0) { // bundle
1✔
143
                        if ($type != $this->data['type']) {
1✔
144
                            throw new RuntimeException(\sprintf('Asset bundle type error (%s != %s).', $type, $this->data['type']));
×
145
                        }
146
                    }
147
                    $this->data['file'] = $file;
1✔
148
                    $this->data['files'][] = $file;
1✔
149
                    $this->data['path'] = $path;
1✔
150
                    $this->data['url'] = Util\File::isRemote($paths[$i]) ? $paths[$i] : null;
1✔
151
                    $this->data['ext'] = Util\File::getExtension($file);
1✔
152
                    $this->data['type'] = $type;
1✔
153
                    $this->data['subtype'] = Util\File::getMediaType($file)[1];
1✔
154
                    $this->data['size'] += filesize($file) ?: 0;
1✔
155
                    $this->data['content'] .= Util\File::fileGetContents($file);
1✔
156
                    $this->data['hash'] = hash('md5', $this->data['content']);
1✔
157
                    // bundle default filename
158
                    $filename = $options['filename'];
1✔
159
                    if ($pathsCount > 1 && empty($filename)) {
1✔
160
                        switch ($this->data['ext']) {
1✔
161
                            case 'scss':
1✔
162
                            case 'css':
1✔
163
                                $filename = 'styles.css';
1✔
164
                                break;
1✔
165
                            case 'js':
1✔
166
                                $filename = 'scripts.js';
1✔
167
                                break;
1✔
168
                            default:
169
                                throw new RuntimeException(\sprintf('Asset bundle supports %s files only.', '.scss, .css and .js'));
×
170
                        }
171
                    }
172
                    // apply bundle filename to path
173
                    if (!empty($filename)) {
1✔
174
                        $this->data['path'] = $filename;
1✔
175
                    }
176
                    // add leading slash
177
                    if ($options['leading_slash']) {
1✔
178
                        $this->data['path'] = '/' . ltrim($this->data['path'], '/');
1✔
179
                    }
180
                    $this->data['_path'] = $this->data['path'];
1✔
181
                } catch (RuntimeException $e) {
1✔
182
                    if ($options['ignore_missing']) {
1✔
183
                        $this->data['missing'] = true;
1✔
184
                        continue;
1✔
185
                    }
186
                    throw new RuntimeException(\sprintf('Unable to handle asset "%s".', $paths[$i]), previous: $e);
×
187
                }
188
            }
189
            $cache->set($locateCacheKey, $this->data);
1✔
190
        }
191
        $this->data = $cache->get($locateCacheKey);
1✔
192

193
        // missing
194
        if ($this->data['missing']) {
1✔
195
            return;
1✔
196
        }
197

198
        // cache for "process asset"
199
        $cache = new Cache($this->builder, 'assets');
1✔
200
        // create cache tags from options
201
        $this->cacheTags = $options;
1✔
202
        // remove unnecessary cache tags
203
        unset($this->cacheTags['optimize'], $this->cacheTags['ignore_missing'], $this->cacheTags['fallback'], $this->cacheTags['useragent']);
1✔
204
        if (!\in_array($this->data['ext'], ['css', 'js', 'scss'])) {
1✔
205
            unset($this->cacheTags['minify']);
1✔
206
        }
207
        // optimize image?
208
        $optimize = false;
1✔
209
        if ($options['optimize'] && $this->data['type'] == 'image' && !$this->isImageInCdn()) {
1✔
210
            $optimize = true;
1✔
211
            $quality = (int) $this->config->get('assets.images.quality');
1✔
212
            $this->cacheTags['quality'] = $quality;
1✔
213
        }
214
        $cacheKey = $cache->createKeyFromAsset($this, $this->cacheTags);
1✔
215
        if (!$cache->has($cacheKey)) {
1✔
216
            // fingerprinting
217
            if ($options['fingerprint']) {
1✔
218
                $this->doFingerprint();
×
219
            }
220
            // compiling Sass files
221
            $this->doCompile();
1✔
222
            // minifying (CSS and JavaScript files)
223
            if ($options['minify']) {
1✔
224
                $this->doMinify();
×
225
            }
226
            // get width and height
227
            $this->data['width'] = $this->getWidth();
1✔
228
            $this->data['height'] = $this->getHeight();
1✔
229
            // get image exif
230
            if ($this->data['subtype'] == 'image/jpeg') {
1✔
231
                $this->data['exif'] = Util\File::readExif($this->data['file']);
1✔
232
            }
233
            // get duration
234
            if ($this->data['type'] == 'audio') {
1✔
235
                $this->data['duration'] = $this->getAudio()['duration'];
1✔
236
            }
237
            if ($this->data['type'] == 'video') {
1✔
238
                $this->data['duration'] = $this->getVideo()['duration'];
1✔
239
            }
240
            $cache->set($cacheKey, $this->data, $this->config->get('cache.assets.ttl'));
1✔
241
            $this->builder->getLogger()->debug(\sprintf('Asset cached: "%s"', $this->data['path']));
1✔
242
            // optimizing images files (in cache directory)
243
            if ($optimize) {
1✔
244
                $this->optimizeImage($cache->getContentFilePathname($this->data['path']), $this->data['path'], $quality);
1✔
245
            }
246
        }
247
        $this->data = $cache->get($cacheKey);
1✔
248
    }
249

250
    /**
251
     * Returns path.
252
     */
253
    public function __toString(): string
254
    {
255
        $this->save();
1✔
256

257
        if ($this->isImageInCdn()) {
1✔
258
            return $this->buildImageCdnUrl();
×
259
        }
260

261
        if ($this->builder->getConfig()->isEnabled('canonicalurl')) {
1✔
262
            return (string) new Url($this->builder, $this->data['path'], ['canonical' => true]);
×
263
        }
264

265
        return $this->data['path'];
1✔
266
    }
267

268
    /**
269
     * Implements \ArrayAccess.
270
     */
271
    #[\ReturnTypeWillChange]
272
    public function offsetSet($offset, $value): void
273
    {
274
        if (!\is_null($offset)) {
1✔
275
            $this->data[$offset] = $value;
1✔
276
        }
277
    }
278

279
    /**
280
     * Implements \ArrayAccess.
281
     */
282
    #[\ReturnTypeWillChange]
283
    public function offsetExists($offset): bool
284
    {
285
        return isset($this->data[$offset]);
1✔
286
    }
287

288
    /**
289
     * Implements \ArrayAccess.
290
     */
291
    #[\ReturnTypeWillChange]
292
    public function offsetUnset($offset): void
293
    {
294
        unset($this->data[$offset]);
×
295
    }
296

297
    /**
298
     * Implements \ArrayAccess.
299
     */
300
    #[\ReturnTypeWillChange]
301
    public function offsetGet($offset)
302
    {
303
        return isset($this->data[$offset]) ? $this->data[$offset] : null;
1✔
304
    }
305

306
    /**
307
     * Adds asset path to the list of assets to save.
308
     *
309
     * @throws RuntimeException
310
     */
311
    public function save(): void
312
    {
313
        if ($this->data['missing']) {
1✔
314
            return;
1✔
315
        }
316

317
        $cache = new Cache($this->builder, 'assets');
1✔
318
        if (empty($this->data['path']) || !Util\File::getFS()->exists($cache->getContentFilePathname($this->data['path']))) {
1✔
319
            throw new RuntimeException(\sprintf('Unable to add "%s" to assets list. Please clear cache and retry.', $this->data['path']));
×
320
        }
321

322
        $this->builder->addAsset($this->data['path']);
1✔
323
    }
324

325
    /**
326
     * Add hash to the file name + cache.
327
     */
328
    public function fingerprint(): self
329
    {
330
        $this->cacheTags['fingerprint'] = true;
1✔
331
        $cache = new Cache($this->builder, 'assets');
1✔
332
        $cacheKey = $cache->createKeyFromAsset($this, $this->cacheTags);
1✔
333
        if (!$cache->has($cacheKey)) {
1✔
334
            $this->doFingerprint();
1✔
335
            $cache->set($cacheKey, $this->data, $this->config->get('cache.assets.ttl'));
1✔
336
        }
337
        $this->data = $cache->get($cacheKey);
1✔
338

339
        return $this;
1✔
340
    }
341

342
    /**
343
     * Compiles a SCSS + cache.
344
     *
345
     * @throws RuntimeException
346
     */
347
    public function compile(): self
348
    {
349
        $this->cacheTags['compile'] = true;
1✔
350
        $cache = new Cache($this->builder, 'assets');
1✔
351
        $cacheKey = $cache->createKeyFromAsset($this, $this->cacheTags);
1✔
352
        if (!$cache->has($cacheKey)) {
1✔
353
            $this->doCompile();
1✔
354
            $cache->set($cacheKey, $this->data, $this->config->get('cache.assets.ttl'));
1✔
355
        }
356
        $this->data = $cache->get($cacheKey);
1✔
357

358
        return $this;
1✔
359
    }
360

361
    /**
362
     * Minifying a CSS or a JS.
363
     */
364
    public function minify(): self
365
    {
366
        $this->cacheTags['minify'] = true;
1✔
367
        $cache = new Cache($this->builder, 'assets');
1✔
368
        $cacheKey = $cache->createKeyFromAsset($this, $this->cacheTags);
1✔
369
        if (!$cache->has($cacheKey)) {
1✔
370
            $this->doMinify();
1✔
371
            $cache->set($cacheKey, $this->data, $this->config->get('cache.assets.ttl'));
1✔
372
        }
373
        $this->data = $cache->get($cacheKey);
1✔
374

375
        return $this;
1✔
376
    }
377

378
    /**
379
     * Returns the Data URL (encoded in Base64).
380
     *
381
     * @throws RuntimeException
382
     */
383
    public function dataurl(): string
384
    {
385
        if ($this->data['type'] == 'image' && !Image::isSVG($this)) {
1✔
386
            return Image::getDataUrl($this, (int) $this->config->get('assets.images.quality'));
1✔
387
        }
388

389
        return \sprintf('data:%s;base64,%s', $this->data['subtype'], base64_encode($this->data['content']));
1✔
390
    }
391

392
    /**
393
     * Hashing content of an asset with the specified algo, sha384 by default.
394
     * Used for SRI (Subresource Integrity).
395
     *
396
     * @see https://developer.mozilla.org/fr/docs/Web/Security/Subresource_Integrity
397
     */
398
    public function integrity(string $algo = 'sha384'): string
399
    {
400
        return \sprintf('%s-%s', $algo, base64_encode(hash($algo, $this->data['content'], true)));
1✔
401
    }
402

403
    /**
404
     * Resizes an image to the given width or/and height.
405
     *
406
     * - If only the width is specified, the height is calculated to preserve the aspect ratio
407
     * - If only the height is specified, the width is calculated to preserve the aspect ratio
408
     * - If both width and height are specified, the image is resized to fit within the given dimensions, image is cropped and centered if necessary
409
     * - If rmAnimation is true, any animation in the image (e.g., GIF) will be removed.
410
     *
411
     * @throws RuntimeException
412
     */
413
    public function resize(?int $width = null, ?int $height = null, bool $rmAnimation = false): self
414
    {
415
        $this->checkImage();
1✔
416

417
        // if no width and no height, return the original image
418
        if ($width === null && $height === null) {
1✔
NEW
419
            return $this;
×
420
        }
421

422
        // if the image width or height is already smaller, return it
423
        if ($width !== null && $this->data['width'] <= $width && $height === null) {
1✔
424
            return $this;
1✔
425
        }
426
        if ($height !== null && $this->data['height'] <= $height && $width === null) {
1✔
UNCOV
427
            return $this;
×
428
        }
429

430
        $assetResized = clone $this;
1✔
431
        $assetResized->data['width'] = $width ?? $this->data['width'];
1✔
432
        $assetResized->data['height'] = $height ?? $this->data['height'];
1✔
433

434
        if ($this->isImageInCdn()) {
1✔
435
            if ($width === null) {
×
436
                $assetResized->data['width'] = round($this->data['width'] / ($this->data['height'] / $height));
×
437
            }
438
            if ($height === null) {
×
439
                $assetResized->data['height'] = round($this->data['height'] / ($this->data['width'] / $width));
×
440
            }
441

442
            return $assetResized; // returns asset with the new dimensions only: CDN do the rest of the job
×
443
        }
444

445
        $quality = (int) $this->config->get('assets.images.quality');
1✔
446

447
        $cache = new Cache($this->builder, 'assets');
1✔
448
        $assetResized->cacheTags['quality'] = $quality;
1✔
449
        $assetResized->cacheTags['width'] = $width;
1✔
450
        $assetResized->cacheTags['height'] = $height;
1✔
451
        $cacheKey = $cache->createKeyFromAsset($assetResized, $assetResized->cacheTags);
1✔
452
        if (!$cache->has($cacheKey)) {
1✔
453
            $assetResized->data['content'] = Image::resize($assetResized, $width, $height, $quality, $rmAnimation);
1✔
454
            $assetResized->data['path'] = '/' . Util::joinPath(
1✔
455
                (string) $this->config->get('assets.target'),
1✔
456
                self::IMAGE_THUMB,
1✔
457
                (string) $width . 'x' . (string) $height,
1✔
458
                $assetResized->data['path']
1✔
459
            );
1✔
460
            $assetResized->data['path'] = $this->deduplicateThumbPath($assetResized->data['path']);
1✔
461
            $assetResized->data['width'] = $assetResized->getWidth();
1✔
462
            $assetResized->data['height'] = $assetResized->getHeight();
1✔
463
            $assetResized->data['size'] = \strlen($assetResized->data['content']);
1✔
464

465
            $cache->set($cacheKey, $assetResized->data, $this->config->get('cache.assets.ttl'));
1✔
466
            $this->builder->getLogger()->debug(\sprintf('Asset resized: "%s" (%sx%s)', $assetResized->data['path'], $width, $height));
1✔
467
        }
468
        $assetResized->data = $cache->get($cacheKey);
1✔
469

470
        return $assetResized;
1✔
471
    }
472

473
    /**
474
     * Creates a maskable image (with a padding = 20%).
475
     *
476
     * @throws RuntimeException
477
     */
478
    public function maskable(?int $padding = null): self
479
    {
480
        $this->checkImage();
×
481

482
        if ($padding === null) {
×
483
            $padding = 20; // default padding
×
484
        }
485

486
        $assetMaskable = clone $this;
×
487

488
        $quality = (int) $this->config->get('assets.images.quality');
×
489

490
        $cache = new Cache($this->builder, 'assets');
×
491
        $assetMaskable->cacheTags['maskable'] = true;
×
492
        $cacheKey = $cache->createKeyFromAsset($assetMaskable, $assetMaskable->cacheTags);
×
493
        if (!$cache->has($cacheKey)) {
×
494
            $assetMaskable->data['content'] = Image::maskable($assetMaskable, $quality, $padding);
×
495
            $assetMaskable->data['path'] = '/' . Util::joinPath(
×
496
                (string) $this->config->get('assets.target'),
×
497
                'maskable',
×
498
                $assetMaskable->data['path']
×
499
            );
×
500
            $assetMaskable->data['size'] = \strlen($assetMaskable->data['content']);
×
501

502
            $cache->set($cacheKey, $assetMaskable->data, $this->config->get('cache.assets.ttl'));
×
503
            $this->builder->getLogger()->debug(\sprintf('Asset maskabled: "%s"', $assetMaskable->data['path']));
×
504
        }
505
        $assetMaskable->data = $cache->get($cacheKey);
×
506

507
        return $assetMaskable;
×
508
    }
509

510
    /**
511
     * Converts an image asset to $format format.
512
     *
513
     * @throws RuntimeException
514
     */
515
    public function convert(string $format, ?int $quality = null): self
516
    {
517
        if ($this->data['type'] != 'image') {
1✔
518
            throw new RuntimeException(\sprintf('Unable to convert "%s" (%s) to %s: not an image.', $this->data['path'], $this->data['type'], $format));
×
519
        }
520

521
        if ($quality === null) {
1✔
522
            $quality = (int) $this->config->get('assets.images.quality');
1✔
523
        }
524

525
        $asset = clone $this;
1✔
526
        $asset['ext'] = $format;
1✔
527
        $asset->data['subtype'] = "image/$format";
1✔
528

529
        if ($this->isImageInCdn()) {
1✔
530
            return $asset; // returns the asset with the new extension only: CDN do the rest of the job
×
531
        }
532

533
        $cache = new Cache($this->builder, 'assets');
1✔
534
        $this->cacheTags['quality'] = $quality;
1✔
535
        if ($this->data['width']) {
1✔
536
            $this->cacheTags['width'] = $this->data['width'];
1✔
537
        }
538
        $cacheKey = $cache->createKeyFromAsset($asset, $this->cacheTags);
1✔
539
        if (!$cache->has($cacheKey)) {
1✔
540
            $asset->data['content'] = Image::convert($asset, $format, $quality);
1✔
541
            $asset->data['path'] = preg_replace('/\.' . $this->data['ext'] . '$/m', ".$format", $this->data['path']);
1✔
542
            $asset->data['size'] = \strlen($asset->data['content']);
1✔
543
            $cache->set($cacheKey, $asset->data, $this->config->get('cache.assets.ttl'));
1✔
544
            $this->builder->getLogger()->debug(\sprintf('Asset converted: "%s" (%s -> %s)', $asset->data['path'], $this->data['ext'], $format));
1✔
545
        }
546
        $asset->data = $cache->get($cacheKey);
1✔
547

548
        return $asset;
1✔
549
    }
550

551
    /**
552
     * Converts an image asset to WebP format.
553
     *
554
     * @throws RuntimeException
555
     */
556
    public function webp(?int $quality = null): self
557
    {
558
        return $this->convert('webp', $quality);
×
559
    }
560

561
    /**
562
     * Converts an image asset to AVIF format.
563
     *
564
     * @throws RuntimeException
565
     */
566
    public function avif(?int $quality = null): self
567
    {
568
        return $this->convert('avif', $quality);
×
569
    }
570

571
    /**
572
     * Is the asset an image and is it in CDN?
573
     */
574
    public function isImageInCdn(): bool
575
    {
576
        if (
577
            $this->data['type'] == 'image'
1✔
578
            && $this->config->isEnabled('assets.images.cdn')
1✔
579
            && $this->data['ext'] != 'ico'
1✔
580
            && (Image::isSVG($this) && $this->config->isEnabled('assets.images.cdn.svg'))
1✔
581
        ) {
582
            return true;
×
583
        }
584
        // handle remote image?
585
        if ($this->data['url'] !== null && $this->config->isEnabled('assets.images.cdn.remote')) {
1✔
586
            return true;
×
587
        }
588

589
        return false;
1✔
590
    }
591

592
    /**
593
     * Returns the width of an image/SVG or a video.
594
     *
595
     * @throws RuntimeException
596
     */
597
    public function getWidth(): ?int
598
    {
599
        switch ($this->data['type']) {
1✔
600
            case 'image':
1✔
601
                if (Image::isSVG($this) && false !== $svg = Image::getSvgAttributes($this)) {
1✔
602
                    return (int) $svg->width;
1✔
603
                }
604
                if (false === $size = $this->getImageSize()) {
1✔
605
                    throw new RuntimeException(\sprintf('Unable to get width of "%s".', $this->data['path']));
×
606
                }
607

608
                return $size[0];
1✔
609
            case 'video':
1✔
610
                return $this->getVideo()['width'];
1✔
611
        }
612

613
        return null;
1✔
614
    }
615

616
    /**
617
     * Returns the height of an image/SVG or a video.
618
     *
619
     * @throws RuntimeException
620
     */
621
    public function getHeight(): ?int
622
    {
623
        switch ($this->data['type']) {
1✔
624
            case 'image':
1✔
625
                if (Image::isSVG($this) && false !== $svg = Image::getSvgAttributes($this)) {
1✔
626
                    return (int) $svg->height;
1✔
627
                }
628
                if (false === $size = $this->getImageSize()) {
1✔
629
                    throw new RuntimeException(\sprintf('Unable to get height of "%s".', $this->data['path']));
×
630
                }
631

632
                return $size[1];
1✔
633
            case 'video':
1✔
634
                return $this->getVideo()['height'];
1✔
635
        }
636

637
        return null;
1✔
638
    }
639

640
    /**
641
     * Returns audio file infos:
642
     * - duration (in seconds.microseconds)
643
     * - bitrate (in bps)
644
     * - channel ('stereo', 'dual_mono', 'joint_stereo' or 'mono')
645
     *
646
     * @see https://github.com/wapmorgan/Mp3Info
647
     */
648
    public function getAudio(): array
649
    {
650
        $audio = new Mp3Info($this->data['file']);
1✔
651

652
        return [
1✔
653
            'duration' => $audio->duration,
1✔
654
            'bitrate'  => $audio->bitRate,
1✔
655
            'channel'  => $audio->channel,
1✔
656
        ];
1✔
657
    }
658

659
    /**
660
     * Returns video file infos:
661
     * - duration (in seconds)
662
     * - width (in pixels)
663
     * - height (in pixels)
664
     *
665
     * @see https://github.com/JamesHeinrich/getID3
666
     */
667
    public function getVideo(): array
668
    {
669
        if ($this->data['type'] !== 'video') {
1✔
670
            throw new RuntimeException(\sprintf('Unable to get video infos of "%s".', $this->data['path']));
×
671
        }
672

673
        $video = (new \getID3())->analyze($this->data['file']);
1✔
674

675
        return [
1✔
676
            'duration' => $video['playtime_seconds'],
1✔
677
            'width'    => $video['video']['resolution_x'],
1✔
678
            'height'   => $video['video']['resolution_y'],
1✔
679
        ];
1✔
680
    }
681

682
    /**
683
     * Builds a relative path from a URL.
684
     * Used for remote files.
685
     */
686
    public static function buildPathFromUrl(string $url): string
687
    {
688
        $host = parse_url($url, PHP_URL_HOST);
1✔
689
        $path = parse_url($url, PHP_URL_PATH);
1✔
690
        $query = parse_url($url, PHP_URL_QUERY);
1✔
691
        $ext = pathinfo(parse_url($url, PHP_URL_PATH), \PATHINFO_EXTENSION);
1✔
692

693
        // Google Fonts hack
694
        if (Util\Str::endsWith($path, '/css') || Util\Str::endsWith($path, '/css2')) {
1✔
695
            $ext = 'css';
1✔
696
        }
697

698
        return Page::slugify(\sprintf('%s%s%s%s', $host, self::sanitize($path), $query ? "-$query" : '', $query && $ext ? ".$ext" : ''));
1✔
699
    }
700

701
    /**
702
     * Replaces some characters by '_'.
703
     */
704
    public static function sanitize(string $string): string
705
    {
706
        return str_replace(['<', '>', ':', '"', '\\', '|', '?', '*'], '_', $string);
1✔
707
    }
708

709
    /**
710
     * Add hash to the file name.
711
     */
712
    protected function doFingerprint(): self
713
    {
714
        $hash = hash('md5', $this->data['content']);
1✔
715
        $this->data['path'] = preg_replace(
1✔
716
            '/\.' . $this->data['ext'] . '$/m',
1✔
717
            ".$hash." . $this->data['ext'],
1✔
718
            $this->data['path']
1✔
719
        );
1✔
720
        $this->builder->getLogger()->debug(\sprintf('Asset fingerprinted: "%s"', $this->data['path']));
1✔
721

722
        return $this;
1✔
723
    }
724

725
    /**
726
     * Compiles a SCSS.
727
     *
728
     * @throws RuntimeException
729
     */
730
    protected function doCompile(): self
731
    {
732
        // abort if not a SCSS file
733
        if ($this->data['ext'] != 'scss') {
1✔
734
            return $this;
1✔
735
        }
736
        $scssPhp = new Compiler();
1✔
737
        // import paths
738
        $importDir = [];
1✔
739
        $importDir[] = Util::joinPath($this->config->getStaticPath());
1✔
740
        $importDir[] = Util::joinPath($this->config->getAssetsPath());
1✔
741
        $scssDir = (array) $this->config->get('assets.compile.import');
1✔
742
        $themes = $this->config->getTheme() ?? [];
1✔
743
        foreach ($scssDir as $dir) {
1✔
744
            $importDir[] = Util::joinPath($this->config->getStaticPath(), $dir);
1✔
745
            $importDir[] = Util::joinPath($this->config->getAssetsPath(), $dir);
1✔
746
            $importDir[] = Util::joinPath(\dirname($this->data['file']), $dir);
1✔
747
            foreach ($themes as $theme) {
1✔
748
                $importDir[] = Util::joinPath($this->config->getThemeDirPath($theme, "static/$dir"));
1✔
749
                $importDir[] = Util::joinPath($this->config->getThemeDirPath($theme, "assets/$dir"));
1✔
750
            }
751
        }
752
        $scssPhp->setQuietDeps(true);
1✔
753
        $scssPhp->setImportPaths(array_unique($importDir));
1✔
754
        // adds source map
755
        if ($this->builder->isDebug() && $this->config->isEnabled('assets.compile.sourcemap')) {
1✔
756
            $importDir = [];
×
757
            $assetDir = (string) $this->config->get('assets.dir');
×
758
            $assetDirPos = strrpos($this->data['file'], DIRECTORY_SEPARATOR . $assetDir . DIRECTORY_SEPARATOR);
×
759
            $fileRelPath = substr($this->data['file'], $assetDirPos + 8);
×
760
            $filePath = Util::joinFile($this->config->getOutputPath(), $fileRelPath);
×
761
            $importDir[] = \dirname($filePath);
×
762
            foreach ($scssDir as $dir) {
×
763
                $importDir[] = Util::joinFile($this->config->getOutputPath(), $dir);
×
764
            }
765
            $scssPhp->setImportPaths(array_unique($importDir));
×
766
            $scssPhp->setSourceMap(Compiler::SOURCE_MAP_INLINE);
×
767
            $scssPhp->setSourceMapOptions([
×
768
                'sourceMapBasepath' => Util::joinPath($this->config->getOutputPath()),
×
769
                'sourceRoot'        => '/',
×
770
            ]);
×
771
        }
772
        // defines output style
773
        $outputStyles = ['expanded', 'compressed'];
1✔
774
        $outputStyle = strtolower((string) $this->config->get('assets.compile.style'));
1✔
775
        if (!\in_array($outputStyle, $outputStyles)) {
1✔
776
            throw new ConfigException(\sprintf('"%s" value must be "%s".', 'assets.compile.style', implode('" or "', $outputStyles)));
×
777
        }
778
        $scssPhp->setOutputStyle($outputStyle == 'compressed' ? OutputStyle::COMPRESSED : OutputStyle::EXPANDED);
1✔
779
        // set variables
780
        $variables = $this->config->get('assets.compile.variables');
1✔
781
        if (!empty($variables)) {
1✔
782
            $variables = array_map('ScssPhp\ScssPhp\ValueConverter::parseValue', $variables);
1✔
783
            $scssPhp->replaceVariables($variables);
1✔
784
        }
785
        // debug
786
        if ($this->builder->isDebug()) {
1✔
787
            $scssPhp->setQuietDeps(false);
1✔
788
            $this->builder->getLogger()->debug(\sprintf("SCSS compiler imported paths:\n%s", Util\Str::arrayToList(array_unique($importDir))));
1✔
789
        }
790
        // update data
791
        $this->data['path'] = preg_replace('/sass|scss/m', 'css', $this->data['path']);
1✔
792
        $this->data['ext'] = 'css';
1✔
793
        $this->data['type'] = 'text';
1✔
794
        $this->data['subtype'] = 'text/css';
1✔
795
        $this->data['content'] = $scssPhp->compileString($this->data['content'])->getCss();
1✔
796
        $this->data['size'] = \strlen($this->data['content']);
1✔
797

798
        $this->builder->getLogger()->debug(\sprintf('Asset compiled: "%s"', $this->data['path']));
1✔
799

800
        return $this;
1✔
801
    }
802

803
    /**
804
     * Minifying a CSS or a JS + cache.
805
     *
806
     * @throws RuntimeException
807
     */
808
    protected function doMinify(): self
809
    {
810
        // compile SCSS files
811
        if ($this->data['ext'] == 'scss') {
1✔
812
            $this->doCompile();
×
813
        }
814
        // abort if already minified
815
        if (substr($this->data['path'], -8) == '.min.css' || substr($this->data['path'], -7) == '.min.js') {
1✔
816
            return $this;
×
817
        }
818
        // abord if not a CSS or JS file
819
        if (!\in_array($this->data['ext'], ['css', 'js'])) {
1✔
820
            return $this;
×
821
        }
822
        // in debug mode: disable minify to preserve inline source map
823
        if ($this->builder->isDebug() && $this->config->isEnabled('assets.compile.sourcemap')) {
1✔
824
            return $this;
×
825
        }
826
        switch ($this->data['ext']) {
1✔
827
            case 'css':
1✔
828
                $minifier = new Minify\CSS($this->data['content']);
1✔
829
                break;
1✔
830
            case 'js':
1✔
831
                $minifier = new Minify\JS($this->data['content']);
1✔
832
                break;
1✔
833
            default:
834
                throw new RuntimeException(\sprintf('Unable to minify "%s".', $this->data['path']));
×
835
        }
836
        $this->data['content'] = $minifier->minify();
1✔
837
        $this->data['size'] = \strlen($this->data['content']);
1✔
838

839
        $this->builder->getLogger()->debug(\sprintf('Asset minified: "%s"', $this->data['path']));
1✔
840

841
        return $this;
1✔
842
    }
843

844
    /**
845
     * Returns local file path and updated path, or throw an exception.
846
     * If $fallback path is set, it will be used if the remote file is not found.
847
     *
848
     * Try to locate the file in:
849
     *   (1. remote file)
850
     *   1. assets
851
     *   2. themes/<theme>/assets
852
     *   3. static
853
     *   4. themes/<theme>/static
854
     *
855
     * @throws RuntimeException
856
     */
857
    private function locateFile(string $path, ?string $fallback = null, ?string $userAgent = null): array
858
    {
859
        // remote file
860
        if (Util\File::isRemote($path)) {
1✔
861
            try {
862
                $url = $path;
1✔
863
                $path = self::buildPathFromUrl($url);
1✔
864
                $cache = new Cache($this->builder, 'assets/remote');
1✔
865
                if (!$cache->has($path)) {
1✔
866
                    $content = $this->getRemoteFileContent($url, $userAgent);
1✔
867
                    $cache->set($path, [
1✔
868
                        'content' => $content,
1✔
869
                        'path'    => $path,
1✔
870
                    ], $this->config->get('cache.assets.remote.ttl'));
1✔
871
                }
872
                return [
1✔
873
                    'file' => $cache->getContentFilePathname($path),
1✔
874
                    'path' => $path,
1✔
875
                ];
1✔
876
            } catch (RuntimeException $e) {
1✔
877
                if (empty($fallback)) {
1✔
878
                    throw new RuntimeException($e->getMessage());
×
879
                }
880
                $path = $fallback;
1✔
881
            }
882
        }
883

884
        // checks in assets/
885
        $file = Util::joinFile($this->config->getAssetsPath(), $path);
1✔
886
        if (Util\File::getFS()->exists($file)) {
1✔
887
            return [
1✔
888
                'file' => $file,
1✔
889
                'path' => $path,
1✔
890
            ];
1✔
891
        }
892

893
        // checks in each themes/<theme>/assets/
894
        foreach ($this->config->getTheme() ?? [] as $theme) {
1✔
895
            $file = Util::joinFile($this->config->getThemeDirPath($theme, 'assets'), $path);
1✔
896
            if (Util\File::getFS()->exists($file)) {
1✔
897
                return [
1✔
898
                    'file' => $file,
1✔
899
                    'path' => $path,
1✔
900
                ];
1✔
901
            }
902
        }
903

904
        // checks in static/
905
        $file = Util::joinFile($this->config->getStaticPath(), $path);
1✔
906
        if (Util\File::getFS()->exists($file)) {
1✔
907
            return [
1✔
908
                'file' => $file,
1✔
909
                'path' => $path,
1✔
910
            ];
1✔
911
        }
912

913
        // checks in each themes/<theme>/static/
914
        foreach ($this->config->getTheme() ?? [] as $theme) {
1✔
915
            $file = Util::joinFile($this->config->getThemeDirPath($theme, 'static'), $path);
1✔
916
            if (Util\File::getFS()->exists($file)) {
1✔
917
                return [
1✔
918
                    'file' => $file,
1✔
919
                    'path' => $path,
1✔
920
                ];
1✔
921
            }
922
        }
923

924
        throw new RuntimeException(\sprintf('Unable to locate file "%s".', $path));
1✔
925
    }
926

927
    /**
928
     * Try to get remote file content.
929
     * Returns file content or throw an exception.
930
     *
931
     * @throws RuntimeException
932
     */
933
    private function getRemoteFileContent(string $path, ?string $userAgent = null): string
934
    {
935
        if (!Util\File::isRemoteExists($path)) {
1✔
936
            throw new RuntimeException(\sprintf('Unable to get remote file "%s".', $path));
1✔
937
        }
938
        if (false === $content = Util\File::fileGetContents($path, $userAgent)) {
1✔
939
            throw new RuntimeException(\sprintf('Unable to get content of remote file "%s".', $path));
×
940
        }
941
        if (\strlen($content) <= 1) {
1✔
942
            throw new RuntimeException(\sprintf('Remote file "%s" is empty.', $path));
×
943
        }
944

945
        return $content;
1✔
946
    }
947

948
    /**
949
     * Optimizing $filepath image.
950
     * Returns the new file size.
951
     */
952
    private function optimizeImage(string $filepath, string $path, int $quality): int
953
    {
954
        $message = \sprintf('Asset not optimized: "%s"', $path);
1✔
955
        $sizeBefore = filesize($filepath);
1✔
956
        Optimizer::create($quality)->optimize($filepath);
1✔
957
        $sizeAfter = filesize($filepath);
1✔
958
        if ($sizeAfter < $sizeBefore) {
1✔
959
            $message = \sprintf('Asset optimized: "%s" (%s Ko -> %s Ko)', $path, ceil($sizeBefore / 1000), ceil($sizeAfter / 1000));
×
960
        }
961
        $this->builder->getLogger()->debug($message);
1✔
962

963
        return $sizeAfter;
1✔
964
    }
965

966
    /**
967
     * Returns image size informations.
968
     *
969
     * @see https://www.php.net/manual/function.getimagesize.php
970
     *
971
     * @throws RuntimeException
972
     */
973
    private function getImageSize(): array|false
974
    {
975
        if (!$this->data['type'] == 'image') {
1✔
976
            return false;
×
977
        }
978

979
        try {
980
            if (false === $size = getimagesizefromstring($this->data['content'])) {
1✔
981
                return false;
1✔
982
            }
983
        } catch (\Exception $e) {
×
984
            throw new RuntimeException(\sprintf('Handling asset "%s" failed: "%s".', $this->data['path'], $e->getMessage()));
×
985
        }
986

987
        return $size;
1✔
988
    }
989

990
    /**
991
     * Builds CDN image URL.
992
     */
993
    private function buildImageCdnUrl(): string
994
    {
995
        return str_replace(
×
996
            [
×
997
                '%account%',
×
998
                '%image_url%',
×
999
                '%width%',
×
1000
                '%quality%',
×
1001
                '%format%',
×
1002
            ],
×
1003
            [
×
1004
                $this->config->get('assets.images.cdn.account') ?? '',
×
1005
                ltrim($this->data['url'] ?? (string) new Url($this->builder, $this->data['path'], ['canonical' => $this->config->get('assets.images.cdn.canonical') ?? true]), '/'),
×
1006
                $this->data['width'],
×
1007
                (int) $this->config->get('assets.images.quality'),
×
1008
                $this->data['ext'],
×
1009
            ],
×
1010
            (string) $this->config->get('assets.images.cdn.url')
×
1011
        );
×
1012
    }
1013

1014
    /**
1015
     * Checks if the asset is not missing and is typed as an image.
1016
     *
1017
     * @throws RuntimeException
1018
     */
1019
    private function checkImage(): void
1020
    {
1021
        if ($this->data['missing']) {
1✔
1022
            throw new RuntimeException(\sprintf('Unable to resize "%s": file not found.', $this->data['path']));
×
1023
        }
1024
        if ($this->data['type'] != 'image') {
1✔
1025
            throw new RuntimeException(\sprintf('Unable to resize "%s": not an image.', $this->data['path']));
×
1026
        }
1027
    }
1028

1029
    /**
1030
     * Remove redondant '/thumbnails/<width(xheight)>/' in the path.
1031
     */
1032
    private function deduplicateThumbPath(string $path): string
1033
    {
1034
        // https://regex101.com/r/0r7FMY/1
1035
        $pattern = '/(' . self::IMAGE_THUMB . '\/(\d+){0,1}x(\d+){0,1}\/)(' . self::IMAGE_THUMB . '\/(\d+){0,1}x(\d+){0,1}\/)(.*)/i';
1✔
1036

1037
        if (null === $result = preg_replace($pattern, '$1$7', $path)) {
1✔
1038
            return $path;
×
1039
        }
1040

1041
        return $result;
1✔
1042
    }
1043
}
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