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