core/lib/Thelia/Action/Image.php line 91

  1. <?php
  2. /*
  3.  * This file is part of the Thelia package.
  4.  * http://www.thelia.net
  5.  *
  6.  * (c) OpenStudio <info@thelia.net>
  7.  *
  8.  * For the full copyright and license information, please view the LICENSE
  9.  * file that was distributed with this source code.
  10.  */
  11. namespace Thelia\Action;
  12. use Imagine\Gd\Imagine;
  13. use Imagine\Gmagick\Imagine as GmagickImagine;
  14. use Imagine\Image\Box;
  15. use Imagine\Image\ImageInterface;
  16. use Imagine\Image\ImagineInterface;
  17. use Imagine\Image\Palette\RGB;
  18. use Imagine\Image\Point;
  19. use Imagine\Imagick\Imagine as ImagickImagine;
  20. use Symfony\Component\EventDispatcher\EventDispatcherInterface;
  21. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  22. use Thelia\Core\Event\Image\ImageEvent;
  23. use Thelia\Core\Event\TheliaEvents;
  24. use Thelia\Exception\ImageException;
  25. use Thelia\Model\ConfigQuery;
  26. use Thelia\Tools\URL;
  27. /**
  28.  * Image management actions. This class handles image processing and caching.
  29.  *
  30.  * Basically, images are stored outside of the web space (by default in local/media/images),
  31.  * and cached inside the web space (by default in web/local/images).
  32.  *
  33.  * In the images caches directory, a subdirectory for images categories (eg. product, category, folder, etc.) is
  34.  * automatically created, and the cached image is created here. Plugin may use their own subdirectory as required.
  35.  *
  36.  * The cached image name contains a hash of the processing options, and the original (normalized) name of the image.
  37.  *
  38.  * A copy (or symbolic link, by default) of the original image is always created in the cache, so that the full
  39.  * resolution image is always available.
  40.  *
  41.  * Various image processing options are available :
  42.  *
  43.  * - resizing, with border, crop, or by keeping image aspect ratio
  44.  * - rotation, in degrees, positive or negative
  45.  * - background color, applyed to empty background when creating borders or rotating
  46.  * - effects. The effects are applied in the specified order. The following effects are available:
  47.  *    - gamma:value : change the image Gamma to the specified value. Example: gamma:0.7
  48.  *    - grayscale or greyscale: switch image to grayscale
  49.  *    - colorize:color : apply a color mask to the image. Exemple: colorize:#ff2244
  50.  *    - negative : transform the image in its negative equivalent
  51.  *    - vflip or vertical_flip : vertical flip
  52.  *    - hflip or horizontal_flip : horizontal flip
  53.  *
  54.  * If a problem occurs, an ImageException may be thrown.
  55.  *
  56.  * @author Franck Allimant <franck@cqfdev.fr>
  57.  */
  58. class Image extends BaseCachedFile implements EventSubscriberInterface
  59. {
  60.     // Resize mode constants
  61.     public const EXACT_RATIO_WITH_BORDERS 1;
  62.     public const EXACT_RATIO_WITH_CROP 2;
  63.     public const KEEP_IMAGE_RATIO 3;
  64.     /**
  65.      * @return string root of the image cache directory in web space
  66.      */
  67.     protected function getCacheDirFromWebRoot()
  68.     {
  69.         return ConfigQuery::read('image_cache_dir_from_web_root''cache'.DS.'images');
  70.     }
  71.     /**
  72.      * Process image and write the result in the image cache.
  73.      *
  74.      * If the image already exists in cache, the cache file is immediately returned, without any processing
  75.      * If the original (full resolution) image is required, create either a symbolic link with the
  76.      * original image in the cache dir, or copy it in the cache dir.
  77.      *
  78.      * This method updates the cache_file_path and file_url attributes of the event
  79.      *
  80.      * @param string $eventName
  81.      *
  82.      * @throws \Thelia\Exception\ImageException
  83.      * @throws \InvalidArgumentException
  84.      */
  85.     public function processImage(ImageEvent $event$eventNameEventDispatcherInterface $dispatcher): void
  86.     {
  87.         $subdir $event->getCacheSubdirectory();
  88.         $sourceFile $event->getSourceFilepath();
  89.         $imageExt pathinfo($sourceFile\PATHINFO_EXTENSION);
  90.         if (null == $subdir || null == $sourceFile) {
  91.             throw new \InvalidArgumentException('Cache sub-directory and source file path cannot be null');
  92.         }
  93.         // Find cached file path
  94.         $cacheFilePath $this->getCacheFilePath($subdir$sourceFile$event->isOriginalImage(), $event->getOptionsHash());
  95.         // Alternative image path is for browser that don't support webp
  96.         $alternativeImagePath null;
  97.         if ($event->getFormat()) {
  98.             $sourceExtension pathinfo($cacheFilePath\PATHINFO_EXTENSION);
  99.             if ($event->getFormat() === 'webp') {
  100.                 $alternativeImagePath $cacheFilePath;
  101.             }
  102.             $cacheFilePath str_replace($sourceExtension$event->getFormat(), $cacheFilePath);
  103.         }
  104.         $originalImagePathInCache $this->getCacheFilePath($subdir$sourceFiletrue);
  105.         if (!file_exists($cacheFilePath)) {
  106.             if (!file_exists($sourceFile)) {
  107.                 throw new ImageException(sprintf('Source image file %s does not exists.'$sourceFile));
  108.             }
  109.             // Create a cached version of the original image in the web space, if not exists
  110.             if (!file_exists($originalImagePathInCache)) {
  111.                 $mode ConfigQuery::read('original_image_delivery_mode''symlink');
  112.                 if ($mode == 'symlink') {
  113.                     if (false === symlink($sourceFile$originalImagePathInCache)) {
  114.                         throw new ImageException(sprintf('Failed to create symbolic link for %s in %s image cache directory'basename($sourceFile), $subdir));
  115.                     }
  116.                 } else {
  117.                     // mode = 'copy'
  118.                     if (false === @copy($sourceFile$originalImagePathInCache)) {
  119.                         throw new ImageException(sprintf('Failed to copy %s in %s image cache directory'basename($sourceFile), $subdir));
  120.                     }
  121.                 }
  122.             }
  123.             // Process image only if we have some transformations to do.
  124.             if (!$event->isOriginalImage()) {
  125.                 if ('svg' === $imageExt) {
  126.                     $dom = new \DOMDocument('1.0''utf-8');
  127.                     $dom->load($originalImagePathInCache);
  128.                     $svg $dom->documentElement;
  129.                     if (!$svg->hasAttribute('viewBox')) {
  130.                         $pattern '/^(\d*\.\d+|\d+)(px)?$/';
  131.                         $interpretable preg_match($pattern$svg->getAttribute('width'), $width) &&
  132.                             preg_match($pattern$svg->getAttribute('height'), $height);
  133.                         if (!$interpretable || !isset($width) || !isset($height)) {
  134.                             throw new \Exception("can't create viewBox if height and width is not defined in the svg file");
  135.                         }
  136.                         $viewBox implode(' ', [00$width[0], $height[0]]);
  137.                         $svg->setAttribute('viewBox'$viewBox);
  138.                     }
  139.                     $svg->setAttribute('width'$event->getWidth());
  140.                     $svg->setAttribute('height'$event->getWidth());
  141.                     $dom->save($cacheFilePath);
  142.                 } else {
  143.                     $this->applyTransformation($sourceFile$event$dispatcher$cacheFilePath);
  144.                     if ($alternativeImagePath) {
  145.                         $this->applyTransformation($sourceFile$event$dispatcher$alternativeImagePath);
  146.                     }
  147.                 }
  148.             }
  149.         }
  150.         // Compute the image URL
  151.         $processedImageUrl $this->getCacheFileURL($subdirbasename($cacheFilePath));
  152.         // compute the full resolution image path in cache
  153.         $originalImageUrl $this->getCacheFileURL($subdirbasename($originalImagePathInCache));
  154.         // Update the event with file path and file URL
  155.         $event->setCacheFilepath($cacheFilePath);
  156.         $event->setCacheOriginalFilepath($originalImagePathInCache);
  157.         $event->setFileUrl(URL::getInstance()->absoluteUrl($processedImageUrlnullURL::PATH_TO_FILE$this->cdnBaseUrl));
  158.         $event->setOriginalFileUrl(URL::getInstance()->absoluteUrl($originalImageUrlnullURL::PATH_TO_FILE$this->cdnBaseUrl));
  159.         $imagine $this->createImagineInstance();
  160.         $image $imagine->open($cacheFilePath);
  161.         $event->setImageObject($image);
  162.     }
  163.     private function applyTransformation(
  164.         $sourceFile,
  165.         $event,
  166.         $dispatcher,
  167.         $cacheFilePath
  168.     ): void {
  169.         $imagine $this->createImagineInstance();
  170.         $image $imagine->open($sourceFile);
  171.         if (!$image) {
  172.             throw new ImageException(sprintf('Source file %s cannot be opened.'basename($sourceFile)));
  173.         }
  174.         if (\function_exists('exif_read_data')) {
  175.             $exifdata = @exif_read_data($sourceFile);
  176.             if (isset($exifdata['Orientation'])) {
  177.                 $orientation $exifdata['Orientation'];
  178.                 $color = new RGB();
  179.                 switch ($orientation) {
  180.                     case 3:
  181.                         $image->rotate(180$color->color('#F00'));
  182.                         break;
  183.                     case 6:
  184.                         $image->rotate(90$color->color('#F00'));
  185.                         break;
  186.                     case 8:
  187.                         $image->rotate(-90$color->color('#F00'));
  188.                         break;
  189.                 }
  190.             }
  191.         }
  192.         // Allow image pre-processing (watermarging, or other stuff...)
  193.         $event->setImageObject($image);
  194.         $dispatcher->dispatch($eventTheliaEvents::IMAGE_PREPROCESSING);
  195.         $image $event->getImageObject();
  196.         $background_color $event->getBackgroundColor();
  197.         $palette = new RGB();
  198.         if ($background_color != null) {
  199.             $bg_color $palette->color($background_color);
  200.         } else {
  201.             // Define a fully transparent white background color
  202.             $bg_color $palette->color('fff'0);
  203.         }
  204.         // Apply resize
  205.         $image $this->applyResize(
  206.             $imagine,
  207.             $image,
  208.             $event->getWidth(),
  209.             $event->getHeight(),
  210.             $event->getResizeMode(),
  211.             $bg_color,
  212.             $event->getAllowZoom()
  213.         );
  214.         // Rotate if required
  215.         $rotation = (int) $event->getRotation();
  216.         if ($rotation != 0) {
  217.             $image->rotate($rotation$bg_color);
  218.         }
  219.         // Flip
  220.         // Process each effects
  221.         foreach ($event->getEffects() as $effect) {
  222.             $effect trim(strtolower($effect));
  223.             $params explode(':'$effect);
  224.             switch ($params[0]) {
  225.                 case 'greyscale':
  226.                 case 'grayscale':
  227.                     $image->effects()->grayscale();
  228.                     break;
  229.                 case 'negative':
  230.                     $image->effects()->negative();
  231.                     break;
  232.                 case 'horizontal_flip':
  233.                 case 'hflip':
  234.                     $image->flipHorizontally();
  235.                     break;
  236.                 case 'vertical_flip':
  237.                 case 'vflip':
  238.                     $image->flipVertically();
  239.                     break;
  240.                 case 'gamma':
  241.                     // Syntax: gamma:value. Exemple: gamma:0.7
  242.                     if (isset($params[1])) {
  243.                         $gamma = (float) $params[1];
  244.                         $image->effects()->gamma($gamma);
  245.                     }
  246.                     break;
  247.                 case 'colorize':
  248.                     // Syntax: colorize:couleur. Exemple: colorize:#ff00cc
  249.                     if (isset($params[1])) {
  250.                         $the_color $palette->color($params[1]);
  251.                         $image->effects()->colorize($the_color);
  252.                     }
  253.                     break;
  254.                 case 'blur':
  255.                     if (isset($params[1])) {
  256.                         $blur_level = (int) $params[1];
  257.                         $image->effects()->blur($blur_level);
  258.                     }
  259.                     break;
  260.             }
  261.         }
  262.         $quality $event->getQuality();
  263.         if (null === $quality) {
  264.             $quality ConfigQuery::read('default_images_quality_percent'75);
  265.         }
  266.         // Allow image post-processing (watermarging, or other stuff...)
  267.         $event->setImageObject($image);
  268.         $dispatcher->dispatch($eventTheliaEvents::IMAGE_POSTPROCESSING);
  269.         $image $event->getImageObject();
  270.         $image->save(
  271.             $cacheFilePath,
  272.             ['quality' => $quality'animated' => true]
  273.         );
  274.     }
  275.     /**
  276.      * Process image resizing, with borders or cropping. If $dest_width and $dest_height
  277.      * are both null, no resize is performed.
  278.      *
  279.      * @param ImagineInterface $imagine     the Imagine instance
  280.      * @param ImageInterface   $image       the image to process
  281.      * @param int              $dest_width  the required width
  282.      * @param int              $dest_height the required height
  283.      * @param int              $resize_mode the resize mode (crop / bands / keep image ratio)p
  284.      * @param string           $bg_color    the bg_color used for bands
  285.      * @param bool             $allow_zoom  if true, image may be zoomed to matchrequired size. If false, image is not zoomed.
  286.      *
  287.      * @return ImageInterface the resized image
  288.      */
  289.     protected function applyResize(
  290.         ImagineInterface $imagine,
  291.         ImageInterface $image,
  292.         $dest_width,
  293.         $dest_height,
  294.         $resize_mode,
  295.         $bg_color,
  296.         $allow_zoom false
  297.     ) {
  298.         if (!(null === $dest_width && null === $dest_height)) {
  299.             $width_orig $image->getSize()->getWidth();
  300.             $height_orig $image->getSize()->getHeight();
  301.             $ratio $width_orig $height_orig;
  302.             if (null === $dest_width) {
  303.                 $dest_width $dest_height $ratio;
  304.             }
  305.             if (null === $dest_height) {
  306.                 $dest_height $dest_width $ratio;
  307.             }
  308.             if (null === $resize_mode) {
  309.                 $resize_mode self::KEEP_IMAGE_RATIO;
  310.             }
  311.             $width_diff $dest_width $width_orig;
  312.             $height_diff $dest_height $height_orig;
  313.             $delta_x $delta_y $border_width $border_height 0;
  314.             if ($width_diff && $height_diff 1) {
  315.                 // Set the default final size. If zoom is allowed, we will get the required
  316.                 // image dimension. Otherwise, the final image may be smaller than required.
  317.                 if ($allow_zoom) {
  318.                     $resize_width $dest_width;
  319.                     $resize_height $dest_height;
  320.                 } else {
  321.                     $resize_width $width_orig;
  322.                     $resize_height $height_orig;
  323.                 }
  324.                 // When cropping, be sure to always generate an image which is
  325.                 // not smaller than the required size, zooming it if required.
  326.                 if ($resize_mode == self::EXACT_RATIO_WITH_CROP) {
  327.                     if ($allow_zoom) {
  328.                         if ($width_diff $height_diff) {
  329.                             $resize_width $dest_width;
  330.                             $resize_height = (int) ($height_orig $dest_width $width_orig);
  331.                             $delta_y = ($resize_height $dest_height) / 2;
  332.                         } else {
  333.                             $resize_height $dest_height;
  334.                             $resize_width = (int) (($width_orig $resize_height) / $height_orig);
  335.                             $delta_x = ($resize_width $dest_width) / 2;
  336.                         }
  337.                     } else {
  338.                         // No zoom : final image may be smaller than the required size.
  339.                         $dest_width $resize_width;
  340.                         $dest_height $resize_height;
  341.                     }
  342.                 }
  343.             } elseif ($width_diff $height_diff) {
  344.                 // Image height > image width
  345.                 $resize_height $dest_height;
  346.                 $resize_width = (int) (($width_orig $resize_height) / $height_orig);
  347.                 if ($resize_mode == self::EXACT_RATIO_WITH_CROP) {
  348.                     $resize_width $dest_width;
  349.                     $resize_height = (int) ($height_orig $dest_width $width_orig);
  350.                     $delta_y = ($resize_height $dest_height) / 2;
  351.                 } elseif ($resize_mode != self::EXACT_RATIO_WITH_BORDERS) {
  352.                     $dest_width $resize_width;
  353.                 }
  354.             } else {
  355.                 // Image width > image height
  356.                 $resize_width $dest_width;
  357.                 $resize_height = (int) ($height_orig $dest_width $width_orig);
  358.                 if ($resize_mode == self::EXACT_RATIO_WITH_CROP) {
  359.                     $resize_height $dest_height;
  360.                     $resize_width = (int) (($width_orig $resize_height) / $height_orig);
  361.                     $delta_x = ($resize_width $dest_width) / 2;
  362.                 } elseif ($resize_mode != self::EXACT_RATIO_WITH_BORDERS) {
  363.                     $dest_height $resize_height;
  364.                 }
  365.             }
  366.             $image->resize(new Box($resize_width$resize_height));
  367.             $resizeFilter 'imagick' === ConfigQuery::read('imagine_graphic_driver''gd')
  368.                 ? ImageInterface::FILTER_LANCZOS
  369.                 ImageInterface::FILTER_UNDEFINED;
  370.             $image->resize(new Box($resize_width$resize_height), $resizeFilter);
  371.             if ($resize_mode == self::EXACT_RATIO_WITH_BORDERS) {
  372.                 $border_width = (int) (($dest_width $resize_width) / 2);
  373.                 $border_height = (int) (($dest_height $resize_height) / 2);
  374.                 $canvas = new Box($dest_width$dest_height);
  375.                 $layersCount \count($image->layers());
  376.                 if ('imagick' === ConfigQuery::read('imagine_graphic_driver''gd') && $layersCount 1) {
  377.                     // If image has layers we apply transformation to all layers since paste method would flatten the image
  378.                     $newImage $imagine->create($canvas$bg_color);
  379.                     $resizedLayers $newImage->layers();
  380.                     $resizedLayers->remove(0);
  381.                     for ($i 0$i $layersCount; ++$i) {
  382.                         $newImage2 $imagine->create($canvas$bg_color);
  383.                         $resizedLayers[] = $newImage2->paste($image->layers()->get($i)->resize(new Box($resize_width$resize_height), $resizeFilter), new Point($border_width$border_height));
  384.                     }
  385.                     return $newImage;
  386.                 }
  387.                 return $imagine->create($canvas$bg_color)
  388.                         ->paste($image, new Point($border_width$border_height));
  389.             }
  390.             if ($resize_mode == self::EXACT_RATIO_WITH_CROP) {
  391.                 $image->crop(
  392.                     new Point($delta_x$delta_y),
  393.                     new Box($dest_width$dest_height)
  394.                 );
  395.             }
  396.         }
  397.         return $image;
  398.     }
  399.     /**
  400.      * Create a new Imagine object using current driver configuration.
  401.      *
  402.      * @return ImagineInterface
  403.      */
  404.     protected function createImagineInstance()
  405.     {
  406.         $driver ConfigQuery::read('imagine_graphic_driver''gd');
  407.         switch ($driver) {
  408.             case 'imagick':
  409.                 $image = new ImagickImagine();
  410.                 break;
  411.             case 'gmagick':
  412.                 $image = new GmagickImagine();
  413.                 break;
  414.             case 'gd':
  415.             default:
  416.                 $image = new Imagine();
  417.         }
  418.         return $image;
  419.     }
  420.     /**
  421.      * {@inheritdoc}
  422.      */
  423.     public static function getSubscribedEvents()
  424.     {
  425.         return [
  426.             TheliaEvents::IMAGE_PROCESS => ['processImage'128],
  427.             // Implemented in parent class BaseCachedFile
  428.             TheliaEvents::IMAGE_CLEAR_CACHE => ['clearCache'128],
  429.             TheliaEvents::IMAGE_DELETE => ['deleteFile'128],
  430.             TheliaEvents::IMAGE_SAVE => ['saveFile'128],
  431.             TheliaEvents::IMAGE_UPDATE => ['updateFile'128],
  432.             TheliaEvents::IMAGE_UPDATE_POSITION => ['updatePosition'128],
  433.             TheliaEvents::IMAGE_TOGGLE_VISIBILITY => ['toggleVisibility'128],
  434.         ];
  435.     }
  436. }