Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 102
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Image
0.00% covered (danger)
0.00%
0 / 102
0.00% covered (danger)
0.00%
0 / 10
1892
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 path
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 publicPath
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 url
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isResizable
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
42
 resize
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 delete
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 createCacheFile
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
110
 getCacheFilePath
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
182
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Assets;
6
7use Celemas\Core\Request;
8use Cosray\Exception\RuntimeException;
9use Cosray\Util\Path;
10use Gumlet\ImageResize;
11use Gumlet\ImageResizeException;
12
13class Image
14{
15    public readonly string $relativeFile;
16    public readonly string $file;
17    protected ?string $cacheFile = null;
18    protected bool $isAnimated = false;
19
20    public function __construct(
21        protected readonly Request $request,
22        protected readonly Assets $assets,
23        string $file,
24    ) {
25        try {
26            $this->file = Path::inside($assets->assetsDir, $file, checkIsFile: true);
27        } catch (RuntimeException) {
28            $this->file = Path::inside($assets->assetsDir, 'not-found.jpg', checkIsFile: true);
29        }
30
31        $this->isResizable();
32        $this->relativeFile = substr($this->file, strlen($assets->assetsDir));
33    }
34
35    public function path(): string
36    {
37        return $this->cacheFile ?: $this->file;
38    }
39
40    public function publicPath(bool $bust = false): string
41    {
42        $path = implode('/', array_map('rawurlencode', explode('/', str_replace(
43            '\\',
44            '/',
45            $this->path(),
46        ))));
47
48        if ($bust) {
49            $buster = hash('xxh32', (string) filemtime($this->file));
50            $path .= '?v=' . $buster;
51        }
52
53        return substr($path, strlen($this->assets->publicDir));
54    }
55
56    public function url(bool $bust = false): string
57    {
58        return $this->request->origin() . $this->publicPath($bust);
59    }
60
61    public function isResizable(): bool
62    {
63        return match (mime_content_type($this->file)) {
64            'image/gif' => true,
65            'image/jpeg' => true,
66            'image/png' => true,
67            'image/webp' => true,
68            default => false,
69        };
70    }
71
72    public function resize(Size $size, ResizeMode $mode, bool $enlarge, ?int $quality): static
73    {
74        if (!$this->isResizable()) {
75            return $this;
76        }
77
78        $this->cacheFile = $this->getCacheFilePath($size, $mode, $enlarge);
79
80        if (is_file($this->cacheFile)) {
81            $fileMtime = filemtime($this->file);
82            $cacheMtime = filemtime($this->cacheFile);
83
84            if ($fileMtime > $cacheMtime) {
85                $this->createCacheFile($size, $mode, $enlarge, $quality);
86            }
87        } else {
88            if (Util::isAnimatedGif($this->file)) {
89                return $this;
90            }
91
92            $this->createCacheFile($size, $mode, $enlarge, $quality);
93        }
94
95        return $this;
96    }
97
98    public function delete(): bool
99    {
100        return unlink($this->file);
101    }
102
103    public function get(): ImageResize
104    {
105        return new ImageResize($this->file);
106    }
107
108    protected function createCacheFile(
109        Size $size,
110        ResizeMode $mode,
111        bool $enlarge,
112        ?int $quality,
113    ): void {
114        try {
115            $image = match ($mode) {
116                ResizeMode::Width => $this->get()->resizeToWidth($size->firstDimension, $enlarge),
117                ResizeMode::Fit => $this->get()->resizeToBestFit(
118                    $size->firstDimension,
119                    $size->secondDimension,
120                    $enlarge,
121                ),
122                ResizeMode::Crop => $this->get()->crop(
123                    $size->firstDimension,
124                    $size->secondDimension,
125                    $size->cropMode,
126                ),
127                ResizeMode::Height => $this->get()->resizeToHeight($size->firstDimension, $enlarge),
128                ResizeMode::LongSide => $this->get()->resizeToLongSide($size->firstDimension, $enlarge),
129                ResizeMode::ShortSide => $this->get()->resizeToShortSide($size->firstDimension, $enlarge),
130                ResizeMode::FreeCrop => $this->get()->freecrop(
131                    $size->firstDimension,
132                    $size->secondDimension,
133                    x: $size->cropMode['x'],
134                    y: $size->cropMode['y'],
135                ),
136                ResizeMode::Resize => $this->get()->resize(
137                    $size->firstDimension,
138                    $size->secondDimension,
139                    $enlarge,
140                ),
141            };
142
143            $image->save($this->cacheFile, quality: $quality);
144        } catch (ImageResizeException $e) {
145            throw new RuntimeException('Assets error: ' . $e->getMessage(), $e->getCode(), previous: $e);
146        }
147    }
148
149    protected function getCacheFilePath(Size $size, ResizeMode $mode, bool $enlarge): string
150    {
151        $info = pathinfo($this->relativeFile);
152        $relativeDir = $info['dirname'] ?? null;
153        // pathinfo does not handle multiple dots like .tar.gz well
154        $filenameSegments = explode('.', $info['basename']);
155        $filenameExtension = array_pop($filenameSegments);
156        $filenameBasename = implode('.', $filenameSegments);
157
158        $cacheDir = $this->assets->cacheDir;
159
160        if ($relativeDir !== '/') {
161            $cacheDir .= $relativeDir;
162
163            // create cache sub directory if it does not exist
164            if (!is_dir($cacheDir)) {
165                mkdir($cacheDir, 0o755, true);
166            }
167        }
168
169        $suffix = '-' . match ($mode) {
170            ResizeMode::Width => 'w' . $size->firstDimension,
171            ResizeMode::Fit => $size->firstDimension . 'x' . $size->secondDimension . '-fit',
172            ResizeMode::Crop => $size->firstDimension
173                . 'x'
174                . $size->secondDimension
175                . '-crop'
176                . $size->cropMode,
177            ResizeMode::FreeCrop => $size->firstDimension
178                . 'x'
179                . $size->secondDimension
180                . '-crop-x'
181                . $size->cropMode['x']
182                . 'y'
183                . $size->cropMode['y'],
184            ResizeMode::Height => 'h' . $size->firstDimension,
185            ResizeMode::LongSide => 'l' . $size->firstDimension,
186            ResizeMode::ShortSide => 's' . $size->firstDimension,
187            ResizeMode::Resize => $size->firstDimension . 'x' . $size->secondDimension . '-resize',
188            default => throw new RuntimeException('Assets error: resize mode not supported'),
189        };
190
191        if ($enlarge) {
192            $suffix .= '-enl';
193        }
194
195        $cacheFile = $cacheDir . '/' . $filenameBasename . $suffix;
196
197        // Add extension
198        return $cacheFile . '.' . $filenameExtension;
199    }
200}