Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
80 / 80 |
|
100.00% |
9 / 9 |
CRAP | |
100.00% |
1 / 1 |
| Sizes | |
100.00% |
80 / 80 |
|
100.00% |
9 / 9 |
35 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| get | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| has | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| parse | |
100.00% |
32 / 32 |
|
100.00% |
1 / 1 |
5 | |||
| dimensions | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
11 | |||
| pos | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
6 | |||
| quality | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
5 | |||
| enlarge | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| error | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Cosray\Assets; |
| 6 | |
| 7 | use Cosray\Exception\RuntimeException; |
| 8 | use 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 | */ |
| 18 | final 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 | } |