diff options
Diffstat (limited to 'core/modules/image')
12 files changed, 168 insertions, 20 deletions
diff --git a/core/modules/image/config/install/image.style.large.yml b/core/modules/image/config/install/image.style.large.yml index e0b8394552e3..1e327eea8e57 100644 --- a/core/modules/image/config/install/image.style.large.yml +++ b/core/modules/image/config/install/image.style.large.yml @@ -14,7 +14,7 @@ effects: upscale: false 6e8fe467-84c1-4ef0-a73b-7eccf1cc20e8: uuid: 6e8fe467-84c1-4ef0-a73b-7eccf1cc20e8 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/modules/image/config/install/image.style.medium.yml b/core/modules/image/config/install/image.style.medium.yml index f096610c6593..d7ea09a67894 100644 --- a/core/modules/image/config/install/image.style.medium.yml +++ b/core/modules/image/config/install/image.style.medium.yml @@ -14,7 +14,7 @@ effects: upscale: false c410ed2f-aa30-4d9c-a224-d2865d9188cd: uuid: c410ed2f-aa30-4d9c-a224-d2865d9188cd - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/modules/image/config/install/image.style.thumbnail.yml b/core/modules/image/config/install/image.style.thumbnail.yml index c03c60e00e27..c2d7a4e5042b 100644 --- a/core/modules/image/config/install/image.style.thumbnail.yml +++ b/core/modules/image/config/install/image.style.thumbnail.yml @@ -14,7 +14,7 @@ effects: upscale: false c4eb9942-2c9e-4a81-949f-6161a44b6559: uuid: c4eb9942-2c9e-4a81-949f-6161a44b6559 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/modules/image/config/install/image.style.wide.yml b/core/modules/image/config/install/image.style.wide.yml index 8573ae26346f..b62e05f3e386 100644 --- a/core/modules/image/config/install/image.style.wide.yml +++ b/core/modules/image/config/install/image.style.wide.yml @@ -14,7 +14,7 @@ effects: upscale: false 294c5f76-42a4-43ce-82c2-81c2f4723da0: uuid: 294c5f76-42a4-43ce-82c2-81c2f4723da0 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/modules/image/config/schema/image.schema.yml b/core/modules/image/config/schema/image.schema.yml index f805caa378ca..68edccf507ab 100644 --- a/core/modules/image/config/schema/image.schema.yml +++ b/core/modules/image/config/schema/image.schema.yml @@ -52,6 +52,10 @@ image.effect.image_convert: Choice: callback: 'Drupal\Core\ImageToolkit\ImageToolkitManager::getAllValidExtensions' +image.effect.image_convert_avif: + type: image.effect.image_convert + label: 'Convert to AVIF' + image.effect.image_resize: type: image_size label: 'Image resize' diff --git a/core/modules/image/src/Hook/ImageRequirements.php b/core/modules/image/src/Hook/ImageRequirements.php index cf631bfe3754..e1018cf539be 100644 --- a/core/modules/image/src/Hook/ImageRequirements.php +++ b/core/modules/image/src/Hook/ImageRequirements.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\image\Hook; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\ImageToolkit\ImageToolkitManager; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -46,7 +47,7 @@ class ImageRequirements { 'title' => $this->t('Image toolkit'), 'value' => $this->t('None'), 'description' => $this->t("No image toolkit is configured on the site. Check PHP installed extensions or add a contributed toolkit that doesn't require a PHP extension. Make sure that at least one valid image toolkit is installed."), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ], ]; } diff --git a/core/modules/image/src/ImageEffectBase.php b/core/modules/image/src/ImageEffectBase.php index 58be370c1e6e..745976133be7 100644 --- a/core/modules/image/src/ImageEffectBase.php +++ b/core/modules/image/src/ImageEffectBase.php @@ -3,7 +3,7 @@ namespace Drupal\image; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; -use Drupal\Core\Plugin\PluginBase; +use Drupal\Core\Plugin\ConfigurablePluginBase; use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -17,7 +17,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface; * @see \Drupal\image\ImageEffectManager * @see plugin_api */ -abstract class ImageEffectBase extends PluginBase implements ImageEffectInterface, ContainerFactoryPluginInterface { +abstract class ImageEffectBase extends ConfigurablePluginBase implements ImageEffectInterface, ContainerFactoryPluginInterface { /** * The image effect ID. @@ -46,7 +46,6 @@ abstract class ImageEffectBase extends PluginBase implements ImageEffectInterfac public function __construct(array $configuration, $plugin_id, $plugin_definition, LoggerInterface $logger) { parent::__construct($configuration, $plugin_id, $plugin_definition); - $this->setConfiguration($configuration); $this->logger = $logger; } @@ -154,13 +153,6 @@ abstract class ImageEffectBase extends PluginBase implements ImageEffectInterfac /** * {@inheritdoc} */ - public function defaultConfiguration() { - return []; - } - - /** - * {@inheritdoc} - */ public function calculateDependencies() { return []; } diff --git a/core/modules/image/src/Plugin/Field/FieldType/ImageItem.php b/core/modules/image/src/Plugin/Field/FieldType/ImageItem.php index 7ed6b0d3371c..72937d4e79a3 100644 --- a/core/modules/image/src/Plugin/Field/FieldType/ImageItem.php +++ b/core/modules/image/src/Plugin/Field/FieldType/ImageItem.php @@ -389,7 +389,9 @@ class ImageItem extends FileItem { $image->setFileName($file_system->basename($path)); $destination_dir = static::doGetUploadLocation($settings); $file_system->prepareDirectory($destination_dir, FileSystemInterface::CREATE_DIRECTORY); - $destination = $destination_dir . '/' . basename($path); + // Ensure directory ends with a slash. + $destination_dir .= str_ends_with($destination_dir, '/') ? '' : '/'; + $destination = $destination_dir . basename($path); $file = \Drupal::service('file.repository')->move($image, $destination); $images[$extension][$min_resolution][$max_resolution][$file->id()] = $file; } diff --git a/core/modules/image/src/Plugin/ImageEffect/AvifImageEffect.php b/core/modules/image/src/Plugin/ImageEffect/AvifImageEffect.php new file mode 100644 index 000000000000..595743eece7a --- /dev/null +++ b/core/modules/image/src/Plugin/ImageEffect/AvifImageEffect.php @@ -0,0 +1,82 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\image\Plugin\ImageEffect; + +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Image\ImageInterface; +use Drupal\Core\ImageToolkit\ImageToolkitManager; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\image\Attribute\ImageEffect; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Converts an image resource to AVIF, with fallback. + */ +#[ImageEffect( + id: "image_convert_avif", + label: new TranslatableMarkup("Convert to AVIF"), + description: new TranslatableMarkup("Converts an image to AVIF, with a fallback if AVIF is not supported."), +)] +class AvifImageEffect extends ConvertImageEffect { + + /** + * The image toolkit manager. + * + * @var \Drupal\Core\ImageToolkit\ImageToolkitManager + */ + protected ImageToolkitManager $imageToolkitManager; + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { + $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition); + $instance->imageToolkitManager = $container->get(ImageToolkitManager::class); + return $instance; + } + + /** + * {@inheritdoc} + */ + public function applyEffect(ImageInterface $image) { + // If avif is not supported fallback to the parent. + if (!$this->isAvifSupported()) { + return parent::applyEffect($image); + } + + if (!$image->convert('avif')) { + $this->logger->error('Image convert failed using the %toolkit toolkit on %path (%mimetype)', ['%toolkit' => $image->getToolkitId(), '%path' => $image->getSource(), '%mimetype' => $image->getMimeType()]); + return FALSE; + } + + return TRUE; + } + + /** + * {@inheritdoc} + */ + public function getDerivativeExtension($extension) { + return $this->isAvifSupported() ? 'avif' : $this->configuration['extension']; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form = parent::buildConfigurationForm($form, $form_state); + unset($form['extension']['#options']['avif']); + $form['extension']['#title'] = $this->t('Fallback format'); + $form['extension']['#description'] = $this->t('Format to use if AVIF is not available.'); + return $form; + } + + /** + * Is AVIF supported by the image toolkit. + */ + protected function isAvifSupported(): bool { + return in_array('avif', $this->imageToolkitManager->getDefaultToolkit()->getSupportedExtensions()); + } + +} diff --git a/core/modules/image/tests/src/Kernel/ImageEffectsTest.php b/core/modules/image/tests/src/Kernel/ImageEffectsTest.php index 1e5c75339225..54130e7818b6 100644 --- a/core/modules/image/tests/src/Kernel/ImageEffectsTest.php +++ b/core/modules/image/tests/src/Kernel/ImageEffectsTest.php @@ -120,6 +120,31 @@ class ImageEffectsTest extends KernelTestBase { } /** + * Tests the 'image_convert_avif' effect when avif is supported. + */ + public function testConvertAvifEffect(): void { + $this->container->get('keyvalue')->get('image_test')->set('avif_enabled', TRUE); + $this->assertImageEffect(['convert'], 'image_convert_avif', [ + 'extension' => 'webp', + ]); + + $calls = $this->imageTestGetAllCalls(); + $this->assertEquals('avif', $calls['convert'][0][0]); + } + + /** + * Tests the 'image_convert_avif' effect with webp fallback. + */ + public function testConvertAvifEffectFallback(): void { + $this->assertImageEffect(['convert'], 'image_convert_avif', [ + 'extension' => 'webp', + ]); + + $calls = $this->imageTestGetAllCalls(); + $this->assertEquals('webp', $calls['convert'][0][0]); + } + + /** * Tests the 'image_scale_and_crop' effect. */ public function testScaleAndCropEffect(): void { diff --git a/core/modules/image/tests/src/Kernel/ImageItemTest.php b/core/modules/image/tests/src/Kernel/ImageItemTest.php index 0598b25e9db8..55f2503686d4 100644 --- a/core/modules/image/tests/src/Kernel/ImageItemTest.php +++ b/core/modules/image/tests/src/Kernel/ImageItemTest.php @@ -9,6 +9,7 @@ use Drupal\Core\Entity\ContentEntityForm; use Drupal\Core\Entity\Entity\EntityFormDisplay; use Drupal\Core\Entity\EntityDisplayRepositoryInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldItemInterface; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\FieldStorageDefinitionInterface; @@ -19,6 +20,7 @@ use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; use Drupal\file\Entity\File; use Drupal\Tests\field\Kernel\FieldKernelTestBase; +use Drupal\image\Plugin\Field\FieldType\ImageItem; use Drupal\user\Entity\Role; /** @@ -208,6 +210,14 @@ class ImageItemTest extends FieldKernelTestBase { } /** + * Tests image URIs for empty and custom directories. + */ + public function testImageUriDirectories(): void { + $this->validateImageUriForDirectory('', 'public://'); + $this->validateImageUriForDirectory('custom_directory/subdir', 'public://custom_directory/subdir/'); + } + + /** * Tests display_default. */ public function testDisplayDefaultValue(): void { @@ -225,4 +235,36 @@ class ImageItemTest extends FieldKernelTestBase { self::assertEquals(1, $form_state->getValue(['image_test', 0, 'display'])); } + /** + * Validates the image file URI generated for a given file directory. + * + * @param string $file_directory + * The file directory to test (e.g., empty or 'custom_directory/subdir'). + * @param string $expected_start + * The expected starting string of the file URI (e.g., 'public://'). + */ + private function validateImageUriForDirectory(string $file_directory, string $expected_start): void { + // Mock the field definition with the specified file directory. + $definition = $this->createMock(FieldDefinitionInterface::class); + $definition->expects($this->any()) + ->method('getSettings') + ->willReturn([ + 'file_extensions' => 'jpg', + 'file_directory' => $file_directory, + 'uri_scheme' => 'public', + ]); + // Generate sample value and check the URI format. + $value = ImageItem::generateSampleValue($definition); + $this->assertNotEmpty($value); + + // Load the file entity and get its URI. + $fid = $value['target_id']; + $file = File::load($fid); + $fileUri = $file->getFileUri(); + + // Verify the file URI starts with the expected protocol and structure. + $this->assertStringStartsWith($expected_start, $fileUri); + $this->assertMatchesRegularExpression('#^' . preg_quote($expected_start, '#') . '[^/]+#', $fileUri); + } + } diff --git a/core/modules/image/tests/src/Unit/ImageStyleTest.php b/core/modules/image/tests/src/Unit/ImageStyleTest.php index d247642b3cd4..8b877753fefd 100644 --- a/core/modules/image/tests/src/Unit/ImageStyleTest.php +++ b/core/modules/image/tests/src/Unit/ImageStyleTest.php @@ -174,8 +174,8 @@ class ImageStyleTest extends UnitTestCase { // Assert the extension has been added to the URI before creating the token. $this->assertEquals($image_style->getPathToken('public://test.jpeg.png'), $image_style->getPathToken('public://test.jpeg')); - $this->assertEquals(substr(Crypt::hmacBase64($image_style->id() . ':' . 'public://test.jpeg.png', $private_key . $hash_salt), 0, 8), $image_style->getPathToken('public://test.jpeg')); - $this->assertNotEquals(substr(Crypt::hmacBase64($image_style->id() . ':' . 'public://test.jpeg', $private_key . $hash_salt), 0, 8), $image_style->getPathToken('public://test.jpeg')); + $this->assertEquals(substr(Crypt::hmacBase64($image_style->id() . ':public://test.jpeg.png', $private_key . $hash_salt), 0, 8), $image_style->getPathToken('public://test.jpeg')); + $this->assertNotEquals(substr(Crypt::hmacBase64($image_style->id() . ':public://test.jpeg', $private_key . $hash_salt), 0, 8), $image_style->getPathToken('public://test.jpeg')); // Image style that doesn't change the extension. $image_effect_id = $this->randomMachineName(); @@ -195,8 +195,8 @@ class ImageStyleTest extends UnitTestCase { ->willReturn($hash_salt); // Assert no extension has been added to the uri before creating the token. $this->assertNotEquals($image_style->getPathToken('public://test.jpeg.png'), $image_style->getPathToken('public://test.jpeg')); - $this->assertNotEquals(substr(Crypt::hmacBase64($image_style->id() . ':' . 'public://test.jpeg.png', $private_key . $hash_salt), 0, 8), $image_style->getPathToken('public://test.jpeg')); - $this->assertEquals(substr(Crypt::hmacBase64($image_style->id() . ':' . 'public://test.jpeg', $private_key . $hash_salt), 0, 8), $image_style->getPathToken('public://test.jpeg')); + $this->assertNotEquals(substr(Crypt::hmacBase64($image_style->id() . ':public://test.jpeg.png', $private_key . $hash_salt), 0, 8), $image_style->getPathToken('public://test.jpeg')); + $this->assertEquals(substr(Crypt::hmacBase64($image_style->id() . ':public://test.jpeg', $private_key . $hash_salt), 0, 8), $image_style->getPathToken('public://test.jpeg')); } /** |