Passed
Push — 8.x-dev ( 640d34...2250ec )
by Arnaud
04:51 queued 16s
created

Asset::resize()   B

Complexity

Conditions 11
Paths 21

Size

Total Lines 63
Code Lines 42

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 30
CRAP Score 12.1286

Importance

Changes 0
Metric Value
cc 11
eloc 42
c 0
b 0
f 0
nc 21
nop 1
dl 0
loc 63
ccs 30
cts 38
cp 0.7895
crap 12.1286
rs 7.3166

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Cecil.
7
 *
8
 * Copyright (c) Arnaud Ligny <[email protected]>
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13
14
namespace Cecil\Assets;
15
16
use Cecil\Assets\Image\Optimizer;
17
use Cecil\Builder;
18
use Cecil\Collection\Page\Page;
19
use Cecil\Config;
20
use Cecil\Exception\RuntimeException;
21
use Cecil\Util;
22
use Intervention\Image\ImageManagerStatic as ImageManager;
23
use MatthiasMullie\Minify;
24
use ScssPhp\ScssPhp\Compiler;
25
use wapmorgan\Mp3Info\Mp3Info;
26
27
class Asset implements \ArrayAccess
28
{
29
    /** @var Builder */
30
    protected $builder;
31
32
    /** @var Config */
33
    protected $config;
34
35
    /** @var array */
36
    protected $data = [];
37
38
    /** @var bool */
39
    protected $fingerprinted = false;
40
41
    /** @var bool */
42
    protected $compiled = false;
43
44
    /** @var bool */
45
    protected $minified = false;
46
47
    /** @var bool */
48
    protected $optimize = false;
49
50
    /** @var bool */
51
    protected $ignore_missing = false;
52
53
    /**
54
     * Creates an Asset from a file path, an array of files path or an URL.
55
     *
56
     * @param Builder      $builder
57
     * @param string|array $paths
58
     * @param array|null   $options e.g.: ['fingerprint' => true, 'minify' => true, 'filename' => '', 'ignore_missing' => false]
59
     *
60
     * @throws RuntimeException
61
     */
62 1
    public function __construct(Builder $builder, $paths, array $options = null)
63
    {
64 1
        $this->builder = $builder;
65 1
        $this->config = $builder->getConfig();
66 1
        $paths = \is_array($paths) ? $paths : [$paths];
67 1
        array_walk($paths, function ($path) {
68 1
            if (!\is_string($path)) {
69
                throw new RuntimeException(sprintf('The path to an asset must be a string (%s given).', \gettype($path)));
70
            }
71 1
            if (empty($path)) {
72
                throw new RuntimeException('The path to an asset can\'t be empty.');
73
            }
74 1
            if (substr($path, 0, 2) == '..') {
75
                throw new RuntimeException(sprintf('The path to asset "%s" is wrong: it must be directly relative to "assets" or "static" directory, or a remote URL.', $path));
76
            }
77 1
        });
78 1
        $this->data = [
79 1
            'file'           => '',    // absolute file path
80 1
            'files'          => [],    // array of files path (if bundle)
81 1
            'filename'       => '',    // filename
82 1
            'path_source'    => '',    // public path to the file, before transformations
83 1
            'path'           => '',    // public path to the file, after transformations
84 1
            'url'            => null,  // URL of a remote image
85 1
            'missing'        => false, // if file not found, but missing ollowed 'missing' is true
86 1
            'ext'            => '',    // file extension
87 1
            'type'           => '',    // file type (e.g.: image, audio, video, etc.)
88 1
            'subtype'        => '',    // file media type (e.g.: image/png, audio/mp3, etc.)
89 1
            'size'           => 0,     // file size (in bytes)
90 1
            'content_source' => '',    // file content, before transformations
91 1
            'content'        => '',    // file content, after transformations
92 1
            'width'          => 0,     // width (in pixels) in case of an image
93 1
            'height'         => 0,     // height (in pixels) in case of an image
94 1
            'exif'           => [],    // exif data
95 1
        ];
96
97
        // handles options
98 1
        $fingerprint = (bool) $this->config->get('assets.fingerprint.enabled');
99 1
        $minify = (bool) $this->config->get('assets.minify.enabled');
100 1
        $optimize = (bool) $this->config->get('assets.images.optimize.enabled');
101 1
        $filename = '';
102 1
        $ignore_missing = false;
103 1
        $remote_fallback = null;
104 1
        $force_slash = true;
105 1
        extract(\is_array($options) ? $options : [], EXTR_IF_EXISTS);
106 1
        $this->ignore_missing = $ignore_missing;
107
108
        // fill data array with file(s) informations
109 1
        $cache = new Cache($this->builder, (string) $this->builder->getConfig()->get('cache.assets.dir'));
110 1
        $cacheKey = sprintf('%s__%s', implode('_', $paths), $this->builder->getVersion());
111 1
        if (!$cache->has($cacheKey)) {
112 1
            $pathsCount = \count($paths);
113 1
            $file = [];
114 1
            for ($i = 0; $i < $pathsCount; $i++) {
115
                // loads file(s)
116 1
                $file[$i] = $this->loadFile($paths[$i], $ignore_missing, $remote_fallback, $force_slash);
117
                // bundle: same type/ext only
118 1
                if ($i > 0) {
119 1
                    if ($file[$i]['type'] != $file[$i - 1]['type']) {
120
                        throw new RuntimeException(sprintf('Asset bundle type error (%s != %s).', $file[$i]['type'], $file[$i - 1]['type']));
121
                    }
122 1
                    if ($file[$i]['ext'] != $file[$i - 1]['ext']) {
123
                        throw new RuntimeException(sprintf('Asset bundle extension error (%s != %s).', $file[$i]['ext'], $file[$i - 1]['ext']));
124
                    }
125
                }
126
                // missing allowed = empty path
127 1
                if ($file[$i]['missing']) {
128 1
                    $this->data['missing'] = true;
129 1
                    $this->data['path'] = $file[$i]['path'];
130
131 1
                    continue;
132
                }
133
                // set data
134 1
                $this->data['size'] += $file[$i]['size'];
135 1
                $this->data['content_source'] .= $file[$i]['content'];
136 1
                $this->data['content'] .= $file[$i]['content'];
137 1
                if ($i == 0) {
138 1
                    $this->data['file'] = $file[$i]['filepath'];
139 1
                    $this->data['filename'] = $file[$i]['path'];
140 1
                    $this->data['path_source'] = $file[$i]['path'];
141 1
                    $this->data['path'] = $file[$i]['path'];
142 1
                    $this->data['url'] = $file[$i]['url'];
143 1
                    $this->data['ext'] = $file[$i]['ext'];
144 1
                    $this->data['type'] = $file[$i]['type'];
145 1
                    $this->data['subtype'] = $file[$i]['subtype'];
146 1
                    if ($this->data['type'] == 'image') {
147 1
                        $this->data['width'] = $this->getWidth();
148 1
                        $this->data['height'] = $this->getHeight();
149 1
                        if ($this->data['subtype'] == 'jpeg') {
150
                            $this->data['exif'] = Util\File::readExif($file[$i]['filepath']);
151
                        }
152
                    }
153
                    // bundle: default filename
154 1
                    if ($pathsCount > 1 && empty($filename)) {
155 1
                        switch ($this->data['ext']) {
156 1
                            case 'scss':
157 1
                            case 'css':
158 1
                                $filename = '/styles.css';
159 1
                                break;
160 1
                            case 'js':
161 1
                                $filename = '/scripts.js';
162 1
                                break;
163
                            default:
164
                                throw new RuntimeException(\sprintf('Asset bundle supports "%s" files only.', '.scss, .css and .js'));
165
                        }
166
                    }
167
                    // bundle: filename and path
168 1
                    if (!empty($filename)) {
169 1
                        $this->data['filename'] = $filename;
170 1
                        $this->data['path'] = '/' . ltrim($filename, '/');
171
                    }
172
                }
173
                // bundle: files path
174 1
                $this->data['files'][] = $file[$i]['filepath'];
175
            }
176 1
            // bundle: define path
177
            if ($pathsCount > 1 && empty($filename)) { /** @phpstan-ignore-line */
178 1
                switch ($this->data['ext']) {
179
                    case 'scss':
180
                    case 'css':
181 1
                        $this->data['path'] = '/styles.' . $file[0]['ext'];
182 1
                        break;
183
                    case 'js':
184
                        $this->data['path'] = '/scripts.' . $file[0]['ext'];
185 1
                        break;
186 1
                    default:
187
                        throw new RuntimeException(sprintf('Asset bundle supports "%s" files only.', '.scss, .css and .js'));
188
                }
189 1
            }
190 1
            $cache->set($cacheKey, $this->data);
191
        }
192
        $this->data = $cache->get($cacheKey);
193 1
194 1
        // fingerprinting
195
        if ($fingerprint) {
196
            $this->fingerprint();
197
        }
198
        // compiling (Sass files)
199
        if ((bool) $this->config->get('assets.compile.enabled')) {
200
            $this->compile();
201
        }
202
        // minifying (CSS and JavScript files)
203 1
        if ($minify) {
204
            $this->minify();
205
        }
206 1
        // optimizing (images files)
207
        if ($optimize) {
208
            $this->optimize = true;
209
        }
210
    }
211 1
212
    /**
213
     * Returns path.
214
     *
215 1
     * @throws RuntimeException
216
     */
217
    public function __toString(): string
218
    {
219 1
        try {
220
            $this->save();
221
        } catch (\Exception $e) {
222
            $this->builder->getLogger()->error($e->getMessage());
223
        }
224
225 1
        if ($this->isImageInCdn()) {
226
            return $this->buildImageCdnUrl();
227 1
        }
228 1
229
        if ($this->builder->getConfig()->get('canonicalurl')) {
230
            return (string) new Url($this->builder, $this->data['path'], ['canonical' => true]);
231 1
        }
232 1
233 1
        return $this->data['path'];
234 1
    }
235 1
236 1
    /**
237
     * Fingerprints a file.
238 1
     */
239
    public function fingerprint(): self
240 1
    {
241
        if ($this->fingerprinted) {
242
            return $this;
243
        }
244
245
        $fingerprint = hash('md5', $this->data['content_source']);
246
        $this->data['path'] = preg_replace(
247
            '/\.' . $this->data['ext'] . '$/m',
248 1
            ".$fingerprint." . $this->data['ext'],
249
            $this->data['path']
250 1
        );
251 1
252
        $this->fingerprinted = true;
253
254 1
        return $this;
255 1
    }
256
257
    /**
258 1
     * Compiles a SCSS.
259 1
     *
260 1
     * @throws RuntimeException
261 1
     */
262 1
    public function compile(): self
263 1
    {
264 1
        if ($this->compiled) {
265 1
            return $this;
266 1
        }
267 1
268 1
        if ($this->data['ext'] != 'scss') {
269 1
            return $this;
270 1
        }
271 1
272 1
        $cache = new Cache($this->builder, (string) $this->builder->getConfig()->get('cache.assets.dir'));
273 1
        $cacheKey = $cache->createKeyFromAsset($this, ['compiled']);
274
        if (!$cache->has($cacheKey)) {
275
            $scssPhp = new Compiler();
276 1
            $importDir = [];
277
            $importDir[] = Util::joinPath($this->config->getStaticPath());
278 1
            $importDir[] = Util::joinPath($this->config->getAssetsPath());
279
            $scssDir = $this->config->get('assets.compile.import') ?? [];
280
            $themes = $this->config->getTheme() ?? [];
281
            foreach ($scssDir as $dir) {
282
                $importDir[] = Util::joinPath($this->config->getStaticPath(), $dir);
283
                $importDir[] = Util::joinPath($this->config->getAssetsPath(), $dir);
284
                $importDir[] = Util::joinPath(\dirname($this->data['file']), $dir);
285
                foreach ($themes as $theme) {
286
                    $importDir[] = Util::joinPath($this->config->getThemeDirPath($theme, "static/$dir"));
287
                    $importDir[] = Util::joinPath($this->config->getThemeDirPath($theme, "assets/$dir"));
288
                }
289
            }
290
            $scssPhp->setImportPaths(array_unique($importDir));
291
            // source map
292
            if ($this->builder->isDebug() && (bool) $this->config->get('assets.compile.sourcemap')) {
293
                $importDir = [];
294
                $assetDir = (string) $this->config->get('assets.dir');
295
                $assetDirPos = strrpos($this->data['file'], DIRECTORY_SEPARATOR . $assetDir . DIRECTORY_SEPARATOR);
296 1
                $fileRelPath = substr($this->data['file'], $assetDirPos + 8);
297 1
                $filePath = Util::joinFile($this->config->getOutputPath(), $fileRelPath);
298 1
                $importDir[] = \dirname($filePath);
299
                foreach ($scssDir as $dir) {
300
                    $importDir[] = Util::joinFile($this->config->getOutputPath(), $dir);
301 1
                }
302
                $scssPhp->setImportPaths(array_unique($importDir));
303 1
                $scssPhp->setSourceMap(Compiler::SOURCE_MAP_INLINE);
304 1
                $scssPhp->setSourceMapOptions([
305 1
                    'sourceMapBasepath' => Util::joinPath($this->config->getOutputPath()),
306 1
                    'sourceRoot'        => '/',
307
                ]);
308
            }
309 1
            // output style
310 1
            $outputStyles = ['expanded', 'compressed'];
311 1
            $outputStyle = strtolower((string) $this->config->get('assets.compile.style'));
312 1
            if (!\in_array($outputStyle, $outputStyles)) {
313 1
                throw new RuntimeException(sprintf('Scss output style "%s" doesn\'t exists.', $outputStyle));
314 1
            }
315 1
            $scssPhp->setOutputStyle($outputStyle);
316 1
            // variables
317
            $variables = $this->config->get('assets.compile.variables') ?? [];
318 1
            if (!empty($variables)) {
319
                $variables = array_map('ScssPhp\ScssPhp\ValueConverter::parseValue', $variables);
320 1
                $scssPhp->replaceVariables($variables);
321
            }
322
            // update data
323
            $this->data['path'] = preg_replace('/sass|scss/m', 'css', $this->data['path']);
324
            $this->data['ext'] = 'css';
325
            $this->data['type'] = 'text';
326
            $this->data['subtype'] = 'text/css';
327
            $this->data['content'] = $scssPhp->compileString($this->data['content'])->getCss();
328 1
            $this->data['size'] = \strlen($this->data['content']);
329
            $this->compiled = true;
330
            $cache->set($cacheKey, $this->data);
331 1
        }
332
        $this->data = $cache->get($cacheKey);
333
334
        return $this;
335 1
    }
336
337
    /**
338
     * Minifying a CSS or a JS.
339 1
     *
340
     * @throws RuntimeException
341
     */
342
    public function minify(): self
343 1
    {
344
        // disable minify to preserve inline source map
345
        if ($this->builder->isDebug() && (bool) $this->config->get('assets.compile.sourcemap')) {
346
            return $this;
347 1
        }
348
349
        if ($this->minified) {
350
            return $this;
351
        }
352
353 1
        if ($this->data['ext'] == 'scss') {
354 1
            $this->compile();
355 1
        }
356 1
357 1
        if ($this->data['ext'] != 'css' && $this->data['ext'] != 'js') {
358 1
            return $this;
359 1
        }
360 1
361 1
        if (substr($this->data['path'], -8) == '.min.css' || substr($this->data['path'], -7) == '.min.js') {
362 1
            $this->minified;
363
364
            return $this;
365
        }
366 1
367 1
        $cache = new Cache($this->builder, (string) $this->builder->getConfig()->get('cache.assets.dir'));
368 1
        $cacheKey = $cache->createKeyFromAsset($this, ['minified']);
369 1
        if (!$cache->has($cacheKey)) {
370 1
            switch ($this->data['ext']) {
371 1
                case 'css':
372 1
                    $minifier = new Minify\CSS($this->data['content']);
373 1
                    break;
374 1
                case 'js':
375
                    $minifier = new Minify\JS($this->data['content']);
376 1
                    break;
377
                default:
378 1
                    throw new RuntimeException(sprintf('Not able to minify "%s"', $this->data['path']));
379
            }
380
            $this->data['path'] = preg_replace(
381
                '/\.' . $this->data['ext'] . '$/m',
382
                '.min.' . $this->data['ext'],
383
                $this->data['path']
384 1
            );
385
            $this->data['content'] = $minifier->minify();
386 1
            $this->data['size'] = \strlen($this->data['content']);
387 1
            $this->minified = true;
388
            $cache->set($cacheKey, $this->data);
389
        }
390 1
        $this->data = $cache->get($cacheKey);
391 1
392 1
        return $this;
393 1
    }
394 1
395
    /**
396 1
     * Optimizing an image.
397 1
     */
398
    public function optimize(string $filepath): self
399 1
    {
400 1
        if ($this->data['type'] != 'image') {
401 1
            return $this;
402 1
        }
403 1
404
        $quality = $this->config->get('assets.images.quality') ?? 75;
405
        $cache = new Cache($this->builder, (string) $this->builder->getConfig()->get('cache.assets.dir'));
406
        $tags = ["q$quality", 'optimized'];
407
        if ($this->data['width']) {
408
            array_unshift($tags, "{$this->data['width']}x");
409
        }
410
        $cacheKey = $cache->createKeyFromAsset($this, $tags);
411 1
        if (!$cache->has($cacheKey)) {
412 1
            $message = $this->data['path'];
413 1
            $sizeBefore = filesize($filepath);
414 1
            Optimizer::create($quality)->optimize($filepath);
415
            $sizeAfter = filesize($filepath);
416
            if ($sizeAfter < $sizeBefore) {
417
                $message = sprintf(
418
                    '%s (%s Ko -> %s Ko)',
419 1
                    $message,
420
                    ceil($sizeBefore / 1000),
421 1
                    ceil($sizeAfter / 1000)
422
                );
423
            }
424
            $this->data['content'] = Util\File::fileGetContents($filepath);
425
            $this->data['size'] = $sizeAfter;
426
            $cache->set($cacheKey, $this->data);
427
            $this->builder->getLogger()->debug(sprintf('Asset "%s" optimized', $message));
428
        }
429 1
        $this->data = $cache->get($cacheKey, $this->data);
430
431 1
        return $this;
432
    }
433
434 1
    /**
435
     * Resizes an image with a new $width.
436
     *
437 1
     * @throws RuntimeException
438 1
     */
439
    public function resize(int $width): self
440
    {
441 1
        if ($this->data['missing']) {
442 1
            throw new RuntimeException(sprintf('Not able to resize "%s": file not found', $this->data['path']));
443
        }
444 1
        if ($this->data['type'] != 'image') {
445
            throw new RuntimeException(sprintf('Not able to resize "%s": not an image', $this->data['path']));
446
        }
447
        if ($width >= $this->data['width']) {
448 1
            return $this;
449 1
        }
450 1
451 1
        $assetResized = clone $this;
452 1
        $assetResized->data['width'] = $width;
453
454
        if ($this->isImageInCdn()) {
455 1
            return $assetResized; // returns the asset with the new width only: CDN do the rest of the job
456
        }
457
458
        $quality = $this->config->get('assets.images.quality');
459
        $cache = new Cache($this->builder, (string) $this->builder->getConfig()->get('cache.assets.dir'));
460 1
        $cacheKey = $cache->createKeyFromAsset($assetResized, ["{$width}x", "q$quality"]);
461 1
        if (!$cache->has($cacheKey)) {
462 1
            if ($assetResized->data['type'] !== 'image') {
463 1
                throw new RuntimeException(sprintf('Not able to resize "%s"', $assetResized->data['path']));
464 1
            }
465
            if (!\extension_loaded('gd')) {
466
                throw new RuntimeException('GD extension is required to use images resize.');
467
            }
468 1
469 1
            try {
470 1
                $img = ImageManager::make($assetResized->data['content_source'])->encode($assetResized->data['ext']);
471 1
                $img->resize($width, null, function (\Intervention\Image\Constraint $constraint) {
472 1
                    $constraint->aspectRatio();
473 1
                    $constraint->upsize();
474
                });
475
            } catch (\Exception $e) {
476 1
                throw new RuntimeException(sprintf('Not able to resize image "%s": %s', $assetResized->data['path'], $e->getMessage()));
477
            }
478
            $assetResized->data['path'] = '/' . Util::joinPath(
479 1
                (string) $this->config->get('assets.target'),
480 1
                (string) $this->config->get('assets.images.resize.dir'),
481 1
                (string) $width,
482 1
                $assetResized->data['path']
483
            );
484
485
            try {
486
                if ($assetResized->data['subtype'] == 'image/jpeg') {
487 1
                    $img->interlace();
488
                }
489 1
                $assetResized->data['content'] = (string) $img->encode($assetResized->data['ext'], $quality);
490
                $img->destroy();
491 1
                $assetResized->data['height'] = $assetResized->getHeight();
492
                $assetResized->data['size'] = \strlen($assetResized->data['content']);
493
            } catch (\Exception $e) {
494
                throw new RuntimeException(sprintf('Not able to encode image "%s": %s', $assetResized->data['path'], $e->getMessage()));
495
            }
496
497
            $cache->set($cacheKey, $assetResized->data);
498
        }
499 1
        $assetResized->data = $cache->get($cacheKey);
500
501 1
        return $assetResized;
502
    }
503
504
    /**
505 1
     * Converts an image asset to WebP format.
506 1
     *
507
     * @throws RuntimeException
508
     */
509 1
    public function webp(?int $quality = null): self
510 1
    {
511 1
        if ($this->data['type'] !== 'image') {
512
            throw new RuntimeException(sprintf('can\'t convert "%s" (%s) to WebP: it\'s not an image file.', $this->data['path'], $this->data['type']));
513 1
        }
514
515
        if ($quality === null) {
516
            $quality = (int) $this->config->get('assets.images.quality') ?? 75;
517 1
        }
518 1
519
        $assetWebp = clone $this;
520
        $format = 'webp';
521
        $assetWebp['ext'] = $format;
522
523
        if ($this->isImageInCdn()) {
524
            return $assetWebp; // returns the asset with the new extension ('webp') only: CDN do the rest of the job
525
        }
526
527
        $img = ImageManager::make($assetWebp['content']);
528
        $assetWebp['content'] = (string) $img->encode($format, $quality);
529
        $img->destroy();
530
        $assetWebp['path'] = preg_replace('/\.' . $this->data['ext'] . '$/m', ".$format", $this->data['path']);
531 1
        $assetWebp['subtype'] = "image/$format";
532
        $assetWebp['size'] = \strlen($assetWebp['content']);
0 ignored issues
show
Bug introduced by
It seems like $assetWebp['content'] can also be of type null; however, parameter $string of strlen() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

532
        $assetWebp['size'] = \strlen(/** @scrutinizer ignore-type */ $assetWebp['content']);
Loading history...
533 1
534 1
        return $assetWebp;
535
    }
536
537
    /**
538
     * Implements \ArrayAccess.
539
     */
540
    #[\ReturnTypeWillChange]
541
    public function offsetSet($offset, $value): void
542 1
    {
543
        if (!\is_null($offset)) {
544 1
            $this->data[$offset] = $value;
545
        }
546
    }
547
548
    /**
549
     * Implements \ArrayAccess.
550
     */
551
    #[\ReturnTypeWillChange]
552
    public function offsetExists($offset): bool
553
    {
554
        return isset($this->data[$offset]);
555
    }
556
557
    /**
558
     * Implements \ArrayAccess.
559
     */
560 1
    #[\ReturnTypeWillChange]
561
    public function offsetUnset($offset): void
562 1
    {
563
        unset($this->data[$offset]);
564
    }
565
566
    /**
567
     * Implements \ArrayAccess.
568
     */
569
    #[\ReturnTypeWillChange]
570
    public function offsetGet($offset)
571 1
    {
572
        return isset($this->data[$offset]) ? $this->data[$offset] : null;
573 1
    }
574
575
    /**
576
     * Hashing content of an asset with the specified algo, sha384 by default.
577
     * Used for SRI (Subresource Integrity).
578
     *
579
     * @see https://developer.mozilla.org/fr/docs/Web/Security/Subresource_Integrity
580
     */
581 1
    public function getIntegrity(string $algo = 'sha384'): string
582
    {
583 1
        return sprintf('%s-%s', $algo, base64_encode(hash($algo, $this->data['content'], true)));
584
    }
585
586
    /**
587 1
     * Returns MP3 file infos.
588
     *
589
     * @see https://github.com/wapmorgan/Mp3Info
590
     */
591
    public function getAudio(): Mp3Info
592
    {
593
        if ($this->data['type'] !== 'audio') {
594
            throw new RuntimeException(sprintf('Not able to get audio infos of "%s"', $this->data['path']));
595 1
        }
596
597 1
        return new Mp3Info($this->data['file']);
598 1
    }
599
600
    /**
601 1
     * Returns MP4 file infos.
602
     *
603
     * @see https://github.com/clwu88/php-read-mp4info
604
     */
605
    public function getVideo(): array
606
    {
607
        if ($this->data['type'] !== 'video') {
608
            throw new RuntimeException(sprintf('Not able to get video infos of "%s"', $this->data['path']));
609
        }
610 1
611
        return \Clwu\Mp4::getInfo($this->data['file']);
612 1
    }
613 1
614
    /**
615 1
     * Returns the data URL (encoded in Base64).
616 1
     *
617 1
     * @throws RuntimeException
618 1
     */
619
    public function dataurl(): string
620
    {
621
        if ($this->data['type'] == 'image' && !$this->isSVG()) {
622
            return (string) ImageManager::make($this->data['content'])->encode('data-url', $this->config->get('assets.images.quality'));
623
        }
624
625
        return sprintf('data:%s;base64,%s', $this->data['subtype'], base64_encode($this->data['content']));
626
    }
627
628
    /**
629
     * Saves file.
630
     * Note: a file from `static/` with the same name will NOT be overridden.
631
     *
632
     * @throws RuntimeException
633 1
     */
634
    public function save(): void
635 1
    {
636 1
        $filepath = Util::joinFile($this->config->getOutputPath(), $this->data['path']);
637
        if (!$this->builder->getBuildOptions()['dry-run'] && !Util\File::getFS()->exists($filepath)) {
638
            try {
639
                Util\File::getFS()->dumpFile($filepath, $this->data['content']);
640
                $this->builder->getLogger()->debug(sprintf('Asset "%s" saved', $filepath));
641
                if ($this->optimize) {
642
                    $this->optimize($filepath);
643
                }
644
            } catch (\Symfony\Component\Filesystem\Exception\IOException $e) {
645
                if (!$this->ignore_missing) {
646
                    throw new RuntimeException(sprintf('Can\'t save asset "%s"', $filepath));
647
                }
648
            }
649
        }
650
    }
651 1
652
    /**
653 1
     * Is Asset is an image in CDN.
654 1
     *
655 1
     * @return bool
656
     */
657
    public function isImageInCdn()
658 1
    {
659 1
        if ($this->data['type'] != 'image' || (bool) $this->config->get('assets.images.cdn.enabled') !== true || ($this->isSVG() && (bool) $this->config->get('assets.images.cdn.svg') !== true)) {
660 1
            return false;
661 1
        }
662 1
        // remote image?
663
        if ($this->data['url'] !== null && (bool) $this->config->get('assets.images.cdn.remote') !== true) {
664 1
            return false;
665
        }
666
667
        return true;
668
    }
669
670 1
    /**
671 1
     * Load file data.
672 1
     *
673 1
     * @throws RuntimeException
674 1
     */
675 1
    private function loadFile(string $path, bool $ignore_missing = false, ?string $remote_fallback = null, bool $force_slash = true): array
676
    {
677 1
        $file = [
678 1
            'url' => null,
679 1
        ];
680 1
681 1
        try {
682
            $filePath = $this->findFile($path, $remote_fallback);
683 1
        } catch (\Exception $e) {
684
            if ($ignore_missing) {
685 1
                $file['path'] = $path;
686 1
                $file['missing'] = true;
687
688
                return $file;
689 1
            }
690 1
691
            throw new RuntimeException(sprintf('Asset file "%s" doesn\'t exist', $path));
692 1
        }
693 1
694 1
        if (Util\Url::isUrl($path)) {
695 1
            $file['url'] = $path;
696 1
            $path = Util::joinPath(
697 1
                (string) $this->config->get('assets.target'),
698 1
                Util\File::getFS()->makePathRelative($filePath, $this->config->getCacheAssetsRemotePath())
699 1
            );
700
            // remote_fallback in assets/ ont in cache/assets/remote/
701 1
            if (substr(Util\File::getFS()->makePathRelative($filePath, $this->config->getCacheAssetsRemotePath()), 0, 2) == '..') {
702
                $path = Util::joinPath(
703
                    (string) $this->config->get('assets.target'),
704
                    Util\File::getFS()->makePathRelative($filePath, $this->config->getAssetsPath())
705
                );
706
            }
707
            $force_slash = true;
708
        }
709
        if ($force_slash) {
710
            $path = '/' . ltrim($path, '/');
711
        }
712
713 1
        list($type, $subtype) = Util\File::getMimeType($filePath);
714
        $content = Util\File::fileGetContents($filePath);
715
716 1
        $file['filepath'] = $filePath;
717 1
        $file['path'] = $path;
718 1
        $file['ext'] = pathinfo($path)['extension'] ?? '';
719 1
        $file['type'] = $type;
720 1
        $file['subtype'] = $subtype;
721 1
        $file['size'] = filesize($filePath);
722
        $file['content'] = $content;
723 1
        $file['missing'] = false;
724 1
725
        return $file;
726 1
    }
727 1
728 1
    /**
729 1
     * Try to find the file:
730 1
     *   1. remote (if $path is a valid URL)
731 1
     *   2. in static/
732 1
     *   3. in themes/<theme>/static/
733 1
     * Returns local file path or throw an exception.
734
     *
735 1
     * @throws RuntimeException
736
     */
737 1
    private function findFile(string $path, ?string $remote_fallback = null): string
738 1
    {
739
        // in case of remote file: save it and returns cached file path
740 1
        if (Util\Url::isUrl($path)) {
741
            $url = $path;
742
            $urlHost = parse_url($path, PHP_URL_HOST);
743 1
            $urlPath = parse_url($path, PHP_URL_PATH);
744 1
            $urlQuery = parse_url($path, PHP_URL_QUERY);
745
            $extension = pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION);
746 1
            // Google Fonts hack
747
            if (Util\Str::endsWith($urlPath, '/css') || Util\Str::endsWith($urlPath, '/css2')) {
748 1
                $extension = 'css';
749 1
            }
750 1
            $relativePath = Page::slugify(sprintf(
751 1
                '%s%s%s%s',
752
                $urlHost,
753
                $this->sanitize($urlPath),
754
                $urlQuery ? "-$urlQuery" : '',
755
                $urlQuery && $extension ? ".$extension" : ''
756
            ));
757
            $filePath = Util::joinFile($this->config->getCacheAssetsRemotePath(), $relativePath);
758
            // not already in cache
759 1
            if (!file_exists($filePath)) {
760
                try {
761
                    if (!Util\Url::isRemoteFileExists($url)) {
762 1
                        throw new RuntimeException(\sprintf('File "%s" doesn\'t exists', $url));
763
                    }
764
                    if (false === $content = Util\File::fileGetContents($url, true)) {
765
                        throw new RuntimeException(\sprintf('Can\'t get content of file "%s"', $url));
766 1
                    }
767 1
                    if (strlen($content) <= 1) {
768 1
                        throw new RuntimeException(\sprintf('File "%s" is empty', $url));
769
                    }
770
                } catch (RuntimeException $e) {
771
                    // is there a fallback in assets/
772 1
                    if ($remote_fallback) {
773 1
                        $filePath = Util::joinFile($this->config->getAssetsPath(), $remote_fallback);
774 1
                        if (Util\File::getFS()->exists($filePath)) {
775 1
                            return $filePath;
776
                        }
777
                        throw new RuntimeException(\sprintf('Fallback file "%s" doesn\'t exists', $filePath));
778
                    }
779
780 1
                    return false;
781 1
                }
782 1
                if (false === $content = Util\File::fileGetContents($url, true)) {
783
                    return false;
784
                }
785
                if (\strlen($content) <= 1) {
786 1
                    throw new RuntimeException(sprintf('Asset at "%s" is empty', $url));
787 1
                }
788 1
                // put file in cache
789 1
                Util\File::getFS()->dumpFile($filePath, $content);
790
            }
791
792
            return $filePath;
793 1
        }
794
795
        // checks in assets/
796
        $filePath = Util::joinFile($this->config->getAssetsPath(), $path);
797
        if (Util\File::getFS()->exists($filePath)) {
798
            return $filePath;
799
        }
800
801 1
        // checks in each themes/<theme>/assets/
802
        foreach ($this->config->getTheme() as $theme) {
803 1
            $filePath = Util::joinFile($this->config->getThemeDirPath($theme, 'assets'), $path);
804
            if (Util\File::getFS()->exists($filePath)) {
805
                return $filePath;
806 1
            }
807 1
        }
808
809 1
        // checks in static/
810
        $filePath = Util::joinFile($this->config->getStaticTargetPath(), $path);
811
        if (Util\File::getFS()->exists($filePath)) {
812
            return $filePath;
813 1
        }
814
815
        // checks in each themes/<theme>/static/
816
        foreach ($this->config->getTheme() as $theme) {
817
            $filePath = Util::joinFile($this->config->getThemeDirPath($theme, 'static'), $path);
818
            if (Util\File::getFS()->exists($filePath)) {
819
                return $filePath;
820
            }
821 1
        }
822
823 1
        throw new RuntimeException(\sprintf('Can\'t find file "%s"', $path));
824
    }
825
826 1
    /**
827 1
     * Returns the width of an image/SVG.
828
     *
829 1
     * @throws RuntimeException
830
     */
831
    private function getWidth(): int
832
    {
833 1
        if ($this->data['type'] != 'image') {
834
            return 0;
835
        }
836
        if ($this->isSVG() && false !== $svg = $this->getSvgAttributes()) {
837
            return (int) $svg->width;
838
        }
839
        if (false === $size = $this->getImageSize()) {
840
            throw new RuntimeException(sprintf('Not able to get width of "%s"', $this->data['path']));
841
        }
842
843 1
        return $size[0];
844
    }
845 1
846
    /**
847
     * Returns the height of an image/SVG.
848
     *
849
     * @throws RuntimeException
850 1
     */
851 1
    private function getHeight(): int
852
    {
853
        if ($this->data['type'] != 'image') {
854
            return 0;
855
        }
856
        if ($this->isSVG() && false !== $svg = $this->getSvgAttributes()) {
857 1
            return (int) $svg->height;
858
        }
859
        if (false === $size = $this->getImageSize()) {
860
            throw new RuntimeException(sprintf('Not able to get height of "%s"', $this->data['path']));
861
        }
862
863 1
        return $size[1];
864
    }
865 1
866
    /**
867
     * Returns image size informations.
868
     *
869
     * @see https://www.php.net/manual/function.getimagesize.php
870
     *
871
     * @return array|false
872
     */
873 1
    private function getImageSize()
874
    {
875 1
        if (!$this->data['type'] == 'image') {
876
            return false;
877
        }
878
879 1
        try {
880
            if (false === $size = getimagesizefromstring($this->data['content'])) {
881
                return false;
882
            }
883
        } catch (\Exception $e) {
884
            throw new RuntimeException(sprintf('Handling asset "%s" failed: "%s"', $this->data['path_source'], $e->getMessage()));
885 1
        }
886
887 1
        return $size;
888
    }
889
890
    /**
891
     * Returns true if asset is a SVG.
892
     */
893
    private function isSVG(): bool
894
    {
895
        return \in_array($this->data['subtype'], ['image/svg', 'image/svg+xml']) || $this->data['ext'] == 'svg';
896
    }
897
898
    /**
899
     * Returns SVG attributes.
900
     *
901
     * @return \SimpleXMLElement|false
902
     */
903
    private function getSvgAttributes()
904
    {
905
        if (false === $xml = simplexml_load_string($this->data['content_source'])) {
906
            return false;
907
        }
908
909
        return $xml->attributes();
910
    }
911
912
    /**
913
     * Replaces some characters by '_'.
914
     */
915
    private function sanitize(string $string): string
916
    {
917
        return str_replace(['<', '>', ':', '"', '\\', '|', '?', '*'], '_', $string);
918
    }
919
920
    /**
921
     * Builds CDN image URL.
922
     */
923
    private function buildImageCdnUrl(): string
924
    {
925
        return str_replace(
926
            [
927
                '%account%',
928
                '%image_url%',
929
                '%width%',
930
                '%quality%',
931
                '%format%',
932
            ],
933
            [
934
                $this->config->get('assets.images.cdn.account'),
935
                ltrim($this->data['url'] ?? (string) new Url($this->builder, $this->data['path'], ['canonical' => $this->config->get('assets.images.cdn.canonical') ?? true]), '/'),
936
                $this->data['width'],
937
                $this->config->get('assets.images.quality') ?? 75,
938
                $this->data['ext'],
939
            ],
940
            (string) $this->config->get('assets.images.cdn.url')
941
        );
942
    }
943
}
944