Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.73% covered (warning)
72.73%
48 / 66
55.56% covered (warning)
55.56%
5 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Image
72.73% covered (warning)
72.73%
48 / 66
55.56% covered (warning)
55.56%
5 / 9
43.90
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
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 resize
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 get
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createCacheFile
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 writeRendition
59.26% covered (warning)
59.26%
16 / 27
0.00% covered (danger)
0.00%
0 / 1
16.76
 getCacheFilePath
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 secondDimension
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
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', 'image/jpeg', 'image/png', 'image/webp' => true,
41            default => false,
42        };
43    }
44
45    /**
46     * Materialize the rendition carrying the given size name next to its
47     * native URL.
48     */
49    public function resize(
50        Size $size,
51        ResizeMode $mode,
52        bool $enlarge,
53        ?int $quality,
54        string $name,
55    ): static {
56        if (!$this->isResizable()) {
57            return $this;
58        }
59
60        $cacheFile = $this->getCacheFilePath($name);
61        $this->cacheFile = $cacheFile;
62
63        if (!is_file($cacheFile) || filemtime($this->file) > filemtime($cacheFile)) {
64            $this->createCacheFile($cacheFile, $size, $mode, $enlarge, $quality);
65        }
66
67        return $this;
68    }
69
70    public function get(): ImageResize
71    {
72        return new ImageResize($this->file);
73    }
74
75    protected function createCacheFile(
76        string $cacheFile,
77        Size $size,
78        ResizeMode $mode,
79        bool $enlarge,
80        ?int $quality,
81    ): void {
82        // Concurrent first requests must never see a half-written file, so
83        // each one writes its own temporary file and moves it into place.
84        // Threads of one worker process share a PID, hence the random name.
85        $tmp = $cacheFile . '.tmp-' . bin2hex(random_bytes(6));
86
87        try {
88            $this->writeRendition($tmp, $size, $mode, $enlarge, $quality);
89
90            if (!rename($tmp, $cacheFile)) {
91                throw new RuntimeException('Assets error: could not move rendition into place');
92            }
93        } catch (ImageResizeException $e) {
94            throw new RuntimeException('Assets error: ' . $e->getMessage(), $e->getCode(), previous: $e);
95        } finally {
96            if (is_file($tmp)) {
97                unlink($tmp);
98            }
99        }
100    }
101
102    protected function writeRendition(
103        string $tmp,
104        Size $size,
105        ResizeMode $mode,
106        bool $enlarge,
107        ?int $quality,
108    ): void {
109        // Gumlet keeps only the first frame, so an animated GIF is
110        // materialized as a copy of the original instead.
111        if (Util::isAnimatedGif($this->file)) {
112            if (!copy($this->file, $tmp)) {
113                throw new RuntimeException('Assets error: could not copy animated gif');
114            }
115
116            return;
117        }
118
119        $image = match ($mode) {
120            ResizeMode::Width => $this->get()->resizeToWidth($size->firstDimension, $enlarge),
121            ResizeMode::Fit => $this->get()->resizeToBestFit(
122                $size->firstDimension,
123                $this->secondDimension($size, $mode),
124                $enlarge,
125            ),
126            ResizeMode::Crop => $this->get()->crop(
127                $size->firstDimension,
128                $size->secondDimension ?? $size->firstDimension,
129                $enlarge,
130                $size->cropMode ?? ImageResize::CROPCENTER,
131            ),
132            ResizeMode::Height => $this->get()->resizeToHeight($size->firstDimension, $enlarge),
133            ResizeMode::LongSide => $this->get()->resizeToLongSide($size->firstDimension, $enlarge),
134            ResizeMode::ShortSide => $this->get()->resizeToShortSide($size->firstDimension, $enlarge),
135            ResizeMode::Resize => $this->get()->resize(
136                $size->firstDimension,
137                $this->secondDimension($size, $mode),
138                $enlarge,
139            ),
140        };
141
142        $image->save($tmp, quality: $quality);
143    }
144
145    protected function getCacheFilePath(string $name): string
146    {
147        $info = pathinfo($this->relativeFile);
148        $relativeDir = $info['dirname'] ?? null;
149        // pathinfo does not handle multiple dots like .tar.gz well
150        $filenameSegments = explode('.', $info['basename']);
151        $filenameExtension = array_pop($filenameSegments);
152        $filenameBasename = implode('.', $filenameSegments);
153
154        $cacheDir = $this->assets->cacheDir;
155
156        if ($relativeDir !== '/') {
157            $cacheDir .= $relativeDir;
158
159            Path::ensureDirectory($cacheDir);
160        }
161
162        return $cacheDir . '/' . $filenameBasename . '-' . $name . '.' . $filenameExtension;
163    }
164
165    private function secondDimension(Size $size, ResizeMode $mode): int
166    {
167        return (
168            $size->secondDimension ?? throw new RuntimeException(
169                "Assets error: resize mode {$mode->name} needs two dimensions",
170            )
171        );
172    }
173}