Skip to content

Commit 313f3ad

Browse files
arogachevStyleCIBotvjik
authored
Add aspect ratio support to Image rule (#681)
* Add aspect ratio support to `Image` rule * Apply fixes from StyleCI * Code coverage, mutants * Add detailed test cases' descriptions in data providers * Change placeholders * Work with options * Work with options 2 * More configuration checks * Fix logic, add PHPDoc * Use value object for aspect ratio * Actualize fix for mutant for absolute margin * Fix error messages * Fix new mutants * Update src/Rule/Image/ImageAspectRatio.php Co-authored-by: Sergei Predvoditelev <sergei@predvoditelev.ru> * Do not use dedicated container in tests, fix last mutant --------- Co-authored-by: StyleCI Bot <bot@styleci.io> Co-authored-by: Sergei Predvoditelev <sergei@predvoditelev.ru>
1 parent f4c688c commit 313f3ad

File tree

6 files changed

+493
-23
lines changed

6 files changed

+493
-23
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
- New #665: Add methods `addErrorWithFormatOnly()` and `addErrorWithoutPostProcessing()` to `Result` object (@vjik)
66
- Enh #668: Clarify psalm types in `Result` (@vjik)
7-
- New #670, #680: Add `Image` validation rule (@vjik, @arogachev)
7+
- New #670, #677, #680: Add `Image` validation rule (@vjik, @arogachev)
88
- New #678: Add `Date`, `DateTime` and `Time` validation rules (@vjik)
99

1010
## 1.2.0 February 21, 2024

src/Rule/Image/Image.php

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Attribute;
88
use Closure;
9+
use InvalidArgumentException;
910
use Yiisoft\Validator\Rule\Trait\SkipOnEmptyTrait;
1011
use Yiisoft\Validator\Rule\Trait\SkipOnErrorTrait;
1112
use Yiisoft\Validator\Rule\Trait\WhenTrait;
@@ -36,6 +37,7 @@ final class Image implements RuleWithOptionsInterface, SkipOnErrorInterface, Whe
3637
* @param int|null $minHeight Expected minimum height of validated image file.
3738
* @param int|null $maxWidth Expected maximum width of validated image file.
3839
* @param int|null $maxHeight Expected maximum height of validated image file.
40+
* @param ImageAspectRatio|null $aspectRatio Expected aspect ratio of validated image file.
3941
* @param string $notImageMessage A message used when the validated value is not valid image file.
4042
*
4143
* You may use the following placeholders in the message:
@@ -89,6 +91,20 @@ final class Image implements RuleWithOptionsInterface, SkipOnErrorInterface, Whe
8991
*
9092
* - `{attribute}`: the translated label of the attribute being validated.
9193
* - `{limit}`: expected maximum height of validated image file.
94+
*
95+
* @param string $invalidAspectRatioMessage A message used when aspect ratio of validated image file is different
96+
* than {@see ImageAspectRatio::$width}:{@see ImageAspectRatio::$height} with correction based on
97+
* {@see ImageAspectRatio::$margin}.
98+
*
99+
* You may use the following placeholders in the message:
100+
*
101+
* - `{attribute}`: the translated label of the attribute being validated.
102+
* - `{aspectRatioWidth}`: expected width part for aspect ratio. For example, for `4:3` aspect ratio, it will be
103+
* `4`.
104+
* - `{aspectRatioHeight}`: expected height part for aspect ratio. For example, for `4:3` aspect ratio, it will be
105+
* `3`.
106+
* - `{aspectRatioMargin}`: expected margin for aspect ratio in percents.
107+
*
92108
* @param bool|callable|null $skipOnEmpty Whether to skip this rule if the value validated is empty.
93109
* See {@see SkipOnEmptyInterface}.
94110
* @param bool $skipOnError Whether to skip this rule if any of the previous rules gave an error.
@@ -105,17 +121,26 @@ public function __construct(
105121
private ?int $minHeight = null,
106122
private ?int $maxWidth = null,
107123
private ?int $maxHeight = null,
124+
private ?ImageAspectRatio $aspectRatio = null,
108125
private string $notImageMessage = 'The value must be an image.',
109-
private string $notExactWidthMessage = 'The width of image "{attribute}" must be exactly {exactly, number} {exactly, plural, one{pixel} other{pixels}}.',
110-
private string $notExactHeightMessage = 'The height of image "{attribute}" must be exactly {exactly, number} {exactly, plural, one{pixel} other{pixels}}.',
111-
private string $tooSmallWidthMessage = 'The width of image "{attribute}" cannot be smaller than {limit, number} {limit, plural, one{pixel} other{pixels}}.',
112-
private string $tooSmallHeightMessage = 'The height of image "{attribute}" cannot be smaller than {limit, number} {limit, plural, one{pixel} other{pixels}}.',
113-
private string $tooLargeWidthMessage = 'The width of image "{attribute}" cannot be larger than {limit, number} {limit, plural, one{pixel} other{pixels}}.',
114-
private string $tooLargeHeightMessage = 'The height of image "{attribute}" cannot be larger than {limit, number} {limit, plural, one{pixel} other{pixels}}.',
126+
private string $notExactWidthMessage = 'The width must be exactly {exactly, number} {exactly, plural, one{pixel} other{pixels}}.',
127+
private string $notExactHeightMessage = 'The height must be exactly {exactly, number} {exactly, plural, one{pixel} other{pixels}}.',
128+
private string $tooSmallWidthMessage = 'The width cannot be smaller than {limit, number} {limit, plural, one{pixel} other{pixels}}.',
129+
private string $tooSmallHeightMessage = 'The height cannot be smaller than {limit, number} {limit, plural, one{pixel} other{pixels}}.',
130+
private string $tooLargeWidthMessage = 'The width cannot be larger than {limit, number} {limit, plural, one{pixel} other{pixels}}.',
131+
private string $tooLargeHeightMessage = 'The height cannot be larger than {limit, number} {limit, plural, one{pixel} other{pixels}}.',
132+
private string $invalidAspectRatioMessage = 'The aspect ratio must be {aspectRatioWidth, number}:{aspectRatioHeight, number} with margin {aspectRatioMargin, number}%.',
115133
private mixed $skipOnEmpty = null,
116134
private bool $skipOnError = false,
117135
private Closure|null $when = null,
118136
) {
137+
if ($this->width !== null && ($this->minWidth !== null || $this->maxWidth !== null)) {
138+
throw new InvalidArgumentException('Exact width and min / max width can\'t be specified together.');
139+
}
140+
141+
if ($this->height !== null && ($this->minHeight !== null || $this->maxHeight !== null)) {
142+
throw new InvalidArgumentException('Exact width and min / max height can\'t be specified together.');
143+
}
119144
}
120145

121146
public function getWidth(): ?int
@@ -148,6 +173,11 @@ public function getMaxHeight(): ?int
148173
return $this->maxHeight;
149174
}
150175

176+
public function getAspectRatio(): ?ImageAspectRatio
177+
{
178+
return $this->aspectRatio;
179+
}
180+
151181
public function getNotImageMessage(): string
152182
{
153183
return $this->notImageMessage;
@@ -183,6 +213,11 @@ public function getTooLargeHeightMessage(): string
183213
return $this->tooLargeHeightMessage;
184214
}
185215

216+
public function getInvalidAspectRatioMessage(): string
217+
{
218+
return $this->invalidAspectRatioMessage;
219+
}
220+
186221
public function getName(): string
187222
{
188223
return 'image';
@@ -202,6 +237,9 @@ public function getOptions(): array
202237
'minHeight' => $this->minHeight,
203238
'maxWidth' => $this->maxWidth,
204239
'maxHeight' => $this->maxHeight,
240+
'aspectRatioWidth' => $this->getAspectRatio()?->getWidth(),
241+
'aspectRatioHeight' => $this->getAspectRatio()?->getHeight(),
242+
'aspectRatioMargin' => $this->getAspectRatio()?->getMargin(),
205243
'notExactWidthMessage' => [
206244
'template' => $this->notExactWidthMessage,
207245
'parameters' => [
@@ -242,6 +280,14 @@ public function getOptions(): array
242280
'template' => $this->notImageMessage,
243281
'parameters' => [],
244282
],
283+
'invalidAspectRatioMessage' => [
284+
'template' => $this->invalidAspectRatioMessage,
285+
'parameters' => [
286+
'aspectRatioWidth' => $this->getAspectRatio()?->getWidth(),
287+
'aspectRatioHeight' => $this->getAspectRatio()?->getHeight(),
288+
'aspectRatioMargin' => $this->getAspectRatio()?->getMargin(),
289+
],
290+
],
245291
'skipOnEmpty' => $this->getSkipOnEmptyOption(),
246292
'skipOnError' => $this->skipOnError,
247293
];
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Yiisoft\Validator\Rule\Image;
6+
7+
/**
8+
* @link https://en.wikipedia.org/wiki/Aspect_ratio_(image)
9+
*/
10+
final class ImageAspectRatio
11+
{
12+
/**
13+
* @param int $width Expected width part for aspect ratio. For example, for `4:3` aspect ratio, it will be `4`.
14+
* @param int $height Expected height part for aspect ratio. For example, for `4:3` aspect ratio, it will be `3`.
15+
* @param float $margin Expected margin for aspect ratio in percents. For example, with value `1` and `4:3` aspect
16+
* ratio:
17+
*
18+
* - If the validated image has height of 600 pixels, the allowed width range is 794 - 806 pixels.
19+
* - If the validated image has width of 800 pixels, the allowed height range is 596 - 604 pixels.
20+
*
21+
* Defaults to `0` meaning no margin is allowed. For example, image with size 800 x 600 pixels and aspect ratio
22+
* expected to be `4:3` will meet this requirement.
23+
*/
24+
public function __construct(
25+
private int $width,
26+
private int $height,
27+
private float $margin = 0,
28+
) {
29+
}
30+
31+
public function getWidth(): int
32+
{
33+
return $this->width;
34+
}
35+
36+
public function getHeight(): int
37+
{
38+
return $this->height;
39+
}
40+
41+
public function getMargin(): float
42+
{
43+
return $this->margin;
44+
}
45+
}

src/Rule/Image/ImageHandler.php

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ public function validate(mixed $value, object $rule, ValidationContext $context)
9191
]);
9292
}
9393

94+
$this->validateAspectRatio($width, $height, $rule, $context, $result);
95+
9496
return $result;
9597
}
9698

@@ -101,7 +103,8 @@ private function shouldValidateDimensions(Image $rule): bool
101103
|| $rule->getMinHeight() !== null
102104
|| $rule->getMinWidth() !== null
103105
|| $rule->getMaxHeight() !== null
104-
|| $rule->getMaxWidth() !== null;
106+
|| $rule->getMaxWidth() !== null
107+
|| $rule->getAspectRatio() !== null;
105108
}
106109

107110
private function getImageFilePath(mixed $value): ?string
@@ -138,4 +141,35 @@ private function getFilePath(mixed $value): ?string
138141
}
139142
return is_string($value) ? $value : null;
140143
}
144+
145+
private function validateAspectRatio(
146+
int $validatedWidth,
147+
int $validatedHeight,
148+
Image $rule,
149+
ValidationContext $context,
150+
Result $result,
151+
): void {
152+
if ($rule->getAspectRatio() === null) {
153+
return;
154+
}
155+
156+
$validatedAspectRatio = $validatedWidth / $validatedHeight;
157+
$expectedAspectRatio = $rule->getAspectRatio()->getWidth() / $rule->getAspectRatio()->getHeight();
158+
$absoluteMargin = $rule->getAspectRatio()->getMargin() / 100;
159+
160+
if (
161+
($validatedAspectRatio < $expectedAspectRatio - $absoluteMargin) ||
162+
($validatedAspectRatio > $expectedAspectRatio + $absoluteMargin)
163+
) {
164+
$result->addError(
165+
$rule->getInvalidAspectRatioMessage(),
166+
[
167+
'attribute' => $context->getTranslatedAttribute(),
168+
'aspectRatioWidth' => $rule->getAspectRatio()->getWidth(),
169+
'aspectRatioHeight' => $rule->getAspectRatio()->getHeight(),
170+
'aspectRatioMargin' => $rule->getAspectRatio()->getMargin(),
171+
],
172+
);
173+
}
174+
}
141175
}

src/RuleHandlerResolver/SimpleRuleHandlerContainer.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ final class SimpleRuleHandlerContainer implements RuleHandlerResolverInterface
4141
*/
4242
public function __construct(
4343
/**
44-
* @var array<string, RuleHandlerInterface> A storage of rule handlers' instances - a mapping where keys are
45-
* strings (the rule handlers' class names by default) and values are corresponding rule handlers' instances.
44+
* @var RuleHandlerInterface[] A storage of rule handlers' instances - a mapping where keys are strings (the
45+
* rule handlers' class names by default) and values are corresponding rule handlers' instances.
46+
* @psalm-var array<string, RuleHandlerInterface>
4647
*/
4748
private array $instances = [],
4849
) {

0 commit comments

Comments
 (0)