Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: multiple image formats support (webp, avif) #2017

Merged
merged 11 commits into from
Jul 25, 2024
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