From de440962300047bed404af4f085803aeb12d9d3d Mon Sep 17 00:00:00 2001 From: Guy Sartorelli Date: Wed, 10 Jul 2024 11:00:07 +1200 Subject: [PATCH] DEP Upgrade to intervention/image 3 Also add strict typing to Image_Backend and InterventionBackend. --- _config/image.yml | 15 + composer.json | 2 +- .../InterventionImageFileConverter.php | 111 +---- src/ImageManipulation.php | 23 ++ src/Image_Backend.php | 104 +++-- src/InterventionBackend.php | 380 ++++++------------ src/Storage/AssetContainer.php | 5 + tests/php/GDImageTest.php | 5 +- tests/php/ImageTest.php | 2 +- tests/php/ImagickImageTest.php | 5 +- tests/php/Shortcodes/FileLinkTrackingTest.php | 26 +- .../testscript-test-file.jpg | Bin 0 -> 12349 bytes .../testscript-test-file.txt | 1 + 13 files changed, 247 insertions(+), 432 deletions(-) create mode 100644 tests/php/Shortcodes/FileLinkTrackingTest/testscript-test-file.jpg create mode 100644 tests/php/Shortcodes/FileLinkTrackingTest/testscript-test-file.txt diff --git a/_config/image.yml b/_config/image.yml index d792371a..35bac241 100644 --- a/_config/image.yml +++ b/_config/image.yml @@ -8,3 +8,18 @@ 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: + constructor: + driver: '%$InterventionImageDriver' + +--- +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/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..a1d8e6d0 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,73 @@ 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; + + /** + * Check if the image is animated (e.g. an animated GIF). + */ + 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..41faba06 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,115 @@ 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); + } + + $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 then pad out the canvas + // 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 getIsAnimated(): bool + { + return $this->getImageResource()?->isAnimated() ?? false; + } + + /** + * @inheritDoc + */ + public function removeAnimation(int|string $position): ?static + { + return $this->createCloneWithResource( + function (InterventionImage $resource) use ($position) { + return $resource->removeAnimation($position); } ); } @@ -705,9 +594,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 +631,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 +648,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 +681,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 +693,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 +706,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 +717,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/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..e54eddef 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(), 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 0000000000000000000000000000000000000000..ccc8346abac51837bfc7ee539e10920a93cb9cf3 GIT binary patch literal 12349 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 F{|EOE3lsnV literal 0 HcmV?d00001 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