diff --git a/core/modules/image/config/schema/image.action.schema.yml b/core/modules/image/config/schema/image.action.schema.yml new file mode 100644 index 0000000000000000000000000000000000000000..605fb1981e07e6747611fb55bb117000adafc9ed --- /dev/null +++ b/core/modules/image/config/schema/image.action.schema.yml @@ -0,0 +1,21 @@ +action.configuration.file_image_styles_generate_action: + type: mapping + label: 'Configuration for "File Image Styles Generate" action' + mapping: + image_styles: + type: sequence + label: 'The image styles to generate image derivatives for.' + sequence: + type: string + label: 'Image style' + regenerate: + type: boolean + label: 'Regenerate image derivatives' + +action.configuration.file_original_image_style_action: + type: mapping + label: 'Configuration for "File Original Image Style" action' + mapping: + image_style: + type: string + label: 'The image style to be applied to an image file entity.' diff --git a/core/modules/image/src/Plugin/Action/FileImageStyleActionBase.php b/core/modules/image/src/Plugin/Action/FileImageStyleActionBase.php new file mode 100644 index 0000000000000000000000000000000000000000..d1e583109c9f194cb3e04de3c1c79ac5a3e8985d --- /dev/null +++ b/core/modules/image/src/Plugin/Action/FileImageStyleActionBase.php @@ -0,0 +1,65 @@ +<?php + +namespace Drupal\image\Plugin\Action; + +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Access\AccessResultInterface; +use Drupal\Core\Action\ConfigurableActionBase; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\file\FileInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Drupal\Core\File\FileSystemInterface; +use Drupal\image\ImageStyleStorageInterface; +use Psr\Log\LoggerInterface; + +/** + * Base class for file image styles actions. + */ +abstract class FileImageStyleActionBase extends ConfigurableActionBase implements ContainerFactoryPluginInterface { + + public function __construct( + array $configuration, + $plugin_id, + $plugin_definition, + protected FileSystemInterface $fileSystem, + protected ImageStyleStorageInterface $imageStyleStorage, + protected LoggerInterface $logger, + ) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('file_system'), + $container->get('entity_type.manager')->getStorage('image_style'), + $container->get('logger.factory')->get('image'), + ); + } + + /** + * {@inheritdoc} + */ + public function access($object, ?AccountInterface $account = NULL, $return_as_object = FALSE): bool|AccessResultInterface { + if (!($object instanceof FileInterface)) { + return $return_as_object ? AccessResult::forbidden() : FALSE; + } + + // Only process image files. + $mime_type = $object->getMimeType(); + if (strpos($mime_type, 'image/') !== 0) { + return $return_as_object ? AccessResult::forbidden() : FALSE; + } + + $access = $object->access('update', $account, TRUE) + ->andIf($object->access('delete', $account, TRUE)); + return $return_as_object ? $access : $access->isAllowed(); + } + +} diff --git a/core/modules/image/src/Plugin/Action/FileImageStylesGenerateAction.php b/core/modules/image/src/Plugin/Action/FileImageStylesGenerateAction.php new file mode 100644 index 0000000000000000000000000000000000000000..5b1fd6fbbb78569150b6068811276cecb1108d9e --- /dev/null +++ b/core/modules/image/src/Plugin/Action/FileImageStylesGenerateAction.php @@ -0,0 +1,115 @@ +<?php + +namespace Drupal\image\Plugin\Action; + +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Access\AccessResultInterface; +use Drupal\Core\Action\Attribute\Action; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\Core\StringTranslation\ByteSizeMarkup; +use Drupal\Core\StringTranslation\TranslatableMarkup; + +/** + * Action to generate an image file derivatives for the given image styles. + */ +#[Action( + id: 'file_image_styles_generate_action', + label: new TranslatableMarkup('Generate image derivatives for the provided image styles'), + type: 'file' +)] +class FileImageStylesGenerateAction extends FileImageStyleActionBase { + + /** + * {@inheritdoc} + */ + public function defaultConfiguration(): array { + return [ + 'image_styles' => [], + 'regenerate' => FALSE, + ]; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state): array { + $styles = $this->imageStyleStorage->loadMultiple(); + $options = []; + foreach ($styles as $style) { + $options[$style->id()] = $style->label(); + } + + $form['image_styles'] = [ + '#type' => 'checkboxes', + '#title' => $this->t('Image styles'), + '#description' => $this->t('Select the image styles to generate derivatives for.'), + '#options' => $options, + '#default_value' => $this->configuration['image_styles'], + '#required' => TRUE, + ]; + + $form['regenerate'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Regenerate'), + '#description' => $this->t('Force regenerate the derivatives, even they already exists. Usually this is needed when the styles effects were changed.'), + '#default_value' => $this->configuration['regenerate'], + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state): void { + $this->configuration['image_styles'] = $form_state->getValue('image_styles'); + $this->configuration['regenerate'] = $form_state->getValue('regenerate'); + } + + /** + * {@inheritdoc} + */ + public function execute($file = NULL): void { + $style_ids = array_filter($this->configuration['image_styles']); + /** @var \Drupal\image\Entity\ImageStyle[] $styles */ + $styles = $this->imageStyleStorage->loadMultiple($style_ids); + + $original_uri = $file->getFileUri(); + $original_size = filesize($original_uri); + foreach ($styles as $style) { + // Set up derivative file information. + $derivative_uri = $style->buildUri($original_uri); + // Create derivative if necessary. + if (!file_exists($derivative_uri) || $this->configuration['regenerate']) { + $style->createDerivative($original_uri, $derivative_uri); + $new_size = filesize($original_uri); + $this->logger->info('New image derivative %file_uri with style %style was generated. Original size: %old_size, new size: %new_size.', [ + '%file' => $derivative_uri, + '%style' => $style->id(), + '%old_size' => ByteSizeMarkup::create($original_size), + '%new_size' => ByteSizeMarkup::create($new_size), + ]); + + } + } + } + + /** + * {@inheritdoc} + */ + public function access($object, ?AccountInterface $account = NULL, $return_as_object = FALSE): bool|AccessResultInterface { + // Make sure the image styles are set and they are real. + $style_ids = array_filter($this->configuration['image_styles']); + if (empty($style_ids)) { + return $return_as_object ? AccessResult::forbidden() : FALSE; + } + $styles = $this->imageStyleStorage->loadMultiple($style_ids); + if (empty($styles)) { + return $return_as_object ? AccessResult::forbidden() : FALSE; + } + + return parent::access($object, $account, $return_as_object); + } + +} diff --git a/core/modules/image/src/Plugin/Action/FileOriginalImageStyleAction.php b/core/modules/image/src/Plugin/Action/FileOriginalImageStyleAction.php new file mode 100644 index 0000000000000000000000000000000000000000..118ca796e309d1502058234a2e04b4f3530f29ed --- /dev/null +++ b/core/modules/image/src/Plugin/Action/FileOriginalImageStyleAction.php @@ -0,0 +1,143 @@ +<?php + +namespace Drupal\image\Plugin\Action; + +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Access\AccessResultInterface; +use Drupal\Core\Action\Attribute\Action; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\Core\StringTranslation\ByteSizeMarkup; +use Drupal\Core\StringTranslation\TranslatableMarkup; + +/** + * Action to replace an image file with one processed by an image style. + */ +#[Action( + id: 'file_original_image_style_action', + label: new TranslatableMarkup('Replace image file with the provided image style'), + type: 'file' +)] +class FileOriginalImageStyleAction extends FileImageStyleActionBase implements ContainerFactoryPluginInterface { + + /** + * {@inheritdoc} + */ + public function defaultConfiguration(): array { + return [ + 'image_style' => '', + ]; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state): array { + $styles = $this->imageStyleStorage->loadMultiple(); + $options = []; + foreach ($styles as $style) { + $options[$style->id()] = $style->label(); + } + + $form['image_style'] = [ + '#type' => 'select', + '#title' => $this->t('Original image style'), + '#description' => $this->t('Select the image style to apply to the original image.'), + '#options' => $options, + '#default_value' => $this->configuration['image_style'], + '#required' => TRUE, + ]; + + $form['warning'] = [ + '#type' => 'markup', + '#markup' => '<div class="messages messages--warning">' . $this->t('Warning: This action will permanently replace the original image files with the styled versions. This cannot be undone.') . '</div>', + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state): void { + $this->configuration['image_style'] = $form_state->getValue('image_style'); + } + + /** + * {@inheritdoc} + */ + public function execute($file = NULL): void { + // Get the image style. + $style_id = $this->configuration['image_style']; + /** @var \Drupal\image\Entity\ImageStyle $style */ + $style = $this->imageStyleStorage->load($style_id); + if (!$style) { + $this->logger->error('Image style %style not found.', ['%style' => $style_id]); + return; + } + + // Check if style extension is different from the original file extension, + // and if so, change the file name and uri. + $original_uri = $file->getFileUri(); + $file_name = $file->getFilename(); + $original_extension = pathinfo($file_name, PATHINFO_EXTENSION); + $derivative_extension = $style->getDerivativeExtension($original_extension); + $derivative_uri = $original_uri; + $extension_changed = FALSE; + if ($derivative_extension !== $original_extension) { + $file_name = str_replace('.' . $original_extension, '.' . $derivative_extension, $file_name); + $derivative_uri = str_replace('.' . $original_extension, '.' . $derivative_extension, $original_uri); + $extension_changed = TRUE; + } + + try { + // Generate the styled image. + $style->createDerivative($original_uri, $derivative_uri); + + // Get the file stats before replacement. + $original_size = filesize($original_uri); + + // Update the file metadata. + $new_size = filesize($derivative_uri); + if ($extension_changed) { + $file->setFileUri($derivative_uri); + $file->setFilename($file_name); + $this->fileSystem->delete($original_uri); + } + $file->setSize($new_size); + $file->save(); + + $this->logger->info('Replaced image %file with style %style. Original size: %old_size, new size: %new_size.', [ + '%file' => $file->getFilename(), + '%style' => $style_id, + '%old_size' => ByteSizeMarkup::create($original_size), + '%new_size' => ByteSizeMarkup::create($new_size), + ]); + } + catch (\Exception $e) { + $this->logger->error('Failed to replace image %file with style %style: @error', [ + '%file' => $file->getFilename(), + '%style' => $style_id, + '@error' => $e->getMessage(), + ]); + } + } + + /** + * {@inheritdoc} + */ + public function access($object, ?AccountInterface $account = NULL, $return_as_object = FALSE): bool|AccessResultInterface { + // Make sure the action image style is set and real. + if (empty($this->configuration['image_style'])) { + return $return_as_object ? AccessResult::forbidden() : FALSE; + } + $style = $this->imageStyleStorage->load($this->configuration['image_style']); + if (!$style) { + return $return_as_object ? AccessResult::forbidden() : FALSE; + } + + return parent::access($object, $account, $return_as_object); + } + +} diff --git a/core/modules/image/tests/src/Kernel/Plugin/Action/FileImageStyleActionTest.php b/core/modules/image/tests/src/Kernel/Plugin/Action/FileImageStyleActionTest.php new file mode 100644 index 0000000000000000000000000000000000000000..f1e8cfb4c5b829aaee2580daa2b67df95a0c935c --- /dev/null +++ b/core/modules/image/tests/src/Kernel/Plugin/Action/FileImageStyleActionTest.php @@ -0,0 +1,192 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\image\Kernel\Plugin\Action; + +use Drupal\Core\Image\ImageFactory; +use Drupal\file\Entity\File; +use Drupal\file\FileInterface; +use Drupal\image\Entity\ImageStyle; +use Drupal\KernelTests\KernelTestBase; +use Drupal\system\Entity\Action; +use Drupal\Tests\TestFileCreationTrait; + +/** + * Tests File Image Styles actions. + * + * @covers \Drupal\image\Plugin\Action\FileImageStylesGenerateAction + * @covers \Drupal\image\Plugin\Action\FileOriginalImageStyleAction + * + * @group action + * @group image + */ +class FileImageStyleActionTest extends KernelTestBase { + + use TestFileCreationTrait { + getTestFiles as drupalGetTestFiles; + } + + /** + * The image factory service. + */ + protected ImageFactory $imageFactory; + + /** + * An image file path for uploading. + */ + protected FileInterface $image; + + /** + * An image style. + */ + protected ImageStyle $imageStyle; + + /** + * The ID of the resize effect. + */ + protected string $resizeEffectId; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'file', + 'image', + 'system', + 'user', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->installConfig(['system']); + $this->installEntitySchema('file'); + $this->installEntitySchema('user'); + $this->installSchema('file', ['file_usage']); + $this->installEntitySchema('image_style'); + + $this->imageFactory = $this->container->get('image.factory'); + + $this->imageStyle = ImageStyle::create([ + 'name' => 'original_style', + 'label' => 'Original style', + ]); + + // Add an image effect. + $convert_effect = [ + 'id' => 'image_convert', + 'data' => [ + 'extension' => 'webp', + ], + 'weight' => 0, + ]; + $this->imageStyle->addImageEffect($convert_effect); + $resize_effect = [ + 'id' => 'image_scale_and_crop', + 'data' => [ + 'anchor' => 'center-center', + 'width' => 1, + 'height' => 1, + ], + 'weight' => 1, + ]; + $this->resizeEffectId = $this->imageStyle->addImageEffect($resize_effect); + $this->imageStyle->save(); + + $image_files = $this->drupalGetTestFiles('image'); + $this->image = File::create((array) current($image_files)); + $this->image->save(); + } + + /** + * Test File Image Styles Generate Action. + */ + public function testFileImageStylesGenerateAction(): void { + // Make sure the derivative does not exist, initially. + $derivative_uri = $this->imageStyle->buildUri($this->image->getFileUri()); + $this->assertFalse(file_exists($derivative_uri)); + + // Create the File Image Styles Generate Action. + $action = Action::create([ + 'id' => 'file_image_styles_generate_action', + 'label' => 'Optimize image', + 'plugin' => 'file_image_styles_generate_action', + 'configuration' => [ + 'image_styles' => ['original_style', 'invalid_style'], + ], + ]); + $action->save(); + $action->execute([$this->image]); + // Make sure the derivative images was generated and the image style effects + // were applied. + $this->assertTrue(file_exists($derivative_uri)); + $derivative_image = $this->imageFactory->get($derivative_uri); + $this->assertEquals('image/webp', $derivative_image->getMimeType()); + $this->assertEquals($derivative_image->getHeight(), 1); + + // Update the image style effects. + $resize_effect = $this->imageStyle->getEffect($this->resizeEffectId); + $this->imageStyle->deleteImageEffect($resize_effect); + $resize_effect_config = $resize_effect->getConfiguration(); + $resize_effect_config['data']['width'] = 2; + $resize_effect_config['data']['height'] = 2; + $this->imageStyle->addImageEffect($resize_effect_config); + $this->imageStyle->save(); + + // Regenerate the derivative image. + $action->set('configuration', [ + 'image_styles' => ['original_style'], + 'regenerate' => TRUE, + ]); + $action->save(); + $action->execute([$this->image]); + $derivative_image = $this->imageFactory->get($derivative_uri); + $this->assertEquals($derivative_image->getHeight(), 2); + } + + /** + * Test File Original Image Style Action. + */ + public function testFileImageStyleAction(): void { + $original_image = $this->imageFactory->get($this->image->getFileUri()); + + // Create an action with a non existing image style. + $action = Action::create([ + 'id' => 'file_original_image_style_action', + 'label' => 'Optimize image', + 'plugin' => 'file_original_image_style_action', + 'configuration' => [ + 'image_style' => 'invalid_style', + ], + ]); + $action->save(); + + // Check that the action does not execute. + $action->execute([$this->image]); + $file_not_styled = File::load($this->image->id()); + $not_styled_image = $this->imageFactory->get($file_not_styled->getFileUri()); + $this->assertEquals($original_image->getFileSize(), $not_styled_image->getFileSize()); + $this->assertEquals($original_image->getMimeType(), $not_styled_image->getMimeType()); + $this->assertEquals($original_image->getHeight(), $not_styled_image->getHeight()); + + // Correct action image style configuration. + $action->set('configuration', ['image_style' => 'original_style']); + $action->save(); + $action->execute([$this->image]); + + // Test that the original file has been replaced with the styled one. + $file_styled = File::load($this->image->id()); + $styled_image = $this->imageFactory->get($file_styled->getFileUri()); + $this->assertNotEquals($original_image->getFileSize(), $styled_image->getFileSize()); + $this->assertNotEquals($original_image->getMimeType(), $styled_image->getMimeType()); + $this->assertNotEquals($original_image->getHeight(), $styled_image->getHeight()); + $this->assertFalse(file_exists($original_image->getSource())); + $this->assertTrue(file_exists($styled_image->getSource())); + $this->assertEquals($styled_image->getHeight(), 1); + } + +}