Drupal 9: Generating Header Images For Pages Of Content Using PHP

Drupal 9: Generating Header Images For Pages Of Content Using PHP

4th September 2022 - 25 minutes read time

Embedding image within pages of content helps both within the design of the page and when shared on social media. If you set up meta tags to point at a particular image then that image will appear when the page is shared on social media. This makes your page stand out more.

A while ago I added a header image to the articles in the form of a field that references a media item, which is quite typical when adding images to pages. To add an header image to an article I just had to upload an image and Drupal would handle the size, placement and meta data for that image.

With the field in place, however, I spent a while adding a default header image to new articles so I haven't been making good use of it. My GIMP skills aren't that amazing and so the prospect writing an article and spending time fiddling with text elements on an image every week wasn't that appealing.

I decided that rather than spend time hand crafting an image for the header, I would automate the process as much as possible. To this end I set about writing the components that would allow me to automatically inject a generated header image using Drupal services. I thought that the setup of this functionality would make a good article itself.

In this article I will briefly touch upon creating images using PHP and how to inject those images into a media reference field on a node using a form in a local task.

Creating An Image Using PHP

This is a big topic so I'm not going to cover everything here, but I thought an introduction into how I created the header image might be of interest to readers. I'll be using the PHP GD library functions here as they are pretty easy to use and are normally installed on most PHP platforms.

The first step here is to create an image handle using the imagecreatetruecolor() function. This takes the width and height of the image and will return a handle that will then be passed to functions that build up the image.

$im = imagecreatetruecolor($width, $height);

Any colours that we use in the image need to be defined first, or at least before they are used. This is done using the imagecolorallocate() function, which takes the red, green, and blue values of the colour. Here I am creating a background image in blue and a font colour in pure white.

$backgroundColour = imagecolorallocate($im, 42, 106, 172);
$fontColour = imagecolorallocate($im, 255, 255, 255);

Each element you add to the image will be added in order you add them to the image, which essentially means any background layers need to be added to the image first. I wanted a single colour background so I used imagefilledrectangle() with the same dimensions of the image to fill the image with a single colour.

// Fill background with a solid colour.
imagefilledrectangle($im, 0, 0, $width, $height, $backgroundColour);

Adding text is a little bit more complex than just calling a function, especially if you want decent control over the formatting of that text. The default kerning (the spaces between letters) in PHP is pretty wonky, and is not something you can change. In order to get maximum control over the text we need to add it in a slightly different way by printing each character in turn.

First though, we need to define a font that we will use for the text, this is just a font file that we can pass to the text functions.

$font = 'Courier.dfont';

The logo of this site (which is "#!") is printed 600 pixels from the left and 10% from the bottom of the image. As I mentioned before, in order to print out the text with controlled kerning we need to print each character out individually and control the spacing between the characters. This is done using the imagefttext() function, which accepts the coordinates to print the text as well as the colour and font to use.

$logoText = '#!';
for ($i = 0; $i < strlen($logoText); $i++) {
  imagefttext($im, 50, 0, 600 + ($i * 40), round($height * 0.90), $fontColour, $font, $logoText[$i]);
}

This generates an image that looks like this.

A header image test, showing a blue background and the hash bang code logo in the lower right corner.

The final image also had a larger image in the background that was rotated slightly. This used a similar method but with the text rotated slightly and using a bigger font.

Perhaps the most complex part of the image is printing out the title of the article. This needs to fit into the middle of the image so that when it gets inevitably cropped on social media no information is lost. To do this we chunk the title into lines and print each line out using the imagefttext() function. The complex part is around making sure the font and the number of characters on each line are the right size to fit the title into the middle of the image. We also calculate the coordinates of each line as we progress through the chunks of the title.

Here is the code that does this. I'm adding a very long title to prove that the resulting image doesn't have any problems displaying such a lot of text.

$title = 'The title of the post can be as long as it needs to be, without causing problems with the layout of the image.';

// Work out the scaling factor.
$fontSize = 25 + (-7 * (strlen($title) - 10) / (120));
// Split the text into chunks based on this factor.
$lines = explode('|', wordwrap($title, intval(400 / $fontSize), '|'));
foreach ($lines as $i => $line) {
  // Calculate the y offset of the text, with a small buffer.
  $y = count($lines) + ($fontSize * 1.5) * $i + ($fontSize * 1.5);
  // Write the text to the image.
  imagefttext($im, $fontSize, 0, 315, intval($y), $fontColour, $font, $line);
}

When we run the image generation now the following image is created.

Drupal header image test with a blue background, the site logo and very long article title.

There are some other parts involved here, including the tags of the post and the offset background logo that gives the image a bit more interest. As I mentioned before, I will leave adding that detail to this article for now since it's a little irrelevant.

The last thing we need to do in this step is extract the file information as data, rather than save it to the file system. The default functionality of the imagepng() function is to print the image to screen, which isn't that helpful to us. We can add in the filename as a second parameter to save the file, but we need Drupal to handle the file save process and so that's not applicable in this case either.

To get this working I used output buffering to capture the printing of the file and store it in a variable.

ob_start();
imagepng($im);
$fileData = ob_get_clean();

The $fileData variable here now contains the data needed for the image as a string. We can hand this over to Drupal to save the file information to the file system and register the file in the Drupal database for us.

Let's look at plugging this functionality into Drupal.

Creating A Drupal Service

The image creation code is added to Drupal as a service class. This helps to encapsulate it away from any other code in Drupal, but also allows us to inject dependencies in order to interact with other Drupal services.

The service definition for this class is added to a file called hashbangcode_module.services.yml and looks like this.

services:
  hashbangcode_module.header_image_creation:
    class: Drupal\hashbangcode_module\HeaderImageCreation
    arguments: ['@file.repository', '@module_handler']

We are injecting the file.repository service to save the file data and the module_handler service to get the path of the module so that we can reference the font file within the module.

The HeaderImageCreation class is added to a file at the location src/HeaderImageCreation.php within the module and looks a bit like the following code. Note that I have removed the image generation code as I went through that above and it just bloats the code example considerably.

<?php

namespace Drupal\hashbangcode_module;

use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\file\FileRepositoryInterface;
use Drupal\Core\File\FileSystemInterface;

/**
 * Helps to create a header image based on some information from a node.
 */
class HeaderImageCreation implements HeaderImageCreationInterface {

  /**
   * The file repository service.
   *
   * @var \Drupal\file\FileRepositoryInterface
   */
  protected $fileRepository;

  /**
   * The module handler service.
   *
   * @var \Drupal\Core\Extension\ModuleHandlerInterface
   */
  protected $moduleHandler;

  /**
   * HeaderImageCreation constructor.
   *
   * @param \Drupal\file\FileRepositoryInterface $file_repository
   *   The file repository service.
   * @param Drupal\Core\Extension\ModuleHandlerInterface $module_handler
   *   The module handler service.
   */
  public function __construct(FileRepositoryInterface $file_repository, ModuleHandlerInterface $module_handler) {
    $this->fileRepository = $file_repository;
    $this->moduleHandler = $module_handler;
  }

  /**
   * {@inheritDoc}
   */
  public function generateHeaderImage(string $title, array $tags) {
    $width = 1000;
    $height = 250;

    $im = imagecreatetruecolor($width, $height);

    $path = $this->moduleHandler->getModule('hashbangcode_module')->getPath();
    $font = $path . '/assets/font/Courier.dfont';

    // ----------------------
    // Code to generate the image components added here....
    // ----------------------

    // Convert the title to a filename and set the directory.
    $fileName = preg_replace('/[^a-z0-9 _-]/','', strtolower($title));
    $fileName = str_replace(' ','_', $fileName);
    $fileName = self::HEADER_IMAGE_DIRECTORY . '/' . $fileName . '.png';

    // We need to extract the file as data, rather than save it to the file
    // system. To this end we use output buffering to capture the printing
    // of the file and store it in a variable.
    ob_start();
    imagepng($im);
    $fileData = ob_get_clean();

    /** @var \Drupal\file\FileInterface $file */
    $file = $this->fileRepository->writeData($fileData, $fileName, FileSystemInterface::EXISTS_REPLACE);

    // Free memory by destroying the generated image.
    imagedestroy($im);

    return $file;
  }

}

The service class is pretty straight forward really. When we want to generate an image for an article we just call the generateHeaderImage() method and pass in the title and tags of that article. The file object created by Drupal is then returned from the method and we can use that upstream.

The file name is determined by the title of the article, which is changed in order to remove any special characters. The HEADER_IMAGE_DIRECTORY constant it set in the interface for this service and just points to a Drupal directory called 'public://header-image' so the file is saved into the Drupal public file system.

Creating A Drupal Form

I did a fair bit of research around how I wanted to generate the image for the articles. Ultimately, the generation of the image needed to happen some time before the article was published; but not as the article is created. I often go through a number of different titles and taxonomy terms when creating articles and so a small manual task of generating the image was preferable to battling against an automated process.

I therefore set about creating a form that I could submit to generate the article image using just the article object itself. I could then submit this form to generate the header image when the article was ready to be published.

The form definition itself it pretty simple, it just needs to print out the header image (if it has been set) and then show a submit button to submit the form.

  public function buildForm(array $form, FormStateInterface $form_state, NodeInterface $node = NULL) {
    $this->node = $node;

    if ($this->node->get('field_article_header')->isEmpty()) {
      $form['current_header_image'] = [
        '#markup' => '<p>' . $this->t('Header image not set.') . '</p>',
      ];
    }
    else {
      $image = $this->node->get('field_article_header')->view('default');
      $form['current_header_image'] = $image;
      $form['current_header_image']['#weight'] = -1;
    }

    $form['submit'] = [
      '#type' => 'submit',
      '#value' => $this->t('Generate Header Image'),
    ];

    return $form;
  }

The form needs a few dependencies injected into it, most important of these being the hashbangcode_module.header_image_creation service that was created in the previous step. The file_system service is again injected into this form in order to ensure that the directory is setup for the image to created into. Finally, the entity_type.manager service is used because we want to either update or create the media entity connected the article in question and need a way of loading or creating that entity.

In addition to the media entity the entity manager service is also used in this class to load the image style, which is important if we have regenerated the image and want to flush the image style cache for that image.

  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('hashbangcode_module.header_image_creation'),
      $container->get('file_system'),
      $container->get('entity_type.manager')
    );
  }

  /**
   * Constructs a HeaderImageCreationForm object.
   *
   * @param \Drupal\hashbangcode_module\HeaderImageCreationInterface $header_creation
   *   A header image creation instance.
   * @param \Drupal\Core\File\FileSystemInterface $file_system
   *   The file system service.
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
   *   The entityTypeManager.
   */
  public function __construct(HeaderImageCreationInterface $header_creation, FileSystemInterface $file_system, EntityTypeManagerInterface $entityTypeManager) {
    $this->headerCreation = $header_creation;
    $this->fileSystem = $file_system;
    $this->entityTypeManager = $entityTypeManager;
  }

The submit handler for the form is a little more complex, and also has a bunch of built in checks in order to prevent errors caused, for example, when the file system can't be written to. What we are doing in this method is extracting the data from the article (i.e. the title and the tags), creating the file object using the hashbangcode_module.header_image_creation service, making sure the media item exists or is updated with that file and then updating the node.

  public function submitForm(array &$form, FormStateInterface $form_state) {
    $directory = HeaderImageCreationInterface::HEADER_IMAGE_DIRECTORY;
    if (!$this->fileSystem->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY)) {
      // Directory creation didn't work.
      $this->messenger()->addError('Unable to create the directory for header image creation.');
      return;
    }

    // Extract the data we need from the node.
    $title = $this->node->getTitle();
    $tags = $this->node->get('field_tags')->referencedEntities();
    foreach ($tags as $id => $tag) {
      $tags[$id] = $tag->label();
    }

    // Generate the image.
    $file = $this->headerCreation->generateHeaderImage($title, $tags);

    if (!$this->node->get('field_article_header')->isEmpty()) {
      // Node already has this information in it.
      $imageMedia = $this->node->get('field_article_header')->referencedEntities()[0];
      $originalFile = $imageMedia->get('field_media_image')->referencedEntities()[0];

      if ($originalFile->id() !== $file->id()) {
        // File is different, so we save it to the media item and delete
        // the original file.
        $imageMedia->get('field_media_image')->setValue($file);
        $imageMedia->save();

        $originalFile->delete();
      }

      // Clear the image style cache for this image.
      $style = $this->entityTypeManager->getStorage('image_style')->load('header_image');
      $url = $style->buildUri($file->getFileUri());
      $this->fileSystem->unlink($url);
    }
    else {
      // Add the image we created to a media item.
      $imageMedia = Media::create([
        'bundle' => 'image',
        'uid' => $this->node->uid->target_id,
        'langcode' => $this->node->get('langcode')->value,
        'field_media_image' => [
          'target_id' => $file->id(),
          'alt' => $title,
          'title' => $title,
        ],
      ]);

      // Save the newly created media item.
      $imageMedia->setName($title)
        ->setPublished()
        ->save();
    }

    // Set the media item to the header field.
    $this->node->get('field_article_header')->setValue([$imageMedia]);

    // Save the node.
    $this->node->save();

    $this->messenger()->addStatus('Header image created successfully');
  }

When we submit the form the header image is generated and saved to the node. If any information about the article has changed (e.g. we change the title) then a new image is created, but we make sure to remove the old image from the file system. It is important to clean up this sort of stuff on a site as it can quickly lead to messy file systems with orphan files sitting around.

There is still one thing left to do here. We can't actually get to the form to submit it just yet, so let's create a local task so that the form is available and has context about the page we want to generate the header image for.

Adding The Form As A Local Task

A local task in Drupal a small and highly dynamic menu that is usually connected to an entity, but can be added to any menu. A good example of a local task menu is the node "View", "Edit", "Delete" links when editing a page of content. Modules can inject their own links into this menu to provide extra functionality, which is what we will do with the header image creation form.

The first step in adding a local task is to add a route, which is added to the file hashbangcode_module.routing.yml. This is mostly a normal route to a form, but because we want to inject the node into the form we also need to add that to the route parameters.

hashbangcode_module.header_image_creation:
  path: 'node/{node}/header-image'
  defaults:
    _form: '\Drupal\hashbangcode_module\Form\HeaderImageCreationForm'
    _title: 'Header Image Creation'
  requirements:
    _header_image_creation_access_check: 'TRUE'
    node: \d+
  options:
    _node_operation_route: TRUE
    parameters:
      node:
        type: entity:node

There are also some additional options here. The _node_operation_route parameter is used by Drupal to see if this is an administration route or not. In other words, if this option is set, then Drupal knows that this is an admin route and that will influence things like displaying the administration theme when viewing this page.

As I only wanted the header image creation form to show on pages that had the header image field I needed a way to control that display. I found that the simplest way to do this was to add an access check that will just deny access to the menu if the page in question does not support header image. This access check can be seen in the above route definition as _header_image_creation_access_check, which maps to a service class that we define in the hashbangcode_module.services.yml file. Here is the addition to the file.

services:

  hashbangcode_module.header_image_creation_access_check:
    class: Drupal\hashbangcode_module\Access\HeaderImageCreationAccessCheck
    tags:
      - { name: access_check, applies_to: _header_image_creation_access_check }

Drupal makes the connection between the _header_image_creation_access_check requirement and the tag against this service and will use the defined class as an access checking class.

The access check class is fairly simple really; if the node isn't a particular type then we deny access to the route, which essentially hides the local task from any page that doesn't have a header image. If the access check passes this step then we perform an additional permission check, this time using a custom permission.

<?php

namespace Drupal\hashbangcode_module\Access;

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\node\NodeInterface;
use Symfony\Component\Routing\Route;

/**
 * Provides an access check for the header image creation page.
 *
 * Since the header image is only available on certain node types this
 * class is intended to check the note type and return forbidden if the node
 * type is not applicable.
 *
 * @ingroup node_access
 */
class HeaderImageCreationAccessCheck implements AccessInterface {

  /**
   * Checks routing access for the node revision.
   *
   * @param \Symfony\Component\Routing\Route $route
   *   The route to check against.
   * @param \Drupal\Core\Session\AccountInterface $account
   *   The currently logged in account.
   * @param \Drupal\node\NodeInterface $node
   *   (optional) A node object. Used for checking access to a node's header
   *   page.
   *
   * @return \Drupal\Core\Access\AccessResultInterface
   *   The access result.
   */
  public function access(Route $route, AccountInterface $account, NodeInterface $node = NULL) {
    if ($node->getType() !== 'article') {
      // The note type doesn't match, so return forbidden.
      return AccessResult::forbidden('This node type does not have a header image.');
    }
    $userAllowed = $account->hasPermission('access generate header image page');
    return AccessResult::allowedIf($node && $userAllowed)
      ->cachePerPermissions()
      ->addCacheableDependency($node);
  }

}

We could make this more generic check than this by looking for the existence of the header field, but this is fine for my purposes since I know that the article is the only content type that has this field on this site.

The final step is adding this to the node page is to create a hashbangcode_module.links.task.yml file, which file tells Drupal what local tasks we want to add to the site. All we need to do in this file is link the route to the form to the base route of the node page, which is called entitly.node.canonical.

hashbangcode_module.header_image_creation:
  route_name: hashbangcode_module.header_image_creation
  base_route: entity.node.canonical
  title: 'Header Image'
  weight: 50

With all of this in place I now have an easy to use mechanism to generate header images at the click of a button.

Conclusion

Creating header images is now just submitting a form saves me quite some time in crafting these images by hand.

One word of warning not to allow your users to add media items to the page themselves; or at least restrict this to certain roles. This is because the auto generation code doesn't care if the media item you have added is used in several different places on the site, it will just replace it with whatever the current page content is.

This code is pretty custom to my requirements, but I thought it would be of interest if anyone needs to solve a similar problem. I won't be creating a contributed module for this for the main reason that the image creation code is quite unique to the module and my site. That being said, if anyone has any ideas around that and would like this code to be available then leave a comment below.

More in this series

Comments

Permalink

Hi
Philip, thanks for the article.
Recently, I also made a similar module(https://git.drupalcode.org/project/jpgraph_bar), but for creating and publishing a keyword frequency graph in an article (node).
But for plotting, I used another library(jpgraph).
I still had unresolved issues with registering images in the file system, as I was still researching which of the solutions is more correct in the Drupal infrastructure.
In your article, I saw a solution that can be applied to my module. I also discovered that the latest versions of Drupal introduced the FileRepositoryInterface, which was not yet in version 9.2
As a result, I got an additional source for code analysis, where I can explore the file storage approach.


Thanks again, and good coding, good luck with your work!

darkdim (Mon, 09/05/2022 - 13:02)

Permalink

Hi darkdim, thanks for reading! Really happy to have inspired you to improve your module :)

Add new comment

The content of this field is kept private and will not be shown publicly.