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

Open in your IDE?
  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.         if (null == $subdir || null == $sourceFile) {
  90.             throw new \InvalidArgumentException('Cache sub-directory and source file path cannot be null');
  91.         }
  92.         // Find cached file path
  93.         $cacheFilePath $this->getCacheFilePath($subdir$sourceFile$event->isOriginalImage(), $event->getOptionsHash());
  94.         //Alternative image path is for browser that don't support webp
  95.         $alternativeImagePath null;
  96.         if ($event->getFormat()) {
  97.             $sourceExtension pathinfo($cacheFilePath\PATHINFO_EXTENSION);
  98.             if ($event->getFormat() === 'webp') {
  99.                 $alternativeImagePath $cacheFilePath;
  100.             }
  101.             $cacheFilePath str_replace($sourceExtension$event->getFormat(), $cacheFilePath);
  102.         }
  103.         $originalImagePathInCache $this->getCacheFilePath($subdir$sourceFiletrue);
  104.         if (!file_exists($cacheFilePath)) {
  105.             if (!file_exists($sourceFile)) {
  106.                 throw new ImageException(sprintf('Source image file %s does not exists.'$sourceFile));
  107.             }
  108.             // Create a cached version of the original image in the web space, if not exists
  109.             if (!file_exists($originalImagePathInCache)) {
  110.                 $mode ConfigQuery::read('original_image_delivery_mode''symlink');
  111.                 if ($mode == 'symlink') {
  112.                     if (false === symlink($sourceFile$originalImagePathInCache)) {
  113.                         throw new ImageException(sprintf('Failed to create symbolic link for %s in %s image cache directory'basename($sourceFile), $subdir));
  114.                     }
  115.                 } else {
  116.                     // mode = 'copy'
  117.                     if (false === @copy($sourceFile$originalImagePathInCache)) {
  118.                         throw new ImageException(sprintf('Failed to copy %s in %s image cache directory'basename($sourceFile), $subdir));
  119.                     }
  120.                 }
  121.             }
  122.             // Process image only if we have some transformations to do.
  123.             if (!$event->isOriginalImage()) {
  124.                 $this->applyTransformation($sourceFile$event$dispatcher$cacheFilePath);
  125.                 if ($alternativeImagePath) {
  126.                     $this->applyTransformation($sourceFile$event$dispatcher$alternativeImagePath);
  127.                 }
  128.             }
  129.         }
  130.         // Compute the image URL
  131.         $processedImageUrl $this->getCacheFileURL($subdirbasename($cacheFilePath));
  132.         // compute the full resolution image path in cache
  133.         $originalImageUrl $this->getCacheFileURL($subdirbasename($originalImagePathInCache));
  134.         // Update the event with file path and file URL
  135.         $event->setCacheFilepath($cacheFilePath);
  136.         $event->setCacheOriginalFilepath($originalImagePathInCache);
  137.         $event->setFileUrl(URL::getInstance()->absoluteUrl($processedImageUrlnullURL::PATH_TO_FILE$this->cdnBaseUrl));
  138.         $event->setOriginalFileUrl(URL::getInstance()->absoluteUrl($originalImageUrlnullURL::PATH_TO_FILE$this->cdnBaseUrl));
  139.         $imagine $this->createImagineInstance();
  140.         $image $imagine->open($cacheFilePath);
  141.         $event->setImageObject($image);
  142.     }
  143.     private function applyTransformation(
  144.         $sourceFile,
  145.         $event,
  146.         $dispatcher,
  147.         $cacheFilePath
  148.     ): void {
  149.         $imagine $this->createImagineInstance();
  150.         $image $imagine->open($sourceFile);
  151.         if (!$image) {
  152.             throw new ImageException(sprintf('Source file %s cannot be opened.'basename($sourceFile)));
  153.         }
  154.         if (\function_exists('exif_read_data')) {
  155.             $exifdata = @exif_read_data($sourceFile);
  156.             if (isset($exifdata['Orientation'])) {
  157.                 $orientation $exifdata['Orientation'];
  158.                 $color = new RGB();
  159.                 switch ($orientation) {
  160.                     case 3:
  161.                         $image->rotate(180$color->color('#F00'));
  162.                         break;
  163.                     case 6:
  164.                         $image->rotate(90$color->color('#F00'));
  165.                         break;
  166.                     case 8:
  167.                         $image->rotate(-90$color->color('#F00'));
  168.                         break;
  169.                 }
  170.             }
  171.         }
  172.         // Allow image pre-processing (watermarging, or other stuff...)
  173.         $event->setImageObject($image);
  174.         $dispatcher->dispatch($eventTheliaEvents::IMAGE_PREPROCESSING);
  175.         $image $event->getImageObject();
  176.         $background_color $event->getBackgroundColor();
  177.         $palette = new RGB();
  178.         if ($background_color != null) {
  179.             $bg_color $palette->color($background_color);
  180.         } else {
  181.             // Define a fully transparent white background color
  182.             $bg_color $palette->color('fff'0);
  183.         }
  184.         // Apply resize
  185.         $image $this->applyResize(
  186.             $imagine,
  187.             $image,
  188.             $event->getWidth(),
  189.             $event->getHeight(),
  190.             $event->getResizeMode(),
  191.             $bg_color,
  192.             $event->getAllowZoom()
  193.         );
  194.         // Rotate if required
  195.         $rotation = (int) ($event->getRotation());
  196.         if ($rotation != 0) {
  197.             $image->rotate($rotation$bg_color);
  198.         }
  199.         // Flip
  200.         // Process each effects
  201.         foreach ($event->getEffects() as $effect) {
  202.             $effect trim(strtolower($effect));
  203.             $params explode(':'$effect);
  204.             switch ($params[0]) {
  205.                 case 'greyscale':
  206.                 case 'grayscale':
  207.                     $image->effects()->grayscale();
  208.                     break;
  209.                 case 'negative':
  210.                     $image->effects()->negative();
  211.                     break;
  212.                 case 'horizontal_flip':
  213.                 case 'hflip':
  214.                     $image->flipHorizontally();
  215.                     break;
  216.                 case 'vertical_flip':
  217.                 case 'vflip':
  218.                     $image->flipVertically();
  219.                     break;
  220.                 case 'gamma':
  221.                     // Syntax: gamma:value. Exemple: gamma:0.7
  222.                     if (isset($params[1])) {
  223.                         $gamma = (float) ($params[1]);
  224.                         $image->effects()->gamma($gamma);
  225.                     }
  226.                     break;
  227.                 case 'colorize':
  228.                     // Syntax: colorize:couleur. Exemple: colorize:#ff00cc
  229.                     if (isset($params[1])) {
  230.                         $the_color $palette->color($params[1]);
  231.                         $image->effects()->colorize($the_color);
  232.                     }
  233.                     break;
  234.                 case 'blur':
  235.                     if (isset($params[1])) {
  236.                         $blur_level = (int) ($params[1]);
  237.                         $image->effects()->blur($blur_level);
  238.                     }
  239.                     break;
  240.             }
  241.         }
  242.         $quality $event->getQuality();
  243.         if (null === $quality) {
  244.             $quality ConfigQuery::read('default_images_quality_percent'75);
  245.         }
  246.         // Allow image post-processing (watermarging, or other stuff...)
  247.         $event->setImageObject($image);
  248.         $dispatcher->dispatch($eventTheliaEvents::IMAGE_POSTPROCESSING);
  249.         $image $event->getImageObject();
  250.         $image->save(
  251.             $cacheFilePath,
  252.             ['quality' => $quality'animated' => true]
  253.         );
  254.     }
  255.     /**
  256.      * Process image resizing, with borders or cropping. If $dest_width and $dest_height
  257.      * are both null, no resize is performed.
  258.      *
  259.      * @param ImagineInterface $imagine     the Imagine instance
  260.      * @param ImageInterface   $image       the image to process
  261.      * @param int              $dest_width  the required width
  262.      * @param int              $dest_height the required height
  263.      * @param int              $resize_mode the resize mode (crop / bands / keep image ratio)p
  264.      * @param string           $bg_color    the bg_color used for bands
  265.      * @param bool             $allow_zoom  if true, image may be zoomed to matchrequired size. If false, image is not zoomed.
  266.      *
  267.      * @return ImageInterface the resized image
  268.      */
  269.     protected function applyResize(
  270.         ImagineInterface $imagine,
  271.         ImageInterface $image,
  272.         $dest_width,
  273.         $dest_height,
  274.         $resize_mode,
  275.         $bg_color,
  276.         $allow_zoom false
  277.     ) {
  278.         if (!(null === $dest_width && null === $dest_height)) {
  279.             $width_orig $image->getSize()->getWidth();
  280.             $height_orig $image->getSize()->getHeight();
  281.             $ratio $width_orig $height_orig;
  282.             if (null === $dest_width) {
  283.                 $dest_width $dest_height $ratio;
  284.             }
  285.             if (null === $dest_height) {
  286.                 $dest_height $dest_width $ratio;
  287.             }
  288.             if (null === $resize_mode) {
  289.                 $resize_mode self::KEEP_IMAGE_RATIO;
  290.             }
  291.             $width_diff $dest_width $width_orig;
  292.             $height_diff $dest_height $height_orig;
  293.             $delta_x $delta_y $border_width $border_height 0;
  294.             if ($width_diff && $height_diff 1) {
  295.                 // Set the default final size. If zoom is allowed, we will get the required
  296.                 // image dimension. Otherwise, the final image may be smaller than required.
  297.                 if ($allow_zoom) {
  298.                     $resize_width $dest_width;
  299.                     $resize_height $dest_height;
  300.                 } else {
  301.                     $resize_width $width_orig;
  302.                     $resize_height $height_orig;
  303.                 }
  304.                 // When cropping, be sure to always generate an image which is
  305.                 // not smaller than the required size, zooming it if required.
  306.                 if ($resize_mode == self::EXACT_RATIO_WITH_CROP) {
  307.                     if ($allow_zoom) {
  308.                         if ($width_diff $height_diff) {
  309.                             $resize_width $dest_width;
  310.                             $resize_height = (int) ($height_orig $dest_width $width_orig);
  311.                             $delta_y = ($resize_height $dest_height) / 2;
  312.                         } else {
  313.                             $resize_height $dest_height;
  314.                             $resize_width = (int) (($width_orig $resize_height) / $height_orig);
  315.                             $delta_x = ($resize_width $dest_width) / 2;
  316.                         }
  317.                     } else {
  318.                         // No zoom : final image may be smaller than the required size.
  319.                         $dest_width $resize_width;
  320.                         $dest_height $resize_height;
  321.                     }
  322.                 }
  323.             } elseif ($width_diff $height_diff) {
  324.                 // Image height > image width
  325.                 $resize_height $dest_height;
  326.                 $resize_width = (int) (($width_orig $resize_height) / $height_orig);
  327.                 if ($resize_mode == self::EXACT_RATIO_WITH_CROP) {
  328.                     $resize_width $dest_width;
  329.                     $resize_height = (int) ($height_orig $dest_width $width_orig);
  330.                     $delta_y = ($resize_height $dest_height) / 2;
  331.                 } elseif ($resize_mode != self::EXACT_RATIO_WITH_BORDERS) {
  332.                     $dest_width $resize_width;
  333.                 }
  334.             } else {
  335.                 // Image width > image height
  336.                 $resize_width $dest_width;
  337.                 $resize_height = (int) ($height_orig $dest_width $width_orig);
  338.                 if ($resize_mode == self::EXACT_RATIO_WITH_CROP) {
  339.                     $resize_height $dest_height;
  340.                     $resize_width = (int) (($width_orig $resize_height) / $height_orig);
  341.                     $delta_x = ($resize_width $dest_width) / 2;
  342.                 } elseif ($resize_mode != self::EXACT_RATIO_WITH_BORDERS) {
  343.                     $dest_height $resize_height;
  344.                 }
  345.             }
  346.             $image->resize(new Box($resize_width$resize_height));
  347.             $resizeFilter 'imagick' === ConfigQuery::read('imagine_graphic_driver''gd')
  348.                 ? ImageInterface::FILTER_LANCZOS
  349.                 ImageInterface::FILTER_UNDEFINED;
  350.             $image->resize(new Box($resize_width$resize_height), $resizeFilter);
  351.             if ($resize_mode == self::EXACT_RATIO_WITH_BORDERS) {
  352.                 $border_width = (int) (($dest_width $resize_width) / 2);
  353.                 $border_height = (int) (($dest_height $resize_height) / 2);
  354.                 $canvas = new Box($dest_width$dest_height);
  355.                 return $imagine->create($canvas$bg_color)
  356.                     ->paste($image, new Point($border_width$border_height));
  357.             }
  358.             if ($resize_mode == self::EXACT_RATIO_WITH_CROP) {
  359.                 $image->crop(
  360.                     new Point($delta_x$delta_y),
  361.                     new Box($dest_width$dest_height)
  362.                 );
  363.             }
  364.         }
  365.         return $image;
  366.     }
  367.     /**
  368.      * Create a new Imagine object using current driver configuration.
  369.      *
  370.      * @return ImagineInterface
  371.      */
  372.     protected function createImagineInstance()
  373.     {
  374.         $driver ConfigQuery::read('imagine_graphic_driver''gd');
  375.         switch ($driver) {
  376.             case 'imagick':
  377.                 $image = new ImagickImagine();
  378.                 break;
  379.             case 'gmagick':
  380.                 $image = new GmagickImagine();
  381.                 break;
  382.             case 'gd':
  383.             default:
  384.                 $image = new Imagine();
  385.         }
  386.         return $image;
  387.     }
  388.     /**
  389.      * {@inheritdoc}
  390.      */
  391.     public static function getSubscribedEvents()
  392.     {
  393.         return [
  394.             TheliaEvents::IMAGE_PROCESS => ['processImage'128],
  395.             // Implemented in parent class BaseCachedFile
  396.             TheliaEvents::IMAGE_CLEAR_CACHE => ['clearCache'128],
  397.             TheliaEvents::IMAGE_DELETE => ['deleteFile'128],
  398.             TheliaEvents::IMAGE_SAVE => ['saveFile'128],
  399.             TheliaEvents::IMAGE_UPDATE => ['updateFile'128],
  400.             TheliaEvents::IMAGE_UPDATE_POSITION => ['updatePosition'128],
  401.             TheliaEvents::IMAGE_TOGGLE_VISIBILITY => ['toggleVisibility'128],
  402.         ];
  403.     }
  404. }