diff --git a/core/core.services.yml b/core/core.services.yml index 238a169..fe12663 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -722,7 +722,10 @@ services: - { name: event_subscriber } image.toolkit.manager: class: Drupal\Core\ImageToolkit\ImageToolkitManager - arguments: ['@container.namespaces', '@cache.discovery', '@config.factory', '@module_handler'] + arguments: ['@container.namespaces', '@cache.discovery', '@config.factory', '@module_handler', '@image.toolkit.operation.manager'] + image.toolkit.operation.manager: + class: Drupal\Core\ImageToolkit\ImageToolkitOperationManager + parent: default_plugin_manager image.factory: class: Drupal\Core\Image\ImageFactory arguments: ['@image.toolkit.manager'] diff --git a/core/lib/Drupal/Core/Image/Image.php b/core/lib/Drupal/Core/Image/Image.php index 8d1ce9b..8c8aea2 100644 --- a/core/lib/Drupal/Core/Image/Image.php +++ b/core/lib/Drupal/Core/Image/Image.php @@ -58,7 +58,7 @@ class Image implements ImageInterface { */ public function __construct(ImageToolkitInterface $toolkit, $source = NULL) { $this->toolkit = $toolkit; - $this->toolkit->setImage($this); + $this->getToolkit()->setImage($this); if ($source) { $this->source = $source; $this->parseFile(); @@ -76,14 +76,14 @@ public function isValid() { * {@inheritdoc} */ public function getHeight() { - return $this->toolkit->getHeight(); + return $this->getToolkit()->getHeight(); } /** * {@inheritdoc} */ public function getWidth() { - return $this->toolkit->getWidth(); + return $this->getToolkit()->getWidth(); } /** @@ -97,7 +97,7 @@ public function getFileSize() { * {@inheritdoc} */ public function getMimeType() { - return $this->toolkit->getMimeType(); + return $this->getToolkit()->getMimeType(); } /** @@ -111,7 +111,7 @@ public function getSource() { * {@inheritdoc} */ public function getToolkitId() { - return $this->toolkit->getPluginId(); + return $this->getToolkit()->getPluginId(); } /** @@ -131,7 +131,7 @@ public function save($destination = NULL) { } $destination = $destination ?: $this->getSource(); - if ($return = $this->toolkit->save($destination)) { + if ($return = $this->getToolkit()->save($destination)) { // Clear the cached file size and refresh the image information. clearstatcache(TRUE, $destination); $this->fileSize = filesize($destination); @@ -157,48 +157,59 @@ public function save($destination = NULL) { * image information is populated. */ protected function parseFile() { - if ($this->valid = $this->toolkit->parseFile()) { + if ($this->valid = $this->getToolkit()->parseFile()) { $this->fileSize = filesize($this->source); } return $this->valid; } /** - * Passes through calls that represent image toolkit operations onto the - * image toolkit. - * - * This is a temporary solution to keep patches reviewable. The __call() - * method will be replaced in https://drupal.org/node/2110499 with a new - * interface method ImageInterface::apply(). An image operation will be - * performed as in the next example: - * @code - * $image = new Image($toolkit, $path); - * $image->apply('scale', array('width' => 50, 'height' => 100)); - * @endcode - * Also in https://drupal.org/node/2110499 operation arguments sent to toolkit - * will be moved to a keyed array, unifying the interface of toolkit - * operations. - * - * @todo Drop this in https://drupal.org/node/2110499 in favor of new apply(). - */ - public function __call($method, $arguments) { - // @todo Temporary to avoid that legacy GD setResource(), getResource(), - // hasResource() methods moved to GD toolkit in #2103621, setWidth(), - // setHeight() methods moved to ImageToolkitInterface in #2196067, - // getType() method moved to GDToolkit in #2211227 get - // invoked from this class anyway through the magic __call. Will be - // removed through https://drupal.org/node/2073759, when - // call_user_func_array() will be replaced by - // $this->toolkit->apply($name, $this, $arguments). - if (in_array($method, array('setResource', 'getResource', 'hasResource', 'setWidth', 'setHeight', 'getType', 'setImage'))) { - throw new \BadMethodCallException($method); - } - if (is_callable(array($this->toolkit, $method))) { - // @todo In https://drupal.org/node/2073759, call_user_func_array() will - // be replaced by $this->toolkit->apply($name, $arguments). - return call_user_func_array(array($this->toolkit, $method), $arguments); - } - throw new \BadMethodCallException($method); + * {@inheritdoc} + */ + public function apply($operation, array $arguments = array()) { + return $this->getToolkit()->apply($operation, $arguments); + } + + /** + * {@inheritdoc} + */ + public function crop($x, $y, $width, $height = NULL) { + return $this->apply('crop', array('x' => $x, 'y' => $y, 'width' => $width, 'height' => $height)); + } + + /** + * {@inheritdoc} + */ + public function desaturate() { + return $this->apply('desaturate', array()); + } + + /** + * {@inheritdoc} + */ + public function resize($width, $height) { + return $this->apply('resize', array('width' => $width, 'height' => $height)); + } + + /** + * {@inheritdoc} + */ + public function rotate($degrees, $background = NULL) { + return $this->apply('rotate', array('degrees' => $degrees, 'background' => $background)); + } + + /** + * {@inheritdoc} + */ + public function scaleAndCrop($width, $height) { + return $this->apply('scale_and_crop', array('width' => $width, 'height' => $height)); + } + + /** + * {@inheritdoc} + */ + public function scale($width, $height = NULL, $upscale = FALSE) { + return $this->apply('scale', array('width' => $width, 'height' => $height, 'upscale' => $upscale)); } /** diff --git a/core/lib/Drupal/Core/Image/ImageInterface.php b/core/lib/Drupal/Core/Image/ImageInterface.php index af1b31c..02db478 100644 --- a/core/lib/Drupal/Core/Image/ImageInterface.php +++ b/core/lib/Drupal/Core/Image/ImageInterface.php @@ -79,6 +79,23 @@ public function getToolkit(); public function getToolkitId(); /** + * Applies a toolkit operation to the image. + * + * The operation is deferred to the active toolkit. + * + * @param string $operation + * The operation to be performed against the image. + * @param array $arguments + * An associative array of arguments to be passed to the toolkit + * operation, e.g. array('width' => 50, 'height' => 100, + * 'upscale' => TRUE). + * + * @return bool + * TRUE on success, FALSE on failure. + */ + public function apply($operation, array $arguments = array()); + + /** * Closes the image and saves the changes to a file. * * @param string|null $destination @@ -92,4 +109,97 @@ public function getToolkitId(); */ public function save($destination = NULL); + /** + * Scales an image while maintaining aspect ratio. + * + * The resulting image can be smaller for one or both target dimensions. + * + * @param int|null $width + * The target width, in pixels. If this value is null then the scaling will + * be based only on the height value. + * @param int|null $height + * (optional) The target height, in pixels. If this value is null then the + * scaling will be based only on the width value. + * @param bool $upscale + * (optional) Boolean indicating that files smaller than the dimensions will + * be scaled up. This generally results in a low quality image. + * + * @return bool + * TRUE on success, FALSE on failure. + */ + public function scale($width, $height = NULL, $upscale = FALSE); + + /** + * Scales an image to the exact width and height given. + * + * This function achieves the target aspect ratio by cropping the original + * image equally on both sides, or equally on the top and bottom. This + * function is useful to create uniform sized avatars from larger images. + * + * The resulting image always has the exact target dimensions. + * + * @param int $width + * The target width, in pixels. + * @param int $height + * The target height, in pixels. + * + * @return bool + * TRUE on success, FALSE on failure. + */ + public function scaleAndCrop($width, $height); + + /** + * Crops an image to a rectangle specified by the given dimensions. + * + * @param int $x + * The top left coordinate, in pixels, of the crop area (x axis value). + * @param int $y + * The top left coordinate, in pixels, of the crop area (y axis value). + * @param int $width + * The target width, in pixels. + * @param int $height + * The target height, in pixels. + * + * @return bool + * TRUE on success, FALSE on failure. + */ + public function crop($x, $y, $width, $height = NULL); + + /** + * Resizes an image to the given dimensions (ignoring aspect ratio). + * + * @param int $width + * The target width, in pixels. + * @param int $height + * The target height, in pixels. + * + * @return bool + * TRUE on success, FALSE on failure. + */ + public function resize($width, $height); + + /** + * Converts an image to grayscale. + * + * @return bool + * TRUE on success, FALSE on failure. + */ + public function desaturate(); + + /** + * Rotates an image by the given number of degrees. + * + * @param float $degrees + * The number of (clockwise) degrees to rotate the image. + * @param string|null $background + * (optional) An hexadecimal integer specifying the background color to use + * for the uncovered area of the image after the rotation. E.g. 0x000000 for + * black, 0xff00ff for magenta, and 0xffffff for white. For images that + * support transparency, this will default to transparent. Otherwise it will + * be white. + * + * @return bool + * TRUE on success, FALSE on failure. + */ + public function rotate($degrees, $background = NULL); } diff --git a/core/lib/Drupal/Core/ImageToolkit/Annotation/ImageToolkitOperation.php b/core/lib/Drupal/Core/ImageToolkit/Annotation/ImageToolkitOperation.php new file mode 100644 index 0000000..59e9fb0 --- /dev/null +++ b/core/lib/Drupal/Core/ImageToolkit/Annotation/ImageToolkitOperation.php @@ -0,0 +1,76 @@ +operationManager = $operation_manager; + } + + /** * {@inheritdoc} */ public function setImage(ImageInterface $image) { if ($this->image) { - throw new \BadMethodCallException(__METHOD__ . '() may only be called once.'); + throw new \BadMethodCallException(__METHOD__ . '() may only be called once'); } $this->image = $image; } @@ -43,4 +68,35 @@ public function getRequirements() { return array(); } + /** + * Gets a toolkit operation plugin instance. + * + * @param string $operation + * The toolkit operation requested. + * + * @return \Drupal\Core\ImageToolkit\ImageToolkitOperationInterface + * An instance of the requested toolkit operation plugin. + */ + protected function getToolkitOperation($operation) { + return $this->operationManager->getToolkitOperation($this, $operation); + } + + /** + * {@inheritdoc} + */ + public function apply($operation, array $arguments = array()) { + try { + // Get the plugin to use for the operation and apply the operation. + return $this->getToolkitOperation($operation)->apply($arguments); + } + catch (PluginNotFoundException $e) { + \Drupal::logger('image')->error("The selected image handling toolkit '@toolkit' can not process operation '@operation'.", array('@toolkit' => $this->getPluginId(), '@operation' => $operation)); + return FALSE; + } + catch (\InvalidArgumentException $e) { + \Drupal::logger('image')->warning($e->getMessage(), array()); + return FALSE; + } + } + } diff --git a/core/lib/Drupal/Core/ImageToolkit/ImageToolkitInterface.php b/core/lib/Drupal/Core/ImageToolkit/ImageToolkitInterface.php index 568bcc8..30d424e 100644 --- a/core/lib/Drupal/Core/ImageToolkit/ImageToolkitInterface.php +++ b/core/lib/Drupal/Core/ImageToolkit/ImageToolkitInterface.php @@ -62,11 +62,11 @@ public function settingsFormSubmit($form, &$form_state); /** * Sets the image object that this toolkit instance is tied to. * - * @throws \BadMethodCallException - * When called twice. - * * @param \Drupal\Core\Image\ImageInterface $image * The image that this toolkit instance will be tied to. + * + * @throws \BadMethodCallException + * When called twice. */ public function setImage(ImageInterface $image); @@ -79,65 +79,6 @@ public function setImage(ImageInterface $image); public function getImage(); /** - * Scales an image to the specified size. - * - * @param int $width - * The new width of the resized image, in pixels. - * @param int $height - * The new height of the resized image, in pixels. - * - * @return bool - * TRUE on success, FALSE on failure. - */ - public function resize($width, $height); - - /** - * Rotates an image the given number of degrees. - * - * @param int $degrees - * The number of (clockwise) degrees to rotate the image. - * @param string $background - * (optional) An hexadecimal integer specifying the background color to use - * for the uncovered area of the image after the rotation. E.g. 0x000000 for - * black, 0xff00ff for magenta, and 0xffffff for white. For images that - * support transparency, this will default to transparent. Otherwise it will - * be white. - * - * @return bool - * TRUE on success, FALSE on failure. - */ - public function rotate($degrees, $background = NULL); - - /** - * Crops an image. - * - * @param int $x - * The starting x offset at which to start the crop, in pixels. - * @param int $y - * The starting y offset at which to start the crop, in pixels. - * @param int $width - * The width of the cropped area, in pixels. - * @param int $height - * The height of the cropped area, in pixels. - * - * @return bool - * TRUE on success, FALSE on failure. - * - * @see image_crop() - */ - public function crop($x, $y, $width, $height); - - /** - * Converts an image resource to grayscale. - * - * Note that transparent GIFs loose transparency when desaturated. - * - * @return bool - * TRUE on success, FALSE on failure. - */ - public function desaturate(); - - /** * Writes an image resource to a destination file. * * @param string $destination @@ -149,45 +90,6 @@ public function desaturate(); public function save($destination); /** - * Scales an image while maintaining aspect ratio. - * - * The resulting image can be smaller for one or both target dimensions. - * - * @param int $width - * (optional) The target width, in pixels. This value is omitted then the - * scaling will based only on the height value. - * @param int $height - * (optional) The target height, in pixels. This value is omitted then the - * scaling will based only on the width value. - * @param bool $upscale - * (optional) Boolean indicating that files smaller than the dimensions will - * be scaled up. This generally results in a low quality image. - * - * @return bool - * TRUE on success, FALSE on failure. - */ - public function scale($width = NULL, $height = NULL, $upscale = FALSE); - - /** - * Scales an image to the exact width and height given. - * - * This function achieves the target aspect ratio by cropping the original - * image equally on both sides, or equally on the top and bottom. This - * function is useful to create uniform sized avatars from larger images. - * - * The resulting image always has the exact target dimensions. - * - * @param int $width - * The target width, in pixels. - * @param int $height - * The target height, in pixels. - * - * @return bool - * TRUE on success, FALSE on failure. - */ - public function scaleAndCrop($width, $height); - - /** * Determines if a file contains a valid image. * * @return bool @@ -250,4 +152,19 @@ public static function isAvailable(); */ public static function getSupportedExtensions(); + /** + * Applies a toolkit operation to an image. + * + * @param string $operation + * The toolkit operation to be processed. + * @param array $arguments + * An associative array of arguments to be passed to the toolkit + * operation, e.g. array('width' => 50, 'height' => 100, + * 'upscale' => TRUE). + * + * @return bool + * TRUE if the operation was performed successfully, FALSE otherwise. + */ + public function apply($operation, array $arguments = array()); + } diff --git a/core/lib/Drupal/Core/ImageToolkit/ImageToolkitManager.php b/core/lib/Drupal/Core/ImageToolkit/ImageToolkitManager.php index a780630..789ef5a 100644 --- a/core/lib/Drupal/Core/ImageToolkit/ImageToolkitManager.php +++ b/core/lib/Drupal/Core/ImageToolkit/ImageToolkitManager.php @@ -11,6 +11,7 @@ use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Plugin\DefaultPluginManager; +use Drupal\Component\Plugin\Factory\DefaultFactory; /** * Manages toolkit plugins. @@ -25,6 +26,13 @@ class ImageToolkitManager extends DefaultPluginManager { protected $configFactory; /** + * The image toolkit operation manager. + * + * @var \Drupal\Core\ImageToolkit\ImageToolkitOperationManagerInterface + */ + protected $operationManager; + + /** * Constructs the ImageToolkitManager object. * * @param \Traversable $namespaces @@ -36,12 +44,15 @@ class ImageToolkitManager extends DefaultPluginManager { * The config factory. * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler * The module handler. + * @param \Drupal\Core\ImageToolkit\ImageToolkitOperationManagerInterface $operation_manager + * The toolkit operation manager. */ - public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ConfigFactoryInterface $config_factory, ModuleHandlerInterface $module_handler) { + public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ConfigFactoryInterface $config_factory, ModuleHandlerInterface $module_handler, ImageToolkitOperationManagerInterface $operation_manager) { parent::__construct('Plugin/ImageToolkit', $namespaces, $module_handler, 'Drupal\Core\ImageToolkit\Annotation\ImageToolkit'); $this->setCacheBackend($cache_backend, 'image_toolkit_plugins'); $this->configFactory = $config_factory; + $this->operationManager = $operation_manager; } /** @@ -97,4 +108,14 @@ public function getAvailableToolkits() { return $output; } + + /** + * {@inheritdoc} + */ + public function createInstance($plugin_id, array $configuration = array()) { + $plugin_definition = $this->getDefinition($plugin_id); + $plugin_class = DefaultFactory::getPluginClass($plugin_id, $plugin_definition); + return new $plugin_class($configuration, $plugin_id, $plugin_definition, $this->operationManager); + } + } diff --git a/core/lib/Drupal/Core/ImageToolkit/ImageToolkitOperationBase.php b/core/lib/Drupal/Core/ImageToolkit/ImageToolkitOperationBase.php new file mode 100644 index 0000000..6b66c29 --- /dev/null +++ b/core/lib/Drupal/Core/ImageToolkit/ImageToolkitOperationBase.php @@ -0,0 +1,178 @@ +toolkit = $toolkit; + } + + /** + * Returns the image toolkit instance for this operation. + * + * Image toolkit implementers should provide a trait that overrides this + * method to correctly document the return type of this getter. This provides + * better DX (code checking and code completion) for image toolkit operation + * developers. + * + * @return \Drupal\Core\ImageToolkit\ImageToolkitInterface + */ + protected function getToolkit() { + return $this->toolkit; + } + + /** + * Returns the definition of the operation arguments. + * + * Image toolkit operation implementers must implement this method to + * "document" their operation, thus also if no arguments are expected. + * + * @return array + * An array whose keys are the names of the arguments (e.g. "width", + * "degrees") and each value is an associative array having the following + * keys: + * - description: A string with the argument description. This is used only + * internally for documentation purposes, so it does not need to be + * translatable. + * - required: (optional) A boolean indicating if this argument should be + * provided or not. Defaults to TRUE. + * - default: (optional) When the argument is set to "required" = FALSE, + * this must be set to a default value. Ignored for "required" = TRUE + * arguments. + */ + abstract protected function arguments(); + + /** + * Checks if required arguments are passed in and adds defaults for non passed + * in optional arguments. + * + * Image toolkit operation implementers should not normally need to override + * this method as they should place their own validation in validateArguments. + * + * @param array $arguments + * An associative array of arguments to be used by the toolkit operation. + * + * @return array + * The prepared arguments array. + * + * @throws \InvalidArgumentException. + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException. + */ + protected function prepareArguments(array $arguments) { + foreach ($this->arguments() as $id => $argument) { + $argument += array('required' => TRUE); + // Check if the argument is required and, if so, has been provided. + if ($argument['required']) { + if (!array_key_exists($id, $arguments)) { + // If the argument is required throw an exception. + throw new \InvalidArgumentException(String::format("Argument '@argument' expected by plugin '@plugin' but not passed", array('@argument' => $id, '@plugin' => $this->getPluginId()))); + } + } + else { + // Optional arguments require a 'default' value. + // We check this even if the argument is provided by the caller, as we + // want to fail fast here, i.e. at development time. + if (!array_key_exists('default', $argument)) { + // The plugin did not define a default, so throw a plugin exception, + // not an invalid argument exception. + throw new InvalidPluginDefinitionException(String::format("Default for argument '@argument' expected by plugin '@plugin' but not defined", array('@argument' => $id, '@plugin' => $this->getPluginId()))); + } + + // Use the default value if the argument is not passed in. + if (!array_key_exists($id, $arguments)) { + $arguments[$id] = $argument['default']; + } + } + } + return $arguments; + } + + /** + * Validates the arguments. + * + * Image toolkit operation implementers should place any argument validation + * in this method, throwing an InvalidArgumentException when an error is + * encountered. + * + * Validation typically includes things like: + * - Checking that width and height are not negative. + * - Checking that a color value is indeed a color. + * + * But validation may also include correcting the arguments, e.g: + * - Casting arguments to the correct type. + * - Rounding pixel values to an integer. + * + * This base implementation just returns the array of arguments and thus does + * not need to be called by overriding methods. + * + * @param array $arguments + * An associative array of arguments to be used by the toolkit operation. + * + * @return array + * The validated and corrected arguments array. + * + * @throws \InvalidArgumentException + * If one or more of the arguments are not valid. + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * If the plugin does not define a default for an optional argument. + */ + protected function validateArguments(array $arguments) { + return $arguments; + } + + /** + * {@inheritdoc} + */ + public final function apply(array $arguments) { + $arguments = $this->prepareArguments($arguments); + $arguments = $this->validateArguments($arguments); + return $this->execute($arguments); + } + + /** + * Performs the actual manipulation on the image. + * + * Image toolkit operation implementers must implement this method. This + * method is responsible for actually performing the operation on the image. + * When this method gets called, the implementer may assume all arguments, + * also the optional ones, to be available, validated and corrected. + * + * @param array $arguments + * An associative array of arguments to be used by the toolkit operation. + * + * @return bool + * TRUE if the operation was performed successfully, FALSE otherwise. + */ + abstract protected function execute(array $arguments); + +} diff --git a/core/lib/Drupal/Core/ImageToolkit/ImageToolkitOperationInterface.php b/core/lib/Drupal/Core/ImageToolkit/ImageToolkitOperationInterface.php new file mode 100644 index 0000000..8cd9da3 --- /dev/null +++ b/core/lib/Drupal/Core/ImageToolkit/ImageToolkitOperationInterface.php @@ -0,0 +1,35 @@ +alterInfo('image_toolkit_operation'); + $this->setCacheBackend($cache_backend, 'image_toolkit_operation_plugins'); + } + + /** + * Returns the plugin ID for a given toolkit and operation. + * + * @param string $toolkit_id + * The toolkit plugin ID. + * @param string $operation + * The operation (e.g. "crop"). + * + * @return string + * The plugin ID. + * + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + * When no plugin is available. + */ + protected function getToolkitOperationPluginId($toolkit_id, $operation) { + $definitions = $this->getDefinitions(); + + $definitions = array_filter($definitions, + function ($definition) use ($toolkit_id, $operation) { + return $definition['toolkit'] == $toolkit_id && $definition['operation'] == $operation; + } + ); + + if (!$definitions) { + $message = String::format("No image operation plugin for '@toolkit' toolkit and '@operation' operation.", array('@toolkit' => $toolkit_id, '@operation' => $operation)); + throw new PluginNotFoundException($toolkit_id . '.' . $operation, $message); + } + else { + // Pickup the first plugin found. + // @todo In https://drupal.org/node/2110591 we'll return here the UI + // selected plugin or the first found if missed. + $definition = reset($definitions); + return $definition['id']; + } + } + + /** + * {@inheritdoc} + */ + public function createInstance($plugin_id, array $configuration = array(), ImageToolkitInterface $toolkit = NULL) { + $plugin_definition = $this->getDefinition($plugin_id); + $plugin_class = DefaultFactory::getPluginClass($plugin_id, $plugin_definition); + return new $plugin_class($configuration, $plugin_id, $plugin_definition, $toolkit); + } + + /** + * {@inheritdoc} + */ + public function getToolkitOperation(ImageToolkitInterface $toolkit, $operation) { + $plugin_id = $this->getToolkitOperationPluginId($toolkit->getPluginId(), $operation); + return $this->createInstance($plugin_id, array(), $toolkit); + } + +} diff --git a/core/lib/Drupal/Core/ImageToolkit/ImageToolkitOperationManagerInterface.php b/core/lib/Drupal/Core/ImageToolkit/ImageToolkitOperationManagerInterface.php new file mode 100644 index 0000000..e632adc --- /dev/null +++ b/core/lib/Drupal/Core/ImageToolkit/ImageToolkitOperationManagerInterface.php @@ -0,0 +1,31 @@ + 5, 'height' => 10, )); - $this->assertToolkitOperationsCalled(array('scaleAndCrop')); + $this->assertToolkitOperationsCalled(array('scale_and_crop')); // Check the parameters. $calls = $this->imageTestGetAllCalls(); - $this->assertEqual($calls['scaleAndCrop'][0][0], 5, 'Width was computed and passed correctly'); - $this->assertEqual($calls['scaleAndCrop'][0][1], 10, 'Height was computed and passed correctly'); + $this->assertEqual($calls['scale_and_crop'][0][0], 5, 'Width was computed and passed correctly'); + $this->assertEqual($calls['scale_and_crop'][0][1], 10, 'Height was computed and passed correctly'); } /** diff --git a/core/modules/system/src/Plugin/ImageToolkit/GDToolkit.php b/core/modules/system/src/Plugin/ImageToolkit/GDToolkit.php index 969eaa5..c1a29a6 100644 --- a/core/modules/system/src/Plugin/ImageToolkit/GDToolkit.php +++ b/core/modules/system/src/Plugin/ImageToolkit/GDToolkit.php @@ -2,14 +2,13 @@ /** * @file - * Contains \Drupal\system\Plugin\ImageToolkit\GDToolkit;. + * Contains \Drupal\system\Plugin\ImageToolkit\GDToolkit. */ namespace Drupal\system\Plugin\ImageToolkit; use Drupal\Component\Utility\Unicode; use Drupal\Core\ImageToolkit\ImageToolkitBase; -use Drupal\Component\Utility\Image as ImageUtility; /** * Defines the GD2 toolkit for image manipulation within Drupal. @@ -84,157 +83,6 @@ public function settingsFormSubmit($form, &$form_state) { } /** - * {@inheritdoc} - */ - public function resize($width, $height) { - // @todo Dimensions computation will be moved into a dedicated functionality - // in https://drupal.org/node/2108307. - $width = (int) round($width); - $height = (int) round($height); - - if ($width <= 0 || $height <= 0) { - return FALSE; - } - - $res = $this->createTmp($this->getType(), $width, $height); - - if (!imagecopyresampled($res, $this->getResource(), 0, 0, 0, 0, $width, $height, $this->getWidth(), $this->getHeight())) { - return FALSE; - } - - imagedestroy($this->getResource()); - // Update image object. - $this->setResource($res); - return TRUE; - } - - /** - * {@inheritdoc} - */ - public function rotate($degrees, $background = NULL) { - // PHP installations using non-bundled GD do not have imagerotate. - if (!function_exists('imagerotate')) { - watchdog('image', 'The image %file could not be rotated because the imagerotate() function is not available in this PHP installation.', array('%file' => $this->getImage()->getSource())); - return FALSE; - } - - // Convert the hexadecimal background value to a color index value. - if (isset($background)) { - $rgb = array(); - for ($i = 16; $i >= 0; $i -= 8) { - $rgb[] = (($background >> $i) & 0xFF); - } - $background = imagecolorallocatealpha($this->getResource(), $rgb[0], $rgb[1], $rgb[2], 0); - } - // Set the background color as transparent if $background is NULL. - else { - // Get the current transparent color. - $background = imagecolortransparent($this->getResource()); - - // If no transparent colors, use white. - if ($background == 0) { - $background = imagecolorallocatealpha($this->getResource(), 255, 255, 255, 0); - } - } - - // Images are assigned a new color palette when rotating, removing any - // transparency flags. For GIF images, keep a record of the transparent color. - if ($this->getType() == IMAGETYPE_GIF) { - $transparent_index = imagecolortransparent($this->getResource()); - if ($transparent_index != 0) { - $transparent_gif_color = imagecolorsforindex($this->getResource(), $transparent_index); - } - } - - $this->setResource(imagerotate($this->getResource(), 360 - $degrees, $background)); - - // GIFs need to reassign the transparent color after performing the rotate. - if (isset($transparent_gif_color)) { - $background = imagecolorexactalpha($this->getResource(), $transparent_gif_color['red'], $transparent_gif_color['green'], $transparent_gif_color['blue'], $transparent_gif_color['alpha']); - imagecolortransparent($this->getResource(), $background); - } - - return TRUE; - } - - /** - * {@inheritdoc} - */ - public function crop($x, $y, $width, $height) { - // @todo Dimensions computation will be moved into a dedicated functionality - // in https://drupal.org/node/2108307. - $aspect = $this->getHeight() / $this->getWidth(); - $height = empty($height) ? $width * $aspect : $height; - $width = empty($width) ? $height / $aspect : $width; - $width = (int) round($width); - $height = (int) round($height); - - if ($width <= 0 || $height <= 0) { - return FALSE; - } - - $res = $this->createTmp($this->getType(), $width, $height); - - if (!imagecopyresampled($res, $this->getResource(), 0, 0, $x, $y, $width, $height, $width, $height)) { - return FALSE; - } - - // Destroy the original image and return the modified image. - imagedestroy($this->getResource()); - $this->setResource($res); - return TRUE; - } - - /** - * {@inheritdoc} - */ - public function desaturate() { - // PHP installations using non-bundled GD do not have imagefilter. - if (!function_exists('imagefilter')) { - watchdog('image', 'The image %file could not be desaturated because the imagefilter() function is not available in this PHP installation.', array('%file' => $this->getImage()->getSource())); - return FALSE; - } - - return imagefilter($this->getResource(), IMG_FILTER_GRAYSCALE); - } - - /** - * {@inheritdoc} - */ - public function scale($width = NULL, $height = NULL, $upscale = FALSE) { - // @todo Dimensions computation will be moved into a dedicated functionality - // in https://drupal.org/node/2108307. - $dimensions = array( - 'width' => $this->getWidth(), - 'height' => $this->getHeight(), - ); - - // Scale the dimensions - if they don't change then just return success. - if (!ImageUtility::scaleDimensions($dimensions, $width, $height, $upscale)) { - return TRUE; - } - - return $this->resize($dimensions['width'], $dimensions['height']); - } - - /** - * {@inheritdoc} - */ - public function scaleAndCrop($width, $height) { - // @todo Dimensions computation will be moved into a dedicated functionality - // in https://drupal.org/node/2108307. - $scale = max($width / $this->getWidth(), $height / $this->getHeight()); - $x = ($this->getWidth() * $scale - $width) / 2; - $y = ($this->getHeight() * $scale - $height) / 2; - - if ($this->resize($this->getWidth() * $scale, $this->getHeight() * $scale)) { - return $this->crop($x, $y, $width, $height); - } - - return FALSE; - } - - /** * Loads a GD resource from a file. * * @return bool @@ -395,7 +243,7 @@ public function getType() { * The image type represented by a PHP IMAGETYPE_* constant (e.g. * IMAGETYPE_JPEG). * - * @return this + * @return $this */ public function setType($type) { if (in_array($type, static::supportedTypes())) { diff --git a/core/modules/system/src/Plugin/ImageToolkit/Operation/gd/Crop.php b/core/modules/system/src/Plugin/ImageToolkit/Operation/gd/Crop.php new file mode 100644 index 0000000..67c435c --- /dev/null +++ b/core/modules/system/src/Plugin/ImageToolkit/Operation/gd/Crop.php @@ -0,0 +1,95 @@ + array( + 'description' => 'The starting x offset at which to start the crop, in pixels', + ), + 'y' => array( + 'description' => 'The starting y offset at which to start the crop, in pixels', + ), + 'width' => array( + 'description' => 'The width of the cropped area, in pixels', + 'required' => FALSE, + 'default' => NULL, + ), + 'height' => array( + 'description' => 'The height of the cropped area, in pixels', + 'required' => FALSE, + 'default' => NULL, + ), + ); + } + + /** + * {@inheritdoc} + */ + protected function validateArguments(array $arguments) { + // Assure at least one dimension. + if (empty($arguments['width']) && empty($arguments['height'])) { + throw new \InvalidArgumentException("At least one dimension ('width' or 'height') must be provided to the image 'crop' operation"); + } + + // Preserve aspect. + $aspect = $this->getToolkit()->getHeight() / $this->getToolkit()->getWidth(); + $arguments['height'] = empty($arguments['height']) ? $arguments['width'] * $aspect : $arguments['height']; + $arguments['width'] = empty($arguments['width']) ? $arguments['height'] / $aspect : $arguments['width']; + + // Assure integers for all arguments. + foreach (array('x', 'y', 'width', 'height') as $key) { + $arguments[$key] = (int) round($arguments[$key]); + } + + // Fail when width or height are 0 or negative. + if ($arguments['width'] <= 0) { + throw new \InvalidArgumentException(String::format("Invalid width (@value) specified for the image 'crop' operation", array('@value' => $arguments['width']))); + } + if ($arguments['height'] <= 0) { + throw new \InvalidArgumentException(String::format("Invalid height (@value) specified for the image 'crop' operation", array('@value' => $arguments['height']))); + } + + return $arguments; + } + + /** + * {@inheritdoc} + */ + protected function execute(array $arguments) { + $res = $this->getToolkit()->createTmp($this->getToolkit()->getType(), $arguments['width'], $arguments['height']); + + if (!imagecopyresampled($res, $this->getToolkit()->getResource(), 0, 0, $arguments['x'], $arguments['y'], $arguments['width'], $arguments['height'], $arguments['width'], $arguments['height'])) { + return FALSE; + } + + // Destroy the original image and return the modified image. + imagedestroy($this->getToolkit()->getResource()); + $this->getToolkit()->setResource($res); + + return TRUE; + } + +} diff --git a/core/modules/system/src/Plugin/ImageToolkit/Operation/gd/Desaturate.php b/core/modules/system/src/Plugin/ImageToolkit/Operation/gd/Desaturate.php new file mode 100644 index 0000000..b6ea3e9 --- /dev/null +++ b/core/modules/system/src/Plugin/ImageToolkit/Operation/gd/Desaturate.php @@ -0,0 +1,44 @@ +notice("The image '@file' could not be desaturated because the imagefilter() function is not available in this PHP installation.", array('@file' => $this->getToolkit()->getImage()->getSource())); + return FALSE; + } + + return imagefilter($this->getToolkit()->getResource(), IMG_FILTER_GRAYSCALE); + } + +} diff --git a/core/modules/system/src/Plugin/ImageToolkit/Operation/gd/GDImageToolkitOperationBase.php b/core/modules/system/src/Plugin/ImageToolkit/Operation/gd/GDImageToolkitOperationBase.php new file mode 100644 index 0000000..b1a9b79 --- /dev/null +++ b/core/modules/system/src/Plugin/ImageToolkit/Operation/gd/GDImageToolkitOperationBase.php @@ -0,0 +1,23 @@ + array( + 'description' => 'The new width of the resized image, in pixels', + ), + 'height' => array( + 'description' => 'The new height of the resized image, in pixels', + ), + ); + } + + /** + * {@inheritdoc} + */ + protected function validateArguments(array $arguments) { + // Assure integers for all arguments. + $arguments['width'] = (int) round($arguments['width']); + $arguments['height'] = (int) round($arguments['height']); + + // Fail when width or height are 0 or negative. + if ($arguments['width'] <= 0) { + throw new \InvalidArgumentException(String::format("Invalid width (@value) specified for the image 'resize' operation", array('@value' => $arguments['width']))); + } + if ($arguments['height'] <= 0) { + throw new \InvalidArgumentException(String::format("Invalid height (@value) specified for the image 'resize' operation", array('@value' => $arguments['height']))); + } + + return $arguments; + } + + /** + * {@inheritdoc} + */ + protected function execute(array $arguments = array()) { + $res = $this->getToolkit()->createTmp($this->getToolkit()->getType(), $arguments['width'], $arguments['height']); + + if (!imagecopyresampled($res, $this->getToolkit()->getResource(), 0, 0, 0, 0, $arguments['width'], $arguments['height'], $this->getToolkit()->getWidth(), $this->getToolkit()->getHeight())) { + return FALSE; + } + + imagedestroy($this->getToolkit()->getResource()); + // Update image object. + $this->getToolkit()->setResource($res); + + return TRUE; + } + +} diff --git a/core/modules/system/src/Plugin/ImageToolkit/Operation/gd/Rotate.php b/core/modules/system/src/Plugin/ImageToolkit/Operation/gd/Rotate.php new file mode 100644 index 0000000..7191a62 --- /dev/null +++ b/core/modules/system/src/Plugin/ImageToolkit/Operation/gd/Rotate.php @@ -0,0 +1,88 @@ + array( + 'description' => 'The number of (clockwise) degrees to rotate the image', + ), + 'background' => array( + 'description' => 'An hexadecimal integer specifying the background color to use for the uncovered area of the image after the rotation. E.g. 0x000000 for black, 0xff00ff for magenta, and 0xffffff for white. For images that support transparency, this will default to transparent. Otherwise it will be white', + 'required' => FALSE, + 'default' => NULL, + ), + ); + } + + /** + * {@inheritdoc} + */ + protected function execute(array $arguments) { + // PHP installations using non-bundled GD do not have imagerotate. + if (!function_exists('imagerotate')) { + \Drupal::logger('image')->notice('The image %file could not be rotated because the imagerotate() function is not available in this PHP installation.', array('%file' => $this->getToolkit()->getImage()->getSource())); + return FALSE; + } + + // Convert the hexadecimal background value to a color index value. + if (!empty($arguments['background'])) { + $rgb = array(); + for ($i = 16; $i >= 0; $i -= 8) { + $rgb[] = (($arguments['background'] >> $i) & 0xFF); + } + $arguments['background'] = imagecolorallocatealpha($this->getToolkit()->getResource(), $rgb[0], $rgb[1], $rgb[2], 0); + } + // Set background color as transparent if $arguments['background'] is NULL. + else { + // Get the current transparent color. + $arguments['background'] = imagecolortransparent($this->getToolkit()->getResource()); + + // If no transparent colors, use white. + if ($arguments['background'] == 0) { + $arguments['background'] = imagecolorallocatealpha($this->getToolkit()->getResource(), 255, 255, 255, 0); + } + } + + // Images are assigned a new color palette when rotating, removing any + // transparency flags. For GIF images, keep a record of the transparent color. + if ($this->getToolkit()->getType() == IMAGETYPE_GIF) { + $transparent_index = imagecolortransparent($this->getToolkit()->getResource()); + if ($transparent_index != 0) { + $transparent_gif_color = imagecolorsforindex($this->getToolkit()->getResource(), $transparent_index); + } + } + + $this->getToolkit()->setResource(imagerotate($this->getToolkit()->getResource(), 360 - $arguments['degrees'], $arguments['background'])); + + // GIFs need to reassign the transparent color after performing the rotate. + if (isset($transparent_gif_color)) { + $arguments['background'] = imagecolorexactalpha($this->getToolkit()->getResource(), $transparent_gif_color['red'], $transparent_gif_color['green'], $transparent_gif_color['blue'], $transparent_gif_color['alpha']); + imagecolortransparent($this->getToolkit()->getResource(), $arguments['background']); + } + + return TRUE; + } + +} diff --git a/core/modules/system/src/Plugin/ImageToolkit/Operation/gd/Scale.php b/core/modules/system/src/Plugin/ImageToolkit/Operation/gd/Scale.php new file mode 100644 index 0000000..ee08c56 --- /dev/null +++ b/core/modules/system/src/Plugin/ImageToolkit/Operation/gd/Scale.php @@ -0,0 +1,99 @@ + array( + 'description' => 'The target width, in pixels. This value is omitted then the scaling will based only on the height value', + 'required' => FALSE, + 'default' => NULL, + ), + 'height' => array( + 'description' => 'The target height, in pixels. This value is omitted then the scaling will based only on the width value', + 'required' => FALSE, + 'default' => NULL, + ), + 'upscale' => array( + 'description' => 'Boolean indicating that files smaller than the dimensions will be scaled up. This generally results in a low quality image', + 'required' => FALSE, + 'default' => FALSE, + ), + ); + } + + /** + * {@inheritdoc} + */ + protected function validateArguments(array $arguments) { + // Assure at least one dimension. + if (empty($arguments['width']) && empty($arguments['height'])) { + throw new \InvalidArgumentException("At least one dimension ('width' or 'height') must be provided to the image 'scale' operation"); + } + + // Calculate one of the dimensions from the other target dimension, + // ensuring the same aspect ratio as the source dimensions. If one of the + // target dimensions is missing, that is the one that is calculated. If both + // are specified then the dimension calculated is the one that would not be + // calculated to be bigger than its target. + $aspect = $this->getToolkit()->getHeight() / $this->getToolkit()->getWidth(); + if (($arguments['width'] && !$arguments['height']) || ($arguments['width'] && $arguments['height'] && $aspect < $arguments['height'] / $arguments['width'])) { + $arguments['height'] = (int) round($arguments['width'] * $aspect); + } + else { + $arguments['width'] = (int) round($arguments['height'] / $aspect); + } + + // Assure integers for all arguments. + $arguments['width'] = (int) round($arguments['width']); + $arguments['height'] = (int) round($arguments['height']); + + // Fail when width or height are 0 or negative. + if ($arguments['width'] <= 0) { + throw new \InvalidArgumentException(String::format("Invalid width (@value) specified for the image 'scale' operation", array('@value' => $arguments['width']))); + } + if ($arguments['height'] <= 0) { + throw new \InvalidArgumentException(String::format("Invalid height (@value) specified for the image 'scale' operation", array('@value' => $arguments['height']))); + } + + return $arguments; + } + + /** + * {@inheritdoc} + */ + protected function execute(array $arguments = array()) { + // Don't scale if we don't change the dimensions at all. + if ($arguments['width'] !== $this->getToolkit()->getWidth() || $arguments['height'] !== $this->getToolkit()->getHeight()) { + // Don't upscale if the option isn't enabled. + if ($arguments['upscale'] || ($arguments['width'] <= $this->getToolkit()->getWidth() && $arguments['height'] <= $this->getToolkit()->getHeight())) { + return parent::execute($arguments); + } + } + return TRUE; + } + +} diff --git a/core/modules/system/src/Plugin/ImageToolkit/Operation/gd/ScaleAndCrop.php b/core/modules/system/src/Plugin/ImageToolkit/Operation/gd/ScaleAndCrop.php new file mode 100644 index 0000000..f5848a1 --- /dev/null +++ b/core/modules/system/src/Plugin/ImageToolkit/Operation/gd/ScaleAndCrop.php @@ -0,0 +1,73 @@ + array( + 'description' => 'The target width, in pixels', + ), + 'height' => array( + 'description' => 'The target height, in pixels', + ), + ); + } + + /** + * {@inheritdoc} + */ + protected function validateArguments(array $arguments) { + $actualWidth = $this->getToolkit()->getWidth(); + $actualHeight = $this->getToolkit()->getHeight(); + + $scaleFactor = max($arguments['width'] / $actualWidth, $arguments['height'] / $actualHeight); + + $arguments['x'] = (int) round(($actualWidth * $scaleFactor - $arguments['width']) / 2); + $arguments['y'] = (int) round(($actualHeight * $scaleFactor - $arguments['height']) / 2); + $arguments['resize'] = array( + 'width' => (int) round($actualWidth * $scaleFactor), + 'height' => (int) round($actualHeight * $scaleFactor), + ); + + // Fail when width or height are 0 or negative. + if ($arguments['width'] <= 0) { + throw new \InvalidArgumentException(String::format("Invalid width (@value) specified for the image 'scale_and_crop' operation", array('@value' => $arguments['width']))); + } + if ($arguments['height'] <= 0) { + throw new \InvalidArgumentException(String::format("Invalid height (@value) specified for the image 'scale_and_crop' operation", array('@value' => $arguments['height']))); + } + + return $arguments; + } + + /** + * {@inheritdoc} + */ + protected function execute(array $arguments = array()) { + return $this->getToolkit()->apply('resize', $arguments['resize']) + && $this->getToolkit()->apply('crop', $arguments); + } + +} diff --git a/core/modules/system/src/Tests/Image/ToolkitGdTest.php b/core/modules/system/src/Tests/Image/ToolkitGdTest.php index 6927ec0..3d1542c 100644 --- a/core/modules/system/src/Tests/Image/ToolkitGdTest.php +++ b/core/modules/system/src/Tests/Image/ToolkitGdTest.php @@ -124,49 +124,49 @@ function testManipulations() { $operations = array( 'resize' => array( 'function' => 'resize', - 'arguments' => array(20, 10), + 'arguments' => array('width' => 20, 'height' => 10), 'width' => 20, 'height' => 10, 'corners' => $default_corners, ), 'scale_x' => array( 'function' => 'scale', - 'arguments' => array(20, NULL), + 'arguments' => array('width' => 20), 'width' => 20, 'height' => 10, 'corners' => $default_corners, ), 'scale_y' => array( 'function' => 'scale', - 'arguments' => array(NULL, 10), + 'arguments' => array('height' => 10), 'width' => 20, 'height' => 10, 'corners' => $default_corners, ), 'upscale_x' => array( 'function' => 'scale', - 'arguments' => array(80, NULL, TRUE), + 'arguments' => array('width' => 80, 'upscale' => TRUE), 'width' => 80, 'height' => 40, 'corners' => $default_corners, ), 'upscale_y' => array( 'function' => 'scale', - 'arguments' => array(NULL, 40, TRUE), + 'arguments' => array('height' => 40, 'upscale' => TRUE), 'width' => 80, 'height' => 40, 'corners' => $default_corners, ), 'crop' => array( 'function' => 'crop', - 'arguments' => array(12, 4, 16, 12), + 'arguments' => array('x' => 12, 'y' => 4, 'width' => 16, 'height' => 12), 'width' => 16, 'height' => 12, 'corners' => array_fill(0, 4, $this->white), ), 'scale_and_crop' => array( - 'function' => 'scaleAndCrop', - 'arguments' => array(10, 8), + 'function' => 'scale_and_crop', + 'arguments' => array('width' => 10, 'height' => 8), 'width' => 10, 'height' => 8, 'corners' => array_fill(0, 4, $this->black), @@ -178,28 +178,28 @@ function testManipulations() { $operations += array( 'rotate_5' => array( 'function' => 'rotate', - 'arguments' => array(5, 0xFF00FF), // Fuchsia background. + 'arguments' => array('degrees' => 5, 'background' => 0xFF00FF), // Fuchsia background. 'width' => 42, 'height' => 24, 'corners' => array_fill(0, 4, $this->fuchsia), ), 'rotate_90' => array( 'function' => 'rotate', - 'arguments' => array(90, 0xFF00FF), // Fuchsia background. + 'arguments' => array('degrees' => 90, 'background' => 0xFF00FF), // Fuchsia background. 'width' => 20, 'height' => 40, 'corners' => array($this->transparent, $this->red, $this->green, $this->blue), ), 'rotate_transparent_5' => array( 'function' => 'rotate', - 'arguments' => array(5), + 'arguments' => array('degrees' => 5), 'width' => 42, 'height' => 24, 'corners' => array_fill(0, 4, $this->transparent), ), 'rotate_transparent_90' => array( 'function' => 'rotate', - 'arguments' => array(90), + 'arguments' => array('degrees' => 90), 'width' => 20, 'height' => 40, 'corners' => array($this->transparent, $this->red, $this->green, $this->blue), @@ -233,7 +233,7 @@ function testManipulations() { // Load up a fresh image. $image = $this->imageFactory->get(drupal_get_path('module', 'simpletest') . '/files/' . $file); $toolkit = $image->getToolkit(); - if (!$image) { + if (!$image->isValid()) { $this->fail(String::format('Could not load image %file.', array('%file' => $file))); continue 2; } @@ -250,7 +250,7 @@ function testManipulations() { } // Perform our operation. - call_user_func_array(array($image, $values['function']), $values['arguments']); + $image->apply($values['function'], $values['arguments']); // To keep from flooding the test with assert values, make a general // value for whether each group of values fail. @@ -310,4 +310,25 @@ function testManipulations() { } } + /** + * Tests calling a missing image operation plugin. + */ + function testMissingOperation() { + + // Test that the image factory is set to use the GD toolkit. + $this->assertEqual($this->imageFactory->getToolkitId(), 'gd', 'The image factory is set to use the \'gd\' image toolkit.'); + + // An image file that will be tested. + $file = 'image-test.png'; + + // Load up a fresh image. + $image = $this->imageFactory->get(drupal_get_path('module', 'simpletest') . '/files/' . $file); + if (!$image->isValid()) { + $this->fail(String::format('Could not load image %file.', array('%file' => $file))); + } + + // Try perform a missing toolkit operation. + $this->assertFalse($image->apply('missing_op', array()), 'Calling a missing image toolkit operation plugin fails.'); + } + } diff --git a/core/modules/system/src/Tests/Image/ToolkitTest.php b/core/modules/system/src/Tests/Image/ToolkitTest.php index 8965a45..35686bf 100644 --- a/core/modules/system/src/Tests/Image/ToolkitTest.php +++ b/core/modules/system/src/Tests/Image/ToolkitTest.php @@ -44,83 +44,31 @@ function testSave() { } /** - * Test the image_resize() function. + * Test the image_apply() function. */ - function testResize() { - $this->assertTrue($this->image->resize(1, 2), 'Function returned the expected value.'); - $this->assertToolkitOperationsCalled(array('resize')); + function testApply() { + $data = array('p1' => 1, 'p2' => TRUE, 'p3' => 'text'); + $this->assertTrue($this->image->apply('my_operation', $data), 'Function returned the expected value.'); - // Check the parameters. + // Check that apply was called and with the correct parameters. + $this->assertToolkitOperationsCalled(array('apply')); $calls = $this->imageTestGetAllCalls(); - $this->assertEqual($calls['resize'][0][0], 1, 'Width was passed correctly'); - $this->assertEqual($calls['resize'][0][1], 2, 'Height was passed correctly'); + $this->assertEqual($calls['apply'][0][0], 'my_operation', "'my_operation' was passed correctly as operation"); + $this->assertEqual($calls['apply'][0][1]['p1'], 1, 'integer parameter p1 was passed correctly'); + $this->assertEqual($calls['apply'][0][1]['p2'], TRUE, 'boolean parameter p2 was passed correctly'); + $this->assertEqual($calls['apply'][0][1]['p3'], 'text', 'string parameter p3 was passed correctly'); } /** - * Test the image_scale() function. + * Test the image_apply() function. */ - function testScale() { -// TODO: need to test upscaling - $this->assertTrue($this->image->scale(10, 10), 'Function returned the expected value.'); - $this->assertToolkitOperationsCalled(array('scale')); + function testApplyNoParameters() { + $this->assertTrue($this->image->apply('my_operation'), 'Function returned the expected value.'); - // Check the parameters. + // Check that apply was called and with the correct parameters. + $this->assertToolkitOperationsCalled(array('apply')); $calls = $this->imageTestGetAllCalls(); - $this->assertEqual($calls['scale'][0][0], 10, 'Width was passed correctly'); - $this->assertEqual($calls['scale'][0][1], 10, 'Height was based off aspect ratio and passed correctly'); - } - - /** - * Test the image_scale_and_crop() function. - */ - function testScaleAndCrop() { - $this->assertTrue($this->image->scaleAndCrop(5, 10), 'Function returned the expected value.'); - $this->assertToolkitOperationsCalled(array('scaleAndCrop')); - - // Check the parameters. - $calls = $this->imageTestGetAllCalls(); - - $this->assertEqual($calls['scaleAndCrop'][0][0], 5, 'Width was computed and passed correctly'); - $this->assertEqual($calls['scaleAndCrop'][0][1], 10, 'Height was computed and passed correctly'); - } - - /** - * Test the image_rotate() function. - */ - function testRotate() { - $this->assertTrue($this->image->rotate(90, 1), 'Function returned the expected value.'); - $this->assertToolkitOperationsCalled(array('rotate')); - - // Check the parameters. - $calls = $this->imageTestGetAllCalls(); - $this->assertEqual($calls['rotate'][0][0], 90, 'Degrees were passed correctly'); - $this->assertEqual($calls['rotate'][0][1], 1, 'Background color was passed correctly'); - } - - /** - * Test the image_crop() function. - */ - function testCrop() { - $this->assertTrue($this->image->crop(1, 2, 3, 4), 'Function returned the expected value.'); - $this->assertToolkitOperationsCalled(array('crop')); - - // Check the parameters. - $calls = $this->imageTestGetAllCalls(); - $this->assertEqual($calls['crop'][0][0], 1, 'X was passed correctly'); - $this->assertEqual($calls['crop'][0][1], 2, 'Y was passed correctly'); - $this->assertEqual($calls['crop'][0][2], 3, 'Width was passed correctly'); - $this->assertEqual($calls['crop'][0][3], 4, 'Height was passed correctly'); - } - - /** - * Test the image_desaturate() function. - */ - function testDesaturate() { - $this->assertTrue($this->image->desaturate(), 'Function returned the expected value.'); - $this->assertToolkitOperationsCalled(array('desaturate')); - - // Check the parameters. - $calls = $this->imageTestGetAllCalls(); - $this->assertEqual(count($calls['desaturate'][0]), 0, 'No parameters were passed.'); + $this->assertEqual($calls['apply'][0][0], 'my_operation', "'my_operation' was passed correctly as operation"); + $this->assertEqual($calls['apply'][0][1], array(), 'passing no parameters was handled correctly'); } } diff --git a/core/modules/system/src/Tests/Image/ToolkitTestBase.php b/core/modules/system/src/Tests/Image/ToolkitTestBase.php index c8b04e1..810dd9f 100644 --- a/core/modules/system/src/Tests/Image/ToolkitTestBase.php +++ b/core/modules/system/src/Tests/Image/ToolkitTestBase.php @@ -81,6 +81,21 @@ protected function getImage() { * 'save', 'crop', etc. */ function assertToolkitOperationsCalled(array $expected) { + // If one of the image operations is expected, apply should be expected as + // well. + $operations = array( + 'resize', + 'rotate', + 'crop', + 'desaturate', + 'scale', + 'scale_and_crop', + 'my_operation', + ); + if (count(array_intersect($expected, $operations)) > 0 && !in_array('apply', $expected)) { + $expected[] = 'apply'; + } + // Determine which operations were called. $actual = array_keys(array_filter($this->imageTestGetAllCalls())); @@ -94,8 +109,10 @@ function assertToolkitOperationsCalled(array $expected) { } // Determine if there were any unexpected calls. + // If all unexpected calls are operations and apply was expected, we do not + // count it as an error. $unexpected = array_diff($actual, $expected); - if (count($unexpected)) { + if (count($unexpected) && (!in_array('apply', $expected) || count(array_intersect($unexpected, $operations)) !== count($unexpected))) { $this->assertTrue(FALSE, String::format('Unexpected operations were called: %unexpected.', array('%unexpected' => implode(', ', $unexpected)))); } else { @@ -112,10 +129,13 @@ function imageTestReset() { 'parseFile' => array(), 'save' => array(), 'settings' => array(), + 'apply' => array(), 'resize' => array(), 'rotate' => array(), 'crop' => array(), 'desaturate' => array(), + 'scale' => array(), + 'scale_and_crop' => array(), ); \Drupal::state()->set('image_test.results', $results); } diff --git a/core/modules/system/tests/modules/image_test/src/Plugin/ImageToolkit/TestToolkit.php b/core/modules/system/tests/modules/image_test/src/Plugin/ImageToolkit/TestToolkit.php index ecbe0fe..42c8830 100644 --- a/core/modules/system/tests/modules/image_test/src/Plugin/ImageToolkit/TestToolkit.php +++ b/core/modules/system/tests/modules/image_test/src/Plugin/ImageToolkit/TestToolkit.php @@ -45,7 +45,7 @@ class TestToolkit extends ImageToolkitBase { * {@inheritdoc} */ public function settingsForm() { - $this->logCall('settings', array()); + $this->logCall('settings', func_get_args()); $form['test_parameter'] = array( '#type' => 'number', '#title' => $this->t('Test toolkit parameter'), @@ -70,7 +70,7 @@ public function settingsFormSubmit($form, &$form_state) { * {@inheritdoc} */ public function parseFile() { - $this->logCall('parseFile', array()); + $this->logCall('parseFile', func_get_args()); $data = @getimagesize($this->getImage()->getSource()); if ($data && in_array($data[2], static::supportedTypes())) { $this->setType($data[2]); @@ -85,66 +85,17 @@ public function parseFile() { * {@inheritdoc} */ public function save($destination) { - $this->logCall('save', array($destination)); + $this->logCall('save', func_get_args()); // Return false so that image_save() doesn't try to chmod the destination // file that we didn't bother to create. return FALSE; } /** - * {@inheritdoc} - */ - public function crop($x, $y, $width, $height) { - $this->logCall('crop', array($x, $y, $width, $height)); - return TRUE; - } - - /** - * {@inheritdoc} - */ - public function resize($width, $height) { - $this->logCall('resize', array($width, $height)); - return TRUE; - } - - /** - * {@inheritdoc} - */ - public function rotate($degrees, $background = NULL) { - $this->logCall('rotate', array($degrees, $background)); - return TRUE; - } - - /** - * {@inheritdoc} - */ - public function desaturate() { - $this->logCall('desaturate', array()); - return TRUE; - } - - /** - * {@inheritdoc} - */ - public function scale($width = NULL, $height = NULL, $upscale = FALSE) { - $this->logCall('scale', array($width, $height, $upscale)); - return TRUE; - } - - /** - * {@inheritdoc} - */ - public function scaleAndCrop($width, $height) { - $this->logCall('scaleAndCrop', array($width, $height)); - return TRUE; - } - - /** * Stores the values passed to a toolkit call. * * @param string $op - * One of the image toolkit operations: 'parseFile', 'save', 'settings', - * 'resize', 'rotate', 'crop', 'desaturate'. + * One of the toolkit methods 'parseFile', 'save', 'settings', or 'apply'. * @param array $args * Values passed to hook. * @@ -154,6 +105,13 @@ public function scaleAndCrop($width, $height) { protected function logCall($op, $args) { $results = \Drupal::state()->get('image_test.results') ?: array(); $results[$op][] = $args; + // A call to apply is also logged under its operation name whereby the + // array of arguments are logged as separate arguments, this because at the + // ImageInterface level we still have methods named after the operations. + if ($op === 'apply') { + $operation = array_shift($args); + $results[$operation][] = array_values(reset($args)); + } \Drupal::state()->set('image_test.results', $results); } @@ -189,7 +147,7 @@ public function getType() { * The image type represented by a PHP IMAGETYPE_* constant (e.g. * IMAGETYPE_JPEG). * - * @return this + * @return $this */ public function setType($type) { if (in_array($type, static::supportedTypes())) { @@ -234,4 +192,12 @@ protected static function supportedTypes() { return array(IMAGETYPE_PNG, IMAGETYPE_JPEG, IMAGETYPE_GIF); } + /** + * {@inheritdoc} + */ + public function apply($operation, array $arguments = array()) { + $this->logCall('apply', func_get_args()); + return TRUE; + } + } diff --git a/core/tests/Drupal/Tests/Core/Image/ImageTest.php b/core/tests/Drupal/Tests/Core/Image/ImageTest.php index 94ee5d4..828a1a1 100644 --- a/core/tests/Drupal/Tests/Core/Image/ImageTest.php +++ b/core/tests/Drupal/Tests/Core/Image/ImageTest.php @@ -8,6 +8,7 @@ namespace Drupal\Tests\Core\Image; use Drupal\Core\Image\Image; +use Drupal\Core\ImageToolkit\ImageToolkitInterface; use Drupal\Tests\UnitTestCase; /** @@ -18,7 +19,7 @@ class ImageTest extends UnitTestCase { /** - * Path to the image file. + * Image source path. * * @var string */ @@ -32,22 +33,25 @@ class ImageTest extends UnitTestCase { protected $image; /** - * Image toolkit. + * Mocked image toolkit. * * @var \Drupal\Core\ImageToolkit\ImageToolkitInterface */ protected $toolkit; + /** + * Mocked image toolkit operation. + * + * @var \Drupal\Core\ImageToolkit\ImageToolkitOperationInterface + */ + protected $toolkitOperation; + + /** + * @inheritdoc + */ protected function setUp() { // Use the Druplicon image. $this->source = __DIR__ . '/../../../../../misc/druplicon.png'; - $this->toolkit = $this->getToolkitMock(); - - $this->toolkit->expects($this->any()) - ->method('getPluginId') - ->will($this->returnValue('gd')); - - $this->image = new Image($this->toolkit, $this->source); } /** @@ -68,44 +72,111 @@ protected function getToolkitMock(array $stubs = array()) { } /** + * Mocks a toolkit operation. + * + * @param string $class_name + * The name of the GD toolkit operation class to be mocked. + * @param ImageToolkitInterface $toolkit + * The image toolkit object. + * + * @return \PHPUnit_Framework_MockObject_MockObject + */ + protected function getToolkitOperationMock($class_name, ImageToolkitInterface $toolkit) { + $mock_builder = $this->getMockBuilder('Drupal\system\Plugin\ImageToolkit\Operation\gd\\' . $class_name); + return $mock_builder + ->setMethods(array('execute')) + ->setConstructorArgs(array(array(), '', array(), $toolkit)) + ->getMock(); + } + + /** + * Get an image with a mocked toolkit, for testing. + * + * @param array $stubs + * (optional) Array containing toolkit methods to be replaced with stubs. + * + * @return \Drupal\Core\Image\Image + * An image object. + */ + protected function getTestImage(array $stubs = array()) { + $this->toolkit = $this->getToolkitMock($stubs); + + $this->toolkit->expects($this->any()) + ->method('getPluginId') + ->will($this->returnValue('gd')); + + $this->image = new Image($this->toolkit, $this->source); + } + + /** + * Get an image with mocked toolkit and operation, for operation testing. + * + * @param string $class_name + * The name of the GD toolkit operation class to be mocked. + * + * @return \Drupal\Core\Image\Image + * An image object. + */ + protected function getTestImageForOperation($class_name) { + $this->toolkit = $this->getToolkitMock(array('getToolkitOperation', 'getPluginId')); + $this->toolkitOperation = $this->getToolkitOperationMock($class_name, $this->toolkit); + + $this->toolkit->expects($this->any()) + ->method('getPluginId') + ->will($this->returnValue('gd')); + + $this->toolkit->expects($this->any()) + ->method('getToolkitOperation') + ->will($this->returnValue($this->toolkitOperation)); + + $this->image = new Image($this->toolkit, $this->source); + } + + /** * Tests \Drupal\Core\Image\Image::getHeight(). */ public function testGetHeight() { - $this->assertEquals($this->image->getHeight(), 100); + $this->getTestImage(); + $this->assertEquals(100, $this->image->getHeight()); } /** * Tests \Drupal\Core\Image\Image::getWidth(). */ public function testGetWidth() { - $this->assertEquals($this->image->getWidth(), 88); + $this->getTestImage(); + $this->assertEquals(88, $this->image->getWidth()); } /** * Tests \Drupal\Core\Image\Image::getFileSize */ public function testGetFileSize() { - $this->assertEquals($this->image->getFileSize(), 3905); + $this->getTestImage(); + $this->assertEquals(3905, $this->image->getFileSize()); } /** * Tests \Drupal\Core\Image\Image::getToolkit()->getType(). */ public function testGetType() { - $this->assertEquals($this->image->getToolkit()->getType(), IMAGETYPE_PNG); + $this->getTestImage(); + $this->assertEquals(IMAGETYPE_PNG, $this->image->getToolkit()->getType()); } /** * Tests \Drupal\Core\Image\Image::getMimeType(). */ public function testGetMimeType() { - $this->assertEquals($this->image->getMimeType(), 'image/png'); + $this->getTestImage(); + $this->assertEquals('image/png', $this->image->getMimeType()); } /** * Tests \Drupal\Core\Image\Image::isValid(). */ public function testIsValid() { + $this->getTestImage(); $this->assertTrue($this->image->isValid()); $this->assertTrue(is_readable($this->image->getSource())); } @@ -114,13 +185,15 @@ public function testIsValid() { * Tests \Drupal\Core\Image\Image::getToolkitId(). */ public function testGetToolkitId() { - $this->assertEquals($this->image->getToolkitId(), 'gd'); + $this->getTestImage(); + $this->assertEquals('gd', $this->image->getToolkitId()); } /** * Tests \Drupal\Core\Image\Image::save(). */ public function testSave() { + $this->getTestImage(); // This will fail if save() method isn't called on the toolkit. $toolkit = $this->getToolkitMock(); $toolkit->expects($this->once()) @@ -139,6 +212,7 @@ public function testSave() { * Tests \Drupal\Core\Image\Image::save(). */ public function testSaveFails() { + $this->getTestImage(); // This will fail if save() method isn't called on the toolkit. $this->toolkit->expects($this->once()) ->method('save') @@ -151,6 +225,7 @@ public function testSaveFails() { * Tests \Drupal\Core\Image\Image::save(). */ public function testChmodFails() { + $this->getTestImage(); // This will fail if save() method isn't called on the toolkit. $toolkit = $this->getToolkitMock(); $toolkit->expects($this->once()) @@ -180,166 +255,179 @@ public function testParseFileFails() { * Tests \Drupal\Core\Image\Image::scale(). */ public function testScaleWidth() { - $toolkit = $this->getToolkitMock(array('resize')); - $image = new Image($toolkit, $this->source); - - $toolkit->expects($this->any()) - ->method('resize') - ->will($this->returnArgument(1)); - $height = $image->scale(44); - $this->assertEquals($height, 50); + $this->getTestImageForOperation('Scale'); + $this->toolkitOperation->expects($this->once()) + ->method('execute') + ->will($this->returnArgument(0)); + + $ret = $this->image->scale(44, NULL, FALSE); + $this->assertEquals(50, $ret['height']); } /** * Tests \Drupal\Core\Image\Image::scale(). */ public function testScaleHeight() { - $toolkit = $this->getToolkitMock(array('resize')); - $image = new Image($toolkit, $this->source); - - $toolkit->expects($this->any()) - ->method('resize') + $this->getTestImageForOperation('Scale'); + $this->toolkitOperation->expects($this->once()) + ->method('execute') ->will($this->returnArgument(0)); - $width = $image->scale(NULL, 50); - $this->assertEquals($width, 44); + + $ret = $this->image->scale(NULL, 50, FALSE); + $this->assertEquals(44, $ret['width']); } /** * Tests \Drupal\Core\Image\Image::scale(). */ public function testScaleSame() { - $toolkit = $this->getToolkitMock(array('resize')); - $image = new Image($toolkit, $this->source); - + $this->getTestImageForOperation('Scale'); // Dimensions are the same, resize should not be called. - $toolkit->expects($this->never()) - ->method('resize') + $this->toolkitOperation->expects($this->once()) + ->method('execute') ->will($this->returnArgument(0)); - $width = $image->scale(88, 100); - $this->assertEquals($width, 88); + $ret = $this->image->scale(88, 100, FALSE); + $this->assertEquals(88, $ret['width']); + $this->assertEquals(100, $ret['height']); } /** * Tests \Drupal\Core\Image\Image::scaleAndCrop(). */ public function testScaleAndCropWidth() { - $toolkit = $this->getToolkitMock(array('resize', 'crop')); - $image = new Image($toolkit, $this->source); - - $toolkit->expects($this->once()) - ->method('resize') - ->will($this->returnValue(TRUE)); - - $toolkit->expects($this->once()) - ->method('crop') + $this->getTestImageForOperation('ScaleAndCrop'); + $this->toolkitOperation->expects($this->once()) + ->method('execute') ->will($this->returnArgument(0)); - $x = $image->scaleAndCrop(34, 50); - $this->assertEquals($x, 5); + $ret = $this->image->scaleAndCrop(34, 50, FALSE); + $this->assertEquals(5, $ret['x']); } /** * Tests \Drupal\Core\Image\Image::scaleAndCrop(). */ public function testScaleAndCropHeight() { - $toolkit = $this->getToolkitMock(array('resize', 'crop')); - $image = new Image($toolkit, $this->source); - - $toolkit->expects($this->once()) - ->method('resize') - ->will($this->returnValue(TRUE)); - - $toolkit->expects($this->once()) - ->method('crop') - ->will($this->returnArgument(1)); + $this->getTestImageForOperation('ScaleAndCrop'); + $this->toolkitOperation->expects($this->once()) + ->method('execute') + ->will($this->returnArgument(0)); - $y = $image->scaleAndCrop(44, 40); - $this->assertEquals($y, 5); + $ret = $this->image->scaleAndCrop(44, 40); + $this->assertEquals(5, $ret['y']); } /** * Tests \Drupal\Core\Image\Image::scaleAndCrop(). */ public function testScaleAndCropFails() { - $toolkit = $this->getToolkitMock(array('resize', 'crop')); - $image = new Image($toolkit, $this->source); - - $toolkit->expects($this->once()) - ->method('resize') - ->will($this->returnValue(FALSE)); + $this->getTestImageForOperation('ScaleAndCrop'); + $this->toolkitOperation->expects($this->once()) + ->method('execute') + ->will($this->returnArgument(0)); - $toolkit->expects($this->never()) - ->method('crop'); - $image->scaleAndCrop(44, 40); + $ret = $this->image->scaleAndCrop(44, 40); + $this->assertEquals(0, $ret['x']); + $this->assertEquals(5, $ret['y']); + $this->assertEquals(44, $ret['resize']['width']); + $this->assertEquals(50, $ret['resize']['height']); } /** * Tests \Drupal\Core\Image\Image::crop(). - * - * @todo Because \Drupal\Tests\Core\Image\ImageTest::testCropWidth() tests - * image geometry conversions (like dimensions, coordinates, etc) and has - * lost its scope in https://drupal.org/node/2103635, it was temporarily - * removed. The test will be added back when implementing the dedicated - * functionality from https://drupal.org/node/2108307. */ + public function testCropWidth() { + $this->getTestImageForOperation('Crop'); + $this->toolkitOperation->expects($this->once()) + ->method('execute') + ->will($this->returnArgument(0)); + + // Cropping with width only should preserve the aspect ratio. + $ret = $this->image->crop(0, 0, 44); + $this->assertEquals(50, $ret['height']); + } /** * Tests \Drupal\Core\Image\Image::crop(). - * - * @todo Because \Drupal\Tests\Core\Image\ImageTest::testCropHeight() tests - * image geometry conversions (like dimensions, coordinates, etc) and has - * lost its scope in https://drupal.org/node/2103635, it was temporarily - * removed. The test will be added back when implementing the dedicated - * functionality from https://drupal.org/node/2108307. */ + public function testCropHeight() { + $this->getTestImageForOperation('Crop'); + $this->toolkitOperation->expects($this->once()) + ->method('execute') + ->will($this->returnArgument(0)); + + // Cropping with height only should preserve the aspect ratio. + $ret = $this->image->crop(0, 0, NULL, 50); + $this->assertEquals(44, $ret['width']); + } /** * Tests \Drupal\Core\Image\Image::crop(). */ public function testCrop() { - $toolkit = $this->getToolkitMock(array('crop')); - $image = new Image($toolkit, $this->source); + $this->getTestImageForOperation('Crop'); + $this->toolkitOperation->expects($this->once()) + ->method('execute') + ->will($this->returnArgument(0)); - $toolkit->expects($this->once()) - ->method('crop') - ->will($this->returnArgument(2)); - $width = $image->crop(0, 0, 44, 50); - $this->assertEquals($width, 44); + $ret = $this->image->crop(0, 0, 44, 50); + $this->assertEquals(44, $ret['width']); } /** * Tests \Drupal\Core\Image\Image::resize(). - * - * @todo Because \Drupal\Tests\Core\Image\ImageTest::testResize() tests image - * geometry conversions (like dimensions, coordinates, etc) and has lost its - * scope in https://drupal.org/node/2103635, it was temporarily removed. The - * test will be added back when implementing the dedicated functionality - * from https://drupal.org/node/2108307. */ + public function testResize() { + $this->getTestImageForOperation('Resize'); + $this->toolkitOperation->expects($this->once()) + ->method('execute') + ->will($this->returnArgument(0)); + + // Resize with integer for width and height. + $ret = $this->image->resize(30, 40); + $this->assertEquals(30, $ret['width']); + $this->assertEquals(40, $ret['height']); + } + + /** + * Tests \Drupal\Core\Image\Image::resize(). + */ + public function testFloatResize() { + $this->getTestImageForOperation('Resize'); + $this->toolkitOperation->expects($this->once()) + ->method('execute') + ->will($this->returnArgument(0)); + + // Pass a float for width. + $ret = $this->image->resize(30.4, 40); + // Ensure that the float was rounded to an integer first. + $this->assertEquals(30, $ret['width']); + } /** * Tests \Drupal\Core\Image\Image::desaturate(). */ public function testDesaturate() { - $toolkit = $this->getToolkitMock(array('desaturate')); - $image = new Image($toolkit, $this->source); + $this->getTestImageForOperation('Desaturate'); + $this->toolkitOperation->expects($this->once()) + ->method('execute') + ->will($this->returnArgument(0)); - $toolkit->expects($this->once()) - ->method('desaturate'); - $image->desaturate(); + $this->image->desaturate(); } /** * Tests \Drupal\Core\Image\Image::rotate(). */ public function testRotate() { - $toolkit = $this->getToolkitMock(array('rotate')); - $image = new Image($toolkit, $this->source); + $this->getTestImageForOperation('Rotate'); + $this->toolkitOperation->expects($this->once()) + ->method('execute') + ->will($this->returnArgument(0)); - $toolkit->expects($this->once()) - ->method('rotate'); - $image->rotate(90); + $ret = $this->image->rotate(90); + $this->assertEquals(90, $ret['degrees']); } }