ResponsiveImageBuilder.php

Namespace

Drupal\responsive_image

File

core/modules/responsive_image/src/ResponsiveImageBuilder.php

View source
<?php

namespace Drupal\responsive_image;

use Drupal\breakpoint\BreakpointInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\File\FileUrlGeneratorInterface;
use Drupal\Core\File\MimeType\MimeTypeMapInterface;
use Drupal\Core\Image\ImageFactory;
use Drupal\Core\Template\Attribute;
use Drupal\image\Entity\ImageStyle;

/**
 * Provides methods related to building responsive image source attributes.
 */
class ResponsiveImageBuilder {
  public function __construct(protected EntityTypeManagerInterface $entityTypeManager, protected MimeTypeMapInterface $mimeTypeMap, protected ImageFactory $imageFactory, protected FileUrlGeneratorInterface $fileUrlGenerator) {
  }
  
  /**
   * Helper function for template_preprocess_responsive_image().
   *
   * Builds an array of attributes for <source> tags to be used in a <picture>
   * tag. In other words, this function provides the attributes for each
   * <source> tag in a <picture> tag.
   *
   * In a responsive image style, each breakpoint has an image style mapping for
   * each of its multipliers. An image style mapping can be either of two types:
   * 'sizes' (meaning it will output a <source> tag with the 'sizes' attribute)
   * or 'image_style' (meaning it will output a <source> tag based on the
   * selected image style for this breakpoint and multiplier). A responsive
   * image style can contain image style mappings of mixed types (both
   * 'image_style' and 'sizes'). For example:
   * @code
   * $responsive_img_style = ResponsiveImageStyle::create([
   *   'id' => 'style_one',
   *   'label' => 'Style One',
   *   'breakpoint_group' => 'responsive_image_test_module',
   * ]);
   * $responsive_img_style->addImageStyleMapping('responsive_image_test_module.mobile', '1x', [
   *   'image_mapping_type' => 'image_style',
   *   'image_mapping' => 'thumbnail',
   * ])
   * ->addImageStyleMapping('responsive_image_test_module.narrow', '1x', [
   *   'image_mapping_type' => 'sizes',
   *   'image_mapping' => [
   *     'sizes' => '(min-width: 700px) 700px, 100vw',
   *     'sizes_image_styles' => [
   *       'large' => 'large',
   *       'medium' => 'medium',
   *     ],
   *   ],
   * ])
   * ->save();
   * @endcode
   * The above responsive image style will result in a <picture> tag like this:
   * @code
   * <picture>
   *   <source media="(min-width: 0px)" srcset="sites/default/files/styles/thumbnail/image.jpeg" />
   *   <source media="(min-width: 560px)" sizes="(min-width: 700px) 700px, 100vw" srcset="sites/default/files/styles/large/image.jpeg 480w, sites/default/files/styles/medium/image.jpeg 220w" />
   *   <img src="fallback.jpeg" />
   * </picture>
   * @endcode
   *
   * When all the images in the 'srcset' attribute of a <source> tag have the
   * same MIME type, the source tag will get a 'mime-type' attribute as well.
   * This way we can gain some front-end performance because browsers can select
   * which image (<source> tag) to load based on the MIME types they support
   * (which, for instance, can be beneficial for browsers supporting WebP).
   * For example:
   * A <source> tag can contain multiple images:
   * @code
   * <source [...] srcset="image1.jpeg 1x, image2.jpeg 2x, image3.jpeg 3x" />
   * @endcode
   * In the above example we can add the 'mime-type' attribute ('image/jpeg')
   * since all images in the 'srcset' attribute of the <source> tag have the
   * same MIME type.
   * If a <source> tag were to look like this:
   * @code
   * <source [...] srcset="image1.jpeg 1x, image2.webp 2x, image3.jpeg 3x" />
   * @endcode
   * We can't add the 'mime-type' attribute ('image/jpeg' vs 'image/webp').
   * So in order to add the 'mime-type' attribute to the <source> tag all images
   * in the 'srcset' attribute of the <source> tag need to be of the same MIME
   * type. This way, a <picture> tag could look like this:
   * @code
   * <picture>
   *   <source [...] mime-type="image/webp" srcset="image1.webp 1x, image2.webp 2x, image3.webp 3x"/>
   *   <source [...] mime-type="image/jpeg" srcset="image1.jpeg 1x, image2.jpeg 2x, image3.jpeg 3x"/>
   *   <img src="fallback.jpeg" />
   * </picture>
   * @endcode
   * This way a browser can decide which <source> tag is preferred based on the
   * MIME type. In other words, the MIME types of all images in one <source> tag
   * need to be the same in order to set the 'mime-type' attribute but not all
   * MIME types within the <picture> tag need to be the same.
   *
   * For image style mappings of the type 'sizes', a width descriptor is added
   * to each source. For example:
   * @code
   * <source media="(min-width: 0px)" srcset="image1.jpeg 100w" />
   * @endcode
   * The width descriptor here is "100w". This way the browser knows this image
   * is 100px wide without having to load it. According to the spec, a
   * multiplier can not be present if a width descriptor is.
   * For example:
   * Valid:
   * @code
   * <source media="(min-width:0px)" srcset="img1.jpeg 50w, img2.jpeg=100w" />
   * @endcode
   * Invalid:
   * @code
   * <source media="(min-width:0px)" srcset="img1.jpeg 50w 1x, img2.jpeg=100w 1x" />
   * @endcode
   *
   * Note: Since the specs do not allow width descriptors and multipliers
   * combined inside one 'srcset' attribute, we either have to use something
   * like
   * @code
   * <source [...] srcset="image1.jpeg 1x, image2.webp 2x, image3.jpeg 3x" />
   * @endcode
   * to support multipliers or
   * @code
   * <source [...] sizes"(min-width: 40em) 80vw, 100vw" srcset="image1.jpeg 300w, image2.webp 600w, image3.jpeg 1200w" />
   * @endcode
   * to support the 'sizes' attribute.
   *
   * In theory people could add an image style mapping for the same breakpoint
   * (but different multiplier) so the array contains an entry for
   * breakpointA.1x and breakpointA.2x. If we would output those we will end up
   * with something like
   * @code
   * <source [...] sizes="(min-width: 40em) 80vw, 100vw" srcset="a1.jpeg 300w 1x, a2.jpeg 600w 1x, a3.jpeg 1200w 1x, b1.jpeg 250w 2x, b2.jpeg 680w 2x, b3.jpeg 1240w 2x" />
   * @endcode
   * which is illegal. So the solution is to merge both arrays into one and
   * disregard the multiplier. Which, in this case, would output
   * @code
   * <source [...] sizes="(min-width: 40em) 80vw, 100vw" srcset="b1.jpeg 250w, a1.jpeg 300w, a2.jpeg 600w, b2.jpeg 680w, a3.jpeg 1200w,  b3.jpeg 1240w" />
   * @endcode
   * See https://www.w3.org/html/wg/drafts/html/master/embedded-content.html#image-candidate-string
   * for further information.
   *
   * @param array $variables
   *   An array with the following keys:
   *     - responsive_image_style_id: The
   *       \Drupal\responsive_image\Entity\ResponsiveImageStyle ID.
   *     - width: The width of the image (if known).
   *     - height: The height of the image (if known).
   *     - uri: The URI of the image file.
   * @param \Drupal\breakpoint\BreakpointInterface $breakpoint
   *   The breakpoint for this source tag.
   * @param array $multipliers
   *   An array with multipliers as keys and image style mappings as values.
   *
   * @return \Drupal\Core\Template\Attribute
   *   An object of attributes for the source tag.
   */
  public function buildSourceAttributes(array $variables, BreakpointInterface $breakpoint, array $multipliers) : Attribute {
    if (empty($variables['width']) || empty($variables['height'])) {
      $image = $this->imageFactory
        ->get($variables['uri']);
      $width = $image->getWidth();
      $height = $image->getHeight();
    }
    else {
      $width = $variables['width'];
      $height = $variables['height'];
    }
    $extension = pathinfo($variables['uri'], PATHINFO_EXTENSION);
    $sizes = [];
    $srcset = [];
    $derivative_mime_types = [];
    // Traverse the multipliers in reverse so the largest image is processed
    // last./ The last image's dimensions are used for img.srcset height and
    // width.
    foreach (array_reverse($multipliers) as $multiplier => $image_style_mapping) {
      switch ($image_style_mapping['image_mapping_type']) {
        // Create a <source> tag with the 'sizes' attribute.
        case 'sizes':
          // Loop through the image styles for this breakpoint and multiplier.
          foreach ($image_style_mapping['image_mapping']['sizes_image_styles'] as $image_style_name) {
            // Get the dimensions.
            $dimensions = $this->getImageDimensions($image_style_name, [
              'width' => $width,
              'height' => $height,
            ], $variables['uri']);
            // Get MIME type.
            $derivative_mime_type = $this->getMimeType($image_style_name, $extension);
            $derivative_mime_types[] = $derivative_mime_type;
            // Add the image source with its width descriptor. When a width
            // descriptor is used in a srcset, we can't add a multiplier to
            // it. Because of this, the image styles for all multipliers of
            // this breakpoint should be merged into one srcset and the sizes
            // attribute should be merged as well.
            if (is_null($dimensions['width'])) {
              throw new \LogicException("Could not determine image width for '{$variables['uri']}' using image style with ID: {$image_style_name}. This image style can not be used for a responsive image style mapping using the 'sizes' attribute.");
            }
            // Use the image width as key so we can sort the array later on.
            // Images within a srcset should be sorted from small to large,
            // since the first matching source will be used.
            $srcset[intval($dimensions['width'])] = $this->getImageStyleUrl($image_style_name, $variables['uri']) . ' ' . $dimensions['width'] . 'w';
            $sizes = array_merge(explode(',', $image_style_mapping['image_mapping']['sizes']), $sizes);
          }
          break;

        case 'image_style':
          // Get MIME type.
          $derivative_mime_type = $this->getMimeType($image_style_mapping['image_mapping'], $extension);
          $derivative_mime_types[] = $derivative_mime_type;
          // Add the image source with its multiplier. Use the multiplier as
          // key so we can sort the array later on. Multipliers within a srcset
          // should be sorted from small to large, since the first matching
          // source will be used. We multiply it by 100 so multipliers with up
          // to two decimals can be used.
          $srcset[intval(mb_substr($multiplier, 0, -1) * 100)] = $this->getImageStyleUrl($image_style_mapping['image_mapping'], $variables['uri']) . ' ' . $multiplier;
          $dimensions = $this->getImageDimensions($image_style_mapping['image_mapping'], [
            'width' => $width,
            'height' => $height,
          ], $variables['uri']);
          break;

      }
    }
    // Sort the srcset from small to large image width or multiplier.
    ksort($srcset);
    $source_attributes = new Attribute([
      'srcset' => implode(', ', array_unique($srcset)),
    ]);
    $media_query = trim($breakpoint->getMediaQuery());
    if (!empty($media_query)) {
      $source_attributes->setAttribute('media', $media_query);
    }
    if (count(array_unique($derivative_mime_types)) == 1) {
      $source_attributes->setAttribute('type', $derivative_mime_types[0]);
    }
    if (!empty($sizes)) {
      $source_attributes->setAttribute('sizes', implode(',', array_unique($sizes)));
    }
    // The images used in a particular srcset attribute should all have the same
    // aspect ratio. The sizes attribute paired with the srcset attribute
    // provides information on how much space these images take up within the
    // viewport at different breakpoints, but the aspect ratios should remain
    // the same across those breakpoints. Multiple source elements can be used
    // for art direction, where aspect ratios should change at particular
    // breakpoints. Each source element can still have srcset and sizes
    // attributes to handle variations for that particular aspect ratio. Because
    // the same aspect ratio is assumed for all images in a srcset, dimensions
    // are always added to the source attribute. Within srcset, images are
    // sorted from largest to smallest in terms of the real dimension of the
    // image.
    if (!empty($dimensions['width']) && !empty($dimensions['height'])) {
      $source_attributes->setAttribute('width', $dimensions['width']);
      $source_attributes->setAttribute('height', $dimensions['height']);
    }
    return $source_attributes;
  }
  
  /**
   * Determines the dimensions of an image.
   *
   * @param string $image_style_name
   *   The name of the style to be used to alter the original image.
   * @param array $dimensions
   *   An associative array containing:
   *   - width: The width of the source image (if known).
   *   - height: The height of the source image (if known).
   * @param string $uri
   *   The URI of the image file.
   *
   * @return array
   *   Dimensions to be modified - an array with components width and height, in
   *   pixels.
   */
  public function getImageDimensions(string $image_style_name, array $dimensions, string $uri) : array {
    // Determine the dimensions of the styled image.
    if ($image_style_name == ResponsiveImageStyleInterface::EMPTY_IMAGE) {
      $dimensions = [
        'width' => 1,
        'height' => 1,
      ];
    }
    elseif ($entity = $this->entityTypeManager
      ->getStorage('image_style')
      ->load($image_style_name)) {
      assert($entity instanceof ImageStyle);
      $entity->transformDimensions($dimensions, $uri);
    }
    return $dimensions;
  }
  
  /**
   * Determines the MIME type of an image.
   *
   * @param string $image_style_name
   *   The image style that will be applied to the image.
   * @param string $extension
   *   The original extension of the image (without the leading dot).
   *
   * @return string|null
   *   The MIME type of the image after the image style is applied, or NULL if
   *   not found.
   */
  public function getMimeType(string $image_style_name, string $extension) : ?string {
    $extension = match ($image_style_name) {  ResponsiveImageStyleInterface::EMPTY_IMAGE => 'gif',
      ResponsiveImageStyleInterface::ORIGINAL_IMAGE => $extension,
      default => $this->entityTypeManager
        ->getStorage('image_style')
        ->load($image_style_name)
        ->getDerivativeExtension($extension),
    
    };
    return $this->mimeTypeMap
      ->getMimeTypeForExtension($extension);
  }
  
  /**
   * Returns the given image style URL.
   *
   * Explicitly supports the special empty image as aa data: URL.
   *
   * @param string $style_name
   *   Name of the image style.
   * @param string $path
   *   The path or URI to the original image.
   *
   * @return string
   *   The image style URL.
   */
  public function getImageStyleUrl(string $style_name, string $path) : string {
    if ($style_name == ResponsiveImageStyleInterface::EMPTY_IMAGE) {
      // The smallest data URI for a 1px square transparent GIF image.
      // http://probablyprogramming.com/2009/03/15/the-tiniest-gif-ever
      return 'data:image/gif;base64,R0lGODlhAQABAIABAP///wAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
    }
    $entity = ImageStyle::load($style_name);
    if ($entity instanceof ImageStyle) {
      return $this->fileUrlGenerator
        ->transformRelative($entity->buildUrl($path));
    }
    return $this->fileUrlGenerator
      ->generateString($path);
  }

}

Classes

Title Deprecated Summary
ResponsiveImageBuilder Provides methods related to building responsive image source attributes.

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.