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 constants
public 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 exists
if (!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 effects
foreach ($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.7
if (isset($params[1])) {
$gamma = (float) $params[1];
$image->effects()->gamma($gamma);
}
break;
case 'colorize':
// Syntax: colorize:couleur. Exemple: colorize:#ff00cc
if (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 BaseCachedFile
TheliaEvents::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],
];
}
}