core/lib/Thelia/Action/Image.php line 91
<?php/** This file is part of the Thelia package.* http://www.thelia.net** (c) OpenStudio <info@thelia.net>** For the full copyright and license information, please view the LICENSE* file that was distributed with this source code.*/namespace Thelia\Action;use Imagine\Gd\Imagine;use Imagine\Gmagick\Imagine as GmagickImagine;use Imagine\Image\Box;use Imagine\Image\ImageInterface;use Imagine\Image\ImagineInterface;use Imagine\Image\Palette\RGB;use Imagine\Image\Point;use Imagine\Imagick\Imagine as ImagickImagine;use Symfony\Component\EventDispatcher\EventDispatcherInterface;use Symfony\Component\EventDispatcher\EventSubscriberInterface;use Thelia\Core\Event\Image\ImageEvent;use Thelia\Core\Event\TheliaEvents;use Thelia\Exception\ImageException;use Thelia\Model\ConfigQuery;use Thelia\Tools\URL;/*** Image management actions. This class handles image processing and caching.** Basically, images are stored outside of the web space (by default in local/media/images),* and cached inside the web space (by default in web/local/images).** In the images caches directory, a subdirectory for images categories (eg. product, category, folder, etc.) is* automatically created, and the cached image is created here. Plugin may use their own subdirectory as required.** The cached image name contains a hash of the processing options, and the original (normalized) name of the image.** A copy (or symbolic link, by default) of the original image is always created in the cache, so that the full* resolution image is always available.** Various image processing options are available :** - resizing, with border, crop, or by keeping image aspect ratio* - rotation, in degrees, positive or negative* - background color, applyed to empty background when creating borders or rotating* - effects. The effects are applied in the specified order. The following effects are available:* - gamma:value : change the image Gamma to the specified value. Example: gamma:0.7* - grayscale or greyscale: switch image to grayscale* - colorize:color : apply a color mask to the image. Exemple: colorize:#ff2244* - negative : transform the image in its negative equivalent* - vflip or vertical_flip : vertical flip* - hflip or horizontal_flip : horizontal flip** If a problem occurs, an ImageException may be thrown.** @author Franck Allimant <franck@cqfdev.fr>*/class Image extends BaseCachedFile implements EventSubscriberInterface{// Resize mode constantspublic const EXACT_RATIO_WITH_BORDERS = 1;public const EXACT_RATIO_WITH_CROP = 2;public const KEEP_IMAGE_RATIO = 3;/*** @return string root of the image cache directory in web space*/protected function getCacheDirFromWebRoot(){return ConfigQuery::read('image_cache_dir_from_web_root', 'cache'.DS.'images');}/*** Process image and write the result in the image cache.** If the image already exists in cache, the cache file is immediately returned, without any processing* If the original (full resolution) image is required, create either a symbolic link with the* original image in the cache dir, or copy it in the cache dir.** This method updates the cache_file_path and file_url attributes of the event** @param string $eventName** @throws \Thelia\Exception\ImageException* @throws \InvalidArgumentException*/public function processImage(ImageEvent $event, $eventName, EventDispatcherInterface $dispatcher): void{$subdir = $event->getCacheSubdirectory();$sourceFile = $event->getSourceFilepath();$imageExt = pathinfo($sourceFile, \PATHINFO_EXTENSION);if (null == $subdir || null == $sourceFile) {throw new \InvalidArgumentException('Cache sub-directory and source file path cannot be null');}// Find cached file path$cacheFilePath = $this->getCacheFilePath($subdir, $sourceFile, $event->isOriginalImage(), $event->getOptionsHash());// Alternative image path is for browser that don't support webp$alternativeImagePath = null;if ($event->getFormat()) {$sourceExtension = pathinfo($cacheFilePath, \PATHINFO_EXTENSION);if ($event->getFormat() === 'webp') {$alternativeImagePath = $cacheFilePath;}$cacheFilePath = str_replace($sourceExtension, $event->getFormat(), $cacheFilePath);}$originalImagePathInCache = $this->getCacheFilePath($subdir, $sourceFile, true);if (!file_exists($cacheFilePath)) {if (!file_exists($sourceFile)) {throw new ImageException(sprintf('Source image file %s does not exists.', $sourceFile));}// Create a cached version of the original image in the web space, if not existsif (!file_exists($originalImagePathInCache)) {$mode = ConfigQuery::read('original_image_delivery_mode', 'symlink');if ($mode == 'symlink') {if (false === symlink($sourceFile, $originalImagePathInCache)) {throw new ImageException(sprintf('Failed to create symbolic link for %s in %s image cache directory', basename($sourceFile), $subdir));}} else {// mode = 'copy'if (false === @copy($sourceFile, $originalImagePathInCache)) {throw new ImageException(sprintf('Failed to copy %s in %s image cache directory', basename($sourceFile), $subdir));}}}// Process image only if we have some transformations to do.if (!$event->isOriginalImage()) {if ('svg' === $imageExt) {$dom = new \DOMDocument('1.0', 'utf-8');$dom->load($originalImagePathInCache);$svg = $dom->documentElement;if (!$svg->hasAttribute('viewBox')) {$pattern = '/^(\d*\.\d+|\d+)(px)?$/';$interpretable = preg_match($pattern, $svg->getAttribute('width'), $width) &&preg_match($pattern, $svg->getAttribute('height'), $height);if (!$interpretable || !isset($width) || !isset($height)) {throw new \Exception("can't create viewBox if height and width is not defined in the svg file");}$viewBox = implode(' ', [0, 0, $width[0], $height[0]]);$svg->setAttribute('viewBox', $viewBox);}$svg->setAttribute('width', $event->getWidth());$svg->setAttribute('height', $event->getWidth());$dom->save($cacheFilePath);} else {$this->applyTransformation($sourceFile, $event, $dispatcher, $cacheFilePath);if ($alternativeImagePath) {$this->applyTransformation($sourceFile, $event, $dispatcher, $alternativeImagePath);}}}}// Compute the image URL$processedImageUrl = $this->getCacheFileURL($subdir, basename($cacheFilePath));// compute the full resolution image path in cache$originalImageUrl = $this->getCacheFileURL($subdir, basename($originalImagePathInCache));// Update the event with file path and file URL$event->setCacheFilepath($cacheFilePath);$event->setCacheOriginalFilepath($originalImagePathInCache);$event->setFileUrl(URL::getInstance()->absoluteUrl($processedImageUrl, null, URL::PATH_TO_FILE, $this->cdnBaseUrl));$event->setOriginalFileUrl(URL::getInstance()->absoluteUrl($originalImageUrl, null, URL::PATH_TO_FILE, $this->cdnBaseUrl));$imagine = $this->createImagineInstance();$image = $imagine->open($cacheFilePath);$event->setImageObject($image);}private function applyTransformation($sourceFile,$event,$dispatcher,$cacheFilePath): void {$imagine = $this->createImagineInstance();$image = $imagine->open($sourceFile);if (!$image) {throw new ImageException(sprintf('Source file %s cannot be opened.', basename($sourceFile)));}if (\function_exists('exif_read_data')) {$exifdata = @exif_read_data($sourceFile);if (isset($exifdata['Orientation'])) {$orientation = $exifdata['Orientation'];$color = new RGB();switch ($orientation) {case 3:$image->rotate(180, $color->color('#F00'));break;case 6:$image->rotate(90, $color->color('#F00'));break;case 8:$image->rotate(-90, $color->color('#F00'));break;}}}// Allow image pre-processing (watermarging, or other stuff...)$event->setImageObject($image);$dispatcher->dispatch($event, TheliaEvents::IMAGE_PREPROCESSING);$image = $event->getImageObject();$background_color = $event->getBackgroundColor();$palette = new RGB();if ($background_color != null) {$bg_color = $palette->color($background_color);} else {// Define a fully transparent white background color$bg_color = $palette->color('fff', 0);}// Apply resize$image = $this->applyResize($imagine,$image,$event->getWidth(),$event->getHeight(),$event->getResizeMode(),$bg_color,$event->getAllowZoom());// Rotate if required$rotation = (int) $event->getRotation();if ($rotation != 0) {$image->rotate($rotation, $bg_color);}// Flip// Process each effectsforeach ($event->getEffects() as $effect) {$effect = trim(strtolower($effect));$params = explode(':', $effect);switch ($params[0]) {case 'greyscale':case 'grayscale':$image->effects()->grayscale();break;case 'negative':$image->effects()->negative();break;case 'horizontal_flip':case 'hflip':$image->flipHorizontally();break;case 'vertical_flip':case 'vflip':$image->flipVertically();break;case 'gamma':// Syntax: gamma:value. Exemple: gamma:0.7if (isset($params[1])) {$gamma = (float) $params[1];$image->effects()->gamma($gamma);}break;case 'colorize':// Syntax: colorize:couleur. Exemple: colorize:#ff00ccif (isset($params[1])) {$the_color = $palette->color($params[1]);$image->effects()->colorize($the_color);}break;case 'blur':if (isset($params[1])) {$blur_level = (int) $params[1];$image->effects()->blur($blur_level);}break;}}$quality = $event->getQuality();if (null === $quality) {$quality = ConfigQuery::read('default_images_quality_percent', 75);}// Allow image post-processing (watermarging, or other stuff...)$event->setImageObject($image);$dispatcher->dispatch($event, TheliaEvents::IMAGE_POSTPROCESSING);$image = $event->getImageObject();$image->save($cacheFilePath,['quality' => $quality, 'animated' => true]);}/*** Process image resizing, with borders or cropping. If $dest_width and $dest_height* are both null, no resize is performed.** @param ImagineInterface $imagine the Imagine instance* @param ImageInterface $image the image to process* @param int $dest_width the required width* @param int $dest_height the required height* @param int $resize_mode the resize mode (crop / bands / keep image ratio)p* @param string $bg_color the bg_color used for bands* @param bool $allow_zoom if true, image may be zoomed to matchrequired size. If false, image is not zoomed.** @return ImageInterface the resized image*/protected function applyResize(ImagineInterface $imagine,ImageInterface $image,$dest_width,$dest_height,$resize_mode,$bg_color,$allow_zoom = false) {if (!(null === $dest_width && null === $dest_height)) {$width_orig = $image->getSize()->getWidth();$height_orig = $image->getSize()->getHeight();$ratio = $width_orig / $height_orig;if (null === $dest_width) {$dest_width = $dest_height * $ratio;}if (null === $dest_height) {$dest_height = $dest_width / $ratio;}if (null === $resize_mode) {$resize_mode = self::KEEP_IMAGE_RATIO;}$width_diff = $dest_width / $width_orig;$height_diff = $dest_height / $height_orig;$delta_x = $delta_y = $border_width = $border_height = 0;if ($width_diff > 1 && $height_diff > 1) {// Set the default final size. If zoom is allowed, we will get the required// image dimension. Otherwise, the final image may be smaller than required.if ($allow_zoom) {$resize_width = $dest_width;$resize_height = $dest_height;} else {$resize_width = $width_orig;$resize_height = $height_orig;}// When cropping, be sure to always generate an image which is// not smaller than the required size, zooming it if required.if ($resize_mode == self::EXACT_RATIO_WITH_CROP) {if ($allow_zoom) {if ($width_diff > $height_diff) {$resize_width = $dest_width;$resize_height = (int) ($height_orig * $dest_width / $width_orig);$delta_y = ($resize_height - $dest_height) / 2;} else {$resize_height = $dest_height;$resize_width = (int) (($width_orig * $resize_height) / $height_orig);$delta_x = ($resize_width - $dest_width) / 2;}} else {// No zoom : final image may be smaller than the required size.$dest_width = $resize_width;$dest_height = $resize_height;}}} elseif ($width_diff > $height_diff) {// Image height > image width$resize_height = $dest_height;$resize_width = (int) (($width_orig * $resize_height) / $height_orig);if ($resize_mode == self::EXACT_RATIO_WITH_CROP) {$resize_width = $dest_width;$resize_height = (int) ($height_orig * $dest_width / $width_orig);$delta_y = ($resize_height - $dest_height) / 2;} elseif ($resize_mode != self::EXACT_RATIO_WITH_BORDERS) {$dest_width = $resize_width;}} else {// Image width > image height$resize_width = $dest_width;$resize_height = (int) ($height_orig * $dest_width / $width_orig);if ($resize_mode == self::EXACT_RATIO_WITH_CROP) {$resize_height = $dest_height;$resize_width = (int) (($width_orig * $resize_height) / $height_orig);$delta_x = ($resize_width - $dest_width) / 2;} elseif ($resize_mode != self::EXACT_RATIO_WITH_BORDERS) {$dest_height = $resize_height;}}$image->resize(new Box($resize_width, $resize_height));$resizeFilter = 'imagick' === ConfigQuery::read('imagine_graphic_driver', 'gd')? ImageInterface::FILTER_LANCZOS: ImageInterface::FILTER_UNDEFINED;$image->resize(new Box($resize_width, $resize_height), $resizeFilter);if ($resize_mode == self::EXACT_RATIO_WITH_BORDERS) {$border_width = (int) (($dest_width - $resize_width) / 2);$border_height = (int) (($dest_height - $resize_height) / 2);$canvas = new Box($dest_width, $dest_height);$layersCount = \count($image->layers());if ('imagick' === ConfigQuery::read('imagine_graphic_driver', 'gd') && $layersCount > 1) {// If image has layers we apply transformation to all layers since paste method would flatten the image$newImage = $imagine->create($canvas, $bg_color);$resizedLayers = $newImage->layers();$resizedLayers->remove(0);for ($i = 0; $i < $layersCount; ++$i) {$newImage2 = $imagine->create($canvas, $bg_color);$resizedLayers[] = $newImage2->paste($image->layers()->get($i)->resize(new Box($resize_width, $resize_height), $resizeFilter), new Point($border_width, $border_height));}return $newImage;}return $imagine->create($canvas, $bg_color)->paste($image, new Point($border_width, $border_height));}if ($resize_mode == self::EXACT_RATIO_WITH_CROP) {$image->crop(new Point($delta_x, $delta_y),new Box($dest_width, $dest_height));}}return $image;}/*** Create a new Imagine object using current driver configuration.** @return ImagineInterface*/protected function createImagineInstance(){$driver = ConfigQuery::read('imagine_graphic_driver', 'gd');switch ($driver) {case 'imagick':$image = new ImagickImagine();break;case 'gmagick':$image = new GmagickImagine();break;case 'gd':default:$image = new Imagine();}return $image;}/*** {@inheritdoc}*/public static function getSubscribedEvents(){return [TheliaEvents::IMAGE_PROCESS => ['processImage', 128],// Implemented in parent class BaseCachedFileTheliaEvents::IMAGE_CLEAR_CACHE => ['clearCache', 128],TheliaEvents::IMAGE_DELETE => ['deleteFile', 128],TheliaEvents::IMAGE_SAVE => ['saveFile', 128],TheliaEvents::IMAGE_UPDATE => ['updateFile', 128],TheliaEvents::IMAGE_UPDATE_POSITION => ['updatePosition', 128],TheliaEvents::IMAGE_TOGGLE_VISIBILITY => ['toggleVisibility', 128],];}}