media_helper-2.0.0/src/Service/MediaHelper.php
src/Service/MediaHelper.php
<?php
namespace Drupal\media_helper\Service;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\Display\EntityDisplayInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\file\FileInterface;
use Drupal\image\ImageStyleInterface;
use Drupal\media\MediaInterface;
use Drupal\media\MediaSourceInterface;
use Drupal\media_helper\MediaHelperInterface;
use function in_array;
/**
* The module's main service providing configuration reading and media rendering.
*/
class MediaHelper implements MediaHelperInterface {
/**
* A list of other modules for which Media Helper integration is enabled.
*
* @var string[]
*/
protected array $moduleIntegrationsEnabled = [];
/**
* A list of supported media source plugin IDs for |media_image.
*
* Dynamic instead of constant so that integrations can add to the list.
*
* @var string[]
*/
protected array $supportedImageSourcePluginIds = [
'image',
];
/**
* Whether svg_image_field module integration should account for image styles.
*
* If this is TRUE, image styles should be taken into account for dimensions. If this is FALSE, they should not.
*/
protected bool $svgImageFieldModuleOverrideDimensions = FALSE;
/**
* Instantiates the MediaHelper service.
*/
public function __construct(
protected ConfigFactoryInterface $configFactory,
protected EntityTypeManagerInterface $entityTypeManager,
protected FileSystemInterface $fileSystem,
protected ModuleHandlerInterface $moduleHandler,
) {
$config = $this->configFactory->get('media_helper.settings');
if ($this->moduleHandler->moduleExists('responsive_image')) {
$this->moduleIntegrationsEnabled[] = 'responsive_image';
}
if (
$config->get('integrations.svg_image.enable')
&& $this->moduleHandler->moduleExists('svg_image')
&& function_exists('svg_image_is_file_svg')
) {
$this->moduleIntegrationsEnabled[] = 'svg_image';
}
if (
$config->get('integrations.svg_image_field.enable')
&& $this->moduleHandler->moduleExists('svg_image_field')
) {
$this->moduleIntegrationsEnabled[] = 'svg_image_field';
$this->supportedImageSourcePluginIds[] = 'svg';
$this->svgImageFieldModuleOverrideDimensions = (bool) $config->get('integrations.svg_image_field.override_dimensions');
}
}
/**
* {@inheritdoc}
*/
public function getSupportedImageSourcePluginIds(): array {
return $this->supportedImageSourcePluginIds;
}
/**
* {@inheritdoc}
*/
public function isImageSourceSupported(string|MediaSourceInterface $source): bool {
if ($source instanceof MediaSourceInterface) {
$source = $source->getPluginId();
}
return in_array($source, $this->supportedImageSourcePluginIds, TRUE);
}
/**
* {@inheritdoc}
*/
public function isModuleIntegrationEnabled(string $module): bool {
return in_array($module, $this->moduleIntegrationsEnabled, TRUE);
}
/**
* {@inheritdoc}
*/
public function getResponsiveImgTagAttributeNames(): array {
$tags = &drupal_static(__METHOD__);
if (!isset($tags)) {
$configured_names = $this->configFactory
->get('media_helper.settings')
->get('integrations.responsive_image.img_tag_attributes')
?: [];
$tags = array_merge(MediaHelperInterface::ALWAYS_IMG_TAG_ATTRS, $configured_names);
}
return $tags;
}
/**
* {@inheritdoc}
*/
public function mediaBundle(MediaInterface|array $media): ?string {
if (!is_array($media)) {
return $media->bundle();
}
$type = NULL;
foreach ($media as $single_media) {
if (!$single_media instanceof MediaInterface) {
throw new \InvalidArgumentException('You may only pass objects of the MediaInterface type.');
}
if (!$type) {
$type = $single_media->bundle();
}
elseif ($type !== $single_media->bundle()) {
return 'mixed';
}
}
return $type;
}
/**
* {@inheritdoc}
*/
public function mediaSource(MediaInterface|array $media): ?string {
if (!is_array($media)) {
return $media->getSource()->getPluginId();
}
$source_id = NULL;
foreach ($media as $single_media) {
if (!$single_media instanceof MediaInterface) {
throw new \InvalidArgumentException('You may only pass objects of the MediaInterface type.');
}
if (!$source_id) {
$source_id = $single_media->getSource()->getPluginId();
}
elseif ($source_id !== $single_media->getSource()->getPluginId()) {
return 'mixed';
}
}
return $source_id;
}
/**
* {@inheritdoc}
*/
public function mediaImage(MediaInterface|array|null $media, string $style = '', array|string $classes = [], array $attributes = []): ?array {
if ($media instanceof MediaInterface) {
$media = [$media];
}
elseif (!$media) {
return NULL;
}
$render = [];
foreach ($media as $image) {
$render[] = $this->renderMediaImage($image, [$this, 'mediaImageRender'], $style, $classes, $attributes);
}
return $render ?: NULL;
}
/**
* {@inheritdoc}
*/
public function mediaImageUrl(MediaInterface|array|null $media, string $style = ''): ?array {
if ($media instanceof MediaInterface) {
$media = [$media];
}
elseif (!$media) {
return NULL;
}
$render = [];
foreach ($media as $image) {
if (!$image instanceof MediaInterface) {
throw new \UnexpectedValueException('Non-media item passed in array!');
}
$render[] = $this->renderMediaImage($image, [$this, 'mediaImageUrlRender'], $style);
}
return $render ?: NULL;
}
/**
* {@inheritdoc}
*/
public function mediaVideo(MediaInterface|array $media, array|string $classes = [], array $settings = [], array $attributes = []): ?array {
if ($media instanceof MediaInterface) {
$media = [$media];
}
elseif (!$media) {
return NULL;
}
$render = [];
foreach ($media as $video) {
$render[] = $this->renderVideo($video, $classes, $settings, $attributes);
}
return $render ?: NULL;
}
/**
* Get the name of the source field on a media entity.
*
* @param \Drupal\media\MediaInterface $media
* The media entity.
*
* @return string|null
* The name of the source field, or NULL if no field is configured.
*/
protected function getSourceFieldName(MediaInterface $media): ?string {
return $media->getSource()->getSourceFieldDefinition($media->bundle->entity)?->getName();
}
/**
* Handles rendering a media image.
*
* @param \Drupal\media\MediaInterface|null $media
* A media entity object.
* @param callable $widget_callback
* A callable to finish constructing widget settings with any logic specific to the widget type. Signature:
* (array &$widget_settings, MediaInterface $media, string $image_style, string $image_field_name)
* Callable should return a boolean indicating if it was successful.
* @param string $style
* (optional) The machine name of the image style to use.
* @param string[]|string $classes
* (optional) Class(es) to add to the element, as a string or array of strings.
* @param array $attributes
* (optional) Attributes to add to the element, name => value.
*
* @return array|null
* A render array, or NULL if there is no meaningful output (e.g. the media source cannot be handled).
*/
protected function renderMediaImage(MediaInterface $media, callable $widget_callback, string $style = '', array|string $classes = [], array $attributes = []): ?array {
$media_source_plugin_id = $media->getSource()->getPluginId();
if (!in_array($media_source_plugin_id, $this->supportedImageSourcePluginIds, TRUE)) {
return NULL;
}
$image_field_name = $this->getSourceFieldName($media);
if (!$image_field_name) {
return NULL;
}
$render = [];
$access_result = $media->access('view', NULL, TRUE);
if ($access_result->isAllowed()) {
$current_image_style = $style ?: $this->getDefaultImageStyle($media);
$image_render_widget_settings = [
'label' => 'hidden',
];
switch ($media_source_plugin_id) {
case 'image':
$image_render_widget_settings['settings'] = [
'image_link' => '',
'image_style' => $current_image_style,
];
break;
case 'svg':
// Formatter from the svg_image_field module.
// No common settings between svg_image_field_formatter and
// svg_image_url_formatter.
break;
default:
throw new \LogicException("Media source plugin {$media_source_plugin_id} has no display widget settings handling!");
}
$settings_success = call_user_func_array(
$widget_callback,
[&$image_render_widget_settings, $media, $current_image_style, $image_field_name],
);
if ($settings_success) {
$render = $media->get($image_field_name)->view($image_render_widget_settings);
if (
!empty($render[0])
&& ($attributes || $classes)
) {
$is_responsive_image = isset($render[0]['#theme']) && $render[0]['#theme'] === 'responsive_image_formatter';
$attributes_key = match(TRUE) {
$is_responsive_image => '#media_helper_attributes',
$media_source_plugin_id === 'svg' => '#attributes',
default => '#item_attributes',
};
if ($attributes) {
foreach ($attributes as $attribute_name => $attribute_value) {
$render[0][$attributes_key][$attribute_name] = $attribute_value;
}
}
// Lastly, take care of CSS classes.
if ($classes) {
if (is_string($classes)) {
$classes = preg_split('/\s+/', $classes, -1, PREG_SPLIT_NO_EMPTY);
}
$existing_classes = ($render[0][$attributes_key]['class'] ?? NULL) ?: [];
$render[0][$attributes_key]['class'] = array_merge($existing_classes, $classes);
}
}
}
}
CacheableMetadata::createFromRenderArray($render)
->addCacheableDependency($media)
->addCacheableDependency($access_result)
->applyTo($render);
return $render;
}
/**
* Handles image display widget settings specific to mediaImage().
*
* @param array $widget_settings
* The half-finished widget settings array.
* @param \Drupal\media\MediaInterface $media
* The media entity being rendered.
* @param string $image_style_name
* The image style to use.
* @param string $image_field_name
* The name of the media image field.
*
* @return bool
* Whether or not the render array was finished successfully.
*/
protected function mediaImageRender(array &$widget_settings, MediaInterface $media, string $image_style_name, string $image_field_name): bool {
$media_source_plugin_id = $media->getSource()->getPluginId();
if ($media_source_plugin_id === 'image') {
$image_style = $image_style_name
? $this->entityTypeManager->getStorage('image_style')->load($image_style_name)
: NULL;
if (!$image_style_name || $image_style) {
$widget_settings['type'] = 'image';
// Check if this is an SVG file allowed into an image field by the svg_image module.
// If so, set width/height attributes from image style effects and/or the SVG file.
if (
$this->isModuleIntegrationEnabled('svg_image')
&& ($file_field = $media->get($image_field_name))
&& ($file = $file_field->entity)
&& svg_image_is_file_svg($file)
) {
$image_item = $file_field->first();
$svg_width = $image_item?->width;
$svg_height = $image_item?->height;
$dimensions = ($svg_width && $svg_height)
? ['width' => $svg_width, 'height' => $svg_height]
: NULL;
if ($image_style) {
$dimensions = $this->applyImageStyleDimensions($image_style, $dimensions);
}
if ($dimensions) {
foreach (['width', 'height'] as $config_attr) {
$widget_settings['settings']['svg_attributes'][$config_attr] = $dimensions[$config_attr];
}
}
}
}
elseif (
$this->isModuleIntegrationEnabled('responsive_image')
&& $this->entityTypeManager->getStorage('responsive_image_style')->load($image_style_name)
) {
$widget_settings['type'] = 'responsive_image';
$widget_settings['settings']['responsive_image_style'] = $image_style_name;
unset($widget_settings['settings']['image_style']);
// @todo support svg_image module dimensions when using responsive image styles? See module svg_image_responsive for possible integration.
if (
$this->isModuleIntegrationEnabled('svg_image')
&& ($file = $media->get($image_field_name)->entity)
&& svg_image_is_file_svg($file)
) {
trigger_error('svg_image module not yet supported together with responsive_image module\'s responsive image styles.', E_USER_WARNING);
}
}
else {
trigger_error('media_image Twig filter could not find an image style with machine name "' . $image_style_name . '"! (media entity ' . $media->id() . ')', E_USER_WARNING);
return FALSE;
};
return TRUE;
}
if ($media_source_plugin_id === 'svg') {
$widget_settings['type'] = 'svg_image_field_formatter';
$widget_settings['settings'] = [
'enable_alt' => TRUE,
'inline' => FALSE,
'link_url' => FALSE,
'apply_dimensions' => FALSE,
];
$file = $media->get($image_field_name)->entity;
if ($file) {
$dimensions = $this->getSvgDimensions($file);
// We do not directly support the responsive_image module here, but we also ensure we do not fail SVG output due
// to the use of a responsive image style name. The responsive_image module is "supported" insofar as passing a
// responsive image style will not cause errors, but neither will it affect image dimension attributes.
if (
$image_style_name
&& $this->svgImageFieldModuleOverrideDimensions
&& ($image_style = $this->entityTypeManager->getStorage('image_style')->load($image_style_name))
) {
$dimensions = $this->applyImageStyleDimensions($image_style, $dimensions);
}
if ($dimensions) {
$widget_settings['settings']['apply_dimensions'] = TRUE;
$widget_settings['settings']['width'] = $dimensions['width'];
$widget_settings['settings']['height'] = $dimensions['height'];
}
}
return TRUE;
}
throw new \LogicException("Media source plugin {$media_source_plugin_id} has no handling in mediaImageRender!");
}
/**
* Manually applies dimensional settings from an image style.
*
* Useful for SVG handling.
*
* If initial dimensions are provided, every effect's transformDimensions() method will be used in order, to further
* process dimensions.
*
* If initial dimensions are not provided, they will be taken from the first plugin with width and height settings.
* Only effects after this point will have their transformDimensions() method applied.
*
* @param \Drupal\image\ImageStyleInterface $image_style
* The image style to apply effects from.
* @param int[]|null $dimensions
* (optional) The dimensions to start from. If provided, should be an array with 'width' and 'height' keys.
*
* @return array|null
* The Image Style-influenced dimensions, or NULL if no dimensions were passed and no image effects established any.
*/
protected function applyImageStyleDimensions(ImageStyleInterface $image_style, ?array $dimensions = NULL): ?array {
foreach ($image_style->getEffects() as $effect) {
if ($dimensions) {
$effect->transformDimensions($dimensions, NULL);
}
elseif (
($config = $effect->getConfiguration())
&& !empty($config['data']['width'])
&& !empty($config['data']['height'])
) {
$dimensions = [
'width' => $config['data']['width'],
'height' => $config['data']['height'],
];
}
}
return $dimensions;
}
/**
* Gets the dimensions of an SVG.
*
* @param \Drupal\file\FileInterface $file
* The SVG to get dimensions for.
*
* @return int[]|null
* An array with 'width' and 'height' attributes, or NULL if the file could not be loaded or dimensions could not be
* determined.
*/
protected function getSvgDimensions(FileInterface $file): ?array {
// @todo cache results. At least in drupal_static(), maybe even in cache backend.
if (
($svg = file_get_contents($file->getFileUri()))
&& ($svg = simplexml_load_string(data: $svg, options: LIBXML_NOWARNING))
) {
$attributes = $svg->attributes();
$width = (int) $attributes->width;
$height = (int) $attributes->height;
if ($width && $height) {
return [
'width' => $width,
'height' => $height,
];
}
}
return NULL;
}
/**
* Handles image display widget settings specific to mediaImageUrl().
*
* @param array $widget_settings
* The half-finished widget settings array.
* @param \Drupal\media\MediaInterface $media
* The media entity being rendered.
* @param string $image_style_name
* The image style to use.
*
* @return bool
* Whether or not the render array was finished successfully.
*/
protected function mediaImageUrlRender(array &$widget_settings, MediaInterface $media, string $image_style_name): bool {
$media_source_plugin_id = $media->getSource()->getPluginId();
if ($media_source_plugin_id === 'image') {
if (!$image_style_name || $this->entityTypeManager->getStorage('image_style')->load($image_style_name)) {
$widget_settings['type'] = 'image_url';
return TRUE;
}
trigger_error('media_image_url Twig filter could not find an image style with machine name "' . $image_style_name . '"! (media entity ' . $media->id() . ')', E_USER_WARNING);
return FALSE;
}
if ($media_source_plugin_id === 'svg') {
$widget_settings['type'] = 'svg_image_url_formatter';
return TRUE;
}
throw new \LogicException("Media source plugin {$media_source_plugin_id} has no handling in mediaImageUrlRender!");
}
/**
* Finds the image style for the source field on media's default display mode.
*
* @param \Drupal\media\MediaInterface $media
* The media entity.
* @param string $source_field_name
* (optional) The name of the source image field for this media.
*
* @return string
* The image style from the default view mode. An empty string if it cannot be found.
*/
protected function getDefaultImageStyle(MediaInterface $media, string $source_field_name = ''): string {
$default_image_style = '';
if (!$source_field_name) {
$source_field_name = $this->getSourceFieldName($media);
}
if ($source_field_name) {
$view_mode_id = $media->getEntityTypeId() . '.' . $media->bundle() . '.default';
$default_view_mode = $this->entityTypeManager
->getStorage('entity_view_display')
->load($view_mode_id);
if ($default_view_mode) {
assert($default_view_mode instanceof EntityDisplayInterface);
$image_field_config = $default_view_mode->getComponent($source_field_name);
if (!empty($image_field_config['type'])) {
if (
$image_field_config['type'] === 'image'
&& !empty($image_field_config['settings']['image_style'])
) {
$default_image_style = $image_field_config['settings']['image_style'];
}
elseif (
$image_field_config['type'] === 'responsive_image'
&& !empty($image_field_config['settings']['responsive_image_style'])
) {
$default_image_style = $image_field_config['settings']['responsive_image_style'];
}
}
}
}
return $default_image_style;
}
/**
* Helper function for processing individual video media.
*
* @return array|null
* A render array, or NULL if there is no meaningful output (e.g. the media source cannot be handled).
*/
protected function renderVideo(MediaInterface $media, array|string $classes, array $settings, array $attributes): ?array {
if ($media->getSource()->getPluginId() !== 'video_file') {
return NULL;
}
$video_field_name = $this->getSourceFieldName($media);
if (!$video_field_name) {
return NULL;
}
$render = [];
$access_result = $media->access('view', NULL, TRUE);
if ($access_result->isAllowed()) {
$video_widget_settings = array_merge(
[
'autoplay' => TRUE,
'controls' => FALSE,
'loop' => TRUE,
'muted' => TRUE,
'multiple_file_display_type' => 'sources',
'width' => NULL,
'height' => NULL,
],
$settings,
);
$render = $media->get($video_field_name)->view([
'type' => 'file_video',
'label' => 'hidden',
'settings' => $video_widget_settings,
]);
// If the video is set to autoplay, add some sensible defaults for autoplay videos on mobile devices.
if ($video_widget_settings['autoplay']) {
$attributes = array_merge(
[
'disablePictureInPicture' => 'true',
'playsinline' => TRUE,
],
$attributes,
);
}
if ($attributes && !empty($render[0])) {
foreach ($attributes as $attribute_name => $attribute_value) {
$render[0]['#attributes'][$attribute_name] = $attribute_value;
}
}
// Lastly, take care of CSS classes.
if ($classes && !empty($render[0])) {
if (is_string($classes)) {
$classes = preg_split('/\s+/', $classes, -1, PREG_SPLIT_NO_EMPTY);
}
$existing_classes = ($render[0]['#attributes']['class'] ?? NULL) ?: [];
$render[0]['#attributes']['class'] = array_merge($existing_classes, $classes);
}
}
CacheableMetadata::createFromRenderArray($render)
->addCacheableDependency($media)
->addCacheableDependency($access_result)
->applyTo($render);
return $render;
}
}
