Des images au cordeau pour vos applications Symfony

Mathieu

Santostefano

Développeur web

PHP, JS, Docker, Elasticsearch, ...

@welcomattic sur Twitter et GitHub

Images responsives

🗜

😀

🌿

Photo by Michael Maasen on Unsplash

Device Pixel Ratio

Rapport entre pixels physiques (écran)

et pixels logiques (CSS)

1 par défaut

2 pour les écrans de type iPhone 6s

3 pour les écrans de type iPhone 6s +

4 pour les écrans de type Samsung Galaxy S8

DPR = 1 : 1 pixel CSS = 1 pixel d'écran

DPR = 2 : 1 pixel CSS = 2 pixels d'écran

DPR = 3 : 1 pixel CSS = 3 pixels d'écran

Device Pixel Ratio

Photo by Hal Gatewood on Unsplash

🤨

Appareils de test

Smartphone (iPhone 6,7,8) 750x1334 DPR 2
Smartphone (Samsung Galaxy S6, S7) 1440x2560 DPR 4
Desktop (Laptop 13 pouces) 1920x1080 DPR 1
Desktop (MacBook Pro Retina) 1440x900 DPR 2

Photo by chuttersnap on Unsplash

1920px

1276px

<img src="elephant_1920.jpg"
     alt="Éléphant"
     width="1920"
     height="1276">
img {
  max-width: 100%;
  height: auto;
}

Image fluide 🥳

mais lourde

😓

Chargement des images

  • Le navigateur charge le DOM
  • Il le parse
  • Il lance les requêtes pour charger les images, les CSS et les scripts JS, ...

Chargement des images

Au parsing du DOM, le navigateur connaît :

  • le DPR de l'appareil
  • les dimensions du viewport
  • la qualité du réseau

mais, le CSS n'étant pas encore chargé, il ne connaît pas :

  • les dimensions des zones de rendu des balises <img>

Responsive

=

Breakpoints

Maquette

50vw

Maquette

100vw

Utilitaire CLI pour générer des breakpoints d'image à partir de données analytics

L'attribut sizes indique au navigateur quelle largeur (en vw ou en pixels CSS) fera la balise <img> selon la largeur du viewport (en pixels physiques)

On utilise ici la syntaxe des media-queries CSS.

💡

<img src="elephant_1920.jpg" alt="Éléphant"
     sizes="(max-width: 3840px) 50vw, 1920px"
     srcset="
         elephant_600.jpg  600w,
         elephant_1166.jpg 1166w,
         elephant_1585.jpg 1585w,
         elephant_1920.jpg 1920w"
>

L'attribut srcset indique au navigateur quelles sources d'image sont disponibles, ainsi que leurs largeurs, en pixels physiques (descripteur w).

 

Ainsi le navigateur peut choisir l'image la plus adaptée.

<img src="elephant_1920.jpg" alt="Éléphant"
     sizes="(max-width: 3840px) 50vw, 1920px"
     srcset="
         elephant_600.jpg  600w,
         elephant_1166.jpg 1166w,
         elephant_1585.jpg 1585w,
         elephant_1920.jpg 1920w"
>

Pas besoin de spécifier le DPR ici, le navigateur le connait, et il saura choisir la bonne image :

 

Viewport == 1440px                                                  <img> = 50vw

DPR = 1                                                                    elephant_1166.jpg

DPR = 2                                                                  elephant_1585.jpg

(50% de 1440) = 720

(50% de (1440 x 2)) = 1440

<img src="elephant_1920.jpg" alt="Éléphant"
     sizes="(max-width: 3840px) 50vw, 1920px"
     srcset="
         elephant_600.jpg  600w,
         elephant_1166.jpg 1166w,
         elephant_1585.jpg 1585w,
         elephant_1920.jpg 1920w"
>

L'attribut srcset peut aussi être utilisé pour gérer les DPR. Ici, un avatar d'une largeur fixe en pixels CSS, sur tous les écrans.

On indique au navigateur les différentes sources disponibles selon le DPR actuel.

<img src="avatar.jpg" alt=""
    width="50" height="50"
    srcset="avatar.jpg 1x,
            avatar_2x.jpg 2x,
            avatar_3x.jpg 3x
            avatar_4x.jpg 4x">

srcset et sizes ne sont que des indications pour le navigateur. Il n'est pas obligé de les suivre.

Sur un smartphone avec un DPR de 4, avec peu de réseau, le navigateur peut forcer le chargement de l'image la plus légère

💡

L'ordre des sources dans srcset n'a aucune importance

L'ordre des media-queries dans sizes a une importance

L'attribut srcset sert également à servir différents formats d'image tout en gardant une compatibilité pour les navigateurs ne supportant pas les formats comme webp.

<picture>
    <source srcset="elephant.webp"
            type="image/webp">
    <img src="elephant.jpg" 
         alt="Éléphant">
</picture>
<picture>
    <source media="(max-width: 767px)"
            sizes="(max-width: 1534px) 100vw, 1534px"
            srcset="elephant_750.jpg 750w,
                    elephant_1131.jpg 1131w,
                    elephant_1415.jpg 1415w,
                    elephant_1534.jpg 1534w">

    <source media="(min-width: 768px) and (max-width: 1199px)"
            sizes="(max-width: 2400px) 50vw, 1200px"
            srcset="elephant_384.jpg 384w,
                    elephant_785.jpg 785w,
                    elephant_1026.jpg 1026w,
                    elephant_1200.jpg 1200w">

    <img src="elephant_1920.jpg" alt=""
         sizes="(max-width: 3840px) 50vw, 1920px"
         srcset="elephant_600.jpg 600w,
                 elephant_1166.jpg 1166w,
                 elephant_1585.jpg 1585w,
                 elephant_1920.jpg 1920w">
</picture>
<picture>
    <source media="(max-width: 767px)"
            sizes="(max-width: 1534px) 100vw, 1534px"
            srcset="elephant_750.jpg 750w,
                    elephant_1131.jpg 1131w,
                    elephant_1415.jpg 1415w,
                    elephant_1534.jpg 1534w">

    <source media="(min-width: 768px) and (max-width: 1199px)"
            sizes="(max-width: 2400px) 50vw, 1200px"
            srcset="elephant_384.jpg 384w,
                    elephant_785.jpg 785w,
                    elephant_1026.jpg 1026w,
                    elephant_1200.jpg 1200w">

    <img src="elephant_1920.jpg" alt=""
         sizes="(max-width: 3840px) 50vw, 1920px"
         srcset="elephant_600.jpg 600w,
                 elephant_1166.jpg 1166w,
                 elephant_1585.jpg 1585w,
                 elephant_1920.jpg 1920w">
</picture>
<picture>
    <source media="(max-width: 767px)"
            sizes="(max-width: 1534px) 100vw, 1534px"
            srcset="elephant_750.jpg 750w,
                    elephant_1131.jpg 1131w,
                    elephant_1415.jpg 1415w,
                    elephant_1534.jpg 1534w">

    <source media="(min-width: 768px) and (max-width: 1199px)"
            sizes="(max-width: 2400px) 50vw, 1200px"
            srcset="elephant_384.jpg 384w,
                    elephant_785.jpg 785w,
                    elephant_1026.jpg 1026w,
                    elephant_1200.jpg 1200w">

    <img src="elephant_1920.jpg" alt=""
         sizes="(max-width: 3840px) 50vw, 1920px"
         srcset="elephant_600.jpg 600w,
                 elephant_1166.jpg 1166w,
                 elephant_1585.jpg 1585w,
                 elephant_1920.jpg 1920w">
</picture>
<picture>
    <source media="(max-width: 767px)"
            sizes="(max-width: 1534px) 100vw, 1534px"
            srcset="elephant_750.jpg 750w,
                    elephant_1131.jpg 1131w,
                    elephant_1415.jpg 1415w,
                    elephant_1534.jpg 1534w">

    <source media="(min-width: 768px) and (max-width: 1199px)"
            sizes="(max-width: 2400px) 50vw, 1200px"
            srcset="elephant_384.jpg 384w,
                    elephant_785.jpg 785w,
                    elephant_1026.jpg 1026w,
                    elephant_1200.jpg 1200w">

    <img src="elephant_1920.jpg" alt=""
         sizes="(max-width: 3840px) 50vw, 1920px"
         srcset="elephant_600.jpg 600w,
                 elephant_1166.jpg 1166w,
                 elephant_1585.jpg 1585w,
                 elephant_1920.jpg 1920w">
</picture>

💡

L'ordre des <source> a une importance, le premier attribut media vérifié est utilisé

<img srcset="..." sizes="...">

!=

<picture> & <source>

srcset est une indication, le navigateur peut ne pas la respecter.

<picture> <source media="..."> la media-query est toujours testée, et respectée par le navigateur si elle est valide.

Générer différentes versions d'une

même image

Glide by The PHP League

Glide by The PHP League

Bibliothèque PHP de manipulation d'images, basée sur Intervention Image et Flysystem

<?php

$server = League\Glide\ServerFactory::create([
    'source' => 'path/to/source/folder',
    'cache' => 'path/to/cache/folder',
]);

$response = $server->getImageResponse('users/1.jpg', [
    'w' => 600, 
    'h' => 400
]);

Glide by The PHP League

  • Support d'ImageMagick et de gd
  • Intégration de Flysystem
  • Gestion des watermarks
  • Mise en cache des images générées
  • API complète de gestion d'image
    • crop
    • resize
    • orientation
    • format
    • dpr (device pixel ratio)
    • qualité
    • ...

Glide by The PHP League

Sécurité par signature des URLs avec une clé privée

Prévient les attaques de type "mass image-resize"

<?php

use League\Glide\Urls\UrlBuilderFactory;

$signkey = 'v-LK4WCdhcfcc%jt*VC2cj%nVpu+xQKvLUA%H86kRVk_4bgG8&CWM#k';
$urlBuilder = UrlBuilderFactory::create('/img/', $signkey);
$url = $urlBuilder->getUrl('cat.jpg', ['w' => 500]);

echo '<img src="'.$url.'">';
// <img src="/img/cat.jpg?w=500&s=af3dc18fc6bfb2afb521e587c348b904">

Glide-symfony

Ajoute le support de

HttpFoundation\StreamedResponse

Définition de presets

parameters:
    media_filters:
        article_384: { w: 384, h: 255 }
        article_600: { w: 600, h: 399 }
        article_750: { w: 750, h: 498 }
        article_785: { w: 785, h: 522 }
        article_1026: { w: 1026, h: 682 }
        article_1131: { w: 1131, h: 751 }
        article_1166: { w: 1166, h: 775 }
        article_1200: { w: 1200, h: 798 }
        article_1415: { w: 1415, h: 940 }
        article_1534: { w: 1534, h: 1019 }
        article_1585: { w: 1585, h: 1053 }
        article_1920: { w: 1920, h: 1276 }

services:
    App\Service\Glide:
        class: App\Service\Glide
        arguments: ['%glide_config%', '%glide_media_filters%']

Glide-symfony

Glide-symfony

class Glide
{
    protected $server;
    protected $filters;

    public function __construct(array $serverConfig, array $filters)
    {
        $this->server = ServerFactory::create([
            'response' => new SymfonyResponseFactory(),
            'source' => $serverConfig['source_path'],
            'cache' => $serverConfig['cache_path'],
        ]);

        $this->filters = $filters;
    }

    public function getServer(): Server
    {
        return $this->server;
    }

    public function getFilters(): array
    {
        return $this->filters;
    }
}

Utilisation dans un controller

Glide-symfony

/**
 * @Route("/glide/{filterName}/{imageName}", name="glide")
 */
public function index(Glide $glide, string $filterName, string $imageName)
{
    $glideServer = $glide->getServer();
    $filter = $glide->getFilters()[$filterName] ?? [];

    return $glideServer->getImageResponse($imageName, $filter);
}
{% macro picture(image) %}
    <picture class="media-object">
        <source media="(max-width: 767px)"
            sizes="(max-width: 1534px) 100vw, 1534px"
            srcset="
                {{ path('glide', { filterName: 'article_750', imageName: image }) }} 750w,
                {{ path('glide', { filterName: 'article_1131', imageName: image }) }} 1131w,
                {{ path('glide', { filterName: 'article_1415', imageName: image }) }} 1415w,
                {{ path('glide', { filterName: 'article_1534', imageName: image }) }} 1534w">
    
        <source media="(min-width: 768px) and (max-width: 1199px)"
            sizes="(max-width: 2400px) 50vw, 1200px"
            srcset="
                {{ path('glide', { filterName: 'article_384', imageName: image }) }} 384w,
                {{ path('glide', { filterName: 'article_785', imageName: image }) }} 785w,
                {{ path('glide', { filterName: 'article_1026', imageName: image }) }} 1026w,
                {{ path('glide', { filterName: 'article_1200', imageName: image }) }} 1200w">
    
        <img id="test" sizes="(max-width: 3840px) 50vw, 1920px"
             srcset="
                 {{ path('glide', { filterName: 'article_600', imageName: image }) }} 600w,
                 {{ path('glide', { filterName: 'article_1166', imageName: image }) }} 1166w,
                 {{ path('glide', { filterName: 'article_1585', imageName: image }) }} 1585w,
                 {{ path('glide', { filterName: 'article_1920', imageName: image }) }} 1920w"
             src="{{ path('glide', { filterName: 'article_1920', imageName: image }) }}"
             alt="">
    </picture>
{% endmacro %}

{% import _self as macros %}

{{ macros.picture('elephant.jpg') }}

LiipImagineBundle

LiipImagineBundle

Bundle Symfony de manipulation d'images

exposé via une API HTTP.

Basé sur la bibliothèque PHP Imagine

Mêmes fonctionnalités que Glide, mais plus fortement lié à Symfony (routing pré-défini, service déjà déclaré, ...)

Principal inconvénient : pas de gestion native de la sécurité

LiipImagineBundle

Liip édite Rokka.io

SaaS de stockage et d'optimisation d'images

Rokka.io

  • Client PHP
  • Bundle Symfony
  • Intégration à LiipImagineBundle comme driver
# config/packages/imagine.yaml
liip_imagine:
    driver: rokka
    cache: rokka

Permet de se décharger du stockage et du traitement des images

En utilisant un service externe

(auto-hébergé ou en SaaS)

Solution open-source de gestion d'images écrite en Python

  • API complète de gestion d'image
  • Gestion de la sécurité (signature des urls)
  • Mise en cache des images générées
  • Intégration à Symfony via jbouzekri/PhumborBundle
    • Service de générations d'URL
    • Configuration des transformations d'images
    • Fonction Twig
  • Support de différents drivers de stockage (local, S3, ...)

Fonctionnalité intéressante : smart-cropping

Attention, c'est pratique mais pas fiable à 100%

Souvent, la direction artistique voudra avoir la main sur le cropping

docker run -p 8000:8000 \
-e DETECTORS="[ \
    'thumbor.detectors.glasses_detector',  \
    'thumbor.detectors.face_detector', \
    'thumbor.detectors.feature_detector', \
    'thumbor.detectors.profile_detector' \
]" apsl/thumbor
ALLOWED_SOURCES = ["https://jolicode.com"]

Sécurité

SECURITY_KEY = 'MY_SECURE_KEY'

http://localhost:8000/[HASH]/300x200/media/image.jpg

jbouzekri/PhumborBundle

jb_phumbor:
    server:
        url: http://your-thumbor-instance
        secret: yourThumborSecretKey
    transformations:
        article_750:
            fit_in: { width: 750, height: 498 }
{{ thumbor(asset('images/test.jpg'), 'article_750') }}

Quelle solution choisir ?

Images servies par Symfony ou par un service tiers ?

Quantité d'images à traiter ?

 Quantité d'appareils différents à prendre en charge ?

Contraintes de la Direction Artistique ?

Budget mensuel pour un SaaS ?

...

Merci !

Questions ?

Des images au cordeau pour vos applications Symfony

By Mathieu Santostefano

Des images au cordeau pour vos applications Symfony

Presentation for Symfony Live Paris 2019

  • 851