Rendre le monde plus petit ?

WebP a été créé par Google. C'est une Photo pour le Web (vous avez compris ?). L'idée c'est de rendre les images plus petite pour réduire le temps de chargement des sites internet. Ceci à l'air génial mais n'est pas forcément très facile pour la plupart des gens. Enregistrer dans ce format n'est pas un habitude ni forcément possible. De plus si nous avons seulement l'image dans ce format, étant récent, il ne sera pas visible pour tout le monde.

Afin d'être dans le meilleur des deux mondes l'idée est de garder l'image de base (jpeg, png, bmp ou gif) et de rajouter sa version WebP. Comme la plupart de mes projets tournent sous PHP 7.4 et Symfony 5 en ce moment j'ai essayé de trouver un moyen de faire cela sans demander à l'utilisateur (ou administrateur) d'envoyer l'image dans les deux formats à chaque fois.

Après avoir passé un peu de temps à chercher comment faire ça de manière automatique j'ai trouvé le paquet rosell-dk/webp-convert sur Packagist. Il semble bien faire l'affaire mais surtout essayer d'être compatible avec tout le monde ce qui le rends un peu trop gros pour nos besoins. C'est pour cela que j'ai commencé notre propre convertisseur.

Réinventer la roue ?

Comme dis auparavant il y a déjà un paquet capable de générer les images Webp. Il contient pas mal de features dont nous n'allons pas avoir besoin et il en manque qui pourraient nous servir. Principalement son avantage c'est d'être compatible avec pleins de versions de PHP, des serveurs et de services de transformation d'images, ... Ceci est fort sympathique mais pas très utile pour nous vu que nos projets ne vont pas toucher autant d'architecture différentes. Si nous utilisons ce paquet nos projets vont finir par se sentir un peu trop obèses sans raisons. Je voulais également ajouter des options afin de pouvoir customiser le nom et le chemin du fichier au moment de son enregistrement.

Le Code !

Le code est publiquement disponible sur github

Exigences

Les exigences sont

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

Cela ne semble pas être grand chose mais regardons de près. PHP 7.4 parce que nous voulons rester proche du future, symfony/http-foundation car il nous permet de bien définir les types de fichiers sans juste faire confiance en leurs extensions.

Le principal ici est ext-gd qui est une extension PHP qui est supposée vouloir dire Graphics Draw (avant, maintenant peut être c'est amené à changer, bref ...). Cette extension à pleins de fonctions qui nous permettent de créer et modifier des images ou, ce qui nous intéresse, de les faire changer de format.

Ceci veut dire que pour que ce paquet fonctionne il est nécessaire d'avoir cette extension installée et configurée correctement. Afin de ne pas avoir à régler ça à chaque fois nous avons créé une image Docker avec tout de configurée.

Avec toutes les exigences mises en place allons voir la suite !

Ce que nous voulons :)

C'est plutôt facile de créer l'image WebP avec la fonction de imagewebp() qui nous est donné par l'extension GD

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

Cela prends une ressource d'image, l'endroit où nous voulons le sauvegarder et la qualité avec laquelle nous voulons faire cela. Cela semble assez simple mais comment allons nous obtenir une ressource d'image ?

Créer la ressource d'image

La ressource est la donnée avec laquelle GD travaille afin de créer ou modifier des images. Pour nous il sera important de créer la ressource à partir des différents format d'images que nous voulons transformer. Voici la fonction qui nous permet de faire cela :

/**
* @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;
}

Cette fonctions prendra le chemin absolu vers le fichier et son extension. La chose importante ici (et donc pourquoi nous avons besoin de symfony/http-foundation) est que l'extension vient du contenu du fichier est pas seulement son nom.

$extension = $file->guessExtension();

Ensuite nous pouvons voir que cette fonction en appelle une autre interneself::setColorsAndAlpha($imageRessource);. Cela nous permet de bien appliquer les bonnes couleurs à l'image finale mais surtout de rajouter les transparences qui viennent des images GIF et PNG

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

Cela appellera d'autres fonctions GD pour mettre ça en place. Nous pouvons voir que le paramètre commence avec & ce qui nous permet de changer l'image envoyée par la fonction d'avant sans avoir à faire un retour.

Maintenant nous avons une superbe ressource d'image qui peut être enregistré en temps qu'image WebP. La prochaine chose à voir c'est comment dire où nous voulons l'enregistrer.

Le chemin de la nouvelle image

Au début ce paquet permettait simplement de rajouter une image WebP avec le même nom que l'image de base et enregistré dans le même dossier. Plus j'ai commencé à l'utiliser dans des projets différents plus j'ai ressenti le besoin de pouvoir customiser le nom et le chemin de l'image finale. C'est pour cela que la fonction principale createWebPImage($image, array $options = []) prends un tableau de paramètres désormais.

Le premier paramètre est l'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);
    // ......

Ceci peut simplement être le chemin absolu vers le fichier ou une instance de la classe File du symfony/http-foundation. Si c'est seulement un chemin nous allons créer un File enfin de pouvoir nous servir de ses fonctions. Ici nous allons nous servir de lui pour toujours avoir le chemin absolu du fichier.

Ensuite nous allons utiliser les options afin d'avoir plus d'informations :

/**
* @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(), '.'));
}

Si les options savePath ou filename sont définis dans le tableau leurs valeurs seront utilisés. Sinon la valeur par défaut sera le nom de l'image fourni sans son extension ainsi que son chemin.

Une fois ces deux options mises en place nous allons vérifier la valeur de toutes les 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');
    }
}

Les quelques premières lignes vont mettre des valeurs par défaut si aucune valeur n'a été envoyé. Ensuite nous transformons le tableau en plusieurs variables PHP grâce à la déstructuration dont vous pouvez en voir un peu plus ici.

Ensuite nous allons vérifier que toutes les options sont des types et des valeurs dont nous avons besoin. Par exemple saveFile et forcedoivent être des booleans et quality doit être un chiffre entre 1 et 100 qui sont les seules valeurs acceptées par les fonctions GD.

Ensuite dans notre fonction principale createWebPImage() nous allons nous assurer que l'image envoyé n'était pas déjà une image WebP et récupérer les options dont nous allons avoir besoin :

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

$extension = $file->guessExtension();

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

Si l'image n'est pas déjà dans le format finale nous allons essayer de créer la ressource d'image à partir de l'image envoyée et créer le chemin d'enregistrement pour le nouveau fichier :

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

nous avons vu la fonction createImageResource , regardons comment créer le chemin de fichier WebP :

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

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

Cela prendra plusieurs des options pour lesquelles nous venons de vérifier les valeurs. Cela retourne une string avec la valeur finale. Par défaut le nom de l'image est la même que l'image fourni ce qui est très souvent la seule chose nécessaire. Le dossier dans lequel le fichier va être rangé peut être défini, sinon le dossier sera le même que l'image source. Cela peut avoir du sens si vous enregistrez vos images dans des dossier spécifiques selon leurs formats par exemple :

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

Et pour finir celui qui me concernait surtout c'était la possibilité de rajouter un suffixe au nom de l'image. Si le nom de l'image de base était mon_image.jpeg et nous l'avions converti avec une qualité de 80/100 nous pouvons désormais ajouter un suffix du genre quality_80 ce qui va va faire en sorte que l'image enregistré au final aura comme nom mon_image_quality_80.webp. Cela peut servir si vous voulez créer plusieurs qualités pour la même image selon la taille de l'écran ou pour charger une mauvaise qualité au début et ensuite l'améliorer afin de toujours avoir une image visible pendant le chargement de la page.

Génial, nous avons toutes les informations dont nous avons besoin. Par défaut la fonction ne retournera que la ressource de l'image WebP ainsi que le chemin ou nous devrions l'enregistrer. Par contre il nous est possible de passer l'option saveFile avec la valeur true afin de déclencher l'enregistrement de l'image automatiquement :

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
];

Comme nous pouvons le voir il y a également une autre variable, force, si une image WebP existe déjà avec le même nom et dans le même dossier elle ne sera pas recréée par défaut. Si nous passons cette option à true l'image sera toujours recréée. Cela peut être utile si nous voulons changer la qualité de l'image mais que nous avons jamais spécifié de suffixe dans son nom.

La fonction retournera toujours la ressource d'image ainsi que son chemin absolu. Cela est suffisant pour tout faire fonctionner mais il y a deux fonctions publiques en plus :

/**
* @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);
}

Ils sont là afin de rendre facile de vérifier le chemin final où l'image aurait été enregistré ou pour vérifier si l'image existe déjà. Cela aurait pu être fait avec la fonction principale createWebPImage vu que cela retourne le chemin de l'image. Mais cela aura toujours déclenché la génération d'une ressource image à partir de l'image envoyée ce qui est la partie qui fera le plus travailler ce paquet. Donc pour avoir un accès plus rapide à ces informations nous avons rajoutés ces fonctions.

Presque fini ;)

Tout fonctionne mais il reste une question. Pourquoi avoir que des fonctions static ? Ceci est fait pour faciliter l'implémentation de ce paquet dans tous les projets PHP sans avoir à se préoccuper d'un constructeur. Cela évite d'avoir à envoyer toutes les options dans tous les cas et dans un ordre précis.

Si vous voulez utiliser ce paquet vous pouvez le télécharger avec Composer :

composer require codebuds/webp-converter

Et appeler la fonction :

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

Cette fonction vous renverra pleins de types d'Exceptions donc pensez bien à les attraper.

Voila tout pour le moment. Bientôt je vous parlerai de comment nous intégrons ça dans un bundle Symfony afin de le rattacher à Twig et d'autres choses qui nous tournent autour.

Merci d'avoir lu ce post, n'hésitez pas à créer des issues sur Github je serai ravi de jeter un œil dessus