-
1200×800Original
-
800×600Step 1: Scale and crop
800×600
-
800×600Step 2: Desaturate
-
800×600Step 3: Convert
PNG
-
906×753Step 4: Rotate
12°
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;
}
}