Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
81 / 81
100.00% covered (success)
100.00%
10 / 10
CRAP
100.00% covered (success)
100.00%
1 / 1
Sizes
100.00% covered (success)
100.00%
81 / 81
100.00% covered (success)
100.00%
10 / 10
36
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 has
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 names
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parse
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
5
 dimensions
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
11
 pos
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 quality
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 enlarge
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 error
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Assets;
6
7use Cosray\Exception\RuntimeException;
8use Gumlet\ImageResize;
9
10/**
11 * The registry of named media sizes from the `media.sizes` config.
12 *
13 * Every rendition URL carries a size name, so this closed set bounds
14 * what the cache fallback route will generate. The whole config is
15 * validated on construction — a typo anywhere fails every request,
16 * not just the template branch that uses it.
17 */
18final class Sizes
19{
20    /** Panel and block defaults; apps may override them by name. */
21    private const array BUILTIN = [
22        'thumb' => ['width' => 400],
23        'preview' => ['width' => 1280],
24        'block-sm' => ['width' => 480],
25        'block' => ['width' => 960],
26        'block-lg' => ['width' => 1440],
27        'block-thumb' => ['width' => 480],
28    ];
29
30    private const array SINGLE = [
31        'width' => ResizeMode::Width,
32        'height' => ResizeMode::Height,
33        'long-side' => ResizeMode::LongSide,
34        'short-side' => ResizeMode::ShortSide,
35    ];
36
37    private const array PAIR = [
38        'crop' => ResizeMode::Crop,
39        'fit' => ResizeMode::Fit,
40        'resize' => ResizeMode::Resize,
41    ];
42
43    private const array POSITIONS = [
44        'top' => ImageResize::CROPTOP,
45        'centre' => ImageResize::CROPCENTRE,
46        'center' => ImageResize::CROPCENTER,
47        'bottom' => ImageResize::CROPBOTTOM,
48        'left' => ImageResize::CROPLEFT,
49        'right' => ImageResize::CROPRIGHT,
50        'topcenter' => ImageResize::CROPTOPCENTER,
51    ];
52
53    /** @var array<string, SizeSpec> */
54    private array $specs = [];
55
56    /** @param array<mixed> $config */
57    public function __construct(array $config)
58    {
59        foreach ($config + self::BUILTIN as $name => $entry) {
60            $this->specs[$name] = $this->parse((string) $name, $entry);
61        }
62    }
63
64    public function get(string $name): SizeSpec
65    {
66        return (
67            $this->specs[$name] ?? throw new RuntimeException(
68                "Unknown media size '{$name}'. Add it to the `media.sizes` config.",
69            )
70        );
71    }
72
73    public function has(string $name): bool
74    {
75        return isset($this->specs[$name]);
76    }
77
78    /** @return list<string> configured names first, then the built-in ones */
79    public function names(): array
80    {
81        return array_keys($this->specs);
82    }
83
84    private function parse(string $name, mixed $entry): SizeSpec
85    {
86        if (preg_match('/^[a-z0-9]+(-[a-z0-9]+)*$/', $name) !== 1) {
87            throw $this->error($name, 'names must match [a-z0-9-]');
88        }
89
90        if (!is_array($entry)) {
91            throw $this->error($name, 'entry must be an array');
92        }
93
94        $modes = array_intersect_key($entry, self::SINGLE + self::PAIR);
95
96        if (count($modes) !== 1) {
97            throw $this->error(
98                $name,
99                'exactly one mode key required ('
100                    . implode(
101                        ', ',
102                        array_keys(self::SINGLE + self::PAIR),
103                    )
104                    . ')',
105            );
106        }
107
108        $modeKey = (string) array_key_first($modes);
109        $unknown = array_diff(
110            array_keys($entry),
111            [$modeKey, 'pos', 'quality', 'enlarge'],
112        );
113
114        if ($unknown !== []) {
115            throw $this->error($name, 'unknown keys: ' . implode(', ', $unknown));
116        }
117
118        [$first, $second] = $this->dimensions($name, $modeKey, $entry[$modeKey]);
119
120        return new SizeSpec(
121            name: $name,
122            mode: self::SINGLE[$modeKey] ?? self::PAIR[$modeKey] ?? throw $this->error($name, 'unknown mode'),
123            first: $first,
124            second: $second,
125            pos: $this->pos($name, $modeKey, $entry),
126            quality: $this->quality($name, $entry),
127            enlarge: $this->enlarge($name, $entry),
128        );
129    }
130
131    /** @return array{0: int, 1: ?int} */
132    private function dimensions(string $name, string $modeKey, mixed $value): array
133    {
134        if (isset(self::SINGLE[$modeKey])) {
135            if (!is_int($value) || $value < 1) {
136                throw $this->error($name, "`{$modeKey}` must be a positive int");
137            }
138
139            return [$value, null];
140        }
141
142        if (
143            !is_array($value)
144            || !array_is_list($value)
145            || count($value) !== 2
146            || !is_int($value[0])
147            || !is_int($value[1])
148            || $value[0] < 1
149            || $value[1] < 1
150        ) {
151            throw $this->error($name, "`{$modeKey}` must be `[width, height]` with positive ints");
152        }
153
154        return [$value[0], $value[1]];
155    }
156
157    /** @param array<mixed> $entry */
158    private function pos(string $name, string $modeKey, array $entry): ?int
159    {
160        if (!array_key_exists('pos', $entry)) {
161            return $modeKey === 'crop' ? ImageResize::CROPCENTER : null;
162        }
163
164        if ($modeKey !== 'crop') {
165            throw $this->error($name, '`pos` is only valid with `crop`');
166        }
167
168        $pos = $entry['pos'];
169
170        if (!is_string($pos) || !isset(self::POSITIONS[$pos])) {
171            throw $this->error(
172                $name,
173                '`pos` must be one of '
174                    . implode(
175                        ', ',
176                        array_keys(self::POSITIONS),
177                    ),
178            );
179        }
180
181        return self::POSITIONS[$pos];
182    }
183
184    /** @param array<mixed> $entry */
185    private function quality(string $name, array $entry): ?int
186    {
187        if (!array_key_exists('quality', $entry)) {
188            return null;
189        }
190
191        $quality = $entry['quality'];
192
193        if (!is_int($quality) || $quality < 1 || $quality > 100) {
194            throw $this->error($name, '`quality` must be an int between 1 and 100');
195        }
196
197        return $quality;
198    }
199
200    /** @param array<mixed> $entry */
201    private function enlarge(string $name, array $entry): bool
202    {
203        if (!array_key_exists('enlarge', $entry)) {
204            return false;
205        }
206
207        if (!is_bool($entry['enlarge'])) {
208            throw $this->error($name, '`enlarge` must be a bool');
209        }
210
211        return $entry['enlarge'];
212    }
213
214    private function error(string $name, string $message): RuntimeException
215    {
216        return new RuntimeException("Invalid media size '{$name}': {$message}");
217    }
218}