diff --git a/docs/image.md b/docs/image.md index daa759b..30a5a8d 100644 --- a/docs/image.md +++ b/docs/image.md @@ -15,6 +15,19 @@ services: If autoconfigure is not active you need to tag it with [twig.extension](https://symfony.com/doc/current/service_container.html#the-autoconfigure-option). +**Recommended Configuration** + +```yaml +Sulu\Twig\Extensions\ImageExtension: + arguments: + $defaultAttributes: + loading: 'lazy' + $defaultAdditionalTypes: + webp: 'image/webp' + $aspectRatio: true + $imageFormatConfiguration: '%sulu_media.image.formats%' +``` + ## Usage #### Get an image @@ -227,3 +240,37 @@ You can also only activate it for a specific call: '(max-width: 650px)': 'sulu-100x100', }, { webp: 'image/webp' }) }} ``` + +##### 8. Set width and height attribute automatically + +Since Sulu 2.3 the original image width and height are saved as properties. +This allows to guess the width and height of a image format and set the respective HTML attributes. + +Setting the width and height attribute allows modern browsers to avoid layer shifts +and therefore the page will not jump when images are loaded. + +This feature can be activated the following way: + +```yaml +services: + Sulu\Twig\Extensions\ImageExtension: + arguments: + $aspectRatio: true + $imageFormatConfiguration: '%sulu_media.image.formats%' # optional but recommended +``` + +For example, if the original image has a resolution of 1920x1080 and the image format is called 100x: + +```twig +{{ get_image(headerImage, '100x') }} +``` + +The feature will automatically add a width and height attribute to the rendered image tag: + +```twig +Title +``` + +The `$imageFormatConfiguration` parameter is optional. If it is not set, the extension +tries to guess the dimensions by the given format key. This will only work for format keys +in the format of 100x, x100, 100x@2x and 100x100-inset. diff --git a/src/ImageExtension.php b/src/ImageExtension.php index 6440975..11716ef 100644 --- a/src/ImageExtension.php +++ b/src/ImageExtension.php @@ -65,14 +65,41 @@ class ImageExtension extends AbstractExtension 'image/svg', ]; + /** + * @var bool + */ + private $aspectRatio = false; + + /** + * @var array|null + */ + private $imageFormatConfiguration = null; + /** * @param string[] $defaultAttributes * @param string[] $defaultAdditionalTypes + * @param array|null $imageFormatConfiguration */ public function __construct( ?string $placeholderPath = null, array $defaultAttributes = [], - array $defaultAdditionalTypes = [] + array $defaultAdditionalTypes = [], + bool $aspectRatio = false, + array $imageFormatConfiguration = null, ) { if (null !== $placeholderPath) { $this->placeholderPath = rtrim($placeholderPath, '/') . '/'; @@ -80,6 +107,8 @@ public function __construct( $this->defaultAttributes = $defaultAttributes; $this->defaultAdditionalTypes = $defaultAdditionalTypes; + $this->aspectRatio = $aspectRatio; + $this->imageFormatConfiguration = $imageFormatConfiguration; } /** @@ -193,6 +222,13 @@ private function createImage( $attributes ); + if ($this->aspectRatio && isset($attributes['src']) && !isset($attributes['width']) && !isset($attributes['height'])) { + list($width, $height) = $this->guessAspectRatio($media, $attributes); + + $attributes['width'] = ((string) $width) ?: null; + $attributes['height'] = ((string) $height) ?: null; + } + // The default additional types and additional types are merged together and not replaced /** @var string[] $additionalTypes */ $additionalTypes = array_merge( @@ -407,6 +443,83 @@ private function getLazyThumbnails(array $thumbnails): ?array return $this->placeholders; } + /** + * @param mixed $media + * @param array $attributes + * + * @return array{ + * 0: int|null, + * 1: int|null, + * } + */ + private function guessAspectRatio($media, array $attributes): array + { + $src = $attributes['src'] ?? ''; + + if ($this->imageFormatConfiguration) { + $scale = $this->imageFormatConfiguration[$src]['scale']['retina'] ? 2 : 1; + $isInset = \in_array($this->imageFormatConfiguration[$src]['scale']['mode'], [ + 1, + 'inset', + ], true); + $x = $this->imageFormatConfiguration[$src]['scale']['x']; + $y = $this->imageFormatConfiguration[$src]['scale']['y']; + $width = $x ? (int) round($x * $scale) : null; + $height = $y ? (int) round($y * $scale) : null; + } else { + /* + * This will extract the format dimension in all common formats. + * @see ImageExtensionTest::testGuessAspectRatio + */ + preg_match('/(\d+)?x(\d+)?(-inset)?(@)?(\d)?(x)?/', $src, $matches); + + $scale = !empty($matches[5]) ? (float) $matches[5] : 1; + $width = !empty($matches[1]) ? (int) round($matches[1] * $scale) : null; + $height = !empty($matches[2]) ? (int) round($matches[2] * $scale) : null; + $isInset = !empty($matches[3]); + } + + // fixed formats can directly be returned + if ($width && $height && !$isInset) { + return [$width, $height]; + } + + $properties = $this->getPropertyAccessor()->getValue($media, 'properties'); + $originalWidth = $properties['width'] ?? null; + $originalHeight = $properties['height'] ?? null; + + if (!$originalWidth || !$originalHeight) { + return [null, null]; + } + + if ($isInset && $width && $height) { + // calculate inset width and height e.g. 200x50-inset, 100x100-inset + $insetWidth = $width; + $insetHeight = $height; + if ($originalWidth > $width) { + $insetHeight = $originalHeight / $originalWidth * $width; + } + + if (round($insetHeight) > $height) { + $insetHeight = $height; + $insetWidth = $originalWidth / $originalHeight * $height; + } + + return [(int) round($insetWidth), (int) round($insetHeight)]; + } + + // calculate the not given dimension parameter + if (!$width) { + $width = $originalWidth / $originalHeight * $height; + } + + if (!$height) { + $height = $originalHeight / $originalWidth * $width; + } + + return [(int) round($width), (int) round($height)]; + } + /** * Return a given src with with a new extension. */ diff --git a/tests/Unit/ImageExtensionTest.php b/tests/Unit/ImageExtensionTest.php index fa54d91..ceef437 100644 --- a/tests/Unit/ImageExtensionTest.php +++ b/tests/Unit/ImageExtensionTest.php @@ -18,11 +18,6 @@ class ImageExtensionTest extends TestCase { - /** - * @var ImageExtension - */ - private $imageExtension; - /** * @var mixed[] */ @@ -40,7 +35,6 @@ class ImageExtensionTest extends TestCase protected function setUp(): void { - $this->imageExtension = new ImageExtension('/lazy'); $this->image = [ 'title' => 'Title', 'description' => 'Description', @@ -54,6 +48,12 @@ protected function setUp(): void 'sulu-170x170.webp' => '/uploads/media/sulu-170x170/01/image.webp?v=1-0', 'sulu-400x400' => '/uploads/media/sulu-400x400/01/image.jpg?v=1-0', 'sulu-400x400.webp' => '/uploads/media/sulu-400x400/01/image.webp?v=1-0', + 'sulu-260x' => '/uploads/media/sulu-400x400/01/image.jpg?v=1-0', + 'sulu-260x.webp' => '/uploads/media/sulu-400x400/01/image.webp?v=1-0', + ], + 'properties' => [ + 'width' => 1920, + 'height' => 1080, ], ]; @@ -84,27 +84,37 @@ protected function setUp(): void 'sulu-400x400' => '/uploads/media/sulu-400x400/01/image.svg?v=1-0', 'sulu-400x400.webp' => '/uploads/media/sulu-400x400/01/image.webp?v=1-0', ], + 'properties' => [ + 'width' => 1920, + 'height' => 1080, + ], ]; } public function testImageTag(): void { + $imageExtension = new ImageExtension('/lazy'); + $this->assertSame( 'Title', - $this->imageExtension->getImage($this->image, 'sulu-100x100') + $imageExtension->getImage($this->image, 'sulu-100x100') ); } public function testImageTagObject(): void { + $imageExtension = new ImageExtension('/lazy'); + $this->assertSame( 'Title', - $this->imageExtension->getImage((object) $this->image, 'sulu-100x100') + $imageExtension->getImage((object) $this->image, 'sulu-100x100') ); } public function testComplexImageTag(): void { + $imageExtension = new ImageExtension('/lazy'); + $this->assertSame( 'Logo', - $this->imageExtension->getImage( + $imageExtension->getImage( $this->image, [ 'src' => 'sulu-400x400', @@ -129,6 +139,8 @@ public function testComplexImageTag(): void public function testPictureTag(): void { + $imageExtension = new ImageExtension('/lazy'); + $this->assertSame( '' . '' . '', - $this->imageExtension->getImage( + $imageExtension->getImage( $this->image, 'sulu-400x400', [ @@ -152,6 +164,8 @@ public function testPictureTag(): void public function testPictureTagMinimalImage(): void { + $imageExtension = new ImageExtension('/lazy'); + $this->assertSame( '' . '' . '', - $this->imageExtension->getImage( + $imageExtension->getImage( $this->minimalImage, 'sulu-400x400', [ @@ -175,6 +189,8 @@ public function testPictureTagMinimalImage(): void public function testComplexPictureTag(): void { + $imageExtension = new ImageExtension('/lazy'); + $this->assertSame( '' . '' . '', - $this->imageExtension->getImage( + $imageExtension->getImage( $this->image, [ 'src' => 'sulu-400x400', @@ -210,14 +226,18 @@ public function testComplexPictureTag(): void public function testLazyImageTag(): void { + $imageExtension = new ImageExtension('/lazy'); + $this->assertSame( 'Title', - $this->imageExtension->getLazyImage($this->image, 'sulu-100x100') + $imageExtension->getLazyImage($this->image, 'sulu-100x100') ); } public function testLazyComplexImageTag(): void { + $imageExtension = new ImageExtension('/lazy'); + $this->assertSame( 'Logo', - $this->imageExtension->getLazyImage( + $imageExtension->getLazyImage( $this->image, [ 'src' => 'sulu-400x400', @@ -244,6 +264,8 @@ public function testLazyComplexImageTag(): void public function testLazyComplexImageTagMinimalImage(): void { + $imageExtension = new ImageExtension('/lazy'); + $this->assertSame( 'image', - $this->imageExtension->getLazyImage( + $imageExtension->getLazyImage( $this->minimalImage, [ 'src' => 'sulu-400x400', @@ -269,6 +291,8 @@ public function testLazyComplexImageTagMinimalImage(): void public function testLazyPictureTag(): void { + $imageExtension = new ImageExtension('/lazy'); + $this->assertSame( '' . '' . '', - $this->imageExtension->getLazyImage( + $imageExtension->getLazyImage( $this->image, 'sulu-400x400', [ @@ -296,6 +320,8 @@ public function testLazyPictureTag(): void public function testLazyComplexPictureTag(): void { + $imageExtension = new ImageExtension('/lazy'); + $this->assertSame( '' . '' . '', - $this->imageExtension->getLazyImage( + $imageExtension->getLazyImage( $this->image, [ 'src' => 'sulu-400x400', @@ -334,14 +360,16 @@ public function testLazyComplexPictureTag(): void public function testHasLazyImage(): void { - $this->assertFalse($this->imageExtension->hasLazyImage()); - $this->imageExtension->getImage($this->image, 'sulu-400x400'); - $this->assertFalse($this->imageExtension->hasLazyImage()); - $this->imageExtension->getLazyImage($this->image, 'sulu-400x400'); - $this->assertTrue($this->imageExtension->hasLazyImage()); - $this->imageExtension->getLazyImage($this->image, 'sulu-400x400'); - $this->imageExtension->getImage($this->image, 'sulu-400x400'); - $this->assertTrue($this->imageExtension->hasLazyImage()); + $imageExtension = new ImageExtension('/lazy'); + + $this->assertFalse($imageExtension->hasLazyImage()); + $imageExtension->getImage($this->image, 'sulu-400x400'); + $this->assertFalse($imageExtension->hasLazyImage()); + $imageExtension->getLazyImage($this->image, 'sulu-400x400'); + $this->assertTrue($imageExtension->hasLazyImage()); + $imageExtension->getLazyImage($this->image, 'sulu-400x400'); + $imageExtension->getImage($this->image, 'sulu-400x400'); + $this->assertTrue($imageExtension->hasLazyImage()); } public function testDefaultAttributes(): void @@ -489,4 +517,105 @@ public function testAdditionalLazyComplexPictureTag(): void ) ); } + + /** + * @dataProvider aspectRatioDataProvider + */ + public function testAspectRatio(string $format, string $expectedWidth, string $expectedHeight): void + { + $imageExtension = new ImageExtension(null, [], [], true); + + $image = array_replace_recursive( + $this->image, + [ + 'thumbnails' => [ + $format => '/uploads/media/' . $format . '/01/image.jpg?v=1-0', + $format . '.webp' => '/uploads/media/' . $format . '/01/image.webp?v=1-0', + ], + 'properties' => [ + 'width' => 1920, + 'height' => 1080, + ], + ], + ); + + $this->assertSame( + 'Title', + $imageExtension->getImage($image, $format) + ); + } + + /** + * @dataProvider aspectRatioDataProvider + */ + public function testAspectRatioWithConfiguration(string $format, string $expectedWidth, string $expectedHeight): void + { + preg_match('/(\d+)?x(\d+)?(-inset)?(@)?(\d)?(x)?/', $format, $matches); + + $scale = !empty($matches[5]) ? (float) $matches[5] : 1; + $x = !empty($matches[1]) ? (int) $matches[1] : null; + $y = !empty($matches[2]) ? (int) $matches[2] : null; + $isInset = !empty($matches[3]); + + $imageExtension = new ImageExtension(null, [], [], true, [ + $format => [ + 'scale' => [ + 'x' => $x, + 'y' => $y, + 'mode' => $isInset ? 1 : 2, + 'retina' => 1 !== $scale, + ], + ], + ]); + + $image = array_replace_recursive( + $this->image, + [ + 'thumbnails' => [ + $format => '/uploads/media/' . $format . '/01/image.jpg?v=1-0', + $format . '.webp' => '/uploads/media/' . $format . '/01/image.webp?v=1-0', + ], + 'properties' => [ + 'width' => 1920, + 'height' => 1080, + ], + ], + ); + + $this->assertSame( + 'Title', + $imageExtension->getImage($image, $format) + ); + } + + /** + * @return \Generator + */ + public function aspectRatioDataProvider(): \Generator + { + yield ['100x', '100', '56']; + yield ['x100', '178', '100']; + yield ['100x100', '100', '100']; + yield ['200x50', '200', '50']; + yield ['100x@2x', '200', '113']; + yield ['x100@2x', '356', '200']; + yield ['100x100@2x', '200', '200']; + yield ['200x50@2x', '400', '100']; + yield ['100x-inset', '100', '56']; + yield ['x100-inset', '178', '100']; + yield ['100x100-inset', '100', '56']; + yield ['200x50-inset', '89', '50']; + yield ['100x-inset@2x', '200', '113']; + yield ['x100-inset@2x', '356', '200']; + yield ['100x100-inset@2x', '200', '113']; + yield ['sulu-100x', '100', '56']; + yield ['sulu-x100', '178', '100']; + yield ['sulu-100x100', '100', '100']; + yield ['sulu-100x-inset', '100', '56']; + yield ['sulu-x100-inset', '178', '100']; + yield ['sulu-100x100-inset', '100', '56']; + yield ['sulu-100x-inset@2x', '200', '113']; + yield ['sulu-x100-inset@2x', '356', '200']; + yield ['sulu-100x100-inset2x', '200', '113']; + } }