Skip to content

Commit

Permalink
feat: multiple image formats support (webp, avif) (#2017)
Browse files Browse the repository at this point in the history
  • Loading branch information
ArnaudLigny authored Jul 25, 2024
1 parent edd6e26 commit dbdf7de
Show file tree
Hide file tree
Showing 9 changed files with 161 additions and 111 deletions.
10 changes: 3 additions & 7 deletions config/default.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,7 @@
'resize' => [
'enabled' => false, // enables image resizing by using the `width` extra attribute (`false` by default)
],
'webp' => [
'enabled' => false, // creates and adds a WebP image as a `source` (`false` by default)
],
'formats' => [], // creates and adds formats images as `source` (empty by default)
'responsive' => [
'enabled' => false, // creates responsive images and adds them to the `srcset` attribute (`false` by default)
],
Expand Down Expand Up @@ -233,9 +231,7 @@
],
'enabled' => false, // `html` filter: creates responsive images (`false` by default)
],
'webp' => [
'enabled' => false, // `html` filter: creates and adds a WebP image as a `source` (`false` by default)
],
'formats' => [], // `html` filter: creates and adds formats images as `source` (empty by default)
'cdn' => [
'enabled' => false, // enables Image CDN (`false` by default)
'canonical' => true, // is `image_url` must be canonical or not (`true` by default)
Expand Down Expand Up @@ -418,7 +414,7 @@
],
'images' => [
'enabled' => true, // enables images files optimization
'ext' => ['jpeg', 'jpg', 'png', 'gif', 'webp', 'svg'], // supported files extensions
'ext' => ['jpeg', 'jpg', 'png', 'gif', 'webp', 'svg', 'avif'], // supported files extensions
],
],
];
9 changes: 5 additions & 4 deletions docs/2-Content.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,9 +330,9 @@ Ratio is preserved (`height` attribute is calculated automatically), the origina
This feature requires [GD extension](https://www.php.net/manual/book.image.php) (otherwise it only add a `width` HTML attribute to the `img` tag).
:::

#### WebP
#### Formats

If the [`webp` option](4-Configuration.md#body) is enabled, an alterative image in the [WebP](https://developers.google.com/speed/webp) format is created.
If the [`formats` option](4-Configuration.md#body) is defined, alternatives images are created and added.

_Example:_

Expand All @@ -344,13 +344,14 @@ Is converted to:

```html
<picture>
<source srcset="/image.avif" type="image/avif">
<source srcset="/image.webp" type="image/webp">
<img src="/image.jpg">
</picture>
```

:::important
This feature requires [WebP](https://developers.google.com/speed/webp) be supported by PHP installation.
Please note that **not all image formats** are always included in the PHP image extensions.
:::

#### Responsive
Expand Down Expand Up @@ -396,7 +397,7 @@ assets:
```

:::info
You can combine `webp` and `responsive` options.
You can combine `formats` and `responsive` options.
:::

#### CSS class
Expand Down
25 changes: 19 additions & 6 deletions docs/3-Templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -995,7 +995,7 @@ _Example:_

### webp

Converts an image to WebP format.
Converts an image to [WebP](https://developers.google.com/speed/webp) format.

_Example:_

Expand All @@ -1006,6 +1006,19 @@ _Example:_
</picture>
```

### avif

Converts an image to [AVIF](https://github.com/AOMediaCodec/libavif) format.

_Example:_

```twig
<picture>
<source type="image/avif" srcset="{{ asset(image_path)|avif }}">
<img src="{{ url(asset(image_path)) }}" width="{{ asset(image_path).width }}" height="{{ asset(image_path).height }}" alt="">
</picture>
```

### dataurl

Returns the [data URL](https://developer.mozilla.org/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) of an asset.
Expand Down Expand Up @@ -1054,10 +1067,10 @@ Converts an asset into an HTML element.
{{ asset(path)|html({attributes, options}) }}
```

| Option | Description | Type | Default |
| ---------- | ----------------------------------------------- | ----- | ------- |
| attributes | Adds `name="value"` couple to the HTML element. | array | |
| options | `{preload: true}`: preloads CSS.<br>`{responsive: true}`: creates responsives images.<br>`{webp: true}`: creates WebP versions of the image. | array | |
| Option | Description | Type |
| ---------- | ----------------------------------------------- | ----- |
| attributes | Adds `name="value"` couple to the HTML element. | array |
| options | `{preload: boolean}`: preloads CSS.<br>`{responsive: boolean}`: creates responsives images.<br>`{formats: array}`: creates image alternative formats. | array |

_Examples:_

Expand All @@ -1068,7 +1081,7 @@ _Examples:_

```twig
{# image with specific attributes and options #}
{{ asset('image.jpg')|html({alt: 'Description', loading: 'lazy'}, {responsive: true, webp: true}) }}
{{ asset('image.jpg')|html({alt: 'Description', loading: 'lazy'}, {responsive: true, formats: ['avif','webp']}) }}
```

```twig
Expand Down
6 changes: 2 additions & 4 deletions docs/4-Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -635,8 +635,7 @@ pages:
enabled: true # adds `decoding="async"` attribute (`true` by default)
resize:
enabled: false # enables image resizing by using the `width` extra attribute (`false` by default)
webp:
enabled: false # adds a WebP image as a `source` (`false` by default)
formats: [] # creates and adds formats images as `source` (empty by default)
responsive:
enabled: false # creates responsive images and add them to the `srcset` attribute (`false` by default)
class: '' # put default class to each image (empty by default)
Expand Down Expand Up @@ -828,8 +827,7 @@ assets:
sizes:
default: '100vw' # default `sizes` attribute (`100vw` by default)
enabled: false # used by `html` filter: creates responsive images by default (`false` by default)
webp:
enabled: false # used by `html` filter: creates and adds a WebP image as a `source` by default (`false` by default)
formats: [] # used by `html` filter: creates and adds formats images as `source` (empty by default)
```
:::
Expand Down
49 changes: 34 additions & 15 deletions src/Assets/Asset.php
Original file line number Diff line number Diff line change
Expand Up @@ -460,45 +460,64 @@ public function resize(int $width): self
}

/**
* Converts an image asset to WebP format.
* Converts an image asset to $format format.
*
* @throws RuntimeException
*/
public function webp(?int $quality = null): self
public function convert(string $format, ?int $quality = null): self
{
if ($this->data['type'] != 'image') {
throw new RuntimeException(sprintf('Not able to convert "%s" (%s) to WebP: not an image.', $this->data['path'], $this->data['type']));
throw new RuntimeException(sprintf('Not able to convert "%s" (%s) to %s: not an image.', $this->data['path'], $this->data['type'], $format));
}

if ($quality === null) {
$quality = (int) $this->config->get('assets.images.quality') ?? 75;
}

$assetWebp = clone $this;
$format = 'webp';
$assetWebp['ext'] = $format;
$asset = clone $this;
$asset['ext'] = $format;

if ($this->isImageInCdn()) {
return $assetWebp; // returns the asset with the new extension only: CDN do the rest of the job
return $asset; // returns the asset with the new extension only: CDN do the rest of the job
}

$cache = new Cache($this->builder, (string) $this->builder->getConfig()->get('cache.assets.dir'));
$tags = ["q$quality"];
if ($this->data['width']) {
array_unshift($tags, "{$this->data['width']}x");
}
$cacheKey = $cache->createKeyFromAsset($assetWebp, $tags);
$cacheKey = $cache->createKeyFromAsset($asset, $tags);
if (!$cache->has($cacheKey)) {
$assetWebp->data['content'] = Image::convert($assetWebp, $format, $quality);
$assetWebp->data['path'] = preg_replace('/\.' . $this->data['ext'] . '$/m', ".$format", $this->data['path']);
$assetWebp->data['subtype'] = "image/$format";
$assetWebp->data['size'] = \strlen($assetWebp->data['content']);
$asset->data['content'] = Image::convert($asset, $format, $quality);
$asset->data['path'] = preg_replace('/\.' . $this->data['ext'] . '$/m', ".$format", $this->data['path']);
$asset->data['subtype'] = "image/$format";
$asset->data['size'] = \strlen($asset->data['content']);

$cache->set($cacheKey, $assetWebp->data);
$cache->set($cacheKey, $asset->data);
}
$assetWebp->data = $cache->get($cacheKey);
$asset->data = $cache->get($cacheKey);

return $asset;
}

/**
* Converts an image asset to WebP format.
*
* @throws RuntimeException
*/
public function webp(?int $quality = null): self
{
return $this->convert('webp', $quality);
}

return $assetWebp;
/**
* Converts an image asset to AVIF format.
*
* @throws RuntimeException
*/
public function avif(?int $quality = null): self
{
return $this->convert('avif', $quality);
}

/**
Expand Down
8 changes: 4 additions & 4 deletions src/Assets/Image.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public static function resize(Asset $asset, int $width, int $quality): string
// resizes to $width with constraint the aspect-ratio and unwanted upsizing
$image->scaleDown(width: $width);
// return image data
return (string) $image->encodeByMediaType($asset['subtype'], /** @scrutinizer ignore-type */ progressive: true, /** @scrutinizer ignore-type */ interlaced: true, quality: $quality);
return (string) $image->encodeByMediaType($asset['subtype'], /** @scrutinizer ignore-type */ progressive: true, /** @scrutinizer ignore-type */ interlaced: false, quality: $quality);
} catch (\Exception $e) {
throw new RuntimeException(sprintf('Not able to resize "%s": %s', $asset['path'], $e->getMessage()));
}
Expand All @@ -74,7 +74,7 @@ public static function convert(Asset $asset, string $format, int $quality): stri
throw new RuntimeException(sprintf('Function "image%s" is not available.', $format));
}

return (string) $image->encodeByExtension($format, /** @scrutinizer ignore-type */ progressive: true, /** @scrutinizer ignore-type */ interlaced: true, quality: $quality);
return (string) $image->encodeByExtension($format, /** @scrutinizer ignore-type */ progressive: true, /** @scrutinizer ignore-type */ interlaced: false, quality: $quality);
} catch (\Exception $e) {
throw new RuntimeException(sprintf('Not able to convert "%s": %s', $asset['path'], $e->getMessage()));
}
Expand All @@ -100,7 +100,7 @@ public static function getDataUrl(Asset $asset, int $quality): string
}

/**
* Returns the dominant hexadecimal color of an image asset.
* Returns the dominant RGB color of an image asset.
*
* @throws RuntimeException
*/
Expand All @@ -114,7 +114,7 @@ public static function getDominantColor(Asset $asset): string
$assetColor = $assetColor->resize(100);
$image = self::manager()->read($assetColor['content']);

return $image->reduceColors(1)->pickColor(0, 0)->toHex();
return $image->pickColor(0, 0)->toString();
} catch (\Exception $e) {
throw new RuntimeException(sprintf('Can\'t get dominant color of "%s": %s', $asset['path'], $e->getMessage()));
}
Expand Down
84 changes: 44 additions & 40 deletions src/Converter/Parsedown.php
Original file line number Diff line number Diff line change
Expand Up @@ -365,8 +365,12 @@ protected function inlineImage($Excerpt)
/*
<!-- if title: a <figure> is required to put in it a <figcaption> -->
<figure>
<!-- if WebP is enabled: a <picture> is required for the WebP <source> -->
<!-- if formats: a <picture> is required for each <source> -->
<picture>
<source type="image/avif"
srcset="..."
sizes="..."
>
<source type="image/webp"
srcset="..."
sizes="..."
Expand All @@ -382,63 +386,63 @@ protected function inlineImage($Excerpt)

$image = $InlineImage;

// converts image (JPEG, PNG or GIF) to WebP and put it in picture > source
// converts image to formats and put them in picture > source
if (
((bool) $this->config->get('pages.body.images.webp.enabled') ?? false)
\count($formats = ((array) $this->config->get('pages.body.images.formats') ?? [])) > 0
&& \in_array($InlineImage['element']['attributes']['src']['subtype'], ['image/jpeg', 'image/png', 'image/gif'])
) {
try {
// InlineImage src must be an Asset instance
if (!$InlineImage['element']['attributes']['src'] instanceof Asset) {
throw new RuntimeException(sprintf('Asset "%s" can\'t be converted to WebP.', $InlineImage['element']['attributes']['src']));
throw new RuntimeException(sprintf('Asset "%s" can\'t be converted.', $InlineImage['element']['attributes']['src']));
}
// abord if InlineImage is an animated GIF
if (Image::isAnimatedGif($InlineImage['element']['attributes']['src'])) {
throw new RuntimeException(sprintf('Asset "%s" is an animated GIF and can\'t be converted to WebP.', $InlineImage['element']['attributes']['src']));
throw new RuntimeException(sprintf('Asset "%s" is an animated GIF.', $InlineImage['element']['attributes']['src']));
}
$assetWebp = $InlineImage['element']['attributes']['src']->webp();
$srcset = '';
// build responsives WebP?
if ((bool) $this->config->get('pages.body.images.responsive.enabled')) {
try {
$srcset = Image::buildSrcset(
$assetWebp,
$this->config->getAssetsImagesWidths()
);
} catch (\Exception $e) {
$this->builder->getLogger()->debug($e->getMessage());
$sources = [];
foreach ($formats as $format) {
$assetConverted = $InlineImage['element']['attributes']['src']->$format();
$srcset = '';
// build responsive images?
if ((bool) $this->config->get('pages.body.images.responsive.enabled')) {
try {
$srcset = Image::buildSrcset($assetConverted, $this->config->getAssetsImagesWidths());
} catch (\Exception $e) {
$this->builder->getLogger()->debug($e->getMessage());
}
}
}
// if not, default image as srcset
if (empty($srcset)) {
$srcset = (string) $assetWebp;
}
$picture = [
'extent' => $InlineImage['extent'],
'element' => [
'name' => 'picture',
'handler' => 'elements',
'attributes' => [
'title' => $image['element']['attributes']['title'],
],
],
];
$source = [
'element' => [
// if not, use default image as srcset
if (empty($srcset)) {
$srcset = (string) $assetConverted;
}
$sources[] = [
'name' => 'source',
'attributes' => [
'type' => 'image/webp',
'type' => "image/$format",
'srcset' => $srcset,
'sizes' => $sizes,
'width' => $InlineImage['element']['attributes']['width'],
'height' => $InlineImage['element']['attributes']['height'],
],
],
];
$picture['element']['text'][] = $source['element'];
unset($image['element']['attributes']['title']); // @phpstan-ignore unset.offset
$picture['element']['text'][] = $image['element'];
$image = $picture;
];
}
if (count($sources) > 0) {
$picture = [
'extent' => $InlineImage['extent'],
'element' => [
'name' => 'picture',
'handler' => 'elements',
'attributes' => [
'title' => $image['element']['attributes']['title'],
],
],
];
$picture['element']['text'] = $sources;
unset($image['element']['attributes']['title']); // @phpstan-ignore unset.offset
$picture['element']['text'][] = $image['element'];
$image = $picture;
}
} catch (\Exception $e) {
$this->builder->getLogger()->debug($e->getMessage());
}
Expand Down
Loading

0 comments on commit dbdf7de

Please sign in to comment.