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
+
+```
+
+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(
'',
- $this->imageExtension->getImage($this->image, 'sulu-100x100')
+ $imageExtension->getImage($this->image, 'sulu-100x100')
);
}
public function testImageTagObject(): void
{
+ $imageExtension = new ImageExtension('/lazy');
+
$this->assertSame(
'',
- $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(
'',
- $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(
'',
- $this->imageExtension->getLazyImage($this->image, 'sulu-100x100')
+ $imageExtension->getLazyImage($this->image, 'sulu-100x100')
);
}
public function testLazyComplexImageTag(): void
{
+ $imageExtension = new ImageExtension('/lazy');
+
$this->assertSame(
'',
- $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(
'',
- $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(
+ '',
+ $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(
+ '',
+ $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'];
+ }
}