🎉 Cet article a été cité dans le Week of Symfony #702 🎉

Aujourd'hui j'ai envie de vous parler d'un pattern récurrent dans un projet Web : la gestion des images et surtout des miniatures associées.

En 2020, le poids des images dans une page Web est toujours un facteur clé dans l'optimisation du poids des pages. Combien d'entre-nous continue encore de servir aux internautes mobiles des images de 1200px de long ?

On va donc voir comment mettre en place dans un projet Symfony 5 la librairie PHP Glide qui met à disposition une API pour gérer la manipulation d'image.

Démo

Avant de partir dans les explications, je vais déjà tenter de vous attendrir avec le type de rendu final que l'on peut obtenir avec cette librairie :

Utiliser Glide avec Symfony
La magnifique démo de cet article : 👉 https://labs.silarhi.fr/images

Installation

Sans plus attendre on va commencer par installer tout doucement la librairie et créer un service Symfony pour gérer tout ça. Le package league/glide-symfony possède un adapter pour Symfony qui intègre la gestion des Response mais ne fournit pas de service par défaut.

composer require league/glide league/glide-symfony

Configuration

On va modifier un peu la configuration actuelle pour ajouter le service Symfony et préparer l'API.

# config/services.yaml
parameters:
    app.public_dir: '%kernel.project_dir%/public'
    app.image_cache_dir: '%kernel.project_dir%/var/storage/cache'

services:
    _defaults:
        #...
        bind:
            $secret: '%kernel.secret%'

    League\Glide\Server:
        factory: ['League\Glide\ServerFactory', create]
        arguments:
            source: '%app.public_dir%'
            cache: '%app.image_cache_dir%'

Mise en place

L'idée est ensuite de créer un Controller pour générer à la volée les miniatures (c'est notre "API"). On passera dans la requête le chemin relatif de l'image à utiliser ainsi que des pour indiquer la longueur / largeur désirée de la miniature, la couleur de fond, etc ...

Note sur la sécurité : Pour empêcher des utilisateurs malveillants de demander à générer sur votre serveur des centaines de miniatures aux dimensions non voulues (Imaginez votre serveur répondre à 10 000 requêtes de miniatures au format 50000x50000), nous allons également ajouter un hash de signature qui sera unique pour les dimensions données et que le contrôleur devra valider pour générer la miniature.

Le controller

<?php
//src/Controller/AssetsController.php

namespace App\Controller;

use League\Glide\Filesystem\FileNotFoundException;
use League\Glide\Responses\SymfonyResponseFactory;
use League\Glide\Server;
use League\Glide\Signatures\SignatureException;
use League\Glide\Signatures\SignatureFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

class AssetsController extends Controller
{
    /**
     * @Route("/assets/{path<(.+)>}", name="asset_url", methods={"GET"})
     */
    public function asset(string $path, string $secret, Request $request, Server $glide)
    {
        $parameters = $request->query->all();

        if (\count($parameters) > 0) {
            try {
                SignatureFactory::create($secret)->validateRequest($path, $parameters);
            } catch (SignatureException $e) {
                throw $this->createNotFoundException('', $e);
            }
        }

        $glide->setResponseFactory(new SymfonyResponseFactory($request));

        try {
            $response = $glide->getImageResponse($path, $parameters);
        } catch (\InvalidArgumentException | FileNotFoundException $e) {
            throw $this->createNotFoundException('', $e);
        }

        return $response;
    }
}

L'extension Twig

<?php
// src/Twig/AssetExtension.php

namespace App\Twig;

use League\Glide\Signatures\SignatureFactory;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;

class AssetExtension extends AbstractExtension
{
    /** @var UrlGeneratorInterface */
    private $router;

    /** @var string */
    private $secret;

    public function __construct(UrlGeneratorInterface $router, string $secret)
    {
        $this->router = $router;
        $this->secret = $secret;
    }

    public function getFunctions()
    {
        return [
            new TwigFunction('app_asset', [$this, 'appAsset'], ['is_safe' => ['html']]),
        ];
    }

    public function appAsset(string $path, array $parameters = [], $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH)
    {
        $parameters['fm'] = 'pjpg';

        if ('png' === substr($path, -3)) {
            $parameters['fm'] = 'png';
        }

        $parameters['s'] = SignatureFactory::create($this->secret)->generateSignature($path, $parameters);
        $parameters['path'] = ltrim($path, '/');

        return $this->router->generate('asset_url', $parameters, $referenceType);
    }
}

Avec ces 2 nouveaux fichiers, vous avez un système tout prêt de génération de miniatures sécurisé pour votre belle application !

Utilisation

Il ne vous reste plus qu'à utiliser votre fonction twig dans vos templates. La version minimale pourrait ressembler à ceci :

{# templates/images.html.twig #}
<img class="img-fluid"
     src="{{ app_asset(asset('images/mon-image.jpg'), {'w': 400, 'h': 200}) }}"
     width="400"
     height="200"
     alt="Ma super image de 400x200"
     title="Ma super image de 400x200"
/>

Ce qui produit l'URL suivante :

/assets/images/mon-image.jpg?w=400&h=200&fm=pjpg&s=d6ba0d9ef7622516b83fa3402527affe

Ce qui revient à demander au serveur :

Génère-moi la miniature du fichier /images/mon-image.jpg (si elle n'est pas déjà générée) d'une longueur de 400px (paramètre w), d'une hauteur de 200px (paramètre h), au format JPG Progressive (paramètre fm). Pour te prouver que je suis de bonne foi, je te donne le hash de vérification de ces 4 paramètres (paramètre s). Merci bisous.

Gestion des écrans retina

La gestion des écrans retina devient beaucoup plus simple vu qu'il n'y a plus qu'à passer les bons paramètres au contrôleur.

{# templates/images.html.twig #}
<img class="img-fluid"
     src="{{ app_asset(asset('images/mon-image.jpg'), {'w': 400, 'h': 200}) }}"
     srcset="{{ app_asset(asset('images/mon-image.jpg'), {'w': 400, 'h': 200, 'dpr': 2}) }}" 2x
     width="400"
     height="200"
     alt="Ma super image de 400x200"
     title="Ma super image de 400x200"
/>

J'ai juste ajouté avec le paramètre dpr (Device pixel ratio) pour générer une image 2 fois plus dense (en l'occurence 800x400).

Aller plus loin

Le bon développeur ou la bonne développeuse que vous êtes a sûrement détecté un pattern à factoriser pour améliorer la maintenabilité de tout ça. Je vous joins les 2 macros que j'utilise dans quasiment tous mes projets Symfony :

{# templates/macros.html.twig #}
{% macro image(path, width, height, attrs, params) %}
    <img src="{{ app_asset(path, params|default({})|merge({'w': width, 'h': height})) }}"
         srcset="{{ app_asset(path, params|default({})|merge({'w': width, 'h': height, 'dpr': 2})) }} 2x"
         width="{{ width }}"
         height="{{ height }}"
         loading="lazy"
        {% for name,value in attrs|default([])|filter((k, v) => k != 'class') %}{{ name }}="{{ value|e }}" {% endfor %}
    />
{% endmacro %}

{% macro fixedHeightImage(path, height, attrs) %}
    <img class="img-fluid {{ attrs.class|default(null) }}"
         src="{{ app_asset(path, {'h': height}) }}"
         srcset="{{ app_asset(path, {'h': height, 'dpr': 2}) }} 2x"
         loading="lazy"
        {% for name,value in attrs|default([]) |filter((k, v) => k != 'class') %}{{ name }}="{{ value|e }}" {% endfor %}
    />
{% endmacro %}
{# templates/images.html #}
{% import 'macros.html.twig' as macros %}

{{ macros.image(
    asset('images/mon-image.jpg'),
    300,
    300,
    {'class': 'img-fluid'},
) }}

Voilà ! Vous êtes maintenant un pro des miniatures ! N'hésitez pas à regarder le code source de la démo qui intègre Webpack en plus de tout ça.

Pour aller plus loin