diff --git a/_config/conversion.yml b/_config/conversion.yml new file mode 100644 index 00000000..56a14992 --- /dev/null +++ b/_config/conversion.yml @@ -0,0 +1,6 @@ +--- +Name: assetsconversion +--- +SilverStripe\Assets\Conversion\FileConversionManager: + converters: + - 'SilverStripe\Assets\Conversion\InterventionImageFileConverter' diff --git a/src/Conversion/FileConversionException.php b/src/Conversion/FileConversionException.php new file mode 100644 index 00000000..06e812c8 --- /dev/null +++ b/src/Conversion/FileConversionException.php @@ -0,0 +1,12 @@ +getExtension(); + foreach (static::config()->get('converters') as $converterClass) { + /** @var FileConverter $converter */ + $converter = Injector::inst()->get($converterClass); + if ($converter->supportsConversion($fromFormat, $toFormat, $options)) { + return $converter->convert($from, $toFormat, $options); + } + } + throw new FileConversionException("No file converter available to convert '$fromFormat' to '$toFormat'."); + } +} diff --git a/src/Conversion/FileConverter.php b/src/Conversion/FileConverter.php new file mode 100644 index 00000000..b7e470a4 --- /dev/null +++ b/src/Conversion/FileConverter.php @@ -0,0 +1,31 @@ +validateOptions($options); + if (!empty($unsupportedOptions)) { + return false; + } + // This converter requires intervention image as the image backend + $backend = Injector::inst()->get(Image_Backend::class); + if (!is_a($backend, InterventionBackend::class)) { + return false; + } + return $this->supportedByIntervention($fromFormat, $backend) && $this->supportedByIntervention($toFormat, $backend); + } + + public function convert(DBFile $from, string $toFormat, array $options = []): DBFile + { + // Do some basic validation up front for things we know aren't supported + $problems = $this->validateOptions($options); + if (!empty($problems)) { + throw new FileConversionException('Invalid options provided: ' . implode(', ', $problems)); + } + $originalBackend = $from->getImageBackend(); + if (!is_a($originalBackend, InterventionBackend::class)) { + $actualClass = $originalBackend ? get_class($originalBackend) : 'null'; + throw new FileConversionException("ImageBackend must be an instance of InterventionBackend. Got $actualClass"); + } + if (!$this->supportedByIntervention($toFormat, $originalBackend)) { + throw new FileConversionException("Convertion to format '$toFormat' is not suported."); + } + + $quality = $options['quality'] ?? null; + // Clone the backend if we're changing quality to avoid affecting other manipulations to that original image + $backend = $quality === null ? $originalBackend : clone $originalBackend; + // Pass through to invervention image to do the conversion for us. + try { + $result = $from->manipulateExtension( + $toFormat, + function (AssetStore $store, string $filename, string $hash, string $variant) use ($backend, $quality) { + if ($quality !== null) { + $backend->setQuality($quality); + } + $config = ['conflict' => AssetStore::CONFLICT_USE_EXISTING]; + $tuple = $backend->writeToStore($store, $filename, $hash, $variant, $config); + return [$tuple, $backend]; + } + ); + } catch (ImageException $e) { + throw new FileConversionException('Failed to convert: ' . $e->getMessage(), $e->getCode(), $e); + } + // This is very unlikely but the API for `manipulateExtension()` allows for it + if ($result === null) { + throw new FileConversionException('File conversion resulted in null. Check whether original file actually exists.'); + } + return $result; + } + + private function validateOptions(array $options): array + { + $problems = []; + foreach ($options as $key => $value) { + if ($key !== 'quality') { + $problems[] = "unexpected option '$key'"; + continue; + } + if (!is_int($value)) { + $problems[] = "quality value must be an integer"; + } + } + return $problems; + } + + private function supportedByIntervention(string $format, InterventionBackend $backend): bool + { + $driver = $backend->getImageManager()->config['driver'] ?? null; + // If the driver is somehow not GD or Imagick, we have no way to know what it might support + if ($driver !== 'gd' && $driver !== 'imagick') { + return false; + } + + // Return early for empty values - we obviously can't support that + if ($format === '') { + return false; + } + + // 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 (strtolower($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 b6c0cd82..fa6d59c6 100644 --- a/src/ImageManipulation.php +++ b/src/ImageManipulation.php @@ -4,6 +4,8 @@ use InvalidArgumentException; use LogicException; +use SilverStripe\Assets\Conversion\FileConversionException; +use SilverStripe\Assets\Conversion\FileConversionManager; use SilverStripe\Assets\FilenameParsing\AbstractFileIDHelper; use SilverStripe\Assets\Storage\AssetContainer; use SilverStripe\Assets\Storage\AssetStore; @@ -709,6 +711,33 @@ public function ThumbnailURL($width, $height) return $this->getIcon(); } + /** + * Convert the file to another format if there's a registered converter that can handle it. + * + * @param string $toFormat The file extension you want to convert to - e.g. "webp". + * @param bool $returnNullOnFailure If true and the conversion fails, `null` is returned instead. + * This is useful if calling this method from a template, for example, where you don't want to be throwing + * exceptions for end users. + * @throws FileConversionException If the conversion fails and $returnNullOnFailure is false. + */ + public function convert(string $toFormat, bool $returnNullOnFailure = false): ?AssetContainer + { + $converter = Injector::inst()->get(FileConversionManager::class); + if ($this instanceof File) { + $from = $this->File; + } elseif ($this instanceof DBFile) { + $from = $this; + } + try { + return $converter->convert($from, $toFormat); + } catch (FileConversionException $e) { + if ($returnNullOnFailure) { + return null; + } + throw $e; + } + } + /** * Return the relative URL of an icon for the file type, * based on the {@link appCategory()} value. diff --git a/tests/php/Conversion/FileConversionManagerTest.php b/tests/php/Conversion/FileConversionManagerTest.php new file mode 100644 index 00000000..81e07323 --- /dev/null +++ b/tests/php/Conversion/FileConversionManagerTest.php @@ -0,0 +1,89 @@ + null, + 'jpg' => null, + ]; + + protected function setUp(): void + { + parent::setUp(); + // Make sure we have a known set of converters for testing + FileConversionManager::config()->set('converters', [ + 'some-service-name', + TestImageConverter::class, + ]); + Injector::inst()->registerService(new TestTxtToImageConverter(), 'some-service-name'); + + // Set backend root to /InterventionImageFileConverterTest + TestAssetStore::activate('InterventionImageFileConverterTest'); + foreach (array_keys($this->originalFiles) as $ext) { + $file = new DBFile('original-file.' . $ext); + $fileToUse = $ext === 'txt' ? 'not-image.txt' : 'test-image.jpg'; + $sourcePath = __DIR__ . '/InterventionImageFileConverterTest/' . $fileToUse; + $file->setFromLocalFile($sourcePath, $file->Filename); + $this->originalFiles[$ext] = $file; + } + } + + public function provideConvert(): array + { + return [ + 'supported by image converter' => [ + 'fromFormat' => 'jpg', + 'toFormat' => 'png', + 'expectSuccess' => true, + ], + 'supported by txt converter' => [ + 'fromFormat' => 'txt', + 'toFormat' => 'png', + 'expectSuccess' => true, + ], + 'unsupported 1' => [ + 'fromFormat' => 'jpg', + 'toFormat' => 'txt', + 'expectSuccess' => false, + ], + 'unsupported 2' => [ + 'fromFormat' => 'txt', + 'toFormat' => 'doc', + 'expectSuccess' => false, + ], + ]; + } + + /** + * @dataProvider provideConvert + */ + public function testConvert(string $fromFormat, string $toFormat, bool $expectSuccess): void + { + $manager = new FileConversionManager(); + + if (!$expectSuccess) { + $this->expectException(FileConversionException::class); + $this->expectExceptionMessage("No file converter available to convert '$fromFormat' to '$toFormat'."); + } + + $origFile = $this->originalFiles[$fromFormat]; + $origName = $origFile->Filename; + $result = $manager->convert($origFile, $toFormat); + + $this->assertSame('converted.' . $toFormat, $result->Filename); + $this->assertSame($origName, $origFile->Filename); + } +} diff --git a/tests/php/Conversion/FileConversionManagerTest/TestImageConverter.php b/tests/php/Conversion/FileConversionManagerTest/TestImageConverter.php new file mode 100644 index 00000000..f175a207 --- /dev/null +++ b/tests/php/Conversion/FileConversionManagerTest/TestImageConverter.php @@ -0,0 +1,24 @@ +Filename = 'converted.' . $toFormat; + return $result; + } +} diff --git a/tests/php/Conversion/FileConversionManagerTest/TestTxtToImageConverter.php b/tests/php/Conversion/FileConversionManagerTest/TestTxtToImageConverter.php new file mode 100644 index 00000000..4bebcf23 --- /dev/null +++ b/tests/php/Conversion/FileConversionManagerTest/TestTxtToImageConverter.php @@ -0,0 +1,24 @@ +Filename = 'converted.' . $toFormat; + return $result; + } +} diff --git a/tests/php/Conversion/InterventionImageFileConverterTest.php b/tests/php/Conversion/InterventionImageFileConverterTest.php new file mode 100644 index 00000000..15ce7c66 --- /dev/null +++ b/tests/php/Conversion/InterventionImageFileConverterTest.php @@ -0,0 +1,197 @@ +exclude('ClassName', Folder::class); + foreach ($files as $file) { + if ($file->Name === 'test-missing-image.jpg') { + continue; + } + $sourcePath = __DIR__ . '/InterventionImageFileConverterTest/' . $file->Name; + $file->setFromLocalFile($sourcePath, $file->Filename); + $file->publishSingle(); + } + } + + public function provideSupportsConversion(): array + { + // We don't need to check every possible file type here. + // We're just validating that the logic overall holds true. + return [ + 'nothing to convert' => [ + 'from' => '', + 'to' => '', + 'options' => [], + 'expected' => false, + ], + 'nothing to convert from' => [ + 'from' => '', + 'to' => 'png', + 'options' => [], + 'expected' => false, + ], + 'nothing to convert to' => [ + 'from' => 'png', + 'to' => '', + 'options' => [], + 'expected' => false, + ], + 'jpg to jpg' => [ + 'from' => 'jpg', + 'to' => 'jpg', + 'options' => [], + 'expected' => true, + ], + 'jpg to png' => [ + 'from' => 'jpg', + 'to' => 'png', + 'options' => [], + 'expected' => true, + ], + 'jpg to png with quality option' => [ + 'from' => 'jpg', + 'to' => 'png', + 'options' => ['quality' => 100], + 'expected' => true, + ], + 'jpg to png with invalid quality option' => [ + 'from' => 'jpg', + 'to' => 'png', + 'options' => ['quality' => 'invalid'], + 'expected' => false, + ], + 'jpg to png with unexpected option' => [ + 'from' => 'jpg', + 'to' => 'png', + 'options' => ['what is this' => 100], + 'expected' => false, + ], + ]; + } + + /** + * @dataProvider provideSupportsConversion + */ + public function testSupportsConversion(string $from, string $to, array $options, bool $expected): void + { + $converter = new InterventionImageFileConverter(); + $this->assertSame($expected, $converter->supportsConversion($from, $to, $options)); + } + + public function provideConvert(): array + { + return [ + 'no options' => [ + 'options' => [], + ], + 'change quality' => [ + 'options' => ['quality' => 5], + ], + ]; + } + + /** + * @dataProvider provideConvert + */ + public function testConvert(array $options): void + { + $origFile = $this->objFromFixture(Image::class, 'jpg-image'); + $origQuality = $origFile->getImageBackend()->getQuality(); + $converter = new InterventionImageFileConverter(); + // Do a conversion we know is supported by both GD and Imagick + $pngFile = $converter->convert($origFile->File, 'png', $options); + + // Validate new file has correct format, but original file is untouched + $finfo = new \finfo(FILEINFO_MIME_TYPE); + $this->assertSame('image/png', $finfo->buffer($pngFile->getString())); + $this->assertSame('image/jpeg', $finfo->buffer($origFile->getString())); + + if (array_key_exists('quality', $options)) { + $this->assertSame($options['quality'], $pngFile->getImageBackend()->getQuality()); + $this->assertSame($origQuality, $origFile->getImageBackend()->getQuality()); + } + } + + public function provideConvertUnsupported(): array + { + return [ + 'nothing to convert from' => [ + 'fixtureClass' => Image::class, + 'fromFixture' => 'missing-image', + 'to' => 'png', + 'options' => [], + 'exceptionMessage' => 'ImageBackend must be an instance of InterventionBackend. Got null', + ], + 'nothing to convert to' => [ + 'fixtureClass' => Image::class, + 'fromFixture' => 'jpg-image', + 'to' => '', + 'options' => [], + 'exceptionMessage' => 'Convertion to format \'\' is not suported.', + ], + 'jpg to txt' => [ + 'fixtureClass' => Image::class, + 'fromFixture' => 'jpg-image', + 'to' => 'txt', + 'options' => [], + 'exceptionMessage' => 'Convertion to format \'txt\' is not suported.', + ], + 'txt to jpg' => [ + 'fixtureClass' => File::class, + 'fromFixture' => 'not-image', + 'to' => 'jpg', + 'options' => [], + 'exceptionMessage' => 'ImageBackend must be an instance of InterventionBackend. Got null', + ], + 'jpg to png with invalid quality option' => [ + 'fixtureClass' => Image::class, + 'fromFixture' => 'jpg-image', + 'to' => 'png', + 'options' => ['quality' => 'invalid'], + 'exceptionMessage' => 'Invalid options provided: quality value must be an integer', + ], + 'jpg to png with unexpected option' => [ + 'fixtureClass' => Image::class, + 'fromFixture' => 'jpg-image', + 'to' => 'png', + 'options' => ['what is this' => 100], + 'exceptionMessage' => 'Invalid options provided: unexpected option \'what is this\'', + ], + ]; + } + + /** + * @dataProvider provideConvertUnsupported + */ + public function testConvertUnsupported(string $fixtureClass, string $fromFixture, string $to, array $options, string $exceptionMessage): void + { + $file = $this->objFromFixture($fixtureClass, $fromFixture); + $converter = new InterventionImageFileConverter(); + + $this->expectException(FileConversionException::class); + $this->expectExceptionMessage($exceptionMessage); + + $converter->convert($file->File, $to, $options); + } +} diff --git a/tests/php/Conversion/InterventionImageFileConverterTest.yml b/tests/php/Conversion/InterventionImageFileConverterTest.yml new file mode 100644 index 00000000..b9da3342 --- /dev/null +++ b/tests/php/Conversion/InterventionImageFileConverterTest.yml @@ -0,0 +1,24 @@ +SilverStripe\Assets\Folder: + folder1: + Name: folder + +SilverStripe\Assets\Image: + jpg-image: + Title: This is a low quality JPEG + FileFilename: folder/test-image.jpg + FileHash: 33be1b95cba0358fe54e8b13532162d52f97421c + Parent: =>SilverStripe\Assets\Folder.folder1 + Name: test-image.jpg + missing-image: + Title: This is a missing image + FileFilename: folder/test-missing-image.jpg + Parent: =>SilverStripe\Assets\Folder.folder1 + Name: test-missing-image.jpg + +SilverStripe\Assets\File: + not-image: + Title: This is not an image + FileFilename: folder/not-image.txt + FileHash: 6ab0df7d967f44e98d4bfa403020c6921a2b46e7 + Parent: =>SilverStripe\Assets\Folder.folder1 + Name: not-image.txt diff --git a/tests/php/Conversion/InterventionImageFileConverterTest/not-image.txt b/tests/php/Conversion/InterventionImageFileConverterTest/not-image.txt new file mode 100644 index 00000000..3479a99c --- /dev/null +++ b/tests/php/Conversion/InterventionImageFileConverterTest/not-image.txt @@ -0,0 +1 @@ +Some non image content diff --git a/tests/php/Conversion/InterventionImageFileConverterTest/test-image.jpg b/tests/php/Conversion/InterventionImageFileConverterTest/test-image.jpg new file mode 100644 index 00000000..ccc8346a Binary files /dev/null and b/tests/php/Conversion/InterventionImageFileConverterTest/test-image.jpg differ diff --git a/tests/php/ImageManipulationTest.php b/tests/php/ImageManipulationTest.php index 314ae1f2..76360f53 100644 --- a/tests/php/ImageManipulationTest.php +++ b/tests/php/ImageManipulationTest.php @@ -3,6 +3,8 @@ namespace SilverStripe\Assets\Tests; use Prophecy\Prophecy\ObjectProphecy; +use SilverStripe\Assets\Conversion\FileConversionException; +use SilverStripe\Assets\Conversion\FileConversionManager; use Silverstripe\Assets\Dev\TestAssetStore; use SilverStripe\Assets\File; use SilverStripe\Assets\FilenameParsing\AbstractFileIDHelper; @@ -12,6 +14,7 @@ use SilverStripe\Assets\InterventionBackend; use SilverStripe\Assets\Storage\AssetStore; use SilverStripe\Assets\Storage\DBFile; +use SilverStripe\Assets\Tests\Conversion\FileConversionManagerTest\TestTxtToImageConverter; use SilverStripe\Assets\Tests\ImageManipulationTest\LazyLoadAccessorExtension; use SilverStripe\Core\Config\Config; use SilverStripe\Core\Injector\Injector; @@ -556,4 +559,55 @@ function (AssetStore $store, string $filename, string $hash, string $variant) { $this->assertTrue($store->exists($manipulated->getFilename(), $manipulated->getHash(), $manipulated->getVariant())); $this->assertSame('Any content will do - csv is just a text file afterall', $manipulated->getString()); } + + public function provideConvert(): array + { + return [ + 'supported conversion' => [ + 'originalFileFixtureClass' => File::class, + 'originalFileFixture' => 'notImage', + 'toFormat' => 'jpg', + 'returnNullOnFailure' => false, + 'success' => true, + ], + 'supported conversion exception' => [ + 'originalFileFixtureClass' => File::class, + 'originalFileFixture' => 'notImage', + 'toFormat' => 'pdf', + 'returnNullOnFailure' => false, + 'success' => false, + ], + 'supported conversion null' => [ + 'originalFileFixtureClass' => File::class, + 'originalFileFixture' => 'notImage', + 'toFormat' => 'pdf', + 'returnNullOnFailure' => true, + 'success' => false, + ], + ]; + } + + /** + * @dataProvider provideConvert + */ + public function testConvert(string $originalFileFixtureClass, string $originalFileFixture, string $toFormat, bool $returnNullOnFailure, bool $success): void + { + // Make sure we have a known set of converters for testing + FileConversionManager::config()->set('converters', [TestTxtToImageConverter::class]); + /** @var File $file */ + $file = $this->objFromFixture($originalFileFixtureClass, $originalFileFixture); + + if (!$success && !$returnNullOnFailure) { + $this->expectException(FileConversionException::class); + $this->expectExceptionMessage("No file converter available to convert 'txt' to '$toFormat'."); + } + + $result = $file->convert($toFormat, $returnNullOnFailure); + + if ($success) { + $this->assertSame('converted.' . $toFormat, $result->Filename); + } else { + $this->assertNull($result); + } + } }