Make the world smaller ?

WebP is developed by Google. it is a Picture for the Web (get it ?). The idea is to make the images smaller to reduce the loading time of websites. This sounds great but it is not very simple for the average person to save his images in this format. Also as this is a pretty recent format not everyone is able to visualize them.

So in order to get the best out of this the idea was to keep the original image (a jpeg, png, bmp or gif) and also add the WebP image. As most of my projects run on Symfony 5 and PHP 7.4 at the moment I tried to find a way to get this done without asking the user (or admin) to upload both of the files every time.

After having spent some time looking for a way to get this done automatically I found the rosell-dk/webp-convert package on Packagist. It seems to do the job but also a bit too big for my needs. This is why I started our own converter.

Why reinvent the wheel ?

As mentioned there was already a package to generate the WebP images. It had some unnecessary features that I didn't need or want, and some features I needed were not included. Mainly it has (great) compatibility with many PHP versions, servers, image conversion programs, ... This is nice but as my projects will not touch so many different architectures this will make them feel fat for no reason. I also wanted to add some specific options to be able to easily change the name of the webp file and the directory in which it will be saved.

The Code !

It is publicly available on github

Requirements

The requirements for this is the following

  • "php": "^7.4",
  • "ext-gd": "*",
  • "symfony/http-foundation": "5.0.*"

This seems pretty small but let's look closer. PHP 7.4 because I try to stay close to the future, the symfony/http-foundation because it helps us figure out what the real type of a file is without just trusting the file extension.

But the main one here is ext-gd which is a PHP extension called GD which stands for Graphics Draw (now, ish, maybe, things have changed over time but that is not important). It has many functions to create or transform images and in our case to convert them to a different image type.

This means that, In order for this package to work, you need to have the extension installed and configured correctly. In order to not have to go through those loops every time we have created a Docker image that will have this ready.

So now that everything is ready in the requirements let's go further !

The things we want :)

So pretty straight forward we want to create a WebP image. In order to do so we will use the imagewebp() function given to us by GD.

imagewebp($imageRessource, $webPPath, $quality);

It takes an image resource, the path of where to save the file and the quality we want to create the file with. This seems pretty simple but what is an image resource ?

Creating an image resource

This is basically the data GD will work with to create and modify the images. In our case we want the resource to come from the different possible file formats so there is a function to get this done :

/**
* @param string $path
* @param string $extension
* @return resource
* @throws Exception
*/
private static function createImageResource(string $path, string $extension)
{
    if ($extension === 'png') {
        $imageResource = imagecreatefrompng($path);
    } elseif ($extension === 'jpeg') {
        $imageResource = imagecreatefromjpeg($path);
    } elseif ($extension === 'bmp') {
        $imageResource = imagecreatefrombmp($path);
    } elseif ($extension === 'gif') {
        $imageResource = imagecreatefromgif($path);
    } else {
        throw new Exception("No valid file type provided for {$path}");
    }
    self::setColorsAndAlpha($imageResource);
    return $imageResource;
}

It will take the path to the image and it's extension. The important thing here (and that is why we use the symfony/http-foundation) is that the extension comes from the files content and not just his name :

$extension = $file->guessExtension();

Next you can see that this functions calls another one self::setColorsAndAlpha($imageRessource); this will allow us to set the correct colors and mainly to add the transparency of the original PNG or GIF file to the final WebP image :

/**
* @param resource $image
*/
private static function setColorsAndAlpha(&$image)
{
    imagepalettetotruecolor($image);
    imagealphablending($image, true);
    imagesavealpha($image, true);
}

This will just call some of the other GD functions to put this in place. You can notice the parameter starts with & this allows us to change the image in the previous function without having to return it.

We now have a great image resource that can be converted to a WebP image ! However the next thing we needed was the path where we want the image to be saved and the name we want it to have.

The path of the saved file

When first starting this I just made the path the same as the original file path and the name as well with just having the extension set to .webp. However I started to feel more needs arising the more I started using this package. This is why the main createWebPImage($image, array $options = []) function now takes an options array.

Let's quickly look at the first parameter, which is an image :

/**
* @param File|string $image
* @param array $options
* @return array
* @throws Exception
*/
public static function createWebPImage($image, array $options = []): array
{
    $file = ($image instanceof File) ? $image : new File($image);
    $fullPath = $file->getRealPath();
    self::setPathAndFilenameOptions($options, $file);

    self::verifyOptions($options);
    // ......

This can be just a path to an image or an instance of the Http-foundation File class. If it is just a path we will create the File object to be able to use it's functions, as you can see here we get the file's full path.

Next we will use the options to get some more information :

/**
* @param array $options
* @param File $file
*/
private static function setPathAndFilenameOptions(array &$options, File $file)
{
    $options['savePath'] ??= $file->getPath();
    $options['filename'] ??= substr($file->getFilename(), 0, strrpos($file->getFilename(), '.'));
}

If the options savePath or filename are set in the options array their values will be used. If not the default file path will be used and the filename will be the original file without its extension.

Now that these two options are set let's check the values of all the options :

/**
* @param array $options
* @throws Exception
*/
private static function verifyOptions(array &$options)
{
    $options['saveFile'] ??= false;
    $options['quality'] ??= 80;
    $options['force'] ??= false;
    $options['filenameSuffix'] ??= '';

    [
        'saveFile' => $saveFile,
        'force' => $force,
        'quality' => $quality,
        'savePath' => $savePath,
        'filename' => $filename,
        'filenameSuffix' => $filenameSuffix
    ] = $options;

    if (!is_bool($saveFile)) {
        throw new Exception('The saveFile option can only be a boolean');
    }

    if (!is_bool($force)) {
        throw new Exception('The force option can only be a boolean');
    }

    if (!is_int($quality) || $quality < 1 || $quality > 100) {
        throw new Exception('The quality option needs to be an integer between 1 and 100');
    }

    if (!is_string($savePath) || !is_string($filename) || !is_string($filenameSuffix)) {
        throw new Exception('The savePath, filename and filenameSuffix options can only be strings');
    }
}

The first few lines will set the default options if none of the options have been set explicitly. Then we create several variables thanks to the PHP array destructuring you can read more about here.

We then make sure all the values are what they are supposed to be, strings, booleans or integers. For the quality we will also make sure the value is between 1 and 100 which are the only acceptable boundaries for the GD function.

Next in the main createWebPImage function we will make sure the submitted image is not already a WebP image and get the options we will need :

  [
      'saveFile' => $saveFile,
      'force' => $force,
      'quality' => $quality,
  ] = $options;

$extension = $file->guessExtension();

if ($file->guessExtension() === "webp") {
    throw new Exception("{$fullPath} is already webP");
}

If this is not the case we will try and create an image resource and the path where the converted image should be saved :

$imageRessource = self::createImageRessource($fullPath, $extension);
$webPPath = self::createWebPPath($options);

We have seen the createImageResource earlier on let's look at how we create the WebPPath :

/**
* @param $options
* @return string
*/
private static function createWebPPath($options): string
{
    [
        'savePath' => $savePath,
        'filename' => $filename,
        'filenameSuffix' => $filenameSuffix
    ] = $options;

    return "{$savePath}/{$filename}{$filenameSuffix}.webp";
    }

It will take some of the options from which we have verified the values and set the default ones if not specified earlier on. Then it will return a string with the final value. By default the name of the image is the same as the image that is going to be converted. This is what will make the most sense most of the time but why not make it customizable ;) . Next the save path can be configured if you want the image to be saved in a different directory then the original. This could make sens if you save your images in different directories depending on their types for example:

  • images/
    • jpeg/
    • png/
    • ...
    • webp/

and finally the one that made a lot of sense for me was the possibility to add a filename suffix. This is just a bit to add to the end of the filename but before the prefix. If the original file was my_image.jpeg and you convert it using 80 out of 100 in quality and add a filenameSuffix _quality_80 the final image will be my_image_quality_80.webp. This can come in handy if you want to create different file qualities depending on the window size or at first get a small one and slowly show a better quality image on the frontend for example.

Great ! All the information is here. By default the function will return the created image resource but not save it. This is in case you want to customize the way it is saved even more. However if you set the saveFile option to true the file will be saved directly :

if ($saveFile) {
    if (file_exists($webPPath) && !$force) {
        throw new Exception("The webp file already exists, set the force option to true if you want to override it");
    }
    imagewebp($imageResource, $webPPath, $quality);
}
return [
    "resource" => $imageResource,
    "path" => $webPPath
];

As you can see there is another variable force, if an image with the same name of what the converted image would end up being already exists by default it will not be created. If you set force to true it always will be. This can come in handy if you do not set a filenameSuffix but end up wanting to change to final quality of each image.

The function will always return the image resource and the path as it has been created with the options.

This is enough to make everything work but there are two more public functions :

/**
* @param File|string $file
* @param array $options
* @return bool
* @throws Exception
*/
public static function convertedWebPImageExists($file, array $options = []): bool
{
    return (file_exists(self::convertedWebPImagePath($file, $options)));
}

/**
* @param File|string $file
* @param array $options
* @return string
* @throws Exception
*/
public static function convertedWebPImagePath($file, array $options = []): string
{
    $file instanceof File ?: $file = new File($file);
    self::setPathAndFilenameOptions($options, $file);
    self::verifyOptions($options);
    return self::createWebPPath($options);
}

These are there so that it can be easy to verify the final path of where the WebP image would be saved or to check whether it already exists. This could be done with the createWebPImage as it will also return the path but that would also trigger the creation of the image resource which we do not always need. So to make this faster these functions have been added.

Yay, nearly there ;)

This all works but a question remains, why are all the functions static ? This was to make it pretty simple to add to any PHP project and not having to worry about constructing a converter. This removes the need to add all the different options in the correct order and allows a simple options array.

If you want to use this package you need to require it :

composer require codebuds/webp-converter

And then just call the function :

 try {
     $webp = WebPConverter::createWebpImage($path, ['saveFile' => true, 'quality' => 10]);
 } catch(Exception $e) {
     // Do something with the exception
 }

This function has many different Exceptions to throw so if you are careful you will try to catch them ;)

That is pretty much it for now for this package, soon we will look at how we have created a Symfony Bundle around it to integrate it with Twig and other stuff.

Thanks for reading, do not hesitate to create issues on Github I will be happy to take a look.