diff --git a/_config/image.yml b/_config/image.yml index d792371a..da282a0b 100644 --- a/_config/image.yml +++ b/_config/image.yml @@ -8,3 +8,19 @@ SilverStripe\Core\Injector\Injector: SilverStripe\Assets\Image_Backend: class: SilverStripe\Assets\InterventionBackend factory: '%$SilverStripe\Assets\ImageBackendFactory' + InterventionImageDriver: + class: 'Intervention\Image\Drivers\Gd\Driver' + Intervention\Image\ImageManager: + factory: '%$SilverStripe\Assets\InterventionManagerFactory' + constructor: + autoOrientation: true + +--- +Name: assetsimage-imagick +After: '#assetsimage' +Only: + extensionloaded: imagick +--- +SilverStripe\Core\Injector\Injector: + InterventionImageDriver: + class: 'Intervention\Image\Drivers\Imagick\Driver' diff --git a/composer.json b/composer.json index 0b4ce0f6..608f529a 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "silverstripe/framework": "^6", "silverstripe/vendor-plugin": "^2", "symfony/filesystem": "^6.1", - "intervention/image": "^2.7.2", + "intervention/image": "^3.7", "league/flysystem": "^3.9.0" }, "require-dev": { diff --git a/src/Conversion/InterventionImageFileConverter.php b/src/Conversion/InterventionImageFileConverter.php index 7f75a0a1..b2b53a49 100644 --- a/src/Conversion/InterventionImageFileConverter.php +++ b/src/Conversion/InterventionImageFileConverter.php @@ -2,8 +2,7 @@ namespace SilverStripe\Assets\Conversion; -use Imagick; -use Intervention\Image\Exception\ImageException; +use Intervention\Image\Exceptions\RuntimeException; use SilverStripe\Assets\File; use SilverStripe\Assets\Image_Backend; use SilverStripe\Assets\InterventionBackend; @@ -31,7 +30,9 @@ public function supportsConversion(string $fromExtension, string $toExtension, a if (!is_a($backend, InterventionBackend::class)) { return false; } - return $this->supportedByIntervention($fromExtension, $backend) && $this->supportedByIntervention($toExtension, $backend); + /** @var InterventionBackend $backend */ + $driver = $backend->getImageManager()->driver(); + return $driver->supports($fromExtension) && $driver->supports($toExtension); } public function convert(DBFile|File $from, string $toExtension, array $options = []): DBFile @@ -46,7 +47,9 @@ public function convert(DBFile|File $from, string $toExtension, array $options = $actualClass = $originalBackend ? get_class($originalBackend) : 'null'; throw new FileConverterException("ImageBackend must be an instance of InterventionBackend. Got $actualClass"); } - if (!$this->supportedByIntervention($toExtension, $originalBackend)) { + /** @var InterventionBackend $originalBackend */ + $driver = $originalBackend->getImageManager()->driver(); + if (!$driver->supports($toExtension)) { throw new FileConverterException("Convertion to format '$toExtension' is not suported."); } @@ -66,7 +69,7 @@ function (AssetStore $store, string $filename, string $hash, string $variant) us return [$tuple, $backend]; } ); - } catch (ImageException $e) { + } catch (RuntimeException $e) { throw new FileConverterException('Failed to convert: ' . $e->getMessage(), $e->getCode(), $e); } // This is very unlikely but the API for `manipulateExtension()` allows for it @@ -90,102 +93,4 @@ private function validateOptions(array $options): array } return $problems; } - - private function supportedByIntervention(string $format, InterventionBackend $backend): bool - { - $driver = $backend->getImageManager()->config['driver'] ?? null; - - // Return early for empty values - we obviously can't support that - if ($format === '') { - return false; - } - - $format = strtolower($format); - - // If the driver is somehow not GD or Imagick, we have no way to know what it might support - if ($driver !== 'gd' && $driver !== 'imagick') { - $supported = false; - $this->extend('updateSupportedByIntervention', $supported, $format, $driver); - return $supported; - } - - // GD and Imagick support different things. - // This follows the logic in intervention's AbstractEncoder::process() method - // and the various methods in the Encoder classes for GD and Imagick, - // excluding checking for strings that were obviously mimetypes - switch ($format) { - case 'gif': - // always supported - return true; - case 'png': - // always supported - return true; - case 'jpg': - case 'jpeg': - case 'jfif': - // always supported - return true; - case 'tif': - case 'tiff': - if ($driver === 'gd') { - false; - } - // always supported by imagick - return true; - case 'bmp': - case 'ms-bmp': - case 'x-bitmap': - case 'x-bmp': - case 'x-ms-bmp': - case 'x-win-bitmap': - case 'x-windows-bmp': - case 'x-xbitmap': - if ($driver === 'gd' && !function_exists('imagebmp')) { - return false; - } - // always supported by imagick - return true; - case 'ico': - if ($driver === 'gd') { - return false; - } - // always supported by imagick - return true; - case 'psd': - if ($driver === 'gd') { - return false; - } - // always supported by imagick - return true; - case 'webp': - if ($driver === 'gd' && !function_exists('imagewebp')) { - return false; - } - if ($driver === 'imagick' && !Imagick::queryFormats('WEBP')) { - return false; - } - return true; - case 'avif': - if ($driver === 'gd' && !function_exists('imageavif')) { - return false; - } - if ($driver === 'imagick' && !Imagick::queryFormats('AVIF')) { - return false; - } - return true; - case 'heic': - if ($driver === 'gd') { - return false; - } - if ($driver === 'imagick' && !Imagick::queryFormats('HEIC')) { - return false; - } - return true; - default: - // Anything else is not supported - return false; - } - // This should never be reached, but return false if it is - return false; - } } diff --git a/src/Image.php b/src/Image.php index 3564eca3..a05ef6b8 100644 --- a/src/Image.php +++ b/src/Image.php @@ -38,6 +38,12 @@ class Image extends File */ private static $lazy_loading_enabled = true; + /** + * Determine whether the image generated by PreviewLink() is allowed to be animated or not. + * Used in asset admin, upload field, and WYSIWYG. + */ + private static bool $allow_animated_preview = false; + public function __construct($record = null, $isSingleton = false, $queryParams = []) { parent::__construct($record, $isSingleton, $queryParams); @@ -99,13 +105,29 @@ public function PreviewLink($action = null) } // Size to width / height - $width = (int)$this->config()->get('asset_preview_width'); - $height = (int)$this->config()->get('asset_preview_height'); - $resized = $this->FitMax($width, $height); - if ($resized && $resized->exists()) { - $link = $resized->getAbsoluteURL(); - } else { - $link = $this->getIcon(); + $width = (int)static::config()->get('asset_preview_width'); + $height = (int)static::config()->get('asset_preview_height'); + + // Temporarily disallow animated manipulations if necessary + $backend = $this->getImageBackend(); + $origAllowAnimation = $backend->getAllowsAnimationInManipulations(); + if ($origAllowAnimation && !static::config()->get('allow_animated_preview')) { + $backend->setAllowsAnimationInManipulations(false); + } + + try { + // Get link for preview + $resized = $this->FitMax($width, $height); + if ($resized && $resized->exists()) { + $link = $resized->getAbsoluteURL(); + } else { + $link = $this->getIcon(); + } + } finally { + // Reset original value + if ($origAllowAnimation !== null) { + $backend->setAllowsAnimationInManipulations($origAllowAnimation); + } } $this->extend('updatePreviewLink', $link, $action); return $link; diff --git a/src/ImageManipulation.php b/src/ImageManipulation.php index a2827fc2..2487cb31 100644 --- a/src/ImageManipulation.php +++ b/src/ImageManipulation.php @@ -602,6 +602,29 @@ public function Fill($width, $height) }); } + /** + * Check if the image is animated (e.g. an animated GIF). + */ + public function getIsAnimated(): bool + { + $backend = $this->getImageBackend(); + if (!$backend) { + return false; + } + return $backend->getIsAnimated(); + } + + public function RemoveAnimation(int|string $position = 0): ?AssetContainer + { + if (!$this->getIsAnimated()) { + return $this; + } + $variant = $this->variantName(__FUNCTION__, $position); + return $this->manipulateImage($variant, function (Image_Backend $backend) use ($position) { + return $backend->removeAnimation($position); + }); + } + /** * Set the quality of the resampled image * diff --git a/src/Image_Backend.php b/src/Image_Backend.php index 061bb6a4..14885ace 100644 --- a/src/Image_Backend.php +++ b/src/Image_Backend.php @@ -36,42 +36,36 @@ interface Image_Backend public function __construct(AssetContainer $assetContainer = null); /** - * @return int The width of the image + * Get the width of the image */ - public function getWidth(); + public function getWidth(): int; /** - * @return int The height of the image + * Get the height of the image */ - public function getHeight(); + public function getHeight(): int; /** * Populate the backend with a given object * * @param AssetContainer $assetContainer Object to load from */ - public function loadFromContainer(AssetContainer $assetContainer); + public function loadFromContainer(AssetContainer $assetContainer): static; /** * Populate the backend from a local path - * - * @param string $path */ - public function loadFrom($path); + public function loadFrom(string $path): static; /** * Get the currently assigned image resource - * - * @return mixed */ - public function getImageResource(); + public function getImageResource(): mixed; /** * Set the currently assigned image resource - * - * @param mixed $resource */ - public function setImageResource($resource); + public function setImageResource($resource): static; /** * Write to the given asset store @@ -84,7 +78,7 @@ public function setImageResource($resource); * @return array Tuple associative array (Filename, Hash, Variant) Unless storing a variant, the hash * will be calculated from the given data. */ - public function writeToStore(AssetStore $assetStore, $filename, $hash = null, $variant = null, $config = []); + public function writeToStore(AssetStore $assetStore, string $filename, ?string $hash = null, ?string $variant = null, array $config = []): array; /** * Write the backend to a local path @@ -92,79 +86,84 @@ public function writeToStore(AssetStore $assetStore, $filename, $hash = null, $v * @param string $path * @return bool if the write was successful */ - public function writeTo($path); + public function writeTo(string $path): bool; /** * Set the quality to a value between 0 and 100 - * - * @param int $quality */ - public function setQuality($quality); + public function setQuality(int $quality): static; + + /** + * Get the current quality (between 0 and 100). + */ + public function getQuality(): int; /** * Resize an image, skewing it as necessary. - * - * @param int $width - * @param int $height - * @return static */ - public function resize($width, $height); + public function resize(int $width, int $height): ?static; /** - * Resize the image by preserving aspect ratio. By default, it will keep the image inside the maxWidth - * and maxHeight. Passing useAsMinimum will make the smaller dimension equal to the maximum corresponding dimension - * - * @param int $width - * @param int $height - * @param bool $useAsMinimum If true, image will be sized outside of these dimensions. - * If false (default) image will be sized inside these dimensions. - * @return static + * Resize the image by preserving aspect ratio. By default, the image cannot be resized to be larger + * than its current size. + * Passing true to useAsMinimum will allow the image to be scaled up. */ - public function resizeRatio($width, $height, $useAsMinimum = false); + public function resizeRatio(int $width, int $height, bool $useAsMinimum = false): ?static; /** * Resize an image by width. Preserves aspect ratio. - * - * @param int $width - * @return static */ - public function resizeByWidth($width); + public function resizeByWidth(int $width): ?static; /** * Resize an image by height. Preserves aspect ratio. - * - * @param int $height - * @return static */ - public function resizeByHeight($height); + public function resizeByHeight(int $height): ?static; /** - * Return a clone of this image resized, with space filled in with the given colour - * - * @param int $width - * @param int $height - * @param string $backgroundColor - * @param int $transparencyPercent - * @return static + * Return a clone of this image resized, with space filled in with the given colour. */ - public function paddedResize($width, $height, $backgroundColor = "FFFFFF", $transparencyPercent = 0); + public function paddedResize(string $width, string $height, string $backgroundColour = 'FFFFFF', int $transparencyPercent = 0): ?static; /** * Resize an image to cover the given width/height completely, and crop off any overhanging edges. - * - * @param int $width - * @param int $height - * @return static */ - public function croppedResize($width, $height); + public function croppedResize(int $width, int $height, string $position = 'center'): ?static; /** * Crop's part of image. - * @param int $top y position of left upper corner of crop rectangle - * @param int $left x position of left upper corner of crop rectangle + * @param int $top Amount of pixels the cutout will be moved on the y (vertical) axis + * @param int $left Amount of pixels the cutout will be moved on the x (horizontal) axis * @param int $width rectangle width * @param int $height rectangle height - * @return Image_Backend + * @param string $position Postion at which the cutout will be aligned + * @param string $backgroundColour Colour to fill any newly created areas + */ + public function crop(int $top, int $left, int $width, int $height, string $position, string $backgroundColour = 'FFFFFF'): ?static; + + /** + * Set whether this image backend is allowed to output animated images as a result of manipulations. + */ + public function setAllowsAnimationInManipulations(bool $allow): static; + + /** + * Get whether this image backend is allowed to output animated images as a result of manipulations. + */ + public function getAllowsAnimationInManipulations(): bool; + + /** + * Check if the image is animated (e.g. an animated GIF). + * Will return false if animations are not allowed for manipulations. + */ + public function getIsAnimated(): bool; + + /** + * Discards all animation frames of the current image instance except the one at the given position. Turns an animated image into a static one. + * + * @param integer|string $position Which frame to use as the still image. + * If an integer is passed, it represents the exact frame number to use (starting at 0). If that frame doesn't exist, an exception is thrown. + * If a string is passed, it must be in the form of a percentage (e.g. '0%' or '50%'). The frame to use is then determined based + * on this percentage (e.g. if '50%' is passed, a frame halfway through the animation is used). */ - public function crop($top, $left, $width, $height); + public function removeAnimation(int|string $position): ?static; } diff --git a/src/InterventionBackend.php b/src/InterventionBackend.php index a6644560..befc502e 100644 --- a/src/InterventionBackend.php +++ b/src/InterventionBackend.php @@ -3,13 +3,13 @@ namespace SilverStripe\Assets; use BadMethodCallException; -use Intervention\Image\Constraint; -use Intervention\Image\Exception\NotReadableException; -use Intervention\Image\Exception\NotSupportedException; -use Intervention\Image\Exception\NotWritableException; -use Intervention\Image\Image as InterventionImage; +use Intervention\Image\Colors\Rgb\Channels\Alpha; +use Intervention\Image\Colors\Rgb\Color; +use Intervention\Image\Drivers\AbstractEncoder; +use Intervention\Image\Exceptions\DecoderException; +use Intervention\Image\Exceptions\EncoderException; +use Intervention\Image\Interfaces\ImageInterface as InterventionImage; use Intervention\Image\ImageManager; -use Intervention\Image\Size; use InvalidArgumentException; use LogicException; use Psr\Http\Message\StreamInterface; @@ -37,21 +37,17 @@ class InterventionBackend implements Image_Backend, Flushable /** * Is cache flushing enabled? - * - * @config - * @var boolean */ - private static $flush_enabled = true; + private static bool $flush_enabled = true; /** * How long to cache each error type * - * @config - * @var array Map of error type to config. + * Map of error type to config. * each config could be a single int (fixed cache time) * or list of integers (increasing scale) */ - private static $error_cache_ttl = [ + private static array $error_cache_ttl = [ InterventionBackend::FAILED_INVALID => 0, // Invalid file type should probably never be retried InterventionBackend::FAILED_MISSING => '5,10,20,40,80', // Missing files may be eventually available InterventionBackend::FAILED_UNKNOWN => 300, // Unknown (edge case). Maybe system error? Needs a flush? @@ -76,40 +72,20 @@ class InterventionBackend implements Image_Backend, Flushable /** * Configure where cached intervention files will be stored * - * @config - * @var string */ - private static $local_temp_path = TEMP_PATH; + private static string $local_temp_path = TEMP_PATH; - /** - * @var AssetContainer - */ - private $container; + private ?AssetContainer $container = null; - /** - * @var InterventionImage - */ - private $image; + private ?InterventionImage $image; - /** - * @var int - */ - private $quality; + private int $quality = AbstractEncoder::DEFAULT_QUALITY; - /** - * @var ImageManager - */ - private $manager; + private ?ImageManager $manager = null; - /** - * @var CacheInterface - */ - private $cache; + private ?CacheInterface $cache = null; - /** - * @var string - */ - private $tempPath; + private ?string $tempPath = null; public function __construct(AssetContainer $assetContainer = null) { @@ -117,28 +93,23 @@ public function __construct(AssetContainer $assetContainer = null) } /** - * @return string The temporary local path for this image + * Get the temporary local path for this image */ - public function getTempPath() + public function getTempPath(): ?string { return $this->tempPath; } /** - * @param string $path - * - * @return $this + * Set the temporary local path for this image */ - public function setTempPath($path) + public function setTempPath(string $path): static { $this->tempPath = $path; return $this; } - /** - * @return CacheInterface - */ - public function getCache() + public function getCache(): CacheInterface { if (!$this->cache) { $this->setCache(Injector::inst()->get(CacheInterface::class . '.InterventionBackend_Manipulations')); @@ -146,41 +117,25 @@ public function getCache() return $this->cache; } - /** - * @param CacheInterface $cache - * - * @return $this - */ - public function setCache($cache) + public function setCache(CacheInterface $cache): static { $this->cache = $cache; return $this; } - /** - * @return AssetContainer - */ - public function getAssetContainer() + public function getAssetContainer(): ?AssetContainer { return $this->container; } - /** - * @param AssetContainer $assetContainer - * - * @return $this - */ - public function setAssetContainer($assetContainer) + public function setAssetContainer(?AssetContainer $assetContainer): static { $this->setImageResource(null); $this->container = $assetContainer; return $this; } - /** - * @return ImageManager - */ - public function getImageManager() + public function getImageManager(): ImageManager { if (!$this->manager) { $this->setImageManager(Injector::inst()->create(ImageManager::class)); @@ -188,12 +143,7 @@ public function getImageManager() return $this->manager; } - /** - * @param ImageManager $manager - * - * @return $this - */ - public function setImageManager($manager) + public function setImageManager(ImageManager $manager): static { $this->manager = $manager; return $this; @@ -203,9 +153,8 @@ public function setImageManager($manager) * Populate the backend with a given object * * @param AssetContainer $assetContainer Object to load from - * @return $this */ - public function loadFromContainer(AssetContainer $assetContainer) + public function loadFromContainer(AssetContainer $assetContainer): static { return $this->setAssetContainer($assetContainer); } @@ -213,10 +162,8 @@ public function loadFromContainer(AssetContainer $assetContainer) /** * Get the currently assigned image resource, or generates one if not yet assigned. * Note: This method may return null if error - * - * @return InterventionImage */ - public function getImageResource() + public function getImageResource(): ?InterventionImage { // Get existing resource if ($this->image) { @@ -267,17 +214,10 @@ public function getImageResource() $bytesWritten = file_put_contents($path ?? '', $stream); // if we fail to write, then load from stream if ($bytesWritten === false) { - $resource = $this->getImageManager()->make($stream); + $resource = $this->getImageManager()->read($stream); } else { $this->setTempPath($path); - $resource = $this->getImageManager()->make($path); - } - - // Fix image orientation - try { - $resource->orientate(); - } catch (NotSupportedException $e) { - // noop - we can't orientate, don't worry about it + $resource = $this->getImageManager()->read($path); } $this->setImageResource($resource); @@ -285,7 +225,7 @@ public function getImageResource() $this->warmCache($hash, $variant); $error = null; return $resource; - } catch (NotReadableException $ex) { + } catch (DecoderException $ex) { // Handle unsupported image encoding on load (will be marked as failed) // Unsupported exceptions are handled without being raised as exceptions $error = InterventionBackend::FAILED_INVALID; @@ -299,11 +239,8 @@ public function getImageResource() /** * Populate the backend from a local path - * - * @param string $path - * @return $this */ - public function loadFrom($path) + public function loadFrom(string $path): static { // Avoid repeat load of broken images $hash = sha1($path ?? ''); @@ -314,10 +251,10 @@ public function loadFrom($path) // Handle resource $error = InterventionBackend::FAILED_UNKNOWN; try { - $this->setImageResource($this->getImageManager()->make($path)); + $this->setImageResource($this->getImageManager()->read($path)); $this->markSuccess($hash, null); $error = null; - } catch (NotReadableException $ex) { + } catch (DecoderException $ex) { // Handle unsupported image encoding on load (will be marked as failed) // Unsupported exceptions are handled without being raised as exceptions $error = InterventionBackend::FAILED_INVALID; @@ -331,11 +268,15 @@ public function loadFrom($path) } /** + * @inheritDoc + * * @param InterventionImage $image - * @return $this */ - public function setImageResource($image) + public function setImageResource($image): static { + if ($image && !is_a($image, InterventionImage::class)) { + throw new InvalidArgumentException('$image must be an instance of ' . InterventionImage::class); + } $this->image = $image; if ($image === null) { // remove our temp file if it exists @@ -347,18 +288,11 @@ public function setImageResource($image) } /** - * Write to the given asset store + * @inheritDoc * - * @param AssetStore $assetStore - * @param string $filename Name for the resulting file - * @param string $hash Hash of original file, if storing a variant. - * @param string $variant Name of variant, if storing a variant. - * @param array $config Write options. {@see AssetStore} - * @return array Tuple associative array (Filename, Hash, Variant) Unless storing a variant, the hash - * will be calculated from the given data. * @throws BadMethodCallException If image isn't valid */ - public function writeToStore(AssetStore $assetStore, $filename, $hash = null, $variant = null, $config = []) + public function writeToStore(AssetStore $assetStore, string $filename, ?string $hash = null, ?string $variant = null, array $config = []): array { try { $resource = $this->getImageResource(); @@ -371,7 +305,7 @@ public function writeToStore(AssetStore $assetStore, $filename, $hash = null, $v $extension = pathinfo($url, PATHINFO_EXTENSION); // Save file $result = $assetStore->setFromString( - $resource->encode($extension, $this->getQuality())->getEncoded(), + $resource->encodeByExtension($extension, quality: $this->getQuality())->toString(), $filename, $hash, $variant, @@ -384,19 +318,17 @@ public function writeToStore(AssetStore $assetStore, $filename, $hash = null, $v } return $result; - } catch (NotSupportedException $e) { + } catch (EncoderException $e) { return null; } } /** - * Write the backend to a local path + * @inheritDoc * - * @param string $path - * @return bool If the writing was successful * @throws BadMethodCallException If image isn't valid */ - public function writeTo($path) + public function writeTo(string $path): bool { try { $resource = $this->getImageResource(); @@ -404,16 +336,16 @@ public function writeTo($path) throw new BadMethodCallException("Cannot write corrupt file to store"); } $resource->save($path, $this->getQuality()); - } catch (NotWritableException $e) { + } catch (EncoderException $e) { return false; } return true; } /** - * @return int + * @inheritDoc */ - public function getQuality() + public function getQuality(): int { return $this->quality; } @@ -421,9 +353,9 @@ public function getQuality() /** * Return dimensions as array with cache enabled * - * @return array Two-length array with width and height + * Returns a two-length array with width and height */ - protected function getDimensions() + protected function getDimensions(): array { // Default result $result = [0, 0]; @@ -465,51 +397,35 @@ protected function getDimensions() /** * Get dimensions from the given resource - * - * @param InterventionImage $resource - * @return array */ - protected function getResourceDimensions(InterventionImage $resource) + protected function getResourceDimensions(InterventionImage $resource): array { - /** @var Size $size */ - $size = $resource->getSize(); return [ - $size->getWidth(), - $size->getHeight() + $resource->width(), + $resource->height(), ]; } /** * Cache key for recording errors - * - * @param string $hash - * @param string|null $variant - * @return string */ - protected function getErrorCacheKey($hash, $variant = null) + protected function getErrorCacheKey(string $hash, ?string $variant = null): string { return InterventionBackend::CACHE_MARK . sha1($hash . '-' . $variant); } /** * Cache key for dimensions for given container - * - * @param string $hash - * @param string|null $variant - * @return string */ - protected function getDimensionCacheKey($hash, $variant = null) + protected function getDimensionCacheKey(string $hash, ?string $variant = null): string { return InterventionBackend::CACHE_DIMENSIONS . sha1($hash . '-' . $variant); } /** * Warm dimension cache for the given asset - * - * @param string $hash - * @param string|null $variant */ - protected function warmCache($hash, $variant = null) + protected function warmCache(string $hash, ?string $variant = null): void { // Warm dimension cache $key = $this->getDimensionCacheKey($hash, $variant); @@ -521,43 +437,36 @@ protected function warmCache($hash, $variant = null) } /** - * @return int The width of the image + * @inheritDoc */ - public function getWidth() + public function getWidth(): int { list($width) = $this->getDimensions(); return (int)$width; } /** - * @return int The height of the image + * @inheritDoc */ - public function getHeight() + public function getHeight(): int { list(, $height) = $this->getDimensions(); return (int)$height; } /** - * Set the quality to a value between 0 and 100 - * - * @param int $quality - * @return $this + * @inheritDoc */ - public function setQuality($quality) + public function setQuality(int $quality): static { $this->quality = $quality; return $this; } /** - * Resize an image, skewing it as necessary. - * - * @param int $width - * @param int $height - * @return static + * @inheritDoc */ - public function resize($width, $height) + public function resize(int $width, int $height): ?static { return $this->createCloneWithResource( function (InterventionImage $resource) use ($width, $height) { @@ -567,135 +476,138 @@ function (InterventionImage $resource) use ($width, $height) { } /** - * Resize the image by preserving aspect ratio. By default, it will keep the image inside the maxWidth - * and maxHeight. Passing useAsMinimum will make the smaller dimension equal to the maximum corresponding dimension - * - * @param int $width - * @param int $height - * @param bool $useAsMinimum If true, image will be sized outside of these dimensions. - * If false (default) image will be sized inside these dimensions. - * @return static + * @inheritDoc */ - public function resizeRatio($width, $height, $useAsMinimum = false) + public function resizeRatio(int $width, int $height, bool $useAsMinimum = false): ?static { return $this->createCloneWithResource( function (InterventionImage $resource) use ($width, $height, $useAsMinimum) { - return $resource->resize( - $width, - $height, - function (Constraint $constraint) use ($useAsMinimum) { - $constraint->aspectRatio(); - if (!$useAsMinimum) { - $constraint->upsize(); - } - } - ); + if ($useAsMinimum) { + return $resource->scale($width, $height); + } + return $resource->scaleDown($width, $height); } ); } /** - * Resize an image by width. Preserves aspect ratio. - * - * @param int $width - * @return static + * @inheritDoc */ - public function resizeByWidth($width) + public function resizeByWidth(int $width): ?static { return $this->createCloneWithResource( function (InterventionImage $resource) use ($width) { - return $resource->widen($width); + return $resource->scale($width); } ); } /** - * Resize an image by height. Preserves aspect ratio. - * - * @param int $height - * @return static + * @inheritDoc */ - public function resizeByHeight($height) + public function resizeByHeight(int $height): ?static { return $this->createCloneWithResource( function (InterventionImage $resource) use ($height) { - return $resource->heighten($height); + return $resource->scale(height: $height); } ); } /** - * Return a clone of this image resized, with space filled in with the given colour - * - * @param int $width - * @param int $height - * @param string $backgroundColor - * @param int $transparencyPercent - * @return static + * @inheritDoc */ - public function paddedResize($width, $height, $backgroundColor = "FFFFFF", $transparencyPercent = 0) + public function paddedResize(string $width, string $height, string $backgroundColour = 'FFFFFF', int $transparencyPercent = 0): ?static { $resource = $this->getImageResource(); if (!$resource) { return null; } - // caclulate the background colour - $background = $resource->getDriver()->parseColor($backgroundColor)->format('array'); - // convert transparancy % to alpha - $background[3] = 1 - round(min(100, max(0, $transparencyPercent)) / 100, 2); + if ($transparencyPercent < 0 || $transparencyPercent > 100) { + throw new InvalidArgumentException('$transparencyPercent must be between 0 and 100. Got ' . $transparencyPercent); + } - // resize the image maintaining the aspect ratio and then pad out the canvas + $bgColour = Color::create($backgroundColour); + // The Color class is immutable, so we have to instantiate a new one to set the alpha channel. + // No need to do that if both the $backgroundColor and $transparencyPercent are 0. + if ($bgColour->channel(Alpha::class)->value() !== 0 && $transparencyPercent !== 0) { + $channels = $bgColour->channels(); + $alpha = (int) round(255 * (1 - ($transparencyPercent * 0.01))); + $bgColour = new Color($channels[0]->value(), $channels[1]->value(), $channels[2]->value(), $alpha); + } + + // resize the image maintaining the aspect ratio and pad out the canvas return $this->createCloneWithResource( - function (InterventionImage $resource) use ($width, $height, $background) { - return $resource - ->resize( - $width, - $height, - function (Constraint $constraint) { - $constraint->aspectRatio(); - } - ) - ->resizeCanvas( - $width, - $height, - 'center', - false, - $background - ); + function (InterventionImage $resource) use ($width, $height, $bgColour) { + return $resource->contain($width, $height, $bgColour, 'center'); } ); } /** - * Resize an image to cover the given width/height completely, and crop off any overhanging edges. - * - * @param int $width - * @param int $height - * @return static + * @inheritDoc */ - public function croppedResize($width, $height) + public function croppedResize(int $width, int $height, string $position = 'center'): ?static { return $this->createCloneWithResource( - function (InterventionImage $resource) use ($width, $height) { - return $resource->fit($width, $height); + function (InterventionImage $resource) use ($width, $height, $position) { + return $resource->cover($width, $height, $position); } ); } /** - * Crop's part of image. - * @param int $top y position of left upper corner of crop rectangle - * @param int $left x position of left upper corner of crop rectangle - * @param int $width rectangle width - * @param int $height rectangle height - * @return Image_Backend + * @inheritDoc */ - public function crop($top, $left, $width, $height) + public function crop(int $top, int $left, int $width, int $height, string $position = 'top-left', string $backgroundColour = 'FFFFFF'): ?static { return $this->createCloneWithResource( - function (InterventionImage $resource) use ($top, $left, $height, $width) { - return $resource->crop($width, $height, $left, $top); + function (InterventionImage $resource) use ($top, $left, $height, $width, $position, $backgroundColour) { + return $resource->crop($width, $height, $left, $top, $backgroundColour, $position); + } + ); + } + + /** + * @inheritDoc + */ + public function setAllowsAnimationInManipulations(bool $allow): static + { + $this->getImageManager()->driver()->config()->decodeAnimation = $allow; + return $this; + } + + /** + * @inheritDoc + */ + public function getAllowsAnimationInManipulations(): bool + { + return $this->getImageManager()->driver()->config()->decodeAnimation; + } + + /** + * @inheritDoc + */ + public function getIsAnimated(): bool + { + if (!$this->getAllowsAnimationInManipulations()) { + return false; + } + return $this->getImageResource()?->isAnimated() ?? false; + } + + /** + * @inheritDoc + */ + public function removeAnimation(int|string $position): ?static + { + if (!$this->getAllowsAnimationInManipulations()) { + return $this; + } + return $this->createCloneWithResource( + function (InterventionImage $resource) use ($position) { + return $resource->removeAnimation($position); } ); } @@ -705,9 +617,8 @@ function (InterventionImage $resource) use ($top, $left, $height, $width) { * * @param InterventionImage|callable $resourceOrTransformation Either the resource to assign to the clone, * or a function which takes the current resource as a parameter - * @return static */ - protected function createCloneWithResource($resourceOrTransformation) + protected function createCloneWithResource($resourceOrTransformation): ?static { // No clone with no argument if (!$resourceOrTransformation) { @@ -743,11 +654,8 @@ protected function createCloneWithResource($resourceOrTransformation) /** * Clear any cached errors / metadata for this image - * - * @param string $hash - * @param string|null $variant */ - protected function markSuccess($hash, $variant = null) + protected function markSuccess(string $hash, ?string $variant = null): void { $key = $this->getErrorCacheKey($hash, $variant); $this->getCache()->deleteMultiple([ @@ -763,7 +671,7 @@ protected function markSuccess($hash, $variant = null) * @param string|null $variant Variant being loaded * @param string $reason Reason this file is failed */ - protected function markFailed($hash, $variant = null, $reason = InterventionBackend::FAILED_UNKNOWN) + protected function markFailed(string $hash, ?string $variant = null, string $reason = InterventionBackend::FAILED_UNKNOWN): void { $key = $this->getErrorCacheKey($hash, $variant); @@ -796,10 +704,8 @@ protected function markFailed($hash, $variant = null, $reason = InterventionBack * Will return one of the FAILED_* constant values, or null if not failed * * @param string $hash Hash of the original file being manipulated - * @param string|null $variant - * @return string|null */ - protected function hasFailed($hash, $variant = null) + protected function hasFailed(string $hash, ?string $variant = null): ?string { $key = $this->getErrorCacheKey($hash, $variant); return $this->getCache()->get($key.'_reason', null); @@ -810,10 +716,6 @@ protected function hasFailed($hash, $variant = null) */ public function __destruct() { - //skip the `getImageResource` method because we don't want to load the resource just to destroy it - if ($this->image) { - $this->image->destroy(); - } // remove our temp file if it exists if (file_exists($this->getTempPath() ?? '')) { unlink($this->getTempPath() ?? ''); @@ -827,7 +729,7 @@ public function __destruct() * * @see FlushRequestFilter */ - public static function flush() + public static function flush(): void { if (Config::inst()->get(static::class, 'flush_enabled')) { /** @var CacheInterface $cache */ @@ -838,11 +740,8 @@ public static function flush() /** * Validate the stream resource is readable - * - * @param mixed $stream - * @return bool */ - protected function isStreamReadable($stream) + protected function isStreamReadable(mixed $stream): bool { if (empty($stream)) { return false; diff --git a/src/InterventionManagerFactory.php b/src/InterventionManagerFactory.php new file mode 100644 index 00000000..870eb1f8 --- /dev/null +++ b/src/InterventionManagerFactory.php @@ -0,0 +1,20 @@ +create('InterventionImageDriver'), ...$params); + } +} diff --git a/src/Storage/AssetContainer.php b/src/Storage/AssetContainer.php index ec021fb8..2b16e99c 100644 --- a/src/Storage/AssetContainer.php +++ b/src/Storage/AssetContainer.php @@ -106,6 +106,11 @@ public function getAbsoluteSize(); */ public function getIsImage(); + /** + * Check if the asset is an animated image + */ + public function getIsAnimated(): bool; + /** * Determine visibility of the given file * diff --git a/tests/php/GDImageTest.php b/tests/php/GDImageTest.php index 8a8c4e38..81f44fe3 100644 --- a/tests/php/GDImageTest.php +++ b/tests/php/GDImageTest.php @@ -2,6 +2,7 @@ namespace SilverStripe\Assets\Tests; +use Intervention\Image\Drivers\Gd\Driver as GDDriver; use Intervention\Image\ImageManager; use SilverStripe\Assets\Image; use SilverStripe\Assets\InterventionBackend; @@ -24,7 +25,7 @@ protected function setUp(): void Injector::inst()->setConfigLocator(new SilverStripeServiceConfigurationLocator()); Config::modify()->set(Injector::class, ImageManager::class, [ 'constructor' => [ - [ 'driver' => 'gd' ], + '%$' . GDDriver::class, ], ]); } @@ -35,7 +36,7 @@ public function testDriverType() $image = $this->objFromFixture(Image::class, 'imageWithTitle'); /** @var InterventionBackend $backend */ $backend = $image->getImageBackend(); - $this->assertEquals('gd', $backend->getImageManager()->config['driver']); + $this->assertInstanceOf(GDDriver::class, $backend->getImageManager()->driver()); } public function testGetTagWithTitle() diff --git a/tests/php/ImageTest.php b/tests/php/ImageTest.php index 8eb8d928..a9f98791 100644 --- a/tests/php/ImageTest.php +++ b/tests/php/ImageTest.php @@ -34,7 +34,6 @@ protected function setUp(): void TestAssetStore::activate('ImageTest'); // Copy test images for each of the fixture references - /** @var File $image */ $files = File::get()->exclude('ClassName', Folder::class); foreach ($files as $image) { $sourcePath = __DIR__ . '/ImageTest/' . $image->Name; @@ -147,6 +146,7 @@ public function testQualityAdjustsImageFilesize() // Same test, but with manipulations in a different order $highQuality = $image->Quality(100)->ScaleWidth(200); $lowQuality = $image->Quality(1)->ScaleWidth(200); + $this->assertLessThan( $highQuality->getAbsoluteSize(), $lowQuality->getAbsoluteSize(), @@ -576,4 +576,27 @@ public function testGetLazyLoadingEnabled() $this->assertFalse(Image::getLazyLoadingEnabled()); }); } + + public function testAnimatedVariants(): void + { + $image = $this->objFromFixture(Image::class, 'animated'); + $this->assertTrue($image->getIsAnimated()); + + $animatedVariant = $image->Fit(200, 200); + $this->assertTrue($animatedVariant->getIsAnimated()); + + $stillVariant = $image->RemoveAnimation(); + $this->assertFalse($stillVariant->getIsAnimated()); + + $smallStillVariant = $stillVariant->Fit(300, 200); + $this->assertFalse($smallStillVariant->getIsAnimated()); + + $image->getImageBackend()->setAllowsAnimationInManipulations(false); + $stillVariant2 = $image->Fit(400, 200); + $this->assertFalse($stillVariant2->getIsAnimated()); + + $image->getImageBackend()->setAllowsAnimationInManipulations(true); + $animatedVariant2 = $image->Fit(500, 200); + $this->assertTrue($animatedVariant2->getIsAnimated()); + } } diff --git a/tests/php/ImageTest.yml b/tests/php/ImageTest.yml index 55966ec8..24032e5c 100644 --- a/tests/php/ImageTest.yml +++ b/tests/php/ImageTest.yml @@ -42,6 +42,11 @@ SilverStripe\Assets\Image: FileHash: 1b22f41d0d27755f06b77eaa27e074eff84d3019 Parent: =>SilverStripe\Assets\Folder.folder1 Name: landscape-to-portrait.jpg + animated: + Title: This is an animated GIF + FileFilename: folder/animated.gif + Parent: =>SilverStripe\Assets\Folder.folder1 + Name: animated.gif SilverStripe\Assets\File: notImage: diff --git a/tests/php/ImageTest/animated.gif b/tests/php/ImageTest/animated.gif new file mode 100644 index 00000000..e8963f17 Binary files /dev/null and b/tests/php/ImageTest/animated.gif differ diff --git a/tests/php/ImagickImageTest.php b/tests/php/ImagickImageTest.php index 232bda9c..1c9a50b3 100644 --- a/tests/php/ImagickImageTest.php +++ b/tests/php/ImagickImageTest.php @@ -2,6 +2,7 @@ namespace SilverStripe\Assets\Tests; +use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver; use Intervention\Image\ImageManager; use SilverStripe\Assets\Image; use SilverStripe\Assets\InterventionBackend; @@ -24,7 +25,7 @@ protected function setUp(): void Injector::inst()->setConfigLocator(new SilverStripeServiceConfigurationLocator()); Config::modify()->set(Injector::class, ImageManager::class, [ 'constructor' => [ - [ 'driver' => 'imagick' ], + '%$' . ImagickDriver::class, ], ]); } @@ -35,6 +36,6 @@ public function testDriverType() $image = $this->objFromFixture(Image::class, 'imageWithTitle'); /** @var InterventionBackend $backend */ $backend = $image->getImageBackend(); - $this->assertEquals('imagick', $backend->getImageManager()->config['driver']); + $this->assertInstanceOf(ImagickDriver::class, $backend->getImageManager()->driver()); } } diff --git a/tests/php/Shortcodes/FileLinkTrackingTest.php b/tests/php/Shortcodes/FileLinkTrackingTest.php index 3e81bf71..ff23d644 100644 --- a/tests/php/Shortcodes/FileLinkTrackingTest.php +++ b/tests/php/Shortcodes/FileLinkTrackingTest.php @@ -33,8 +33,8 @@ protected function setUp(): void $files = File::get()->exclude('ClassName', Folder::class); foreach ($files as $file) { // Mock content for files - $content = $file->Filename . ' ' . str_repeat('x', 1000000); - $file->setFromString($content, $file->Filename); + $sourcePath = __DIR__ . '/FileLinkTrackingTest/' . $file->Name; + $file->setFromLocalFile($sourcePath, $file->Filename); $file->write(); $file->publishRecursive(); } @@ -78,7 +78,7 @@ public function testFileRenameUpdatesDraftAndPublishedPages() // Live and stage pages both have link to public file $this->assertStringContainsString( - 'dbObject('Content')->forTemplate() ); $this->assertStringContainsString( @@ -91,7 +91,7 @@ public function testFileRenameUpdatesDraftAndPublishedPages() /** @var EditableObject $pageLive */ $pageLive = EditableObject::get()->byID($page->ID); $this->assertStringContainsString( - 'dbObject('Content')->forTemplate() ); $this->assertStringContainsString( @@ -120,14 +120,14 @@ public function testFileRenameUpdatesDraftAndPublishedPages() // the mocked test location disappears for secure files. $page = EditableObject::get()->byID($page->ID); $this->assertStringContainsString( - 'dbObject('Content')->forTemplate() ); Versioned::withVersionedMode(function () use ($page) { Versioned::set_stage(Versioned::LIVE); $pageLive = EditableObject::get()->byID($page->ID); $this->assertStringContainsString( - 'dbObject('Content')->forTemplate() ); }); @@ -137,14 +137,14 @@ public function testFileRenameUpdatesDraftAndPublishedPages() $image1->publishRecursive(); $page = EditableObject::get()->byID($page->ID); $this->assertStringContainsString( - 'dbObject('Content')->forTemplate() ); Versioned::withVersionedMode(function () use ($page) { Versioned::set_stage(Versioned::LIVE); $pageLive = EditableObject::get()->byID($page->ID); $this->assertStringContainsString( - 'dbObject('Content')->forTemplate() ); }); @@ -153,14 +153,14 @@ public function testFileRenameUpdatesDraftAndPublishedPages() $page->publishRecursive(); $page = EditableObject::get()->byID($page->ID); $this->assertStringContainsString( - 'dbObject('Content')->forTemplate() ); Versioned::withVersionedMode(function () use ($page) { Versioned::set_stage(Versioned::LIVE); $pageLive = EditableObject::get()->byID($page->ID); $this->assertStringContainsString( - 'dbObject('Content')->forTemplate() ); }); @@ -194,7 +194,7 @@ public function testTwoFileRenamesInARowWork() Versioned::set_stage(Versioned::LIVE); $livePage = EditableObject::get()->byID($page->ID); $this->assertStringContainsString( - 'dbObject('Content')->forTemplate() ); }); @@ -213,7 +213,7 @@ public function testTwoFileRenamesInARowWork() // Confirm that the correct image is shown in both the draft and live site $page = EditableObject::get()->byID($page->ID); $this->assertStringContainsString( - 'dbObject('Content')->forTemplate() ); @@ -223,7 +223,7 @@ public function testTwoFileRenamesInARowWork() Versioned::set_stage(Versioned::LIVE); $pageLive = EditableObject::get()->byID($page->ID); $this->assertStringContainsString( - 'dbObject('Content')->forTemplate() ); }); diff --git a/tests/php/Shortcodes/FileLinkTrackingTest/testscript-test-file.jpg b/tests/php/Shortcodes/FileLinkTrackingTest/testscript-test-file.jpg new file mode 100644 index 00000000..ccc8346a Binary files /dev/null and b/tests/php/Shortcodes/FileLinkTrackingTest/testscript-test-file.jpg differ diff --git a/tests/php/Shortcodes/FileLinkTrackingTest/testscript-test-file.txt b/tests/php/Shortcodes/FileLinkTrackingTest/testscript-test-file.txt new file mode 100644 index 00000000..8b0e0112 --- /dev/null +++ b/tests/php/Shortcodes/FileLinkTrackingTest/testscript-test-file.txt @@ -0,0 +1 @@ +Some arbitrary content