How to dynamically create image styles derivatives - Part 1

Parent Feed: 

Three months ago I wrote an article on how to Create Image Styles and Effects programmatically and today we're following up on that article but introducing on how we can do that dynamically.

So, essentially what we would like to do is that we display an image, where we can adjust the way the image is outputted, given a height, width or aspect ratio etc.

Please bear in mind that all code provided in this article are experimental and does not yet cover things like access control, etc in this part.

Let's take a look at the service Unsplash.com. Its basically a free image bank with high quality images submitted by awesome freelancers and professionals that you can use for free.

Lake Tahio

Image by Eric Ward

The URL for the image above is the following:

https://images.unsplash.com/photo-1499365094259-713ae26508c5?ixlib=rb-0.3.5&ixid=eyJhcHBfaWQiOjEyMDd9&s=26d4766855746c603e3d42aaec633144&auto=format&fit=crop&w=500&q=60

The parts we're actually interested in are: &auto=format&fit=crop&w=500&q=60 we can adjust them as we like and the image is displayed differently, i.e. changing the width of the earlier image to a smaller one:

Lake Tahio smaller

Alright, that's what we would like to do in Drupal 8. This article will be very iteratively, we'll rewrite the same code over and over until we get what we want. We'll notice issues and problems that we will deal with through out the article.

Prepare an environment to work in

We'll use a fresh Drupal 8.6.x installation.

To quickly scaffold some boilerplate code I'm going to use Drupal Console.

First let's create a custom module where we can put our code and logic in:

$ vendor/bin/drupal generate:module

I'll name the module dynamic_image_viewer

dynamic_image_viewer.info.yml

name: 'Dynamic Image Viewer'
type: module
description: 'View an image dynamically'
core: 8.x
package: 'Custom'

Next we need some images to work with, we'll use the core Media module for that. So let's enable that module:

vendor/bin/drupal module:install media

Now we can add some images. Go to Content >> Media >> Add media.

Media content

Implementing a Controller to display the image

The first step is to create a controller that will render the Media image to the browser. Again we'll use Drupal Console for a controller scaffold: vendor/bin/drupal generate:controller

We'll create a route on /image/{media} where Media will accept an media ID that due to Drupals parameter upcasting will give us a media instance in the controller method arguments. Doing this, if a invalid media ID is passed in the URL a 404 page is shown for us. Neat!

So we'll modify the generated controller slightly to this:

src/Controller/ImageController.php

<?php

namespace Drupal\dynamic_image_viewer\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\media\MediaInterface;

/**
 * Class ImageController.
 */
class ImageController extends ControllerBase {

  /**
   * Show an image.
   *
   * @param MediaInterface $media
   *
   * @return array
   */
  public function show(MediaInterface $media) {
    return [
      '#type' => 'markup',
      '#markup' => $media->id(),
    ];
  }

}


And the routing file looks like this: dynamic_image_viewer.routing.yml

dynamic_image_viewer.image_controller_show:
  path: '/image/{media}'
  defaults:
    _controller: '\Drupal\dynamic_image_viewer\Controller\ImageController::show'
    _title: 'show'
  requirements:
    _permission: 'access content'

If we install the module, vendor/bin/drupal module:install dynamic_image_viewer and hit the URL /image/1 we should see a page with the ID being outputted.

Render the original image

Ok. Currently nothing is rendered, so what we'll do is that we render the uploaded original image first.

To serve the file we'll use BinaryFileResponse. So let's update the ImageController::show method.

We'll also import the class in the top of the file:

use Symfony\Component\HttpFoundation\BinaryFileResponse;

  /**
   * Show an image.
   *
   * @param MediaInterface $media
   *
   * @return BinaryFileResponse
   */
  public function show(MediaInterface $media) {
    $file = $media->field_media_image->entity;

    $uri = $file->getFileUri();
    $headers = file_get_content_headers($file);

    $response = new BinaryFileResponse($uri, 200, $headers);

    return $response;
  }

So what we do here is that we grab the File entity from the field_media_image field on the Media image bundle. We get the URI and, using the file_get_content_headers we get the proper headers. Finally we serve the file back with the proper headers to the viewer.

And if we hit the URL again:

original-image-rendered

Before we continue, we should note some things that we'll get back to later:

  • What if the media ID is not a Media image?
  • The user can still access the media even if its unpublished.
  • What about cache?

Let's make a hard-coded image derivative

To modify the image, we'll create a new instance of ImageStyle and add an image effect.

Let's update the ImageController::show method again:

  /**
   * Show an image.
   *
   * @param MediaInterface $media
   *
   * @return BinaryFileResponse
   */
  public function show(MediaInterface $media) {
    $file = $media->field_media_image->entity;

    $image_uri = $file->getFileUri();

    $image_style = ImageStyle::create([
      'name' => uniqid(), // @TODO This will create a new image derivative on each request.
    ]);
    $image_style->addImageEffect([
      'id' => 'image_scale_and_crop',
      'weight' => 0,
      'data' => [
        'width' => 600,
        'height' => 500,
      ],
    ]);

    $derivative_uri = $image_style->buildUri($image_uri);

    $success = file_exists($derivative_uri) || $image_style->createDerivative($image_uri, $derivative_uri);

    $response = new BinaryFileResponse($derivative_uri, 200);

    return $response;
  }

So what we do here is that we create a new ImageStyle entity, but we don't save it. We give it a unique name (but we'll change that soon) and then add we add an image effect that scale and crops the image to a width of 600 and height 500.
And then we build the derivate uri and if the file exists already, we'll serve it and if not we'll create a derivative of it.

There is one big problem here. Since we use a unique id as name of the image style we'll generate a new derivative on each request which means that the same image will be re-generated over and over. To solve it for now, we could just change the

 $image_style = ImageStyle::create([
      'name' => uniqid(), // @TODO This will create a new image derivative on each request.

to a constant value, but I left it for that reason intentionally. The reason is that I want to explicitily tell us that we need to do something about that and here is how:

If we look back at the URI from Unsplash earlier &auto=format&fit=crop&w=500&q=60, these different keys are telling the code to derive the image in a certain way.

We'll use the provided keys and combine them some how in to a fitting name for the image style. For instance, we could just take the values and join them with a underscore.

Like so:

format_crop_500_60 and we'll have a unique string. If the user enters the same URL with the same parameters we'll be able to find the already existing derivative or if its another image, we'll create a derivative for it.

You'll also notice that I removed the $headers = file_get_content_headers($file); it is because those headers are not the correct ones for ur derivatives, we'll add them back soon.

Dynamic width and height values

On our second iteration of the code we'll now add the width and height parameters, and we'll also change the name of the image style to be dynamic.

Again, we'll update ImageController::show

We'll also import a class by adding use Symfony\Component\HttpFoundation\Request; in the top of the file.

  /**
   * Show an image.
   *
   * @param Request $request
   * @param MediaInterface $media
   *
   * @return BinaryFileResponse
   */
  public function show(Request $request, MediaInterface $media) {

    $query = $request->query;

    $width = (int) $query->get('width', 500);
    $height = (int) $query->get('height', 500);

    // We'll create the image style name from the provided values.
    $image_style_id = sprintf('%d_%d', $width, $height);

    $file = $media->field_media_image->entity;

    $image_uri = $file->getFileUri();

    $image_style = ImageStyle::create([
      'name' => $image_style_id,
    ]);
    $image_style->addImageEffect([
      'id' => 'image_scale_and_crop',
      'weight' => 0,
      'data' => [
        'width' => $width,
        'height' => $height,
      ],
    ]);
    
    // ... Rest of code

First we updated the method signature and injected the current request. Next, we'll get the width and height parameters if they exist and if not we fallback to something. We'll build an image style name of these dynamic values. With this we updated the name of the ImageStyle instance we create which makes sure that we can load the same derivative if the user hits the same URL. Finally we updated the width and height in the image effect.

Here is the updated ImageController::show and current file:

src/Controller/ImageController.php

<?php

namespace Drupal\dynamic_image_viewer\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\media\MediaInterface;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Drupal\image\Entity\ImageStyle;
use Symfony\Component\HttpFoundation\Request;
use Drupal\Core\Image\ImageFactory;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Class ImageController.
 */
class ImageController extends ControllerBase {

  /**
   * The image factory.
   *
   * @var \Drupal\Core\Image\ImageFactory
   */
  protected $imageFactory;

  /**
   * Constructs a ImageController object.
   *
   * @param \Drupal\Core\Image\ImageFactory $image_factory
   *   The image factory.
   */
  public function __construct(ImageFactory $image_factory) {
    $this->imageFactory = $image_factory;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('image.factory')
    );
  }
  /**
   * Show an image.
   *
   * @param Request $request
   * @param MediaInterface $media
   *
   * @return BinaryFileResponse
   */
  public function show(Request $request, MediaInterface $media) {

    $query = $request->query;

    $width = (int) $query->get('width', 500);
    $height = (int) $query->get('height', 500);

    $image_style_id = sprintf('%d_%d', $width, $height);

    $file = $media->field_media_image->entity;

    $image_uri = $file->getFileUri();

    $image_style = ImageStyle::create([
      'name' => $image_style_id,
    ]);
    $image_style->addImageEffect([
      'id' => 'image_scale_and_crop',
      'weight' => 0,
      'data' => [
        'width' => $width,
        'height' => $height,
      ],
    ]);

    $derivative_uri = $image_style->buildUri($image_uri);

    $success = file_exists($derivative_uri) || $image_style->createDerivative($image_uri, $derivative_uri);

    $headers = [];

    $image = $this->imageFactory->get($derivative_uri);
    $uri = $image->getSource();
    $headers += [
      'Content-Type' => $image->getMimeType(),
      'Content-Length' => $image->getFileSize(),
    ];

    $response = new BinaryFileResponse($uri, 200, $headers);

    return $response;
  }

}

First we added a new dependency to our controller \Drupal\Core\Image\ImageFactory which allows us to construct an Image instance, where we can get meta data from the image, but also gives us a unified interface to apply things to our image. For instance, we could desaturate the image by doing $image->desaturate(); and then resave the file. Fow now we're only using it to retrieve the meta data. We'll take advantage of that in the next part, when we refactor some of the written code and add more flexibility to what we can dynamically output.

If we hit the url and add both the width and height parameters we'll get something like this:

generated-image

In the up coming article we'll take a better look at what we have, what we miss (access control, what if a user hits the same URL at the same time), adding more effects, and exploring the use of the Image and toolkit APIs more in depth.

We'll most likely remove adding image effects through ImageStyles and only use the image style for creating derivates that we can we can later apply changes with the toolkit API.

If you want to continue on your own, take a look at ImageStyleDownloadController.php file in core which contains a lot of code that we can re-use.

Author: 
Original Post: 

About Drupal Sun

Drupal Sun is an Evolving Web project. It allows you to:

  • Do full-text search on all the articles in Drupal Planet (thanks to Apache Solr)
  • Facet based on tags, author, or feed
  • Flip through articles quickly (with j/k or arrow keys) to find what you're interested in
  • View the entire article text inline, or in the context of the site where it was created

See the blog post at Evolving Web

Evolving Web