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