Skip to content

Commit

Permalink
NEW File converter API
Browse files Browse the repository at this point in the history
  • Loading branch information
GuySartorelli committed Apr 10, 2024
1 parent 0cee400 commit 145e9a8
Show file tree
Hide file tree
Showing 14 changed files with 707 additions and 0 deletions.
6 changes: 6 additions & 0 deletions _config/conversion.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
Name: assetsconversion
---
SilverStripe\Assets\Conversion\FileConverterManager:
converters:
- 'SilverStripe\Assets\Conversion\InterventionImageFileConverter'
31 changes: 31 additions & 0 deletions src/Conversion/FileConverter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace SilverStripe\Assets\Conversion;

use SilverStripe\Assets\Storage\DBFile;

/**
* Interface providing the public API for file converters, so that FileConverterManager
* can find and use suitable converters.
*/
interface FileConverter
{
/**
* Checks whether this converter supports a conversion from one file type to another.
*
* @param string $fromExtension The file extension you want to convert from - e.g. "jpg".
* @param string $toExtension The file extension you want to convert to - e.g. "webp".
* @param array $options Any options defined for this converter which should apply to the conversion.
* Note that if the converter supports this conversion generally but doesn't support these options, this method will return `false`.
*/
public function supportsConversion(string $fromExtension, string $toExtension, array $options = []): bool;

/**
* Converts the given DBFile instance to another file type.
*
* @param string $toExtension The file extension you want to convert to - e.g. "webp".
* @param array $options Any options defined for this converter which should apply to the conversion.
* @throws FileConverterException if invalid options are passed, or the conversion is not supported or fails.
*/
public function convert(DBFile $from, string $toExtension, array $options = []): DBFile;
}
12 changes: 12 additions & 0 deletions src/Conversion/FileConverterException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace SilverStripe\Assets\Conversion;

use LogicException;

/**
* An exception that represents a failure to convert a file in a FileConverter class.
*/
class FileConverterException extends LogicException
{
}
42 changes: 42 additions & 0 deletions src/Conversion/FileConverterManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace SilverStripe\Assets\Conversion;

use SilverStripe\Assets\Storage\DBFile;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Injector\Injector;

/**
* This class holds a list of available file converters which it uses to convert files from one format to another.
*/
class FileConverterManager
{
use Configurable;

/**
* An array of classes or injector service names for
* classes that implement the FileConverter interface.
*/
private static array $converters = [];

/**
* Convert the file to the given format using the first available converter that can perform the conversion.
*
* @param string $toExtension The file extension you want to convert to - e.g. "webp".
* @param array $options Any options defined for this converter which should apply to the conversion.
* Note that if a converter supports the conversion generally but doesn't support these options, that converter will not be used.
* @throws FileConverterException if the conversion failed or there were no converters available.
*/
public function convert(DBFile $from, string $toExtension, array $options = []): DBFile
{
$fromExtension = $from->getExtension();
foreach (static::config()->get('converters') as $converterClass) {
/** @var FileConverter $converter */
$converter = Injector::inst()->get($converterClass);
if ($converter->supportsConversion($fromExtension, $toExtension, $options)) {
return $converter->convert($from, $toExtension, $options);
}
}
throw new FileConverterException("No file converter available to convert '$fromExtension' to '$toExtension'.");
}
}
182 changes: 182 additions & 0 deletions src/Conversion/InterventionImageFileConverter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
<?php

namespace SilverStripe\Assets\Conversion;

use Imagick;
use Intervention\Image\Exception\ImageException;
use SilverStripe\Assets\Image_Backend;
use SilverStripe\Assets\InterventionBackend;
use SilverStripe\Assets\Storage\AssetStore;
use SilverStripe\Assets\Storage\DBFile;
use SilverStripe\Core\Injector\Injector;

/**
* File converter powered by the Intervention Image library.
* Supports any file conversions that Intervention Image can perform.
*/
class InterventionImageFileConverter implements FileConverter
{
public function supportsConversion(string $fromExtension, string $toExtension, array $options = []): bool
{
$unsupportedOptions = $this->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($fromExtension, $backend) && $this->supportedByIntervention($toExtension, $backend);
}

public function convert(DBFile $from, string $toExtension, 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 FileConverterException('Invalid options provided: ' . implode(', ', $problems));
}
$originalBackend = $from->getImageBackend();
if (!is_a($originalBackend, InterventionBackend::class)) {
$actualClass = $originalBackend ? get_class($originalBackend) : 'null';
throw new FileConverterException("ImageBackend must be an instance of InterventionBackend. Got $actualClass");
}
if (!$this->supportedByIntervention($toExtension, $originalBackend)) {
throw new FileConverterException("Convertion to format '$toExtension' 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(
$toExtension,
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 FileConverterException('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 FileConverterException('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;
}
}
27 changes: 27 additions & 0 deletions src/ImageManipulation.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

use InvalidArgumentException;
use LogicException;
use Psr\Log\LoggerInterface;
use SilverStripe\Assets\Conversion\FileConverterException;
use SilverStripe\Assets\Conversion\FileConverterManager;
use SilverStripe\Assets\FilenameParsing\AbstractFileIDHelper;
use SilverStripe\Assets\Storage\AssetContainer;
use SilverStripe\Assets\Storage\AssetStore;
Expand Down Expand Up @@ -709,6 +712,30 @@ 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 $toExtension The file extension you want to convert to - e.g. "webp".
* @throws FileConverterException If the conversion fails and $returnNullOnFailure is false.
*/
public function Convert(string $toExtension): ?AssetContainer
{
$converter = Injector::inst()->get(FileConverterManager::class);
if ($this instanceof File) {
$from = $this->File;
} elseif ($this instanceof DBFile) {
$from = $this;
}
try {
return $converter->convert($from, $toExtension);
} catch (FileConverterException $e) {
/** @var LoggerInterface $logger */
$logger = Injector::inst()->get(LoggerInterface::class . '.errorhandler');
$logger->error($e->getMessage());
return null;
}
}

/**
* Return the relative URL of an icon for the file type,
* based on the {@link appCategory()} value.
Expand Down
Loading

0 comments on commit 145e9a8

Please sign in to comment.