From 54e67bd16b494e984ad84f97e8de6c82440413df Mon Sep 17 00:00:00 2001 From: core23 Date: Thu, 18 Mar 2021 20:29:04 +0100 Subject: [PATCH] Add CropResizer --- .../SonataMediaExtension.php | 10 + src/Resizer/CropResizer.php | 166 ++++++++++++ src/Resources/config/media.xml | 1 + tests/Resizer/CropResizerTest.php | 237 ++++++++++++++++++ 4 files changed, 414 insertions(+) create mode 100755 src/Resizer/CropResizer.php create mode 100644 tests/Resizer/CropResizerTest.php diff --git a/src/DependencyInjection/SonataMediaExtension.php b/src/DependencyInjection/SonataMediaExtension.php index d1d0809169..c51d5fcb7b 100644 --- a/src/DependencyInjection/SonataMediaExtension.php +++ b/src/DependencyInjection/SonataMediaExtension.php @@ -548,6 +548,16 @@ private function configureAdapters(ContainerBuilder $container, array $config): private function configureResizers(ContainerBuilder $container, array $config): void { + if ($container->hasParameter('sonata.media.resizer.crop.class')) { + $class = $container->getParameter('sonata.media.resizer.crop.class'); + $definition = new Definition($class, [ + new Reference('sonata.media.adapter.image.default'), + new Reference('sonata.media.metadata.proxy'), + ]); + $definition->addTag('sonata.media.resizer'); + $container->setDefinition('sonata.media.resizer.crop', $definition); + } + if ($container->hasParameter('sonata.media.resizer.simple.class')) { $class = $container->getParameter('sonata.media.resizer.simple.class'); $definition = new Definition($class, [ diff --git a/src/Resizer/CropResizer.php b/src/Resizer/CropResizer.php new file mode 100755 index 0000000000..793052bc32 --- /dev/null +++ b/src/Resizer/CropResizer.php @@ -0,0 +1,166 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\MediaBundle\Resizer; + +use Gaufrette\File; +use Imagine\Image\Box; +use Imagine\Image\ImageInterface; +use Imagine\Image\ImagineInterface; +use Imagine\Image\Point; +use Sonata\MediaBundle\Metadata\MetadataBuilderInterface; +use Sonata\MediaBundle\Model\MediaInterface; + +/** + * @author Christian Gripp + */ +final class CropResizer implements ResizerInterface +{ + /** + * @var ImagineInterface + */ + private $adapter; + + /** + * @var MetadataBuilderInterface + */ + private $metadata; + + public function __construct(ImagineInterface $adapter, MetadataBuilderInterface $metadata) + { + $this->adapter = $adapter; + $this->metadata = $metadata; + } + + public function resize(MediaInterface $media, File $in, File $out, $format, array $settings): void + { + if (!isset($settings['width'])) { + throw new \RuntimeException(sprintf( + 'Width parameter is missing in context "%s" for provider "%s"', + $media->getContext(), + $media->getProviderName() + )); + } + + if (!isset($settings['height'])) { + throw new \RuntimeException(sprintf( + 'Height parameter is missing in context "%s" for provider "%s"', + $media->getContext(), + $media->getProviderName() + )); + } + + $image = $this->adapter->load($in->getContent()); + + $sourceSize = $media->getBox(); + $targetSize = $this->createTargetBox($settings); + + if ($this->shouldModify($sourceSize, $targetSize)) { + $image = $this->cropImage($image, $sourceSize, $targetSize); + } + + // Always change format and quality + $content = $image->get($format, [ + 'quality' => $settings['quality'], + ]); + + $out->setContent($content, $this->metadata->get($media, $out->getName())); + } + + public function getBox(MediaInterface $media, array $settings) + { + $sourceSize = $media->getBox(); + $targetSize = $this->createTargetBox($settings); + + return new Box( + min($sourceSize->getWidth(), $targetSize->getWidth()), + min($sourceSize->getHeight(), $targetSize->getHeight()) + ); + } + + /** + * @param array $settings + */ + private function createTargetBox(array $settings): Box + { + return new Box($settings['width'], $settings['height']); + } + + private function shouldModify(Box $sourceSize, Box $targetSize): bool + { + return !($sourceSize->getWidth() <= $targetSize->getWidth() && $sourceSize->getHeight() <= $targetSize->getHeight()); + } + + private function shouldResize(Box $sourceSize, Box $targetSize): bool + { + if ($sourceSize->getWidth() <= $targetSize->getWidth()) { + return false; + } + + return $sourceSize->getHeight() > $targetSize->getHeight(); + } + + private function shouldCrop(Box $sourceSize, Box $targetSize): bool + { + return $sourceSize->getWidth() > $targetSize->getWidth() || $sourceSize->getHeight() > $targetSize->getHeight(); + } + + private function cropImage(ImageInterface $image, Box $sourceSize, Box $targetSize): ImageInterface + { + if ($this->shouldResize($sourceSize, $targetSize)) { + $scaleSize = $this->createBox($sourceSize, $targetSize, false); + + $image = $image->thumbnail($scaleSize, 'outbound'); + + $sourceSize = $scaleSize; + } + + if ($this->shouldCrop($sourceSize, $targetSize)) { + $cropSize = new Box( + min($sourceSize->getWidth(), $targetSize->getWidth()), + min($sourceSize->getHeight(), $targetSize->getHeight()) + ); + + $point = new Point( + (int) (($sourceSize->getWidth() - $cropSize->getWidth()) / 2), + (int) (($sourceSize->getHeight() - $cropSize->getHeight()) / 2) + ); + + $image = $image->crop($point, $cropSize); + } + + return $image; + } + + private function createBox(Box $sourceSize, Box $targetSize, bool $smallest = true): Box + { + $widthRatio = (float) ($targetSize->getWidth() / $sourceSize->getWidth()); + $heightRatio = (float) ($targetSize->getHeight() / $sourceSize->getHeight()); + + if (0.0 !== $widthRatio - $heightRatio) { + return $sourceSize->scale( + $smallest ? min($widthRatio, $heightRatio) : max($widthRatio, $heightRatio) + ); + } + + if ($targetSize->getHeight() >= $sourceSize->getHeight()) { + return $sourceSize; + } + + if ($targetSize->getWidth() >= $sourceSize->getWidth()) { + return $sourceSize; + } + + return $targetSize; + } +} diff --git a/src/Resources/config/media.xml b/src/Resources/config/media.xml index 609e39e99e..6ee0490ee5 100644 --- a/src/Resources/config/media.xml +++ b/src/Resources/config/media.xml @@ -1,6 +1,7 @@ + Sonata\MediaBundle\Resizer\CropResizer Sonata\MediaBundle\Resizer\SimpleResizer Sonata\MediaBundle\Resizer\SquareResizer Imagine\Gd\Imagine diff --git a/tests/Resizer/CropResizerTest.php b/tests/Resizer/CropResizerTest.php new file mode 100644 index 0000000000..60d2a175c5 --- /dev/null +++ b/tests/Resizer/CropResizerTest.php @@ -0,0 +1,237 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\MediaBundle\Tests\Resizer; + +use Gaufrette\File; +use Imagine\Image\Box; +use Imagine\Image\ImageInterface; +use Imagine\Image\ImagineInterface; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Sonata\MediaBundle\Metadata\MetadataBuilderInterface; +use Sonata\MediaBundle\Model\MediaInterface; +use Sonata\MediaBundle\Resizer\CropResizer; + +/** + * @author Christian Gripp + */ +final class CropResizerTest extends TestCase +{ + private const FORMAT = 'format'; + + private const QUALITY = 75; + + /** + * @var ImagineInterface&MockObject + */ + private $adapter; + + /** + * @var MockObject&MetadataBuilderInterface + */ + private $metadata; + + protected function setUp(): void + { + parent::setUp(); + + $this->adapter = $this->createMock(ImagineInterface::class); + $this->metadata = $this->createMock(MetadataBuilderInterface::class); + } + + /** + * @dataProvider getResizeProvider + */ + public function testResize( + int $srcWidth, + int $srcHeight, + int $targetWidth, + int $targetHeight, + int $scaleWidth, + int $scaleHeight, + int $cropWidth, + int $cropHeight + ): void { + $media = $this->createMock(MediaInterface::class); + $media->method('getContext')->willReturn('sample'); + $media->method('getProviderName')->willReturn('acme.sample.provider'); + $media->method('getBox')->willReturn(new Box($srcWidth, $srcHeight)); + + $input = $this->createMock(File::class); + $output = $this->createMock(File::class); + + $image = $this->createMock(ImageInterface::class); + $image->expects(0 === $scaleHeight && 0 === $scaleWidth ? static::never() : static::once()) + ->method('thumbnail') + ->with(static::callback(static function (Box $box) use ($scaleWidth, $scaleHeight): bool { + return $box->getWidth() === $scaleWidth && $box->getHeight() === $scaleHeight; + }), static::equalTo('outbound')) + ->willReturnReference($image) + ; + + $image->expects(0 === $cropWidth && 0 === $cropHeight ? static::never() : static::once()) + ->method('crop') + ->with(static::anything(), static::callback(static function (Box $box) use ($cropWidth, $cropHeight): bool { + return $box->getWidth() === $cropWidth && $box->getHeight() === $cropHeight; + })) + ->willReturnReference($image) + ; + + $image->method('get') + ->with(self::FORMAT, [ + 'quality' => self::QUALITY, + ]) + ->willReturn('CONTENT') + ; + + $this->adapter->method('load')->willReturn($image); + + $resizer = new CropResizer($this->adapter, $this->metadata); + $resizer->resize($media, $input, $output, self::FORMAT, [ + 'width' => $targetWidth, + 'height' => $targetHeight, + 'quality' => self::QUALITY, + ]); + } + + /** + * @return int[][] + */ + public function getResizeProvider(): iterable + { + yield 'landscape: resize, no crop' => [800, 200, 400, 100, 400, 100, 0, 0]; + yield 'landscape: resize, crop' => [800, 200, 600, 100, 600, 150, 600, 100]; + yield 'landscape: no resize, crop' => [800, 200, 800, 100, 0, 0, 800, 100]; + + yield 'landscape to portrait: no resize, crop' => [800, 200, 200, 800, 0, 0, 200, 200]; + yield 'landscape to portrait: resize, crop' => [8000, 4000, 400, 800, 1600, 800, 400, 800]; + + yield 'portrait: resize, no crop' => [200, 800, 100, 400, 100, 400, 0, 0]; + yield 'portrait: resize, crop' => [200, 800, 100, 600, 150, 600, 100, 600]; + yield 'portrait: no resize, crop' => [200, 800, 100, 800, 0, 0, 100, 800]; + + yield 'portrait to landscape: crop' => [200, 800, 800, 200, 0, 0, 200, 200]; + yield 'portrait to landscape: resize, crop' => [4000, 8000, 800, 400, 800, 1600, 800, 400]; + + yield 'square: resize, no crop' => [200, 200, 100, 100, 100, 100, 0, 0]; + yield 'square: no resize, no crop' => [200, 200, 200, 200, 0, 0, 0, 0]; + } + + /** + * @dataProvider getResizeNoChangeProvider + */ + public function testResizeNoChange( + int $srcWidth, + int $srcHeight, + int $targetWidth, + int $targetHeight + ): void { + $media = $this->createMock(MediaInterface::class); + $media->method('getContext')->willReturn('sample'); + $media->method('getProviderName')->willReturn('acme.sample.provider'); + $media->method('getBox')->willReturn(new Box($srcWidth, $srcHeight)); + + $input = $this->createMock(File::class); + $output = $this->createMock(File::class); + + $image = $this->createMock(ImageInterface::class); + $image->expects(static::never())->method('thumbnail'); + $image->expects(static::never())->method('crop'); + + $image->method('get') + ->with(self::FORMAT, [ + 'quality' => self::QUALITY, + ]) + ->willReturn('CONTENT') + ; + + $this->adapter->method('load')->willReturn($image); + + $resizer = new CropResizer($this->adapter, $this->metadata); + $resizer->resize($media, $input, $output, self::FORMAT, [ + 'width' => $targetWidth, + 'height' => $targetHeight, + 'quality' => self::QUALITY, + ]); + } + + /** + * @return int[][] + */ + public function getResizeNoChangeProvider(): iterable + { + yield 'landscape: match' => [800, 200, 800, 200]; + yield 'landscape: small width' => [800, 100, 800, 200]; + yield 'landscape: small height' => [700, 200, 800, 200]; + + yield 'portrait: match' => [200, 800, 200, 800]; + yield 'portrait: small width' => [100, 800, 200, 800]; + yield 'portrait: small height' => [200, 700, 200, 800]; + + yield 'square: match' => [200, 200, 200, 200]; + yield 'square: small' => [100, 100, 200, 200]; + } + + /** + * @dataProvider getBoxProvider + */ + public function testGetBox(int $srcWidth, int $srcHeight, int $targetWidth, int $targetHeight, int $expectWidth, int $expectHeight): void + { + $media = $this->createMock(MediaInterface::class); + $media->method('getWidth') + ->willReturn($srcWidth) + ; + $media->method('getHeight') + ->willReturn($srcHeight) + ; + $media->expects(static::once())->method('getBox') + ->willReturn(new Box($srcWidth, $srcHeight)) + ; + + $resizer = new CropResizer($this->adapter, $this->metadata); + $box = $resizer->getBox($media, ['width' => $targetWidth, 'height' => $targetHeight]); + + static::assertSame($expectWidth, $box->getWidth(), 'width mismatch'); + static::assertSame($expectHeight, $box->getHeight(), 'height mismatch'); + } + + /** + * @return int[][] + */ + public function getBoxProvider(): iterable + { + yield 'source = target' => [800, 800, 800, 800, 800, 800]; + + yield 'square: same ratio' => [1000, 1000, 60, 60, 60, 60]; + yield 'square: wrong ratio (width)' => [1000, 1000, 200, 100, 200, 100]; + yield 'square: wrong ratio (height)' => [1000, 1000, 100, 200, 100, 200]; + yield 'square: source too small' => [1000, 1000, 2000, 2000, 1000, 1000]; + yield 'square: source too wide' => [1000, 1000, 2000, 100, 1000, 100]; + yield 'square: source too high' => [1000, 1000, 100, 2000, 100, 1000]; + + yield 'landscape: same ratio' => [1000, 100, 600, 60, 600, 60]; + yield 'landscape: wrong ratio (width)' => [1000, 100, 600, 30, 600, 30]; + yield 'landscape: wrong ratio (height)' => [1000, 100, 400, 10, 400, 10]; + yield 'landscape: source too small' => [1000, 100, 10000, 1000, 1000, 100]; + yield 'landscape: source too wide' => [1000, 100, 2000, 100, 1000, 100]; + yield 'landscape: source too high' => [1000, 100, 100, 2000, 100, 100]; + + yield 'portrait: same ratio' => [100, 1000, 60, 600, 60, 600]; + yield 'portrait: wrong ratio (width)' => [100, 1000, 30, 600, 30, 600]; + yield 'portrait: wrong ratio (height)' => [100, 1000, 10, 400, 10, 400]; + yield 'portrait: source too small' => [100, 1000, 1000, 10000, 100, 1000]; + yield 'portrait: source too wide' => [100, 1000, 2000, 100, 100, 100]; + yield 'portrait: source too high' => [100, 1000, 100, 2000, 100, 1000]; + } +}