Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
80 / 80
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
Sizes
100.00% covered (success)
100.00%
80 / 80
100.00% covered (success)
100.00%
9 / 9
35
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
 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' => ['crop' => [400, 267]],
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    public function __construct(array $config)
57    {
58        foreach ($config + self::BUILTIN as $name => $entry) {
59            $this->specs[$name] = $this->parse((string) $name, $entry);
60        }
61    }
62
63    public function get(string $name): SizeSpec
64    {
65        return (
66            $this->specs[$name] ?? throw new RuntimeException(
67                "Unknown media size '{$name}'. Add it to the `media.sizes` config.",
68            )
69        );
70    }
71
72    public function has(string $name): bool
73    {
74        return isset($this->specs[$name]);
75    }
76
77    private function parse(string $name, mixed $entry): SizeSpec
78    {
79        if (preg_match('/^[a-z0-9]+(-[a-z0-9]+)*$/', $name) !== 1) {
80            throw $this->error($name, 'names must match [a-z0-9-]');
81        }
82
83        if (!is_array($entry)) {
84            throw $this->error($name, 'entry must be an array');
85        }
86
87        $modes = array_intersect_key($entry, self::SINGLE + self::PAIR);
88
89        if (count($modes) !== 1) {
90            throw $this->error(
91                $name,
92                'exactly one mode key required ('
93                    . implode(
94                        ', ',
95                        array_keys(self::SINGLE + self::PAIR),
96                    )
97                    . ')',
98            );
99        }
100
101        $modeKey = (string) array_key_first($modes);
102        $unknown = array_diff(
103            array_keys($entry),
104            [$modeKey, 'pos', 'quality', 'enlarge'],
105        );
106
107        if ($unknown !== []) {
108            throw $this->error($name, 'unknown keys: ' . implode(', ', $unknown));
109        }
110
111        [$first, $second] = $this->dimensions($name, $modeKey, $entry[$modeKey]);
112
113        return new SizeSpec(
114            name: $name,
115            mode: self::SINGLE[$modeKey] ?? self::PAIR[$modeKey],
116            first: $first,
117            second: $second,
118            pos: $this->pos($name, $modeKey, $entry),
119            quality: $this->quality($name, $entry),
120            enlarge: $this->enlarge($name, $entry),
121        );
122    }
123
124    /** @return array{0: int, 1: ?int} */
125    private function dimensions(string $name, string $modeKey, mixed $value): array
126    {
127        if (isset(self::SINGLE[$modeKey])) {
128            if (!is_int($value) || $value < 1) {
129                throw $this->error($name, "`{$modeKey}` must be a positive int");
130            }
131
132            return [$value, null];
133        }
134
135        if (
136            !is_array($value)
137            || !array_is_list($value)
138            || count($value) !== 2
139            || !is_int($value[0])
140            || !is_int($value[1])
141            || $value[0] < 1
142            || $value[1] < 1
143        ) {
144            throw $this->error($name, "`{$modeKey}` must be `[width, height]` with positive ints");
145        }
146
147        return [$value[0], $value[1]];
148    }
149
150    private function pos(string $name, string $modeKey, array $entry): ?int
151    {
152        if (!array_key_exists('pos', $entry)) {
153            return $modeKey === 'crop' ? ImageResize::CROPCENTER : null;
154        }
155
156        if ($modeKey !== 'crop') {
157            throw $this->error($name, '`pos` is only valid with `crop`');
158        }
159
160        $pos = $entry['pos'];
161
162        if (!is_string($pos) || !isset(self::POSITIONS[$pos])) {
163            throw $this->error(
164                $name,
165                '`pos` must be one of '
166                    . implode(
167                        ', ',
168                        array_keys(self::POSITIONS),
169                    ),
170            );
171        }
172
173        return self::POSITIONS[$pos];
174    }
175
176    private function quality(string $name, array $entry): ?int
177    {
178        if (!array_key_exists('quality', $entry)) {
179            return null;
180        }
181
182        $quality = $entry['quality'];
183
184        if (!is_int($quality) || $quality < 1 || $quality > 100) {
185            throw $this->error($name, '`quality` must be an int between 1 and 100');
186        }
187
188        return $quality;
189    }
190
191    private function enlarge(string $name, array $entry): bool
192    {
193        if (!array_key_exists('enlarge', $entry)) {
194            return false;
195        }
196
197        if (!is_bool($entry['enlarge'])) {
198            throw $this->error($name, '`enlarge` must be a bool');
199        }
200
201        return $entry['enlarge'];
202    }
203
204    private function error(string $name, string $message): RuntimeException
205    {
206        return new RuntimeException("Invalid media size '{$name}': {$message}");
207    }
208}