first commit

This commit is contained in:
2024-07-15 12:33:27 +02:00
commit ce50ae282b
22084 changed files with 2623791 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
/**
* Image style configuration pages.
*/
.image-style-new,
.image-style-new div {
display: inline;
}
.image-style-preview .preview-image-wrapper {
top: 50%;
float: left;
width: 48%;
padding-bottom: 2em;
text-align: center;
}
.image-style-preview .preview-image {
position: relative;
margin: auto;
}
.image-style-preview .preview-image .width {
position: absolute;
bottom: -6px;
left: -1px;
box-sizing: content-box;
height: 2px;
border: 1px solid #666;
border-top: none;
}
.image-style-preview .preview-image .width span {
position: relative;
top: 4px;
}
.image-style-preview .preview-image .height {
position: absolute;
top: -1px;
right: -6px;
box-sizing: content-box;
width: 2px;
border: 1px solid #666;
border-left: none;
}
.image-style-preview .preview-image .height span {
position: absolute;
top: 50%;
left: 10px;
height: 2em;
margin-top: -1em;
}
/**
* Improve image style preview on narrow viewports.
*/
@media screen and (max-width: 470px) {
.image-style-preview .preview-image-wrapper {
float: none;
margin-bottom: 1em;
}
.image-style-preview .preview-image-wrapper:last-child {
margin-bottom: 0;
}
}
/**
* Image anchor element.
*/
.image-anchor {
width: auto;
}
.image-anchor tr {
background: none;
}
.image-anchor td {
border: 1px solid #ccc;
}

View File

@@ -0,0 +1,23 @@
---
label: 'Adding an image style'
related:
- core.media
- field_ui.manage_display
- layout_builder.overview
---
{% set media_topic = render_var(help_topic_link('core.media')) %}
{% set styles_text %}{% trans %}Image styles{% endtrans %}{% endset %}
{% set styles_link = render_var(help_route_link(styles_text, 'entity.image_style.collection')) %}
<h2>{% trans %}Goal{% endtrans %}</h2>
<p>{% trans %}Add a new image style, which can be used to process and display images. See {{ media_topic }} for an overview of image styles.{% endtrans %}</p>
<h2>{% trans %}Steps{% endtrans %}</h2>
<ol>
<li>{% trans %}In the <em>Manage</em> administrative menu, navigate to <em>Configuration</em> &gt; <em>Media</em> &gt; {{ styles_link }}.{% endtrans %}</li>
<li>{% trans %}Click <em>Add image style</em>.{% endtrans %}</li>
<li>{% trans %}Enter a descriptive <em>Image style name</em>, and click <em>Create new style</em>.{% endtrans %}</li>
<li>{% trans %}Under <em>Effect</em>, choose an effect to apply and click <em>Add</em>.{% endtrans %}</li>
<li>{% trans %}Configure the effect on the next page. Most effects require some additional configuration after they are added. For example, for the <em>Crop</em> effect, enter the <em>Width</em> and <em>Height</em> to crop the image to, and choose the <em>Anchor</em> point. Click <em>Add effect</em>.{% endtrans %}</li>
<li>{% trans %}Repeat the previous two steps until all of the effects have been added.{% endtrans %}</li>
<li>{% trans %}Drag to change the order of the effects. Then click <em>Save</em> to save the new order.{% endtrans %}</li>
<li>{% trans %}The image style can now be used to format a field containing an image in your layouts or traditional field displays. It can also be used as part of a responsive image style. See related topics below for more information.{% endtrans %}</li>
</ol>

View File

@@ -0,0 +1,143 @@
<?php
/**
* @file
* Administration pages for image settings.
*/
use Drupal\Core\Render\Element;
/**
* Prepares variables for image style preview templates.
*
* Default template: image-style-preview.html.twig.
*
* @param array $variables
* An associative array containing:
* - style: \Drupal\image\ImageStyleInterface image style being previewed.
*/
function template_preprocess_image_style_preview(&$variables) {
/** @var \Drupal\Core\File\FileUrlGeneratorInterface $file_url_generator */
$file_url_generator = \Drupal::service('file_url_generator');
// Style information.
$style = $variables['style'];
$variables['style_id'] = $style->id();
$variables['style_name'] = $style->label();
// Cache bypass token.
$variables['cache_bypass'] = \Drupal::time()->getRequestTime();
// Sample image info.
$sample_width = 160;
$sample_height = 160;
$image_factory = \Drupal::service('image.factory');
// Set up original file information.
$original_path = \Drupal::config('image.settings')->get('preview_image');
$original_image = $image_factory->get($original_path);
$variables['original'] = [
'url' => $file_url_generator->generateString($original_path),
'width' => $original_image->getWidth(),
'height' => $original_image->getHeight(),
];
if ($variables['original']['width'] > $variables['original']['height']) {
$variables['preview']['original']['width'] = min($variables['original']['width'], $sample_width);
$variables['preview']['original']['height'] = round($variables['preview']['original']['width'] / $variables['original']['width'] * $variables['original']['height']);
}
else {
$variables['preview']['original']['height'] = min($variables['original']['height'], $sample_height);
$variables['preview']['original']['width'] = round($variables['preview']['original']['height'] / $variables['original']['height'] * $variables['original']['width']);
}
// Set up derivative file information.
$preview_file = $style->buildUri($original_path);
// Create derivative if necessary.
if (!file_exists($preview_file)) {
$style->createDerivative($original_path, $preview_file);
}
$preview_image = $image_factory->get($preview_file);
// Generate an itok.
$defaultScheme = \Drupal::config('system.file')->get('default_scheme');
$variables['itok'] = $style->getPathToken($defaultScheme . '://' . $original_path);
$variables['derivative'] = [
'url' => $file_url_generator->generateString($preview_file),
'width' => $preview_image->getWidth(),
'height' => $preview_image->getHeight(),
];
if ($variables['derivative']['width'] > $variables['derivative']['height']) {
$variables['preview']['derivative']['width'] = min($variables['derivative']['width'], $sample_width);
$variables['preview']['derivative']['height'] = round($variables['preview']['derivative']['width'] / $variables['derivative']['width'] * $variables['derivative']['height']);
}
else {
$variables['preview']['derivative']['height'] = min($variables['derivative']['height'], $sample_height);
$variables['preview']['derivative']['width'] = round($variables['preview']['derivative']['height'] / $variables['derivative']['height'] * $variables['derivative']['width']);
}
// Build the preview of the original image.
$variables['original']['rendered'] = [
'#theme' => 'image',
'#uri' => $original_path,
'#alt' => t('Sample original image'),
'#title' => '',
'#attributes' => [
'width' => $variables['original']['width'],
'height' => $variables['original']['height'],
'style' => 'width: ' . $variables['preview']['original']['width'] . 'px; height: ' . $variables['preview']['original']['height'] . 'px;',
],
];
// Build the preview of the image style derivative. Timestamps are added
// to prevent caching of images on the client side.
$variables['derivative']['rendered'] = [
'#theme' => 'image',
'#uri' => $variables['derivative']['url'] . '?cache_bypass=' . $variables['cache_bypass'] . '&itok=' . $variables['itok'],
'#alt' => t('Sample modified image'),
'#title' => '',
'#attributes' => [
'width' => $variables['derivative']['width'],
'height' => $variables['derivative']['height'],
'style' => 'width: ' . $variables['preview']['derivative']['width'] . 'px; height: ' . $variables['preview']['derivative']['height'] . 'px;',
],
];
}
/**
* Prepares variables for image anchor templates.
*
* Default template: image-anchor.html.twig.
*
* @param array $variables
* An associative array containing:
* - element: An associative array containing the image.
*/
function template_preprocess_image_anchor(&$variables) {
$element = $variables['element'];
$rows = [];
$row = [];
foreach (Element::children($element) as $n => $key) {
$element[$key]['#attributes']['title'] = $element[$key]['#title'];
unset($element[$key]['#title']);
$row[] = [
'data' => $element[$key],
];
if ($n % 3 == 3 - 1) {
$rows[] = $row;
$row = [];
}
}
$variables['table'] = [
'#type' => 'table',
'#header' => [],
'#rows' => $rows,
'#attributes' => [
'class' => ['image-anchor'],
],
];
}

View File

@@ -0,0 +1,46 @@
<?php
/**
* @file
* Hooks related to image styles and effects.
*/
/**
* @addtogroup hooks
* @{
*/
/**
* Alter the information provided in \Drupal\image\Annotation\ImageEffect.
*
* @param $effects
* The array of image effects, keyed on the machine-readable effect name.
*/
function hook_image_effect_info_alter(&$effects) {
// Override the Image module's 'Scale and Crop' effect label.
$effects['image_scale_and_crop']['label'] = t('Bangers and Mash');
}
/**
* Respond to image style flushing.
*
* This hook enables modules to take effect when a style is being flushed (all
* images are being deleted from the server and regenerated). Any
* module-specific caches that contain information related to the style should
* be cleared using this hook. This hook is called whenever a style is updated,
* deleted, or any effect associated with the style is update or deleted.
*
* @param \Drupal\image\ImageStyleInterface $style
* The image style object that is being flushed.
* @param string|null $path
* (optional) The original image path or URI. If it's supplied, only this
* image derivative will be flushed.
*/
function hook_image_style_flush($style, $path = NULL) {
// Empty cached data that contains information about the style.
\Drupal::cache('my_module')->deleteAll();
}
/**
* @} End of "addtogroup hooks".
*/

View File

@@ -0,0 +1,75 @@
<?php
/**
* @file
* Implement an image field, based on the file module's file field.
*/
use Drupal\Core\Render\Element;
/**
* Prepares variables for image widget templates.
*
* Default template: image-widget.html.twig.
*
* @param array $variables
* An associative array containing:
* - element: A render element representing the image field widget.
*/
function template_preprocess_image_widget(&$variables) {
$element = $variables['element'];
$variables['attributes'] = ['class' => ['image-widget', 'js-form-managed-file', 'form-managed-file', 'clearfix']];
$variables['data'] = [];
foreach (Element::children($element) as $child) {
$variables['data'][$child] = $element[$child];
}
}
/**
* Prepares variables for image formatter templates.
*
* Default template: image-formatter.html.twig.
*
* @param array $variables
* An associative array containing:
* - item: An ImageItem object.
* - item_attributes: An optional associative array of html attributes to be
* placed in the img tag.
* - image_style: An optional image style.
* - url: An optional \Drupal\Core\Url object.
*/
function template_preprocess_image_formatter(&$variables) {
if ($variables['image_style']) {
$variables['image'] = [
'#theme' => 'image_style',
'#style_name' => $variables['image_style'],
];
}
else {
$variables['image'] = [
'#theme' => 'image',
];
}
$variables['image']['#attributes'] = $variables['item_attributes'];
$item = $variables['item'];
// Do not output an empty 'title' attribute.
if (!is_null($item->title) && mb_strlen($item->title) != 0) {
$variables['image']['#title'] = $item->title;
}
if (($entity = $item->entity) && empty($item->uri)) {
$variables['image']['#uri'] = $entity->getFileUri();
}
else {
$variables['image']['#uri'] = $item->uri;
}
foreach (['width', 'height', 'alt'] as $key) {
$variables['image']["#$key"] = $item->$key;
}
}

View File

@@ -0,0 +1,13 @@
name: Image
type: module
description: 'Defines a field type for image media and provides display configuration tools.'
package: Field types
# version: VERSION
dependencies:
- drupal:file
configure: entity.image_style.collection
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,78 @@
<?php
/**
* @file
* Install, update and uninstall functions for the image module.
*/
use Drupal\Core\File\Exception\FileException;
use Drupal\Core\File\FileSystemInterface;
/**
* Implements hook_install().
*/
function image_install() {
// Create the styles directory and ensure it's writable.
$directory = \Drupal::config('system.file')->get('default_scheme') . '://styles';
\Drupal::service('file_system')->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
}
/**
* Implements hook_uninstall().
*/
function image_uninstall() {
// Remove the styles directory and generated images.
/** @var \Drupal\Core\File\FileSystemInterface $file_system */
$file_system = \Drupal::service('file_system');
try {
$file_system->deleteRecursive(\Drupal::config('system.file')->get('default_scheme') . '://styles');
}
catch (FileException $e) {
// Ignore failed deletes.
}
}
/**
* Implements hook_requirements().
*/
function image_requirements($phase) {
if ($phase != 'runtime') {
return [];
}
$toolkit = \Drupal::service('image.toolkit.manager')->getDefaultToolkit();
if ($toolkit) {
$plugin_definition = $toolkit->getPluginDefinition();
$requirements = [
'image.toolkit' => [
'title' => t('Image toolkit'),
'value' => $toolkit->getPluginId(),
'description' => $plugin_definition['title'],
],
];
foreach ($toolkit->getRequirements() as $key => $requirement) {
$namespaced_key = 'image.toolkit.' . $toolkit->getPluginId() . '.' . $key;
$requirements[$namespaced_key] = $requirement;
}
}
else {
$requirements = [
'image.toolkit' => [
'title' => t('Image toolkit'),
'value' => t('None'),
'description' => 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,
],
];
}
return $requirements;
}
/**
* Implements hook_update_last_removed().
*/
function image_update_last_removed() {
return 8201;
}

View File

@@ -0,0 +1,5 @@
admin:
version: VERSION
css:
theme:
css/image.admin.css: {}

View File

@@ -0,0 +1,5 @@
image_style_add_action:
route_name: image.style_add
title: 'Add image style'
appears_on:
- entity.image_style.collection

View File

@@ -0,0 +1,5 @@
entity.image_style.collection:
title: 'Image styles'
description: 'Configure styles that can be used for resizing or adjusting images on display.'
parent: system.admin_config_media
route_name: entity.image_style.collection

View File

@@ -0,0 +1,9 @@
entity.image_style.edit_form:
title: 'Edit'
route_name: entity.image_style.edit_form
base_route: entity.image_style.edit_form
entity.image_style.collection:
title: List
route_name: entity.image_style.collection
base_route: entity.image_style.collection

509
core/modules/image/image.module Executable file
View File

@@ -0,0 +1,509 @@
<?php
/**
* @file
* Exposes global functionality for creating image styles.
*/
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\StreamWrapper\StreamWrapperManager;
use Drupal\Core\Url;
use Drupal\field\FieldConfigInterface;
use Drupal\field\FieldStorageConfigInterface;
use Drupal\file\FileInterface;
use Drupal\image\Controller\ImageStyleDownloadController;
use Drupal\image\Entity\ImageStyle;
/**
* The name of the query parameter for image derivative tokens.
*/
define('IMAGE_DERIVATIVE_TOKEN', 'itok');
/**
* Implements hook_help().
*/
function image_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
case 'help.page.image':
$field_ui_url = \Drupal::moduleHandler()->moduleExists('field_ui') ? Url::fromRoute('help.page', ['name' => 'field_ui'])->toString() : '#';
$output = '';
$output .= '<h2>' . t('About') . '</h2>';
$output .= '<p>' . t('The Image module allows you to create fields that contain image files and to configure <a href=":image_styles">Image styles</a> that can be used to manipulate the display of images. See the <a href=":field">Field module help</a> and the <a href=":field_ui">Field UI help</a> pages for terminology and general information on entities, fields, and how to create and manage fields. For more information, see the <a href=":image_documentation">online documentation for the Image module</a>.', [':image_styles' => Url::fromRoute('entity.image_style.collection')->toString(), ':field' => Url::fromRoute('help.page', ['name' => 'field'])->toString(), ':field_ui' => $field_ui_url, ':image_documentation' => 'https://www.drupal.org/docs/core-modules-and-themes/core-modules/image-module/working-with-images']) . '</p>';
$output .= '<h2>' . t('Uses') . '</h2>';
$output .= '<dt>' . t('Defining image styles') . '</dt>';
$output .= '<dd>' . t('The concept of image styles is that you can upload a single image but display it in several ways; each display variation, or <em>image style</em>, is the result of applying one or more <em>effects</em> to the original image. As an example, you might upload a high-resolution image with a 4:3 aspect ratio, and display it scaled down, square cropped, or black-and-white (or any combination of these effects). The Image module provides a way to do this efficiently: you configure an image style with the desired effects on the <a href=":image">Image styles page</a>, and the first time a particular image is requested in that style, the effects are applied. The resulting image is saved, and the next time that same style is requested, the saved image is retrieved without the need to recalculate the effects. Drupal core provides several effects that you can use to define styles; others may be provided by contributed modules.', [':image' => Url::fromRoute('entity.image_style.collection')->toString()]);
$output .= '<dt>' . t('Naming image styles') . '</dt>';
$output .= '<dd>' . t('When you define an image style, you will need to choose a displayed name and a machine name. The displayed name is shown in administrative pages, and the machine name is used to generate the URL for accessing an image processed in that style. There are two common approaches to naming image styles: either based on the effects being applied (for example, <em>Square 85x85</em>), or based on where you plan to use it (for example, <em>Profile picture</em>).') . '</dd>';
$output .= '<dt>' . t('Configuring image fields') . '</dt>';
$output .= '<dd>' . t('A few of the settings for image fields are defined once when you create the field and cannot be changed later; these include the choice of public or private file storage and the number of images that can be stored in the field. The rest of the settings can be edited later; these settings include the field label, help text, allowed file extensions, image dimensions restrictions, and the subdirectory in the public or private file storage where the images will be stored. The editable settings can also have different values for different entity sub-types; for instance, if your image field is used on both Page and Article content types, you can store the files in a different subdirectory for the two content types.') . '</dd>';
$output .= '<dd>' . t('For accessibility and search engine optimization, all images that convey meaning on websites should have alternate text. Drupal also allows entry of title text for images, but it can lead to confusion for screen reader users and its use is not recommended. Image fields can be configured so that alternate and title text fields are enabled or disabled; if enabled, the fields can be set to be required. The recommended setting is to enable and require alternate text and disable title text.') . '</dd>';
$output .= '<dd>' . t('When you create an image field, you will need to choose whether the uploaded images will be stored in the public or private file directory defined in your settings.php file and shown on the <a href=":file-system">File system page</a>. This choice cannot be changed later. You can also configure your field to store files in a subdirectory of the public or private directory; this setting can be changed later and can be different for each entity sub-type using the field. For more information on file storage, see the <a href=":system-help">System module help page</a>.', [':file-system' => Url::fromRoute('system.file_system_settings')->toString(), ':system-help' => Url::fromRoute('help.page', ['name' => 'system'])->toString()]) . '</dd>';
$output .= '<dd>' . t('The maximum file size that can be uploaded is limited by PHP settings of the server, but you can restrict it further by configuring a <em>Maximum upload size</em> in the field settings (this setting can be changed later). The maximum file size, either from PHP server settings or field configuration, is automatically displayed to users in the help text of the image field.') . '</dd>';
$output .= '<dd>' . t('You can also configure a minimum and/or maximum dimensions for uploaded images. Images that are too small will be rejected. Images that are to large will be resized. During the resizing the <a href="http://wikipedia.org/wiki/Exchangeable_image_file_format">EXIF data</a> in the image will be lost.') . '</dd>';
$output .= '<dd>' . t('You can also configure a default image that will be used if no image is uploaded in an image field. This default can be defined for all instances of the field in the field storage settings when you create a field, and the setting can be overridden for each entity sub-type that uses the field.') . '</dd>';
$output .= '<dt>' . t('Configuring displays and form displays') . '</dt>';
$output .= '<dd>' . t('On the <em>Manage display</em> page, you can choose the image formatter, which determines the image style used to display the image in each display mode and whether or not to display the image as a link. On the <em>Manage form display</em> page, you can configure the image upload widget, including setting the preview image style shown on the entity edit form.') . '</dd>';
$output .= '</dl>';
return $output;
case 'entity.image_style.collection':
return '<p>' . t('Image styles commonly provide thumbnail sizes by scaling and cropping images, but can also add various effects before an image is displayed. When an image is displayed with a style, a new file is created and the original image is left unchanged.') . '</p>';
case 'image.effect_add_form':
$effect = \Drupal::service('plugin.manager.image.effect')->getDefinition($route_match->getParameter('image_effect'));
return isset($effect['description']) ? ('<p>' . $effect['description'] . '</p>') : NULL;
case 'image.effect_edit_form':
$effect = $route_match->getParameter('image_style')->getEffect($route_match->getParameter('image_effect'));
$effect_definition = $effect->getPluginDefinition();
return isset($effect_definition['description']) ? ('<p>' . $effect_definition['description'] . '</p>') : NULL;
}
}
/**
* Implements hook_theme().
*/
function image_theme() {
return [
// Theme functions in image.module.
'image_style' => [
// HTML 4 and XHTML 1.0 always require an alt attribute. The HTML 5 draft
// allows the alt attribute to be omitted in some cases. Therefore,
// default the alt attribute to an empty string, but allow code using
// '#theme' => 'image_style' to pass explicit NULL for it to be omitted.
// Usually, neither omission nor an empty string satisfies accessibility
// requirements, so it is strongly encouraged for code using '#theme' =>
// 'image_style' to pass a meaningful value for the alt variable.
// - http://www.w3.org/TR/REC-html40/struct/objects.html#h-13.8
// - http://www.w3.org/TR/xhtml1/dtds.html
// - http://dev.w3.org/html5/spec/Overview.html#alt
// The title attribute is optional in all cases, so it is omitted by
// default.
'variables' => [
'style_name' => NULL,
'uri' => NULL,
'width' => NULL,
'height' => NULL,
'alt' => '',
'title' => NULL,
'attributes' => [],
],
],
// Theme functions in image.admin.inc.
'image_style_preview' => [
'variables' => ['style' => NULL],
'file' => 'image.admin.inc',
],
'image_anchor' => [
'render element' => 'element',
'file' => 'image.admin.inc',
],
'image_resize_summary' => [
'variables' => ['data' => NULL, 'effect' => []],
],
'image_scale_summary' => [
'variables' => ['data' => NULL, 'effect' => []],
],
'image_crop_summary' => [
'variables' => ['data' => NULL, 'effect' => []],
],
'image_scale_and_crop_summary' => [
'variables' => ['data' => NULL, 'effect' => []],
],
'image_rotate_summary' => [
'variables' => ['data' => NULL, 'effect' => []],
],
// Theme functions in image.field.inc.
'image_widget' => [
'render element' => 'element',
'file' => 'image.field.inc',
],
'image_formatter' => [
'variables' => ['item' => NULL, 'item_attributes' => NULL, 'url' => NULL, 'image_style' => NULL],
'file' => 'image.field.inc',
],
];
}
/**
* Implements hook_file_download().
*
* Control the access to files underneath the styles directory.
*/
function image_file_download($uri) {
$path = StreamWrapperManager::getTarget($uri);
// Private file access for image style derivatives.
if (str_starts_with($path, 'styles/')) {
$args = explode('/', $path);
// Discard "styles", style name, and scheme from the path
$args = array_slice($args, 3);
// Then the remaining parts are the path to the image.
$original_uri = StreamWrapperManager::getScheme($uri) . '://' . implode('/', $args);
// Check that the file exists and is an image.
$image = \Drupal::service('image.factory')->get($uri);
if ($image->isValid()) {
// If the image style converted the extension, it has been added to the
// original file, resulting in filenames like image.png.jpeg. So to find
// the actual source image, we remove the extension and check if that
// image exists.
if (!file_exists($original_uri)) {
$converted_original_uri = ImageStyleDownloadController::getUriWithoutConvertedExtension($original_uri);
if ($converted_original_uri !== $original_uri && file_exists($converted_original_uri)) {
// The converted file does exist, use it as the source.
$original_uri = $converted_original_uri;
}
}
// Check the permissions of the original to grant access to this image.
$headers = \Drupal::moduleHandler()->invokeAll('file_download', [$original_uri]);
// Confirm there's at least one module granting access and none denying access.
if (!empty($headers) && !in_array(-1, $headers)) {
return [
// Send headers describing the image's size, and MIME-type.
'Content-Type' => $image->getMimeType(),
'Content-Length' => $image->getFileSize(),
// By not explicitly setting them here, this uses normal Drupal
// Expires, Cache-Control and ETag headers to prevent proxy or
// browser caching of private images.
];
}
}
return -1;
}
// If it is the sample image we need to grant access.
$samplePath = \Drupal::config('image.settings')->get('preview_image');
if ($path === $samplePath) {
$image = \Drupal::service('image.factory')->get($samplePath);
return [
// Send headers describing the image's size, and MIME-type.
'Content-Type' => $image->getMimeType(),
'Content-Length' => $image->getFileSize(),
// By not explicitly setting them here, this uses normal Drupal
// Expires, Cache-Control and ETag headers to prevent proxy or
// browser caching of private images.
];
}
}
/**
* Implements hook_file_move().
*/
function image_file_move(FileInterface $file, FileInterface $source) {
// Delete any image derivatives at the original image path.
image_path_flush($source->getFileUri());
}
/**
* Implements hook_ENTITY_TYPE_predelete() for file entities.
*/
function image_file_predelete(FileInterface $file) {
// Delete any image derivatives of this image.
image_path_flush($file->getFileUri());
}
/**
* Clears cached versions of a specific file in all styles.
*
* @param $path
* The Drupal file path to the original image.
*/
function image_path_flush($path) {
$styles = ImageStyle::loadMultiple();
foreach ($styles as $style) {
$style->flush($path);
}
}
/**
* Gets an array of image styles suitable for using as select list options.
*
* @param $include_empty
* If TRUE a '- None -' option will be inserted in the options array.
*
* @return string[]
* Array of image styles both key and value are set to style name.
*/
function image_style_options($include_empty = TRUE) {
$styles = ImageStyle::loadMultiple();
$options = [];
if ($include_empty && !empty($styles)) {
$options[''] = t('- None -');
}
foreach ($styles as $name => $style) {
$options[$name] = $style->label();
}
if (empty($options)) {
$options[''] = t('No defined styles');
}
return $options;
}
/**
* Prepares variables for image style templates.
*
* Default template: image-style.html.twig.
*
* @param array $variables
* An associative array containing:
* - width: The width of the image.
* - height: The height of the image.
* - style_name: The name of the image style to be applied.
* - uri: URI of the source image before styling.
* - alt: The alternative text for text-based browsers. HTML 4 and XHTML 1.0
* always require an alt attribute. The HTML 5 draft allows the alt
* attribute to be omitted in some cases. Therefore, this variable defaults
* to an empty string, but can be set to NULL for the attribute to be
* omitted. Usually, neither omission nor an empty string satisfies
* accessibility requirements, so it is strongly encouraged for code using
* '#theme' => 'image_style' to pass a meaningful value for this variable.
* - http://www.w3.org/TR/REC-html40/struct/objects.html#h-13.8
* - http://www.w3.org/TR/xhtml1/dtds.html
* - http://dev.w3.org/html5/spec/Overview.html#alt
* - title: The title text is displayed when the image is hovered in some
* popular browsers.
* - attributes: Associative array of additional attributes to be placed in
* the img tag.
*/
function template_preprocess_image_style(&$variables) {
$style = ImageStyle::load($variables['style_name']);
// Determine the dimensions of the styled image.
$dimensions = [
'width' => $variables['width'],
'height' => $variables['height'],
];
$style->transformDimensions($dimensions, $variables['uri']);
$variables['image'] = [
'#theme' => 'image',
'#width' => $dimensions['width'],
'#height' => $dimensions['height'],
'#attributes' => $variables['attributes'],
'#style_name' => $variables['style_name'],
];
// If the current image toolkit supports this file type, prepare the URI for
// the derivative image. If not, just use the original image resized to the
// dimensions specified by the style.
if ($style->supportsUri($variables['uri'])) {
$variables['image']['#uri'] = $style->buildUrl($variables['uri']);
}
else {
$variables['image']['#uri'] = $variables['uri'];
// Don't render the image by default, but allow other preprocess functions
// to override that if they need to.
$variables['image']['#access'] = FALSE;
// Inform the site builders why their image didn't work.
\Drupal::logger('image')->warning('Could not apply @style image style to @uri because the style does not support it.', [
'@style' => $style->label(),
'@uri' => $variables['uri'],
]);
}
if (\array_key_exists('alt', $variables)) {
$variables['image']['#alt'] = $variables['alt'];
}
if (\array_key_exists('title', $variables)) {
$variables['image']['#title'] = $variables['title'];
}
}
/**
* Returns the offset in pixels from the anchor.
*
* @param string $anchor
* The anchor ('top', 'left', 'bottom', 'right', 'center').
* @param int $current_size
* The current size, in pixels.
* @param int $new_size
* The new size, in pixels.
*
* @return int|string
* The offset from the anchor, in pixels, or the anchor itself, if its value
* isn't one of the accepted values.
*/
function image_filter_keyword($anchor, $current_size, $new_size) {
switch ($anchor) {
case 'top':
case 'left':
return 0;
case 'bottom':
case 'right':
return $current_size - $new_size;
case 'center':
return $current_size / 2 - $new_size / 2;
default:
return $anchor;
}
}
/**
* Implements hook_entity_presave().
*
* Transforms default image of image field from array into single value at save.
*/
function image_entity_presave(EntityInterface $entity) {
// Get the default image settings, return if not saving an image field storage
// or image field entity.
$default_image = [];
if (($entity instanceof FieldStorageConfigInterface || $entity instanceof FieldConfigInterface) && $entity->getType() == 'image') {
$default_image = $entity->getSetting('default_image');
}
else {
return;
}
if ($entity->isSyncing()) {
return;
}
$uuid = $default_image['uuid'];
if ($uuid) {
$original_uuid = isset($entity->original) ? $entity->original->getSetting('default_image')['uuid'] : NULL;
if ($uuid != $original_uuid) {
$file = \Drupal::service('entity.repository')->loadEntityByUuid('file', $uuid);
if ($file) {
$image = \Drupal::service('image.factory')->get($file->getFileUri());
$default_image['width'] = $image->getWidth();
$default_image['height'] = $image->getHeight();
}
else {
$default_image['uuid'] = NULL;
}
}
}
// Both FieldStorageConfigInterface and FieldConfigInterface have a
// setSetting() method.
$entity->setSetting('default_image', $default_image);
}
/**
* Implements hook_ENTITY_TYPE_update() for 'field_storage_config'.
*/
function image_field_storage_config_update(FieldStorageConfigInterface $field_storage) {
if ($field_storage->getType() != 'image') {
// Only act on image fields.
return;
}
$prior_field_storage = $field_storage->original;
// The value of a managed_file element can be an array if #extended == TRUE.
$uuid_new = $field_storage->getSetting('default_image')['uuid'];
$uuid_old = $prior_field_storage->getSetting('default_image')['uuid'];
$file_new = $uuid_new ? \Drupal::service('entity.repository')->loadEntityByUuid('file', $uuid_new) : FALSE;
if ($uuid_new != $uuid_old) {
// Is there a new file?
if ($file_new) {
$file_new->setPermanent();
$file_new->save();
\Drupal::service('file.usage')->add($file_new, 'image', 'default_image', $field_storage->uuid());
}
// Is there an old file?
if ($uuid_old && ($file_old = \Drupal::service('entity.repository')->loadEntityByUuid('file', $uuid_old))) {
\Drupal::service('file.usage')->delete($file_old, 'image', 'default_image', $field_storage->uuid());
}
}
// If the upload destination changed, then move the file.
if ($file_new && (StreamWrapperManager::getScheme($file_new->getFileUri()) != $field_storage->getSetting('uri_scheme'))) {
$directory = $field_storage->getSetting('uri_scheme') . '://default_images/';
\Drupal::service('file_system')->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY);
\Drupal::service('file.repository')->move($file_new, $directory . $file_new->getFilename());
}
}
/**
* Implements hook_ENTITY_TYPE_update() for 'field_config'.
*/
function image_field_config_update(FieldConfigInterface $field) {
$field_storage = $field->getFieldStorageDefinition();
if ($field_storage->getType() != 'image') {
// Only act on image fields.
return;
}
$prior_instance = $field->original;
$uuid_new = $field->getSetting('default_image')['uuid'];
$uuid_old = $prior_instance->getSetting('default_image')['uuid'];
// If the old and new files do not match, update the default accordingly.
$file_new = $uuid_new ? \Drupal::service('entity.repository')->loadEntityByUuid('file', $uuid_new) : FALSE;
if ($uuid_new != $uuid_old) {
// Save the new file, if present.
if ($file_new) {
$file_new->setPermanent();
$file_new->save();
\Drupal::service('file.usage')->add($file_new, 'image', 'default_image', $field->uuid());
}
// Delete the old file, if present.
if ($uuid_old && ($file_old = \Drupal::service('entity.repository')->loadEntityByUuid('file', $uuid_old))) {
\Drupal::service('file.usage')->delete($file_old, 'image', 'default_image', $field->uuid());
}
}
// If the upload destination changed, then move the file.
if ($file_new && (StreamWrapperManager::getScheme($file_new->getFileUri()) != $field_storage->getSetting('uri_scheme'))) {
$directory = $field_storage->getSetting('uri_scheme') . '://default_images/';
\Drupal::service('file_system')->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY);
\Drupal::service('file.repository')->move($file_new, $directory . $file_new->getFilename());
}
}
/**
* Implements hook_ENTITY_TYPE_delete() for 'field_storage_config'.
*/
function image_field_storage_config_delete(FieldStorageConfigInterface $field) {
if ($field->getType() != 'image') {
// Only act on image fields.
return;
}
// The value of a managed_file element can be an array if #extended == TRUE.
$uuid = $field->getSetting('default_image')['uuid'];
if ($uuid && ($file = \Drupal::service('entity.repository')->loadEntityByUuid('file', $uuid))) {
\Drupal::service('file.usage')->delete($file, 'image', 'default_image', $field->uuid());
}
}
/**
* Implements hook_ENTITY_TYPE_delete() for 'field_config'.
*/
function image_field_config_delete(FieldConfigInterface $field) {
$field_storage = $field->getFieldStorageDefinition();
if ($field_storage->getType() != 'image') {
// Only act on image fields.
return;
}
// The value of a managed_file element can be an array if #extended == TRUE.
$uuid = $field->getSetting('default_image')['uuid'];
// Remove the default image when the instance is deleted.
if ($uuid && ($file = \Drupal::service('entity.repository')->loadEntityByUuid('file', $uuid))) {
\Drupal::service('file.usage')->delete($file, 'image', 'default_image', $field->uuid());
}
}

View File

@@ -0,0 +1,2 @@
administer image styles:
title: 'Administer image styles'

View File

@@ -0,0 +1,17 @@
<?php
/**
* @file
* Post-update functions for Image.
*/
/**
* Implements hook_removed_post_updates().
*/
function image_removed_post_updates() {
return [
'image_post_update_image_style_dependencies' => '9.0.0',
'image_post_update_scale_and_crop_effect_add_anchor' => '9.0.0',
'image_post_update_image_loading_attribute' => '10.0.0',
];
}

View File

@@ -0,0 +1,74 @@
image.style_add:
path: '/admin/config/media/image-styles/add'
defaults:
_entity_form: image_style.add
_title: 'Add image style'
requirements:
_permission: 'administer image styles'
entity.image_style.edit_form:
path: '/admin/config/media/image-styles/manage/{image_style}'
defaults:
_entity_form: image_style.edit
_title: 'Edit style'
requirements:
_permission: 'administer image styles'
entity.image_style.delete_form:
path: '/admin/config/media/image-styles/manage/{image_style}/delete'
defaults:
_entity_form: 'image_style.delete'
_title: 'Delete'
requirements:
_entity_access: 'image_style.delete'
entity.image_style.flush_form:
path: '/admin/config/media/image-styles/manage/{image_style}/flush'
defaults:
_entity_form: 'image_style.flush'
_title: 'Flush'
requirements:
_permission: 'administer image styles'
image.effect_delete:
path: '/admin/config/media/image-styles/manage/{image_style}/effects/{image_effect}/delete'
defaults:
_form: '\Drupal\image\Form\ImageEffectDeleteForm'
_title: 'Delete image effect'
requirements:
_permission: 'administer image styles'
entity.image_style.collection:
path: '/admin/config/media/image-styles'
defaults:
_entity_list: 'image_style'
_title: 'Image styles'
requirements:
_permission: 'administer image styles'
image.style_private:
path: '/system/files/styles/{image_style}/{scheme}'
defaults:
_controller: '\Drupal\image\Controller\ImageStyleDownloadController::deliver'
required_derivative_scheme: 'private'
requirements:
_access: 'TRUE'
image.effect_add_form:
path: '/admin/config/media/image-styles/manage/{image_style}/add/{image_effect}'
defaults:
_form: '\Drupal\image\Form\ImageEffectAddForm'
_title: 'Add image effect'
requirements:
_permission: 'administer image styles'
image.effect_edit_form:
path: '/admin/config/media/image-styles/manage/{image_style}/effects/{image_effect}'
defaults:
_form: '\Drupal\image\Form\ImageEffectEditForm'
_title: 'Edit image effect'
requirements:
_permission: 'administer image styles'
route_callbacks:
- '\Drupal\image\Routing\ImageStyleRoutes::routes'

View File

@@ -0,0 +1,15 @@
services:
path_processor.image_styles:
class: Drupal\image\PathProcessor\PathProcessorImageStyles
arguments: ['@stream_wrapper_manager']
tags:
- { name: path_processor_inbound, priority: 300 }
plugin.manager.image.effect:
class: Drupal\image\ImageEffectManager
parent: default_plugin_manager
image.page_cache_response_policy.deny_private_image_style_download:
class: Drupal\image\PageCache\DenyPrivateImageStyleDownload
arguments: ['@current_route_match']
public: false
tags:
- { name: page_cache_response_policy }

View File

@@ -0,0 +1,70 @@
<?php
/**
* @file
* Provide views data for image.module.
*/
use Drupal\field\FieldStorageConfigInterface;
/**
* Implements hook_field_views_data().
*
* Views integration for image fields. Adds an image relationship to the default
* field data.
*
* @see views_field_default_views_data()
*/
function image_field_views_data(FieldStorageConfigInterface $field_storage) {
$data = views_field_default_views_data($field_storage);
foreach ($data as $table_name => $table_data) {
// Add the relationship only on the target_id field.
$data[$table_name][$field_storage->getName() . '_target_id']['relationship'] = [
'id' => 'standard',
'base' => 'file_managed',
'entity type' => 'file',
'base field' => 'fid',
'label' => t('image from @field_name', ['@field_name' => $field_storage->getName()]),
];
}
return $data;
}
/**
* Implements hook_field_views_data_views_data_alter().
*
* Views integration to provide reverse relationships on image fields.
*/
function image_field_views_data_views_data_alter(array &$data, FieldStorageConfigInterface $field_storage) {
$entity_type_id = $field_storage->getTargetEntityTypeId();
$field_name = $field_storage->getName();
$entity_type_manager = \Drupal::entityTypeManager();
$entity_type = $entity_type_manager->getDefinition($entity_type_id);
$pseudo_field_name = 'reverse_' . $field_name . '_' . $entity_type_id;
/** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */
$table_mapping = $entity_type_manager->getStorage($entity_type_id)->getTableMapping();
[$label] = views_entity_field_label($entity_type_id, $field_name);
$data['file_managed'][$pseudo_field_name]['relationship'] = [
'title' => t('@entity using @field', ['@entity' => $entity_type->getLabel(), '@field' => $label]),
'label' => t('@field_name', ['@field_name' => $field_name]),
'help' => t('Relate each @entity with a @field set to the image.', ['@entity' => $entity_type->getLabel(), '@field' => $label]),
'group' => $entity_type->getLabel(),
'id' => 'entity_reverse',
'base' => $entity_type->getDataTable() ?: $entity_type->getBaseTable(),
'entity_type' => $entity_type_id,
'base field' => $entity_type->getKey('id'),
'field_name' => $field_name,
'field table' => $table_mapping->getDedicatedDataTableName($field_storage),
'field field' => $field_name . '_target_id',
'join_extra' => [
0 => [
'field' => 'deleted',
'value' => 0,
'numeric' => TRUE,
],
],
];
}

View File

@@ -0,0 +1,4 @@
<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
</svg>

After

Width:  |  Height:  |  Size: 261 B

View File

@@ -0,0 +1,4 @@
<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z"/>
</svg>

After

Width:  |  Height:  |  Size: 202 B

View File

@@ -0,0 +1,26 @@
# cspell:ignore imagecache presetname
id: d6_imagecache_presets
label: ImageCache Presets
migration_tags:
- Drupal 6
- Configuration
source:
plugin: d6_imagecache_presets
process:
name:
-
plugin: machine_name
source: presetname
-
plugin: make_unique_entity_field
entity_type: image_style
field: name
length: 30
label: presetname
effects:
plugin: d6_imagecache_actions
source:
- '@plugin'
- data
destination:
plugin: entity:image_style

View File

@@ -0,0 +1,19 @@
id: d7_image_settings
label: Image configuration
migration_tags:
- Drupal 7
- Configuration
source:
plugin: variable
variables:
- allow_insecure_derivatives
- suppress_itok_output
- image_style_preview_image
source_module: image
process:
suppress_itok_output: suppress_itok_output
allow_insecure_derivatives: allow_insecure_derivatives
preview_image: image_style_preview_image
destination:
plugin: config
config_name: image.settings

View File

@@ -0,0 +1,19 @@
id: d7_image_styles
label: Image styles
migration_tags:
- Drupal 7
- Configuration
source:
plugin: d7_image_styles
process:
name: name
label: label
effects:
plugin: sub_process
source: effects
process:
id: name
weight: weight
data: data
destination:
plugin: entity:image_style

View File

@@ -0,0 +1,7 @@
# cspell:ignore imagefield imagecache
finished:
6:
imagecache: image
imagefield: image
7:
image: image

BIN
core/modules/image/sample.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

View File

@@ -0,0 +1,57 @@
<?php
namespace Drupal\image\Annotation;
use Drupal\Component\Annotation\Plugin;
/**
* Defines an image effect annotation object.
*
* Plugin Namespace: Plugin\ImageEffect
*
* For a working example, see
* \Drupal\image\Plugin\ImageEffect\ResizeImageEffect
*
* @see hook_image_effect_info_alter()
* @see \Drupal\image\ConfigurableImageEffectInterface
* @see \Drupal\image\ConfigurableImageEffectBase
* @see \Drupal\image\ImageEffectInterface
* @see \Drupal\image\ImageEffectBase
* @see \Drupal\image\ImageEffectManager
* @see \Drupal\Core\ImageToolkit\Annotation\ImageToolkitOperation
* @see plugin_api
*
* @Annotation
*/
class ImageEffect extends Plugin {
/**
* The plugin ID.
*
* @var string
*/
public $id;
/**
* The human-readable name of the image effect.
*
* @ingroup plugin_translatable
*
* @var \Drupal\Core\Annotation\Translation
*/
public $label;
/**
* A brief description of the image effect.
*
* This property is optional and it does not need to be declared.
*
* This will be shown when adding or configuring this image effect.
*
* @ingroup plugin_translatable
*
* @var \Drupal\Core\Annotation\Translation
*/
public $description = '';
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Drupal\image\Attribute;
use Drupal\Component\Plugin\Attribute\Plugin;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Defines an ImageEffect attribute for plugin discovery.
*
* Plugin Namespace: Plugin\ImageEffect
*
* For a working example, see
* \Drupal\image\Plugin\ImageEffect\ResizeImageEffect
*
* @see hook_image_effect_info_alter()
* @see \Drupal\image\ConfigurableImageEffectInterface
* @see \Drupal\image\ConfigurableImageEffectBase
* @see \Drupal\image\ImageEffectInterface
* @see \Drupal\image\ImageEffectBase
* @see \Drupal\image\ImageEffectManager
* @see \Drupal\Core\ImageToolkit\Attribute\ImageToolkitOperation
* @see plugin_api
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
class ImageEffect extends Plugin {
/**
* Constructs an ImageEffect attribute.
*
* @param string $id
* The plugin ID.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup $label
* The human-readable name of the image effect.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $description
* (optional) A brief description of the image effect. This will be shown
* when adding or configuring this image effect.
* @param class-string|null $deriver
* (optional) The deriver class.
*/
public function __construct(
public readonly string $id,
public readonly TranslatableMarkup $label,
public readonly ?TranslatableMarkup $description = NULL,
public readonly ?string $deriver = NULL,
) {}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Drupal\image;
use Drupal\Core\Form\FormStateInterface;
/**
* Provides a base class for configurable image effects.
*
* @see \Drupal\image\Annotation\ImageEffect
* @see \Drupal\image\ConfigurableImageEffectInterface
* @see \Drupal\image\ImageEffectInterface
* @see \Drupal\image\ImageEffectBase
* @see \Drupal\image\ImageEffectManager
* @see plugin_api
*/
abstract class ConfigurableImageEffectBase extends ImageEffectBase implements ConfigurableImageEffectInterface {
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Drupal\image;
use Drupal\Core\Plugin\PluginFormInterface;
/**
* Defines the interface for configurable image effects.
*
* @see \Drupal\image\Annotation\ImageEffect
* @see \Drupal\image\ConfigurableImageEffectBase
* @see \Drupal\image\ImageEffectInterface
* @see \Drupal\image\ImageEffectBase
* @see \Drupal\image\ImageEffectManager
* @see plugin_api
*/
interface ConfigurableImageEffectInterface extends ImageEffectInterface, PluginFormInterface {
}

View File

@@ -0,0 +1,315 @@
<?php
namespace Drupal\image\Controller;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Image\ImageFactory;
use Drupal\Core\Lock\LockBackendInterface;
use Drupal\Core\Site\Settings;
use Drupal\Core\StreamWrapper\StreamWrapperManager;
use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
use Drupal\image\ImageStyleInterface;
use Drupal\system\FileDownloadController;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
/**
* Defines a controller to serve image styles.
*/
class ImageStyleDownloadController extends FileDownloadController {
/**
* The lock backend.
*
* @var \Drupal\Core\Lock\LockBackendInterface
*/
protected $lock;
/**
* The image factory.
*
* @var \Drupal\Core\Image\ImageFactory
*/
protected $imageFactory;
/**
* A logger instance.
*
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* File system service.
*
* @var \Drupal\Core\File\FileSystemInterface
*/
protected $fileSystem;
/**
* Constructs an ImageStyleDownloadController object.
*
* @param \Drupal\Core\Lock\LockBackendInterface $lock
* The lock backend.
* @param \Drupal\Core\Image\ImageFactory $image_factory
* The image factory.
* @param \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $stream_wrapper_manager
* The stream wrapper manager.
* @param \Drupal\Core\File\FileSystemInterface $file_system
* The system service.
*/
public function __construct(LockBackendInterface $lock, ImageFactory $image_factory, StreamWrapperManagerInterface $stream_wrapper_manager, FileSystemInterface $file_system) {
parent::__construct($stream_wrapper_manager);
$this->lock = $lock;
$this->imageFactory = $image_factory;
$this->logger = $this->getLogger('image');
$this->fileSystem = $file_system;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('lock'),
$container->get('image.factory'),
$container->get('stream_wrapper_manager'),
$container->get('file_system')
);
}
/**
* Generates a derivative, given a style and image path.
*
* After generating an image, transfer it to the requesting agent.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
* @param string $scheme
* The file scheme, defaults to 'private'.
* @param \Drupal\image\ImageStyleInterface $image_style
* The image style to deliver.
* @param string $required_derivative_scheme
* The required scheme for the derivative image.
*
* @return \Symfony\Component\HttpFoundation\BinaryFileResponse|\Symfony\Component\HttpFoundation\Response
* The transferred file as response or some error response.
*
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
* Thrown when the file request is invalid.
* @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
* Thrown when the user does not have access to the file.
* @throws \Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException
* Thrown when the file is still being generated.
*/
public function deliver(Request $request, $scheme, ImageStyleInterface $image_style, string $required_derivative_scheme) {
$target = $request->query->get('file');
$image_uri = $scheme . '://' . $target;
$image_uri = $this->streamWrapperManager->normalizeUri($image_uri);
$sample_image_uri = $scheme . '://' . $this->config('image.settings')->get('preview_image');
if ($this->streamWrapperManager->isValidScheme($scheme)) {
$normalized_target = $this->streamWrapperManager->getTarget($image_uri);
if ($normalized_target !== FALSE) {
if (!in_array($scheme, Settings::get('file_sa_core_2023_005_schemes', []))) {
$parts = explode('/', $normalized_target);
if (array_intersect($parts, ['.', '..'])) {
throw new NotFoundHttpException();
}
}
}
}
// Check that the style is defined and the scheme is valid.
$valid = !empty($image_style) && $this->streamWrapperManager->isValidScheme($scheme);
// Also validate the derivative token. Sites which require image
// derivatives to be generated without a token can set the
// 'image.settings:allow_insecure_derivatives' configuration to TRUE to
// bypass this check, but this will increase the site's vulnerability
// to denial-of-service attacks. To prevent this variable from leaving the
// site vulnerable to the most serious attacks, a token is always required
// when a derivative of a style is requested.
// The $target variable for a derivative of a style has
// styles/<style_name>/... as structure, so we check if the $target variable
// starts with styles/.
$token = $request->query->get(IMAGE_DERIVATIVE_TOKEN, '');
$token_is_valid = hash_equals($image_style->getPathToken($image_uri), $token)
|| hash_equals($image_style->getPathToken($scheme . '://' . $target), $token);
if (!$this->config('image.settings')->get('allow_insecure_derivatives') || str_starts_with(ltrim($target, '\/'), 'styles/')) {
$valid = $valid && $token_is_valid;
}
if (!$valid) {
// Return a 404 (Page Not Found) rather than a 403 (Access Denied) as the
// image token is for DDoS protection rather than access checking. 404s
// are more likely to be cached (e.g. at a proxy) which enhances
// protection from DDoS.
throw new NotFoundHttpException();
}
$derivative_uri = $image_style->buildUri($image_uri);
$derivative_scheme = $this->streamWrapperManager->getScheme($derivative_uri);
if ($required_derivative_scheme !== $derivative_scheme) {
throw new AccessDeniedHttpException("The scheme for this image doesn't match the scheme for the original image");
}
if ($token_is_valid) {
$is_public = ($scheme !== 'private');
}
else {
$core_schemes = ['public', 'private', 'temporary'];
$additional_public_schemes = array_diff(Settings::get('file_additional_public_schemes', []), $core_schemes);
$public_schemes = array_merge(['public'], $additional_public_schemes);
$is_public = in_array($derivative_scheme, $public_schemes, TRUE);
}
$headers = [];
// Don't try to generate file if source is missing.
if ($image_uri !== $sample_image_uri && !$this->sourceImageExists($image_uri, $token_is_valid)) {
// If the image style converted the extension, it has been added to the
// original file, resulting in filenames like image.png.jpeg. So to find
// the actual source image, we remove the extension and check if that
// image exists.
$converted_image_uri = static::getUriWithoutConvertedExtension($image_uri);
if ($converted_image_uri !== $image_uri &&
$this->sourceImageExists($converted_image_uri, $token_is_valid)) {
// The converted file does exist, use it as the source.
$image_uri = $converted_image_uri;
}
else {
$this->logger->notice('Source image at %source_image_path not found while trying to generate derivative image at %derivative_path.', ['%source_image_path' => $image_uri, '%derivative_path' => $derivative_uri]);
return new Response($this->t('Error generating image, missing source file.'), 404);
}
}
// If not using a public scheme, let other modules provide headers and
// control access to the file.
if (!$is_public) {
$headers = $this->moduleHandler()->invokeAll('file_download', [$image_uri]);
if (in_array(-1, $headers) || empty($headers)) {
throw new AccessDeniedHttpException();
}
}
// If it is default sample.png, ignore scheme.
// This value swap must be done after hook_file_download is called since
// the hooks are expecting a URI, not a file path.
if ($image_uri === $sample_image_uri) {
$image_uri = $target;
}
// Don't start generating the image if the derivative already exists or if
// generation is in progress in another thread.
if (!file_exists($derivative_uri)) {
$lock_name = 'image_style_deliver:' . $image_style->id() . ':' . Crypt::hashBase64($image_uri);
$lock_acquired = $this->lock->acquire($lock_name);
if (!$lock_acquired) {
// Tell client to retry again in 3 seconds. Currently no browsers are
// known to support Retry-After.
throw new ServiceUnavailableHttpException(3, 'Image generation in progress. Try again shortly.');
}
}
// Try to generate the image, unless another thread just did it while we
// were acquiring the lock.
$success = file_exists($derivative_uri) || $image_style->createDerivative($image_uri, $derivative_uri);
if (!empty($lock_acquired)) {
$this->lock->release($lock_name);
}
if ($success) {
$image = $this->imageFactory->get($derivative_uri);
$uri = $image->getSource();
$headers += [
'Content-Type' => $image->getMimeType(),
'Content-Length' => $image->getFileSize(),
];
// \Drupal\Core\EventSubscriber\FinishResponseSubscriber::onRespond()
// sets response as not cacheable if the Cache-Control header is not
// already modified. When $is_public is TRUE, the following sets the
// Cache-Control header to "public".
return new BinaryFileResponse($uri, 200, $headers, $is_public);
}
else {
$this->logger->notice('Unable to generate the derived image located at %path.', ['%path' => $derivative_uri]);
return new Response($this->t('Error generating image.'), 500);
}
}
/**
* Checks whether the provided source image exists.
*
* @param string $image_uri
* The URI for the source image.
* @param bool $token_is_valid
* Whether a valid image token was supplied.
*
* @return bool
* Whether the source image exists.
*/
private function sourceImageExists(string $image_uri, bool $token_is_valid): bool {
$exists = file_exists($image_uri);
// If the file doesn't exist, we can stop here.
if (!$exists) {
return FALSE;
}
if ($token_is_valid) {
return TRUE;
}
if (StreamWrapperManager::getScheme($image_uri) !== 'public') {
return TRUE;
}
$image_path = $this->fileSystem->realpath($image_uri);
$private_path = Settings::get('file_private_path');
if ($private_path) {
$private_path = realpath($private_path);
if ($private_path && str_starts_with($image_path, $private_path)) {
return FALSE;
}
}
return TRUE;
}
/**
* Get the file URI without the extension from any conversion image style.
*
* If the image style converted the image, then an extension has been added
* to the original file, resulting in filenames like image.png.jpeg.
*
* @param string $uri
* The file URI.
*
* @return string
* The file URI without the extension from any conversion image style.
*/
public static function getUriWithoutConvertedExtension(string $uri): string {
$original_uri = $uri;
$path_info = pathinfo(StreamWrapperManager::getTarget($uri));
// Only convert the URI when the filename still has an extension.
if (!empty($path_info['filename']) && pathinfo($path_info['filename'], PATHINFO_EXTENSION)) {
$original_uri = StreamWrapperManager::getScheme($uri) . '://';
if (!empty($path_info['dirname']) && $path_info['dirname'] !== '.') {
$original_uri .= $path_info['dirname'] . DIRECTORY_SEPARATOR;
}
$original_uri .= $path_info['filename'];
}
return $original_uri;
}
}

View File

@@ -0,0 +1,539 @@
<?php
namespace Drupal\image\Entity;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Config\Entity\ConfigEntityBase;
use Drupal\Core\Entity\Entity\EntityFormDisplay;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityWithPluginCollectionInterface;
use Drupal\Core\File\Exception\FileException;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Routing\RequestHelper;
use Drupal\Core\Site\Settings;
use Drupal\Core\StreamWrapper\StreamWrapperManager;
use Drupal\Core\Url;
use Drupal\image\ImageEffectPluginCollection;
use Drupal\image\ImageEffectInterface;
use Drupal\image\ImageStyleInterface;
use Drupal\Component\Utility\Crypt;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\StreamWrapper\StreamWrapperInterface;
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
use Drupal\Core\Entity\Entity\EntityViewDisplay;
/**
* Defines an image style configuration entity.
*
* @ConfigEntityType(
* id = "image_style",
* label = @Translation("Image style"),
* label_collection = @Translation("Image styles"),
* label_singular = @Translation("image style"),
* label_plural = @Translation("image styles"),
* label_count = @PluralTranslation(
* singular = "@count image style",
* plural = "@count image styles",
* ),
* handlers = {
* "form" = {
* "add" = "Drupal\image\Form\ImageStyleAddForm",
* "edit" = "Drupal\image\Form\ImageStyleEditForm",
* "delete" = "Drupal\image\Form\ImageStyleDeleteForm",
* "flush" = "Drupal\image\Form\ImageStyleFlushForm"
* },
* "list_builder" = "Drupal\image\ImageStyleListBuilder",
* "storage" = "Drupal\image\ImageStyleStorage",
* },
* admin_permission = "administer image styles",
* config_prefix = "style",
* entity_keys = {
* "id" = "name",
* "label" = "label"
* },
* links = {
* "flush-form" = "/admin/config/media/image-styles/manage/{image_style}/flush",
* "edit-form" = "/admin/config/media/image-styles/manage/{image_style}",
* "delete-form" = "/admin/config/media/image-styles/manage/{image_style}/delete",
* "collection" = "/admin/config/media/image-styles",
* },
* config_export = {
* "name",
* "label",
* "effects",
* }
* )
*/
class ImageStyle extends ConfigEntityBase implements ImageStyleInterface, EntityWithPluginCollectionInterface {
/**
* The name of the image style.
*
* @var string
*/
protected $name;
/**
* The image style label.
*
* @var string
*/
protected $label;
/**
* The array of image effects for this image style.
*
* @var array
*/
protected $effects = [];
/**
* Holds the collection of image effects that are used by this image style.
*
* @var \Drupal\image\ImageEffectPluginCollection
*/
protected $effectsCollection;
/**
* {@inheritdoc}
*/
public function id() {
return $this->name;
}
/**
* {@inheritdoc}
*/
public function postSave(EntityStorageInterface $storage, $update = TRUE) {
parent::postSave($storage, $update);
if ($update) {
if (!empty($this->original) && $this->id() !== $this->original->id()) {
// The old image style name needs flushing after a rename.
$this->original->flush();
// Update field settings if necessary.
if (!$this->isSyncing()) {
static::replaceImageStyle($this);
}
}
else {
// Flush image style when updating without changing the name.
$this->flush();
}
}
}
/**
* {@inheritdoc}
*/
public static function postDelete(EntityStorageInterface $storage, array $entities) {
parent::postDelete($storage, $entities);
/** @var \Drupal\image\ImageStyleInterface[] $entities */
foreach ($entities as $style) {
// Flush cached media for the deleted style.
$style->flush();
// Clear the replacement ID, if one has been previously stored.
/** @var \Drupal\image\ImageStyleStorageInterface $storage */
$storage->clearReplacementId($style->id());
}
}
/**
* Update field settings if the image style name is changed.
*
* @param \Drupal\image\ImageStyleInterface $style
* The image style.
*/
protected static function replaceImageStyle(ImageStyleInterface $style) {
if ($style->id() != $style->getOriginalId()) {
// Loop through all entity displays looking for formatters / widgets using
// the image style.
foreach (EntityViewDisplay::loadMultiple() as $display) {
foreach ($display->getComponents() as $name => $options) {
if (isset($options['type']) && $options['type'] == 'image' && $options['settings']['image_style'] == $style->getOriginalId()) {
$options['settings']['image_style'] = $style->id();
$display->setComponent($name, $options)
->save();
}
}
}
foreach (EntityFormDisplay::loadMultiple() as $display) {
foreach ($display->getComponents() as $name => $options) {
if (isset($options['type']) && $options['type'] == 'image_image' && $options['settings']['preview_image_style'] == $style->getOriginalId()) {
$options['settings']['preview_image_style'] = $style->id();
$display->setComponent($name, $options)
->save();
}
}
}
}
}
/**
* {@inheritdoc}
*/
public function buildUri($uri) {
$source_scheme = $scheme = StreamWrapperManager::getScheme($uri);
$default_scheme = $this->fileDefaultScheme();
if ($source_scheme) {
$path = StreamWrapperManager::getTarget($uri);
// The scheme of derivative image files only needs to be computed for
// source files not stored in the default scheme.
if ($source_scheme != $default_scheme) {
$class = $this->getStreamWrapperManager()->getClass($source_scheme);
$is_writable = NULL;
if ($class) {
$is_writable = $class::getType() & StreamWrapperInterface::WRITE;
}
// Compute the derivative URI scheme. Derivatives created from writable
// source stream wrappers will inherit the scheme. Derivatives created
// from read-only stream wrappers will fall-back to the default scheme.
$scheme = $is_writable ? $source_scheme : $default_scheme;
}
}
else {
$path = $uri;
$source_scheme = $scheme = $default_scheme;
}
return "$scheme://styles/{$this->id()}/$source_scheme/{$this->addExtension($path)}";
}
/**
* {@inheritdoc}
*/
public function buildUrl($path, $clean_urls = NULL) {
/** @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $stream_wrapper_manager */
$stream_wrapper_manager = \Drupal::service('stream_wrapper_manager');
$uri = $stream_wrapper_manager->normalizeUri($this->buildUri($path));
// The token query is added even if the
// 'image.settings:allow_insecure_derivatives' configuration is TRUE, so
// that the emitted links remain valid if it is changed back to the default
// FALSE. However, sites which need to prevent the token query from being
// emitted at all can additionally set the
// 'image.settings:suppress_itok_output' configuration to TRUE to achieve
// that (if both are set, the security token will neither be emitted in the
// image derivative URL nor checked for in
// \Drupal\image\ImageStyleInterface::deliver()).
$token_query = [];
if (!\Drupal::config('image.settings')->get('suppress_itok_output')) {
// The passed $path variable can be either a relative path or a full URI.
if (!$stream_wrapper_manager::getScheme($path)) {
$path = \Drupal::config('system.file')->get('default_scheme') . '://' . $path;
}
$original_uri = $stream_wrapper_manager->normalizeUri($path);
$token_query = [IMAGE_DERIVATIVE_TOKEN => $this->getPathToken($original_uri)];
}
if ($clean_urls === NULL) {
// Assume clean URLs unless the request tells us otherwise.
$clean_urls = TRUE;
try {
$request = \Drupal::request();
$clean_urls = RequestHelper::isCleanUrl($request);
}
catch (ServiceNotFoundException $e) {
}
}
// If not using clean URLs, the image derivative callback is only available
// with the script path. If the file does not exist, use Url::fromUri() to
// ensure that it is included. Once the file exists it's fine to fall back
// to the actual file path, this avoids bootstrapping PHP once the files are
// built.
if ($clean_urls === FALSE && $stream_wrapper_manager::getScheme($uri) == 'public' && !file_exists($uri)) {
$directory_path = $stream_wrapper_manager->getViaUri($uri)->getDirectoryPath();
return Url::fromUri('base:' . $directory_path . '/' . $stream_wrapper_manager::getTarget($uri), ['absolute' => TRUE, 'query' => $token_query])->toString();
}
/** @var \Drupal\Core\File\FileUrlGeneratorInterface $file_url_generator */
$file_url_generator = \Drupal::service('file_url_generator');
$file_url = $file_url_generator->generateAbsoluteString($uri);
// Append the query string with the token, if necessary.
if ($token_query) {
$file_url .= (str_contains($file_url, '?') ? '&' : '?') . UrlHelper::buildQuery($token_query);
}
return $file_url;
}
/**
* {@inheritdoc}
*/
public function flush($path = NULL) {
// A specific image path has been provided. Flush only that derivative.
/** @var \Drupal\Core\File\FileSystemInterface $file_system */
$file_system = \Drupal::service('file_system');
if (isset($path)) {
$derivative_uri = $this->buildUri($path);
if (file_exists($derivative_uri)) {
try {
$file_system->delete($derivative_uri);
}
catch (FileException $e) {
// Ignore failed deletes.
}
}
}
else {
// Delete the style directory in each registered wrapper.
$wrappers = $this->getStreamWrapperManager()->getWrappers(StreamWrapperInterface::WRITE_VISIBLE);
foreach ($wrappers as $wrapper => $wrapper_data) {
if (file_exists($directory = $wrapper . '://styles/' . $this->id())) {
try {
$file_system->deleteRecursive($directory);
}
catch (FileException $e) {
// Ignore failed deletes.
}
}
}
}
// Let other modules update as necessary on flush.
$module_handler = \Drupal::moduleHandler();
$module_handler->invokeAll('image_style_flush', [$this, $path]);
// Clear caches when the complete image style is flushed,
// so that field formatters may be added for this style.
if (!isset($path)) {
\Drupal::service('theme.registry')->reset();
Cache::invalidateTags($this->getCacheTagsToInvalidate());
}
return $this;
}
/**
* {@inheritdoc}
*/
public function createDerivative($original_uri, $derivative_uri) {
// If the source file doesn't exist, return FALSE without creating folders.
$image = $this->getImageFactory()->get($original_uri);
if (!$image->isValid()) {
return FALSE;
}
// Get the folder for the final location of this style.
$directory = \Drupal::service('file_system')->dirname($derivative_uri);
// Build the destination folder tree if it doesn't already exist.
if (!\Drupal::service('file_system')->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS)) {
\Drupal::logger('image')->error('Failed to create style directory: %directory', ['%directory' => $directory]);
return FALSE;
}
foreach ($this->getEffects() as $effect) {
$effect->applyEffect($image);
}
if (!$image->save($derivative_uri)) {
if (file_exists($derivative_uri)) {
\Drupal::logger('image')->error('Cached image file %destination already exists. There may be an issue with your rewrite configuration.', ['%destination' => $derivative_uri]);
}
return FALSE;
}
return TRUE;
}
/**
* {@inheritdoc}
*/
public function transformDimensions(array &$dimensions, $uri) {
foreach ($this->getEffects() as $effect) {
$effect->transformDimensions($dimensions, $uri);
}
}
/**
* {@inheritdoc}
*/
public function getDerivativeExtension($extension) {
foreach ($this->getEffects() as $effect) {
$extension = $effect->getDerivativeExtension($extension);
}
return $extension;
}
/**
* {@inheritdoc}
*/
public function getPathToken($uri) {
// Return the first 8 characters.
return substr(Crypt::hmacBase64($this->id() . ':' . $this->addExtension($uri), $this->getPrivateKey() . $this->getHashSalt()), 0, 8);
}
/**
* {@inheritdoc}
*/
public function deleteImageEffect(ImageEffectInterface $effect) {
$this->getEffects()->removeInstanceId($effect->getUuid());
$this->save();
return $this;
}
/**
* {@inheritdoc}
*/
public function supportsUri($uri) {
// Only support the URI if its extension is supported by the current image
// toolkit.
return in_array(
mb_strtolower(pathinfo($uri, PATHINFO_EXTENSION)),
$this->getImageFactory()->getSupportedExtensions()
);
}
/**
* {@inheritdoc}
*/
public function getEffect($effect) {
return $this->getEffects()->get($effect);
}
/**
* {@inheritdoc}
*/
public function getEffects() {
if (!$this->effectsCollection) {
$this->effectsCollection = new ImageEffectPluginCollection($this->getImageEffectPluginManager(), $this->effects);
$this->effectsCollection->sort();
}
return $this->effectsCollection;
}
/**
* {@inheritdoc}
*/
public function getPluginCollections() {
return ['effects' => $this->getEffects()];
}
/**
* {@inheritdoc}
*/
public function addImageEffect(array $configuration) {
$configuration['uuid'] = $this->uuidGenerator()->generate();
$this->getEffects()->addInstanceId($configuration['uuid'], $configuration);
return $configuration['uuid'];
}
/**
* {@inheritdoc}
*/
public function getReplacementID() {
/** @var \Drupal\image\ImageStyleStorageInterface $storage */
$storage = $this->entityTypeManager()->getStorage($this->getEntityTypeId());
return $storage->getReplacementId($this->id());
}
/**
* {@inheritdoc}
*/
public function getName() {
return $this->get('name');
}
/**
* {@inheritdoc}
*/
public function setName($name) {
$this->set('name', $name);
return $this;
}
/**
* Returns the image effect plugin manager.
*
* @return \Drupal\Component\Plugin\PluginManagerInterface
* The image effect plugin manager.
*/
protected function getImageEffectPluginManager() {
return \Drupal::service('plugin.manager.image.effect');
}
/**
* Returns the image factory.
*
* @return \Drupal\Core\Image\ImageFactory
* The image factory.
*/
protected function getImageFactory() {
return \Drupal::service('image.factory');
}
/**
* Gets the Drupal private key.
*
* @return string
* The Drupal private key.
*/
protected function getPrivateKey() {
return \Drupal::service('private_key')->get();
}
/**
* Gets a salt useful for hardening against SQL injection.
*
* @return string
* A salt based on information in settings.php, not in the database.
*
* @throws \RuntimeException
*/
protected function getHashSalt() {
return Settings::getHashSalt();
}
/**
* Adds an extension to a path.
*
* If this image style changes the extension of the derivative, this method
* adds the new extension to the given path. This way we avoid filename
* clashes while still allowing us to find the source image.
*
* @param string $path
* The path to add the extension to.
*
* @return string
* The given path if this image style doesn't change its extension, or the
* path with the added extension if it does.
*/
protected function addExtension($path) {
$original_extension = pathinfo($path, PATHINFO_EXTENSION);
$extension = $this->getDerivativeExtension($original_extension);
if ($original_extension !== $extension) {
$path .= '.' . $extension;
}
return $path;
}
/**
* Provides a wrapper to allow unit testing.
*
* Gets the default file stream implementation.
*
* @return string
* 'public', 'private' or any other file scheme defined as the default.
*/
protected function fileDefaultScheme() {
return \Drupal::config('system.file')->get('default_scheme');
}
/**
* Gets the stream wrapper manager service.
*
* @return \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface
* The stream wrapper manager service
*
* @todo Properly inject this service in Drupal 9.0.x.
*/
protected function getStreamWrapperManager() {
return \Drupal::service('stream_wrapper_manager');
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Drupal\image\Form;
use Drupal\Core\Form\FormStateInterface;
use Drupal\image\ImageEffectManager;
use Drupal\image\ImageStyleInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides an add form for image effects.
*
* @internal
*/
class ImageEffectAddForm extends ImageEffectFormBase {
/**
* The image effect manager.
*
* @var \Drupal\image\ImageEffectManager
*/
protected $effectManager;
/**
* Constructs a new ImageEffectAddForm.
*
* @param \Drupal\image\ImageEffectManager $effect_manager
* The image effect manager.
*/
public function __construct(ImageEffectManager $effect_manager) {
$this->effectManager = $effect_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('plugin.manager.image.effect')
);
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, ?ImageStyleInterface $image_style = NULL, $image_effect = NULL) {
$form = parent::buildForm($form, $form_state, $image_style, $image_effect);
$form['#title'] = $this->t('Add %label effect to style %style', [
'%label' => $this->imageEffect->label(),
'%style' => $image_style->label(),
]);
$form['actions']['submit']['#value'] = $this->t('Add effect');
return $form;
}
/**
* {@inheritdoc}
*/
protected function prepareImageEffect($image_effect) {
$image_effect = $this->effectManager->createInstance($image_effect);
// Set the initial weight so this effect comes last.
$image_effect->setWeight(count($this->imageStyle->getEffects()));
return $image_effect;
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace Drupal\image\Form;
use Drupal\Core\Form\ConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\image\ImageStyleInterface;
/**
* Form for deleting an image effect.
*
* @internal
*/
class ImageEffectDeleteForm extends ConfirmFormBase {
/**
* The image style containing the image effect to be deleted.
*
* @var \Drupal\image\ImageStyleInterface
*/
protected $imageStyle;
/**
* The image effect to be deleted.
*
* @var \Drupal\image\ImageEffectInterface
*/
protected $imageEffect;
/**
* {@inheritdoc}
*/
public function getQuestion() {
return $this->t('Are you sure you want to delete the @effect effect from the %style style?', ['%style' => $this->imageStyle->label(), '@effect' => $this->imageEffect->label()]);
}
/**
* {@inheritdoc}
*/
public function getConfirmText() {
return $this->t('Delete');
}
/**
* {@inheritdoc}
*/
public function getCancelUrl() {
return $this->imageStyle->toUrl('edit-form');
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'image_effect_delete_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, ?ImageStyleInterface $image_style = NULL, $image_effect = NULL) {
$this->imageStyle = $image_style;
$this->imageEffect = $this->imageStyle->getEffect($image_effect);
return parent::buildForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->imageStyle->deleteImageEffect($this->imageEffect);
$this->messenger()->addStatus($this->t('The image effect %name has been deleted.', ['%name' => $this->imageEffect->label()]));
$form_state->setRedirectUrl($this->imageStyle->toUrl('edit-form'));
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Drupal\image\Form;
use Drupal\Core\Form\FormStateInterface;
use Drupal\image\ImageStyleInterface;
/**
* Provides an edit form for image effects.
*
* @internal
*/
class ImageEffectEditForm extends ImageEffectFormBase {
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, ?ImageStyleInterface $image_style = NULL, $image_effect = NULL) {
$form = parent::buildForm($form, $form_state, $image_style, $image_effect);
$form['#title'] = $this->t('Edit %label effect on style %style', [
'%label' => $this->imageEffect->label(),
'%style' => $image_style->label(),
]);
$form['actions']['submit']['#value'] = $this->t('Update effect');
return $form;
}
/**
* {@inheritdoc}
*/
protected function prepareImageEffect($image_effect) {
return $this->imageStyle->getEffect($image_effect);
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace Drupal\image\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\SubformState;
use Drupal\image\ConfigurableImageEffectInterface;
use Drupal\image\ImageStyleInterface;
use Drupal\Component\Plugin\Exception\PluginNotFoundException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Provides a base form for image effects.
*/
abstract class ImageEffectFormBase extends FormBase {
/**
* The image style.
*
* @var \Drupal\image\ImageStyleInterface
*/
protected $imageStyle;
/**
* The image effect.
*
* @var \Drupal\image\ImageEffectInterface|\Drupal\image\ConfigurableImageEffectInterface
*/
protected $imageEffect;
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'image_effect_form';
}
/**
* {@inheritdoc}
*
* @param array $form
* A nested array form elements comprising the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param \Drupal\image\ImageStyleInterface $image_style
* The image style.
* @param string $image_effect
* The image effect ID.
*
* @return array
* The form structure.
*
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
*/
public function buildForm(array $form, FormStateInterface $form_state, ?ImageStyleInterface $image_style = NULL, $image_effect = NULL) {
$this->imageStyle = $image_style;
try {
$this->imageEffect = $this->prepareImageEffect($image_effect);
}
catch (PluginNotFoundException $e) {
throw new NotFoundHttpException("Invalid effect id: '$image_effect'.");
}
$request = $this->getRequest();
if (!($this->imageEffect instanceof ConfigurableImageEffectInterface)) {
throw new NotFoundHttpException();
}
$form['#attached']['library'][] = 'image/admin';
$form['uuid'] = [
'#type' => 'value',
'#value' => $this->imageEffect->getUuid(),
];
$form['id'] = [
'#type' => 'value',
'#value' => $this->imageEffect->getPluginId(),
];
$form['data'] = [];
$subform_state = SubformState::createForSubform($form['data'], $form, $form_state);
$form['data'] = $this->imageEffect->buildConfigurationForm($form['data'], $subform_state);
$form['data']['#tree'] = TRUE;
// Check the URL for a weight, then the image effect, otherwise use default.
$form['weight'] = [
'#type' => 'hidden',
'#value' => $request->query->has('weight') ? (int) $request->query->get('weight') : $this->imageEffect->getWeight(),
];
$form['actions'] = ['#type' => 'actions'];
$form['actions']['submit'] = [
'#type' => 'submit',
'#button_type' => 'primary',
];
$form['actions']['cancel'] = [
'#type' => 'link',
'#title' => $this->t('Cancel'),
'#url' => $this->imageStyle->toUrl('edit-form'),
'#attributes' => ['class' => ['button']],
];
return $form;
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
// The image effect configuration is stored in the 'data' key in the form,
// pass that through for validation.
$this->imageEffect->validateConfigurationForm($form['data'], SubformState::createForSubform($form['data'], $form, $form_state));
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$form_state->cleanValues();
// The image effect configuration is stored in the 'data' key in the form,
// pass that through for submission.
$this->imageEffect->submitConfigurationForm($form['data'], SubformState::createForSubform($form['data'], $form, $form_state));
$this->imageEffect->setWeight($form_state->getValue('weight'));
if (!$this->imageEffect->getUuid()) {
$this->imageStyle->addImageEffect($this->imageEffect->getConfiguration());
}
$this->imageStyle->save();
$this->messenger()->addStatus($this->t('The image effect was successfully applied.'));
$form_state->setRedirectUrl($this->imageStyle->toUrl('edit-form'));
}
/**
* Converts an image effect ID into an object.
*
* @param string $image_effect
* The image effect ID.
*
* @return \Drupal\image\ImageEffectInterface
* The image effect object.
*/
abstract protected function prepareImageEffect($image_effect);
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Drupal\image\Form;
use Drupal\Core\Form\FormStateInterface;
/**
* Controller for image style addition forms.
*
* @internal
*/
class ImageStyleAddForm extends ImageStyleFormBase {
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
parent::submitForm($form, $form_state);
$this->messenger()->addStatus($this->t('Style %name was created.', ['%name' => $this->entity->label()]));
}
/**
* {@inheritdoc}
*/
public function actions(array $form, FormStateInterface $form_state) {
$actions = parent::actions($form, $form_state);
$actions['submit']['#value'] = $this->t('Create new style');
return $actions;
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace Drupal\image\Form;
use Drupal\Core\Entity\EntityDeleteForm;
use Drupal\Core\Form\FormStateInterface;
/**
* Creates a form to delete an image style.
*
* @internal
*/
class ImageStyleDeleteForm extends EntityDeleteForm {
/**
* Replacement options.
*
* @var array
*/
protected $replacementOptions;
/**
* {@inheritdoc}
*/
public function getQuestion() {
return $this->t('Optionally select a style before deleting %style', ['%style' => $this->entity->label()]);
}
/**
* {@inheritdoc}
*/
public function getDescription() {
if (count($this->getReplacementOptions()) > 1) {
return $this->t('If this style is in use on the site, you may select another style to replace it. All images that have been generated for this style will be permanently deleted. If no replacement style is selected, the dependent configurations might need manual reconfiguration.');
}
return $this->t('All images that have been generated for this style will be permanently deleted. The dependent configurations might need manual reconfiguration.');
}
/**
* {@inheritdoc}
*/
public function form(array $form, FormStateInterface $form_state) {
$replacement_styles = $this->getReplacementOptions();
// If there are non-empty options in the list, allow the user to optionally
// pick up a replacement.
if (count($replacement_styles) > 1) {
$form['replacement'] = [
'#type' => 'select',
'#title' => $this->t('Replacement style'),
'#options' => $replacement_styles,
'#empty_option' => $this->t('- No replacement -'),
'#weight' => -5,
];
}
return parent::form($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
// Save a selected replacement in the image style storage. It will be used
// later, in the same request, when resolving dependencies.
if ($replacement = $form_state->getValue('replacement')) {
/** @var \Drupal\image\ImageStyleStorageInterface $storage */
$storage = $this->entityTypeManager->getStorage($this->entity->getEntityTypeId());
$storage->setReplacementId($this->entity->id(), $replacement);
}
parent::submitForm($form, $form_state);
}
/**
* Returns a list of image style replacement options.
*
* @return array
* An option list suitable for the form select '#options'.
*/
protected function getReplacementOptions() {
if (!isset($this->replacementOptions)) {
$this->replacementOptions = array_diff_key(image_style_options(), [$this->getEntity()->id() => '']);
}
return $this->replacementOptions;
}
}

View File

@@ -0,0 +1,276 @@
<?php
namespace Drupal\image\Form;
use Drupal\Component\Utility\Unicode;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
use Drupal\image\ConfigurableImageEffectInterface;
use Drupal\image\ImageEffectManager;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Controller for image style edit form.
*
* @internal
*/
class ImageStyleEditForm extends ImageStyleFormBase {
/**
* The image effect manager service.
*
* @var \Drupal\image\ImageEffectManager
*/
protected $imageEffectManager;
/**
* Constructs an ImageStyleEditForm object.
*
* @param \Drupal\Core\Entity\EntityStorageInterface $image_style_storage
* The storage.
* @param \Drupal\image\ImageEffectManager $image_effect_manager
* The image effect manager service.
*/
public function __construct(EntityStorageInterface $image_style_storage, ImageEffectManager $image_effect_manager) {
parent::__construct($image_style_storage);
$this->imageEffectManager = $image_effect_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager')->getStorage('image_style'),
$container->get('plugin.manager.image.effect')
);
}
/**
* {@inheritdoc}
*/
public function form(array $form, FormStateInterface $form_state) {
$user_input = $form_state->getUserInput();
$form['#title'] = $this->t('Edit style %name', ['%name' => $this->entity->label()]);
$form['#tree'] = TRUE;
$form['#attached']['library'][] = 'image/admin';
// Show the thumbnail preview.
$preview_arguments = ['#theme' => 'image_style_preview', '#style' => $this->entity];
$form['preview'] = [
'#type' => 'item',
'#title' => $this->t('Preview'),
'#markup' => \Drupal::service('renderer')->render($preview_arguments),
// Render preview above parent elements.
'#weight' => -5,
];
// Build the list of existing image effects for this image style.
$form['effects'] = [
'#type' => 'table',
'#header' => [
$this->t('Effect'),
$this->t('Weight'),
$this->t('Operations'),
],
'#tabledrag' => [
[
'action' => 'order',
'relationship' => 'sibling',
'group' => 'image-effect-order-weight',
],
],
'#attributes' => [
'id' => 'image-style-effects',
],
'#empty' => $this->t('There are currently no effects in this style. Add one by selecting an option below.'),
// Render effects below parent elements.
'#weight' => 5,
];
foreach ($this->entity->getEffects() as $effect) {
$key = $effect->getUuid();
$form['effects'][$key]['#attributes']['class'][] = 'draggable';
$form['effects'][$key]['#weight'] = isset($user_input['effects']) ? $user_input['effects'][$key]['weight'] : NULL;
$form['effects'][$key]['effect'] = [
'#tree' => FALSE,
'data' => [
'label' => [
'#plain_text' => $effect->label(),
],
],
];
$summary = $effect->getSummary();
if (!empty($summary)) {
$summary['#prefix'] = ' ';
$form['effects'][$key]['effect']['data']['summary'] = $summary;
}
$form['effects'][$key]['weight'] = [
'#type' => 'weight',
'#title' => $this->t('Weight for @title', ['@title' => $effect->label()]),
'#title_display' => 'invisible',
'#default_value' => $effect->getWeight(),
'#attributes' => [
'class' => ['image-effect-order-weight'],
],
];
$links = [];
$is_configurable = $effect instanceof ConfigurableImageEffectInterface;
if ($is_configurable) {
$links['edit'] = [
'title' => $this->t('Edit'),
'url' => Url::fromRoute('image.effect_edit_form', [
'image_style' => $this->entity->id(),
'image_effect' => $key,
]),
];
}
$links['delete'] = [
'title' => $this->t('Delete'),
'url' => Url::fromRoute('image.effect_delete', [
'image_style' => $this->entity->id(),
'image_effect' => $key,
]),
];
$form['effects'][$key]['operations'] = [
'#type' => 'operations',
'#links' => $links,
];
}
// Build the new image effect addition form and add it to the effect list.
$new_effect_options = [];
$effects = $this->imageEffectManager->getDefinitions();
uasort($effects, function ($a, $b) {
return Unicode::strcasecmp($a['label'], $b['label']);
});
foreach ($effects as $effect => $definition) {
$new_effect_options[$effect] = $definition['label'];
}
$form['effects']['new'] = [
'#tree' => FALSE,
'#weight' => $user_input['weight'] ?? NULL,
'#attributes' => ['class' => ['draggable']],
];
$form['effects']['new']['effect'] = [
'data' => [
'new' => [
'#type' => 'select',
'#title' => $this->t('Effect'),
'#title_display' => 'invisible',
'#options' => $new_effect_options,
'#empty_option' => $this->t('Select a new effect'),
],
[
'add' => [
'#type' => 'submit',
'#value' => $this->t('Add'),
'#validate' => ['::effectValidate'],
'#submit' => ['::submitForm', '::effectSave'],
],
],
],
'#prefix' => '<div class="image-style-new">',
'#suffix' => '</div>',
];
$form['effects']['new']['weight'] = [
'#type' => 'weight',
'#title' => $this->t('Weight for new effect'),
'#title_display' => 'invisible',
'#default_value' => count($this->entity->getEffects()) + 1,
'#attributes' => ['class' => ['image-effect-order-weight']],
];
$form['effects']['new']['operations'] = [
'data' => [],
];
return parent::form($form, $form_state);
}
/**
* Validate handler for image effect.
*/
public function effectValidate($form, FormStateInterface $form_state) {
if (!$form_state->getValue('new')) {
$form_state->setErrorByName('new', $this->t('Select an effect to add.'));
}
}
/**
* Submit handler for image effect.
*/
public function effectSave($form, FormStateInterface $form_state) {
$this->save($form, $form_state);
// Check if this field has any configuration options.
$effect = $this->imageEffectManager->getDefinition($form_state->getValue('new'));
// Load the configuration form for this option.
if (is_subclass_of($effect['class'], '\Drupal\image\ConfigurableImageEffectInterface')) {
$form_state->setRedirect(
'image.effect_add_form',
[
'image_style' => $this->entity->id(),
'image_effect' => $form_state->getValue('new'),
],
['query' => ['weight' => $form_state->getValue('weight')]]
);
$form_state->setIgnoreDestination();
}
// If there's no form, immediately add the image effect.
else {
$effect = [
'id' => $effect['id'],
'data' => [],
'weight' => $form_state->getValue('weight'),
];
$effect_id = $this->entity->addImageEffect($effect);
$this->entity->save();
if (!empty($effect_id)) {
$this->messenger()->addStatus($this->t('The image effect was successfully applied.'));
}
}
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
// Update image effect weights.
if (!$form_state->isValueEmpty('effects')) {
$this->updateEffectWeights($form_state->getValue('effects'));
}
parent::submitForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
parent::save($form, $form_state);
$this->messenger()->addStatus($this->t('Changes to the style have been saved.'));
}
/**
* Updates image effect weights.
*
* @param array $effects
* Associative array with effects having effect uuid as keys and array
* with effect data as values.
*/
protected function updateEffectWeights(array $effects) {
foreach ($effects as $uuid => $effect_data) {
if ($this->entity->getEffects()->has($uuid)) {
$this->entity->getEffect($uuid)->setWeight($effect_data['weight']);
}
}
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Drupal\image\Form;
use Drupal\Core\Entity\EntityConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
/**
* Form controller for image style flush.
*
* @internal
*/
class ImageStyleFlushForm extends EntityConfirmFormBase {
/**
* {@inheritdoc}
*/
public function getQuestion() {
return $this->t('Are you sure you want to apply the updated %name image effect to all images?', ['%name' => $this->entity->label()]);
}
/**
* {@inheritdoc}
*/
public function getDescription() {
return $this->t('This operation does not change the original images but the copies created for this style will be recreated.');
}
/**
* {@inheritdoc}
*/
public function getConfirmText() {
return $this->t('Flush');
}
/**
* {@inheritdoc}
*/
public function getCancelUrl() {
return $this->entity->toUrl('collection');
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->entity->flush();
$this->messenger()->addStatus($this->t('The image style %name has been flushed.', ['%name' => $this->entity->label()]));
$form_state->setRedirectUrl($this->getCancelUrl());
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace Drupal\image\Form;
use Drupal\Core\Entity\EntityForm;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Base form for image style add and edit forms.
*/
abstract class ImageStyleFormBase extends EntityForm {
/**
* The entity being used by this form.
*
* @var \Drupal\image\ImageStyleInterface
*/
protected $entity;
/**
* The image style entity storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $imageStyleStorage;
/**
* Constructs a base class for image style add and edit forms.
*
* @param \Drupal\Core\Entity\EntityStorageInterface $image_style_storage
* The image style entity storage.
*/
public function __construct(EntityStorageInterface $image_style_storage) {
$this->imageStyleStorage = $image_style_storage;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager')->getStorage('image_style')
);
}
/**
* {@inheritdoc}
*/
public function form(array $form, FormStateInterface $form_state) {
$form['label'] = [
'#type' => 'textfield',
'#title' => $this->t('Image style name'),
'#default_value' => $this->entity->label(),
'#required' => TRUE,
];
$form['name'] = [
'#type' => 'machine_name',
'#machine_name' => [
'exists' => [$this->imageStyleStorage, 'load'],
],
'#default_value' => $this->entity->id(),
'#required' => TRUE,
];
return parent::form($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
parent::save($form, $form_state);
$form_state->setRedirectUrl($this->entity->toUrl('edit-form'));
}
}

View File

@@ -0,0 +1,168 @@
<?php
namespace Drupal\image;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Plugin\PluginBase;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a base class for image effects.
*
* @see \Drupal\image\Annotation\ImageEffect
* @see \Drupal\image\ImageEffectInterface
* @see \Drupal\image\ConfigurableImageEffectInterface
* @see \Drupal\image\ConfigurableImageEffectBase
* @see \Drupal\image\ImageEffectManager
* @see plugin_api
*/
abstract class ImageEffectBase extends PluginBase implements ImageEffectInterface, ContainerFactoryPluginInterface {
/**
* The image effect ID.
*
* @var string
*/
protected $uuid;
/**
* The weight of the image effect.
*
* @var int|string
*/
protected $weight = '';
/**
* A logger instance.
*
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, LoggerInterface $logger) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->setConfiguration($configuration);
$this->logger = $logger;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('logger.factory')->get('image')
);
}
/**
* {@inheritdoc}
*/
public function transformDimensions(array &$dimensions, $uri) {
// Most image effects will not change the dimensions. This base
// implementation represents this behavior. Override this method if your
// image effect does change the dimensions.
}
/**
* {@inheritdoc}
*/
public function getDerivativeExtension($extension) {
// Most image effects will not change the extension. This base
// implementation represents this behavior. Override this method if your
// image effect does change the extension.
return $extension;
}
/**
* {@inheritdoc}
*/
public function getSummary() {
return [
'#markup' => '',
'#effect' => [
'id' => $this->pluginDefinition['id'],
'label' => $this->label(),
'description' => $this->pluginDefinition['description'],
],
];
}
/**
* {@inheritdoc}
*/
public function label() {
return $this->pluginDefinition['label'];
}
/**
* {@inheritdoc}
*/
public function getUuid() {
return $this->uuid;
}
/**
* {@inheritdoc}
*/
public function setWeight($weight) {
$this->weight = $weight;
return $this;
}
/**
* {@inheritdoc}
*/
public function getWeight() {
return $this->weight;
}
/**
* {@inheritdoc}
*/
public function getConfiguration() {
return [
'uuid' => $this->getUuid(),
'id' => $this->getPluginId(),
'weight' => $this->getWeight(),
'data' => $this->configuration,
];
}
/**
* {@inheritdoc}
*/
public function setConfiguration(array $configuration) {
$configuration += [
'data' => [],
'uuid' => '',
'weight' => '',
];
$this->configuration = $configuration['data'] + $this->defaultConfiguration();
$this->uuid = $configuration['uuid'];
$this->weight = $configuration['weight'];
return $this;
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [];
}
/**
* {@inheritdoc}
*/
public function calculateDependencies() {
return [];
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace Drupal\image;
use Drupal\Component\Plugin\ConfigurableInterface;
use Drupal\Component\Plugin\DependentPluginInterface;
use Drupal\Component\Plugin\PluginInspectionInterface;
use Drupal\Core\Image\ImageInterface;
/**
* Defines the interface for image effects.
*
* @see \Drupal\image\Annotation\ImageEffect
* @see \Drupal\image\ImageEffectBase
* @see \Drupal\image\ConfigurableImageEffectInterface
* @see \Drupal\image\ConfigurableImageEffectBase
* @see \Drupal\image\ImageEffectManager
* @see plugin_api
*/
interface ImageEffectInterface extends PluginInspectionInterface, ConfigurableInterface, DependentPluginInterface {
/**
* Applies an image effect to the image object.
*
* @param \Drupal\Core\Image\ImageInterface $image
* An image file object.
*
* @return bool
* TRUE on success. FALSE if unable to perform the image effect on the image.
*/
public function applyEffect(ImageInterface $image);
/**
* Determines the dimensions of the styled image.
*
* @param array &$dimensions
* Dimensions to be modified - an array with the following keys:
* - width: the width in pixels, or NULL if unknown
* - height: the height in pixels, or NULL if unknown
* When either of the dimensions are NULL, the corresponding HTML attribute
* will be omitted when an image style using this image effect is used.
* @param string $uri
* Original image file URI. It is passed in to allow an effect to
* optionally use this information to retrieve additional image metadata
* to determine dimensions of the styled image.
* ImageEffectInterface::transformDimensions key objective is to calculate
* styled image dimensions without performing actual image operations, so
* be aware that performing IO on the URI may lead to decrease in
* performance.
*/
public function transformDimensions(array &$dimensions, $uri);
/**
* Returns the extension of the derivative after applying this image effect.
*
* @param string $extension
* The file extension the derivative has before applying.
*
* @return string
* The file extension after applying.
*/
public function getDerivativeExtension($extension);
/**
* Returns a render array summarizing the configuration of the image effect.
*
* @return array
* A render array.
*/
public function getSummary();
/**
* Returns the image effect label.
*
* @return string
* The image effect label.
*/
public function label();
/**
* Returns the unique ID representing the image effect.
*
* @return string
* The image effect ID.
*/
public function getUuid();
/**
* Returns the weight of the image effect.
*
* @return int|string
* Either the integer weight of the image effect, or an empty string.
*/
public function getWeight();
/**
* Sets the weight for this image effect.
*
* @param int $weight
* The weight for this image effect.
*
* @return $this
*/
public function setWeight($weight);
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Drupal\image;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\image\Attribute\ImageEffect;
/**
* Manages image effect plugins.
*
* @see hook_image_effect_info_alter()
* @see \Drupal\image\Annotation\ImageEffect
* @see \Drupal\image\ConfigurableImageEffectInterface
* @see \Drupal\image\ConfigurableImageEffectBase
* @see \Drupal\image\ImageEffectInterface
* @see \Drupal\image\ImageEffectBase
* @see plugin_api
*/
class ImageEffectManager extends DefaultPluginManager {
/**
* Constructs a new ImageEffectManager.
*
* @param \Traversable $namespaces
* An object that implements \Traversable which contains the root paths
* keyed by the corresponding namespace to look for plugin implementations.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
* Cache backend instance to use.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler.
*/
public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
parent::__construct('Plugin/ImageEffect', $namespaces, $module_handler, 'Drupal\image\ImageEffectInterface', ImageEffect::class, 'Drupal\image\Annotation\ImageEffect');
$this->alterInfo('image_effect_info');
$this->setCacheBackend($cache_backend, 'image_effect_plugins');
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Drupal\image;
use Drupal\Core\Plugin\DefaultLazyPluginCollection;
/**
* A collection of image effects.
*/
class ImageEffectPluginCollection extends DefaultLazyPluginCollection {
/**
* {@inheritdoc}
*
* @return \Drupal\image\ImageEffectInterface
*/
public function &get($instance_id) {
return parent::get($instance_id);
}
/**
* {@inheritdoc}
*/
public function sortHelper($aID, $bID) {
return $this->get($aID)->getWeight() <=> $this->get($bID)->getWeight();
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace Drupal\image;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
/**
* Provides an interface defining an image style entity.
*/
interface ImageStyleInterface extends ConfigEntityInterface {
/**
* Returns the image style.
*
* @return string
* The name of the image style.
*/
public function getName();
/**
* Sets the name of the image style.
*
* @param string $name
* The name of the image style.
*
* @return $this
* The class instance this method is called on.
*/
public function setName($name);
/**
* Returns the URI of this image when using this style.
*
* The path returned by this function may not exist. The default generation
* method only creates images when they are requested by a user's browser.
* Modules may implement this method to decide where to place derivatives.
*
* @param string $uri
* The URI or path to the original image.
*
* @return string
* The URI to the image derivative for this style.
*/
public function buildUri($uri);
/**
* Returns the URL of this image derivative for an original image path or URI.
*
* @param string $path
* The path or URI to the original image.
* @param mixed $clean_urls
* (optional) Whether clean URLs are in use.
*
* @return string
* The absolute URL where a style image can be downloaded, suitable for use
* in an <img> tag. Requesting the URL will cause the image to be created.
*
* @see \Drupal\image\Controller\ImageStyleDownloadController::deliver()
* @see \Drupal\Core\File\FileUrlGeneratorInterface::transformRelative()
*/
public function buildUrl($path, $clean_urls = NULL);
/**
* Generates a token to protect an image style derivative.
*
* This prevents unauthorized generation of an image style derivative,
* which can be costly both in CPU time and disk space.
*
* @param string $uri
* The URI of the original image of this style.
*
* @return string
* An eight-character token which can be used to protect image style
* derivatives against denial-of-service attacks.
*/
public function getPathToken($uri);
/**
* Flushes cached media for this style.
*
* @param string $path
* (optional) The original image path or URI. If it's supplied, only this
* image derivative will be flushed.
*
* @return $this
*/
public function flush($path = NULL);
/**
* Creates a new image derivative based on this image style.
*
* Generates an image derivative applying all image effects and saving the
* resulting image.
*
* @param string $original_uri
* Original image file URI.
* @param string $derivative_uri
* Derivative image file URI.
*
* @return bool
* TRUE if an image derivative was generated, or FALSE if the image
* derivative could not be generated.
*/
public function createDerivative($original_uri, $derivative_uri);
/**
* Determines the dimensions of this image style.
*
* Stores the dimensions of this image style into $dimensions associative
* array. Implementations have to provide at least values to next keys:
* - width: Integer with the derivative image width.
* - height: Integer with the derivative image height.
*
* @param array $dimensions
* Associative array passed by reference. Implementations have to store the
* resulting width and height, in pixels.
* @param string $uri
* Original image file URI. It is passed in to allow effects to
* optionally use this information to retrieve additional image metadata
* to determine dimensions of the styled image.
* ImageStyleInterface::transformDimensions key objective is to calculate
* styled image dimensions without performing actual image operations, so
* be aware that performing IO on the URI may lead to decrease in
* performance.
*
* @see ImageEffectInterface::transformDimensions
*/
public function transformDimensions(array &$dimensions, $uri);
/**
* Determines the extension of the derivative without generating it.
*
* @param string $extension
* The file extension of the original image.
*
* @return string
* The extension the derivative image will have, given the extension of the
* original.
*/
public function getDerivativeExtension($extension);
/**
* Returns a specific image effect.
*
* @param string $effect
* The image effect ID.
*
* @return \Drupal\image\ImageEffectInterface
* The image effect object.
*/
public function getEffect($effect);
/**
* Returns the image effects for this style.
*
* @return \Drupal\image\ImageEffectPluginCollection|\Drupal\image\ImageEffectInterface[]
* The image effect plugin collection.
*/
public function getEffects();
/**
* Saves an image effect for this style.
*
* @param array $configuration
* An array of image effect configuration.
*
* @return string
* The image effect ID.
*/
public function addImageEffect(array $configuration);
/**
* Deletes an image effect from this style.
*
* @param \Drupal\image\ImageEffectInterface $effect
* The image effect object.
*
* @return $this
*/
public function deleteImageEffect(ImageEffectInterface $effect);
/**
* Determines if this style can be applied to a given image.
*
* @param string $uri
* The URI of the image.
*
* @return bool
* TRUE if the image is supported, FALSE otherwise.
*/
public function supportsUri($uri);
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Drupal\image;
use Drupal\Core\Config\Entity\ConfigEntityListBuilder;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Url;
/**
* Defines a class to build a listing of image style entities.
*
* @see \Drupal\image\Entity\ImageStyle
*/
class ImageStyleListBuilder extends ConfigEntityListBuilder {
/**
* {@inheritdoc}
*/
public function buildHeader() {
$header['label'] = $this->t('Style name');
return $header + parent::buildHeader();
}
/**
* {@inheritdoc}
*/
public function buildRow(EntityInterface $entity) {
$row['label'] = $entity->label();
return $row + parent::buildRow($entity);
}
/**
* {@inheritdoc}
*/
public function getDefaultOperations(EntityInterface $entity) {
$flush = [
'title' => t('Flush'),
'weight' => 200,
'url' => $entity->toUrl('flush-form'),
];
return parent::getDefaultOperations($entity) + [
'flush' => $flush,
];
}
/**
* {@inheritdoc}
*/
public function render() {
$build = parent::render();
$build['table']['#empty'] = $this->t('There are currently no styles. <a href=":url">Add a new one</a>.', [
':url' => Url::fromRoute('image.style_add')->toString(),
]);
return $build;
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Drupal\image;
use Drupal\Core\Config\Entity\ConfigEntityStorage;
/**
* Storage controller class for "image style" configuration entities.
*/
class ImageStyleStorage extends ConfigEntityStorage implements ImageStyleStorageInterface {
/**
* Image style replacement memory storage.
*
* This value is not stored in the backend. It's used during the deletion of
* an image style to save the replacement image style in the same request. The
* value is used later, when resolving dependencies.
*
* @var string[]
*
* @see \Drupal\image\Form\ImageStyleDeleteForm::submitForm()
*/
protected $replacement = [];
/**
* {@inheritdoc}
*/
public function setReplacementId($name, $replacement) {
$this->replacement[$name] = $replacement;
}
/**
* {@inheritdoc}
*/
public function getReplacementId($name) {
return $this->replacement[$name] ?? NULL;
}
/**
* {@inheritdoc}
*/
public function clearReplacementId($name) {
unset($this->replacement[$name]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Drupal\image;
use Drupal\Core\Config\Entity\ConfigEntityStorageInterface;
/**
* Interface for storage controller for "image style" configuration entities.
*/
interface ImageStyleStorageInterface extends ConfigEntityStorageInterface {
/**
* Stores a replacement ID for an image style being deleted.
*
* The method stores a replacement style to be used by the configuration
* dependency system when an image style is deleted. The replacement style is
* replacing the deleted style in other configuration entities that are
* depending on the image style being deleted.
*
* @param string $name
* The ID of the image style to be deleted.
* @param string $replacement
* The ID of the image style used as replacement.
*/
public function setReplacementId($name, $replacement);
/**
* Retrieves the replacement ID of a deleted image style.
*
* The method is retrieving the value stored by ::setReplacementId().
*
* @param string $name
* The ID of the image style to be replaced.
*
* @return string|null
* The ID of the image style used as replacement, if there's any, or NULL.
*
* @see \Drupal\image\ImageStyleStorageInterface::setReplacementId()
*/
public function getReplacementId($name);
/**
* Clears a replacement ID from the storage.
*
* The method clears the value previously stored with ::setReplacementId().
*
* @param string $name
* The ID of the image style to be replaced.
*
* @see \Drupal\image\ImageStyleStorageInterface::setReplacementId()
*/
public function clearReplacementId($name);
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Drupal\image\PageCache;
use Drupal\Core\PageCache\ResponsePolicyInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Cache policy for image preview page.
*
* This policy rule denies caching of responses generated by the
* entity.image.preview route.
*/
class DenyPrivateImageStyleDownload implements ResponsePolicyInterface {
/**
* The current route match.
*
* @var \Drupal\Core\Routing\RouteMatchInterface
*/
protected $routeMatch;
/**
* Constructs a deny image preview page cache policy.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The current route match.
*/
public function __construct(RouteMatchInterface $route_match) {
$this->routeMatch = $route_match;
}
/**
* {@inheritdoc}
*/
public function check(Response $response, Request $request) {
if ($this->routeMatch->getRouteName() === 'image.style_private') {
return static::DENY;
}
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace Drupal\image\PathProcessor;
use Drupal\Core\PathProcessor\InboundPathProcessorInterface;
use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Defines a path processor to rewrite image styles URLs.
*
* As the route system does not allow arbitrary amount of parameters convert
* the file path to a query parameter on the request.
*
* This processor handles two different cases:
* - public image styles: In order to allow the webserver to serve these files
* directly, the route is registered under the same path as the image style so
* it took over the first generation. Therefore the path processor converts
* the file path to a query parameter.
* - private image styles: In contrast to public image styles, private
* derivatives are already using system/files/styles. Similar to public image
* styles, it also converts the file path to a query parameter.
*/
class PathProcessorImageStyles implements InboundPathProcessorInterface {
/**
* The stream wrapper manager service.
*
* @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface
*/
protected $streamWrapperManager;
/**
* Constructs a new PathProcessorImageStyles object.
*
* @param \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $stream_wrapper_manager
* The stream wrapper manager service.
*/
public function __construct(StreamWrapperManagerInterface $stream_wrapper_manager) {
$this->streamWrapperManager = $stream_wrapper_manager;
}
/**
* {@inheritdoc}
*/
public function processInbound($path, Request $request) {
$directory_path = $this->streamWrapperManager->getViaScheme('public')->getDirectoryPath();
if (str_starts_with($path, '/' . $directory_path . '/styles/')) {
$path_prefix = '/' . $directory_path . '/styles/';
}
// Check if the string '/system/files/styles/' exists inside the path,
// that means we have a case of private file's image style.
elseif (str_contains($path, '/system/files/styles/')) {
$path_prefix = '/system/files/styles/';
$path = substr($path, strpos($path, $path_prefix), strlen($path));
}
else {
return $path;
}
// Strip out path prefix.
$rest = preg_replace('|^' . preg_quote($path_prefix, '|') . '|', '', $path);
// Get the image style, scheme and path.
if (substr_count($rest, '/') >= 2) {
[$image_style, $scheme, $file] = explode('/', $rest, 3);
// Set the file as query parameter.
$request->query->set('file', $file);
return $path_prefix . $image_style . '/' . $scheme;
}
else {
return $path;
}
}
}

View File

@@ -0,0 +1,312 @@
<?php
namespace Drupal\image\Plugin\Field\FieldFormatter;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\File\FileUrlGeneratorInterface;
use Drupal\Core\Link;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Url;
use Drupal\image\Entity\ImageStyle;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Cache\Cache;
/**
* Plugin implementation of the 'image' formatter.
*/
#[FieldFormatter(
id: 'image',
label: new TranslatableMarkup('Image'),
field_types: [
'image',
],
)]
class ImageFormatter extends ImageFormatterBase {
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* The image style entity storage.
*
* @var \Drupal\image\ImageStyleStorageInterface
*/
protected $imageStyleStorage;
/**
* The file URL generator.
*
* @var \Drupal\Core\File\FileUrlGeneratorInterface
*/
protected $fileUrlGenerator;
/**
* Constructs an ImageFormatter object.
*
* @param string $plugin_id
* The plugin_id for the formatter.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
* The definition of the field to which the formatter is associated.
* @param array $settings
* The formatter settings.
* @param string $label
* The formatter label display setting.
* @param string $view_mode
* The view mode.
* @param array $third_party_settings
* Any third party settings.
* @param \Drupal\Core\Session\AccountInterface $current_user
* The current user.
* @param \Drupal\Core\Entity\EntityStorageInterface $image_style_storage
* The image style storage.
* @param \Drupal\Core\File\FileUrlGeneratorInterface $file_url_generator
* The file URL generator.
*/
public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, $label, $view_mode, array $third_party_settings, AccountInterface $current_user, EntityStorageInterface $image_style_storage, FileUrlGeneratorInterface $file_url_generator) {
parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $label, $view_mode, $third_party_settings);
$this->currentUser = $current_user;
$this->imageStyleStorage = $image_style_storage;
$this->fileUrlGenerator = $file_url_generator;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$plugin_id,
$plugin_definition,
$configuration['field_definition'],
$configuration['settings'],
$configuration['label'],
$configuration['view_mode'],
$configuration['third_party_settings'],
$container->get('current_user'),
$container->get('entity_type.manager')->getStorage('image_style'),
$container->get('file_url_generator')
);
}
/**
* {@inheritdoc}
*/
public static function defaultSettings() {
return [
'image_style' => '',
'image_link' => '',
'image_loading' => [
'attribute' => 'lazy',
],
] + parent::defaultSettings();
}
/**
* {@inheritdoc}
*/
public function settingsForm(array $form, FormStateInterface $form_state) {
$element = parent::settingsForm($form, $form_state);
$image_styles = image_style_options(FALSE);
$description_link = Link::fromTextAndUrl(
$this->t('Configure Image Styles'),
Url::fromRoute('entity.image_style.collection')
);
$element['image_style'] = [
'#title' => $this->t('Image style'),
'#type' => 'select',
'#default_value' => $this->getSetting('image_style'),
'#empty_option' => $this->t('None (original image)'),
'#options' => $image_styles,
'#description' => $description_link->toRenderable() + [
'#access' => $this->currentUser->hasPermission('administer image styles'),
],
];
$link_types = [
'content' => $this->t('Content'),
'file' => $this->t('File'),
];
$element['image_link'] = [
'#title' => $this->t('Link image to'),
'#type' => 'select',
'#default_value' => $this->getSetting('image_link'),
'#empty_option' => $this->t('Nothing'),
'#options' => $link_types,
];
$image_loading = $this->getSetting('image_loading');
$element['image_loading'] = [
'#type' => 'details',
'#title' => $this->t('Image loading'),
'#weight' => 10,
'#description' => $this->t('Lazy render images with native image loading attribute (<em>loading="lazy"</em>). This improves performance by allowing browsers to lazily load images.'),
];
$loading_attribute_options = [
'lazy' => $this->t('Lazy (<em>loading="lazy"</em>)'),
'eager' => $this->t('Eager (<em>loading="eager"</em>)'),
];
$element['image_loading']['attribute'] = [
'#title' => $this->t('Image loading attribute'),
'#type' => 'radios',
'#default_value' => $image_loading['attribute'],
'#options' => $loading_attribute_options,
'#description' => $this->t('Select the loading attribute for images. <a href=":link">Learn more about the loading attribute for images.</a>', [
':link' => 'https://html.spec.whatwg.org/multipage/urls-and-fetching.html#lazy-loading-attributes',
]),
];
$element['image_loading']['attribute']['lazy']['#description'] = $this->t('Delays loading the image until that section of the page is visible in the browser. When in doubt, lazy loading is recommended.');
$element['image_loading']['attribute']['eager']['#description'] = $this->t('Force browsers to download an image as soon as possible. This is the browser default for legacy reasons. Only use this option when the image is always expected to render.');
return $element;
}
/**
* {@inheritdoc}
*/
public function settingsSummary() {
$summary = [];
$image_styles = image_style_options(FALSE);
// Unset possible 'No defined styles' option.
unset($image_styles['']);
// Styles could be lost because of enabled/disabled modules that defines
// their styles in code.
$image_style_setting = $this->getSetting('image_style');
if (isset($image_styles[$image_style_setting])) {
$summary[] = $this->t('Image style: @style', ['@style' => $image_styles[$image_style_setting]]);
}
else {
$summary[] = $this->t('Original image');
}
$link_types = [
'content' => $this->t('Linked to content'),
'file' => $this->t('Linked to file'),
];
// Display this setting only if image is linked.
$image_link_setting = $this->getSetting('image_link');
if (isset($link_types[$image_link_setting])) {
$summary[] = $link_types[$image_link_setting];
}
$image_loading = $this->getSetting('image_loading');
$summary[] = $this->t('Image loading: @attribute', [
'@attribute' => $image_loading['attribute'],
]);
return array_merge($summary, parent::settingsSummary());
}
/**
* {@inheritdoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode) {
$elements = [];
$files = $this->getEntitiesToView($items, $langcode);
// Early opt-out if the field is empty.
if (empty($files)) {
return $elements;
}
$url = NULL;
$image_link_setting = $this->getSetting('image_link');
// Check if the formatter involves a link.
if ($image_link_setting == 'content') {
$entity = $items->getEntity();
if (!$entity->isNew()) {
$url = $entity->toUrl();
}
}
elseif ($image_link_setting == 'file') {
$link_file = TRUE;
}
$image_style_setting = $this->getSetting('image_style');
// Collect cache tags to be added for each item in the field.
$base_cache_tags = [];
if (!empty($image_style_setting)) {
$image_style = $this->imageStyleStorage->load($image_style_setting);
$base_cache_tags = $image_style->getCacheTags();
}
foreach ($files as $delta => $file) {
if (isset($link_file)) {
$image_uri = $file->getFileUri();
$url = $this->fileUrlGenerator->generate($image_uri);
}
$cache_tags = Cache::mergeTags($base_cache_tags, $file->getCacheTags());
// Extract field item attributes for the theme function, and unset them
// from the $item so that the field template does not re-render them.
$item = $file->_referringItem;
$item_attributes = $item->_attributes;
unset($item->_attributes);
$image_loading_settings = $this->getSetting('image_loading');
$item_attributes['loading'] = $image_loading_settings['attribute'];
$elements[$delta] = [
'#theme' => 'image_formatter',
'#item' => $item,
'#item_attributes' => $item_attributes,
'#image_style' => $image_style_setting,
'#url' => $url,
'#cache' => [
'tags' => $cache_tags,
],
];
}
return $elements;
}
/**
* {@inheritdoc}
*/
public function calculateDependencies() {
$dependencies = parent::calculateDependencies();
$style_id = $this->getSetting('image_style');
/** @var \Drupal\image\ImageStyleInterface $style */
if ($style_id && $style = ImageStyle::load($style_id)) {
// If this formatter uses a valid image style to display the image, add
// the image style configuration entity as dependency of this formatter.
$dependencies[$style->getConfigDependencyKey()][] = $style->getConfigDependencyName();
}
return $dependencies;
}
/**
* {@inheritdoc}
*/
public function onDependencyRemoval(array $dependencies) {
$changed = parent::onDependencyRemoval($dependencies);
$style_id = $this->getSetting('image_style');
/** @var \Drupal\image\ImageStyleInterface $style */
if ($style_id && $style = ImageStyle::load($style_id)) {
if (!empty($dependencies[$style->getConfigDependencyKey()][$style->getConfigDependencyName()])) {
$replacement_id = $this->imageStyleStorage->getReplacementId($style_id);
// If a valid replacement has been provided in the storage, replace the
// image style with the replacement and signal that the formatter plugin
// settings were updated.
if ($replacement_id && ImageStyle::load($replacement_id)) {
$this->setSetting('image_style', $replacement_id);
$changed = TRUE;
}
}
}
return $changed;
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Drupal\image\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
use Drupal\field\FieldConfigInterface;
use Drupal\file\Plugin\Field\FieldFormatter\FileFormatterBase;
/**
* Base class for image file formatters.
*/
abstract class ImageFormatterBase extends FileFormatterBase {
/**
* {@inheritdoc}
*/
protected function getEntitiesToView(EntityReferenceFieldItemListInterface $items, $langcode) {
// Add the default image if needed.
if ($items->isEmpty()) {
$default_image = $this->getFieldSetting('default_image');
// If we are dealing with a configurable field, look in both
// instance-level and field-level settings.
if (empty($default_image['uuid']) && $this->fieldDefinition instanceof FieldConfigInterface) {
$default_image = $this->fieldDefinition->getFieldStorageDefinition()->getSetting('default_image');
}
if (!empty($default_image['uuid']) && $file = \Drupal::service('entity.repository')->loadEntityByUuid('file', $default_image['uuid'])) {
// Clone the FieldItemList into a runtime-only object for the formatter,
// so that the fallback image can be rendered without affecting the
// field values in the entity being rendered.
$items = clone $items;
$items->setValue([
'target_id' => $file->id(),
'alt' => $default_image['alt'],
'title' => $default_image['title'],
'width' => $default_image['width'],
'height' => $default_image['height'],
'entity' => $file,
'_loaded' => TRUE,
'_is_default' => TRUE,
]);
$file->_referringItem = $items[0];
}
}
return parent::getEntitiesToView($items, $langcode);
}
}

View File

@@ -0,0 +1,179 @@
<?php
namespace Drupal\image\Plugin\Field\FieldFormatter;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Link;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Plugin implementation of the 'image_url' formatter.
*/
#[FieldFormatter(
id: 'image_url',
label: new TranslatableMarkup('URL to image'),
field_types: [
'image',
],
)]
class ImageUrlFormatter extends ImageFormatterBase {
/**
* The image style entity storage.
*
* @var \Drupal\image\ImageStyleStorageInterface
*/
protected $imageStyleStorage;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* Constructs an ImageFormatter object.
*
* @param string $plugin_id
* The plugin_id for the formatter.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
* The definition of the field to which the formatter is associated.
* @param array $settings
* The formatter settings.
* @param string $label
* The formatter label display setting.
* @param string $view_mode
* The view mode.
* @param array $third_party_settings
* Any third party settings.
* @param \Drupal\Core\Entity\EntityStorageInterface $image_style_storage
* The image style storage.
* @param \Drupal\Core\Session\AccountInterface $current_user
* The current user.
*/
public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, $label, $view_mode, array $third_party_settings, EntityStorageInterface $image_style_storage, AccountInterface $current_user) {
parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $label, $view_mode, $third_party_settings);
$this->imageStyleStorage = $image_style_storage;
$this->currentUser = $current_user;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$plugin_id,
$plugin_definition,
$configuration['field_definition'],
$configuration['settings'],
$configuration['label'],
$configuration['view_mode'],
$configuration['third_party_settings'],
$container->get('entity_type.manager')->getStorage('image_style'),
$container->get('current_user'),
);
}
/**
* {@inheritdoc}
*/
public static function defaultSettings() {
return [
'image_style' => '',
];
}
/**
* {@inheritdoc}
*/
public function settingsForm(array $form, FormStateInterface $form_state) {
$element = parent::settingsForm($form, $form_state);
unset($element['image_link'], $element['image_loading']);
$image_styles = image_style_options(FALSE);
$description_link = Link::fromTextAndUrl(
$this->t('Configure Image Styles'),
Url::fromRoute('entity.image_style.collection')
);
$element['image_style'] = [
'#title' => $this->t('Image style'),
'#type' => 'select',
'#default_value' => $this->getSetting('image_style'),
'#empty_option' => $this->t('None (original image)'),
'#options' => $image_styles,
'#description' => $description_link->toRenderable() + [
'#access' => $this->currentUser->hasPermission('administer image styles'),
],
];
return $element;
}
/**
* {@inheritdoc}
*/
public function settingsSummary() {
$summary = [];
$image_styles = image_style_options(FALSE);
// Unset possible 'No defined styles' option.
unset($image_styles['']);
// Styles could be lost because of enabled/disabled modules that defines
// their styles in code.
$image_style_setting = $this->getSetting('image_style');
if (isset($image_styles[$image_style_setting])) {
$summary[] = $this->t('Image style: @style', ['@style' => $image_styles[$image_style_setting]]);
}
else {
$summary[] = $this->t('Original image');
}
return array_merge($summary, parent::settingsSummary());
}
/**
* {@inheritdoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode) {
$elements = [];
/** @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $items */
if (empty($images = $this->getEntitiesToView($items, $langcode))) {
// Early opt-out if the field is empty.
return $elements;
}
/** @var \Drupal\image\ImageStyleInterface $image_style */
$image_style = $this->imageStyleStorage->load($this->getSetting('image_style'));
/** @var \Drupal\Core\File\FileUrlGeneratorInterface $file_url_generator */
$file_url_generator = \Drupal::service('file_url_generator');
/** @var \Drupal\file\FileInterface[] $images */
foreach ($images as $delta => $image) {
$image_uri = $image->getFileUri();
$url = $image_style ? $file_url_generator->transformRelative($image_style->buildUrl($image_uri)) : $file_url_generator->generateString($image_uri);
// Add cacheability metadata from the image and image style.
$cacheability = CacheableMetadata::createFromObject($image);
if ($image_style) {
$cacheability->addCacheableDependency(CacheableMetadata::createFromObject($image_style));
}
$elements[$delta] = ['#markup' => $url];
$cacheability->applyTo($elements[$delta]);
}
return $elements;
}
}

View File

@@ -0,0 +1,528 @@
<?php
namespace Drupal\image\Plugin\Field\FieldType;
use Drupal\Component\Utility\Random;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Field\Attribute\FieldType;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\File\Exception\FileException;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Logger\LoggerChannelTrait;
use Drupal\Core\StreamWrapper\StreamWrapperInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\TypedData\DataDefinition;
use Drupal\file\Entity\File;
use Drupal\file\Plugin\Field\FieldType\FileFieldItemList;
use Drupal\file\Plugin\Field\FieldType\FileItem;
/**
* Plugin implementation of the 'image' field type.
*/
#[FieldType(
id: "image",
label: new TranslatableMarkup("Image"),
description: [
new TranslatableMarkup("For uploading images"),
new TranslatableMarkup("Allows a user to upload an image with configurable extensions, image dimensions, upload size"),
new TranslatableMarkup(
"Can be configured with options such as allowed file extensions, maximum upload size and image dimensions minimums/maximums"
),
],
category: "file_upload",
default_widget: "image_image",
default_formatter: "image",
list_class: FileFieldItemList::class,
constraints: ["ReferenceAccess" => [], "FileValidation" => []],
column_groups: [
"file" => [
"label" => new TranslatableMarkup("File"),
"columns" => [
"target_id",
"width",
"height",
],
"require_all_groups_for_translation" => TRUE,
],
"alt" => [
"label" => new TranslatableMarkup("Alt"),
"translatable" => TRUE,
],
"title" => [
"label" => new TranslatableMarkup("Title"),
"translatable" => TRUE,
],
]
)]
class ImageItem extends FileItem {
use LoggerChannelTrait;
/**
* {@inheritdoc}
*/
public static function defaultStorageSettings() {
return [
'default_image' => [
'uuid' => NULL,
'alt' => '',
'title' => '',
'width' => NULL,
'height' => NULL,
],
] + parent::defaultStorageSettings();
}
/**
* {@inheritdoc}
*/
public static function defaultFieldSettings() {
$settings = [
'file_extensions' => 'png gif jpg jpeg webp',
'alt_field' => 1,
'alt_field_required' => 1,
'title_field' => 0,
'title_field_required' => 0,
'max_resolution' => '',
'min_resolution' => '',
'default_image' => [
'uuid' => NULL,
'alt' => '',
'title' => '',
'width' => NULL,
'height' => NULL,
],
] + parent::defaultFieldSettings();
unset($settings['description_field']);
return $settings;
}
/**
* {@inheritdoc}
*/
public static function schema(FieldStorageDefinitionInterface $field_definition) {
return [
'columns' => [
'target_id' => [
'description' => 'The ID of the file entity.',
'type' => 'int',
'unsigned' => TRUE,
],
'alt' => [
'description' => "Alternative image text, for the image's 'alt' attribute.",
'type' => 'varchar',
'length' => 512,
],
'title' => [
'description' => "Image title text, for the image's 'title' attribute.",
'type' => 'varchar',
'length' => 1024,
],
'width' => [
'description' => 'The width of the image in pixels.',
'type' => 'int',
'unsigned' => TRUE,
],
'height' => [
'description' => 'The height of the image in pixels.',
'type' => 'int',
'unsigned' => TRUE,
],
],
'indexes' => [
'target_id' => ['target_id'],
],
'foreign keys' => [
'target_id' => [
'table' => 'file_managed',
'columns' => ['target_id' => 'fid'],
],
],
];
}
/**
* {@inheritdoc}
*/
public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) {
$properties = parent::propertyDefinitions($field_definition);
unset($properties['display']);
unset($properties['description']);
$properties['alt'] = DataDefinition::create('string')
->setLabel(new TranslatableMarkup('Alternative text'))
->setDescription(new TranslatableMarkup("Alternative image text, for the image's 'alt' attribute."));
$properties['title'] = DataDefinition::create('string')
->setLabel(new TranslatableMarkup('Title'))
->setDescription(new TranslatableMarkup("Image title text, for the image's 'title' attribute."));
$properties['width'] = DataDefinition::create('integer')
->setLabel(new TranslatableMarkup('Width'))
->setDescription(new TranslatableMarkup('The width of the image in pixels.'));
$properties['height'] = DataDefinition::create('integer')
->setLabel(new TranslatableMarkup('Height'))
->setDescription(new TranslatableMarkup('The height of the image in pixels.'));
return $properties;
}
/**
* {@inheritdoc}
*/
public static function storageSettingsSummary(FieldStorageDefinitionInterface $storage_definition): array {
// Bypass the parent setting summary as it produces redundant information.
return [];
}
/**
* {@inheritdoc}
*/
public function storageSettingsForm(array &$form, FormStateInterface $form_state, $has_data) {
$element = [];
// We need the field-level 'default_image' setting, and $this->getSettings()
// will only provide the instance-level one, so we need to explicitly fetch
// the field.
$settings = $this->getFieldDefinition()->getFieldStorageDefinition()->getSettings();
$scheme_options = \Drupal::service('stream_wrapper_manager')->getNames(StreamWrapperInterface::WRITE_VISIBLE);
$element['uri_scheme'] = [
'#type' => 'radios',
'#title' => $this->t('Upload destination'),
'#options' => $scheme_options,
'#default_value' => $settings['uri_scheme'],
'#description' => $this->t('Select where the final files should be stored. Private file storage has significantly more overhead than public files, but allows restricted access to files within this field.'),
];
// Add default_image element.
static::defaultImageForm($element, $settings);
$element['default_image']['#description'] = $this->t('If no image is uploaded, this image will be shown on display.');
return $element;
}
/**
* {@inheritdoc}
*/
public function fieldSettingsForm(array $form, FormStateInterface $form_state) {
// Get base form from FileItem.
$element = parent::fieldSettingsForm($form, $form_state);
$settings = $this->getSettings();
// Add maximum and minimum dimensions settings.
$max_resolution = explode('x', $settings['max_resolution']) + ['', ''];
$element['max_resolution'] = [
'#type' => 'item',
'#title' => $this->t('Maximum image dimensions'),
'#element_validate' => [[static::class, 'validateResolution']],
'#weight' => 4.1,
'#description' => $this->t('The maximum allowed image size expressed as WIDTH×HEIGHT (e.g. 640×480). Leave blank for no restriction. If a larger image is uploaded, it will be resized to reflect the given width and height. Resizing images on upload will cause the loss of <a href="http://wikipedia.org/wiki/Exchangeable_image_file_format">EXIF data</a> in the image.'),
];
$element['max_resolution']['x'] = [
'#type' => 'number',
'#title' => $this->t('Maximum width'),
'#title_display' => 'invisible',
'#default_value' => $max_resolution[0],
'#min' => 1,
'#field_suffix' => ' × ',
'#prefix' => '<div class="form--inline clearfix">',
];
$element['max_resolution']['y'] = [
'#type' => 'number',
'#title' => $this->t('Maximum height'),
'#title_display' => 'invisible',
'#default_value' => $max_resolution[1],
'#min' => 1,
'#field_suffix' => ' ' . $this->t('pixels'),
'#suffix' => '</div>',
];
$min_resolution = explode('x', $settings['min_resolution']) + ['', ''];
$element['min_resolution'] = [
'#type' => 'item',
'#title' => $this->t('Minimum image dimensions'),
'#element_validate' => [[static::class, 'validateResolution']],
'#weight' => 4.2,
'#description' => $this->t('The minimum allowed image size expressed as WIDTH×HEIGHT (e.g. 640×480). Leave blank for no restriction. If a smaller image is uploaded, it will be rejected.'),
];
$element['min_resolution']['x'] = [
'#type' => 'number',
'#title' => $this->t('Minimum width'),
'#title_display' => 'invisible',
'#default_value' => $min_resolution[0],
'#min' => 1,
'#field_suffix' => ' × ',
'#prefix' => '<div class="form--inline clearfix">',
];
$element['min_resolution']['y'] = [
'#type' => 'number',
'#title' => $this->t('Minimum height'),
'#title_display' => 'invisible',
'#default_value' => $min_resolution[1],
'#min' => 1,
'#field_suffix' => ' ' . $this->t('pixels'),
'#suffix' => '</div>',
];
// Remove the description option.
unset($element['description_field']);
// Add title and alt configuration options.
$element['alt_field'] = [
'#type' => 'checkbox',
'#title' => $this->t('Enable <em>Alt</em> field'),
'#default_value' => $settings['alt_field'],
'#description' => $this->t('Short description of the image used by screen readers and displayed when the image is not loaded. Enabling this field is recommended.'),
'#weight' => 9,
];
$element['alt_field_required'] = [
'#type' => 'checkbox',
'#title' => $this->t('<em>Alt</em> field required'),
'#default_value' => $settings['alt_field_required'],
'#description' => $this->t('Making this field required is recommended.'),
'#weight' => 10,
'#states' => [
'visible' => [
':input[name="settings[alt_field]"]' => ['checked' => TRUE],
],
],
];
$element['title_field'] = [
'#type' => 'checkbox',
'#title' => $this->t('Enable <em>Title</em> field'),
'#default_value' => $settings['title_field'],
'#description' => $this->t('The title attribute is used as a tooltip when the mouse hovers over the image. Enabling this field is not recommended as it can cause problems with screen readers.'),
'#weight' => 11,
];
$element['title_field_required'] = [
'#type' => 'checkbox',
'#title' => $this->t('<em>Title</em> field required'),
'#default_value' => $settings['title_field_required'],
'#weight' => 12,
'#states' => [
'visible' => [
':input[name="settings[title_field]"]' => ['checked' => TRUE],
],
],
];
// Add default_image element.
static::defaultImageForm($element, $settings);
$element['default_image']['#description'] = $this->t("If no image is uploaded, this image will be shown on display and will override the field's default image.");
return $element;
}
/**
* {@inheritdoc}
*/
public function preSave() {
parent::preSave();
$width = $this->width;
$height = $this->height;
// Determine the dimensions if necessary.
if ($this->entity && $this->entity instanceof EntityInterface) {
if (empty($width) || empty($height)) {
$image = \Drupal::service('image.factory')->get($this->entity->getFileUri());
if ($image->isValid()) {
$this->width = $image->getWidth();
$this->height = $image->getHeight();
}
}
}
else {
$this->getLogger('image')->warning("Missing file with ID %id.", ['%id' => $this->target_id]);
}
}
/**
* {@inheritdoc}
*/
public static function generateSampleValue(FieldDefinitionInterface $field_definition) {
$random = new Random();
$settings = $field_definition->getSettings();
static $images = [];
$min_resolution = empty($settings['min_resolution']) ? '100x100' : $settings['min_resolution'];
$max_resolution = empty($settings['max_resolution']) ? '600x600' : $settings['max_resolution'];
$extensions = array_intersect(explode(' ', $settings['file_extensions']), ['png', 'gif', 'jpg', 'jpeg']);
$extension = array_rand(array_combine($extensions, $extensions));
$min = explode('x', $min_resolution);
$max = explode('x', $max_resolution);
if (intval($min[0]) > intval($max[0])) {
$max[0] = $min[0];
}
if (intval($min[1]) > intval($max[1])) {
$max[1] = $min[1];
}
$max_resolution = "$max[0]x$max[1]";
// Generate a max of 5 different images.
if (!isset($images[$extension][$min_resolution][$max_resolution]) || count($images[$extension][$min_resolution][$max_resolution]) <= 5) {
/** @var \Drupal\Core\File\FileSystemInterface $file_system */
$file_system = \Drupal::service('file_system');
$tmp_file = $file_system->tempnam('temporary://', 'generateImage_');
$destination = $tmp_file . '.' . $extension;
try {
$file_system->move($tmp_file, $destination);
}
catch (FileException $e) {
// Ignore failed move.
}
if ($path = $random->image($file_system->realpath($destination), $min_resolution, $max_resolution)) {
$image = File::create();
$image->setFileUri($path);
$image->setOwnerId(\Drupal::currentUser()->id());
$guesser = \Drupal::service('file.mime_type.guesser');
$image->setMimeType($guesser->guessMimeType($path));
$image->setFileName($file_system->basename($path));
$destination_dir = static::doGetUploadLocation($settings);
$file_system->prepareDirectory($destination_dir, FileSystemInterface::CREATE_DIRECTORY);
$destination = $destination_dir . '/' . basename($path);
$file = \Drupal::service('file.repository')->move($image, $destination);
$images[$extension][$min_resolution][$max_resolution][$file->id()] = $file;
}
else {
return [];
}
}
else {
// Select one of the images we've already generated for this field.
$image_index = array_rand($images[$extension][$min_resolution][$max_resolution]);
$file = $images[$extension][$min_resolution][$max_resolution][$image_index];
}
[$width, $height] = getimagesize($file->getFileUri());
$values = [
'target_id' => $file->id(),
'alt' => $random->sentences(4),
'title' => $random->sentences(4),
'width' => $width,
'height' => $height,
];
return $values;
}
/**
* Element validate function for dimensions fields.
*/
public static function validateResolution($element, FormStateInterface $form_state) {
if (!empty($element['x']['#value']) || !empty($element['y']['#value'])) {
foreach (['x', 'y'] as $dimension) {
if (!$element[$dimension]['#value']) {
// We expect the field name placeholder value to be wrapped in $this->t()
// here, so it won't be escaped again as it's already marked safe.
$form_state->setError($element[$dimension], new TranslatableMarkup('Both a height and width value must be specified in the @name field.', ['@name' => $element['#title']]));
return;
}
}
$form_state->setValueForElement($element, $element['x']['#value'] . 'x' . $element['y']['#value']);
}
else {
$form_state->setValueForElement($element, '');
}
}
/**
* Builds the default_image details element.
*
* @param array $element
* The form associative array passed by reference.
* @param array $settings
* The field settings array.
*/
protected function defaultImageForm(array &$element, array $settings) {
$element['default_image'] = [
'#type' => 'details',
'#title' => $this->t('Default image'),
'#open' => TRUE,
];
// Convert the stored UUID to a FID.
$fids = [];
$uuid = $settings['default_image']['uuid'];
if ($uuid && ($file = \Drupal::service('entity.repository')->loadEntityByUuid('file', $uuid))) {
$fids[0] = $file->id();
}
$element['default_image']['uuid'] = [
'#type' => 'managed_file',
'#title' => $this->t('Image'),
'#description' => $this->t('Image to be shown if no image is uploaded.'),
'#default_value' => $fids,
'#upload_location' => $settings['uri_scheme'] . '://default_images/',
'#element_validate' => [
'\Drupal\file\Element\ManagedFile::validateManagedFile',
[static::class, 'validateDefaultImageForm'],
],
'#upload_validators' => $this->getUploadValidators(),
];
$element['default_image']['alt'] = [
'#type' => 'textfield',
'#title' => $this->t('Alternative text'),
'#description' => $this->t('Short description of the image used by screen readers and displayed when the image is not loaded. This is important for accessibility.'),
'#default_value' => $settings['default_image']['alt'],
'#maxlength' => 512,
];
$element['default_image']['title'] = [
'#type' => 'textfield',
'#title' => $this->t('Title'),
'#description' => $this->t('The title attribute is used as a tooltip when the mouse hovers over the image.'),
'#default_value' => $settings['default_image']['title'],
'#maxlength' => 1024,
];
$element['default_image']['width'] = [
'#type' => 'value',
'#value' => $settings['default_image']['width'],
];
$element['default_image']['height'] = [
'#type' => 'value',
'#value' => $settings['default_image']['height'],
];
}
/**
* Validates the managed_file element for the default Image form.
*
* This function ensures the fid is a scalar value and not an array. It is
* assigned as an #element_validate callback in
* \Drupal\image\Plugin\Field\FieldType\ImageItem::defaultImageForm().
*
* @param array $element
* The form element to process.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
*/
public static function validateDefaultImageForm(array &$element, FormStateInterface $form_state) {
// Consolidate the array value of this field to a single FID as #extended
// for default image is not TRUE and this is a single value.
if (isset($element['fids']['#value'][0])) {
$value = $element['fids']['#value'][0];
// Convert the file ID to a uuid.
if ($file = \Drupal::entityTypeManager()->getStorage('file')->load($value)) {
$value = $file->uuid();
}
}
else {
$value = '';
}
$form_state->setValueForElement($element, $value);
}
/**
* {@inheritdoc}
*/
public function isDisplayed() {
// Image items do not have per-item visibility settings.
return TRUE;
}
}

View File

@@ -0,0 +1,347 @@
<?php
namespace Drupal\image\Plugin\Field\FieldWidget;
use Drupal\Core\Field\Attribute\FieldWidget;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Image\ImageFactory;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\ElementInfoManagerInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\file\Entity\File;
use Drupal\file\Plugin\Field\FieldWidget\FileWidget;
use Drupal\image\Entity\ImageStyle;
/**
* Plugin implementation of the 'image_image' widget.
*/
#[FieldWidget(
id: 'image_image',
label: new TranslatableMarkup('Image'),
field_types: ['image'],
)]
class ImageWidget extends FileWidget {
/**
* The image factory service.
*
* @var \Drupal\Core\Image\ImageFactory
*/
protected $imageFactory;
/**
* Constructs an ImageWidget object.
*
* @param string $plugin_id
* The plugin_id for the widget.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
* The definition of the field to which the widget is associated.
* @param array $settings
* The widget settings.
* @param array $third_party_settings
* Any third party settings.
* @param \Drupal\Core\Render\ElementInfoManagerInterface $element_info
* The element info manager service.
* @param \Drupal\Core\Image\ImageFactory $image_factory
* The image factory service.
*/
public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, ElementInfoManagerInterface $element_info, ?ImageFactory $image_factory = NULL) {
parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings, $element_info);
$this->imageFactory = $image_factory ?: \Drupal::service('image.factory');
}
/**
* {@inheritdoc}
*/
public static function defaultSettings() {
return [
'progress_indicator' => 'throbber',
'preview_image_style' => 'thumbnail',
] + parent::defaultSettings();
}
/**
* {@inheritdoc}
*/
public function settingsForm(array $form, FormStateInterface $form_state) {
$element = parent::settingsForm($form, $form_state);
$element['preview_image_style'] = [
'#title' => $this->t('Preview image style'),
'#type' => 'select',
'#options' => image_style_options(FALSE),
'#empty_option' => '<' . $this->t('no preview') . '>',
'#default_value' => $this->getSetting('preview_image_style'),
'#description' => $this->t('The preview image will be shown while editing the content.'),
'#weight' => 15,
];
return $element;
}
/**
* {@inheritdoc}
*/
public function settingsSummary() {
$summary = parent::settingsSummary();
$image_styles = image_style_options(FALSE);
// Unset possible 'No defined styles' option.
unset($image_styles['']);
// Styles could be lost because of enabled/disabled modules that defines
// their styles in code.
$image_style_setting = $this->getSetting('preview_image_style');
if (isset($image_styles[$image_style_setting])) {
$preview_image_style = $this->t('Preview image style: @style', ['@style' => $image_styles[$image_style_setting]]);
}
else {
$preview_image_style = $this->t('No preview');
}
array_unshift($summary, $preview_image_style);
return $summary;
}
/**
* Overrides \Drupal\file\Plugin\Field\FieldWidget\FileWidget::formMultipleElements().
*
* Special handling for draggable multiple widgets and 'add more' button.
*/
protected function formMultipleElements(FieldItemListInterface $items, array &$form, FormStateInterface $form_state) {
$elements = parent::formMultipleElements($items, $form, $form_state);
$cardinality = $this->fieldDefinition->getFieldStorageDefinition()->getCardinality();
$file_upload_help = [
'#theme' => 'file_upload_help',
'#description' => '',
'#upload_validators' => $elements[0]['#upload_validators'],
'#cardinality' => $cardinality,
];
if ($cardinality == 1) {
// If there's only one field, return it as delta 0.
if (empty($elements[0]['#default_value']['fids'])) {
$file_upload_help['#description'] = $this->getFilteredDescription();
$elements[0]['#description'] = \Drupal::service('renderer')->renderInIsolation($file_upload_help);
}
}
else {
$elements['#file_upload_description'] = $file_upload_help;
}
return $elements;
}
/**
* {@inheritdoc}
*/
public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
$element = parent::formElement($items, $delta, $element, $form, $form_state);
$field_settings = $this->getFieldSettings();
// Add image validation.
$element['#upload_validators']['FileIsImage'] = [];
// Add upload dimensions validation.
if ($field_settings['max_resolution'] || $field_settings['min_resolution']) {
$element['#upload_validators']['FileImageDimensions'] = [
'maxDimensions' => $field_settings['max_resolution'],
'minDimensions' => $field_settings['min_resolution'],
];
}
$extensions = $field_settings['file_extensions'];
$supported_extensions = $this->imageFactory->getSupportedExtensions();
// If using custom extension validation, ensure that the extensions are
// supported by the current image toolkit. Otherwise, validate against all
// toolkit supported extensions.
$extensions = !empty($extensions) ? array_intersect(explode(' ', $extensions), $supported_extensions) : $supported_extensions;
$element['#upload_validators']['FileExtension']['extensions'] = implode(' ', $extensions);
// Add mobile device image capture acceptance.
$element['#accept'] = 'image/*';
// Add properties needed by process() method.
$element['#preview_image_style'] = $this->getSetting('preview_image_style');
$element['#title_field'] = $field_settings['title_field'];
$element['#title_field_required'] = $field_settings['title_field_required'];
$element['#alt_field'] = $field_settings['alt_field'];
$element['#alt_field_required'] = $field_settings['alt_field_required'];
// Default image.
$default_image = $field_settings['default_image'];
if (empty($default_image['uuid'])) {
$default_image = $this->fieldDefinition->getFieldStorageDefinition()->getSetting('default_image');
}
// Convert the stored UUID into a file ID.
if (!empty($default_image['uuid']) && $entity = \Drupal::service('entity.repository')->loadEntityByUuid('file', $default_image['uuid'])) {
$default_image['fid'] = $entity->id();
}
$element['#default_image'] = !empty($default_image['fid']) ? $default_image : [];
return $element;
}
/**
* Form API callback: Processes an image_image field element.
*
* Expands the image_image type to include the alt and title fields.
*
* This method is assigned as a #process callback in formElement() method.
*/
public static function process($element, FormStateInterface $form_state, $form) {
$item = $element['#value'];
$item['fids'] = $element['fids']['#value'];
$element['#theme'] = 'image_widget';
// Add the image preview.
if (!empty($element['#files']) && $element['#preview_image_style']) {
$file = reset($element['#files']);
$variables = [
'style_name' => $element['#preview_image_style'],
'uri' => $file->getFileUri(),
];
$dimension_key = $variables['uri'] . '.image_preview_dimensions';
// Determine image dimensions.
if (isset($element['#value']['width']) && isset($element['#value']['height'])) {
$variables['width'] = $element['#value']['width'];
$variables['height'] = $element['#value']['height'];
}
elseif ($form_state->has($dimension_key)) {
$variables += $form_state->get($dimension_key);
}
else {
$image = \Drupal::service('image.factory')->get($file->getFileUri());
if ($image->isValid()) {
$variables['width'] = $image->getWidth();
$variables['height'] = $image->getHeight();
}
else {
$variables['width'] = $variables['height'] = NULL;
}
}
$element['preview'] = [
'#weight' => -10,
'#theme' => 'image_style',
'#width' => $variables['width'],
'#height' => $variables['height'],
'#style_name' => $variables['style_name'],
'#uri' => $variables['uri'],
];
// Store the dimensions in the form so the file doesn't have to be
// accessed again. This is important for remote files.
$form_state->set($dimension_key, ['width' => $variables['width'], 'height' => $variables['height']]);
}
elseif (!empty($element['#default_image'])) {
$default_image = $element['#default_image'];
$file = File::load($default_image['fid']);
if (!empty($file)) {
$element['preview'] = [
'#weight' => -10,
'#theme' => 'image_style',
'#width' => $default_image['width'],
'#height' => $default_image['height'],
'#style_name' => $element['#preview_image_style'],
'#uri' => $file->getFileUri(),
];
}
}
// Add the additional alt and title fields.
$element['alt'] = [
'#title' => new TranslatableMarkup('Alternative text'),
'#type' => 'textfield',
'#default_value' => $item['alt'] ?? '',
'#description' => new TranslatableMarkup('Short description of the image used by screen readers and displayed when the image is not loaded. This is important for accessibility.'),
// @see https://www.drupal.org/node/465106#alt-text
'#maxlength' => 512,
'#weight' => -12,
'#access' => (bool) $item['fids'] && $element['#alt_field'],
'#required' => $element['#alt_field_required'],
'#element_validate' => $element['#alt_field_required'] == 1 ? [[static::class, 'validateRequiredFields']] : [],
];
$element['title'] = [
'#type' => 'textfield',
'#title' => new TranslatableMarkup('Title'),
'#default_value' => $item['title'] ?? '',
'#description' => new TranslatableMarkup('The title is used as a tool tip when the user hovers the mouse over the image.'),
'#maxlength' => 1024,
'#weight' => -11,
'#access' => (bool) $item['fids'] && $element['#title_field'],
'#required' => $element['#title_field_required'],
'#element_validate' => $element['#title_field_required'] == 1 ? [[static::class, 'validateRequiredFields']] : [],
];
return parent::process($element, $form_state, $form);
}
/**
* Validate callback for alt and title field, if the user wants them required.
*
* This is separated in a validate function instead of a #required flag to
* avoid being validated on the process callback.
*/
public static function validateRequiredFields($element, FormStateInterface $form_state) {
// Only do validation if the function is triggered from other places than
// the image process form.
$triggering_element = $form_state->getTriggeringElement();
if (!empty($triggering_element['#submit']) && in_array('file_managed_file_submit', $triggering_element['#submit'], TRUE)) {
$form_state->setLimitValidationErrors([]);
}
}
/**
* {@inheritdoc}
*/
public function calculateDependencies() {
$dependencies = parent::calculateDependencies();
$style_id = $this->getSetting('preview_image_style');
/** @var \Drupal\image\ImageStyleInterface $style */
if ($style_id && $style = ImageStyle::load($style_id)) {
// If this widget uses a valid image style to display the preview of the
// uploaded image, add that image style configuration entity as dependency
// of this widget.
$dependencies[$style->getConfigDependencyKey()][] = $style->getConfigDependencyName();
}
return $dependencies;
}
/**
* {@inheritdoc}
*/
public function onDependencyRemoval(array $dependencies) {
$changed = parent::onDependencyRemoval($dependencies);
$style_id = $this->getSetting('preview_image_style');
/** @var \Drupal\image\ImageStyleInterface $style */
if ($style_id && $style = ImageStyle::load($style_id)) {
if (!empty($dependencies[$style->getConfigDependencyKey()][$style->getConfigDependencyName()])) {
/** @var \Drupal\image\ImageStyleStorageInterface $storage */
$storage = \Drupal::entityTypeManager()->getStorage($style->getEntityTypeId());
$replacement_id = $storage->getReplacementId($style_id);
// If a valid replacement has been provided in the storage, replace the
// preview image style with the replacement.
if ($replacement_id && ImageStyle::load($replacement_id)) {
$this->setSetting('preview_image_style', $replacement_id);
}
// If there's no replacement or the replacement is invalid, disable the
// image preview.
else {
$this->setSetting('preview_image_style', '');
}
// Signal that the formatter plugin settings were updated.
$changed = TRUE;
}
}
return $changed;
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace Drupal\image\Plugin\ImageEffect;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Image\ImageInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\image\Attribute\ImageEffect;
use Drupal\image\ConfigurableImageEffectBase;
/**
* Converts an image resource.
*/
#[ImageEffect(
id: "image_convert",
label: new TranslatableMarkup("Convert"),
description: new TranslatableMarkup("Converts an image to a format (such as JPEG)."),
)]
class ConvertImageEffect extends ConfigurableImageEffectBase {
/**
* {@inheritdoc}
*/
public function applyEffect(ImageInterface $image) {
if (!$image->convert($this->configuration['extension'])) {
$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->configuration['extension'];
}
/**
* {@inheritdoc}
*/
public function getSummary() {
$summary = [
'#markup' => mb_strtoupper($this->configuration['extension']),
];
$summary += parent::getSummary();
return $summary;
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'extension' => NULL,
];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$extensions = \Drupal::service('image.toolkit.manager')->getDefaultToolkit()->getSupportedExtensions();
$options = array_combine(
$extensions,
array_map('mb_strtoupper', $extensions)
);
$form['extension'] = [
'#type' => 'select',
'#title' => $this->t('Convert to'),
'#default_value' => $this->configuration['extension'],
'#required' => TRUE,
'#options' => $options,
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
parent::submitConfigurationForm($form, $form_state);
$this->configuration['extension'] = $form_state->getValue('extension');
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace Drupal\image\Plugin\ImageEffect;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Image\ImageInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\image\Attribute\ImageEffect;
/**
* Crops an image resource.
*/
#[ImageEffect(
id: "image_crop",
label: new TranslatableMarkup("Crop"),
description: new TranslatableMarkup("Resizing will make images an exact set of dimensions. This may cause images to be stretched or shrunk disproportionately."),
)]
class CropImageEffect extends ResizeImageEffect {
/**
* {@inheritdoc}
*/
public function applyEffect(ImageInterface $image) {
[$x, $y] = explode('-', $this->configuration['anchor']);
$x = image_filter_keyword($x, $image->getWidth(), $this->configuration['width']);
$y = image_filter_keyword($y, $image->getHeight(), $this->configuration['height']);
if (!$image->crop($x, $y, $this->configuration['width'], $this->configuration['height'])) {
$this->logger->error('Image crop failed using the %toolkit toolkit on %path (%mimetype, %dimensions)', ['%toolkit' => $image->getToolkitId(), '%path' => $image->getSource(), '%mimetype' => $image->getMimeType(), '%dimensions' => $image->getWidth() . 'x' . $image->getHeight()]);
return FALSE;
}
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getSummary() {
$summary = [
'#theme' => 'image_crop_summary',
'#data' => $this->configuration,
];
$summary += parent::getSummary();
return $summary;
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return parent::defaultConfiguration() + [
'anchor' => 'center-center',
];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form = parent::buildConfigurationForm($form, $form_state);
$form['anchor'] = [
'#type' => 'radios',
'#title' => $this->t('Anchor'),
'#options' => [
'left-top' => $this->t('Top left'),
'center-top' => $this->t('Top center'),
'right-top' => $this->t('Top right'),
'left-center' => $this->t('Center left'),
'center-center' => $this->t('Center'),
'right-center' => $this->t('Center right'),
'left-bottom' => $this->t('Bottom left'),
'center-bottom' => $this->t('Bottom center'),
'right-bottom' => $this->t('Bottom right'),
],
'#theme' => 'image_anchor',
'#default_value' => $this->configuration['anchor'],
'#description' => $this->t('The part of the image that will be retained during the crop.'),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
parent::submitConfigurationForm($form, $form_state);
$this->configuration['anchor'] = $form_state->getValue('anchor');
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Drupal\image\Plugin\ImageEffect;
use Drupal\Core\Image\ImageInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\image\Attribute\ImageEffect;
use Drupal\image\ImageEffectBase;
/**
* Desaturates (grayscale) an image resource.
*/
#[ImageEffect(
id: "image_desaturate",
label: new TranslatableMarkup("Desaturate"),
description: new TranslatableMarkup("Desaturate converts an image to grayscale."),
)]
class DesaturateImageEffect extends ImageEffectBase {
/**
* {@inheritdoc}
*/
public function applyEffect(ImageInterface $image) {
if (!$image->desaturate()) {
$this->logger->error('Image desaturate failed using the %toolkit toolkit on %path (%mimetype, %dimensions)', ['%toolkit' => $image->getToolkitId(), '%path' => $image->getSource(), '%mimetype' => $image->getMimeType(), '%dimensions' => $image->getWidth() . 'x' . $image->getHeight()]);
return FALSE;
}
return TRUE;
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace Drupal\image\Plugin\ImageEffect;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Image\ImageInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\image\Attribute\ImageEffect;
use Drupal\image\ConfigurableImageEffectBase;
/**
* Resizes an image resource.
*/
#[ImageEffect(
id: "image_resize",
label: new TranslatableMarkup("Resize"),
description: new TranslatableMarkup("Resizing will make images an exact set of dimensions. This may cause images to be stretched or shrunk disproportionately."),
)]
class ResizeImageEffect extends ConfigurableImageEffectBase {
/**
* {@inheritdoc}
*/
public function applyEffect(ImageInterface $image) {
if (!$image->resize($this->configuration['width'], $this->configuration['height'])) {
$this->logger->error('Image resize failed using the %toolkit toolkit on %path (%mimetype, %dimensions)', ['%toolkit' => $image->getToolkitId(), '%path' => $image->getSource(), '%mimetype' => $image->getMimeType(), '%dimensions' => $image->getWidth() . 'x' . $image->getHeight()]);
return FALSE;
}
return TRUE;
}
/**
* {@inheritdoc}
*/
public function transformDimensions(array &$dimensions, $uri) {
// The new image will have the exact dimensions defined for the effect.
$dimensions['width'] = $this->configuration['width'];
$dimensions['height'] = $this->configuration['height'];
}
/**
* {@inheritdoc}
*/
public function getSummary() {
$summary = [
'#theme' => 'image_resize_summary',
'#data' => $this->configuration,
];
$summary += parent::getSummary();
return $summary;
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'width' => NULL,
'height' => NULL,
];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['width'] = [
'#type' => 'number',
'#title' => $this->t('Width'),
'#default_value' => $this->configuration['width'],
'#field_suffix' => ' ' . $this->t('pixels'),
'#required' => TRUE,
'#min' => 1,
];
$form['height'] = [
'#type' => 'number',
'#title' => $this->t('Height'),
'#default_value' => $this->configuration['height'],
'#field_suffix' => ' ' . $this->t('pixels'),
'#required' => TRUE,
'#min' => 1,
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
parent::submitConfigurationForm($form, $form_state);
$this->configuration['height'] = $form_state->getValue('height');
$this->configuration['width'] = $form_state->getValue('width');
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace Drupal\image\Plugin\ImageEffect;
use Drupal\Component\Utility\Color;
use Drupal\Component\Utility\Rectangle;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Image\ImageInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\image\Attribute\ImageEffect;
use Drupal\image\ConfigurableImageEffectBase;
/**
* Rotates an image resource.
*/
#[ImageEffect(
id: "image_rotate",
label: new TranslatableMarkup("Rotate"),
description: new TranslatableMarkup("Rotating an image may cause the dimensions of an image to increase to fit the diagonal.")
)]
class RotateImageEffect extends ConfigurableImageEffectBase {
/**
* {@inheritdoc}
*/
public function applyEffect(ImageInterface $image) {
if (!empty($this->configuration['random'])) {
$degrees = abs((float) $this->configuration['degrees']);
$this->configuration['degrees'] = rand(-$degrees, $degrees);
}
if (!$image->rotate($this->configuration['degrees'], $this->configuration['bgcolor'])) {
$this->logger->error('Image rotate failed using the %toolkit toolkit on %path (%mimetype, %dimensions)', ['%toolkit' => $image->getToolkitId(), '%path' => $image->getSource(), '%mimetype' => $image->getMimeType(), '%dimensions' => $image->getWidth() . 'x' . $image->getHeight()]);
return FALSE;
}
return TRUE;
}
/**
* {@inheritdoc}
*/
public function transformDimensions(array &$dimensions, $uri) {
// If the rotate is not random and current dimensions are set,
// then the new dimensions can be determined.
if (!$this->configuration['random'] && $dimensions['width'] && $dimensions['height']) {
$rect = new Rectangle($dimensions['width'], $dimensions['height']);
$rect = $rect->rotate($this->configuration['degrees']);
$dimensions['width'] = $rect->getBoundingWidth();
$dimensions['height'] = $rect->getBoundingHeight();
}
else {
$dimensions['width'] = $dimensions['height'] = NULL;
}
}
/**
* {@inheritdoc}
*/
public function getSummary() {
$summary = [
'#theme' => 'image_rotate_summary',
'#data' => $this->configuration,
];
$summary += parent::getSummary();
return $summary;
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'degrees' => 0,
'bgcolor' => NULL,
'random' => FALSE,
];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['degrees'] = [
'#type' => 'number',
'#default_value' => $this->configuration['degrees'],
'#title' => $this->t('Rotation angle'),
'#description' => $this->t('The number of degrees the image should be rotated. Positive numbers are clockwise, negative are counter-clockwise.'),
'#field_suffix' => '°',
'#required' => TRUE,
];
$form['bgcolor'] = [
'#type' => 'textfield',
'#default_value' => $this->configuration['bgcolor'],
'#title' => $this->t('Background color'),
'#description' => $this->t('The background color to use for exposed areas of the image. Use web-style hex colors (#FFFFFF for white, #000000 for black). Leave blank for transparency on image types that support it.'),
'#size' => 7,
'#maxlength' => 7,
];
$form['random'] = [
'#type' => 'checkbox',
'#default_value' => $this->configuration['random'],
'#title' => $this->t('Randomize'),
'#description' => $this->t('Randomize the rotation angle for each image. The angle specified above is used as a maximum.'),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
if (!$form_state->isValueEmpty('bgcolor') && !Color::validateHex($form_state->getValue('bgcolor'))) {
$form_state->setErrorByName('bgcolor', $this->t('Background color must be a hexadecimal color value.'));
}
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
parent::submitConfigurationForm($form, $form_state);
$this->configuration['degrees'] = $form_state->getValue('degrees');
$this->configuration['bgcolor'] = $form_state->getValue('bgcolor');
$this->configuration['random'] = $form_state->getValue('random');
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Drupal\image\Plugin\ImageEffect;
use Drupal\Core\Image\ImageInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\image\Attribute\ImageEffect;
/**
* Scales and crops an image resource.
*/
#[ImageEffect(
id: "image_scale_and_crop",
label: new TranslatableMarkup("Scale and crop"),
description: new TranslatableMarkup("Scale and crop will maintain the aspect-ratio of the original image, then crop the larger dimension. This is most useful for creating perfectly square thumbnails without stretching the image.")
)]
class ScaleAndCropImageEffect extends CropImageEffect {
/**
* {@inheritdoc}
*/
public function applyEffect(ImageInterface $image) {
$width = $this->configuration['width'];
$height = $this->configuration['height'];
$scale = max($width / $image->getWidth(), $height / $image->getHeight());
[$x, $y] = explode('-', $this->configuration['anchor']);
$x = image_filter_keyword($x, $image->getWidth() * $scale, $width);
$y = image_filter_keyword($y, $image->getHeight() * $scale, $height);
if (!$image->apply('scale_and_crop', ['x' => $x, 'y' => $y, 'width' => $width, 'height' => $height])) {
$this->logger->error('Image scale and crop failed using the %toolkit toolkit on %path (%mimetype, %dimensions)', ['%toolkit' => $image->getToolkitId(), '%path' => $image->getSource(), '%mimetype' => $image->getMimeType(), '%dimensions' => $image->getWidth() . 'x' . $image->getHeight()]);
return FALSE;
}
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getSummary() {
$summary = [
'#theme' => 'image_scale_and_crop_summary',
'#data' => $this->configuration,
];
$summary += parent::getSummary();
return $summary;
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace Drupal\image\Plugin\ImageEffect;
use Drupal\Component\Utility\Image;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Image\ImageInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\image\Attribute\ImageEffect;
/**
* Scales an image resource.
*/
#[ImageEffect(
id: "image_scale",
label: new TranslatableMarkup("Scale"),
description: new TranslatableMarkup("Scaling will maintain the aspect-ratio of the original image. If only a single dimension is specified, the other dimension will be calculated.")
)]
class ScaleImageEffect extends ResizeImageEffect {
/**
* {@inheritdoc}
*/
public function applyEffect(ImageInterface $image) {
if (!$image->scale($this->configuration['width'], $this->configuration['height'], $this->configuration['upscale'])) {
$this->logger->error('Image scale failed using the %toolkit toolkit on %path (%mimetype, %dimensions)', ['%toolkit' => $image->getToolkitId(), '%path' => $image->getSource(), '%mimetype' => $image->getMimeType(), '%dimensions' => $image->getWidth() . 'x' . $image->getHeight()]);
return FALSE;
}
return TRUE;
}
/**
* {@inheritdoc}
*/
public function transformDimensions(array &$dimensions, $uri) {
if ($dimensions['width'] && $dimensions['height']) {
Image::scaleDimensions($dimensions, $this->configuration['width'], $this->configuration['height'], $this->configuration['upscale']);
}
}
/**
* {@inheritdoc}
*/
public function getSummary() {
$summary = [
'#theme' => 'image_scale_summary',
'#data' => $this->configuration,
];
$summary += parent::getSummary();
return $summary;
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return parent::defaultConfiguration() + [
'upscale' => FALSE,
];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form = parent::buildConfigurationForm($form, $form_state);
$form['width']['#required'] = FALSE;
$form['height']['#required'] = FALSE;
$form['upscale'] = [
'#type' => 'checkbox',
'#default_value' => $this->configuration['upscale'],
'#title' => $this->t('Allow Upscaling'),
'#description' => $this->t('Let scale make images larger than their original size.'),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
parent::validateConfigurationForm($form, $form_state);
if ($form_state->isValueEmpty('width') && $form_state->isValueEmpty('height')) {
$form_state->setErrorByName('data', $this->t('Width and height can not both be blank.'));
}
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
parent::submitConfigurationForm($form, $form_state);
$this->configuration['upscale'] = $form_state->getValue('upscale');
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Drupal\image\Plugin\migrate\destination;
use Drupal\Component\Plugin\Exception\PluginNotFoundException;
use Drupal\migrate\Attribute\MigrateDestination;
use Drupal\migrate\MigrateException;
use Drupal\migrate\Plugin\migrate\destination\EntityConfigBase;
use Drupal\migrate\Row;
/**
* Entity image style destination.
*
* Every migration that uses this destination must have an optional
* dependency on the d6_file migration to ensure it runs first.
*/
#[MigrateDestination('entity:image_style')]
class EntityImageStyle extends EntityConfigBase {
/**
* {@inheritdoc}
*/
public function import(Row $row, array $old_destination_id_values = []) {
$effects = [];
// Need to set the effects property to null on the row before the ImageStyle
// is created, this prevents improper effect plugin initialization.
if ($row->getDestinationProperty('effects')) {
$effects = $row->getDestinationProperty('effects');
$row->setDestinationProperty('effects', []);
}
/** @var \Drupal\image\Entity\ImageStyle $style */
$style = $this->getEntity($row, $old_destination_id_values);
// Iterate the effects array so each effect plugin can be initialized.
// Catch any missing plugin exceptions.
foreach ($effects as $effect) {
try {
$style->addImageEffect($effect);
}
catch (PluginNotFoundException $e) {
throw new MigrateException($e->getMessage(), 0, $e);
}
}
$style->save();
return [$style->id()];
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Drupal\image\Plugin\migrate\field\d6;
use Drupal\file\Plugin\migrate\field\d6\FileField;
use Drupal\migrate_drupal\Attribute\MigrateField;
// cspell:ignore imagefield
/**
* MigrateField Plugin for Drupal 6 image fields.
*/
#[MigrateField(
id: 'imagefield',
core: [6],
source_module: 'imagefield',
destination_module: 'image',
)]
class ImageField extends FileField {}

View File

@@ -0,0 +1,45 @@
<?php
namespace Drupal\image\Plugin\migrate\field\d7;
use Drupal\migrate\Plugin\MigrationInterface;
use Drupal\migrate_drupal\Attribute\MigrateField;
use Drupal\migrate_drupal\Plugin\migrate\field\FieldPluginBase;
#[MigrateField(
id: 'image',
core: [7],
source_module: 'image',
destination_module: 'image',
)]
class ImageField extends FieldPluginBase {
/**
* {@inheritdoc}
*/
public function getFieldWidgetMap() {
return [
'image' => 'image_default',
'image_miw' => 'image_image',
];
}
/**
* {@inheritdoc}
*/
public function defineValueProcessPipeline(MigrationInterface $migration, $field_name, $data) {
$process = [
'plugin' => 'sub_process',
'source' => $field_name,
'process' => [
'target_id' => 'fid',
'alt' => 'alt',
'title' => 'title',
'width' => 'width',
'height' => 'height',
],
];
$migration->mergeProcessOfProperty($field_name, $process);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Drupal\image\Plugin\migrate\process\d6;
use Drupal\migrate\Attribute\MigrateProcess;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\Row;
// cspell:ignore imagecache
/**
* Defines the image cache actions migrate process plugin.
*/
#[MigrateProcess('d6_imagecache_actions')]
class ImageCacheActions extends ProcessPluginBase {
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
$effects = [];
foreach ($row->getSourceProperty('actions') as $action) {
if (empty($action['action'])) {
continue;
}
$id = preg_replace('/^imagecache/', 'image', $action['action']);
if ($id === 'image_crop') {
$action['data']['anchor'] = $action['data']['xoffset'] . '-' . $action['data']['yoffset'];
if (!preg_match('/^[a-z]*\-[a-z]*/', $action['data']['anchor'])) {
$migrate_executable->message->display(
'The Drupal 8 image crop effect does not support numeric values for x and y offsets. Use keywords to set crop effect offsets instead.',
'error'
);
}
unset($action['data']['xoffset']);
unset($action['data']['yoffset']);
}
$effects[] = [
'id' => $id,
'weight' => $action['weight'],
'data' => $action['data'],
];
}
return $effects;
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Drupal\image\Plugin\migrate\source\d6;
use Drupal\migrate_drupal\Plugin\migrate\source\DrupalSqlBase;
use Drupal\migrate\Row;
// cspell:ignore imagecache presetid presetname
/**
* Drupal 6 imagecache presets source from database.
*
* For available configuration keys, refer to the parent classes.
*
* @see \Drupal\migrate\Plugin\migrate\source\SqlBase
* @see \Drupal\migrate\Plugin\migrate\source\SourcePluginBase
*
* @MigrateSource(
* id = "d6_imagecache_presets",
* source_module = "imagecache"
* )
*/
class ImageCachePreset extends DrupalSqlBase {
/**
* {@inheritdoc}
*/
public function query() {
$query = $this->select('imagecache_preset', 'icp')
->fields('icp');
return $query;
}
/**
* {@inheritdoc}
*/
public function fields() {
$fields = [
'presetid' => $this->t('Preset ID'),
'presetname' => $this->t('Preset Name'),
];
return $fields;
}
/**
* {@inheritdoc}
*/
public function getIds() {
$ids['presetid']['type'] = 'integer';
return $ids;
}
/**
* {@inheritdoc}
*/
public function prepareRow(Row $row) {
$actions = [];
$results = $this->select('imagecache_action', 'ica')
->fields('ica')
->condition('presetid', $row->getSourceProperty('presetid'))
->execute();
foreach ($results as $key => $result) {
$actions[$key] = $result;
$actions[$key]['data'] = unserialize($result['data']);
}
$row->setSourceProperty('actions', $actions);
return parent::prepareRow($row);
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Drupal\image\Plugin\migrate\source\d7;
use Drupal\migrate_drupal\Plugin\migrate\source\DrupalSqlBase;
use Drupal\migrate\Row;
// cspell:ignore isid
/**
* Drupal 7 image styles source from database.
*
* For available configuration keys, refer to the parent classes.
*
* @see \Drupal\migrate\Plugin\migrate\source\SqlBase
* @see \Drupal\migrate\Plugin\migrate\source\SourcePluginBase
*
* @MigrateSource(
* id = "d7_image_styles",
* source_module = "image"
* )
*/
class ImageStyles extends DrupalSqlBase {
/**
* {@inheritdoc}
*/
public function query() {
return $this->select('image_styles', 'ims')
->fields('ims');
}
/**
* {@inheritdoc}
*/
public function fields() {
$fields = [
'isid' => $this->t('The primary identifier for an image style.'),
'name' => $this->t('The style machine name.'),
'label' => $this->t('The style administrative name.'),
];
return $fields;
}
/**
* {@inheritdoc}
*/
public function getIds() {
$ids['isid']['type'] = 'integer';
return $ids;
}
/**
* {@inheritdoc}
*/
public function prepareRow(Row $row) {
$effects = [];
$results = $this->select('image_effects', 'ie')
->fields('ie')
->condition('isid', $row->getSourceProperty('isid'))
->execute();
foreach ($results as $key => $result) {
$result['data'] = unserialize($result['data']);
$effects[$key] = $result;
}
$row->setSourceProperty('effects', $effects);
return parent::prepareRow($row);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Drupal\image\Routing;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Routing\Route;
/**
* Defines a route subscriber to register a URL for serving image styles.
*/
class ImageStyleRoutes implements ContainerInjectionInterface {
/**
* The stream wrapper manager service.
*
* @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface
*/
protected $streamWrapperManager;
/**
* Constructs a new ImageStyleRoutes object.
*
* @param \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $stream_wrapper_manager
* The stream wrapper manager service.
*/
public function __construct(StreamWrapperManagerInterface $stream_wrapper_manager) {
$this->streamWrapperManager = $stream_wrapper_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('stream_wrapper_manager')
);
}
/**
* Returns an array of route objects.
*
* @return \Symfony\Component\Routing\Route[]
* An array of route objects.
*/
public function routes() {
$routes = [];
// Generate image derivatives of publicly available files. If clean URLs are
// disabled image derivatives will always be served through the menu system.
// If clean URLs are enabled and the image derivative already exists, PHP
// will be bypassed.
$directory_path = $this->streamWrapperManager->getViaScheme('public')->getDirectoryPath();
$routes['image.style_public'] = new Route(
'/' . $directory_path . '/styles/{image_style}/{scheme}',
[
'_controller' => 'Drupal\image\Controller\ImageStyleDownloadController::deliver',
'required_derivative_scheme' => 'public',
],
[
'_access' => 'TRUE',
]
);
return $routes;
}
}

View File

@@ -0,0 +1,14 @@
{#
/**
* @file
* Default theme implementation for a 3x3 grid of checkboxes for image anchors.
*
* Available variables:
* - table: HTML for the table of image anchors.
*
* @see template_preprocess_image_anchor()
*
* @ingroup themeable
*/
#}
{{ table }}

View File

@@ -0,0 +1,32 @@
{#
/**
* @file
* Default theme implementation for a summary of an image crop effect.
*
* Available variables:
* - data: The current configuration for this resize effect, including:
* - width: The width of the resized image.
* - height: The height of the resized image.
* - anchor: The part of the image that will be retained after cropping.
* - anchor_label: The translated label of the crop anchor.
* - effect: The effect information, including:
* - id: The effect identifier.
* - label: The effect name.
* - description: The effect description.
*
* @ingroup themeable
*/
#}
{% if data.width and data.height -%}
{{ data.width }}×{{ data.height }}
{%- else -%}
{% if data.width %}
{% trans %}
width {{ data.width }}
{% endtrans %}
{% elseif data.height %}
{% trans %}
height {{ data.height }}
{% endtrans %}
{% endif %}
{%- endif %}

View File

@@ -0,0 +1,20 @@
{#
/**
* @file
* Default theme implementation to display a formatted image field.
*
* Available variables:
* - image: A collection of image data.
* - image_style: An optional image style.
* - url: An optional URL the image can be linked to.
*
* @see template_preprocess_image_formatter()
*
* @ingroup themeable
*/
#}
{% if url %}
{{ link(image, url) }}
{% else %}
{{ image }}
{% endif %}

View File

@@ -0,0 +1,30 @@
{#
/**
* @file
* Default theme implementation for a summary of an image resize effect.
*
* Available variables:
* - data: The current configuration for this resize effect, including:
* - width: The width of the resized image.
* - height: The height of the resized image.
* - effect: The effect information, including:
* - id: The effect identifier.
* - label: The effect name.
* - description: The effect description.
*
* @ingroup themeable
*/
#}
{% if data.width and data.height -%}
{{ data.width }}×{{ data.height }}
{%- else -%}
{% if data.width %}
{% trans %}
width {{ data.width }}
{% endtrans %}
{% elseif data.height %}
{% trans %}
height {{ data.height }}
{% endtrans %}
{% endif %}
{%- endif %}

View File

@@ -0,0 +1,28 @@
{#
/**
* @file
* Default theme implementation for a summary of an image rotate effect.
*
* Available variables:
* - data: The current configuration for this resize effect, including:
* - degrees: Degrees to rotate the image, positive values will rotate the
* image clockwise, negative values counter-clockwise.
* - bgcolor: The hex background color of the new areas created as consequence
* of rotation.
* - random: If the rotation angle is randomized.
* - effect: The effect information, including:
* - id: The effect identifier.
* - label: The effect name.
* - description: The effect description.
*
* @ingroup themeable
*/
#}
{% if data.random %}
{% set degrees = data.degrees|abs %}
{% trans %}
random between -{{ degrees }}° and {{ degrees }}°
{% endtrans %}
{% else %}
{{ data.degrees }}°
{% endif %}

View File

@@ -0,0 +1,32 @@
{#
/**
* @file
* Default theme implementation for a summary of an image scale and crop effect.
*
* Available variables:
* - data: The current configuration for this resize effect, including:
* - width: The width of the resized image.
* - height: The height of the resized image.
* - anchor: The part of the image that will be retained after cropping.
* - anchor_label: The translated label of the crop anchor.
* - effect: The effect information, including:
* - id: The effect identifier.
* - label: The effect name.
* - description: The effect description.
*
* @ingroup themeable
*/
#}
{% if data.width and data.height -%}
{{ data.width }}×{{ data.height }}
{%- else -%}
{% if data.width %}
{% trans %}
width {{ data.width }}
{% endtrans %}
{% elseif data.height %}
{% trans %}
height {{ data.height }}
{% endtrans %}
{% endif %}
{%- endif %}

View File

@@ -0,0 +1,37 @@
{#
/**
* @file
* Default theme implementation for a summary of an image scale effect.
*
* Available variables:
* - data: The current configuration for this resize effect, including:
* - width: The width of the resized image.
* - height: The height of the resized image.
* - upscale: If images larger than their original size can scale.
* - effect: The effect information, including:
* - id: The effect identifier.
* - label: The effect name.
* - description: The effect description.
*
* @ingroup themeable
*/
#}
{% if data.width and data.height -%}
{{ data.width }}×{{ data.height }}
{%- else -%}
{% if data.width %}
{% trans %}
width {{ data.width }}
{% endtrans %}
{% elseif data.height %}
{% trans %}
height {{ data.height }}
{% endtrans %}
{% endif %}
{%- endif %}
{% if data.upscale %}
{% trans %}
(upscaling allowed)
{% endtrans %}
{% endif %}

View File

@@ -0,0 +1,57 @@
{#
/**
* @file
* Default theme implementation to display a preview of an image style.
*
* Available variables:
* - style_id: The ID of the image style.
* - style_name: The name of the image style.
* - cache_bypass: A timestamp token used to avoid browser caching of images.
* - original: An associative array containing:
* - url: The URL of the original image.
* - width: The width in pixels of the original image.
* - height: The height in pixels of the original image.
* - rendered: The render array for the original image.
* - derivative: An associative array containing:
* - url: The URL of the derivative image.
* - width: The width in pixels of the derivative image.
* - height: The height in pixels of the derivative image.
* - rendered: The rendered derivative image.
* - preview: An associative array containing:
* - original: An associative array containing:
* - width: The width in pixels of the original image in the preview.
* - height: The height in pixels of the original image in the preview.
* - derivative: An associative array containing:
* - width: The width in pixels of the derivative image in the preview.
* - height: The height in pixels of the derivative image in the preview.
*
* @see template_preprocess_image_style_preview()
*
* @ingroup themeable
*/
#}
<div class="image-style-preview preview clearfix">
{# Preview of the original image. #}
<div class="preview-image-wrapper">
{{ 'original'|t }} (<a href="{{ original.url }}">{{ 'view actual size'|t }}</a>)
<div class="preview-image original-image" style="width: {{ preview.original.width }}px; height: {{ preview.original.height }}px;">
<a href="{{ original.url }}">
{{ original.rendered }}
</a>
<div class="height" style="height: {{ preview.original.height }}px"><span>{{ original.height }}px</span></div>
<div class="width" style="width: {{ preview.original.width }}px"><span>{{ original.width }}px</span></div>
</div>
</div>
{# Derivative of the image style. #}
<div class="preview-image-wrapper">
{{ style_name }} (<a href="{{ derivative.url }}?{{ cache_bypass }}">{{ 'view actual size'|t }}</a>)
<div class="preview-image modified-image" style="width: {{ preview.derivative.width }}px; height: {{ preview.derivative.height }}px;">
<a href="{{ derivative.url }}?{{ cache_bypass }}">
{{ derivative.rendered }}
</a>
<div class="height" style="height: {{ preview.derivative.height }}px"><span>{{ derivative.height }}px</span></div>
<div class="width" style="width: {{ preview.derivative.width }}px"><span>{{ derivative.width }}px</span></div>
</div>
</div>
</div>

View File

@@ -0,0 +1,20 @@
{#
/**
* @file
* Default theme implementation for an image using a specific image style.
*
* Available variables:
* - attributes: HTML attributes for the image, including the following:
* - src: Full URL or relative path to the image file.
* - class: One or more classes to be applied to the image.
* - width: The width of the image (if known).
* - height: The height of the image (if known).
* - title: The title of the image.
* - alt: The alternative text for the image.
*
* @see template_preprocess_image_style()
*
* @ingroup themeable
*/
#}
{{ image }}

View File

@@ -0,0 +1,19 @@
{#
/**
* @file
* Default theme implementation for an image field widget.
*
* Available variables:
* - attributes: HTML attributes for the containing element.
* - data: Render elements of the image widget.
*
* @see template_preprocess_image_widget()
*
* @ingroup themeable
*/
#}
<div{{ attributes }}>
{{ data.preview }}
{# Render widget data without the image preview that was output already. #}
{{ data|without('preview') }}
</div>

View File

@@ -0,0 +1,10 @@
name: 'Image access test for hidden fields'
type: module
description: 'Provides an entity field access hook implementation to set an image field as hidden.'
package: Testing
# version: VERSION
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,21 @@
<?php
/**
* @file
* Image field access for hidden fields.
*/
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Access\AccessResult;
/**
* Implements hook_entity_field_access().
*/
function image_access_test_hidden_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, ?FieldItemListInterface $items = NULL) {
if ($field_definition->getName() == 'field_image' && $operation == 'edit') {
return AccessResult::forbidden();
}
return AccessResult::neutral();
}

View File

@@ -0,0 +1,10 @@
name: 'Image test'
type: module
description: 'Provides hook implementations for testing Image module functionality.'
package: Testing
# version: VERSION
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,48 @@
<?php
/**
* @file
* Provides Image module hook implementations for testing purposes.
*/
use Drupal\image\ImageStyleInterface;
function image_module_test_file_download($uri) {
$default_uri = \Drupal::state()->get('image.test_file_download', FALSE);
if ($default_uri == $uri) {
return ['X-Image-Owned-By' => 'image_module_test'];
}
}
/**
* Implements hook_image_effect_info_alter().
*/
function image_module_test_image_effect_info_alter(&$effects) {
$state = \Drupal::state();
// The 'image_module_test.counter' state variable value is set and accessed
// from the ImageEffectsTest::testImageEffectsCaching() test and used to
// signal if the image effect plugin definitions were computed or were
// retrieved from the cache.
// @see \Drupal\Tests\image\Kernel\ImageEffectsTest::testImageEffectsCaching()
$counter = $state->get('image_module_test.counter');
// Increase the test counter, signaling that image effects were processed,
// rather than being served from the cache.
$state->set('image_module_test.counter', ++$counter);
}
/**
* Implements hook_image_style_presave().
*
* Used to save test third party settings in the image style entity.
*/
function image_module_test_image_style_presave(ImageStyleInterface $style) {
$style->setThirdPartySetting('image_module_test', 'foo', 'bar');
}
/**
* Implements hook_image_style_flush().
*/
function image_module_test_image_style_flush($style, $path = NULL) {
$state = \Drupal::state();
$state->set('image_module_test_image_style_flush.called', $path);
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Drupal\image_module_test\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\FormatterBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Empty renderer for a dummy field with an AJAX handler.
*/
#[FieldFormatter(
id: 'image_module_test_dummy_ajax_formatter',
label: new TranslatableMarkup('Dummy AJAX'),
field_types: [
'image_module_test_dummy_ajax',
],
)]
class DummyAjaxFormatter extends FormatterBase {
/**
* {@inheritdoc}
*/
public function settingsSummary() {
$summary = [];
$summary[] = $this->t('Renders nothing');
return $summary;
}
/**
* {@inheritdoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode) {
$element = [];
return $element;
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Drupal\image_module_test\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\FormatterBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Plugin implementation of the Dummy image formatter.
*/
#[FieldFormatter(
id: 'dummy_image_formatter',
label: new TranslatableMarkup('Dummy image'),
field_types: [
'image',
],
)]
class DummyImageFormatter extends FormatterBase {
/**
* {@inheritdoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode) {
return [
['#markup' => 'Dummy'],
];
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Drupal\image_module_test\Plugin\Field\FieldType;
use Drupal\Core\Field\Attribute\FieldType;
use Drupal\Core\Field\FieldItemBase;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\TypedData\DataDefinition;
/**
* Defines a dummy field containing an AJAX handler.
*/
#[FieldType(
id: "image_module_test_dummy_ajax",
label: new TranslatableMarkup("Dummy AJAX"),
description: new TranslatableMarkup("A field containing an AJAX handler."),
default_widget: "image_module_test_dummy_ajax_widget",
default_formatter: "image_module_test_dummy_ajax_formatter"
)]
class DummyAjaxItem extends FieldItemBase {
/**
* {@inheritdoc}
*/
public static function schema(FieldStorageDefinitionInterface $field_definition) {
return [
'columns' => [
'value' => [
'type' => 'varchar',
'length' => 255,
],
],
];
}
/**
* {@inheritdoc}
*/
public function isEmpty() {
return empty($this->get('value')->getValue());
}
/**
* {@inheritdoc}
*/
public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) {
$properties['value'] = DataDefinition::create('string')
->setLabel(new TranslatableMarkup('Dummy string value'));
return $properties;
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Drupal\image_module_test\Plugin\Field\FieldWidget;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Field\Attribute\FieldWidget;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\WidgetBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Default widget for Dummy AJAX test.
*/
#[FieldWidget(
id: 'image_module_test_dummy_ajax_widget',
label: new TranslatableMarkup('Dummy AJAX widget'),
field_types: ['image_module_test_dummy_ajax'],
multiple_values: TRUE,
)]
class DummyAjaxWidget extends WidgetBase {
/**
* {@inheritdoc}
*/
public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
$element['select_widget'] = [
'#type' => 'select',
'#title' => $this->t('Dummy select'),
'#options' => ['pow' => 'Pow!', 'bam' => 'Bam!'],
'#required' => TRUE,
'#ajax' => [
'callback' => static::class . '::dummyAjaxCallback',
'effect' => 'fade',
],
];
return $element;
}
/**
* Ajax callback for Dummy AJAX test.
*
* @param array $form
* The build form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
*
* @return \Drupal\Core\Ajax\AjaxResponse
* Ajax response.
*/
public static function dummyAjaxCallback(array &$form, FormStateInterface $form_state) {
return new AjaxResponse();
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace Drupal\image_module_test\Plugin\ImageEffect;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\HtmlCommand;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Image\ImageInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\image\Attribute\ImageEffect;
use Drupal\image\ConfigurableImageEffectBase;
/**
* Provides a test effect using Ajax in the configuration form.
*/
#[ImageEffect(
id: "image_module_test_ajax",
label: new TranslatableMarkup("Ajax test")
)]
class AjaxTestImageEffect extends ConfigurableImageEffectBase {
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'test_parameter' => 0,
];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['test_parameter'] = [
'#type' => 'number',
'#title' => $this->t('Test parameter'),
'#default_value' => $this->configuration['test_parameter'],
'#min' => 0,
];
$form['ajax_refresh'] = [
'#type' => 'button',
'#value' => $this->t('Ajax refresh'),
'#ajax' => ['callback' => [$this, 'ajaxCallback']],
];
$form['ajax_value'] = [
'#id' => 'ajax-value',
'#type' => 'item',
'#title' => $this->t('Ajax value'),
'#markup' => 'bar',
];
return $form;
}
/**
* AJAX callback.
*/
public function ajaxCallback($form, FormStateInterface $form_state) {
$item = [
'#type' => 'item',
'#title' => $this->t('Ajax value'),
'#markup' => microtime(),
];
$response = new AjaxResponse();
$response->addCommand(new HtmlCommand('#ajax-value', $item));
return $response;
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
parent::submitConfigurationForm($form, $form_state);
$this->configuration['test_parameter'] = $form_state->getValue('test_parameter');
}
/**
* {@inheritdoc}
*/
public function applyEffect(ImageInterface $image) {
return TRUE;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Drupal\image_module_test\Plugin\ImageEffect;
use Drupal\Core\Image\ImageInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\image\Attribute\ImageEffect;
use Drupal\image\ImageEffectBase;
/**
* Performs no operation on an image resource.
*/
#[ImageEffect(
id: "image_module_test_null",
label: new TranslatableMarkup("Image module test")
)]
class NullTestImageEffect extends ImageEffectBase {
/**
* {@inheritdoc}
*/
public function transformDimensions(array &$dimensions, $uri) {
// Unset image dimensions.
$dimensions['width'] = $dimensions['height'] = NULL;
}
/**
* {@inheritdoc}
*/
public function applyEffect(ImageInterface $image) {
return TRUE;
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace Drupal\image_module_test\Plugin\ImageEffect;
use Drupal\Core\Image\ImageInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\image\Attribute\ImageEffect;
use Drupal\image\ImageEffectBase;
/**
* Performs an image operation that depends on the URI of the original image.
*/
#[ImageEffect(
id: "image_module_test_uri_dependent",
label: new TranslatableMarkup("URI dependent test image effect")
)]
class UriDependentTestImageEffect extends ImageEffectBase {
/**
* {@inheritdoc}
*/
public function transformDimensions(array &$dimensions, $uri) {
$dimensions = $this->getUriDependentDimensions($uri);
}
/**
* {@inheritdoc}
*/
public function applyEffect(ImageInterface $image) {
$dimensions = $this->getUriDependentDimensions($image->getSource());
return $image->resize($dimensions['width'], $dimensions['height']);
}
/**
* Make the image dimensions dependent on the image file extension.
*
* @param string $uri
* Original image file URI.
*
* @return array
* Associative array.
* - width: Integer with the derivative image width.
* - height: Integer with the derivative image height.
*/
protected function getUriDependentDimensions($uri) {
$dimensions = [];
$extension = pathinfo($uri, PATHINFO_EXTENSION);
switch (strtolower($extension)) {
case 'png':
$dimensions['width'] = $dimensions['height'] = 100;
break;
case 'gif':
$dimensions['width'] = $dimensions['height'] = 50;
break;
default:
$dimensions['width'] = $dimensions['height'] = 20;
break;
}
return $dimensions;
}
}

View File

@@ -0,0 +1,13 @@
name: 'Image test views'
type: module
description: 'Provides default views for views image tests.'
package: Testing
# version: VERSION
dependencies:
- drupal:image
- drupal:views
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,76 @@
langcode: en
status: true
dependencies:
module:
- file
- user
id: test_image_user_image_data
label: test_image_user_image_data
module: views
description: ''
tag: ''
base_table: users_field_data
base_field: uid
display:
default:
display_plugin: default
id: default
display_title: Default
position: 0
display_options:
access:
type: perm
options:
perm: 'access user profiles'
cache:
type: tag
style:
type: table
options:
grouping: { }
row_class: ''
default_row_class: true
override: true
sticky: false
caption: ''
summary: ''
description: ''
columns:
name: name
fid: fid
info:
name:
sortable: false
default_sort_order: asc
align: ''
separator: ''
empty_column: false
responsive: ''
fid:
sortable: false
default_sort_order: asc
align: ''
separator: ''
empty_column: false
responsive: ''
default: '-1'
empty_table: false
row:
type: fields
options:
inline: { }
separator: ''
hide_empty: false
default_field_elements: true
relationships:
user_picture_target_id:
id: user_picture_target_id
table: user__user_picture
field: user_picture_target_id
relationship: none
group_type: group
admin_label: 'image from user_picture'
required: true
plugin_id: standard
arguments: { }
display_extenders: { }

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\image\Functional;
use Drupal\Tests\system\Functional\Module\GenericModuleTestBase;
/**
* Generic module test for image.
*
* @group image
*/
class GenericTest extends GenericModuleTestBase {}

View File

@@ -0,0 +1,557 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\image\Functional;
use Drupal\Core\Entity\Entity\EntityViewDisplay;
use Drupal\Core\Url;
use Drupal\file\Entity\File;
use Drupal\image\Entity\ImageStyle;
use Drupal\image\ImageStyleInterface;
use Drupal\node\Entity\Node;
use Drupal\Tests\TestFileCreationTrait;
/**
* Tests creation, deletion, and editing of image styles and effects.
*
* @group image
* @group #slow
*/
class ImageAdminStylesTest extends ImageFieldTestBase {
use TestFileCreationTrait {
getTestFiles as drupalGetTestFiles;
compareFiles as drupalCompareFiles;
}
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Given an image style, generate an image.
*/
public function createSampleImage(ImageStyleInterface $style) {
static $file_path;
// First, we need to make sure we have an image in our testing
// file directory. Copy over an image on the first run.
if (!isset($file_path)) {
$files = $this->drupalGetTestFiles('image');
$file = reset($files);
$file_path = \Drupal::service('file_system')->copy($file->uri, 'public://');
}
return $style->buildUrl($file_path) ? $file_path : FALSE;
}
/**
* Count the number of images currently create for a style.
*/
public function getImageCount(ImageStyleInterface $style) {
$count = 0;
if (is_dir('public://styles/' . $style->id())) {
$count = count(\Drupal::service('file_system')->scanDirectory('public://styles/' . $style->id(), '/.*/'));
}
return $count;
}
/**
* Tests creating an image style with a numeric name.
*/
public function testNumericStyleName(): void {
$style_name = rand();
$style_label = $this->randomString();
$edit = [
'name' => $style_name,
'label' => $style_label,
];
$this->drupalGet('admin/config/media/image-styles/add');
$this->submitForm($edit, 'Create new style');
$this->assertSession()->statusMessageContains("Style {$style_label} was created.", 'status');
$options = image_style_options();
$this->assertArrayHasKey($style_name, $options);
}
/**
* General test to add a style, add/remove/edit effects to it, then delete it.
*/
public function testStyle(): void {
$admin_path = 'admin/config/media/image-styles';
// Setup a style to be created and effects to add to it.
$style_name = $this->randomMachineName(10);
$style_label = $this->randomString();
$style_path = $admin_path . '/manage/' . $style_name;
$effect_edits = [
'image_resize' => [
'width' => 100,
'height' => 101,
],
'image_scale' => [
'width' => 110,
'height' => 111,
'upscale' => 1,
],
'image_scale_and_crop' => [
'width' => 120,
'height' => 121,
],
'image_crop' => [
'width' => 130,
'height' => 131,
'anchor' => 'left-top',
],
'image_desaturate' => [
// No options for desaturate.
],
'image_rotate' => [
'degrees' => 5,
'random' => 1,
'bgcolor' => '#FFFF00',
],
];
// Add style form.
$edit = [
'name' => $style_name,
'label' => $style_label,
];
$this->drupalGet($admin_path . '/add');
$this->submitForm($edit, 'Create new style');
$this->assertSession()->statusMessageContains("Style {$style_label} was created.", 'status');
// Ensure that the expected entity operations are there.
$this->drupalGet($admin_path);
$this->assertSession()->linkByHrefExists($style_path);
$this->assertSession()->linkByHrefExists($style_path . '/flush');
$this->assertSession()->linkByHrefExists($style_path . '/delete');
// Add effect form.
// Add each sample effect to the style.
foreach ($effect_edits as $effect => $edit) {
$edit_data = [];
foreach ($edit as $field => $value) {
$edit_data['data[' . $field . ']'] = $value;
}
// Add the effect.
$this->drupalGet($style_path);
$this->submitForm(['new' => $effect], 'Add');
if (!empty($edit)) {
$effect_label = \Drupal::service('plugin.manager.image.effect')->createInstance($effect)->label();
$this->assertSession()->pageTextContains("Add {$effect_label} effect to style {$style_label}");
$this->submitForm($edit_data, 'Add effect');
}
}
// Load the saved image style.
$style = ImageStyle::load($style_name);
// Ensure that third party settings were added to the config entity.
// These are added by a hook_image_style_presave() implemented in
// image_module_test module.
$this->assertEquals('bar', $style->getThirdPartySetting('image_module_test', 'foo'), 'Third party settings were added to the image style.');
// Ensure that the image style URI matches our expected path.
$style_uri_path = $style->toUrl()->toString();
$this->assertStringContainsString($style_path, $style_uri_path, 'The image style URI is correct.');
// Confirm that all effects on the image style have settings that match
// what was saved.
$uuids = [];
foreach ($style->getEffects() as $uuid => $effect) {
// Store the uuid for later use.
$uuids[$effect->getPluginId()] = $uuid;
$effect_configuration = $effect->getConfiguration();
foreach ($effect_edits[$effect->getPluginId()] as $field => $value) {
$this->assertEquals($effect_configuration['data'][$field], $value, "The $field field in the {$effect->getPluginId()} effect has the correct value of $value.");
}
}
// Assert that every effect was saved.
foreach (array_keys($effect_edits) as $effect_name) {
$this->assertTrue(isset($uuids[$effect_name]), "A $effect_name effect was saved with ID $uuids[$effect_name]");
}
// Image style overview form (ordering and renaming).
// Confirm the order of effects is maintained according to the order we
// added the fields.
$effect_edits_order = array_keys($effect_edits);
$order_correct = TRUE;
$index = 0;
foreach ($style->getEffects() as $effect) {
if ($effect_edits_order[$index] != $effect->getPluginId()) {
$order_correct = FALSE;
}
$index++;
}
$this->assertTrue($order_correct, 'The order of the effects is correctly set by default.');
// Test the style overview form.
// Change the name of the style and adjust the weights of effects.
$style_name = $this->randomMachineName(10);
$style_label = $this->randomMachineName();
$weight = count($effect_edits);
$edit = [
'name' => $style_name,
'label' => $style_label,
];
foreach ($style->getEffects() as $uuid => $effect) {
$edit['effects[' . $uuid . '][weight]'] = $weight;
$weight--;
}
// Create an image to make sure it gets flushed after saving.
$image_path = $this->createSampleImage($style);
$this->assertEquals(1, $this->getImageCount($style), "Image style {$style->label()} image $image_path successfully generated.");
$this->drupalGet($style_path);
$this->submitForm($edit, 'Save');
// Note that after changing the style name, the style path is changed.
$style_path = 'admin/config/media/image-styles/manage/' . $style_name;
// Check that the URL was updated.
$this->drupalGet($style_path);
$this->assertSession()->titleEquals("Edit style $style_label | Drupal");
// Check that the available image effects are properly sorted.
$option = $this->assertSession()->selectExists('edit-new--2')->findAll('css', 'option');
$this->assertEquals('Ajax test', $option[1]->getText(), '"Ajax test" is the first selectable effect.');
// Check that the image was flushed after updating the style.
// This is especially important when renaming the style. Make sure that
// the old image directory has been deleted.
$this->assertEquals(0, $this->getImageCount($style), "Image style {$style->label()} was flushed after renaming the style and updating the order of effects.");
// Load the style by the new name with the new weights.
$style = ImageStyle::load($style_name);
// Confirm the new style order was saved.
$effect_edits_order = array_reverse($effect_edits_order);
$order_correct = TRUE;
$index = 0;
foreach ($style->getEffects() as $effect) {
if ($effect_edits_order[$index] != $effect->getPluginId()) {
$order_correct = FALSE;
}
$index++;
}
$this->assertTrue($order_correct, 'The order of the effects is correctly set by default.');
// Image effect deletion form.
// Create an image to make sure it gets flushed after deleting an effect.
$image_path = $this->createSampleImage($style);
$this->assertEquals(1, $this->getImageCount($style), "Image style {$style->label()} image $image_path successfully generated.");
// Delete the 'image_crop' effect from the style.
$this->drupalGet($style_path . '/effects/' . $uuids['image_crop'] . '/delete');
$this->submitForm([], 'Delete');
// Confirm that the form submission was successful.
$this->assertSession()->statusCodeEquals(200);
$image_crop_effect = $style->getEffect($uuids['image_crop']);
$this->assertSession()->statusMessageContains("The image effect {$image_crop_effect->label()} has been deleted.", 'status');
// Confirm that there is no longer a link to the effect.
$this->assertSession()->linkByHrefNotExists($style_path . '/effects/' . $uuids['image_crop'] . '/delete');
// Refresh the image style information and verify that the effect was
// actually deleted.
$entity_type_manager = $this->container->get('entity_type.manager');
$style = $entity_type_manager->getStorage('image_style')->loadUnchanged($style->id());
$this->assertFalse($style->getEffects()->has($uuids['image_crop']), "Effect with ID {$uuids['image_crop']} no longer found on image style {$style->label()}");
// Additional test on Rotate effect, for transparent background.
$edit = [
'data[degrees]' => 5,
'data[random]' => 0,
'data[bgcolor]' => '',
];
$this->drupalGet($style_path);
$this->submitForm(['new' => 'image_rotate'], 'Add');
$this->submitForm($edit, 'Add effect');
$entity_type_manager = $this->container->get('entity_type.manager');
$style = $entity_type_manager->getStorage('image_style')->loadUnchanged($style_name);
$this->assertCount(6, $style->getEffects(), 'Rotate effect with transparent background was added.');
// Style deletion form.
// Delete the style.
$this->drupalGet($style_path . '/delete');
$this->submitForm([], 'Delete');
// Confirm the style directory has been removed.
$directory = 'public://styles/' . $style_name;
$this->assertDirectoryDoesNotExist($directory);
$this->assertNull(ImageStyle::load($style_name), "Image style {$style->label()} successfully deleted.");
// Test empty text when there are no image styles.
// Delete all image styles.
foreach (ImageStyle::loadMultiple() as $image_style) {
$image_style->delete();
}
// Confirm that the empty text is correct on the image styles page.
$this->drupalGet($admin_path);
$this->assertSession()->pageTextContains("There are currently no styles. Add a new one.");
$this->assertSession()->linkByHrefExists(Url::fromRoute('image.style_add')->toString());
}
/**
* Tests deleting a style and choosing a replacement style.
*/
public function testStyleReplacement(): void {
// Create a new style.
$style_name = $this->randomMachineName(10);
$style_label = $this->randomString();
$style = ImageStyle::create(['name' => $style_name, 'label' => $style_label]);
$style->save();
$style_path = 'admin/config/media/image-styles/manage/';
// Create an image field that uses the new style.
$field_name = $this->randomMachineName(10);
$this->createImageField($field_name, 'node', 'article');
\Drupal::service('entity_display.repository')
->getViewDisplay('node', 'article')
->setComponent($field_name, [
'type' => 'image',
'settings' => ['image_style' => $style_name],
])
->save();
// Create a new node with an image attached.
$test_image = current($this->drupalGetTestFiles('image'));
$nid = $this->uploadNodeImage($test_image, $field_name, 'article', $this->randomMachineName());
$node = Node::load($nid);
// Get node field original image URI.
$fid = $node->get($field_name)->target_id;
$original_uri = File::load($fid)->getFileUri();
// Test that image is displayed using newly created style.
/** @var \Drupal\Core\File\FileUrlGeneratorInterface $file_url_generator */
$file_url_generator = \Drupal::service('file_url_generator');
$this->drupalGet('node/' . $nid);
$this->assertSession()->responseContains($file_url_generator->transformRelative($style->buildUrl($original_uri)));
// Rename the style and make sure the image field is updated.
$new_style_name = $this->randomMachineName(10);
$new_style_label = $this->randomString();
$edit = [
'name' => $new_style_name,
'label' => $new_style_label,
];
$this->drupalGet($style_path . $style_name);
$this->submitForm($edit, 'Save');
$this->assertSession()->statusMessageContains('Changes to the style have been saved.', 'status');
$this->drupalGet('node/' . $nid);
// Reload the image style using the new name.
$style = ImageStyle::load($new_style_name);
$this->assertSession()->responseContains($file_url_generator->transformRelative($style->buildUrl($original_uri)));
// Delete the style and choose a replacement style.
$edit = [
'replacement' => 'thumbnail',
];
$this->drupalGet($style_path . $new_style_name . '/delete');
$this->submitForm($edit, 'Delete');
$this->assertSession()->statusMessageContains("The image style {$new_style_label} has been deleted.", 'status');
$replacement_style = ImageStyle::load('thumbnail');
$this->drupalGet('node/' . $nid);
$this->assertSession()->responseContains($file_url_generator->transformRelative($replacement_style->buildUrl($original_uri)));
}
/**
* Verifies that editing an image effect does not cause it to be duplicated.
*/
public function testEditEffect(): void {
// Add a scale effect.
$style_name = 'test_style_effect_edit';
$this->drupalGet('admin/config/media/image-styles/add');
$this->submitForm(['label' => 'Test style effect edit', 'name' => $style_name], 'Create new style');
$this->submitForm(['new' => 'image_scale_and_crop'], 'Add');
$this->submitForm(['data[width]' => '300', 'data[height]' => '200'], 'Add effect');
$this->assertSession()->pageTextContains('Scale and crop 300×200');
// There should normally be only one edit link on this page initially.
$this->clickLink('Edit');
$this->assertSession()->pageTextContains("Edit Scale and crop effect on style Test style effect edit");
$this->submitForm(['data[width]' => '360', 'data[height]' => '240'], 'Update effect');
$this->assertSession()->pageTextContains('Scale and crop 360×240');
// Check that the previous effect is replaced.
$this->assertSession()->pageTextNotContains('Scale and crop 300×200');
// Add another scale effect.
$this->drupalGet('admin/config/media/image-styles/add');
$this->submitForm(['label' => 'Test style scale edit scale', 'name' => 'test_style_scale_edit_scale'], 'Create new style');
$this->submitForm(['new' => 'image_scale'], 'Add');
$this->submitForm(['data[width]' => '12', 'data[height]' => '19'], 'Add effect');
// Edit the scale effect that was just added.
$this->clickLink('Edit');
$this->assertSession()->pageTextContains("Edit Scale effect on style Test style scale edit scale");
$this->submitForm(['data[width]' => '24', 'data[height]' => '19'], 'Update effect');
// Add another scale effect and make sure both exist. Click through from
// the overview to make sure that it is possible to add new effect then.
$this->drupalGet('admin/config/media/image-styles');
$rows = $this->xpath('//table/tbody/tr');
$i = 0;
foreach ($rows as $row) {
if ($row->find('css', 'td')->getText() === 'Test style scale edit scale') {
$this->clickLink('Edit', $i);
break;
}
$i++;
}
$this->submitForm(['new' => 'image_scale'], 'Add');
$this->submitForm(['data[width]' => '12', 'data[height]' => '19'], 'Add effect');
$this->assertSession()->pageTextContains('Scale 24×19');
$this->assertSession()->pageTextContains('Scale 12×19');
// Try to edit a nonexistent effect.
$uuid = $this->container->get('uuid');
$this->drupalGet('admin/config/media/image-styles/manage/' . $style_name . '/effects/' . $uuid->generate());
$this->assertSession()->statusCodeEquals(404);
}
/**
* Tests flush user interface.
*/
public function testFlushUserInterface(): void {
$admin_path = 'admin/config/media/image-styles';
// Create a new style.
$style_name = $this->randomMachineName(10);
$style = ImageStyle::create(['name' => $style_name, 'label' => $this->randomString()]);
$style->save();
// Create an image to make sure it gets flushed.
$files = $this->drupalGetTestFiles('image');
$image_uri = $files[0]->uri;
$derivative_uri = $style->buildUri($image_uri);
$this->assertTrue($style->createDerivative($image_uri, $derivative_uri));
$this->assertEquals(1, $this->getImageCount($style));
// Go to image styles list page and check if the flush operation link
// exists.
$this->drupalGet($admin_path);
$flush_path = $admin_path . '/manage/' . $style_name . '/flush';
$this->assertSession()->linkByHrefExists($flush_path);
// Flush the image style derivatives using the user interface.
$this->drupalGet($flush_path);
$this->submitForm([], 'Flush');
// The derivative image file should have been deleted.
$this->assertEquals(0, $this->getImageCount($style));
}
/**
* Tests image style configuration import that does a delete.
*/
public function testConfigImport(): void {
// Create a new style.
$style_name = $this->randomMachineName(10);
$style_label = $this->randomString();
$style = ImageStyle::create(['name' => $style_name, 'label' => $style_label]);
$style->save();
// Create an image field that uses the new style.
$field_name = $this->randomMachineName(10);
$this->createImageField($field_name, 'node', 'article');
\Drupal::service('entity_display.repository')
->getViewDisplay('node', 'article')
->setComponent($field_name, [
'type' => 'image',
'settings' => ['image_style' => $style_name],
])
->save();
// Create a new node with an image attached.
$test_image = current($this->drupalGetTestFiles('image'));
$nid = $this->uploadNodeImage($test_image, $field_name, 'article', $this->randomMachineName());
$node = Node::load($nid);
// Get node field original image URI.
$fid = $node->get($field_name)->target_id;
$original_uri = File::load($fid)->getFileUri();
// Test that image is displayed using newly created style.
$this->drupalGet('node/' . $nid);
$this->assertSession()->responseContains(\Drupal::service('file_url_generator')->transformRelative($style->buildUrl($original_uri)));
// Copy config to sync, and delete the image style.
$sync = $this->container->get('config.storage.sync');
$active = $this->container->get('config.storage');
// Remove the image field from the display, to avoid a dependency error
// during import.
EntityViewDisplay::load('node.article.default')
->removeComponent($field_name)
->save();
$this->copyConfig($active, $sync);
$sync->delete('image.style.' . $style_name);
$this->configImporter()->import();
$this->assertNull(ImageStyle::load($style_name), 'Style deleted after config import.');
$this->assertEquals(0, $this->getImageCount($style), 'Image style was flushed after being deleted by config import.');
}
/**
* Tests access for the image style listing.
*/
public function testImageStyleAccess(): void {
$style = ImageStyle::create(['name' => 'style_foo', 'label' => $this->randomString()]);
$style->save();
$this->drupalGet('admin/config/media/image-styles');
$this->clickLink('Edit');
$this->assertSession()->pageTextContains("Select a new effect");
}
/**
* Tests the display of preview images using a private scheme.
*/
public function testPreviewImageShowInPrivateScheme(): void {
$this->config('system.file')->set('default_scheme', 'private')->save();
/** @var \Drupal\Core\File\FileUrlGeneratorInterface $file_url_generator */
$file_url_generator = \Drupal::service('file_url_generator');
// Get the original preview image file in core config.
$original_path = $this->config('image.settings')->get('preview_image');
$style = ImageStyle::create(['name' => 'test_foo', 'label' => 'test foo']);
$style->save();
// Build the derivative preview image file with the Image Style.
// @see template_preprocess_image_style_preview()
$preview_file = $style->buildUri($original_path);
$style->createDerivative($original_path, $preview_file);
// Check if the derivative image exists.
$this->assertFileExists($preview_file);
// Generate itok token for the preview image.
$itok = $style->getPathToken('private://' . $original_path);
$url = $file_url_generator->generateAbsoluteString($preview_file);
$url .= '?itok=' . $itok;
// Check if the preview image with style is shown.
$this->drupalGet($url);
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->responseHeaderContains('Content-Type', 'image/png');
}
}

View File

@@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\image\Functional;
use Drupal\Core\File\FileExists;
use Drupal\image\Entity\ImageStyle;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\TestFileCreationTrait;
/**
* Tests that images have correct dimensions when styled.
*
* @group image
*/
class ImageDimensionsTest extends BrowserTestBase {
use TestFileCreationTrait {
getTestFiles as drupalGetTestFiles;
compareFiles as drupalCompareFiles;
}
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['image', 'image_module_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
protected $profile = 'testing';
/**
* Tests styled image dimensions cumulatively.
*/
public function testImageDimensions(): void {
$image_factory = $this->container->get('image.factory');
// Create a working copy of the file.
$files = $this->drupalGetTestFiles('image');
$file = reset($files);
/** @var \Drupal\Core\File\FileSystemInterface $file_system */
$file_system = \Drupal::service('file_system');
$original_uri = $file_system->copy($file->uri, 'public://', FileExists::Rename);
// Create a style.
/** @var \Drupal\image\ImageStyleInterface $style */
$style = ImageStyle::create(['name' => 'test', 'label' => 'Test']);
$style->save();
$generated_uri = 'public://styles/test/public/' . $file_system->basename($original_uri);
/** @var \Drupal\Core\File\FileUrlGeneratorInterface $file_url_generator */
$file_url_generator = \Drupal::service('file_url_generator');
$url = $file_url_generator->transformRelative($style->buildUrl($original_uri));
$variables = [
'#theme' => 'image_style',
'#style_name' => 'test',
'#uri' => $original_uri,
'#width' => 40,
'#height' => 20,
];
// Verify that the original image matches the hard-coded values.
$image_file = $image_factory->get($original_uri);
$this->assertEquals($variables['#width'], $image_file->getWidth());
$this->assertEquals($variables['#height'], $image_file->getHeight());
// Scale an image that is wider than it is high.
$effect = [
'id' => 'image_scale',
'data' => [
'width' => 120,
'height' => 90,
'upscale' => TRUE,
],
'weight' => 0,
];
$style->addImageEffect($effect);
$style->save();
$this->assertEquals('<img src="' . $url . '" width="120" height="60" alt="" loading="lazy" />', $this->getImageTag($variables));
$this->assertFileDoesNotExist($generated_uri);
$this->drupalGet($this->getAbsoluteUrl($url));
$this->assertSession()->statusCodeEquals(200);
$this->assertFileExists($generated_uri);
$image_file = $image_factory->get($generated_uri);
$this->assertEquals(120, $image_file->getWidth());
$this->assertEquals(60, $image_file->getHeight());
// Rotate 90 degrees anticlockwise.
$effect = [
'id' => 'image_rotate',
'data' => [
'degrees' => -90,
'random' => FALSE,
],
'weight' => 1,
];
$style->addImageEffect($effect);
$style->save();
$this->assertEquals('<img src="' . $url . '" width="60" height="120" alt="" loading="lazy" />', $this->getImageTag($variables));
$this->assertFileDoesNotExist($generated_uri);
$this->drupalGet($this->getAbsoluteUrl($url));
$this->assertSession()->statusCodeEquals(200);
$this->assertFileExists($generated_uri);
$image_file = $image_factory->get($generated_uri);
$this->assertEquals(60, $image_file->getWidth());
$this->assertEquals(120, $image_file->getHeight());
// Scale an image that is higher than it is wide (rotated by previous effect).
$effect = [
'id' => 'image_scale',
'data' => [
'width' => 120,
'height' => 90,
'upscale' => TRUE,
],
'weight' => 2,
];
$style->addImageEffect($effect);
$style->save();
$this->assertEquals('<img src="' . $url . '" width="45" height="90" alt="" loading="lazy" />', $this->getImageTag($variables));
$this->assertFileDoesNotExist($generated_uri);
$this->drupalGet($this->getAbsoluteUrl($url));
$this->assertSession()->statusCodeEquals(200);
$this->assertFileExists($generated_uri);
$image_file = $image_factory->get($generated_uri);
$this->assertEquals(45, $image_file->getWidth());
$this->assertEquals(90, $image_file->getHeight());
// Test upscale disabled.
$effect = [
'id' => 'image_scale',
'data' => [
'width' => 400,
'height' => 200,
'upscale' => FALSE,
],
'weight' => 3,
];
$style->addImageEffect($effect);
$style->save();
$this->assertEquals('<img src="' . $url . '" width="45" height="90" alt="" loading="lazy" />', $this->getImageTag($variables));
$this->assertFileDoesNotExist($generated_uri);
$this->drupalGet($this->getAbsoluteUrl($url));
$this->assertSession()->statusCodeEquals(200);
$this->assertFileExists($generated_uri);
$image_file = $image_factory->get($generated_uri);
$this->assertEquals(45, $image_file->getWidth());
$this->assertEquals(90, $image_file->getHeight());
// Add a desaturate effect.
$effect = [
'id' => 'image_desaturate',
'data' => [],
'weight' => 4,
];
$style->addImageEffect($effect);
$style->save();
$this->assertEquals('<img src="' . $url . '" width="45" height="90" alt="" loading="lazy" />', $this->getImageTag($variables));
$this->assertFileDoesNotExist($generated_uri);
$this->drupalGet($this->getAbsoluteUrl($url));
$this->assertSession()->statusCodeEquals(200);
$this->assertFileExists($generated_uri);
$image_file = $image_factory->get($generated_uri);
$this->assertEquals(45, $image_file->getWidth());
$this->assertEquals(90, $image_file->getHeight());
// Add a random rotate effect.
$effect = [
'id' => 'image_rotate',
'data' => [
'degrees' => 180,
'random' => TRUE,
],
'weight' => 5,
];
$style->addImageEffect($effect);
$style->save();
$this->assertEquals('<img src="' . $url . '" alt="" />', $this->getImageTag($variables));
$this->assertFileDoesNotExist($generated_uri);
$this->drupalGet($this->getAbsoluteUrl($url));
$this->assertSession()->statusCodeEquals(200);
$this->assertFileExists($generated_uri);
// Add a crop effect.
$effect = [
'id' => 'image_crop',
'data' => [
'width' => 30,
'height' => 30,
'anchor' => 'center-center',
],
'weight' => 6,
];
$style->addImageEffect($effect);
$style->save();
$this->assertEquals('<img src="' . $url . '" width="30" height="30" alt="" loading="lazy" />', $this->getImageTag($variables));
$this->assertFileDoesNotExist($generated_uri);
$this->drupalGet($this->getAbsoluteUrl($url));
$this->assertSession()->statusCodeEquals(200);
$this->assertFileExists($generated_uri);
$image_file = $image_factory->get($generated_uri);
$this->assertEquals(30, $image_file->getWidth());
$this->assertEquals(30, $image_file->getHeight());
// Rotate to a non-multiple of 90 degrees.
$effect = [
'id' => 'image_rotate',
'data' => [
'degrees' => 57,
'random' => FALSE,
],
'weight' => 7,
];
$effect_id = $style->addImageEffect($effect);
$style->save();
// @todo Uncomment this once
// https://www.drupal.org/project/drupal/issues/2670966 is resolved.
// $this->assertEquals('<img src="' . $url . '" width="41" height="41" alt="" class="image-style-test" />', $this->getImageTag($variables));
$this->assertFileDoesNotExist($generated_uri);
$this->drupalGet($this->getAbsoluteUrl($url));
$this->assertSession()->statusCodeEquals(200);
$this->assertFileExists($generated_uri);
$image_file = $image_factory->get($generated_uri);
// @todo Uncomment this once
// https://www.drupal.org/project/drupal/issues/2670966 is resolved.
// $this->assertEquals(41, $image_file->getWidth());
// $this->assertEquals(41, $image_file->getHeight());
$effect_plugin = $style->getEffect($effect_id);
$style->deleteImageEffect($effect_plugin);
// Ensure that an effect can unset dimensions.
$effect = [
'id' => 'image_module_test_null',
'data' => [],
'weight' => 8,
];
$style->addImageEffect($effect);
$style->save();
$this->assertEquals('<img src="' . $url . '" alt="" />', $this->getImageTag($variables));
// Test URI dependent image effect.
$style = ImageStyle::create(['name' => 'test_uri', 'label' => 'Test URI']);
$effect = [
'id' => 'image_module_test_uri_dependent',
'data' => [],
'weight' => 0,
];
$style->addImageEffect($effect);
$style->save();
$variables = [
'#theme' => 'image_style',
'#style_name' => 'test_uri',
'#uri' => $original_uri,
'#width' => 40,
'#height' => 20,
];
// PNG original image. Should be resized to 100x100.
$generated_uri = 'public://styles/test_uri/public/' . $file_system->basename($original_uri);
$url = \Drupal::service('file_url_generator')->transformRelative($style->buildUrl($original_uri));
$this->assertEquals('<img src="' . $url . '" width="100" height="100" alt="" loading="lazy" />', $this->getImageTag($variables));
$this->assertFileDoesNotExist($generated_uri);
$this->drupalGet($this->getAbsoluteUrl($url));
$this->assertSession()->statusCodeEquals(200);
$this->assertFileExists($generated_uri);
$image_file = $image_factory->get($generated_uri);
$this->assertEquals(100, $image_file->getWidth());
$this->assertEquals(100, $image_file->getHeight());
// GIF original image. Should be resized to 50x50.
$file = $files[1];
$original_uri = $file_system->copy($file->uri, 'public://', FileExists::Rename);
$generated_uri = 'public://styles/test_uri/public/' . $file_system->basename($original_uri);
$url = $file_url_generator->transformRelative($style->buildUrl($original_uri));
$variables['#uri'] = $original_uri;
$this->assertEquals('<img src="' . $url . '" width="50" height="50" alt="" loading="lazy" />', $this->getImageTag($variables));
$this->assertFileDoesNotExist($generated_uri);
$this->drupalGet($this->getAbsoluteUrl($url));
$this->assertSession()->statusCodeEquals(200);
$this->assertFileExists($generated_uri);
$image_file = $image_factory->get($generated_uri);
$this->assertEquals(50, $image_file->getWidth());
$this->assertEquals(50, $image_file->getHeight());
}
/**
* Render an image style element.
*
* Function \Drupal\Core\Render\RendererInterface::render() alters the passed
* $variables array by adding a new key '#printed' => TRUE. This prevents next
* call to re-render the element. We wrap
* \Drupal\Core\Render\RendererInterface::render() in a helper protected
* method and pass each time a fresh array so that $variables won't get
* altered and the element is re-rendered each time.
*/
protected function getImageTag($variables) {
return str_replace("\n", '', (string) \Drupal::service('renderer')->renderRoot($variables));
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\image\Functional\ImageEffect;
use Drupal\Core\File\FileExists;
use Drupal\image\Entity\ImageStyle;
use Drupal\Tests\BrowserTestBase;
/**
* Tests for the Convert image effect.
*
* @group image
*/
class ConvertTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $modules = ['image'];
/**
* Tests that files stored in the root folder are converted properly.
*/
public function testConvertFileInRoot(): void {
// Create the test image style with a Convert effect.
$image_style = ImageStyle::create([
'name' => 'image_effect_test',
'label' => 'Image Effect Test',
]);
$this->assertEquals(SAVED_NEW, $image_style->save());
$image_style->addImageEffect([
'id' => 'image_convert',
'data' => [
'extension' => 'jpeg',
],
]);
$this->assertEquals(SAVED_UPDATED, $image_style->save());
// Create a copy of a test image file in root.
$test_uri = 'public://image-test-do.png';
\Drupal::service('file_system')->copy('core/tests/fixtures/files/image-test.png', $test_uri, FileExists::Replace);
$this->assertFileExists($test_uri);
// Execute the image style on the test image via a GET request.
$derivative_uri = 'public://styles/image_effect_test/public/image-test-do.png.jpeg';
$this->assertFileDoesNotExist($derivative_uri);
$url = \Drupal::service('file_url_generator')->transformRelative($image_style->buildUrl($test_uri));
$this->drupalGet($this->getAbsoluteUrl($url));
$this->assertSession()->statusCodeEquals(200);
$this->assertFileExists($derivative_uri);
}
}

View File

@@ -0,0 +1,295 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\image\Functional;
use Drupal\Core\Entity\Entity\EntityViewDisplay;
use Drupal\Core\File\FileExists;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\file\Entity\File;
use Drupal\Tests\EntityViewTrait;
use Drupal\Tests\TestFileCreationTrait;
/**
* Tests setting up default images both to the field and field storage.
*
* @group image
*/
class ImageFieldDefaultImagesTest extends ImageFieldTestBase {
use TestFileCreationTrait {
getTestFiles as drupalGetTestFiles;
compareFiles as drupalCompareFiles;
}
use EntityViewTrait {
buildEntityView as drupalBuildEntityView;
}
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['field_ui'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests CRUD for fields and field storages with default images.
*/
public function testDefaultImages(): void {
$node_storage = $this->container->get('entity_type.manager')->getStorage('node');
// Create files to use as the default images.
$files = $this->drupalGetTestFiles('image');
// Create 10 files so the default image fids are not a single value.
for ($i = 1; $i <= 10; $i++) {
$filename = $this->randomMachineName() . "$i";
$desired_filepath = 'public://' . $filename;
\Drupal::service('file_system')->copy($files[0]->uri, $desired_filepath, FileExists::Error);
$file = File::create(['uri' => $desired_filepath, 'filename' => $filename, 'name' => $filename]);
$file->save();
}
$default_images = [];
foreach (['field_storage', 'field', 'field2', 'field_storage_new', 'field_new', 'field_storage_private', 'field_private'] as $image_target) {
$file = File::create((array) array_pop($files));
$file->save();
$default_images[$image_target] = $file;
}
// Create an image field storage and add a field to the article content
// type.
$field_name = $this->randomMachineName();
$storage_settings['default_image'] = [
'uuid' => $default_images['field_storage']->uuid(),
'alt' => '',
'title' => '',
'width' => 0,
'height' => 0,
];
$field_settings['default_image'] = [
'uuid' => $default_images['field']->uuid(),
'alt' => '',
'title' => '',
'width' => 0,
'height' => 0,
];
$widget_settings = [
'preview_image_style' => 'medium',
];
$field = $this->createImageField($field_name, 'node', 'article', $storage_settings, $field_settings, $widget_settings);
// The field default image id should be 2.
$this->assertEquals($default_images['field']->uuid(), $field->getSetting('default_image')['uuid']);
// Also test \Drupal\field\Entity\FieldConfig::getSettings().
$this->assertEquals($default_images['field']->uuid(), $field->getSettings()['default_image']['uuid']);
$field_storage = $field->getFieldStorageDefinition();
// The field storage default image id should be 1.
$this->assertEquals($default_images['field_storage']->uuid(), $field_storage->getSetting('default_image')['uuid']);
// Also test \Drupal\field\Entity\FieldStorageConfig::getSettings().
$this->assertEquals($default_images['field_storage']->uuid(), $field_storage->getSettings()['default_image']['uuid']);
// Add another field with another default image to the page content type.
$field2 = FieldConfig::create([
'field_storage' => $field_storage,
'bundle' => 'page',
'label' => $field->label(),
'required' => $field->isRequired(),
'settings' => [
'default_image' => [
'uuid' => $default_images['field2']->uuid(),
'alt' => '',
'title' => '',
'width' => 0,
'height' => 0,
],
],
]);
$field2->save();
/** @var \Drupal\Core\Entity\EntityDisplayRepositoryInterface $display_repository */
$display_repository = \Drupal::service('entity_display.repository');
$widget_settings = $display_repository->getFormDisplay('node', $field->getTargetBundle())->getComponent($field_name);
$display_repository->getFormDisplay('node', 'page')
->setComponent($field_name, $widget_settings)
->save();
$display_repository->getViewDisplay('node', 'page')
->setComponent($field_name)
->save();
// Confirm the defaults are present on the article field storage settings
// sub-form.
$field_id = $field->id();
$this->drupalGet("admin/structure/types/manage/article/fields/$field_id");
$this->assertSession()->hiddenFieldValueEquals('field_storage[subform][settings][default_image][uuid][fids]', $default_images['field_storage']->id());
// Confirm the defaults are present on the article field edit form.
$this->drupalGet("admin/structure/types/manage/article/fields/$field_id");
$this->assertSession()->hiddenFieldValueEquals('settings[default_image][uuid][fids]', $default_images['field']->id());
// Confirm the defaults are present on the page field storage settings form.
$this->drupalGet("admin/structure/types/manage/page/fields/$field_id");
$this->assertSession()->hiddenFieldValueEquals('field_storage[subform][settings][default_image][uuid][fids]', $default_images['field_storage']->id());
// Confirm the defaults are present on the page field edit form.
$field2_id = $field2->id();
$this->drupalGet("admin/structure/types/manage/page/fields/$field2_id");
$this->assertSession()->hiddenFieldValueEquals('settings[default_image][uuid][fids]', $default_images['field2']->id());
// Confirm that the image default is shown for a new article node.
$article = $this->drupalCreateNode(['type' => 'article']);
$article_built = $this->drupalBuildEntityView($article);
$this->assertEquals($default_images['field']->id(), $article_built[$field_name][0]['#item']->target_id, "A new article node without an image has the expected default image file ID of {$default_images['field']->id()}.");
// Also check that the field renders without warnings when the label is
// hidden.
EntityViewDisplay::load('node.article.default')
->setComponent($field_name, ['label' => 'hidden', 'type' => 'image'])
->save();
$this->drupalGet('node/' . $article->id());
// Confirm that the image default is shown for a new page node.
$page = $this->drupalCreateNode(['type' => 'page']);
$page_built = $this->drupalBuildEntityView($page);
$this->assertEquals($default_images['field2']->id(), $page_built[$field_name][0]['#item']->target_id, "A new page node without an image has the expected default image file ID of {$default_images['field2']->id()}.");
// Upload a new default for the field storage.
$default_image_settings = $field_storage->getSetting('default_image');
$default_image_settings['uuid'] = $default_images['field_storage_new']->uuid();
$field_storage->setSetting('default_image', $default_image_settings);
$field_storage->save();
// Confirm that the new default is used on the article field storage
// settings form.
$this->drupalGet("admin/structure/types/manage/article/fields/$field_id");
$this->assertSession()->hiddenFieldValueEquals('field_storage[subform][settings][default_image][uuid][fids]', $default_images['field_storage_new']->id());
// Reload the nodes and confirm the field defaults are used.
$node_storage->resetCache([$article->id(), $page->id()]);
$article_built = $this->drupalBuildEntityView($article = $node_storage->load($article->id()));
$page_built = $this->drupalBuildEntityView($page = $node_storage->load($page->id()));
$this->assertEquals($default_images['field']->id(), $article_built[$field_name][0]['#item']->target_id, "An existing article node without an image has the expected default image file ID of {$default_images['field']->id()}.");
$this->assertEquals($default_images['field2']->id(), $page_built[$field_name][0]['#item']->target_id, "An existing page node without an image has the expected default image file ID of {$default_images['field2']->id()}.");
// Upload a new default for the article's field.
$default_image_settings = $field->getSetting('default_image');
$default_image_settings['uuid'] = $default_images['field_new']->uuid();
$field->setSetting('default_image', $default_image_settings);
$field->save();
// Confirm the new field default is used on the article field admin form.
$this->drupalGet("admin/structure/types/manage/article/fields/$field_id");
$this->assertSession()->hiddenFieldValueEquals('settings[default_image][uuid][fids]', $default_images['field_new']->id());
// Reload the nodes.
$node_storage->resetCache([$article->id(), $page->id()]);
$article_built = $this->drupalBuildEntityView($article = $node_storage->load($article->id()));
$page_built = $this->drupalBuildEntityView($page = $node_storage->load($page->id()));
// Confirm the article uses the new default.
$this->assertEquals($default_images['field_new']->id(), $article_built[$field_name][0]['#item']->target_id, "An existing article node without an image has the expected default image file ID of {$default_images['field_new']->id()}.");
// Confirm the page remains unchanged.
$this->assertEquals($default_images['field2']->id(), $page_built[$field_name][0]['#item']->target_id, "An existing page node without an image has the expected default image file ID of {$default_images['field2']->id()}.");
// Confirm the default image is shown on the node form.
$file = File::load($default_images['field_new']->id());
$this->drupalGet('node/add/article');
$this->assertSession()->responseContains($file->getFilename());
// Remove the field default from articles.
$default_image_settings = $field->getSetting('default_image');
$default_image_settings['uuid'] = \Drupal::service('uuid')->generate();
$field->setSetting('default_image', $default_image_settings);
$field->save();
// Confirm the article field default has been removed.
$this->drupalGet("admin/structure/types/manage/article/fields/$field_id");
$this->assertSession()->hiddenFieldValueEquals('settings[default_image][uuid][fids]', '');
// Reload the nodes.
$node_storage->resetCache([$article->id(), $page->id()]);
$article_built = $this->drupalBuildEntityView($article = $node_storage->load($article->id()));
$page_built = $this->drupalBuildEntityView($page = $node_storage->load($page->id()));
// Confirm the article uses the new field storage (not field) default.
$this->assertEquals($default_images['field_storage_new']->id(), $article_built[$field_name][0]['#item']->target_id, "An existing article node without an image has the expected default image file ID of {$default_images['field_storage_new']->id()}.");
// Confirm the page remains unchanged.
$this->assertEquals($default_images['field2']->id(), $page_built[$field_name][0]['#item']->target_id, "An existing page node without an image has the expected default image file ID of {$default_images['field2']->id()}.");
$non_image = $this->drupalGetTestFiles('text');
$this->submitForm(['files[settings_default_image_uuid]' => \Drupal::service('file_system')->realpath($non_image[0]->uri)], 'Upload');
$this->assertSession()->statusMessageContains('The specified file text-0.txt could not be uploaded.', 'error');
$this->assertSession()->statusMessageContains('Only files with the following extensions are allowed: png gif jpg jpeg webp.', 'error');
// Confirm the default image is shown on the node form.
$file = File::load($default_images['field_storage_new']->id());
$this->drupalGet('node/add/article');
$this->assertSession()->responseContains($file->getFilename());
// Change the default image for the field storage and also change the upload
// destination to the private filesystem at the same time.
$default_image_settings = $field_storage->getSetting('default_image');
$default_image_settings['uuid'] = $default_images['field_storage_private']->uuid();
$field_storage->setSetting('default_image', $default_image_settings);
$field_storage->setSetting('uri_scheme', 'private');
$field_storage->save();
// Confirm that the new default is used on the article field storage
// settings sub-form.
$this->drupalGet("admin/structure/types/manage/article/fields/$field_id");
$this->assertSession()->hiddenFieldValueEquals('field_storage[subform][settings][default_image][uuid][fids]', $default_images['field_storage_private']->id());
// Upload a new default for the article's field after setting the field
// storage upload destination to 'private'.
$default_image_settings = $field->getSetting('default_image');
$default_image_settings['uuid'] = $default_images['field_private']->uuid();
$field->setSetting('default_image', $default_image_settings);
$field->save();
// Confirm the new field default is used on the article field admin form.
$this->drupalGet("admin/structure/types/manage/article/fields/$field_id");
$this->assertSession()->hiddenFieldValueEquals('settings[default_image][uuid][fids]', $default_images['field_private']->id());
}
/**
* Tests image field and field storage having an invalid default image.
*/
public function testInvalidDefaultImage(): void {
$field_storage = FieldStorageConfig::create([
'field_name' => $this->randomMachineName(),
'entity_type' => 'node',
'type' => 'image',
'settings' => [
'default_image' => [
'uuid' => 100000,
],
],
]);
$field_storage->save();
$settings = $field_storage->getSettings();
// The non-existent default image should not be saved.
$this->assertNull($settings['default_image']['uuid']);
$field = FieldConfig::create([
'field_storage' => $field_storage,
'bundle' => 'page',
'label' => $this->randomMachineName(),
'settings' => [
'default_image' => [
'uuid' => 100000,
],
],
]);
$field->save();
$settings = $field->getSettings();
// The non-existent default image should not be saved.
$this->assertNull($settings['default_image']['uuid']);
}
}

View File

@@ -0,0 +1,617 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\image\Functional;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\StreamWrapper\StreamWrapperManager;
use Drupal\Core\Url;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\image\Entity\ImageStyle;
use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait;
use Drupal\Tests\TestFileCreationTrait;
use Drupal\user\RoleInterface;
/**
* Tests the display of image fields.
*
* @group image
* @group #slow
*/
class ImageFieldDisplayTest extends ImageFieldTestBase {
use AssertPageCacheContextsAndTagsTrait;
use TestFileCreationTrait {
getTestFiles as drupalGetTestFiles;
compareFiles as drupalCompareFiles;
}
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['field_ui'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests image formatters on node display for public files.
*/
public function testImageFieldFormattersPublic(): void {
$this->_testImageFieldFormatters('public');
}
/**
* Tests image formatters on node display for private files.
*/
public function testImageFieldFormattersPrivate(): void {
// Remove access content permission from anonymous users.
user_role_change_permissions(RoleInterface::ANONYMOUS_ID, ['access content' => FALSE]);
$this->_testImageFieldFormatters('private');
}
/**
* Tests image formatters on node display.
*/
public function _testImageFieldFormatters($scheme) {
/** @var \Drupal\Core\Render\RendererInterface $renderer */
$renderer = $this->container->get('renderer');
$node_storage = $this->container->get('entity_type.manager')->getStorage('node');
$field_name = $this->randomMachineName();
$field_settings = ['alt_field_required' => 0];
$instance = $this->createImageField($field_name, 'node', 'article', ['uri_scheme' => $scheme], $field_settings);
// Go to manage display page.
$this->drupalGet("admin/structure/types/manage/article/display");
// Test for existence of link to image styles configuration.
$this->submitForm([], "{$field_name}_settings_edit");
$this->assertSession()->linkByHrefExists(Url::fromRoute('entity.image_style.collection')->toString(), 0, 'Link to image styles configuration is found');
// Remove 'administer image styles' permission from testing admin user.
$admin_user_roles = $this->adminUser->getRoles(TRUE);
user_role_change_permissions(reset($admin_user_roles), ['administer image styles' => FALSE]);
// Go to manage display page again.
$this->drupalGet("admin/structure/types/manage/article/display");
// Test for absence of link to image styles configuration.
$this->submitForm([], "{$field_name}_settings_edit");
$this->assertSession()->linkByHrefNotExists(Url::fromRoute('entity.image_style.collection')->toString(), 'Link to image styles configuration is absent when permissions are insufficient');
// Restore 'administer image styles' permission to testing admin user
user_role_change_permissions(reset($admin_user_roles), ['administer image styles' => TRUE]);
// Create a new node with an image attached.
$test_image = current($this->drupalGetTestFiles('image'));
// Ensure that preview works.
$this->previewNodeImage($test_image, $field_name, 'article');
// After previewing, make the alt field required. It cannot be required
// during preview because the form validation will fail.
$instance->setSetting('alt_field_required', 1);
$instance->save();
// Create alt text for the image.
$alt = $this->randomMachineName();
// Save node.
$nid = $this->uploadNodeImage($test_image, $field_name, 'article', $alt);
$node_storage->resetCache([$nid]);
$node = $node_storage->load($nid);
// Test that the default formatter is being used.
/** @var \Drupal\file\FileInterface $file */
$file = $node->{$field_name}->entity;
$image_uri = $file->getFileUri();
$image = [
'#theme' => 'image',
'#uri' => $image_uri,
'#width' => 40,
'#height' => 20,
'#alt' => $alt,
'#attributes' => ['loading' => 'lazy'],
];
$default_output = str_replace("\n", '', (string) $renderer->renderRoot($image));
$this->assertSession()->responseContains($default_output);
// Test the image linked to file formatter.
$display_options = [
'type' => 'image',
'settings' => ['image_link' => 'file'],
];
$display = \Drupal::service('entity_display.repository')
->getViewDisplay('node', $node->getType());
$display->setComponent($field_name, $display_options)
->save();
$image = [
'#theme' => 'image',
'#uri' => $image_uri,
'#width' => 40,
'#height' => 20,
'#alt' => $alt,
'#attributes' => ['loading' => 'lazy'],
];
$default_output = '<a href="' . $file->createFileUrl() . '">' . (string) $renderer->renderRoot($image) . '</a>';
$this->drupalGet('node/' . $nid);
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', $file->getCacheTags()[0]);
// @todo Remove in https://www.drupal.org/node/2646744.
$this->assertCacheContext('url.site');
// Verify that no image style cache tags are found.
$this->assertSession()->responseHeaderNotContains('X-Drupal-Cache-Tags', 'image_style:');
$this->assertSession()->responseContains($default_output);
// Verify that the image can be downloaded.
$this->assertEquals(file_get_contents($test_image->uri), $this->drupalGet($file->createFileUrl(FALSE)), 'File was downloaded successfully.');
if ($scheme == 'private') {
// Only verify HTTP headers when using private scheme and the headers are
// sent by Drupal.
$this->assertSession()->responseHeaderEquals('Content-Type', 'image/png');
$this->assertSession()->responseHeaderContains('Cache-Control', 'private');
// Log out and ensure the file cannot be accessed.
$this->drupalLogout();
$this->drupalGet($file->createFileUrl(FALSE));
$this->assertSession()->statusCodeEquals(403);
// Log in again.
$this->drupalLogin($this->adminUser);
}
// Test the image linked to content formatter.
$display_options['settings']['image_link'] = 'content';
$display->setComponent($field_name, $display_options)
->save();
$image = [
'#theme' => 'image',
'#uri' => $image_uri,
'#width' => 40,
'#height' => 20,
];
$this->drupalGet('node/' . $nid);
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', $file->getCacheTags()[0]);
// Verify that no image style cache tags are found.
$this->assertSession()->responseHeaderNotContains('X-Drupal-Cache-Tags', 'image_style:');
$this->assertSession()->elementsCount('xpath', '//a[@href="' . $node->toUrl()->toString() . '"]/img[@src="' . $file->createFileUrl() . '" and @alt="' . $alt . '" and @width="' . $image['#width'] . '" and @height="' . $image['#height'] . '"]', 1);
// Test the image style 'thumbnail' formatter.
$display_options['settings']['image_link'] = '';
$display_options['settings']['image_style'] = 'thumbnail';
$display->setComponent($field_name, $display_options)
->save();
// Ensure the derivative image is generated so we do not have to deal with
// image style callback paths.
$this->drupalGet(ImageStyle::load('thumbnail')->buildUrl($image_uri));
$image_style = [
'#theme' => 'image_style',
'#uri' => $image_uri,
'#width' => 40,
'#height' => 20,
'#style_name' => 'thumbnail',
'#alt' => $alt,
'#attributes' => ['loading' => 'lazy'],
];
$default_output = (string) $renderer->renderRoot($image_style);
$this->drupalGet('node/' . $nid);
$image_style = ImageStyle::load('thumbnail');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', $image_style->getCacheTags()[0]);
$this->assertSession()->responseContains($default_output);
if ($scheme == 'private') {
// Log out and ensure the file cannot be accessed.
$this->drupalLogout();
$this->drupalGet(ImageStyle::load('thumbnail')->buildUrl($image_uri));
$this->assertSession()->statusCodeEquals(403);
// Log in again.
$this->drupalLogin($this->adminUser);
}
// Test the image URL formatter without an image style.
$display_options = [
'type' => 'image_url',
'settings' => ['image_style' => ''],
];
$expected_url = $file->createFileUrl();
$this->assertEquals($expected_url, $node->{$field_name}->view($display_options)[0]['#markup']);
// Test the image URL formatter with an image style.
$display_options['settings']['image_style'] = 'thumbnail';
$expected_url = \Drupal::service('file_url_generator')->transformRelative(ImageStyle::load('thumbnail')->buildUrl($image_uri));
$this->assertEquals($expected_url, $node->{$field_name}->view($display_options)[0]['#markup']);
// Test the settings summary.
$display_options = [
'type' => 'image_url',
'settings' => [
'image_style' => 'thumbnail',
],
];
$display = \Drupal::service('entity_display.repository')->getViewDisplay('node', $node->getType(), 'default');
$display->setComponent($field_name, $display_options)->save();
$this->drupalGet("admin/structure/types/manage/" . $node->getType() . "/display");
$this->assertSession()->responseContains('Image style: Thumbnail (100×100)');
}
/**
* Tests for image field settings.
*/
public function testImageFieldSettings(): void {
/** @var \Drupal\Core\Render\RendererInterface $renderer */
$renderer = $this->container->get('renderer');
$node_storage = $this->container->get('entity_type.manager')->getStorage('node');
$test_image = current($this->drupalGetTestFiles('image'));
[, $test_image_extension] = explode('.', $test_image->filename);
$field_name = $this->randomMachineName();
$field_settings = [
'alt_field' => 1,
'file_extensions' => $test_image_extension,
'max_filesize' => '50 KB',
'max_resolution' => '100x100',
'min_resolution' => '10x10',
'title_field' => 1,
];
$widget_settings = [
'preview_image_style' => 'medium',
];
$field = $this->createImageField($field_name, 'node', 'article', [], $field_settings, $widget_settings);
// Verify that the min/max dimensions set on the field are properly
// extracted, and displayed, on the image field's configuration form.
$this->drupalGet('admin/structure/types/manage/article/fields/' . $field->id());
$this->assertSession()->fieldValueEquals('settings[max_resolution][x]', '100');
$this->assertSession()->fieldValueEquals('settings[max_resolution][y]', '100');
$this->assertSession()->fieldValueEquals('settings[min_resolution][x]', '10');
$this->assertSession()->fieldValueEquals('settings[min_resolution][y]', '10');
$this->drupalGet('node/add/article');
$this->assertSession()->pageTextContains('50 KB limit.');
$this->assertSession()->pageTextContains('Allowed types: ' . $test_image_extension . '.');
$this->assertSession()->pageTextContains('Images must be larger than 10x10 pixels. Images larger than 100x100 pixels will be resized.');
// We have to create the article first and then edit it because the alt
// and title fields do not display until the image has been attached.
// Create alt text for the image.
$alt = $this->randomMachineName();
$nid = $this->uploadNodeImage($test_image, $field_name, 'article', $alt);
$this->drupalGet('node/' . $nid . '/edit');
// Verify that the optional fields alt & title are saved & filled.
$this->assertSession()->fieldValueEquals($field_name . '[0][alt]', $alt);
$this->assertSession()->fieldValueEquals($field_name . '[0][title]', '');
// Verify that the attached image is being previewed using the 'medium'
// style.
$node_storage->resetCache([$nid]);
$node = $node_storage->load($nid);
$file = $node->{$field_name}->entity;
$file_url_generator = \Drupal::service('file_url_generator');
$url = $file_url_generator->transformRelative(ImageStyle::load('medium')->buildUrl($file->getFileUri()));
$this->assertSession()->elementExists('css', 'img[width=40][height=20][src="' . $url . '"]');
// Add alt/title fields to the image and verify that they are displayed.
$image = [
'#theme' => 'image',
'#uri' => $file->getFileUri(),
'#alt' => $alt,
'#title' => $this->randomMachineName(),
'#width' => 40,
'#height' => 20,
'#attributes' => ['loading' => 'lazy'],
];
$edit = [
$field_name . '[0][alt]' => $image['#alt'],
$field_name . '[0][title]' => $image['#title'],
];
$this->drupalGet('node/' . $nid . '/edit');
$this->submitForm($edit, 'Save');
$default_output = str_replace("\n", '', (string) $renderer->renderRoot($image));
$this->assertSession()->responseContains($default_output);
// Verify that alt/title longer than allowed results in a validation error.
$test_size = 2000;
$edit = [
$field_name . '[0][alt]' => $this->randomMachineName($test_size),
$field_name . '[0][title]' => $this->randomMachineName($test_size),
];
$this->drupalGet('node/' . $nid . '/edit');
$this->submitForm($edit, 'Save');
$schema = $field->getFieldStorageDefinition()->getSchema();
$this->assertSession()->statusMessageContains("Alternative text cannot be longer than {$schema['columns']['alt']['length']} characters but is currently {$test_size} characters long.", 'error');
$this->assertSession()->statusMessageContains("Title cannot be longer than {$schema['columns']['title']['length']} characters but is currently {$test_size} characters long.", 'error');
// Set cardinality to unlimited and add upload a second image.
// The image widget is extending on the file widget, but the image field
// type does not have the 'display_field' setting which is expected by
// the file widget. This resulted in notices before when cardinality is not
// 1, so we need to make sure the file widget prevents these notices by
// providing all settings, even if they are not used.
// @see FileWidget::formMultipleElements().
$this->drupalGet('admin/structure/types/manage/article/fields/node.article.' . $field_name);
$this->submitForm([
'field_storage[subform][cardinality]' => FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED,
], 'Save');
$edit = [
'files[' . $field_name . '_1][]' => \Drupal::service('file_system')->realpath($test_image->uri),
];
$this->drupalGet('node/' . $node->id() . '/edit');
$this->submitForm($edit, 'Save');
// Add the required alt text.
$this->submitForm([$field_name . '[1][alt]' => $alt], 'Save');
$this->assertSession()->statusMessageContains('Article ' . $node->getTitle() . ' has been updated.', 'status');
// Assert ImageWidget::process() calls FieldWidget::process().
$this->drupalGet('node/' . $node->id() . '/edit');
$edit = [
'files[' . $field_name . '_2][]' => \Drupal::service('file_system')->realpath($test_image->uri),
];
$this->submitForm($edit, $field_name . '_2_upload_button');
$this->assertSession()->elementNotExists('css', 'input[name="files[' . $field_name . '_2][]"]');
$this->assertSession()->elementExists('css', 'input[name="files[' . $field_name . '_3][]"]');
}
/**
* Tests for image loading attribute settings.
*/
public function testImageLoadingAttribute(): void {
/** @var \Drupal\Core\Render\RendererInterface $renderer */
$renderer = $this->container->get('renderer');
$node_storage = $this->container->get('entity_type.manager')->getStorage('node');
$field_name = $this->randomMachineName();
$field_settings = ['alt_field_required' => 0];
$instance = $this->createImageField($field_name, 'node', 'article', [], $field_settings);
// Go to manage display page.
$this->drupalGet("admin/structure/types/manage/article/display");
// Test for existence of link to image styles configuration.
$this->submitForm([], "{$field_name}_settings_edit");
$this->assertSession()->linkByHrefExists(Url::fromRoute('entity.image_style.collection')->toString(), 0, 'Link to image styles configuration is found');
// Remove 'administer image styles' permission from testing admin user.
$admin_user_roles = $this->adminUser->getRoles(TRUE);
user_role_change_permissions(reset($admin_user_roles), ['administer image styles' => FALSE]);
// Go to manage display page again.
$this->drupalGet("admin/structure/types/manage/article/display");
// Test for absence of link to image styles configuration.
$this->submitForm([], "{$field_name}_settings_edit");
$this->assertSession()->linkByHrefNotExists(Url::fromRoute('entity.image_style.collection')->toString(), 'Link to image styles configuration is absent when permissions are insufficient');
// Restore 'administer image styles' permission to testing admin user
user_role_change_permissions(reset($admin_user_roles), ['administer image styles' => TRUE]);
// Create a new node with an image attached.
$test_image = current($this->drupalGetTestFiles('image'));
// Ensure that preview works.
$this->previewNodeImage($test_image, $field_name, 'article');
// After previewing, make the alt field required. It cannot be required
// during preview because the form validation will fail.
$instance->setSetting('alt_field_required', 1);
$instance->save();
// Create alt text for the image.
$alt = $this->randomMachineName();
// Save node.
$nid = $this->uploadNodeImage($test_image, $field_name, 'article', $alt);
$node_storage->resetCache([$nid]);
$node = $node_storage->load($nid);
// Test that the default image loading attribute is being used.
/** @var \Drupal\file\FileInterface $file */
$file = $node->{$field_name}->entity;
$image_uri = $file->getFileUri();
$image = [
'#theme' => 'image',
'#uri' => $image_uri,
'#width' => 40,
'#height' => 20,
'#alt' => $alt,
'#attributes' => ['loading' => 'lazy'],
];
$default_output = str_replace("\n", '', (string) $renderer->renderRoot($image));
$this->assertSession()->responseContains($default_output);
// Test overrides of image loading attribute.
$display_options = [
'type' => 'image',
'settings' => [
'image_link' => '',
'image_style' => '',
'image_loading' => ['attribute' => 'eager'],
],
];
$display = \Drupal::service('entity_display.repository')
->getViewDisplay('node', $node->getType());
$display->setComponent($field_name, $display_options)
->save();
$image = [
'#theme' => 'image',
'#uri' => $image_uri,
'#width' => 40,
'#height' => 20,
'#alt' => $alt,
'#attributes' => ['loading' => 'eager'],
];
$default_output = (string) $renderer->renderRoot($image);
$this->drupalGet('node/' . $nid);
$this->assertSession()->responseContains($default_output);
// Test the image loading "priority" formatter works together with "image_style".
$display_options['settings']['image_style'] = 'thumbnail';
$display->setComponent($field_name, $display_options)
->save();
// Ensure the derivative image is generated so we do not have to deal with
// image style callback paths.
$this->drupalGet(ImageStyle::load('thumbnail')->buildUrl($image_uri));
$image_style = [
'#theme' => 'image_style',
'#uri' => $image_uri,
'#width' => 40,
'#height' => 20,
'#style_name' => 'thumbnail',
'#alt' => $alt,
'#attributes' => ['loading' => 'eager'],
];
$default_output = (string) $renderer->renderRoot($image_style);
$this->drupalGet('node/' . $nid);
$this->assertSession()->responseContains($default_output);
}
/**
* Tests use of a default image with an image field.
*/
public function testImageFieldDefaultImage(): void {
/** @var \Drupal\Core\Render\RendererInterface $renderer */
$renderer = $this->container->get('renderer');
$node_storage = $this->container->get('entity_type.manager')->getStorage('node');
// Create a new image field.
$field_name = $this->randomMachineName();
$this->createImageField($field_name, 'node', 'article');
// Create a new node, with no images and verify that no images are
// displayed.
$node = $this->drupalCreateNode(['type' => 'article']);
$this->drupalGet('node/' . $node->id());
// Verify that no image is displayed on the page by checking for the class
// that would be used on the image field.
$this->assertSession()->responseNotMatches('<div class="(.*?)field--name-' . strtr($field_name, '_', '-') . '(.*?)">');
// Verify that no image style cache tags are found.
$this->assertSession()->responseHeaderNotContains('X-Drupal-Cache-Tags', 'image_style:');
// Add a default image to the public image field.
$images = $this->drupalGetTestFiles('image');
$alt = $this->randomString(512);
$title = $this->randomString(1024);
$edit = [
// Get the path of the 'image-test.png' file.
'files[field_storage_subform_settings_default_image_uuid]' => \Drupal::service('file_system')->realpath($images[0]->uri),
'field_storage[subform][settings][default_image][alt]' => $alt,
'field_storage[subform][settings][default_image][title]' => $title,
];
$this->drupalGet("admin/structure/types/manage/article/fields/node.article.{$field_name}");
$this->submitForm($edit, 'Save');
// Clear field definition cache so the new default image is detected.
\Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
$field_storage = FieldStorageConfig::loadByName('node', $field_name);
$default_image = $field_storage->getSetting('default_image');
$file = \Drupal::service('entity.repository')->loadEntityByUuid('file', $default_image['uuid']);
$this->assertTrue($file->isPermanent(), 'The default image status is permanent.');
$image = [
'#theme' => 'image',
'#uri' => $file->getFileUri(),
'#alt' => $alt,
'#title' => $title,
'#width' => 40,
'#height' => 20,
'#attributes' => ['loading' => 'lazy'],
];
$default_output = str_replace("\n", '', (string) $renderer->renderRoot($image));
$this->drupalGet('node/' . $node->id());
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', $file->getCacheTags()[0]);
// Verify that no image style cache tags are found.
$this->assertSession()->responseHeaderNotContains('X-Drupal-Cache-Tags', 'image_style:');
$this->assertSession()->responseContains($default_output);
// Create a node with an image attached and ensure that the default image
// is not displayed.
// Create alt text for the image.
$alt = $this->randomMachineName();
// Upload the 'image-test.gif' file.
$nid = $this->uploadNodeImage($images[2], $field_name, 'article', $alt);
$node_storage->resetCache([$nid]);
$node = $node_storage->load($nid);
$file = $node->{$field_name}->entity;
$image = [
'#theme' => 'image',
'#uri' => $file->getFileUri(),
'#width' => 40,
'#height' => 20,
'#alt' => $alt,
'#attributes' => ['loading' => 'lazy'],
];
$image_output = str_replace("\n", '', (string) $renderer->renderRoot($image));
$this->drupalGet('node/' . $nid);
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', $file->getCacheTags()[0]);
// Verify that no image style cache tags are found.
$this->assertSession()->responseHeaderNotContains('X-Drupal-Cache-Tags', 'image_style:');
// Default image should not be displayed.
$this->assertSession()->responseNotContains($default_output);
// User supplied image should be displayed.
$this->assertSession()->responseContains($image_output);
// Remove default image from the field and make sure it is no longer used.
// Can't use fillField cause Mink can't fill hidden fields.
$this->drupalGet("admin/structure/types/manage/article/fields/node.article.$field_name");
$this->getSession()->getPage()->find('css', 'input[name="field_storage[subform][settings][default_image][uuid][fids]"]')->setValue(0);
$this->getSession()->getPage()->pressButton('Save');
// Clear field definition cache so the new default image is detected.
\Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
$field_storage = FieldStorageConfig::loadByName('node', $field_name);
$default_image = $field_storage->getSetting('default_image');
$this->assertEmpty($default_image['uuid'], 'Default image removed from field.');
// Create an image field that uses the private:// scheme and test that the
// default image works as expected.
$private_field_name = $this->randomMachineName();
$this->createImageField($private_field_name, 'node', 'article', ['uri_scheme' => 'private']);
// Add a default image to the new field.
$edit = [
// Get the path of the 'image-test.gif' file.
'files[field_storage_subform_settings_default_image_uuid]' => \Drupal::service('file_system')->realpath($images[2]->uri),
'field_storage[subform][settings][default_image][alt]' => $alt,
'field_storage[subform][settings][default_image][title]' => $title,
];
$this->drupalGet('admin/structure/types/manage/article/fields/node.article.' . $private_field_name);
$this->submitForm($edit, 'Save');
// Clear field definition cache so the new default image is detected.
\Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
$private_field_storage = FieldStorageConfig::loadByName('node', $private_field_name);
$default_image = $private_field_storage->getSetting('default_image');
$file = \Drupal::service('entity.repository')->loadEntityByUuid('file', $default_image['uuid']);
$this->assertEquals('private', StreamWrapperManager::getScheme($file->getFileUri()), 'Default image uses private:// scheme.');
$this->assertTrue($file->isPermanent(), 'The default image status is permanent.');
// Create a new node with no image attached and ensure that default private
// image is displayed.
$node = $this->drupalCreateNode(['type' => 'article']);
$image = [
'#theme' => 'image',
'#uri' => $file->getFileUri(),
'#alt' => $alt,
'#title' => $title,
'#width' => 40,
'#height' => 20,
'#attributes' => ['loading' => 'lazy'],
];
$default_output = str_replace("\n", '', (string) $renderer->renderRoot($image));
$this->drupalGet('node/' . $node->id());
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', $file->getCacheTags()[0]);
// Verify that no image style cache tags are found.
$this->assertSession()->responseHeaderNotContains('X-Drupal-Cache-Tags', 'image_style:');
// Default private image should be displayed when no user supplied image
// is present.
$this->assertSession()->responseContains($default_output);
}
}

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\image\Functional;
use Drupal\Tests\image\Kernel\ImageFieldCreationTrait;
use Drupal\Tests\BrowserTestBase;
/**
* @todo Test the following functions.
*
* In file:
* - image.effects.inc:
* image_style_generate()
* \Drupal\image\ImageStyleInterface::createDerivative()
*
* - image.module:
* image_style_options()
* \Drupal\image\ImageStyleInterface::flush()
* image_filter_keyword()
*/
/**
* This class provides methods specifically for testing Image's field handling.
*/
abstract class ImageFieldTestBase extends BrowserTestBase {
use ImageFieldCreationTrait;
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = [
'node',
'image',
'field_ui',
'image_module_test',
];
/**
* A user with permissions to administer content types and image styles.
*
* @var \Drupal\user\UserInterface
*/
protected $adminUser;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Create Basic page and Article node types.
if ($this->profile != 'standard') {
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
$this->drupalCreateContentType(['type' => 'article', 'name' => 'Article']);
}
$this->adminUser = $this->drupalCreateUser([
'access content',
'access administration pages',
'administer site configuration',
'administer content types',
'administer node fields',
'administer nodes',
'create article content',
'edit any article content',
'delete any article content',
'administer image styles',
'administer node display',
]);
$this->drupalLogin($this->adminUser);
}
/**
* Preview an image in a node.
*
* @param \Drupal\Core\Image\ImageInterface $image
* A file object representing the image to upload.
* @param string $field_name
* Name of the image field the image should be attached to.
* @param string $type
* The type of node to create.
*/
public function previewNodeImage($image, $field_name, $type) {
$edit = [
'title[0][value]' => $this->randomMachineName(),
];
$edit['files[' . $field_name . '_0]'] = \Drupal::service('file_system')->realpath($image->uri);
$this->drupalGet('node/add/' . $type);
$this->submitForm($edit, 'Preview');
}
/**
* Upload an image to a node.
*
* @param $image
* A file object representing the image to upload.
* @param $field_name
* Name of the image field the image should be attached to.
* @param $type
* The type of node to create.
* @param $alt
* The alt text for the image. Use if the field settings require alt text.
*/
public function uploadNodeImage($image, $field_name, $type, $alt = '') {
$edit = [
'title[0][value]' => $this->randomMachineName(),
];
$edit['files[' . $field_name . '_0]'] = \Drupal::service('file_system')->realpath($image->uri);
$this->drupalGet('node/add/' . $type);
$this->submitForm($edit, 'Save');
if ($alt) {
// Add alt text.
$this->submitForm([$field_name . '[0][alt]' => $alt], 'Save');
}
// Retrieve ID of the newly created node from the current URL.
$matches = [];
preg_match('/node\/([0-9]+)/', $this->getUrl(), $matches);
return $matches[1] ?? FALSE;
}
/**
* Retrieves the fid of the last inserted file.
*/
protected function getLastFileId() {
return (int) \Drupal::entityQueryAggregate('file')
->accessCheck(FALSE)
->aggregate('fid', 'max')
->execute()[0]['fid_max'];
}
}

View File

@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\image\Functional;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Tests\TestFileCreationTrait;
/**
* Tests validation functions such as min/max dimensions.
*
* @group image
* @group #slow
*/
class ImageFieldValidateTest extends ImageFieldTestBase {
use TestFileCreationTrait {
getTestFiles as drupalGetTestFiles;
compareFiles as drupalCompareFiles;
}
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests image validity.
*/
public function testValid(): void {
$file_system = $this->container->get('file_system');
$image_files = $this->drupalGetTestFiles('image');
$field_name = $this->randomMachineName();
$this->createImageField($field_name, 'node', 'article', [], ['file_directory' => 'test-upload']);
$expected_path = 'public://test-upload';
// Create alt text for the image.
$alt = $this->randomMachineName();
// Create a node with a valid image.
$node = $this->uploadNodeImage($image_files[0], $field_name, 'article', $alt);
$this->assertFileExists($expected_path . '/' . $image_files[0]->filename);
// Remove the image.
$this->drupalGet('node/' . $node . '/edit');
$this->submitForm([], 'Remove');
$this->submitForm([], 'Save');
// Get invalid image test files.
$dir = 'core/tests/fixtures/files';
$files = [];
if (is_dir($dir)) {
$files = $file_system->scanDirectory($dir, '/invalid-img-.*/');
}
$invalid_image_files = [];
foreach ($files as $file) {
$invalid_image_files[$file->filename] = $file;
}
// Try uploading a zero-byte image.
$zero_size_image = $invalid_image_files['invalid-img-zero-size.png'];
$edit = [
'files[' . $field_name . '_0]' => $file_system->realpath($zero_size_image->uri),
];
$this->drupalGet('node/' . $node . '/edit');
$this->submitForm($edit, 'Upload');
$this->assertFileDoesNotExist($expected_path . '/' . $zero_size_image->filename);
// Try uploading an invalid image.
$invalid_image = $invalid_image_files['invalid-img-test.png'];
$edit = [
'files[' . $field_name . '_0]' => $file_system->realpath($invalid_image->uri),
];
$this->drupalGet('node/' . $node . '/edit');
$this->submitForm($edit, 'Upload');
$this->assertFileDoesNotExist($expected_path . '/' . $invalid_image->filename);
// Upload a valid image again.
$valid_image = $image_files[0];
$edit = [
'files[' . $field_name . '_0]' => $file_system->realpath($valid_image->uri),
];
$this->drupalGet('node/' . $node . '/edit');
$this->submitForm($edit, 'Upload');
$this->assertFileExists($expected_path . '/' . $valid_image->filename);
}
/**
* Tests min/max dimensions settings.
*/
public function testResolution(): void {
$field_names = [
0 => $this->randomMachineName(),
1 => $this->randomMachineName(),
2 => $this->randomMachineName(),
];
$min_resolution = [
'width' => 50,
'height' => 50,
];
$max_resolution = [
'width' => 100,
'height' => 100,
];
$no_height_min_resolution = [
'width' => 50,
'height' => NULL,
];
$no_height_max_resolution = [
'width' => 100,
'height' => NULL,
];
$no_width_min_resolution = [
'width' => NULL,
'height' => 50,
];
$no_width_max_resolution = [
'width' => NULL,
'height' => 100,
];
$field_settings = [
0 => $this->getFieldSettings($min_resolution, $max_resolution),
1 => $this->getFieldSettings($no_height_min_resolution, $no_height_max_resolution),
2 => $this->getFieldSettings($no_width_min_resolution, $no_width_max_resolution),
];
$this->createImageField($field_names[0], 'node', 'article', [], $field_settings[0]);
$this->createImageField($field_names[1], 'node', 'article', [], $field_settings[1]);
$this->createImageField($field_names[2], 'node', 'article', [], $field_settings[2]);
// We want a test image that is too small, and a test image that is too
// big, so cycle through test image files until we have what we need.
$image_that_is_too_big = FALSE;
$image_that_is_too_small = FALSE;
$image_factory = $this->container->get('image.factory');
foreach ($this->drupalGetTestFiles('image') as $image) {
$image_file = $image_factory->get($image->uri);
if ($image_file->getWidth() > $max_resolution['width']) {
$image_that_is_too_big = $image;
}
if ($image_file->getWidth() < $min_resolution['width']) {
$image_that_is_too_small = $image;
$image_that_is_too_small_file = $image_file;
}
if ($image_that_is_too_small && $image_that_is_too_big) {
break;
}
}
$this->uploadNodeImage($image_that_is_too_small, $field_names[0], 'article');
$this->assertSession()->statusMessageContains("The specified file {$image_that_is_too_small->filename} could not be uploaded.", 'error');
$this->assertSession()->statusMessageContains("The image is too small. The minimum dimensions are 50x50 pixels and the image size is {$image_that_is_too_small_file->getWidth()}x{$image_that_is_too_small_file->getHeight()} pixels.", 'error');
$this->uploadNodeImage($image_that_is_too_big, $field_names[0], 'article');
$this->assertSession()->statusMessageContains('The image was resized to fit within the maximum allowed dimensions of 100x100 pixels.', 'status');
$this->uploadNodeImage($image_that_is_too_small, $field_names[1], 'article');
$this->assertSession()->statusMessageContains("The specified file {$image_that_is_too_small->filename} could not be uploaded.", 'error');
$this->uploadNodeImage($image_that_is_too_big, $field_names[1], 'article');
$this->assertSession()->statusMessageContains('The image was resized to fit within the maximum allowed width of 100 pixels.', 'status');
$this->uploadNodeImage($image_that_is_too_small, $field_names[2], 'article');
$this->assertSession()->statusMessageContains("The specified file {$image_that_is_too_small->filename} could not be uploaded.", 'error');
$this->uploadNodeImage($image_that_is_too_big, $field_names[2], 'article');
$this->assertSession()->statusMessageContains('The image was resized to fit within the maximum allowed height of 100 pixels.', 'status');
}
/**
* Tests that required alt/title fields gets validated right.
*/
public function testRequiredAttributes(): void {
$field_name = $this->randomMachineName();
$field_settings = [
'alt_field' => 1,
'alt_field_required' => 1,
'title_field' => 1,
'title_field_required' => 1,
'required' => 1,
];
$instance = $this->createImageField($field_name, 'node', 'article', [], $field_settings);
$images = $this->drupalGetTestFiles('image');
// Let's just use the first image.
$image = $images[0];
$this->uploadNodeImage($image, $field_name, 'article');
// Look for form-required for the alt text.
$this->assertSession()->elementExists('xpath', '//label[@for="edit-' . $field_name . '-0-alt" and @class="js-form-required form-required"]/following-sibling::input[@id="edit-' . $field_name . '-0-alt"]');
$this->assertSession()->elementExists('xpath', '//label[@for="edit-' . $field_name . '-0-title" and @class="js-form-required form-required"]/following-sibling::input[@id="edit-' . $field_name . '-0-title"]');
$this->assertSession()->statusMessageContains('Alternative text field is required.', 'error');
$this->assertSession()->statusMessageContains('Title field is required.', 'error');
$instance->setSetting('alt_field_required', 0);
$instance->setSetting('title_field_required', 0);
$instance->save();
$edit = [
'title[0][value]' => $this->randomMachineName(),
];
$this->drupalGet('node/add/article');
$this->submitForm($edit, 'Save');
$this->assertSession()->statusMessageNotContains('Alternative text field is required.');
$this->assertSession()->statusMessageNotContains('Title field is required.');
$instance->setSetting('required', 0);
$instance->setSetting('alt_field_required', 1);
$instance->setSetting('title_field_required', 1);
$instance->save();
$edit = [
'title[0][value]' => $this->randomMachineName(),
];
$this->drupalGet('node/add/article');
$this->submitForm($edit, 'Save');
$this->assertSession()->statusMessageNotContains('Alternative text field is required.');
$this->assertSession()->statusMessageNotContains('Title field is required.');
}
/**
* Tests creating an entity while leaving the image field empty.
*
* This is tested first with edit access to the image field allowed, and then
* with it forbidden.
*
* @dataProvider providerTestEmpty
*/
public function testEmpty($field_name, $required, $cardinality, $form_element_name, $expected_page_text_when_edit_access_allowed, $expected_page_text_when_edit_access_forbidden): void {
$this->createImageField($field_name, 'node', 'article', ['cardinality' => $cardinality], ['required' => $required]);
// Test with field edit access allowed.
$this->drupalGet('node/add/article');
$this->assertSession()->fieldExists($form_element_name);
$edit = [
'title[0][value]' => 'Article with edit-access-allowed image field',
];
$this->submitForm($edit, 'Save');
$this->assertSession()->pageTextContains($expected_page_text_when_edit_access_allowed);
// Test with field edit access forbidden.
\Drupal::service('module_installer')->install(['image_access_test_hidden']);
$this->drupalGet('node/add/article');
$this->assertSession()->fieldNotExists($form_element_name);
$edit = [
'title[0][value]' => 'Article with edit-access-forbidden image field',
];
$this->submitForm($edit, 'Save');
$this->assertSession()->pageTextContains($expected_page_text_when_edit_access_forbidden);
}
/**
* Data provider for ::testEmpty()
*
* @return array
* Test cases.
*/
public static function providerTestEmpty() {
return [
'optional-single' => ['field_image', FALSE, 1, 'files[field_image_0]', 'Article Article with edit-access-allowed image field has been created.', 'Article Article with edit-access-forbidden image field has been created.'],
'optional-unlimited' => ['field_image', FALSE, FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED, 'files[field_image_0][]', 'Article Article with edit-access-allowed image field has been created.', 'Article Article with edit-access-forbidden image field has been created.'],
'optional-multiple-limited' => ['field_image', FALSE, 2, 'files[field_image_0][]', 'Article Article with edit-access-allowed image field has been created.', 'Article Article with edit-access-forbidden image field has been created.'],
'required-single' => ['field_image', TRUE, 1, 'files[field_image_0]', 'field_image field is required.', 'field_image field is required.'],
'required-unlimited' => ['field_image', TRUE, FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED, 'files[field_image_0][]', 'field_image field is required.', 'field_image field is required.'],
// @todo Fix this discrepancy in https://www.drupal.org/project/drupal/issues/3011744.
'required-multiple-limited' => ['field_image', TRUE, 2, 'files[field_image_0][]', 'This value should not be null.', 'Article Article with edit-access-forbidden image field has been created.'],
];
}
/**
* Returns field settings.
*
* @param int[] $min_resolution
* The minimum width and height setting.
* @param int[] $max_resolution
* The maximum width and height setting.
*
* @return array
*/
protected function getFieldSettings($min_resolution, $max_resolution) {
return [
'max_resolution' => $max_resolution['width'] . 'x' . $max_resolution['height'],
'min_resolution' => $min_resolution['width'] . 'x' . $min_resolution['height'],
'alt_field' => 0,
];
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\image\Functional;
use Drupal\field\Entity\FieldConfig;
/**
* Tests the image field widget.
*
* @group image
*/
class ImageFieldWidgetTest extends ImageFieldTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests file widget element.
*/
public function testWidgetElement(): void {
// Check for image widget in add/node/article page
$field_name = $this->randomMachineName();
$min_resolution = 50;
$max_resolution = 100;
$field_settings = [
'max_resolution' => $max_resolution . 'x' . $max_resolution,
'min_resolution' => $min_resolution . 'x' . $min_resolution,
'alt_field' => 0,
];
$this->createImageField($field_name, 'node', 'article', [], $field_settings, [], [], 'Image test on [site:name]');
$this->drupalGet('node/add/article');
// Verify that the image field widget is found on add/node page.
$this->assertSession()->elementExists('xpath', '//div[contains(@class, "field--widget-image-image")]');
// Verify that the image field widget limits accepted files.
$this->assertSession()->elementExists('xpath', '//input[contains(@accept, "image/*")]');
$this->assertSession()->pageTextNotContains('Image test on [site:name]');
// Check for allowed image file extensions - default.
$this->assertSession()->pageTextContains('Allowed types: png gif jpg jpeg webp.');
// Try adding to the field config an unsupported extension, should not
// appear in the allowed types.
$field_config = FieldConfig::loadByName('node', 'article', $field_name);
$field_config->setSetting('file_extensions', 'png gif jpg jpeg webp tiff')->save();
$this->drupalGet('node/add/article');
$this->assertSession()->pageTextContains('Allowed types: png gif jpg jpeg webp.');
// Add a supported extension and remove some supported ones, we should see
// the intersect of those entered in field config with those supported.
$field_config->setSetting('file_extensions', 'png jpe tiff')->save();
$this->drupalGet('node/add/article');
$this->assertSession()->pageTextContains('Allowed types: png jpe.');
}
}

Some files were not shown because too many files have changed in this diff Show More