Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.13% covered (warning)
72.13%
44 / 61
57.14% covered (warning)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Image
72.13% covered (warning)
72.13%
44 / 61
57.14% covered (warning)
57.14%
4 / 7
49.48
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 path
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isResizable
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
8.83
 resize
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 get
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createCacheFile
59.38% covered (warning)
59.38%
19 / 32
0.00% covered (danger)
0.00%
0 / 1
24.33
 getCacheFilePath
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Assets;
6
7use Cosray\Exception\RuntimeException;
8use Cosray\Util\Path;
9use Gumlet\ImageResize;
10use Gumlet\ImageResizeException;
11
12/**
13 * Materializes renditions for the cache fallback route. Everything else
14 * only builds URLs (`Asset::path()`/`sizePath()`); this is the single
15 * place that touches the resize pipeline.
16 */
17class Image
18{
19    public readonly string $relativeFile;
20    public readonly string $file;
21    protected ?string $cacheFile = null;
22
23    public function __construct(
24        protected readonly Assets $assets,
25        string $file,
26    ) {
27        $this->file = Path::inside($assets->assetsDir, $file, checkIsFile: true);
28        $this->isResizable();
29        $this->relativeFile = substr($this->file, strlen($assets->assetsDir));
30    }
31
32    public function path(): string
33    {
34        return $this->cacheFile ?: $this->file;
35    }
36
37    public function isResizable(): bool
38    {
39        return match (mime_content_type($this->file)) {
40            'image/gif' => true,
41            'image/jpeg' => true,
42            'image/png' => true,
43            'image/webp' => true,
44            default => false,
45        };
46    }
47
48    /**
49     * Materialize the rendition carrying the given size name next to its
50     * native URL.
51     */
52    public function resize(
53        Size $size,
54        ResizeMode $mode,
55        bool $enlarge,
56        ?int $quality,
57        string $name,
58    ): static {
59        if (!$this->isResizable()) {
60            return $this;
61        }
62
63        $this->cacheFile = $this->getCacheFilePath($name);
64
65        if (!is_file($this->cacheFile) || filemtime($this->file) > filemtime($this->cacheFile)) {
66            $this->createCacheFile($size, $mode, $enlarge, $quality);
67        }
68
69        return $this;
70    }
71
72    public function get(): ImageResize
73    {
74        return new ImageResize($this->file);
75    }
76
77    protected function createCacheFile(
78        Size $size,
79        ResizeMode $mode,
80        bool $enlarge,
81        ?int $quality,
82    ): void {
83        // Concurrent first requests must never see a half-written file.
84        $tmp = $this->cacheFile . '.tmp' . getmypid();
85
86        // Gumlet keeps only the first frame, so an animated GIF is
87        // materialized as a copy of the original instead.
88        if (Util::isAnimatedGif($this->file)) {
89            if (!copy($this->file, $tmp) || !rename($tmp, $this->cacheFile)) {
90                throw new RuntimeException('Assets error: could not copy animated gif');
91            }
92
93            return;
94        }
95
96        try {
97            $image = match ($mode) {
98                ResizeMode::Width => $this->get()->resizeToWidth($size->firstDimension, $enlarge),
99                ResizeMode::Fit => $this->get()->resizeToBestFit(
100                    $size->firstDimension,
101                    $size->secondDimension,
102                    $enlarge,
103                ),
104                ResizeMode::Crop => $this->get()->crop(
105                    $size->firstDimension,
106                    $size->secondDimension ?? $size->firstDimension,
107                    $enlarge,
108                    $size->cropMode ?? ImageResize::CROPCENTER,
109                ),
110                ResizeMode::Height => $this->get()->resizeToHeight($size->firstDimension, $enlarge),
111                ResizeMode::LongSide => $this->get()->resizeToLongSide($size->firstDimension, $enlarge),
112                ResizeMode::ShortSide => $this->get()->resizeToShortSide($size->firstDimension, $enlarge),
113                ResizeMode::Resize => $this->get()->resize(
114                    $size->firstDimension,
115                    $size->secondDimension,
116                    $enlarge,
117                ),
118            };
119
120            $image->save($tmp, quality: $quality);
121
122            if (!rename($tmp, $this->cacheFile)) {
123                throw new RuntimeException('Assets error: could not move rendition into place');
124            }
125        } catch (ImageResizeException $e) {
126            throw new RuntimeException('Assets error: ' . $e->getMessage(), $e->getCode(), previous: $e);
127        }
128    }
129
130    protected function getCacheFilePath(string $name): string
131    {
132        $info = pathinfo($this->relativeFile);
133        $relativeDir = $info['dirname'] ?? null;
134        // pathinfo does not handle multiple dots like .tar.gz well
135        $filenameSegments = explode('.', $info['basename']);
136        $filenameExtension = array_pop($filenameSegments);
137        $filenameBasename = implode('.', $filenameSegments);
138
139        $cacheDir = $this->assets->cacheDir;
140
141        if ($relativeDir !== '/') {
142            $cacheDir .= $relativeDir;
143
144            // create cache sub directory if it does not exist
145            if (!is_dir($cacheDir)) {
146                mkdir($cacheDir, 0o755, true);
147            }
148        }
149
150        return $cacheDir . '/' . $filenameBasename . '-' . $name . '.' . $filenameExtension;
151    }
152}