Skip to main content

Drupal website

Demo: Image style pipeline

One image through Drupal’s image effects, with the result of every step.

  1. The sample image

    Original

    1200×800
  2. The image after Scale and crop

    Step 1: Scale and crop

    800×600

    800×600
  3. The image after Desaturate

    Step 2: Desaturate

    800×600
  4. The image after Convert

    Step 3: Convert

    PNG

    800×600
  5. The image after Rotate

    Step 4: Rotate

    12°

    906×753
How it’s built gwt_demo/src/Plugin/ProjectDemo/ImagePipeline.php · 303 lines
<?php

declare(strict_types=1);

namespace Drupal\gwt_demo\Plugin\ProjectDemo;

use Drupal\Component\Plugin\Definition\PluginDefinitionInterface;
use Drupal\Component\Uuid\UuidInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\File\FileUrlGeneratorInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\SubformState;
use Drupal\Core\Image\ImageFactory;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\file\FileInterface;
use Drupal\gwt_demo\Attribute\ProjectDemo;
use Drupal\gwt_demo\ProjectDemo\PluginSystemDemoBase;
use Drupal\image\ConfigurableImageEffectInterface;
use Drupal\image\ImageEffectInterface;
use Drupal\image\ImageEffectManager;
use Drupal\image\ImageEffectPluginCollection;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

/**
 * One sample image run through image effects, shown step by step.
 *
 * The effects are kept like an image style keeps them: in an image effect
 * plugin collection under the "effects" key, each with its own settings form.
 * Each step's image is generated once, when the demo is first built, under
 * public://project-demos/<hash>; the hash covers the image and the effects,
 * so a changed demo gets new files and an unchanged one reuses them.
 */
#[ProjectDemo(
  id: 'image_pipeline',
  label: new TranslatableMarkup('Image style pipeline'),
  description: new TranslatableMarkup('A sample image run through image effects, with the result of every step.'),
)]
final class ImagePipeline extends PluginSystemDemoBase {

  /**
   * The effects the demo can use.
   */
  private const ALLOWED = [
    'image_scale',
    'image_crop',
    'image_scale_and_crop',
    'image_resize',
    'image_desaturate',
    'image_rotate',
    'image_convert',
  ];

  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    #[Autowire(service: 'plugin.manager.image.effect')]
    ImageEffectManager $pluginManager,
    protected EntityTypeManagerInterface $entityTypeManager,
    protected FileSystemInterface $fileSystem,
    protected FileUrlGeneratorInterface $fileUrlGenerator,
    protected ImageFactory $imageFactory,
    protected UuidInterface $uuid,
  ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition, $pluginManager);
  }

  /**
   * {@inheritdoc}
   */
  public function defaultConfiguration(): array {
    return ['image' => NULL, 'effects' => []];
  }

  /**
   * {@inheritdoc}
   */
  protected function createPluginCollections(): array {
    return ['effects' => new ImageEffectPluginCollection($this->pluginManager, $this->configuration['effects'])];
  }

  /**
   * {@inheritdoc}
   */
  protected function isListed(array|PluginDefinitionInterface $definition, string $plugin_id): bool {
    return in_array($plugin_id, self::ALLOWED, TRUE);
  }

  /**
   * {@inheritdoc}
   */
  public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
    $form['image'] = [
      '#type' => 'managed_file',
      '#title' => $this->t('Sample image'),
      '#upload_location' => 'public://project-demos/source',
      '#upload_validators' => [
        'FileExtension' => ['extensions' => 'png jpg jpeg webp'],
        'FileSizeLimit' => ['fileLimit' => 5 * 1024 * 1024],
      ],
      '#default_value' => $this->configuration['image'] ? [$this->configuration['image']] : [],
      '#required' => TRUE,
    ];

    // Every allowed effect, each at most once, with its own settings form.
    // Its required settings are checked only when the effect is used.
    $form['effects'] = [
      '#type' => 'fieldset',
      '#title' => $this->t('Effects'),
      '#description' => $this->t('Applied in order of weight, lightest first.'),
      '#tree' => TRUE,
    ];
    foreach ($this->getDefinitions() as $id => $definition) {
      $effect = $this->getEffect($id);
      $form['effects'][$id] = [
        '#type' => 'details',
        '#title' => $definition['label'],
        '#open' => $this->isUsed($id),
        'used' => [
          '#type' => 'checkbox',
          '#title' => $this->t('Use this effect'),
          '#default_value' => $this->isUsed($id),
        ],
        'weight' => [
          '#type' => 'number',
          '#title' => $this->t('Weight'),
          '#default_value' => $effect->getWeight() ?? 0,
          '#size' => 4,
        ],
        'data' => $effect instanceof ConfigurableImageEffectInterface ? $this->makeRequiredOptional($effect->buildConfigurationForm([], $form_state)) : [],
      ];
    }
    return $form;
  }

  /**
   * {@inheritdoc}
   */
  public function validateConfigurationForm(array &$form, FormStateInterface $form_state): void {
    foreach ($this->getUsedEffects($form_state) as $id => $effect) {
      $data = &$form['effects'][$id]['data'];
      foreach ($this->getRequiredElements($data) as $element) {
        if ($element['#value'] === '' || $element['#value'] === NULL) {
          $form_state->setError($element, $this->t('@effect: @field is required.', [
            '@effect' => $form['effects'][$id]['#title'],
            '@field' => $element['#title'],
          ]));
        }
      }
      if ($effect instanceof ConfigurableImageEffectInterface) {
        $effect->validateConfigurationForm($data, SubformState::createForSubform($data, $form, $form_state));
      }
    }
  }

  /**
   * {@inheritdoc}
   */
  public function submitConfigurationForm(array &$form, FormStateInterface $form_state): void {
    $effects = [];
    foreach ($this->getUsedEffects($form_state) as $id => $effect) {
      if ($effect instanceof ConfigurableImageEffectInterface) {
        $effect->submitConfigurationForm($form['effects'][$id]['data'], SubformState::createForSubform($form['effects'][$id]['data'], $form, $form_state));
      }
      // Keyed by plugin ID: the demo uses each effect at most once.
      $effects[$id] = [
        'uuid' => $effect->getUuid() ?: $this->uuid->generate(),
        'weight' => (int) $form_state->getValue(['effects', $id, 'weight']),
      ] + $effect->getConfiguration();
    }
    $fids = $form_state->getValue('image') ?: [];

    // The base class copies form values to settings of the same name; these
    // two need the values worked out above instead.
    parent::submitConfigurationForm($form, $form_state);
    $this->configuration['effects'] = $effects;
    $this->configuration['image'] = $fids ? (int) reset($fids) : NULL;
    if ($file = $this->getImage()) {
      $file->setPermanent();
      $file->save();
    }
  }

  /**
   * {@inheritdoc}
   */
  public function build(): array {
    $file = $this->getImage();
    $effects = $this->getPluginCollections()['effects'];
    if (!$file || !$effects instanceof ImageEffectPluginCollection) {
      return [];
    }
    $effects->sort();

    $steps = [$this->buildStep($file->getFileUri(), $this->t('Original'), [])];
    $hash = hash('sha256', serialize([$file->getFileUri(), $file->getChangedTime(), $effects->getConfiguration()]));
    $directory = 'public://project-demos/' . substr($hash, 0, 12);
    $extension = pathinfo($file->getFileUri(), PATHINFO_EXTENSION);
    $uris = [];
    foreach ($effects as $effect) {
      $extension = $effect->getDerivativeExtension($extension);
      $uris[] = $directory . '/step-' . (count($uris) + 1) . '.' . $extension;
    }

    // Generate the steps once: each effect is applied to the result of the
    // ones before it, and that result saved.
    if (array_filter($uris, fn(string $uri) => !file_exists($uri))) {
      $this->fileSystem->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
      $image = $this->imageFactory->get($file->getFileUri());
      foreach (array_values(iterator_to_array($effects)) as $n => $effect) {
        $effect->applyEffect($image);
        $image->save($uris[$n]);
      }
    }
    foreach (array_values(iterator_to_array($effects)) as $n => $effect) {
      $steps[] = $this->buildStep($uris[$n], $effect->label(), $effect->getSummary());
    }

    return [
      '#theme' => 'gwt_demo_image_pipeline',
      '#steps' => $steps,
      '#cache' => ['tags' => $file->getCacheTags()],
    ];
  }

  /**
   * Builds one step for the template.
   */
  private function buildStep(string $uri, \Stringable|string $label, array $summary): array {
    $image = $this->imageFactory->get($uri);
    return [
      'url' => $this->fileUrlGenerator->generateString($uri),
      'width' => $image->getWidth(),
      'height' => $image->getHeight(),
      'label' => $label,
      // Only the summary text; the image module's markup holds the label too.
      'summary' => array_diff_key($summary, ['#effect' => TRUE]),
    ];
  }

  /**
   * Returns the sample image, or NULL if there is none.
   */
  private function getImage(): ?FileInterface {
    return $this->configuration['image'] ? $this->entityTypeManager->getStorage('file')->load($this->configuration['image']) : NULL;
  }

  /**
   * Checks whether the demo uses an effect.
   */
  private function isUsed(string $id): bool {
    return isset($this->configuration['effects'][$id]);
  }

  /**
   * Returns an effect: the demo's own, or a new one with default settings.
   */
  private function getEffect(string $id): ImageEffectInterface {
    return $this->getPlugin('effects', $id) ?? $this->pluginManager->createInstance($id, ['data' => []]);
  }

  /**
   * Returns the effects checked in the form, keyed by plugin ID.
   */
  private function getUsedEffects(FormStateInterface $form_state): array {
    $used = array_filter($form_state->getValue('effects') ?? [], fn(array $values) => !empty($values['used']));
    return array_combine(array_keys($used), array_map($this->getEffect(...), array_keys($used)));
  }

  /**
   * Makes a settings form's required elements optional, marking them.
   */
  private function makeRequiredOptional(array $form): array {
    foreach ($form as $key => &$element) {
      if (is_array($element) && !str_starts_with((string) $key, '#')) {
        if (!empty($element['#required'])) {
          $element['#required'] = FALSE;
          $element['#gwt_demo_required'] = TRUE;
        }
        $element = $this->makeRequiredOptional($element);
      }
    }
    return $form;
  }

  /**
   * Returns the elements makeRequiredOptional() marked.
   */
  private function getRequiredElements(array $form): array {
    $elements = [];
    foreach ($form as $key => $element) {
      if (is_array($element) && !str_starts_with((string) $key, '#')) {
        if (!empty($element['#gwt_demo_required'])) {
          $elements[] = $element;
        }
        array_push($elements, ...$this->getRequiredElements($element));
      }
    }
    return $elements;
  }

}