diff --git a/.travis.yml b/.travis.yml index 4321d9f3207f4..89b2503c991e0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,6 +11,12 @@ addons: - language-pack-fr-base - ldap-utils - slapd + - libtiff-dev + - libjpeg-dev + - libdjvulibre-dev + - libwmf-dev + - pkg-config + - liblcms2-dev env: global: @@ -27,15 +33,16 @@ matrix: - php: 5.5 - php: 5.6 - php: 7.0 - env: deps=high + env: deps=high IMAGE_DRIVER=gmagick - php: 7.1 - env: deps=low + env: deps=low IMAGE_DRIVER=imagick fast_finish: true cache: directories: - .phpunit - php-$MIN_PHP + - cache services: - memcached @@ -89,6 +96,8 @@ install: - if [[ ! $skip ]]; then composer update; fi - if [[ ! $skip ]]; then ./phpunit install; fi - if [[ ! $skip && ! $PHP = hhvm* ]]; then php -i; else hhvm --php -r 'print_r($_SERVER);print_r(ini_get_all());'; fi + - if [[ $IMAGE_DRIVER = imagick ]]; then bash ./.travis/imagick.sh; fi + - if [[ $IMAGE_DRIVER = gmagick ]]; then bash ./.travis/gmagick.sh; fi script: - REPORT=' && echo -e "\\e[32mOK\\e[0m {}\\n\\n" || (echo -e "\\e[41mKO\\e[0m {}\\n\\n" && $(exit 1))' diff --git a/.travis/gmagick.sh b/.travis/gmagick.sh new file mode 100755 index 0000000000000..259564660892c --- /dev/null +++ b/.travis/gmagick.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +set -xe + +GRAPHICSMAGIC_VERSION="1.3.23" +if [ $TRAVIS_PHP_VERSION = '7.0' ] || [ $TRAVIS_PHP_VERSION = '7.1' ] +then + GMAGICK_VERSION="2.0.4RC1" +else + GMAGICK_VERSION="1.1.7RC2" +fi + +mkdir -p cache +cd cache + +if [ ! -e ./GraphicsMagick-$GRAPHICSMAGIC_VERSION ] +then + wget http://78.108.103.11/MIRROR/ftp/GraphicsMagick/1.3/GraphicsMagick-$GRAPHICSMAGIC_VERSION.tar.xz + tar -xf GraphicsMagick-$GRAPHICSMAGIC_VERSION.tar.xz + rm GraphicsMagick-$GRAPHICSMAGIC_VERSION.tar.xz + cd GraphicsMagick-$GRAPHICSMAGIC_VERSION + ./configure --prefix=$HOME/opt/gmagick --enable-shared --with-lcms2 + make -j +else + cd GraphicsMagick-$GRAPHICSMAGIC_VERSION +fi + +make install +cd .. + +if [ ! -e ./gmagick-$GMAGICK_VERSION ] +then + wget https://pecl.php.net/get/gmagick-$GMAGICK_VERSION.tgz + tar -xzf gmagick-$GMAGICK_VERSION.tgz + rm gmagick-$GMAGICK_VERSION.tgz + cd gmagick-$GMAGICK_VERSION + phpize + ./configure --with-gmagick=$HOME/opt/gmagick + make -j +else + cd gmagick-$GMAGICK_VERSION +fi + +make install +echo "extension=gmagick.so" >> `php --ini | grep "Loaded Configuration" | sed -e "s|.*:\s*||"` +php --ri gmagick diff --git a/.travis/imagick.sh b/.travis/imagick.sh new file mode 100755 index 0000000000000..41a15f873a2d3 --- /dev/null +++ b/.travis/imagick.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +set -xe + +IMAGEMAGICK_VERSION="6.8.9-10" +IMAGICK_VERSION="3.4.3" + +mkdir -p cache +cd cache + +if [ ! -e ./ImageMagick-$IMAGEMAGICK_VERSION ] +then + wget http://www.imagemagick.org/download/releases/ImageMagick-$IMAGEMAGICK_VERSION.tar.xz + tar -xf ImageMagick-$IMAGEMAGICK_VERSION.tar.xz + rm ImageMagick-$IMAGEMAGICK_VERSION.tar.xz + cd ImageMagick-$IMAGEMAGICK_VERSION + ./configure --prefix=$HOME/opt/imagemagick + make -j +else + cd ImageMagick-$IMAGEMAGICK_VERSION +fi + +make install +export PKG_CONFIG_PATH=$PKG_CONFIG_PATH:$HOME/opt/imagemagick/lib/pkgconfig +ln -s $HOME/opt/imagemagick/include/ImageMagick-6 $HOME/opt/imagemagick/include/ImageMagick +cd .. + +if [ ! -e ./imagick-$IMAGICK_VERSION ] +then + wget https://pecl.php.net/get/imagick-$IMAGICK_VERSION.tgz + tar -xzf imagick-$IMAGICK_VERSION.tgz + rm imagick-$IMAGICK_VERSION.tgz + cd imagick-$IMAGICK_VERSION + phpize + ./configure --with-imagick=$HOME/opt/imagemagick + make -j +else + cd imagick-$IMAGICK_VERSION +fi + +make install +echo "extension=imagick.so" >> `php --ini | grep "Loaded Configuration" | sed -e "s|.*:\s*||"` +php --ri imagick diff --git a/src/Symfony/Component/Image/.gitignore b/src/Symfony/Component/Image/.gitignore new file mode 100644 index 0000000000000..7db2fa2cf7f5c --- /dev/null +++ b/src/Symfony/Component/Image/.gitignore @@ -0,0 +1,5 @@ +composer.lock +phpunit.xml +vendor/ +Tests/Fixtures/results/in_out +!Tests/Fixtures/results/in_out/.placeholder diff --git a/src/Symfony/Component/Image/Draw/DrawerInterface.php b/src/Symfony/Component/Image/Draw/DrawerInterface.php new file mode 100644 index 0000000000000..33d85a57db8af --- /dev/null +++ b/src/Symfony/Component/Image/Draw/DrawerInterface.php @@ -0,0 +1,146 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Draw; + +use Symfony\Component\Image\Image\AbstractFont; +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\PointInterface; +use Symfony\Component\Image\Exception\RuntimeException; + +interface DrawerInterface +{ + /** + * Draws an arc on a starting at a given x, y coordinates under a given + * start and end angles. + * + * @param PointInterface $center + * @param BoxInterface $size + * @param int $start + * @param int $end + * @param ColorInterface $color + * @param int $thickness + * + * @throws RuntimeException + * + * @return DrawerInterface + */ + public function arc(PointInterface $center, BoxInterface $size, $start, $end, ColorInterface $color, $thickness = 1); + + /** + * Same as arc, but also connects end points with a straight line. + * + * @param PointInterface $center + * @param BoxInterface $size + * @param int $start + * @param int $end + * @param ColorInterface $color + * @param bool $fill + * @param int $thickness + * + * @throws RuntimeException + * + * @return DrawerInterface + */ + public function chord(PointInterface $center, BoxInterface $size, $start, $end, ColorInterface $color, $fill = false, $thickness = 1); + + /** + * Draws and ellipse with center at the given x, y coordinates, and given + * width and height. + * + * @param PointInterface $center + * @param BoxInterface $size + * @param ColorInterface $color + * @param bool $fill + * @param int $thickness + * + * @throws RuntimeException + * + * @return DrawerInterface + */ + public function ellipse(PointInterface $center, BoxInterface $size, ColorInterface $color, $fill = false, $thickness = 1); + + /** + * Draws a line from start(x, y) to end(x, y) coordinates. + * + * @param PointInterface $start + * @param PointInterface $end + * @param ColorInterface $outline + * @param int $thickness + * + * @return DrawerInterface + */ + public function line(PointInterface $start, PointInterface $end, ColorInterface $outline, $thickness = 1); + + /** + * Same as arc, but connects end points and the center. + * + * @param PointInterface $center + * @param BoxInterface $size + * @param int $start + * @param int $end + * @param ColorInterface $color + * @param bool $fill + * @param int $thickness + * + * @throws RuntimeException + * + * @return DrawerInterface + */ + public function pieSlice(PointInterface $center, BoxInterface $size, $start, $end, ColorInterface $color, $fill = false, $thickness = 1); + + /** + * Places a one pixel point at specific coordinates and fills it with + * specified color. + * + * @param PointInterface $position + * @param ColorInterface $color + * + * @throws RuntimeException + * + * @return DrawerInterface + */ + public function dot(PointInterface $position, ColorInterface $color); + + /** + * Draws a polygon using array of x, y coordinates. Must contain at least + * three coordinates. + * + * @param array $coordinates + * @param ColorInterface $color + * @param bool $fill + * @param int $thickness + * + * @throws RuntimeException + * + * @return DrawerInterface + */ + public function polygon(array $coordinates, ColorInterface $color, $fill = false, $thickness = 1); + + /** + * Annotates image with specified text at a given position starting on the + * top left of the final text box. + * + * The rotation is done CW + * + * @param string $string + * @param AbstractFont $font + * @param PointInterface $position + * @param int $angle + * @param int $width + * + * @throws RuntimeException + * + * @return DrawerInterface + */ + public function text($string, AbstractFont $font, PointInterface $position, $angle = 0, $width = null); +} diff --git a/src/Symfony/Component/Image/Effects/EffectsInterface.php b/src/Symfony/Component/Image/Effects/EffectsInterface.php new file mode 100644 index 0000000000000..c327f010b601c --- /dev/null +++ b/src/Symfony/Component/Image/Effects/EffectsInterface.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Effects; + +use Symfony\Component\Image\Exception\RuntimeException; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; + +interface EffectsInterface +{ + /** + * Apply gamma correction. + * + * @param float $correction + * + * @return EffectsInterface + * + * @throws RuntimeException + */ + public function gamma($correction); + + /** + * Invert the colors of the image. + * + * @return EffectsInterface + * + * @throws RuntimeException + */ + public function negative(); + + /** + * Grayscale the image. + * + * @return EffectsInterface + * + * @throws RuntimeException + */ + public function grayscale(); + + /** + * Colorize the image. + * + * @param ColorInterface $color + * + * @return EffectsInterface + * + * @throws RuntimeException + */ + public function colorize(ColorInterface $color); + + /** + * Sharpens the image. + * + * @return EffectsInterface + * + * @throws RuntimeException + */ + public function sharpen(); + + /** + * Blur the image. + * + * @param float|int $sigma + * + * @return EffectsInterface + * + * @throws RuntimeException + */ + public function blur($sigma); +} diff --git a/src/Symfony/Component/Image/Exception/ExceptionInterface.php b/src/Symfony/Component/Image/Exception/ExceptionInterface.php new file mode 100644 index 0000000000000..0f26b9acce285 --- /dev/null +++ b/src/Symfony/Component/Image/Exception/ExceptionInterface.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Exception; + +interface ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Image/Exception/InvalidArgumentException.php b/src/Symfony/Component/Image/Exception/InvalidArgumentException.php new file mode 100644 index 0000000000000..75e5da1e81f46 --- /dev/null +++ b/src/Symfony/Component/Image/Exception/InvalidArgumentException.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Exception; + +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Image/Exception/NotSupportedException.php b/src/Symfony/Component/Image/Exception/NotSupportedException.php new file mode 100644 index 0000000000000..bd3e8a90506f4 --- /dev/null +++ b/src/Symfony/Component/Image/Exception/NotSupportedException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Exception; + +/** + * Should be used when a driver does not support an operation. + */ +class NotSupportedException extends RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Image/Exception/OutOfBoundsException.php b/src/Symfony/Component/Image/Exception/OutOfBoundsException.php new file mode 100644 index 0000000000000..13d6dc497d896 --- /dev/null +++ b/src/Symfony/Component/Image/Exception/OutOfBoundsException.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Exception; + +class OutOfBoundsException extends \OutOfBoundsException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Image/Exception/RuntimeException.php b/src/Symfony/Component/Image/Exception/RuntimeException.php new file mode 100644 index 0000000000000..51a63c07155a0 --- /dev/null +++ b/src/Symfony/Component/Image/Exception/RuntimeException.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Exception; + +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Image/Filter/Advanced/Border.php b/src/Symfony/Component/Image/Filter/Advanced/Border.php new file mode 100644 index 0000000000000..60ed1facf6989 --- /dev/null +++ b/src/Symfony/Component/Image/Filter/Advanced/Border.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter\Advanced; + +use Symfony\Component\Image\Filter\FilterInterface; +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\Point; + +/** + * A border filter. + */ +class Border implements FilterInterface +{ + /** + * @var ColorInterface + */ + private $color; + + /** + * @var int + */ + private $width; + + /** + * @var int + */ + private $height; + + /** + * Constructs Border filter with given color, width and height. + * + * @param ColorInterface $color + * @param int $width Width of the border on the left and right sides of the image + * @param int $height Height of the border on the top and bottom sides of the image + */ + public function __construct(ColorInterface $color, $width = 1, $height = 1) + { + $this->color = $color; + $this->width = $width; + $this->height = $height; + } + + /** + * {@inheritdoc} + */ + public function apply(ImageInterface $image) + { + $size = $image->getSize(); + $width = $size->getWidth(); + $height = $size->getHeight(); + + $draw = $image->draw(); + + // Draw top and bottom lines + $draw + ->line( + new Point(0, 0), + new Point($width - 1, 0), + $this->color, + $this->height + ) + ->line( + new Point($width - 1, $height - 1), + new Point(0, $height - 1), + $this->color, + $this->height + ) + ; + + // Draw sides + $draw + ->line( + new Point(0, 0), + new Point(0, $height - 1), + $this->color, + $this->width + ) + ->line( + new Point($width - 1, 0), + new Point($width - 1, $height - 1), + $this->color, + $this->width + ) + ; + + return $image; + } +} diff --git a/src/Symfony/Component/Image/Filter/Advanced/Canvas.php b/src/Symfony/Component/Image/Filter/Advanced/Canvas.php new file mode 100644 index 0000000000000..7f06bcfdb10e4 --- /dev/null +++ b/src/Symfony/Component/Image/Filter/Advanced/Canvas.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter\Advanced; + +use Symfony\Component\Image\Filter\FilterInterface; +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Image\Point; +use Symfony\Component\Image\Image\PointInterface; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\LoaderInterface; + +/** + * A canvas filter. + */ +class Canvas implements FilterInterface +{ + /** + * @var BoxInterface + */ + private $size; + + /** + * @var PointInterface + */ + private $placement; + + /** + * @var ColorInterface + */ + private $background; + + /** + * @var LoaderInterface + */ + private $imagine; + + /** + * Constructs Canvas filter with given width and height and the placement of the current image + * inside the new canvas. + * + * @param LoaderInterface $imagine + * @param BoxInterface $size + * @param PointInterface $placement + * @param ColorInterface $background + */ + public function __construct(LoaderInterface $imagine, BoxInterface $size, PointInterface $placement = null, ColorInterface $background = null) + { + $this->imagine = $imagine; + $this->size = $size; + $this->placement = $placement ?: new Point(0, 0); + $this->background = $background; + } + + /** + * {@inheritdoc} + */ + public function apply(ImageInterface $image) + { + $canvas = $this->imagine->create($this->size, $this->background); + $canvas->paste($image, $this->placement); + + return $canvas; + } +} diff --git a/src/Symfony/Component/Image/Filter/Advanced/Grayscale.php b/src/Symfony/Component/Image/Filter/Advanced/Grayscale.php new file mode 100644 index 0000000000000..481e1292a92e2 --- /dev/null +++ b/src/Symfony/Component/Image/Filter/Advanced/Grayscale.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter\Advanced; + +use Symfony\Component\Image\Filter\FilterInterface; +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\Point; + +/** + * The Grayscale filter calculates the gray-value based on RGB. + */ +class Grayscale extends OnPixelBased implements FilterInterface +{ + public function __construct() + { + parent::__construct(function (ImageInterface $image, Point $point) { + $color = $image->getColorAt($point); + $image->draw()->dot($point, $color->grayscale()); + }); + } +} diff --git a/src/Symfony/Component/Image/Filter/Advanced/OnPixelBased.php b/src/Symfony/Component/Image/Filter/Advanced/OnPixelBased.php new file mode 100644 index 0000000000000..5ff6c64d81483 --- /dev/null +++ b/src/Symfony/Component/Image/Filter/Advanced/OnPixelBased.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter\Advanced; + +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Filter\FilterInterface; +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\Point; + +/** + * The OnPixelBased takes a callable, and for each pixel, this callable is called with the + * image (Symfony\Component\Image\Image\ImageInterface) and the current point (Symfony\Component\Image\Image\Point). + */ +class OnPixelBased implements FilterInterface +{ + protected $callback; + + public function __construct($callback) + { + if (!is_callable($callback)) { + throw new InvalidArgumentException('$callback has to be callable'); + } + + $this->callback = $callback; + } + + /** + * Applies scheduled transformation to ImageInterface instance + * Returns processed ImageInterface instance. + * + * @param ImageInterface $image + * + * @return ImageInterface + */ + public function apply(ImageInterface $image) + { + $w = $image->getSize()->getWidth(); + $h = $image->getSize()->getHeight(); + + for ($x = 0; $x < $w; ++$x) { + for ($y = 0; $y < $h; ++$y) { + call_user_func($this->callback, $image, new Point($x, $y)); + } + } + + return $image; + } +} diff --git a/src/Symfony/Component/Image/Filter/Advanced/RelativeResize.php b/src/Symfony/Component/Image/Filter/Advanced/RelativeResize.php new file mode 100644 index 0000000000000..c6c5b8f4d08ec --- /dev/null +++ b/src/Symfony/Component/Image/Filter/Advanced/RelativeResize.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter\Advanced; + +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Filter\FilterInterface; +use Symfony\Component\Image\Image\ImageInterface; + +/** + * The RelativeResize filter allows images to be resized relative to their + * existing dimensions. + */ +class RelativeResize implements FilterInterface +{ + private $method; + private $parameter; + + /** + * Constructs a RelativeResize filter with the given method and argument. + * + * @param string $method BoxInterface method + * @param mixed $parameter Parameter for BoxInterface method + */ + public function __construct($method, $parameter) + { + if (!in_array($method, array('heighten', 'increase', 'scale', 'widen'))) { + throw new InvalidArgumentException(sprintf('Unsupported method: %s', $method)); + } + + $this->method = $method; + $this->parameter = $parameter; + } + + /** + * {@inheritdoc} + */ + public function apply(ImageInterface $image) + { + return $image->resize(call_user_func(array($image->getSize(), $this->method), $this->parameter)); + } +} diff --git a/src/Symfony/Component/Image/Filter/Basic/ApplyMask.php b/src/Symfony/Component/Image/Filter/Basic/ApplyMask.php new file mode 100644 index 0000000000000..a28a9cea17f2b --- /dev/null +++ b/src/Symfony/Component/Image/Filter/Basic/ApplyMask.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter\Basic; + +use Symfony\Component\Image\Filter\FilterInterface; +use Symfony\Component\Image\Image\ImageInterface; + +/** + * An apply mask filter. + */ +class ApplyMask implements FilterInterface +{ + /** + * @var ImageInterface + */ + private $mask; + + /** + * @param ImageInterface $mask + */ + public function __construct(ImageInterface $mask) + { + $this->mask = $mask; + } + + /** + * {@inheritdoc} + */ + public function apply(ImageInterface $image) + { + return $image->applyMask($this->mask); + } +} diff --git a/src/Symfony/Component/Image/Filter/Basic/Autorotate.php b/src/Symfony/Component/Image/Filter/Basic/Autorotate.php new file mode 100644 index 0000000000000..12181c43f86bc --- /dev/null +++ b/src/Symfony/Component/Image/Filter/Basic/Autorotate.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter\Basic; + +use Symfony\Component\Image\Filter\FilterInterface; +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; + +/** + * Rotates an image automatically based on exif information. + * + * Your attention please: This filter requires the use of the + * ExifMetadataReader to work. + * + * @see https://imagine.readthedocs.org/en/latest/usage/metadata.html + */ +class Autorotate implements FilterInterface +{ + private $color; + + /** + * @param string|array|ColorInterface $color A color + */ + public function __construct($color = '000000') + { + $this->color = $color; + } + + /** + * {@inheritdoc} + */ + public function apply(ImageInterface $image) + { + $metadata = $image->metadata(); + + switch (isset($metadata['ifd0.Orientation']) ? $metadata['ifd0.Orientation'] : null) { + case 1: // top-left + break; + case 2: // top-right + $image->flipHorizontally(); + break; + case 3: // bottom-right + $image->rotate(180, $this->getColor($image)); + break; + case 4: // bottom-left + $image->flipHorizontally(); + $image->rotate(180, $this->getColor($image)); + break; + case 5: // left-top + $image->flipHorizontally(); + $image->rotate(-90, $this->getColor($image)); + break; + case 6: // right-top + $image->rotate(90, $this->getColor($image)); + break; + case 7: // right-bottom + $image->flipHorizontally(); + $image->rotate(90, $this->getColor($image)); + break; + case 8: // left-bottom + $image->rotate(-90, $this->getColor($image)); + break; + default: // Invalid orientation + break; + } + + return $image; + } + + private function getColor(ImageInterface $image) + { + if ($this->color instanceof ColorInterface) { + return $this->color; + } + + return $image->palette()->color($this->color); + } +} diff --git a/src/Symfony/Component/Image/Filter/Basic/Copy.php b/src/Symfony/Component/Image/Filter/Basic/Copy.php new file mode 100644 index 0000000000000..3c130c78dbd96 --- /dev/null +++ b/src/Symfony/Component/Image/Filter/Basic/Copy.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter\Basic; + +use Symfony\Component\Image\Filter\FilterInterface; +use Symfony\Component\Image\Image\ImageInterface; + +/** + * A copy filter. + */ +class Copy implements FilterInterface +{ + /** + * {@inheritdoc} + */ + public function apply(ImageInterface $image) + { + return $image->copy(); + } +} diff --git a/src/Symfony/Component/Image/Filter/Basic/Crop.php b/src/Symfony/Component/Image/Filter/Basic/Crop.php new file mode 100644 index 0000000000000..5b5a04ee171f3 --- /dev/null +++ b/src/Symfony/Component/Image/Filter/Basic/Crop.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter\Basic; + +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Image\PointInterface; +use Symfony\Component\Image\Filter\FilterInterface; + +/** + * A crop filter. + */ +class Crop implements FilterInterface +{ + /** + * @var PointInterface + */ + private $start; + + /** + * @var BoxInterface + */ + private $size; + + /** + * Constructs a Crop filter with given x, y, coordinates and crop width and + * height values. + * + * @param PointInterface $start + * @param BoxInterface $size + */ + public function __construct(PointInterface $start, BoxInterface $size) + { + $this->start = $start; + $this->size = $size; + } + + /** + * {@inheritdoc} + */ + public function apply(ImageInterface $image) + { + return $image->crop($this->start, $this->size); + } +} diff --git a/src/Symfony/Component/Image/Filter/Basic/Fill.php b/src/Symfony/Component/Image/Filter/Basic/Fill.php new file mode 100644 index 0000000000000..cc4bbefaa7486 --- /dev/null +++ b/src/Symfony/Component/Image/Filter/Basic/Fill.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter\Basic; + +use Symfony\Component\Image\Filter\FilterInterface; +use Symfony\Component\Image\Image\Fill\FillInterface; +use Symfony\Component\Image\Image\ImageInterface; + +/** + * A fill filter. + */ +class Fill implements FilterInterface +{ + /** + * @var FillInterface + */ + private $fill; + + /** + * @param FillInterface $fill + */ + public function __construct(FillInterface $fill) + { + $this->fill = $fill; + } + + /** + * {@inheritdoc} + */ + public function apply(ImageInterface $image) + { + return $image->fill($this->fill); + } +} diff --git a/src/Symfony/Component/Image/Filter/Basic/FlipHorizontally.php b/src/Symfony/Component/Image/Filter/Basic/FlipHorizontally.php new file mode 100644 index 0000000000000..fe10dfbfd3c90 --- /dev/null +++ b/src/Symfony/Component/Image/Filter/Basic/FlipHorizontally.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter\Basic; + +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Filter\FilterInterface; + +/** + * A "flip horizontally" filter. + */ +class FlipHorizontally implements FilterInterface +{ + /** + * {@inheritdoc} + */ + public function apply(ImageInterface $image) + { + return $image->flipHorizontally(); + } +} diff --git a/src/Symfony/Component/Image/Filter/Basic/FlipVertically.php b/src/Symfony/Component/Image/Filter/Basic/FlipVertically.php new file mode 100644 index 0000000000000..079727b645d45 --- /dev/null +++ b/src/Symfony/Component/Image/Filter/Basic/FlipVertically.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter\Basic; + +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Filter\FilterInterface; + +/** + * A "flip vertically" filter. + */ +class FlipVertically implements FilterInterface +{ + /** + * {@inheritdoc} + */ + public function apply(ImageInterface $image) + { + return $image->flipVertically(); + } +} diff --git a/src/Symfony/Component/Image/Filter/Basic/Paste.php b/src/Symfony/Component/Image/Filter/Basic/Paste.php new file mode 100644 index 0000000000000..dbef92eccff67 --- /dev/null +++ b/src/Symfony/Component/Image/Filter/Basic/Paste.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter\Basic; + +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\PointInterface; +use Symfony\Component\Image\Filter\FilterInterface; + +/** + * A paste filter. + */ +class Paste implements FilterInterface +{ + /** + * @var ImageInterface + */ + private $image; + + /** + * @var PointInterface + */ + private $start; + + /** + * Constructs a Paste filter with given ImageInterface to paste and x, y + * coordinates of target position. + * + * @param ImageInterface $image + * @param PointInterface $start + */ + public function __construct(ImageInterface $image, PointInterface $start) + { + $this->image = $image; + $this->start = $start; + } + + /** + * {@inheritdoc} + */ + public function apply(ImageInterface $image) + { + return $image->paste($this->image, $this->start); + } +} diff --git a/src/Symfony/Component/Image/Filter/Basic/Resize.php b/src/Symfony/Component/Image/Filter/Basic/Resize.php new file mode 100644 index 0000000000000..86e9c38f83c41 --- /dev/null +++ b/src/Symfony/Component/Image/Filter/Basic/Resize.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter\Basic; + +use Symfony\Component\Image\Filter\FilterInterface; +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\BoxInterface; + +/** + * A resize filter. + */ +class Resize implements FilterInterface +{ + /** + * @var BoxInterface + */ + private $size; + private $filter; + + /** + * Constructs Resize filter with given width and height. + * + * @param BoxInterface $size + * @param string $filter + */ + public function __construct(BoxInterface $size, $filter = ImageInterface::FILTER_UNDEFINED) + { + $this->size = $size; + $this->filter = $filter; + } + + /** + * {@inheritdoc} + */ + public function apply(ImageInterface $image) + { + return $image->resize($this->size, $this->filter); + } +} diff --git a/src/Symfony/Component/Image/Filter/Basic/Rotate.php b/src/Symfony/Component/Image/Filter/Basic/Rotate.php new file mode 100644 index 0000000000000..82309f322e4e6 --- /dev/null +++ b/src/Symfony/Component/Image/Filter/Basic/Rotate.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter\Basic; + +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Filter\FilterInterface; + +/** + * A rotate filter. + */ +class Rotate implements FilterInterface +{ + /** + * @var int + */ + private $angle; + + /** + * @var ColorInterface + */ + private $background; + + /** + * Constructs Rotate filter with given angle and background color. + * + * @param int $angle + * @param ColorInterface $background + */ + public function __construct($angle, ColorInterface $background = null) + { + $this->angle = $angle; + $this->background = $background; + } + + /** + * {@inheritdoc} + */ + public function apply(ImageInterface $image) + { + return $image->rotate($this->angle, $this->background); + } +} diff --git a/src/Symfony/Component/Image/Filter/Basic/Save.php b/src/Symfony/Component/Image/Filter/Basic/Save.php new file mode 100644 index 0000000000000..4116b05b2b7bf --- /dev/null +++ b/src/Symfony/Component/Image/Filter/Basic/Save.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter\Basic; + +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Filter\FilterInterface; + +/** + * A save filter. + */ +class Save implements FilterInterface +{ + /** + * @var string + */ + private $path; + + /** + * @var array + */ + private $options; + + /** + * Constructs Save filter with given path and options. + * + * @param string $path + * @param array $options + */ + public function __construct($path = null, array $options = array()) + { + $this->path = $path; + $this->options = $options; + } + + /** + * {@inheritdoc} + */ + public function apply(ImageInterface $image) + { + return $image->save($this->path, $this->options); + } +} diff --git a/src/Symfony/Component/Image/Filter/Basic/Show.php b/src/Symfony/Component/Image/Filter/Basic/Show.php new file mode 100644 index 0000000000000..752d4dc683662 --- /dev/null +++ b/src/Symfony/Component/Image/Filter/Basic/Show.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter\Basic; + +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Filter\FilterInterface; + +/** + * A show filter. + */ +class Show implements FilterInterface +{ + /** + * @var string + */ + private $format; + + /** + * @var array + */ + private $options; + + /** + * Constructs the Show filter with given format and options. + * + * @param string $format + * @param array $options + */ + public function __construct($format, array $options = array()) + { + $this->format = $format; + $this->options = $options; + } + + /** + * {@inheritdoc} + */ + public function apply(ImageInterface $image) + { + return $image->show($this->format, $this->options); + } +} diff --git a/src/Symfony/Component/Image/Filter/Basic/Strip.php b/src/Symfony/Component/Image/Filter/Basic/Strip.php new file mode 100644 index 0000000000000..0b87c3ca4ebd0 --- /dev/null +++ b/src/Symfony/Component/Image/Filter/Basic/Strip.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter\Basic; + +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Filter\FilterInterface; + +/** + * A strip filter. + */ +class Strip implements FilterInterface +{ + /** + * {@inheritdoc} + */ + public function apply(ImageInterface $image) + { + return $image->strip(); + } +} diff --git a/src/Symfony/Component/Image/Filter/Basic/Thumbnail.php b/src/Symfony/Component/Image/Filter/Basic/Thumbnail.php new file mode 100644 index 0000000000000..ad949f4dab517 --- /dev/null +++ b/src/Symfony/Component/Image/Filter/Basic/Thumbnail.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter\Basic; + +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Filter\FilterInterface; + +/** + * A thumbnail filter. + */ +class Thumbnail implements FilterInterface +{ + /** + * @var BoxInterface + */ + private $size; + + /** + * @var string + */ + private $mode; + + /** + * @var string + */ + private $filter; + + /** + * Constructs the Thumbnail filter with given width, height and mode. + * + * @param BoxInterface $size + * @param string $mode + * @param string $filter + */ + public function __construct(BoxInterface $size, $mode = ImageInterface::THUMBNAIL_INSET, $filter = ImageInterface::FILTER_UNDEFINED) + { + $this->size = $size; + $this->mode = $mode; + $this->filter = $filter; + } + + /** + * {@inheritdoc} + */ + public function apply(ImageInterface $image) + { + return $image->thumbnail($this->size, $this->mode, $this->filter); + } +} diff --git a/src/Symfony/Component/Image/Filter/Basic/WebOptimization.php b/src/Symfony/Component/Image/Filter/Basic/WebOptimization.php new file mode 100644 index 0000000000000..06fb5102d423d --- /dev/null +++ b/src/Symfony/Component/Image/Filter/Basic/WebOptimization.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter\Basic; + +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\Palette\RGB; +use Symfony\Component\Image\Filter\FilterInterface; + +/** + * A filter to render web-optimized images + */ +class WebOptimization implements FilterInterface +{ + private $palette; + private $path; + private $options; + + public function __construct($path = null, array $options = array()) + { + $this->path = $path; + $this->options = array_replace(array( + 'resolution-units' => ImageInterface::RESOLUTION_PIXELSPERINCH, + 'resolution-y' => 72, + 'resolution-x' => 72, + ), $options); + $this->palette = new RGB(); + } + + /** + * {@inheritdoc} + */ + public function apply(ImageInterface $image) + { + $image + ->usePalette($this->palette) + ->strip(); + + if (is_callable($this->path)) { + $path = call_user_func($this->path, $image); + } elseif (null !== $this->path) { + $path = $this->path; + } else { + return $image; + } + + return $image->save($path, $this->options); + } +} diff --git a/src/Symfony/Component/Image/Filter/FilterInterface.php b/src/Symfony/Component/Image/Filter/FilterInterface.php new file mode 100644 index 0000000000000..7f9f90d37f046 --- /dev/null +++ b/src/Symfony/Component/Image/Filter/FilterInterface.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter; + +use Symfony\Component\Image\Image\ImageInterface; + +/** + * Interface for imagine filters + */ +interface FilterInterface +{ + /** + * Applies scheduled transformation to ImageInterface instance + * Returns processed ImageInterface instance + * + * @param ImageInterface $image + * + * @return ImageInterface + */ + public function apply(ImageInterface $image); +} diff --git a/src/Symfony/Component/Image/Filter/LoaderAware.php b/src/Symfony/Component/Image/Filter/LoaderAware.php new file mode 100644 index 0000000000000..1041222eb8e03 --- /dev/null +++ b/src/Symfony/Component/Image/Filter/LoaderAware.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter; + +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Image\LoaderInterface; + +/** + * LoaderAware base class + */ +abstract class LoaderAware implements FilterInterface +{ + /** + * An LoaderInterface instance. + * + * @var LoaderInterface + */ + private $imagine; + + /** + * Set LoaderInterface instance. + * + * @param LoaderInterface $imagine An LoaderInterface instance + */ + public function setLoader(LoaderInterface $imagine) + { + $this->imagine = $imagine; + } + + /** + * Get LoaderInterface instance. + * + * @return LoaderInterface + * + * @throws InvalidArgumentException + */ + public function getLoader() + { + if (!$this->imagine instanceof LoaderInterface) { + throw new InvalidArgumentException(sprintf('In order to use %s pass an Symfony\Component\Image\Image\LoaderInterface instance to filter constructor', get_class($this))); + } + + return $this->imagine; + } +} diff --git a/src/Symfony/Component/Image/Filter/Transformation.php b/src/Symfony/Component/Image/Filter/Transformation.php new file mode 100644 index 0000000000000..2e3a1a663958d --- /dev/null +++ b/src/Symfony/Component/Image/Filter/Transformation.php @@ -0,0 +1,240 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Filter; + +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Filter\Basic\ApplyMask; +use Symfony\Component\Image\Filter\Basic\Copy; +use Symfony\Component\Image\Filter\Basic\Crop; +use Symfony\Component\Image\Filter\Basic\Fill; +use Symfony\Component\Image\Filter\Basic\FlipVertically; +use Symfony\Component\Image\Filter\Basic\FlipHorizontally; +use Symfony\Component\Image\Filter\Basic\Paste; +use Symfony\Component\Image\Filter\Basic\Resize; +use Symfony\Component\Image\Filter\Basic\Rotate; +use Symfony\Component\Image\Filter\Basic\Save; +use Symfony\Component\Image\Filter\Basic\Show; +use Symfony\Component\Image\Filter\Basic\Strip; +use Symfony\Component\Image\Filter\Basic\Thumbnail; +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\LoaderInterface; +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\Fill\FillInterface; +use Symfony\Component\Image\Image\ManipulatorInterface; +use Symfony\Component\Image\Image\PointInterface; + +/** + * A transformation filter + */ +final class Transformation implements FilterInterface, ManipulatorInterface +{ + /** + * @var array + */ + private $filters = array(); + + /** + * @var array + */ + private $sorted; + + /** + * An LoaderInterface instance. + * + * @var LoaderInterface + */ + private $imagine; + + /** + * Class constructor. + * + * @param LoaderInterface $imagine An LoaderInterface instance + */ + public function __construct(LoaderInterface $imagine = null) + { + $this->imagine = $imagine; + } + + /** + * Applies a given FilterInterface onto given ImageInterface and returns + * modified ImageInterface + * + * @param ImageInterface $image + * @param FilterInterface $filter + * + * @return ImageInterface + * @throws InvalidArgumentException + */ + public function applyFilter(ImageInterface $image, FilterInterface $filter) + { + if ($filter instanceof LoaderAware) { + if ($this->imagine === null) { + throw new InvalidArgumentException(sprintf('In order to use %s pass an Symfony\Component\Image\Image\LoaderInterface instance to Transformation constructor', get_class($filter))); + } + $filter->setLoader($this->imagine); + } + + return $filter->apply($image); + } + + /** + * Returns a list of filters sorted by their priority. Filters with same priority will be returned in the order they were added. + * + * @return array + */ + public function getFilters() + { + if (null === $this->sorted) { + if (!empty($this->filters)) { + ksort($this->filters); + $this->sorted = call_user_func_array('array_merge', $this->filters); + } else { + $this->sorted = array(); + } + } + + return $this->sorted; + } + + /** + * {@inheritdoc} + */ + public function apply(ImageInterface $image) + { + return array_reduce( + $this->getFilters(), + array($this, 'applyFilter'), + $image + ); + } + + /** + * {@inheritdoc} + */ + public function copy() + { + return $this->add(new Copy()); + } + + /** + * {@inheritdoc} + */ + public function crop(PointInterface $start, BoxInterface $size) + { + return $this->add(new Crop($start, $size)); + } + + /** + * {@inheritdoc} + */ + public function flipHorizontally() + { + return $this->add(new FlipHorizontally()); + } + + /** + * {@inheritdoc} + */ + public function flipVertically() + { + return $this->add(new FlipVertically()); + } + + /** + * {@inheritdoc} + */ + public function strip() + { + return $this->add(new Strip()); + } + + /** + * {@inheritdoc} + */ + public function paste(ImageInterface $image, PointInterface $start) + { + return $this->add(new Paste($image, $start)); + } + + /** + * {@inheritdoc} + */ + public function applyMask(ImageInterface $mask) + { + return $this->add(new ApplyMask($mask)); + } + + /** + * {@inheritdoc} + */ + public function fill(FillInterface $fill) + { + return $this->add(new Fill($fill)); + } + + /** + * {@inheritdoc} + */ + public function resize(BoxInterface $size, $filter = ImageInterface::FILTER_UNDEFINED) + { + return $this->add(new Resize($size, $filter)); + } + + /** + * {@inheritdoc} + */ + public function rotate($angle, ColorInterface $background = null) + { + return $this->add(new Rotate($angle, $background)); + } + + /** + * {@inheritdoc} + */ + public function save($path = null, array $options = array()) + { + return $this->add(new Save($path, $options)); + } + + /** + * {@inheritdoc} + */ + public function show($format, array $options = array()) + { + return $this->add(new Show($format, $options)); + } + + /** + * {@inheritdoc} + */ + public function thumbnail(BoxInterface $size, $mode = ImageInterface::THUMBNAIL_INSET, $filter = ImageInterface::FILTER_UNDEFINED) + { + return $this->add(new Thumbnail($size, $mode, $filter)); + } + + /** + * Registers a given FilterInterface in an internal array of filters for + * later application to an instance of ImageInterface + * + * @param FilterInterface $filter + * @param int $priority + * @return Transformation + */ + public function add(FilterInterface $filter, $priority = 0) + { + $this->filters[$priority][] = $filter; + $this->sorted = null; + + return $this; + } +} diff --git a/src/Symfony/Component/Image/Gd/Drawer.php b/src/Symfony/Component/Image/Gd/Drawer.php new file mode 100644 index 0000000000000..b543d4c8e0aba --- /dev/null +++ b/src/Symfony/Component/Image/Gd/Drawer.php @@ -0,0 +1,333 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Gd; + +use Symfony\Component\Image\Draw\DrawerInterface; +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Exception\RuntimeException; +use Symfony\Component\Image\Image\AbstractFont; +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\Palette\Color\RGB as RGBColor; +use Symfony\Component\Image\Image\PointInterface; + +/** + * Drawer implementation using the GD library + */ +final class Drawer implements DrawerInterface +{ + /** + * @var resource + */ + private $resource; + + /** + * @var array + */ + private $info; + + /** + * Constructs Drawer with a given gd image resource + * + * @param resource $resource + */ + public function __construct($resource) + { + $this->loadGdInfo(); + $this->resource = $resource; + } + + /** + * {@inheritdoc} + */ + public function arc(PointInterface $center, BoxInterface $size, $start, $end, ColorInterface $color, $thickness = 1) + { + imagesetthickness($this->resource, max(1, (int) $thickness)); + + if (false === imagealphablending($this->resource, true)) { + throw new RuntimeException('Draw arc operation failed'); + } + + if (false === imagearc($this->resource, $center->getX(), $center->getY(), $size->getWidth(), $size->getHeight(), $start, $end, $this->getColor($color))) { + imagealphablending($this->resource, false); + throw new RuntimeException('Draw arc operation failed'); + } + + if (false === imagealphablending($this->resource, false)) { + throw new RuntimeException('Draw arc operation failed'); + } + + return $this; + } + + /** + * This function does not work properly because of a bug in GD + * + * {@inheritdoc} + */ + public function chord(PointInterface $center, BoxInterface $size, $start, $end, ColorInterface $color, $fill = false, $thickness = 1) + { + imagesetthickness($this->resource, max(1, (int) $thickness)); + + if ($fill) { + $style = IMG_ARC_CHORD; + } else { + $style = IMG_ARC_CHORD | IMG_ARC_NOFILL; + } + + if (false === imagealphablending($this->resource, true)) { + throw new RuntimeException('Draw chord operation failed'); + } + + if (false === imagefilledarc($this->resource, $center->getX(), $center->getY(), $size->getWidth(), $size->getHeight(), $start, $end, $this->getColor($color), $style)) { + imagealphablending($this->resource, false); + throw new RuntimeException('Draw chord operation failed'); + } + + if (false === imagealphablending($this->resource, false)) { + throw new RuntimeException('Draw chord operation failed'); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function ellipse(PointInterface $center, BoxInterface $size, ColorInterface $color, $fill = false, $thickness = 1) + { + imagesetthickness($this->resource, max(1, (int) $thickness)); + + if ($fill) { + $callback = 'imagefilledellipse'; + } else { + $callback = 'imageellipse'; + } + + if (false === imagealphablending($this->resource, true)) { + throw new RuntimeException('Draw ellipse operation failed'); + } + + if (false === $callback($this->resource, $center->getX(), $center->getY(), $size->getWidth(), $size->getHeight(), $this->getColor($color))) { + imagealphablending($this->resource, false); + throw new RuntimeException('Draw ellipse operation failed'); + } + + if (false === imagealphablending($this->resource, false)) { + throw new RuntimeException('Draw ellipse operation failed'); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function line(PointInterface $start, PointInterface $end, ColorInterface $color, $thickness = 1) + { + imagesetthickness($this->resource, max(1, (int) $thickness)); + + if (false === imagealphablending($this->resource, true)) { + throw new RuntimeException('Draw line operation failed'); + } + + if (false === imageline($this->resource, $start->getX(), $start->getY(), $end->getX(), $end->getY(), $this->getColor($color))) { + imagealphablending($this->resource, false); + throw new RuntimeException('Draw line operation failed'); + } + + if (false === imagealphablending($this->resource, false)) { + throw new RuntimeException('Draw line operation failed'); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function pieSlice(PointInterface $center, BoxInterface $size, $start, $end, ColorInterface $color, $fill = false, $thickness = 1) + { + imagesetthickness($this->resource, max(1, (int) $thickness)); + + if ($fill) { + $style = IMG_ARC_EDGED; + } else { + $style = IMG_ARC_EDGED | IMG_ARC_NOFILL; + } + + if (false === imagealphablending($this->resource, true)) { + throw new RuntimeException('Draw chord operation failed'); + } + + if (false === imagefilledarc($this->resource, $center->getX(), $center->getY(), $size->getWidth(), $size->getHeight(), $start, $end, $this->getColor($color), $style)) { + imagealphablending($this->resource, false); + throw new RuntimeException('Draw chord operation failed'); + } + + if (false === imagealphablending($this->resource, false)) { + throw new RuntimeException('Draw chord operation failed'); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function dot(PointInterface $position, ColorInterface $color) + { + if (false === imagealphablending($this->resource, true)) { + throw new RuntimeException('Draw point operation failed'); + } + + if (false === imagesetpixel($this->resource, $position->getX(), $position->getY(), $this->getColor($color))) { + imagealphablending($this->resource, false); + throw new RuntimeException('Draw point operation failed'); + } + + if (false === imagealphablending($this->resource, false)) { + throw new RuntimeException('Draw point operation failed'); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function polygon(array $coordinates, ColorInterface $color, $fill = false, $thickness = 1) + { + imagesetthickness($this->resource, max(1, (int) $thickness)); + + if (count($coordinates) < 3) { + throw new InvalidArgumentException(sprintf('A polygon must consist of at least 3 points, %d given', count($coordinates))); + } + + $points = call_user_func_array('array_merge', array_map(function (PointInterface $p) { + return array($p->getX(), $p->getY()); + }, $coordinates)); + + if ($fill) { + $callback = 'imagefilledpolygon'; + } else { + $callback = 'imagepolygon'; + } + + if (false === imagealphablending($this->resource, true)) { + throw new RuntimeException('Draw polygon operation failed'); + } + + if (false === $callback($this->resource, $points, count($coordinates), $this->getColor($color))) { + imagealphablending($this->resource, false); + throw new RuntimeException('Draw polygon operation failed'); + } + + if (false === imagealphablending($this->resource, false)) { + throw new RuntimeException('Draw polygon operation failed'); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function text($string, AbstractFont $font, PointInterface $position, $angle = 0, $width = null) + { + if (!$this->info['FreeType Support']) { + throw new RuntimeException('GD is not compiled with FreeType support'); + } + + $angle = -1 * $angle; + $fontsize = $font->getSize(); + $fontfile = $font->getFile(); + $x = $position->getX(); + $y = $position->getY() + $fontsize; + + if ($width !== null) { + $string = $this->wrapText($string, $font, $angle, $width); + } + + if (false === imagealphablending($this->resource, true)) { + throw new RuntimeException('Font mask operation failed'); + } + + if (false === imagefttext($this->resource, $fontsize, $angle, $x, $y, $this->getColor($font->getColor()), $fontfile, $string)) { + imagealphablending($this->resource, false); + throw new RuntimeException('Font mask operation failed'); + } + + if (false === imagealphablending($this->resource, false)) { + throw new RuntimeException('Font mask operation failed'); + } + + return $this; + } + + /** + * Internal + * + * Generates a GD color from Color instance + * + * @param ColorInterface $color + * + * @return resource + * + * @throws RuntimeException + * @throws InvalidArgumentException + */ + private function getColor(ColorInterface $color) + { + if (!$color instanceof RGBColor) { + throw new InvalidArgumentException('GD driver only supports RGB colors'); + } + + $gdColor = imagecolorallocatealpha($this->resource, $color->getRed(), $color->getGreen(), $color->getBlue(), (100 - $color->getAlpha()) * 127 / 100); + if (false === $gdColor) { + throw new RuntimeException(sprintf('Unable to allocate color "RGB(%s, %s, %s)" with transparency of %d percent', $color->getRed(), $color->getGreen(), $color->getBlue(), $color->getAlpha())); + } + + return $gdColor; + } + + private function loadGdInfo() + { + if (!function_exists('gd_info')) { + throw new RuntimeException('Gd not installed'); + } + + $this->info = gd_info(); + } + + /** + * Internal + * + * Fits a string into box with given width + */ + private function wrapText($string, AbstractFont $font, $angle, $width) + { + $result = ''; + $words = explode(' ', $string); + foreach ($words as $word) { + $teststring = $result . ' ' . $word; + $testbox = imagettfbbox($font->getSize(), $angle, $font->getFile(), $teststring); + if ($testbox[2] > $width) { + $result .= ($result == '' ? '' : "\n") . $word; + } else { + $result .= ($result == '' ? '' : ' ') . $word; + } + } + + return $result; + } +} diff --git a/src/Symfony/Component/Image/Gd/Effects.php b/src/Symfony/Component/Image/Gd/Effects.php new file mode 100644 index 0000000000000..4044ba0489593 --- /dev/null +++ b/src/Symfony/Component/Image/Gd/Effects.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Gd; + +use Symfony\Component\Image\Effects\EffectsInterface; +use Symfony\Component\Image\Exception\RuntimeException; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\Palette\Color\RGB as RGBColor; + +/** + * Effects implementation using the GD library + */ +class Effects implements EffectsInterface +{ + private $resource; + + public function __construct($resource) + { + $this->resource = $resource; + } + + /** + * {@inheritdoc} + */ + public function gamma($correction) + { + if (false === imagegammacorrect($this->resource, 1.0, $correction)) { + throw new RuntimeException('Failed to apply gamma correction to the image'); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function negative() + { + if (false === imagefilter($this->resource, IMG_FILTER_NEGATE)) { + throw new RuntimeException('Failed to negate the image'); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function grayscale() + { + if (false === imagefilter($this->resource, IMG_FILTER_GRAYSCALE)) { + throw new RuntimeException('Failed to grayscale the image'); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function colorize(ColorInterface $color) + { + if (!$color instanceof RGBColor) { + throw new RuntimeException('Colorize effects only accepts RGB color in GD context'); + } + + if (false === imagefilter($this->resource, IMG_FILTER_COLORIZE, $color->getRed(), $color->getGreen(), $color->getBlue())) { + throw new RuntimeException('Failed to colorize the image'); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function sharpen() + { + $sharpenMatrix = array(array(-1,-1,-1), array(-1,16,-1), array(-1,-1,-1)); + $divisor = array_sum(array_map('array_sum', $sharpenMatrix)); + + if (false === imageconvolution($this->resource, $sharpenMatrix, $divisor, 0)) { + throw new RuntimeException('Failed to sharpen the image'); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function blur($sigma = 1) + { + if (false === imagefilter($this->resource, IMG_FILTER_GAUSSIAN_BLUR)) { + throw new RuntimeException('Failed to blur the image'); + } + + return $this; + } +} diff --git a/src/Symfony/Component/Image/Gd/Font.php b/src/Symfony/Component/Image/Gd/Font.php new file mode 100644 index 0000000000000..bb141af92ae46 --- /dev/null +++ b/src/Symfony/Component/Image/Gd/Font.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Gd; + +use Symfony\Component\Image\Exception\RuntimeException; +use Symfony\Component\Image\Image\AbstractFont; +use Symfony\Component\Image\Image\Box; + +/** + * Font implementation using the GD library + */ +final class Font extends AbstractFont +{ + /** + * {@inheritdoc} + */ + public function box($string, $angle = 0) + { + if (!function_exists('imageftbbox')) { + throw new RuntimeException('GD must have been compiled with `--with-freetype-dir` option to use the Font feature.'); + } + + $angle = -1 * $angle; + $info = imageftbbox($this->size, $angle, $this->file, $string); + $xs = array($info[0], $info[2], $info[4], $info[6]); + $ys = array($info[1], $info[3], $info[5], $info[7]); + $width = abs(max($xs) - min($xs)); + $height = abs(max($ys) - min($ys)); + + return new Box($width, $height); + } +} diff --git a/src/Symfony/Component/Image/Gd/Image.php b/src/Symfony/Component/Image/Gd/Image.php new file mode 100644 index 0000000000000..051c89408c791 --- /dev/null +++ b/src/Symfony/Component/Image/Gd/Image.php @@ -0,0 +1,735 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Gd; + +use Symfony\Component\Image\Image\AbstractImage; +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\Box; +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Image\Metadata\MetadataBag; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\Fill\FillInterface; +use Symfony\Component\Image\Image\Point; +use Symfony\Component\Image\Image\PointInterface; +use Symfony\Component\Image\Image\Palette\PaletteInterface; +use Symfony\Component\Image\Image\Palette\Color\RGB as RGBColor; +use Symfony\Component\Image\Image\ProfileInterface; +use Symfony\Component\Image\Image\Palette\RGB; +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Exception\OutOfBoundsException; +use Symfony\Component\Image\Exception\RuntimeException; + +/** + * Image implementation using the GD library + */ +final class Image extends AbstractImage +{ + /** + * @var resource + */ + private $resource; + + /** + * @var Layers|null + */ + private $layers; + + /** + * @var PaletteInterface + */ + private $palette; + + /** + * Constructs a new Image instance + * + * @param resource $resource + * @param PaletteInterface $palette + * @param MetadataBag $metadata + */ + public function __construct($resource, PaletteInterface $palette, MetadataBag $metadata) + { + $this->metadata = $metadata; + $this->palette = $palette; + $this->resource = $resource; + } + + /** + * Makes sure the current image resource is destroyed + */ + public function __destruct() + { + if (is_resource($this->resource) && 'gd' === get_resource_type($this->resource)) { + imagedestroy($this->resource); + } + } + + /** + * Returns Gd resource + * + * @return resource + */ + public function getGdResource() + { + return $this->resource; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + final public function copy() + { + $size = $this->getSize(); + $copy = $this->createImage($size, 'copy'); + + if (false === imagecopy($copy, $this->resource, 0, 0, 0, 0, $size->getWidth(), $size->getHeight())) { + throw new RuntimeException('Image copy operation failed'); + } + + return new Image($copy, $this->palette, $this->metadata); + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + final public function crop(PointInterface $start, BoxInterface $size) + { + if (!$start->in($this->getSize())) { + throw new OutOfBoundsException('Crop coordinates must start at minimum 0, 0 position from top left corner, crop height and width must be positive integers and must not exceed the current image borders'); + } + + $width = $size->getWidth(); + $height = $size->getHeight(); + + $dest = $this->createImage($size, 'crop'); + + if (false === imagecopy($dest, $this->resource, 0, 0, $start->getX(), $start->getY(), $width, $height)) { + throw new RuntimeException('Image crop operation failed'); + } + + imagedestroy($this->resource); + + $this->resource = $dest; + + return $this; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + final public function paste(ImageInterface $image, PointInterface $start) + { + if (!$image instanceof self) { + throw new InvalidArgumentException(sprintf('Gd\Image can only paste() Gd\Image instances, %s given', get_class($image))); + } + + $size = $image->getSize(); + if (!$this->getSize()->contains($size, $start)) { + throw new OutOfBoundsException('Cannot paste image of the given size at the specified position, as it moves outside of the current image\'s box'); + } + + imagealphablending($this->resource, true); + imagealphablending($image->resource, true); + + if (false === imagecopy($this->resource, $image->resource, $start->getX(), $start->getY(), 0, 0, $size->getWidth(), $size->getHeight())) { + throw new RuntimeException('Image paste operation failed'); + } + + imagealphablending($this->resource, false); + imagealphablending($image->resource, false); + + return $this; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + final public function resize(BoxInterface $size, $filter = ImageInterface::FILTER_UNDEFINED) + { + if (ImageInterface::FILTER_UNDEFINED !== $filter) { + throw new InvalidArgumentException('Unsupported filter type, GD only supports ImageInterface::FILTER_UNDEFINED filter'); + } + + $width = $size->getWidth(); + $height = $size->getHeight(); + + $dest = $this->createImage($size, 'resize'); + + imagealphablending($this->resource, true); + imagealphablending($dest, true); + + if (false === imagecopyresampled($dest, $this->resource, 0, 0, 0, 0, $width, $height, imagesx($this->resource), imagesy($this->resource))) { + throw new RuntimeException('Image resize operation failed'); + } + + imagealphablending($this->resource, false); + imagealphablending($dest, false); + + imagedestroy($this->resource); + + $this->resource = $dest; + + return $this; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + final public function rotate($angle, ColorInterface $background = null) + { + $color = $background ? $background : $this->palette->color('fff'); + $resource = imagerotate($this->resource, -1 * $angle, $this->getColor($color)); + + if (false === $resource) { + throw new RuntimeException('Image rotate operation failed'); + } + + imagedestroy($this->resource); + $this->resource = $resource; + + return $this; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + final public function save($path = null, array $options = array()) + { + $path = null === $path ? (isset($this->metadata['filepath']) ? $this->metadata['filepath'] : $path) : $path; + + if (null === $path) { + throw new RuntimeException('You can omit save path only if image has been open from a file'); + } + + if (isset($options['format'])) { + $format = $options['format']; + } elseif ('' !== $extension = pathinfo($path, \PATHINFO_EXTENSION)) { + $format = $extension; + } else { + $originalPath = isset($this->metadata['filepath']) ? $this->metadata['filepath'] : null; + $format = pathinfo($originalPath, \PATHINFO_EXTENSION); + } + + $this->saveOrOutput($format, $options, $path); + + return $this; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function show($format, array $options = array()) + { + header('Content-type: '.$this->getMimeType($format)); + + $this->saveOrOutput($format, $options); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function get($format, array $options = array()) + { + ob_start(); + $this->saveOrOutput($format, $options); + + return ob_get_clean(); + } + + /** + * {@inheritdoc} + */ + public function __toString() + { + return $this->get('png'); + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + final public function flipHorizontally() + { + $size = $this->getSize(); + $width = $size->getWidth(); + $height = $size->getHeight(); + $dest = $this->createImage($size, 'flip'); + + for ($i = 0; $i < $width; $i++) { + if (false === imagecopy($dest, $this->resource, $i, 0, ($width - 1) - $i, 0, 1, $height)) { + throw new RuntimeException('Horizontal flip operation failed'); + } + } + + imagedestroy($this->resource); + + $this->resource = $dest; + + return $this; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + final public function flipVertically() + { + $size = $this->getSize(); + $width = $size->getWidth(); + $height = $size->getHeight(); + $dest = $this->createImage($size, 'flip'); + + for ($i = 0; $i < $height; $i++) { + if (false === imagecopy($dest, $this->resource, 0, $i, 0, ($height - 1) - $i, $width, 1)) { + throw new RuntimeException('Vertical flip operation failed'); + } + } + + imagedestroy($this->resource); + + $this->resource = $dest; + + return $this; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + final public function strip() + { + // GD strips profiles and comment, so there's nothing to do here + return $this; + } + + /** + * {@inheritdoc} + */ + public function draw() + { + return new Drawer($this->resource); + } + + /** + * {@inheritdoc} + */ + public function effects() + { + return new Effects($this->resource); + } + + /** + * {@inheritdoc} + */ + public function getSize() + { + return new Box(imagesx($this->resource), imagesy($this->resource)); + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function applyMask(ImageInterface $mask) + { + if (!$mask instanceof self) { + throw new InvalidArgumentException('Cannot mask non-gd images'); + } + + $size = $this->getSize(); + $maskSize = $mask->getSize(); + + if ($size != $maskSize) { + throw new InvalidArgumentException(sprintf('The given mask doesn\'t match current image\'s size, Current mask\'s dimensions are %s, while image\'s dimensions are %s', $maskSize, $size)); + } + + for ($x = 0, $width = $size->getWidth(); $x < $width; $x++) { + for ($y = 0, $height = $size->getHeight(); $y < $height; $y++) { + $position = new Point($x, $y); + $color = $this->getColorAt($position); + $maskColor = $mask->getColorAt($position); + $round = (int) round(max($color->getAlpha(), (100 - $color->getAlpha()) * $maskColor->getRed() / 255)); + + if (false === imagesetpixel($this->resource, $x, $y, $this->getColor($color->dissolve($round - $color->getAlpha())))) { + throw new RuntimeException('Apply mask operation failed'); + } + } + } + + return $this; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function fill(FillInterface $fill) + { + $size = $this->getSize(); + + for ($x = 0, $width = $size->getWidth(); $x < $width; $x++) { + for ($y = 0, $height = $size->getHeight(); $y < $height; $y++) { + if (false === imagesetpixel($this->resource, $x, $y, $this->getColor($fill->getColor(new Point($x, $y))))) { + throw new RuntimeException('Fill operation failed'); + } + } + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function mask() + { + $mask = $this->copy(); + + if (false === imagefilter($mask->resource, IMG_FILTER_GRAYSCALE)) { + throw new RuntimeException('Mask operation failed'); + } + + return $mask; + } + + /** + * {@inheritdoc} + */ + public function histogram() + { + $size = $this->getSize(); + $colors = array(); + + for ($x = 0, $width = $size->getWidth(); $x < $width; $x++) { + for ($y = 0, $height = $size->getHeight(); $y < $height; $y++) { + $colors[] = $this->getColorAt(new Point($x, $y)); + } + } + + return array_unique($colors); + } + + /** + * {@inheritdoc} + */ + public function getColorAt(PointInterface $point) + { + if (!$point->in($this->getSize())) { + throw new RuntimeException(sprintf('Error getting color at point [%s,%s]. The point must be inside the image of size [%s,%s]', $point->getX(), $point->getY(), $this->getSize()->getWidth(), $this->getSize()->getHeight())); + } + + $index = imagecolorat($this->resource, $point->getX(), $point->getY()); + $info = imagecolorsforindex($this->resource, $index); + + return $this->palette->color(array($info['red'], $info['green'], $info['blue']), max(min(100 - (int) round($info['alpha'] / 127 * 100), 100), 0)); + } + + /** + * {@inheritdoc} + */ + public function layers() + { + if (null === $this->layers) { + $this->layers = new Layers($this, $this->palette, $this->resource); + } + + return $this->layers; + } + + /** + * {@inheritdoc} + **/ + public function interlace($scheme) + { + static $supportedInterlaceSchemes = array( + ImageInterface::INTERLACE_NONE => 0, + ImageInterface::INTERLACE_LINE => 1, + ImageInterface::INTERLACE_PLANE => 1, + ImageInterface::INTERLACE_PARTITION => 1, + ); + + if (!array_key_exists($scheme, $supportedInterlaceSchemes)) { + throw new InvalidArgumentException('Unsupported interlace type'); + } + + imageinterlace($this->resource, $supportedInterlaceSchemes[$scheme]); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function palette() + { + return $this->palette; + } + + /** + * {@inheritdoc} + */ + public function profile(ProfileInterface $profile) + { + throw new RuntimeException('GD driver does not support color profiles'); + } + + /** + * {@inheritdoc} + */ + public function usePalette(PaletteInterface $palette) + { + if (!$palette instanceof RGB) { + throw new RuntimeException('GD driver only supports RGB palette'); + } + + $this->palette = $palette; + + return $this; + } + + /** + * Internal + * + * Performs save or show operation using one of GD's image... functions + * + * @param string $format + * @param array $options + * @param string $filename + * + * @throws InvalidArgumentException + * @throws RuntimeException + */ + private function saveOrOutput($format, array $options, $filename = null) + { + $format = $this->normalizeFormat($format); + + if (!$this->supported($format)) { + throw new InvalidArgumentException(sprintf('Saving image in "%s" format is not supported, please use one of the following extensions: "%s"', $format, implode('", "', $this->supported()))); + } + + $save = 'image'.$format; + $args = array(&$this->resource, $filename); + + // Preserve BC until version 1.0 + if (isset($options['quality']) && !isset($options['png_compression_level'])) { + $options['png_compression_level'] = round((100 - $options['quality']) * 9 / 100); + } + if (isset($options['filters']) && !isset($options['png_compression_filter'])) { + $options['png_compression_filter'] = $options['filters']; + } + + $options = $this->updateSaveOptions($options); + + if ($format === 'jpeg' && isset($options['jpeg_quality'])) { + $args[] = $options['jpeg_quality']; + } + + if ($format === 'png') { + if (isset($options['png_compression_level'])) { + if ($options['png_compression_level'] < 0 || $options['png_compression_level'] > 9) { + throw new InvalidArgumentException('png_compression_level option should be an integer from 0 to 9'); + } + $args[] = $options['png_compression_level']; + } else { + $args[] = -1; // use default level + } + + if (isset($options['png_compression_filter'])) { + if (~PNG_ALL_FILTERS & $options['png_compression_filter']) { + throw new InvalidArgumentException('png_compression_filter option should be a combination of the PNG_FILTER_XXX constants'); + } + $args[] = $options['png_compression_filter']; + } + } + + if (($format === 'wbmp' || $format === 'xbm') && isset($options['foreground'])) { + $args[] = $options['foreground']; + } + + $this->setExceptionHandler(); + + if (false === call_user_func_array($save, $args)) { + throw new RuntimeException('Save operation failed'); + } + + $this->resetExceptionHandler(); + } + + /** + * Internal + * + * Generates a GD image + * + * @param BoxInterface $size + * @param string the operation initiating the creation + * + * @return resource + * + * @throws RuntimeException + * + */ + private function createImage(BoxInterface $size, $operation) + { + $resource = imagecreatetruecolor($size->getWidth(), $size->getHeight()); + + if (false === $resource) { + throw new RuntimeException('Image '.$operation.' failed'); + } + + if (false === imagealphablending($resource, false) || false === imagesavealpha($resource, true)) { + throw new RuntimeException('Image '.$operation.' failed'); + } + + if (function_exists('imageantialias')) { + imageantialias($resource, true); + } + + $transparent = imagecolorallocatealpha($resource, 255, 255, 255, 127); + imagefill($resource, 0, 0, $transparent); + imagecolortransparent($resource, $transparent); + + return $resource; + } + + /** + * Internal + * + * Generates a GD color from Color instance + * + * @param ColorInterface $color + * + * @return integer A color identifier + * + * @throws RuntimeException + * @throws InvalidArgumentException + */ + private function getColor(ColorInterface $color) + { + if (!$color instanceof RGBColor) { + throw new InvalidArgumentException('GD driver only supports RGB colors'); + } + + $index = imagecolorallocatealpha($this->resource, $color->getRed(), $color->getGreen(), $color->getBlue(), round(127 * (100 - $color->getAlpha()) / 100)); + + if (false === $index) { + throw new RuntimeException(sprintf('Unable to allocate color "RGB(%s, %s, %s)" with transparency of %d percent', $color->getRed(), $color->getGreen(), $color->getBlue(), $color->getAlpha())); + } + + return $index; + } + + /** + * Internal + * + * Normalizes a given format name + * + * @param string $format + * + * @return string + */ + private function normalizeFormat($format) + { + $format = strtolower($format); + + if ('jpg' === $format || 'pjpeg' === $format) { + $format = 'jpeg'; + } + + return $format; + } + + /** + * Internal + * + * Checks whether a given format is supported by GD library + * + * @param string $format + * + * @return Boolean + */ + private function supported($format = null) + { + $formats = array('gif', 'jpeg', 'png', 'wbmp', 'xbm'); + + if (null === $format) { + return $formats; + } + + return in_array($format, $formats); + } + + private function setExceptionHandler() + { + set_error_handler(function ($errno, $errstr, $errfile, $errline) { + if (0 === error_reporting()) { + return; + } + + throw new RuntimeException($errstr, $errno, new \ErrorException($errstr, 0, $errno, $errfile, $errline)); + }, E_WARNING | E_NOTICE); + } + + private function resetExceptionHandler() + { + restore_error_handler(); + } + + /** + * Internal + * + * Get the mime type based on format. + * + * @param string $format + * + * @return string mime-type + * + * @throws RuntimeException + */ + private function getMimeType($format) + { + $format = $this->normalizeFormat($format); + + if (!$this->supported($format)) { + throw new RuntimeException('Invalid format'); + } + + static $mimeTypes = array( + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'png' => 'image/png', + 'wbmp' => 'image/vnd.wap.wbmp', + 'xbm' => 'image/xbm', + ); + + return $mimeTypes[$format]; + } +} diff --git a/src/Symfony/Component/Image/Gd/Layers.php b/src/Symfony/Component/Image/Gd/Layers.php new file mode 100644 index 0000000000000..4c13e0a2dea28 --- /dev/null +++ b/src/Symfony/Component/Image/Gd/Layers.php @@ -0,0 +1,144 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Gd; + +use Symfony\Component\Image\Image\AbstractLayers; +use Symfony\Component\Image\Exception\RuntimeException; +use Symfony\Component\Image\Image\Metadata\MetadataBag; +use Symfony\Component\Image\Image\Palette\PaletteInterface; +use Symfony\Component\Image\Exception\NotSupportedException; + +class Layers extends AbstractLayers +{ + private $image; + private $offset; + private $resource; + private $palette; + + public function __construct(Image $image, PaletteInterface $palette, $resource) + { + if (!is_resource($resource)) { + throw new RuntimeException('Invalid Gd resource provided'); + } + + $this->image = $image; + $this->resource = $resource; + $this->offset = 0; + $this->palette = $palette; + } + + /** + * {@inheritdoc} + */ + public function merge() + { + } + + /** + * {@inheritdoc} + */ + public function coalesce() + { + } + + /** + * {@inheritdoc} + */ + public function animate($format, $delay, $loops) + { + return $this; + } + + /** + * {@inheritdoc} + */ + public function current() + { + return new Image($this->resource, $this->palette, new MetadataBag()); + } + + /** + * {@inheritdoc} + */ + public function key() + { + return $this->offset; + } + + /** + * {@inheritdoc} + */ + public function next() + { + ++$this->offset; + } + + /** + * {@inheritdoc} + */ + public function rewind() + { + $this->offset = 0; + } + + /** + * {@inheritdoc} + */ + public function valid() + { + return $this->offset < 1; + } + + /** + * {@inheritdoc} + */ + public function count() + { + return 1; + } + + /** + * {@inheritdoc} + */ + public function offsetExists($offset) + { + return 0 === $offset; + } + + /** + * {@inheritdoc} + */ + public function offsetGet($offset) + { + if (0 === $offset) { + return new Image($this->resource, $this->palette, new MetadataBag()); + } + + throw new RuntimeException('GD only supports one layer at offset 0'); + } + + /** + * {@inheritdoc} + */ + public function offsetSet($offset, $value) + { + throw new NotSupportedException('GD does not support layer set'); + } + + /** + * {@inheritdoc} + */ + public function offsetUnset($offset) + { + throw new NotSupportedException('GD does not support layer unset'); + } +} diff --git a/src/Symfony/Component/Image/Gd/Loader.php b/src/Symfony/Component/Image/Gd/Loader.php new file mode 100644 index 0000000000000..85437cad45c78 --- /dev/null +++ b/src/Symfony/Component/Image/Gd/Loader.php @@ -0,0 +1,195 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Gd; + +use Symfony\Component\Image\Image\AbstractLoader; +use Symfony\Component\Image\Image\Metadata\MetadataBag; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\Palette\RGB; +use Symfony\Component\Image\Image\Palette\PaletteInterface; +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Image\Palette\Color\RGB as RGBColor; +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Exception\RuntimeException; + +/** + * Loader implementation using the GD library + */ +final class Loader extends AbstractLoader +{ + /** + * @var array + */ + private $info; + + /** + * @throws RuntimeException + */ + public function __construct() + { + $this->loadGdInfo(); + $this->requireGdVersion('2.0.1'); + } + + /** + * {@inheritdoc} + */ + public function create(BoxInterface $size, ColorInterface $color = null) + { + $width = $size->getWidth(); + $height = $size->getHeight(); + + $resource = imagecreatetruecolor($width, $height); + + if (false === $resource) { + throw new RuntimeException('Create operation failed'); + } + + $palette = null !== $color ? $color->getPalette() : new RGB(); + $color = $color ? $color : $palette->color('fff'); + + if (!$color instanceof RGBColor) { + throw new InvalidArgumentException('GD driver only supports RGB colors'); + } + + $index = imagecolorallocatealpha($resource, $color->getRed(), $color->getGreen(), $color->getBlue(), round(127 * (100 - $color->getAlpha()) / 100)); + + if (false === $index) { + throw new RuntimeException('Unable to allocate color'); + } + + if (false === imagefill($resource, 0, 0, $index)) { + throw new RuntimeException('Could not set background color fill'); + } + + if ($color->getAlpha() >= 95) { + imagecolortransparent($resource, $index); + } + + return $this->wrap($resource, $palette, new MetadataBag()); + } + + /** + * {@inheritdoc} + */ + public function open($path) + { + $path = $this->checkPath($path); + $data = @file_get_contents($path); + + if (false === $data) { + throw new RuntimeException(sprintf('Failed to open file %s', $path)); + } + + $resource = @imagecreatefromstring($data); + + if (!is_resource($resource)) { + throw new RuntimeException(sprintf('Unable to open image %s', $path)); + } + + return $this->wrap($resource, new RGB(), $this->getMetadataReader()->readFile($path)); + } + + /** + * {@inheritdoc} + */ + public function load($string) + { + return $this->doLoad($string, $this->getMetadataReader()->readData($string)); + } + + /** + * {@inheritdoc} + */ + public function read($resource) + { + if (!is_resource($resource)) { + throw new InvalidArgumentException('Variable does not contain a stream resource'); + } + + $content = stream_get_contents($resource); + + if (false === $content) { + throw new InvalidArgumentException('Cannot read resource content'); + } + + return $this->doLoad($content, $this->getMetadataReader()->readData($content, $resource)); + } + + /** + * {@inheritdoc} + */ + public function font($file, $size, ColorInterface $color) + { + if (!$this->info['FreeType Support']) { + throw new RuntimeException('GD is not compiled with FreeType support'); + } + + return new Font($file, $size, $color); + } + + private function wrap($resource, PaletteInterface $palette, MetadataBag $metadata) + { + if (!imageistruecolor($resource)) { + list($width, $height) = array(imagesx($resource), imagesy($resource)); + + // create transparent truecolor canvas + $truecolor = imagecreatetruecolor($width, $height); + $transparent = imagecolorallocatealpha($truecolor, 255, 255, 255, 127); + + imagefill($truecolor, 0, 0, $transparent); + imagecolortransparent($truecolor, $transparent); + + imagecopymerge($truecolor, $resource, 0, 0, 0, 0, $width, $height, 100); + + imagedestroy($resource); + $resource = $truecolor; + } + + if (false === imagealphablending($resource, false) || false === imagesavealpha($resource, true)) { + throw new RuntimeException('Could not set alphablending, savealpha and antialias values'); + } + + if (function_exists('imageantialias')) { + imageantialias($resource, true); + } + + return new Image($resource, $palette, $metadata); + } + + private function loadGdInfo() + { + if (!function_exists('gd_info')) { + throw new RuntimeException('Gd not installed'); + } + + $this->info = gd_info(); + } + + private function requireGdVersion($version) + { + if (version_compare(GD_VERSION, $version, '<')) { + throw new RuntimeException(sprintf('GD2 version %s or higher is required, %s provided', $version, GD_VERSION)); + } + } + + private function doLoad($string, MetadataBag $metadata) + { + $resource = @imagecreatefromstring($string); + + if (!is_resource($resource)) { + throw new RuntimeException('An image could not be created from the given input'); + } + + return $this->wrap($resource, new RGB(), $metadata); + } +} diff --git a/src/Symfony/Component/Image/Gmagick/Drawer.php b/src/Symfony/Component/Image/Gmagick/Drawer.php new file mode 100644 index 0000000000000..327d880639bda --- /dev/null +++ b/src/Symfony/Component/Image/Gmagick/Drawer.php @@ -0,0 +1,356 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Gmagick; + +use Symfony\Component\Image\Draw\DrawerInterface; +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Exception\NotSupportedException; +use Symfony\Component\Image\Exception\RuntimeException; +use Symfony\Component\Image\Image\AbstractFont; +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\Point; +use Symfony\Component\Image\Image\PointInterface; + +/** + * Drawer implementation using the Gmagick PHP extension + */ +final class Drawer implements DrawerInterface +{ + /** + * @var \Gmagick + */ + private $gmagick; + + /** + * @param \Gmagick $gmagick + */ + public function __construct(\Gmagick $gmagick) + { + $this->gmagick = $gmagick; + } + + /** + * {@inheritdoc} + */ + public function arc(PointInterface $center, BoxInterface $size, $start, $end, ColorInterface $color, $thickness = 1) + { + $x = $center->getX(); + $y = $center->getY(); + $width = $size->getWidth(); + $height = $size->getHeight(); + + try { + $pixel = $this->getColor($color); + $arc = new \GmagickDraw(); + + $arc->setstrokecolor($pixel); + $arc->setstrokewidth(max(1, (int) $thickness)); + $arc->setfillcolor('transparent'); + $arc->arc( + $x - $width / 2, + $y - $height / 2, + $x + $width / 2, + $y + $height / 2, + $start, + $end + ); + + $this->gmagick->drawImage($arc); + + $pixel = null; + + $arc = null; + } catch (\GmagickException $e) { + throw new RuntimeException('Draw arc operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function chord(PointInterface $center, BoxInterface $size, $start, $end, ColorInterface $color, $fill = false, $thickness = 1) + { + $x = $center->getX(); + $y = $center->getY(); + $width = $size->getWidth(); + $height = $size->getHeight(); + + try { + $pixel = $this->getColor($color); + $chord = new \GmagickDraw(); + + $chord->setstrokecolor($pixel); + $chord->setstrokewidth(max(1, (int) $thickness)); + + if ($fill) { + $chord->setfillcolor($pixel); + } else { + $x1 = round($x + $width / 2 * cos(deg2rad($start))); + $y1 = round($y + $height / 2 * sin(deg2rad($start))); + $x2 = round($x + $width / 2 * cos(deg2rad($end))); + $y2 = round($y + $height / 2 * sin(deg2rad($end))); + + $this->line(new Point($x1, $y1), new Point($x2, $y2), $color); + + $chord->setfillcolor('transparent'); + } + + $chord->arc($x - $width / 2, $y - $height / 2, $x + $width / 2, $y + $height / 2, $start, $end); + + $this->gmagick->drawImage($chord); + + $pixel = null; + + $chord = null; + } catch (\GmagickException $e) { + throw new RuntimeException('Draw chord operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function ellipse(PointInterface $center, BoxInterface $size, ColorInterface $color, $fill = false, $thickness = 1) + { + $width = $size->getWidth(); + $height = $size->getHeight(); + + try { + $pixel = $this->getColor($color); + $ellipse = new \GmagickDraw(); + + $ellipse->setstrokecolor($pixel); + $ellipse->setstrokewidth(max(1, (int) $thickness)); + + if ($fill) { + $ellipse->setfillcolor($pixel); + } else { + $ellipse->setfillcolor('transparent'); + } + + $ellipse->ellipse( + $center->getX(), + $center->getY(), + $width / 2, + $height / 2, + 0, 360 + ); + + $this->gmagick->drawImage($ellipse); + + $pixel = null; + + $ellipse = null; + } catch (\GmagickException $e) { + throw new RuntimeException('Draw ellipse operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function line(PointInterface $start, PointInterface $end, ColorInterface $color, $thickness = 1) + { + try { + $pixel = $this->getColor($color); + $line = new \GmagickDraw(); + + $line->setstrokecolor($pixel); + $line->setstrokewidth(max(1, (int) $thickness)); + $line->setfillcolor($pixel); + $line->line( + $start->getX(), + $start->getY(), + $end->getX(), + $end->getY() + ); + + $this->gmagick->drawImage($line); + + $pixel = null; + + $line = null; + } catch (\GmagickException $e) { + throw new RuntimeException('Draw line operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function pieSlice(PointInterface $center, BoxInterface $size, $start, $end, ColorInterface $color, $fill = false, $thickness = 1) + { + $width = $size->getWidth(); + $height = $size->getHeight(); + + $x1 = round($center->getX() + $width / 2 * cos(deg2rad($start))); + $y1 = round($center->getY() + $height / 2 * sin(deg2rad($start))); + $x2 = round($center->getX() + $width / 2 * cos(deg2rad($end))); + $y2 = round($center->getY() + $height / 2 * sin(deg2rad($end))); + + if ($fill) { + $this->chord($center, $size, $start, $end, $color, true, $thickness); + $this->polygon( + array( + $center, + new Point($x1, $y1), + new Point($x2, $y2), + ), + $color, + true, + $thickness + ); + } else { + $this->arc($center, $size, $start, $end, $color, $thickness); + $this->line($center, new Point($x1, $y1), $color, $thickness); + $this->line($center, new Point($x2, $y2), $color, $thickness); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function dot(PointInterface $position, ColorInterface $color) + { + $x = $position->getX(); + $y = $position->getY(); + + try { + $pixel = $this->getColor($color); + $point = new \GmagickDraw(); + + $point->setfillcolor($pixel); + $point->point($x, $y); + + $this->gmagick->drawimage($point); + + $pixel = null; + $point = null; + } catch (\GmagickException $e) { + throw new RuntimeException('Draw point operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function polygon(array $coordinates, ColorInterface $color, $fill = false, $thickness = 1) + { + if (count($coordinates) < 3) { + throw new InvalidArgumentException(sprintf('Polygon must consist of at least 3 coordinates, %d given', count($coordinates))); + } + + $points = array_map(function (PointInterface $p) { + return array('x' => $p->getX(), 'y' => $p->getY()); + }, $coordinates); + + try { + $pixel = $this->getColor($color); + $polygon = new \GmagickDraw(); + + $polygon->setstrokecolor($pixel); + $polygon->setstrokewidth(max(1, (int) $thickness)); + + if ($fill) { + $polygon->setfillcolor($pixel); + } else { + $polygon->setfillcolor('transparent'); + } + + $polygon->polygon($points); + + $this->gmagick->drawImage($polygon); + + unset($pixel, $polygon); + } catch (\GmagickException $e) { + throw new RuntimeException('Draw polygon operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function text($string, AbstractFont $font, PointInterface $position, $angle = 0, $width = null) + { + try { + $pixel = $this->getColor($font->getColor()); + $text = new \GmagickDraw(); + + $text->setfont($font->getFile()); + /** + * @see http://www.php.net/manual/en/imagick.queryfontmetrics.php#101027 + * + * ensure font resolution is the same as GD's hard-coded 96 + */ + $text->setfontsize((int) ($font->getSize() * (96 / 72))); + $text->setfillcolor($pixel); + + $info = $this->gmagick->queryfontmetrics($text, $string); + $rad = deg2rad($angle); + $cos = cos($rad); + $sin = sin($rad); + + $x1 = round(0 * $cos - 0 * $sin); + $x2 = round($info['textWidth'] * $cos - $info['textHeight'] * $sin); + $y1 = round(0 * $sin + 0 * $cos); + $y2 = round($info['textWidth'] * $sin + $info['textHeight'] * $cos); + + $xdiff = 0 - min($x1, $x2); + $ydiff = 0 - min($y1, $y2); + + if ($width !== null) { + throw new NotSupportedException('Gmagick doesn\'t support queryfontmetrics function for multiline text', 1); + } + + $this->gmagick->annotateimage($text, $position->getX() + $x1 + $xdiff, $position->getY() + $y2 + $ydiff, $angle, $string); + + unset($pixel, $text); + } catch (\GmagickException $e) { + throw new RuntimeException('Draw text operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * Gets specifically formatted color string from Color instance + * + * @param ColorInterface $color + * + * @return \GmagickPixel + * + * @throws InvalidArgumentException In case a non-opaque color is passed + */ + private function getColor(ColorInterface $color) + { + if (!$color->isOpaque()) { + throw new InvalidArgumentException('Gmagick doesn\'t support transparency'); + } + + return new \GmagickPixel((string) $color); + } +} diff --git a/src/Symfony/Component/Image/Gmagick/Effects.php b/src/Symfony/Component/Image/Gmagick/Effects.php new file mode 100644 index 0000000000000..488a00b560a0a --- /dev/null +++ b/src/Symfony/Component/Image/Gmagick/Effects.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Gmagick; + +use Symfony\Component\Image\Effects\EffectsInterface; +use Symfony\Component\Image\Exception\RuntimeException; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Exception\NotSupportedException; + +/** + * Effects implementation using the Gmagick PHP extension + */ +class Effects implements EffectsInterface +{ + private $gmagick; + + public function __construct(\Gmagick $gmagick) + { + $this->gmagick = $gmagick; + } + + /** + * {@inheritdoc} + */ + public function gamma($correction) + { + try { + $this->gmagick->gammaimage($correction); + } catch (\GmagickException $e) { + throw new RuntimeException('Failed to apply gamma correction to the image', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function negative() + { + if (!method_exists($this->gmagick, 'negateimage')) { + throw new NotSupportedException('Gmagick version 1.1.0 RC3 is required for negative effect'); + } + + try { + $this->gmagick->negateimage(false, \Gmagick::CHANNEL_ALL); + } catch (\GmagickException $e) { + throw new RuntimeException('Failed to negate the image', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function grayscale() + { + try { + $this->gmagick->setImageType(2); + } catch (\GmagickException $e) { + throw new RuntimeException('Failed to grayscale the image', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function colorize(ColorInterface $color) + { + throw new NotSupportedException('Gmagick does not support colorize'); + } + + /** + * {@inheritdoc} + */ + public function sharpen() + { + throw new NotSupportedException('Gmagick does not support sharpen yet'); + } + + /** + * {@inheritdoc} + */ + public function blur($sigma = 1) + { + try { + $this->gmagick->blurImage(0, $sigma); + } catch (\GmagickException $e) { + throw new RuntimeException('Failed to blur the image', $e->getCode(), $e); + } + + return $this; + } +} diff --git a/src/Symfony/Component/Image/Gmagick/Font.php b/src/Symfony/Component/Image/Gmagick/Font.php new file mode 100644 index 0000000000000..7641426bbf185 --- /dev/null +++ b/src/Symfony/Component/Image/Gmagick/Font.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Gmagick; + +use Symfony\Component\Image\Image\AbstractFont; +use Symfony\Component\Image\Image\Box; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; + +/** + * Font implementation using the Gmagick PHP extension + */ +final class Font extends AbstractFont +{ + /** + * @var \Gmagick + */ + private $gmagick; + + /** + * @param \Gmagick $gmagick + * @param string $file + * @param integer $size + * @param ColorInterface $color + */ + public function __construct(\Gmagick $gmagick, $file, $size, ColorInterface $color) + { + $this->gmagick = $gmagick; + + parent::__construct($file, $size, $color); + } + + /** + * {@inheritdoc} + */ + public function box($string, $angle = 0) + { + $text = new \GmagickDraw(); + + $text->setfont($this->file); + /** + * @see http://www.php.net/manual/en/imagick.queryfontmetrics.php#101027 + * + * ensure font resolution is the same as GD's hard-coded 96 + */ + $text->setfontsize((int) ($this->size * (96 / 72))); + $text->setfontstyle(\Gmagick::STYLE_OBLIQUE); + + $info = $this->gmagick->queryfontmetrics($text, $string); + + $box = new Box($info['textWidth'], $info['textHeight']); + + return $box; + } +} diff --git a/src/Symfony/Component/Image/Gmagick/Image.php b/src/Symfony/Component/Image/Gmagick/Image.php new file mode 100644 index 0000000000000..72ff395c92298 --- /dev/null +++ b/src/Symfony/Component/Image/Gmagick/Image.php @@ -0,0 +1,790 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Gmagick; + +use Symfony\Component\Image\Exception\OutOfBoundsException; +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Exception\RuntimeException; +use Symfony\Component\Image\Image\AbstractImage; +use Symfony\Component\Image\Image\Metadata\MetadataBag; +use Symfony\Component\Image\Image\Palette\PaletteInterface; +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\Box; +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\Fill\FillInterface; +use Symfony\Component\Image\Image\Point; +use Symfony\Component\Image\Image\PointInterface; +use Symfony\Component\Image\Image\ProfileInterface; + +/** + * Image implementation using the Gmagick PHP extension + */ +final class Image extends AbstractImage +{ + /** + * @var \Gmagick + */ + private $gmagick; + /** + * @var Layers + */ + private $layers; + + /** + * @var PaletteInterface + */ + private $palette; + + private static $colorspaceMapping = array( + PaletteInterface::PALETTE_CMYK => \Gmagick::COLORSPACE_CMYK, + PaletteInterface::PALETTE_RGB => \Gmagick::COLORSPACE_RGB, + ); + + /** + * Constructs a new Image instance + * + * @param \Gmagick $gmagick + * @param PaletteInterface $palette + * @param MetadataBag $metadata + */ + public function __construct(\Gmagick $gmagick, PaletteInterface $palette, MetadataBag $metadata) + { + $this->metadata = $metadata; + $this->gmagick = $gmagick; + $this->setColorspace($palette); + $this->layers = new Layers($this, $this->palette, $this->gmagick); + } + + /** + * Destroys allocated gmagick resources + */ + public function __destruct() + { + if ($this->gmagick instanceof \Gmagick) { + $this->gmagick->clear(); + $this->gmagick->destroy(); + } + } + + /** + * Returns gmagick instance + * + * @return \Gmagick + */ + public function getGmagick() + { + return $this->gmagick; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function copy() + { + return new self(clone $this->gmagick, $this->palette, clone $this->metadata); + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function crop(PointInterface $start, BoxInterface $size) + { + if (!$start->in($this->getSize())) { + throw new OutOfBoundsException('Crop coordinates must start at minimum 0, 0 position from top left corner, crop height and width must be positive integers and must not exceed the current image borders'); + } + + try { + $this->gmagick->cropimage($size->getWidth(), $size->getHeight(), $start->getX(), $start->getY()); + } catch (\GmagickException $e) { + throw new RuntimeException('Crop operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function flipHorizontally() + { + try { + $this->gmagick->flopimage(); + } catch (\GmagickException $e) { + throw new RuntimeException('Horizontal flip operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function flipVertically() + { + try { + $this->gmagick->flipimage(); + } catch (\GmagickException $e) { + throw new RuntimeException('Vertical flip operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function strip() + { + try { + try { + $this->profile($this->palette->profile()); + } catch (\Exception $e) { + // here we discard setting the profile as the previous incorporated profile + // is corrupted, let's now strip the image + } + $this->gmagick->stripimage(); + } catch (\GmagickException $e) { + throw new RuntimeException('Strip operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function paste(ImageInterface $image, PointInterface $start) + { + if (!$image instanceof self) { + throw new InvalidArgumentException(sprintf('Gmagick\Image can only paste() Gmagick\Image instances, %s given', get_class($image))); + } + + if (!$this->getSize()->contains($image->getSize(), $start)) { + throw new OutOfBoundsException('Cannot paste image of the given size at the specified position, as it moves outside of the current image\'s box'); + } + + try { + $this->gmagick->compositeimage($image->gmagick, \Gmagick::COMPOSITE_DEFAULT, $start->getX(), $start->getY()); + } catch (\GmagickException $e) { + throw new RuntimeException('Paste operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function resize(BoxInterface $size, $filter = ImageInterface::FILTER_UNDEFINED) + { + static $supportedFilters = array( + ImageInterface::FILTER_UNDEFINED => \Gmagick::FILTER_UNDEFINED, + ImageInterface::FILTER_BESSEL => \Gmagick::FILTER_BESSEL, + ImageInterface::FILTER_BLACKMAN => \Gmagick::FILTER_BLACKMAN, + ImageInterface::FILTER_BOX => \Gmagick::FILTER_BOX, + ImageInterface::FILTER_CATROM => \Gmagick::FILTER_CATROM, + ImageInterface::FILTER_CUBIC => \Gmagick::FILTER_CUBIC, + ImageInterface::FILTER_GAUSSIAN => \Gmagick::FILTER_GAUSSIAN, + ImageInterface::FILTER_HANNING => \Gmagick::FILTER_HANNING, + ImageInterface::FILTER_HAMMING => \Gmagick::FILTER_HAMMING, + ImageInterface::FILTER_HERMITE => \Gmagick::FILTER_HERMITE, + ImageInterface::FILTER_LANCZOS => \Gmagick::FILTER_LANCZOS, + ImageInterface::FILTER_MITCHELL => \Gmagick::FILTER_MITCHELL, + ImageInterface::FILTER_POINT => \Gmagick::FILTER_POINT, + ImageInterface::FILTER_QUADRATIC => \Gmagick::FILTER_QUADRATIC, + ImageInterface::FILTER_SINC => \Gmagick::FILTER_SINC, + ImageInterface::FILTER_TRIANGLE => \Gmagick::FILTER_TRIANGLE + ); + + if (!array_key_exists($filter, $supportedFilters)) { + throw new InvalidArgumentException('Unsupported filter type'); + } + + try { + $this->gmagick->resizeimage($size->getWidth(), $size->getHeight(), $supportedFilters[$filter], 1); + } catch (\GmagickException $e) { + throw new RuntimeException('Resize operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function rotate($angle, ColorInterface $background = null) + { + try { + $background = $background ?: $this->palette->color('fff'); + $pixel = $this->getColor($background); + + $this->gmagick->rotateimage($pixel, $angle); + + unset($pixel); + } catch (\GmagickException $e) { + throw new RuntimeException('Rotate operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * Internal + * + * Applies options before save or output + * + * @param \Gmagick $image + * @param array $options + * @param string $path + * + * @throws InvalidArgumentException + */ + private function applyImageOptions(\Gmagick $image, array $options, $path) + { + if (isset($options['format'])) { + $format = $options['format']; + } elseif ('' !== $extension = pathinfo($path, \PATHINFO_EXTENSION)) { + $format = $extension; + } else { + $format = pathinfo($image->getImageFilename(), \PATHINFO_EXTENSION); + } + + $format = strtolower($format); + + $options = $this->updateSaveOptions($options); + + if (isset($options['jpeg_quality']) && in_array($format, array('jpeg', 'jpg', 'pjpeg'))) { + $image->setCompressionQuality($options['jpeg_quality']); + } + + if ((isset($options['png_compression_level']) || isset($options['png_compression_filter'])) && $format === 'png') { + // first digit: compression level (default: 7) + if (isset($options['png_compression_level'])) { + if ($options['png_compression_level'] < 0 || $options['png_compression_level'] > 9) { + throw new InvalidArgumentException('png_compression_level option should be an integer from 0 to 9'); + } + $compression = $options['png_compression_level'] * 10; + } else { + $compression = 70; + } + + // second digit: compression filter (default: 5) + if (isset($options['png_compression_filter'])) { + if ($options['png_compression_filter'] < 0 || $options['png_compression_filter'] > 9) { + throw new InvalidArgumentException('png_compression_filter option should be an integer from 0 to 9'); + } + $compression += $options['png_compression_filter']; + } else { + $compression += 5; + } + + $image->setCompressionQuality($compression); + } + + if (isset($options['resolution-units']) && isset($options['resolution-x']) && isset($options['resolution-y'])) { + if ($options['resolution-units'] == ImageInterface::RESOLUTION_PIXELSPERCENTIMETER) { + $image->setimageunits(\Gmagick::RESOLUTION_PIXELSPERCENTIMETER); + } elseif ($options['resolution-units'] == ImageInterface::RESOLUTION_PIXELSPERINCH) { + $image->setimageunits(\Gmagick::RESOLUTION_PIXELSPERINCH); + } else { + throw new InvalidArgumentException('Unsupported image unit format'); + } + + $image->setimageresolution($options['resolution-x'], $options['resolution-y']); + } + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function save($path = null, array $options = array()) + { + $path = null === $path ? $this->gmagick->getImageFilename() : $path; + + if ('' === trim($path)) { + throw new RuntimeException('You can omit save path only if image has been open from a file'); + } + + try { + $this->prepareOutput($options, $path); + $allFrames = !isset($options['animated']) || false === $options['animated']; + $this->gmagick->writeimage($path, $allFrames); + } catch (\GmagickException $e) { + throw new RuntimeException('Save operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function show($format, array $options = array()) + { + header('Content-type: '.$this->getMimeType($format)); + echo $this->get($format, $options); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function get($format, array $options = array()) + { + try { + $options['format'] = $format; + $this->prepareOutput($options); + } catch (\GmagickException $e) { + throw new RuntimeException('Get operation failed', $e->getCode(), $e); + } + + return $this->gmagick->getimagesblob(); + } + + /** + * @param array $options + * @param string $path + */ + private function prepareOutput(array $options, $path = null) + { + if (isset($options['format'])) { + $this->gmagick->setimageformat($options['format']); + } + + if (isset($options['animated']) && true === $options['animated']) { + $format = isset($options['format']) ? $options['format'] : 'gif'; + $delay = isset($options['animated.delay']) ? $options['animated.delay'] : null; + $loops = isset($options['animated.loops']) ? $options['animated.loops'] : 0; + + $options['flatten'] = false; + + $this->layers->animate($format, $delay, $loops); + } else { + $this->layers->merge(); + } + $this->applyImageOptions($this->gmagick, $options, $path); + + // flatten only if image has multiple layers + if ((!isset($options['flatten']) || $options['flatten'] === true) && count($this->layers) > 1) { + $this->flatten(); + } + } + + /** + * {@inheritdoc} + */ + public function __toString() + { + return $this->get('png'); + } + + /** + * {@inheritdoc} + */ + public function draw() + { + return new Drawer($this->gmagick); + } + + /** + * {@inheritdoc} + */ + public function effects() + { + return new Effects($this->gmagick); + } + + /** + * {@inheritdoc} + */ + public function getSize() + { + try { + $i = $this->gmagick->getimageindex(); + $this->gmagick->setimageindex(0); //rewind + $width = $this->gmagick->getimagewidth(); + $height = $this->gmagick->getimageheight(); + $this->gmagick->setimageindex($i); + } catch (\GmagickException $e) { + throw new RuntimeException('Get size operation failed', $e->getCode(), $e); + } + + return new Box($width, $height); + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function applyMask(ImageInterface $mask) + { + if (!$mask instanceof self) { + throw new InvalidArgumentException('Can only apply instances of Symfony\Component\Image\Gmagick\Image as masks'); + } + + $size = $this->getSize(); + $maskSize = $mask->getSize(); + + if ($size != $maskSize) { + throw new InvalidArgumentException(sprintf('The given mask doesn\'t match current image\'s size, current mask\'s dimensions are %s, while image\'s dimensions are %s', $maskSize, $size)); + } + + try { + $mask = $mask->copy(); + $this->gmagick->compositeimage($mask->gmagick, \Gmagick::COMPOSITE_DEFAULT, 0, 0); + } catch (\GmagickException $e) { + throw new RuntimeException('Apply mask operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function mask() + { + $mask = $this->copy(); + + try { + $mask->gmagick->modulateimage(100, 0, 100); + } catch (\GmagickException $e) { + throw new RuntimeException('Mask operation failed', $e->getCode(), $e); + } + + return $mask; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function fill(FillInterface $fill) + { + try { + $draw = new \GmagickDraw(); + $size = $this->getSize(); + + $w = $size->getWidth(); + $h = $size->getHeight(); + + for ($x = 0; $x < $w; $x++) { + for ($y = 0; $y < $h; $y++) { + $pixel = $this->getColor($fill->getColor(new Point($x, $y))); + + $draw->setfillcolor($pixel); + $draw->point($x, $y); + + $pixel = null; + } + } + + $this->gmagick->drawimage($draw); + + $draw = null; + } catch (\GmagickException $e) { + throw new RuntimeException('Fill operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function histogram() + { + try { + $pixels = $this->gmagick->getimagehistogram(); + } catch (\GmagickException $e) { + throw new RuntimeException('Error while fetching histogram', $e->getCode(), $e); + } + + $image = $this; + + return array_map(function (\GmagickPixel $pixel) use ($image) { + return $image->pixelToColor($pixel); + }, $pixels); + } + + /** + * {@inheritdoc} + */ + public function getColorAt(PointInterface $point) + { + if (!$point->in($this->getSize())) { + throw new InvalidArgumentException(sprintf('Error getting color at point [%s,%s]. The point must be inside the image of size [%s,%s]', $point->getX(), $point->getY(), $this->getSize()->getWidth(), $this->getSize()->getHeight())); + } + + try { + $cropped = clone $this->gmagick; + $histogram = $cropped + ->cropImage(1, 1, $point->getX(), $point->getY()) + ->getImageHistogram(); + } catch (\GmagickException $e) { + throw new RuntimeException('Unable to get the pixel', $e->getCode(), $e); + } + + $pixel = array_shift($histogram); + + unset($histogram, $cropped); + + return $this->pixelToColor($pixel); + } + + /** + * Returns a color given a pixel, depending the Palette context + * + * Note : this method is public for PHP 5.3 compatibility + * + * @param \GmagickPixel $pixel + * + * @return ColorInterface + * + * @throws InvalidArgumentException In case a unknown color is requested + */ + public function pixelToColor(\GmagickPixel $pixel) + { + static $colorMapping = array( + ColorInterface::COLOR_RED => \Gmagick::COLOR_RED, + ColorInterface::COLOR_GREEN => \Gmagick::COLOR_GREEN, + ColorInterface::COLOR_BLUE => \Gmagick::COLOR_BLUE, + ColorInterface::COLOR_CYAN => \Gmagick::COLOR_CYAN, + ColorInterface::COLOR_MAGENTA => \Gmagick::COLOR_MAGENTA, + ColorInterface::COLOR_YELLOW => \Gmagick::COLOR_YELLOW, + ColorInterface::COLOR_KEYLINE => \Gmagick::COLOR_BLACK, + // There is no gray component in \Gmagick, let's use one of the RGB comp + ColorInterface::COLOR_GRAY => \Gmagick::COLOR_RED, + ); + + if ($this->palette->supportsAlpha()) { + try { + $alpha = (int) round($pixel->getcolorvalue(\Gmagick::COLOR_ALPHA) * 100); + } catch (\GmagickPixelException $e) { + $alpha = null; + } + } else { + $alpha = null; + } + + $palette = $this->palette(); + + return $this->palette->color(array_map(function ($color) use ($palette, $pixel, $colorMapping) { + if (!isset($colorMapping[$color])) { + throw new InvalidArgumentException(sprintf('Color %s is not mapped in Gmagick', $color)); + } + $multiplier = 255; + if ($palette->name() === PaletteInterface::PALETTE_CMYK) { + $multiplier = 100; + } + + return $pixel->getcolorvalue($colorMapping[$color]) * $multiplier; + }, $this->palette->pixelDefinition()), $alpha); + } + + /** + * {@inheritdoc} + */ + public function layers() + { + return $this->layers; + } + + /** + * {@inheritdoc} + */ + public function interlace($scheme) + { + static $supportedInterlaceSchemes = array( + ImageInterface::INTERLACE_NONE => \Gmagick::INTERLACE_NO, + ImageInterface::INTERLACE_LINE => \Gmagick::INTERLACE_LINE, + ImageInterface::INTERLACE_PLANE => \Gmagick::INTERLACE_PLANE, + ImageInterface::INTERLACE_PARTITION => \Gmagick::INTERLACE_PARTITION, + ); + + if (!array_key_exists($scheme, $supportedInterlaceSchemes)) { + throw new InvalidArgumentException('Unsupported interlace type'); + } + + $this->gmagick->setInterlaceScheme($supportedInterlaceSchemes[$scheme]); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function usePalette(PaletteInterface $palette) + { + if (!isset(static::$colorspaceMapping[$palette->name()])) { + throw new InvalidArgumentException(sprintf('The palette %s is not supported by Gmagick driver',$palette->name())); + } + + if ($this->palette->name() === $palette->name()) { + return $this; + } + + try { + try { + $hasICCProfile = (Boolean) $this->gmagick->getimageprofile('ICM'); + } catch (\GmagickException $e) { + $hasICCProfile = false; + } + + if (!$hasICCProfile) { + $this->profile($this->palette->profile()); + } + + $this->profile($palette->profile()); + + $this->setColorspace($palette); + $this->palette = $palette; + } catch (\GmagickException $e) { + throw new RuntimeException('Failed to set colorspace', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function palette() + { + return $this->palette; + } + + /** + * {@inheritdoc} + */ + public function profile(ProfileInterface $profile) + { + try { + $this->gmagick->profileimage('ICM', $profile->data()); + } catch (\GmagickException $e) { + if (false !== strpos($e->getMessage(), 'LCMS encoding not enabled')) { + throw new RuntimeException(sprintf('Unable to add profile %s to image, be sue to compile graphicsmagick with `--with-lcms2` option', $profile->name()), $e->getCode(), $e); + } + + throw new RuntimeException(sprintf('Unable to add profile %s to image', $profile->name()), $e->getCode(), $e); + } + + return $this; + } + + /** + * Internal + * + * Flatten the image. + */ + private function flatten() + { + /** + * @see http://pecl.php.net/bugs/bug.php?id=22435 + */ + if (method_exists($this->gmagick, 'flattenImages')) { + try { + $this->gmagick = $this->gmagick->flattenImages(); + } catch (\GmagickException $e) { + throw new RuntimeException('Flatten operation failed', $e->getCode(), $e); + } + } + } + + /** + * Gets specifically formatted color string from Color instance + * + * @param ColorInterface $color + * + * @return \GmagickPixel + * + * @throws InvalidArgumentException + */ + private function getColor(ColorInterface $color) + { + if (!$color->isOpaque()) { + throw new InvalidArgumentException('Gmagick doesn\'t support transparency'); + } + + return new \GmagickPixel((string) $color); + } + + /** + * Internal + * + * Get the mime type based on format. + * + * @param string $format + * + * @return string mime-type + * + * @throws InvalidArgumentException + */ + private function getMimeType($format) + { + static $mimeTypes = array( + 'jpeg' => 'image/jpeg', + 'jpg' => 'image/jpeg', + 'gif' => 'image/gif', + 'png' => 'image/png', + 'wbmp' => 'image/vnd.wap.wbmp', + 'xbm' => 'image/xbm', + ); + + if (!isset($mimeTypes[$format])) { + throw new InvalidArgumentException(sprintf('Unsupported format given. Only %s are supported, %s given', implode(", ", array_keys($mimeTypes)), $format)); + } + + return $mimeTypes[$format]; + } + + /** + * Sets colorspace and image type, assigns the palette. + * + * @param PaletteInterface $palette + * + * @throws InvalidArgumentException + */ + private function setColorspace(PaletteInterface $palette) + { + if (!isset(static::$colorspaceMapping[$palette->name()])) { + throw new InvalidArgumentException(sprintf('The palette %s is not supported by Gmagick driver', $palette->name())); + } + + $this->gmagick->setimagecolorspace(static::$colorspaceMapping[$palette->name()]); + $this->palette = $palette; + } +} diff --git a/src/Symfony/Component/Image/Gmagick/Layers.php b/src/Symfony/Component/Image/Gmagick/Layers.php new file mode 100644 index 0000000000000..448f213f975f3 --- /dev/null +++ b/src/Symfony/Component/Image/Gmagick/Layers.php @@ -0,0 +1,272 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Gmagick; + +use Symfony\Component\Image\Image\AbstractLayers; +use Symfony\Component\Image\Exception\RuntimeException; +use Symfony\Component\Image\Exception\NotSupportedException; +use Symfony\Component\Image\Exception\OutOfBoundsException; +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Image\Metadata\MetadataBag; +use Symfony\Component\Image\Image\Palette\PaletteInterface; + +class Layers extends AbstractLayers +{ + /** + * @var Image + */ + private $image; + + /** + * @var \Gmagick + */ + private $resource; + + /** + * @var integer + */ + private $offset = 0; + + /** + * @var array + */ + private $layers = array(); + + /** + * @var PaletteInterface + */ + private $palette; + + public function __construct(Image $image, PaletteInterface $palette, \Gmagick $resource) + { + $this->image = $image; + $this->resource = $resource; + $this->palette = $palette; + } + + /** + * {@inheritdoc} + */ + public function merge() + { + foreach ($this->layers as $offset => $image) { + try { + $this->resource->setimageindex($offset); + $this->resource->setimage($image->getGmagick()); + } catch (\GmagickException $e) { + throw new RuntimeException('Failed to substitute layer', $e->getCode(), $e); + } + } + } + + /** + * {@inheritdoc} + */ + public function coalesce() + { + throw new NotSupportedException('Gmagick does not support coalescing'); + } + + /** + * {@inheritdoc} + */ + public function animate($format, $delay, $loops) + { + if ('gif' !== strtolower($format)) { + throw new NotSupportedException('Animated picture is currently only supported on gif'); + } + + if (!is_int($loops) || $loops < 0) { + throw new InvalidArgumentException('Loops must be a positive integer.'); + } + + if (null !== $delay && (!is_int($delay) || $delay < 0)) { + throw new InvalidArgumentException('Delay must be either null or a positive integer.'); + } + + try { + foreach ($this as $offset => $layer) { + $this->resource->setimageindex($offset); + $this->resource->setimageformat($format); + + if (null !== $delay) { + $this->resource->setimagedelay($delay / 10); + } + + $this->resource->setimageiterations($loops); + } + } catch (\GmagickException $e) { + throw new RuntimeException('Failed to animate layers', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function current() + { + return $this->extractAt($this->offset); + } + + /** + * Tries to extract layer at given offset + * + * @param integer $offset + * @return Image + * @throws RuntimeException + */ + private function extractAt($offset) + { + if (!isset($this->layers[$offset])) { + try { + $this->resource->setimageindex($offset); + $this->layers[$offset] = new Image($this->resource->getimage(), $this->palette, new MetadataBag()); + } catch (\GmagickException $e) { + throw new RuntimeException(sprintf('Failed to extract layer %d', $offset), $e->getCode(), $e); + } + } + + return $this->layers[$offset]; + } + + /** + * {@inheritdoc} + */ + public function key() + { + return $this->offset; + } + + /** + * {@inheritdoc} + */ + public function next() + { + ++$this->offset; + } + + /** + * {@inheritdoc} + */ + public function rewind() + { + $this->offset = 0; + } + + /** + * {@inheritdoc} + */ + public function valid() + { + return $this->offset < count($this); + } + + /** + * {@inheritdoc} + */ + public function count() + { + try { + return $this->resource->getnumberimages(); + } catch (\GmagickException $e) { + throw new RuntimeException('Failed to count the number of layers', $e->getCode(), $e); + } + } + + /** + * {@inheritdoc} + */ + public function offsetExists($offset) + { + return is_int($offset) && $offset >= 0 && $offset < count($this); + } + + /** + * {@inheritdoc} + */ + public function offsetGet($offset) + { + return $this->extractAt($offset); + } + + /** + * {@inheritdoc} + */ + public function offsetSet($offset, $image) + { + if (!$image instanceof Image) { + throw new InvalidArgumentException('Only a Gmagick Image can be used as layer'); + } + + if (null === $offset) { + $offset = count($this) - 1; + } else { + if (!is_int($offset)) { + throw new InvalidArgumentException('Invalid offset for layer, it must be an integer'); + } + + if (count($this) < $offset || 0 > $offset) { + throw new OutOfBoundsException(sprintf('Invalid offset for layer, it must be a value between 0 and %d, %d given', count($this), $offset)); + } + + if (isset($this[$offset])) { + unset($this[$offset]); + $offset = $offset - 1; + } + } + + $frame = $image->getGmagick(); + + try { + if (count($this) > 0) { + $this->resource->setimageindex($offset); + $this->resource->nextimage(); + } + $this->resource->addimage($frame); + + /** + * ugly hack to bypass issue https://bugs.php.net/bug.php?id=64623 + */ + if (count($this) == 2) { + $this->resource->setimageindex($offset+1); + $this->resource->nextimage(); + $this->resource->addimage($frame); + unset($this[0]); + } + } catch (\GmagickException $e) { + throw new RuntimeException('Unable to set the layer', $e->getCode(), $e); + } + + $this->layers = array(); + } + + /** + * {@inheritdoc} + */ + public function offsetUnset($offset) + { + try { + $this->extractAt($offset); + } catch (RuntimeException $e) { + return; + } + + try { + $this->resource->setimageindex($offset); + $this->resource->removeimage(); + } catch (\GmagickException $e) { + throw new RuntimeException('Unable to remove layer', $e->getCode(), $e); + } + } +} diff --git a/src/Symfony/Component/Image/Gmagick/Loader.php b/src/Symfony/Component/Image/Gmagick/Loader.php new file mode 100644 index 0000000000000..d12b09e827051 --- /dev/null +++ b/src/Symfony/Component/Image/Gmagick/Loader.php @@ -0,0 +1,165 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Gmagick; + +use Symfony\Component\Image\Image\AbstractLoader; +use Symfony\Component\Image\Exception\NotSupportedException; +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Image\Metadata\MetadataBag; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\Palette\Grayscale; +use Symfony\Component\Image\Image\Palette\CMYK; +use Symfony\Component\Image\Image\Palette\RGB; +use Symfony\Component\Image\Image\Palette\Color\CMYK as CMYKColor; +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Exception\RuntimeException; + +/** + * Loader implementation using the Gmagick PHP extension + */ +class Loader extends AbstractLoader +{ + /** + * @throws RuntimeException + */ + public function __construct() + { + if (!class_exists('Gmagick')) { + throw new RuntimeException('Gmagick not installed'); + } + } + + /** + * {@inheritdoc} + */ + public function open($path) + { + $path = $this->checkPath($path); + + try { + $gmagick = new \Gmagick($path); + $image = new Image($gmagick, $this->createPalette($gmagick), $this->getMetadataReader()->readFile($path)); + } catch (\GmagickException $e) { + throw new RuntimeException(sprintf('Unable to open image %s', $path), $e->getCode(), $e); + } + + return $image; + } + + /** + * {@inheritdoc} + */ + public function create(BoxInterface $size, ColorInterface $color = null) + { + $width = $size->getWidth(); + $height = $size->getHeight(); + + $palette = null !== $color ? $color->getPalette() : new RGB(); + $color = null !== $color ? $color : $palette->color('fff'); + + try { + $gmagick = new \Gmagick(); + // Gmagick does not support creation of CMYK GmagickPixel + // see https://bugs.php.net/bug.php?id=64466 + if ($color instanceof CMYKColor) { + $switchPalette = $palette; + $palette = new RGB(); + $pixel = new \GmagickPixel($palette->color((string) $color)); + } else { + $switchPalette = null; + $pixel = new \GmagickPixel((string) $color); + } + + if ($color->getPalette()->supportsAlpha() && $color->getAlpha() < 100) { + throw new NotSupportedException('alpha transparency is not supported'); + } + + $gmagick->newimage($width, $height, $pixel->getcolor(false)); + $gmagick->setimagecolorspace(\Gmagick::COLORSPACE_TRANSPARENT); + $gmagick->setimagebackgroundcolor($pixel); + + $image = new Image($gmagick, $palette, new MetadataBag()); + + if ($switchPalette) { + $image->usePalette($switchPalette); + } + + return $image; + } catch (\GmagickException $e) { + throw new RuntimeException('Could not create empty image', $e->getCode(), $e); + } + } + + /** + * {@inheritdoc} + */ + public function load($string) + { + return $this->doLoad($string, $this->getMetadataReader()->readData($string)); + } + + /** + * {@inheritdoc} + */ + public function read($resource) + { + if (!is_resource($resource)) { + throw new InvalidArgumentException('Variable does not contain a stream resource'); + } + + $content = stream_get_contents($resource); + + if (false === $content) { + throw new InvalidArgumentException('Couldn\'t read given resource'); + } + + return $this->doLoad($content, $this->getMetadataReader()->readData($content, $resource)); + } + + /** + * {@inheritdoc} + */ + public function font($file, $size, ColorInterface $color) + { + $gmagick = new \Gmagick(); + $gmagick->newimage(1, 1, 'transparent'); + + return new Font($gmagick, $file, $size, $color); + } + + private function createPalette(\Gmagick $gmagick) + { + switch ($gmagick->getimagecolorspace()) { + case \Gmagick::COLORSPACE_SRGB: + case \Gmagick::COLORSPACE_RGB: + return new RGB(); + case \Gmagick::COLORSPACE_CMYK: + return new CMYK(); + case \Gmagick::COLORSPACE_GRAY: + return new Grayscale(); + default: + throw new NotSupportedException('Only RGB and CMYK colorspace are currently supported'); + } + } + + private function doLoad($content, MetadataBag $metadata) + { + try { + $gmagick = new \Gmagick(); + $gmagick->readimageblob($content); + } catch (\GmagickException $e) { + throw new RuntimeException('Could not load image from string', $e->getCode(), $e); + } + + return new Image($gmagick, $this->createPalette($gmagick), $metadata); + } +} diff --git a/src/Symfony/Component/Image/Image/AbstractFont.php b/src/Symfony/Component/Image/Image/AbstractFont.php new file mode 100644 index 0000000000000..d5c057652a4d2 --- /dev/null +++ b/src/Symfony/Component/Image/Image/AbstractFont.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image; + +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; + +/** + * Abstract font base class + */ +abstract class AbstractFont implements FontInterface +{ + /** + * @var string + */ + protected $file; + + /** + * @var integer + */ + protected $size; + + /** + * @var ColorInterface + */ + protected $color; + + /** + * Constructs a font with specified $file, $size and $color + * + * The font size is to be specified in points (e.g. 10pt means 10) + * + * @param string $file + * @param integer $size + * @param ColorInterface $color + */ + public function __construct($file, $size, ColorInterface $color) + { + $this->file = $file; + $this->size = $size; + $this->color = $color; + } + + /** + * {@inheritdoc} + */ + final public function getFile() + { + return $this->file; + } + + /** + * {@inheritdoc} + */ + final public function getSize() + { + return $this->size; + } + + /** + * {@inheritdoc} + */ + final public function getColor() + { + return $this->color; + } +} diff --git a/src/Symfony/Component/Image/Image/AbstractImage.php b/src/Symfony/Component/Image/Image/AbstractImage.php new file mode 100644 index 0000000000000..d01bd679a4575 --- /dev/null +++ b/src/Symfony/Component/Image/Image/AbstractImage.php @@ -0,0 +1,120 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image; + +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Image\Metadata\MetadataBag; + +abstract class AbstractImage implements ImageInterface +{ + /** + * @var MetadataBag + */ + protected $metadata; + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function thumbnail(BoxInterface $size, $mode = ImageInterface::THUMBNAIL_INSET, $filter = ImageInterface::FILTER_UNDEFINED) + { + if ($mode !== ImageInterface::THUMBNAIL_INSET && + $mode !== ImageInterface::THUMBNAIL_OUTBOUND) { + throw new InvalidArgumentException('Invalid mode specified'); + } + + $imageSize = $this->getSize(); + $ratios = array( + $size->getWidth() / $imageSize->getWidth(), + $size->getHeight() / $imageSize->getHeight() + ); + + $thumbnail = $this->copy(); + + $thumbnail->usePalette($this->palette()); + $thumbnail->strip(); + // if target width is larger than image width + // AND target height is longer than image height + if ($size->contains($imageSize)) { + return $thumbnail; + } + + if ($mode === ImageInterface::THUMBNAIL_INSET) { + $ratio = min($ratios); + } else { + $ratio = max($ratios); + } + + if ($mode === ImageInterface::THUMBNAIL_OUTBOUND) { + if (!$imageSize->contains($size)) { + $size = new Box( + min($imageSize->getWidth(), $size->getWidth()), + min($imageSize->getHeight(), $size->getHeight()) + ); + } else { + $imageSize = $thumbnail->getSize()->scale($ratio); + $thumbnail->resize($imageSize, $filter); + } + $thumbnail->crop(new Point( + max(0, round(($imageSize->getWidth() - $size->getWidth()) / 2)), + max(0, round(($imageSize->getHeight() - $size->getHeight()) / 2)) + ), $size); + } else { + if (!$imageSize->contains($size)) { + $imageSize = $imageSize->scale($ratio); + $thumbnail->resize($imageSize, $filter); + } else { + $imageSize = $thumbnail->getSize()->scale($ratio); + $thumbnail->resize($imageSize, $filter); + } + } + + return $thumbnail; + } + + /** + * Updates a given array of save options for backward compatibility with legacy names + * + * @param array $options + * + * @return array + */ + protected function updateSaveOptions(array $options) + { + // Preserve BC until version 1.0 + if (isset($options['quality']) && !isset($options['jpeg_quality'])) { + $options['jpeg_quality'] = $options['quality']; + } + + return $options; + } + + /** + * {@inheritdoc} + */ + public function metadata() + { + return $this->metadata; + } + + /** + * Assures the metadata instance will be cloned, too + */ + public function __clone() + { + if ($this->metadata !== null) { + $this->metadata = clone $this->metadata; + } + } + +} diff --git a/src/Symfony/Component/Image/Image/AbstractLayers.php b/src/Symfony/Component/Image/Image/AbstractLayers.php new file mode 100644 index 0000000000000..936e7421cf1c3 --- /dev/null +++ b/src/Symfony/Component/Image/Image/AbstractLayers.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image; + +abstract class AbstractLayers implements LayersInterface +{ + /** + * {@inheritdoc} + */ + public function add(ImageInterface $image) + { + $this[] = $image; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function set($offset, ImageInterface $image) + { + $this[$offset] = $image; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function remove($offset) + { + unset($this[$offset]); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function get($offset) + { + return $this[$offset]; + } + + /** + * {@inheritdoc} + */ + public function has($offset) + { + return isset($this[$offset]); + } +} diff --git a/src/Symfony/Component/Image/Image/AbstractLoader.php b/src/Symfony/Component/Image/Image/AbstractLoader.php new file mode 100644 index 0000000000000..4006aff865d53 --- /dev/null +++ b/src/Symfony/Component/Image/Image/AbstractLoader.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image; + +use Symfony\Component\Image\Image\Metadata\DefaultMetadataReader; +use Symfony\Component\Image\Image\Metadata\ExifMetadataReader; +use Symfony\Component\Image\Image\Metadata\MetadataReaderInterface; +use Symfony\Component\Image\Exception\InvalidArgumentException; + +abstract class AbstractLoader implements LoaderInterface +{ + /** @var MetadataReaderInterface */ + private $metadataReader; + + /** + * @param MetadataReaderInterface $metadataReader + * + * @return LoaderInterface + */ + public function setMetadataReader(MetadataReaderInterface $metadataReader) + { + $this->metadataReader = $metadataReader; + + return $this; + } + + /** + * @return MetadataReaderInterface + */ + public function getMetadataReader() + { + if (null === $this->metadataReader) { + if (ExifMetadataReader::isSupported()) { + $this->metadataReader = new ExifMetadataReader(); + } else { + $this->metadataReader = new DefaultMetadataReader(); + } + } + + return $this->metadataReader; + } + + /** + * Checks a path that could be used with LoaderInterface::open and returns + * a proper string. + * + * @param string|object $path + * + * @return string + * + * @throws InvalidArgumentException In case the given path is invalid. + */ + protected function checkPath($path) + { + // provide compatibility with objects such as \SplFileInfo + if (is_object($path) && method_exists($path, '__toString')) { + $path = (string) $path; + } + + $handle = @fopen($path, 'r'); + + if (false === $handle) { + throw new InvalidArgumentException(sprintf('File %s does not exist.', $path)); + } + + fclose($handle); + + return $path; + } +} diff --git a/src/Symfony/Component/Image/Image/Box.php b/src/Symfony/Component/Image/Image/Box.php new file mode 100644 index 0000000000000..2872e48a6f670 --- /dev/null +++ b/src/Symfony/Component/Image/Image/Box.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image; + +use Symfony\Component\Image\Exception\InvalidArgumentException; + +/** + * A box implementation + */ +final class Box implements BoxInterface +{ + /** + * @var integer + */ + private $width; + + /** + * @var integer + */ + private $height; + + /** + * Constructs the Size with given width and height + * + * @param integer $width + * @param integer $height + * + * @throws InvalidArgumentException + */ + public function __construct($width, $height) + { + if ($height < 1 || $width < 1) { + throw new InvalidArgumentException(sprintf('Length of either side cannot be 0 or negative, current size is %sx%s', $width, $height)); + } + + $this->width = (int) $width; + $this->height = (int) $height; + } + + /** + * {@inheritdoc} + */ + public function getWidth() + { + return $this->width; + } + + /** + * {@inheritdoc} + */ + public function getHeight() + { + return $this->height; + } + + /** + * {@inheritdoc} + */ + public function scale($ratio) + { + return new Box(round($ratio * $this->width), round($ratio * $this->height)); + } + + /** + * {@inheritdoc} + */ + public function increase($size) + { + return new Box((int) $size + $this->width, (int) $size + $this->height); + } + + /** + * {@inheritdoc} + */ + public function contains(BoxInterface $box, PointInterface $start = null) + { + $start = $start ? $start : new Point(0, 0); + + return $start->in($this) && $this->width >= $box->getWidth() + $start->getX() && $this->height >= $box->getHeight() + $start->getY(); + } + + /** + * {@inheritdoc} + */ + public function square() + { + return $this->width * $this->height; + } + + /** + * {@inheritdoc} + */ + public function __toString() + { + return sprintf('%dx%d px', $this->width, $this->height); + } + + /** + * {@inheritdoc} + */ + public function widen($width) + { + return $this->scale($width / $this->width); + } + + /** + * {@inheritdoc} + */ + public function heighten($height) + { + return $this->scale($height / $this->height); + } +} diff --git a/src/Symfony/Component/Image/Image/BoxInterface.php b/src/Symfony/Component/Image/Image/BoxInterface.php new file mode 100644 index 0000000000000..0fde68689da20 --- /dev/null +++ b/src/Symfony/Component/Image/Image/BoxInterface.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image; + +/** + * Interface for a box + */ +interface BoxInterface +{ + /** + * Gets current image height + * + * @return integer + */ + public function getHeight(); + + /** + * Gets current image width + * + * @return integer + */ + public function getWidth(); + + /** + * Creates new BoxInterface instance with ratios applied to both sides + * + * @param float $ratio + * + * @return BoxInterface + */ + public function scale($ratio); + + /** + * Creates new BoxInterface, adding given size to both sides + * + * @param integer $size + * + * @return BoxInterface + */ + public function increase($size); + + /** + * Checks whether current box can fit given box at a given start position, + * start position defaults to top left corner xy(0,0) + * + * @param BoxInterface $box + * @param PointInterface $start + * + * @return Boolean + */ + public function contains(BoxInterface $box, PointInterface $start = null); + + /** + * Gets current box square, useful for getting total number of pixels in a + * given box + * + * @return integer + */ + public function square(); + + /** + * Returns a string representation of the current box + * + * @return string + */ + public function __toString(); + + /** + * Resizes box to given width, constraining proportions and returns the new box + * + * @param integer $width + * + * @return BoxInterface + */ + public function widen($width); + + /** + * Resizes box to given height, constraining proportions and returns the new box + * + * @param integer $height + * + * @return BoxInterface + */ + public function heighten($height); +} diff --git a/src/Symfony/Component/Image/Image/Fill/FillInterface.php b/src/Symfony/Component/Image/Image/Fill/FillInterface.php new file mode 100644 index 0000000000000..2ec2d32c73df6 --- /dev/null +++ b/src/Symfony/Component/Image/Image/Fill/FillInterface.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image\Fill; + +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\PointInterface; + +/** + * Interface for the fill + */ +interface FillInterface +{ + /** + * Gets color of the fill for the given position + * + * @param PointInterface $position + * + * @return ColorInterface + */ + public function getColor(PointInterface $position); +} diff --git a/src/Symfony/Component/Image/Image/Fill/Gradient/Horizontal.php b/src/Symfony/Component/Image/Image/Fill/Gradient/Horizontal.php new file mode 100644 index 0000000000000..68bad65ff5921 --- /dev/null +++ b/src/Symfony/Component/Image/Image/Fill/Gradient/Horizontal.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image\Fill\Gradient; + +use Symfony\Component\Image\Image\PointInterface; + +/** + * Horizontal gradient fill + */ +final class Horizontal extends Linear +{ + /** + * {@inheritdoc} + */ + public function getDistance(PointInterface $position) + { + return $position->getX(); + } +} diff --git a/src/Symfony/Component/Image/Image/Fill/Gradient/Linear.php b/src/Symfony/Component/Image/Image/Fill/Gradient/Linear.php new file mode 100644 index 0000000000000..5335a16bc674e --- /dev/null +++ b/src/Symfony/Component/Image/Image/Fill/Gradient/Linear.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image\Fill\Gradient; + +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\Fill\FillInterface; +use Symfony\Component\Image\Image\PointInterface; + +/** + * Linear gradient fill + */ +abstract class Linear implements FillInterface +{ + /** + * @var integer + */ + private $length; + + /** + * @var ColorInterface + */ + private $start; + + /** + * @var ColorInterface + */ + private $end; + + /** + * Constructs a linear gradient with overall gradient length, and start and + * end shades, which default to 0 and 255 accordingly + * + * @param integer $length + * @param ColorInterface $start + * @param ColorInterface $end + */ + final public function __construct($length, ColorInterface $start, ColorInterface $end) + { + $this->length = $length; + $this->start = $start; + $this->end = $end; + } + + /** + * {@inheritdoc} + */ + final public function getColor(PointInterface $position) + { + $l = $this->getDistance($position); + + if ($l >= $this->length) { + return $this->end; + } + + if ($l < 0) { + return $this->start; + } + + return $this->start->getPalette()->blend($this->start, $this->end, $l / $this->length); + } + + /** + * @return ColorInterface + */ + final public function getStart() + { + return $this->start; + } + + /** + * @return ColorInterface + */ + final public function getEnd() + { + return $this->end; + } + + /** + * Get the distance of the position relative to the beginning of the gradient + * + * @param PointInterface $position + * + * @return integer + */ + abstract protected function getDistance(PointInterface $position); +} diff --git a/src/Symfony/Component/Image/Image/Fill/Gradient/Vertical.php b/src/Symfony/Component/Image/Image/Fill/Gradient/Vertical.php new file mode 100644 index 0000000000000..cd422d04b3026 --- /dev/null +++ b/src/Symfony/Component/Image/Image/Fill/Gradient/Vertical.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image\Fill\Gradient; + +use Symfony\Component\Image\Image\PointInterface; + +/** + * Vertical gradient fill + */ +final class Vertical extends Linear +{ + /** + * {@inheritdoc} + */ + public function getDistance(PointInterface $position) + { + return $position->getY(); + } +} diff --git a/src/Symfony/Component/Image/Image/FontInterface.php b/src/Symfony/Component/Image/Image/FontInterface.php new file mode 100644 index 0000000000000..3bb4ab41e2303 --- /dev/null +++ b/src/Symfony/Component/Image/Image/FontInterface.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image; + +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; + +/** + * The font interface + */ +interface FontInterface +{ + /** + * Gets the fontfile for current font + * + * @return string + */ + public function getFile(); + + /** + * Gets font's integer point size + * + * @return integer + */ + public function getSize(); + + /** + * Gets font's color + * + * @return ColorInterface + */ + public function getColor(); + + /** + * Gets BoxInterface of font size on the image based on string and angle + * + * @param string $string + * @param integer $angle + * + * @return BoxInterface + */ + public function box($string, $angle = 0); +} diff --git a/src/Symfony/Component/Image/Image/Histogram/Bucket.php b/src/Symfony/Component/Image/Image/Histogram/Bucket.php new file mode 100644 index 0000000000000..c5b63cb8fa657 --- /dev/null +++ b/src/Symfony/Component/Image/Image/Histogram/Bucket.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image\Histogram; + +/** + * Bucket histogram + */ +final class Bucket implements \Countable +{ + /** + * @var Range + */ + private $range; + + /** + * @var integer + */ + private $count; + + /** + * @param Range $range + * @param integer $count + */ + public function __construct(Range $range, $count = 0) + { + $this->range = $range; + $this->count = $count; + } + + /** + * @param integer $value + */ + public function add($value) + { + if ($this->range->contains($value)) { + $this->count++; + } + } + + /** + * @return integer The number of elements in the bucket. + */ + public function count() + { + return $this->count; + } +} diff --git a/src/Symfony/Component/Image/Image/Histogram/Range.php b/src/Symfony/Component/Image/Image/Histogram/Range.php new file mode 100644 index 0000000000000..ff28ce9786902 --- /dev/null +++ b/src/Symfony/Component/Image/Image/Histogram/Range.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image\Histogram; + +use Symfony\Component\Image\Exception\OutOfBoundsException; + +/** + * Range histogram + */ +final class Range +{ + /** + * @var integer + */ + private $start; + + /** + * @var integer + */ + private $end; + + /** + * @param integer $start + * @param integer $end + * + * @throws OutOfBoundsException + */ + public function __construct($start, $end) + { + if ($end <= $start) { + throw new OutOfBoundsException(sprintf('Range end cannot be bigger than start, %d %d given accordingly', $this->start, $this->end)); + } + + $this->start = $start; + $this->end = $end; + } + + /** + * @param integer $value + * + * @return Boolean + */ + public function contains($value) + { + return $value >= $this->start && $value < $this->end; + } +} diff --git a/src/Symfony/Component/Image/Image/ImageInterface.php b/src/Symfony/Component/Image/Image/ImageInterface.php new file mode 100644 index 0000000000000..562d47cdbd8b1 --- /dev/null +++ b/src/Symfony/Component/Image/Image/ImageInterface.php @@ -0,0 +1,173 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image; + +use Symfony\Component\Image\Draw\DrawerInterface; +use Symfony\Component\Image\Effects\EffectsInterface; +use Symfony\Component\Image\Image\Palette\PaletteInterface; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Exception\RuntimeException; +use Symfony\Component\Image\Exception\OutOfBoundsException; + +/** + * The image interface + */ +interface ImageInterface extends ManipulatorInterface +{ + const RESOLUTION_PIXELSPERINCH = 'ppi'; + const RESOLUTION_PIXELSPERCENTIMETER = 'ppc'; + + const INTERLACE_NONE = 'none'; + const INTERLACE_LINE = 'line'; + const INTERLACE_PLANE = 'plane'; + const INTERLACE_PARTITION = 'partition'; + + const FILTER_UNDEFINED = 'undefined'; + const FILTER_POINT = 'point'; + const FILTER_BOX = 'box'; + const FILTER_TRIANGLE = 'triangle'; + const FILTER_HERMITE = 'hermite'; + const FILTER_HANNING = 'hanning'; + const FILTER_HAMMING = 'hamming'; + const FILTER_BLACKMAN = 'blackman'; + const FILTER_GAUSSIAN = 'gaussian'; + const FILTER_QUADRATIC = 'quadratic'; + const FILTER_CUBIC = 'cubic'; + const FILTER_CATROM = 'catrom'; + const FILTER_MITCHELL = 'mitchell'; + const FILTER_LANCZOS = 'lanczos'; + const FILTER_BESSEL = 'bessel'; + const FILTER_SINC = 'sinc'; + + /** + * Returns the image content as a binary string + * + * @param string $format + * @param array $options + * + * @throws RuntimeException + * + * @return string binary + */ + public function get($format, array $options = array()); + + /** + * Returns the image content as a PNG binary string + * + * @throws RuntimeException + * + * @return string binary + */ + public function __toString(); + + /** + * Instantiates and returns a DrawerInterface instance for image drawing + * + * @return DrawerInterface + */ + public function draw(); + + /** + * @return EffectsInterface + */ + public function effects(); + + /** + * Returns current image size + * + * @return BoxInterface + */ + public function getSize(); + + /** + * Transforms creates a grayscale mask from current image, returns a new + * image, while keeping the existing image unmodified + * + * @return ImageInterface + */ + public function mask(); + + /** + * Returns array of image colors as Symfony\Component\Image\Image\Palette\Color\ColorInterface instances + * + * @return array + */ + public function histogram(); + + /** + * Returns color at specified positions of current image + * + * @param PointInterface $point + * + * @throws RuntimeException + * + * @return ColorInterface + */ + public function getColorAt(PointInterface $point); + + /** + * Returns the image layers when applicable. + * + * @throws RuntimeException In case the layer can not be returned + * @throws OutOfBoundsException In case the index is not a valid value + * + * @return LayersInterface + */ + public function layers(); + + /** + * Enables or disables interlacing + * + * @param string $scheme + * + * @throws InvalidArgumentException When an unsupported Interface type is supplied + * + * @return ImageInterface + */ + public function interlace($scheme); + + /** + * Return the current color palette + * + * @return PaletteInterface + */ + public function palette(); + + /** + * Set a palette for the image. Useful to change colorspace. + * + * @param PaletteInterface $palette + * + * @return ImageInterface + * + * @throws RuntimeException + */ + public function usePalette(PaletteInterface $palette); + + /** + * Applies a color profile on the Image + * + * @param ProfileInterface $profile + * + * @return ImageInterface + * + * @throws RuntimeException + */ + public function profile(ProfileInterface $profile); + + /** + * Returns the Image's meta data + * + * @return Metadata\MetadataBag + */ + public function metadata(); +} diff --git a/src/Symfony/Component/Image/Image/LayersInterface.php b/src/Symfony/Component/Image/Image/LayersInterface.php new file mode 100644 index 0000000000000..991d86adbe631 --- /dev/null +++ b/src/Symfony/Component/Image/Image/LayersInterface.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image; + +use Symfony\Component\Image\Exception\RuntimeException; +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Exception\OutOfBoundsException; + +/** + * The layers interface + */ +interface LayersInterface extends \Iterator, \Countable, \ArrayAccess +{ + /** + * Merge layers into the original objects + * + * @throws RuntimeException + */ + public function merge(); + + /** + * Animates layers + * + * @param string $format The output output format + * @param integer $delay The delay in milliseconds between two frames + * @param integer $loops The number of loops, 0 means infinite + * + * @return LayersInterface + * + * @throws InvalidArgumentException In case an invalid argument is provided + * @throws RuntimeException In case the driver fails to animate + */ + public function animate($format, $delay, $loops); + + /** + * Coalesce layers. Each layer in the sequence is the same size as the first and composited with the next layer in + * the sequence. + */ + public function coalesce(); + + /** + * Adds an image at the end of the layers stack + * + * @param ImageInterface $image + * + * @return LayersInterface + * + * @throws RuntimeException + */ + public function add(ImageInterface $image); + + /** + * Set an image at offset + * + * @param integer $offset + * @param ImageInterface $image + * + * @return LayersInterface + * + * @throws RuntimeException + * @throws InvalidArgumentException + * @throws OutOfBoundsException + */ + public function set($offset, ImageInterface $image); + + /** + * Removes the image at offset + * + * @param integer $offset + * + * @return LayersInterface + * + * @throws RuntimeException + * @throws InvalidArgumentException + */ + public function remove($offset); + + /** + * Returns the image at offset + * + * @param integer $offset + * + * @return ImageInterface + * + * @throws RuntimeException + * @throws InvalidArgumentException + */ + public function get($offset); + + /** + * Returns true if a layer at offset is preset + * + * @param integer $offset + * + * @return Boolean + */ + public function has($offset); +} diff --git a/src/Symfony/Component/Image/Image/LoaderInterface.php b/src/Symfony/Component/Image/Image/LoaderInterface.php new file mode 100644 index 0000000000000..54009a1483658 --- /dev/null +++ b/src/Symfony/Component/Image/Image/LoaderInterface.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image; + +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Exception\RuntimeException; + +/** + * The imagine interface + */ +interface LoaderInterface +{ + const VERSION = '0.7-dev'; + + /** + * Creates a new empty image with an optional background color + * + * @param BoxInterface $size + * @param ColorInterface $color + * + * @throws InvalidArgumentException + * @throws RuntimeException + * + * @return ImageInterface + */ + public function create(BoxInterface $size, ColorInterface $color = null); + + /** + * Opens an existing image from $path + * + * @param string $path + * + * @throws RuntimeException + * + * @return ImageInterface + */ + public function open($path); + + /** + * Loads an image from a binary $string + * + * @param string $string + * + * @throws RuntimeException + * + * @return ImageInterface + */ + public function load($string); + + /** + * Loads an image from a resource $resource + * + * @param resource $resource + * + * @throws RuntimeException + * + * @return ImageInterface + */ + public function read($resource); + + /** + * Constructs a font with specified $file, $size and $color + * + * The font size is to be specified in points (e.g. 10pt means 10) + * + * @param string $file + * @param integer $size + * @param ColorInterface $color + * + * @return FontInterface + */ + public function font($file, $size, ColorInterface $color); +} diff --git a/src/Symfony/Component/Image/Image/ManipulatorInterface.php b/src/Symfony/Component/Image/Image/ManipulatorInterface.php new file mode 100644 index 0000000000000..2510222877222 --- /dev/null +++ b/src/Symfony/Component/Image/Image/ManipulatorInterface.php @@ -0,0 +1,181 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image; + +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Exception\OutOfBoundsException; +use Symfony\Component\Image\Exception\RuntimeException; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\Fill\FillInterface; + +/** + * The manipulator interface + */ +interface ManipulatorInterface +{ + const THUMBNAIL_INSET = 'inset'; + const THUMBNAIL_OUTBOUND = 'outbound'; + + /** + * Copies current source image into a new ImageInterface instance + * + * @throws RuntimeException + * + * @return static + */ + public function copy(); + + /** + * Crops a specified box out of the source image (modifies the source image) + * Returns cropped self + * + * @param PointInterface $start + * @param BoxInterface $size + * + * @throws OutOfBoundsException + * @throws RuntimeException + * + * @return static + */ + public function crop(PointInterface $start, BoxInterface $size); + + /** + * Resizes current image and returns self + * + * @param BoxInterface $size + * @param string $filter + * + * @throws RuntimeException + * + * @return static + */ + public function resize(BoxInterface $size, $filter = ImageInterface::FILTER_UNDEFINED); + + /** + * Rotates an image at the given angle. + * Optional $background can be used to specify the fill color of the empty + * area of rotated image. + * + * @param integer $angle + * @param ColorInterface $background + * + * @throws RuntimeException + * + * @return static + */ + public function rotate($angle, ColorInterface $background = null); + + /** + * Pastes an image into a parent image + * Throws exceptions if image exceeds parent image borders or if paste + * operation fails + * + * Returns source image + * + * @param ImageInterface $image + * @param PointInterface $start + * + * @throws InvalidArgumentException + * @throws OutOfBoundsException + * @throws RuntimeException + * + * @return static + */ + public function paste(ImageInterface $image, PointInterface $start); + + /** + * Saves the image at a specified path, the target file extension is used + * to determine file format, only jpg, jpeg, gif, png, wbmp and xbm are + * supported + * + * @param string $path + * @param array $options + * + * @throws RuntimeException + * + * @return static + */ + public function save($path = null, array $options = array()); + + /** + * Outputs the image content + * + * @param string $format + * @param array $options + * + * @throws RuntimeException + * + * @return static + */ + public function show($format, array $options = array()); + + /** + * Flips current image using horizontal axis + * + * @throws RuntimeException + * + * @return static + */ + public function flipHorizontally(); + + /** + * Flips current image using vertical axis + * + * @throws RuntimeException + * + * @return static + */ + public function flipVertically(); + + /** + * Remove all profiles and comments + * + * @throws RuntimeException + * + * @return static + */ + public function strip(); + + /** + * Generates a thumbnail from a current image + * Returns it as a new image, doesn't modify the current image + * + * @param BoxInterface $size + * @param string $mode + * @param string $filter The filter to use for resizing, one of ImageInterface::FILTER_* + * + * @throws RuntimeException + * + * @return static + */ + public function thumbnail(BoxInterface $size, $mode = self::THUMBNAIL_INSET, $filter = ImageInterface::FILTER_UNDEFINED); + + /** + * Applies a given mask to current image's alpha channel + * + * @param ImageInterface $mask + * + * @return static + */ + public function applyMask(ImageInterface $mask); + + /** + * Fills image with provided filling, by replacing each pixel's color in + * the current image with corresponding color from FillInterface, and + * returns modified image + * + * @param FillInterface $fill + * + * @return static + */ + public function fill(FillInterface $fill); +} diff --git a/src/Symfony/Component/Image/Image/Metadata/AbstractMetadataReader.php b/src/Symfony/Component/Image/Image/Metadata/AbstractMetadataReader.php new file mode 100644 index 0000000000000..0c35323a48983 --- /dev/null +++ b/src/Symfony/Component/Image/Image/Metadata/AbstractMetadataReader.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image\Metadata; + +use Symfony\Component\Image\Exception\InvalidArgumentException; + +abstract class AbstractMetadataReader implements MetadataReaderInterface +{ + /** + * {@inheritdoc} + */ + public function readFile($file) + { + if (stream_is_local($file)) { + if (!is_file($file)) { + throw new InvalidArgumentException(sprintf('File %s does not exist.', $file)); + } + + return new MetadataBag(array_merge(array('filepath' => realpath($file), 'uri' => $file), $this->extractFromFile($file))); + } + + return new MetadataBag(array_merge(array('uri' => $file), $this->extractFromFile($file))); + } + + /** + * {@inheritdoc} + */ + public function readData($data, $originalResource = null) + { + if (null !== $originalResource) { + return new MetadataBag(array_merge($this->getStreamMetadata($originalResource), $this->extractFromData($data))); + } + + return new MetadataBag($this->extractFromData($data)); + } + + /** + * {@inheritdoc} + */ + public function readStream($resource) + { + if (!is_resource($resource)) { + throw new InvalidArgumentException('Invalid resource provided.'); + } + + return new MetadataBag(array_merge($this->getStreamMetadata($resource), $this->extractFromStream($resource))); + } + + /** + * Gets the URI from a stream resource + * + * @param resource $resource + * + * @return string|null The URI f ava + */ + private function getStreamMetadata($resource) + { + $metadata = array(); + + if (false !== $data = @stream_get_meta_data($resource)) { + $metadata['uri'] = $data['uri']; + if (stream_is_local($resource)) { + $metadata['filepath'] = realpath($data['uri']); + } + } + + return $metadata; + } + + /** + * Extracts metadata from a file + * + * @param $file + * + * @return array An associative array of metadata + */ + abstract protected function extractFromFile($file); + + /** + * Extracts metadata from raw data + * + * @param $data + * + * @return array An associative array of metadata + */ + abstract protected function extractFromData($data); + + /** + * Extracts metadata from a stream + * + * @param $resource + * + * @return array An associative array of metadata + */ + abstract protected function extractFromStream($resource); +} diff --git a/src/Symfony/Component/Image/Image/Metadata/DefaultMetadataReader.php b/src/Symfony/Component/Image/Image/Metadata/DefaultMetadataReader.php new file mode 100644 index 0000000000000..c8f23833e8f10 --- /dev/null +++ b/src/Symfony/Component/Image/Image/Metadata/DefaultMetadataReader.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image\Metadata; + +/** + * Default metadata reader + */ +class DefaultMetadataReader extends AbstractMetadataReader +{ + /** + * {@inheritdoc} + */ + protected function extractFromFile($file) + { + return array(); + } + + /** + * {@inheritdoc} + */ + protected function extractFromData($data) + { + return array(); + } + + /** + * {@inheritdoc} + */ + protected function extractFromStream($resource) + { + return array(); + } +} diff --git a/src/Symfony/Component/Image/Image/Metadata/ExifMetadataReader.php b/src/Symfony/Component/Image/Image/Metadata/ExifMetadataReader.php new file mode 100644 index 0000000000000..7dc1881d5b6e4 --- /dev/null +++ b/src/Symfony/Component/Image/Image/Metadata/ExifMetadataReader.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image\Metadata; + +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Exception\NotSupportedException; + +/** + * Metadata driven by Exif information + */ +class ExifMetadataReader extends AbstractMetadataReader +{ + public function __construct() + { + if (!self::isSupported()) { + throw new NotSupportedException('PHP exif extension is required to use the ExifMetadataReader'); + } + } + + public static function isSupported() + { + return function_exists('exif_read_data'); + } + + /** + * {@inheritdoc} + */ + protected function extractFromFile($file) + { + if (stream_is_local($file)) { + if (false === is_readable($file)) { + throw new InvalidArgumentException(sprintf('File %s is not readable.', $file)); + } + + return $this->extract($file); + } + + if (false === $data = @file_get_contents($file)) { + throw new InvalidArgumentException(sprintf('File %s is not readable.', $file)); + } + + return $this->doReadData($data); + } + + /** + * {@inheritdoc} + */ + protected function extractFromData($data) + { + return $this->doReadData($data); + } + + /** + * {@inheritdoc} + */ + protected function extractFromStream($resource) + { + return $this->doReadData(stream_get_contents($resource)); + } + + /** + * Extracts metadata from raw data, merges with existing metadata + * + * @param string $data + * + * @return MetadataBag + */ + private function doReadData($data) + { + if (substr($data, 0, 2) === 'II') { + $mime = 'image/tiff'; + } else { + $mime = 'image/jpeg'; + } + + return $this->extract('data://' . $mime . ';base64,' . base64_encode($data)); + } + + /** + * Performs the exif data extraction given a path or data-URI representation. + * + * @param string $path The path to the file or the data-URI representation. + * + * @return MetadataBag + */ + private function extract($path) + { + if (false === $exifData = @exif_read_data($path, null, true, false)) { + return array(); + } + + $metadata = array(); + $sources = array('EXIF' => 'exif', 'IFD0' => 'ifd0'); + + foreach ($sources as $name => $prefix) { + if (!isset($exifData[$name])) { + continue; + } + foreach ($exifData[$name] as $prop => $value) { + $metadata[$prefix.'.'.$prop] = $value; + } + } + + return $metadata; + } +} diff --git a/src/Symfony/Component/Image/Image/Metadata/MetadataBag.php b/src/Symfony/Component/Image/Image/Metadata/MetadataBag.php new file mode 100644 index 0000000000000..91087a03249ab --- /dev/null +++ b/src/Symfony/Component/Image/Image/Metadata/MetadataBag.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image\Metadata; + +/** + * An interface for Image Metadata + */ +class MetadataBag implements \ArrayAccess, \IteratorAggregate, \Countable +{ + /** @var array */ + private $data; + + public function __construct(array $data = array()) + { + $this->data = $data; + } + + /** + * Returns the metadata key, default value if it does not exist + * + * @param string $key + * @param mixed|null $default + * + * @return mixed + */ + public function get($key, $default = null) + { + return array_key_exists($key, $this->data) ? $this->data[$key] : $default; + } + + /** + * {@inheritdoc} + */ + public function count() + { + return count($this->data); + } + + /** + * {@inheritdoc} + */ + public function getIterator() + { + return new \ArrayIterator($this->data); + } + + /** + * {@inheritdoc} + */ + public function offsetExists($offset) + { + return array_key_exists($offset, $this->data); + } + + /** + * {@inheritdoc} + */ + public function offsetSet($offset, $value) + { + $this->data[$offset] = $value; + } + + /** + * {@inheritdoc} + */ + public function offsetUnset($offset) + { + unset($this->data[$offset]); + } + + /** + * {@inheritdoc} + */ + public function offsetGet($offset) + { + return $this->get($offset); + } + + /** + * Returns metadata as an array + * + * @return array An associative array + */ + public function toArray() + { + return $this->data; + } +} diff --git a/src/Symfony/Component/Image/Image/Metadata/MetadataReaderInterface.php b/src/Symfony/Component/Image/Image/Metadata/MetadataReaderInterface.php new file mode 100644 index 0000000000000..110b809e06ebf --- /dev/null +++ b/src/Symfony/Component/Image/Image/Metadata/MetadataReaderInterface.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image\Metadata; + +use Symfony\Component\Image\Exception\InvalidArgumentException; + +interface MetadataReaderInterface +{ + /** + * Reads metadata from a file. + * + * @param $file The path to the file where to read metadata. + * + * @throws InvalidArgumentException In case the file does not exist. + * + * @return MetadataBag + */ + public function readFile($file); + + /** + * Reads metadata from a binary string. + * + * @param $data The binary string to read. + * @param $originalResource An optional resource to gather stream metadata. + * + * @return MetadataBag + */ + public function readData($data, $originalResource = null); + + /** + * Reads metadata from a stream. + * + * @param $resource The stream to read. + * + * @throws InvalidArgumentException In case the resource is not valid. + * + * @return MetadataBag + */ + public function readStream($resource); +} diff --git a/src/Symfony/Component/Image/Image/Palette/CMYK.php b/src/Symfony/Component/Image/Image/Palette/CMYK.php new file mode 100644 index 0000000000000..da49bb0a45ad4 --- /dev/null +++ b/src/Symfony/Component/Image/Image/Palette/CMYK.php @@ -0,0 +1,118 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image\Palette; + +use Symfony\Component\Image\Image\Palette\Color\CMYK as CMYKColor; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Exception\RuntimeException; +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Image\Profile; +use Symfony\Component\Image\Image\ProfileInterface; + +class CMYK implements PaletteInterface +{ + private $parser; + private $profile; + private static $colors = array(); + + public function __construct() + { + $this->parser = new ColorParser(); + } + + /** + * {@inheritdoc} + */ + public function name() + { + return PaletteInterface::PALETTE_CMYK; + } + + /** + * {@inheritdoc} + */ + public function pixelDefinition() + { + return array( + ColorInterface::COLOR_CYAN, + ColorInterface::COLOR_MAGENTA, + ColorInterface::COLOR_YELLOW, + ColorInterface::COLOR_KEYLINE, + ); + } + + /** + * {@inheritdoc} + */ + public function supportsAlpha() + { + return false; + } + + /** + * {@inheritdoc} + */ + public function color($color, $alpha = null) + { + if (null !== $alpha) { + throw new InvalidArgumentException('CMYK palette does not support alpha'); + } + + $color = $this->parser->parseToCMYK($color); + $index = sprintf('cmyk(%d, %d, %d, %d)', $color[0], $color[1], $color[2], $color[3]); + + if (false === array_key_exists($index, self::$colors)) { + self::$colors[$index] = new CMYKColor($this, $color); + } + + return self::$colors[$index]; + } + + /** + * {@inheritdoc} + */ + public function blend(ColorInterface $color1, ColorInterface $color2, $amount) + { + if (!$color1 instanceof CMYKColor || ! $color2 instanceof CMYKColor) { + throw new RuntimeException('CMYK palette can only blend CMYK colors'); + } + + return $this->color(array( + min(100, $color1->getCyan() + $color2->getCyan() * $amount), + min(100, $color1->getMagenta() + $color2->getMagenta() * $amount), + min(100, $color1->getYellow() + $color2->getYellow() * $amount), + min(100, $color1->getKeyline() + $color2->getKeyline() * $amount), + )); + } + + /** + * {@inheritdoc} + */ + public function useProfile(ProfileInterface $profile) + { + $this->profile = $profile; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function profile() + { + if (!$this->profile) { + $this->profile = Profile::fromPath(__DIR__ . '/../../Resources/Adobe/CMYK/USWebUncoated.icc'); + } + + return $this->profile; + } +} diff --git a/src/Symfony/Component/Image/Image/Palette/Color/CMYK.php b/src/Symfony/Component/Image/Image/Palette/Color/CMYK.php new file mode 100644 index 0000000000000..366cb46f66803 --- /dev/null +++ b/src/Symfony/Component/Image/Image/Palette/Color/CMYK.php @@ -0,0 +1,219 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image\Palette\Color; + +use Symfony\Component\Image\Image\Palette\CMYK as CMYKPalette; +use Symfony\Component\Image\Exception\RuntimeException; +use Symfony\Component\Image\Exception\InvalidArgumentException; + +final class CMYK implements ColorInterface +{ + /** + * @var integer + */ + private $c; + + /** + * @var integer + */ + private $m; + + /** + * @var integer + */ + private $y; + + /** + * @var integer + */ + private $k; + + /** + * + * @var CMYK + */ + private $palette; + + public function __construct(CMYKPalette $palette, array $color) + { + $this->palette = $palette; + $this->setColor($color); + } + + /** + * {@inheritdoc} + */ + public function getValue($component) + { + switch ($component) { + case ColorInterface::COLOR_CYAN: + return $this->getCyan(); + case ColorInterface::COLOR_MAGENTA: + return $this->getMagenta(); + case ColorInterface::COLOR_YELLOW: + return $this->getYellow(); + case ColorInterface::COLOR_KEYLINE: + return $this->getKeyline(); + default: + throw new InvalidArgumentException(sprintf('Color component %s is not valid', $component)); + } + } + + /** + * Returns Cyan value of the color + * + * @return integer + */ + public function getCyan() + { + return $this->c; + } + + /** + * Returns Magenta value of the color + * + * @return integer + */ + public function getMagenta() + { + return $this->m; + } + + /** + * Returns Yellow value of the color + * + * @return integer + */ + public function getYellow() + { + return $this->y; + } + + /** + * Returns Key value of the color + * + * @return integer + */ + public function getKeyline() + { + return $this->k; + } + + /** + * {@inheritdoc} + */ + public function getPalette() + { + return $this->palette; + } + + /** + * {@inheritdoc} + */ + public function getAlpha() + { + return null; + } + + /** + * {@inheritdoc} + */ + public function dissolve($alpha) + { + throw new RuntimeException('CMYK does not support dissolution'); + } + + /** + * {@inheritdoc} + */ + public function lighten($shade) + { + return $this->palette->color( + array( + $this->c, + $this->m, + $this->y, + max(0, $this->k - $shade), + ) + ); + } + + /** + * {@inheritdoc} + */ + public function darken($shade) + { + return $this->palette->color( + array( + $this->c, + $this->m, + $this->y, + min(100, $this->k + $shade), + ) + ); + } + + /** + * {@inheritdoc} + */ + public function grayscale() + { + $color = array( + $this->c * (1 - $this->k / 100) + $this->k, + $this->m * (1 - $this->k / 100) + $this->k, + $this->y * (1 - $this->k / 100) + $this->k, + ); + + $gray = min(100, round(0.299 * $color[0] + 0.587 * $color[1] + 0.114 * $color[2])); + + return $this->palette->color(array($gray, $gray, $gray, $this->k)); + } + + /** + * {@inheritdoc} + */ + public function isOpaque() + { + return true; + } + + /** + * Returns hex representation of the color + * + * @return string + */ + public function __toString() + { + return sprintf('cmyk(%d%%, %d%%, %d%%, %d%%)', $this->c, $this->m, $this->y, $this->k); + } + + /** + * Internal, Performs checks for color validity (an of array(C, M, Y, K)) + * + * @param array $color + * + * @throws InvalidArgumentException + */ + private function setColor(array $color) + { + if (count($color) !== 4) { + throw new InvalidArgumentException('Color argument must look like array(C, M, Y, K), where C, M, Y, K are the integer values between 0 and 255 for cyan, magenta, yellow and black color indexes accordingly'); + } + + $colors = array_values($color); + array_walk($colors, function ($color) { + return max(0, min(100, $color)); + }); + + list($this->c, $this->m, $this->y, $this->k) = $colors; + } +} diff --git a/src/Symfony/Component/Image/Image/Palette/Color/ColorInterface.php b/src/Symfony/Component/Image/Image/Palette/Color/ColorInterface.php new file mode 100644 index 0000000000000..47c128984432e --- /dev/null +++ b/src/Symfony/Component/Image/Image/Palette/Color/ColorInterface.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image\Palette\Color; + +use Symfony\Component\Image\Image\Palette\PaletteInterface; + +interface ColorInterface +{ + const COLOR_RED = 'red'; + const COLOR_GREEN = 'green'; + const COLOR_BLUE = 'blue'; + + const COLOR_CYAN = 'cyan'; + const COLOR_MAGENTA = 'magenta'; + const COLOR_YELLOW = 'yellow'; + const COLOR_KEYLINE = 'keyline'; + + const COLOR_GRAY = 'gray'; + + /** + * Return the value of one of the component. + * + * @param string $component One of the ColorInterface::COLOR_* component + * + * @return Integer + */ + public function getValue($component); + + /** + * Returns percentage of transparency of the color + * + * @return integer + */ + public function getAlpha(); + + /** + * Returns the palette attached to the current color + * + * @return PaletteInterface + */ + public function getPalette(); + + /** + * Returns a copy of current color, incrementing the alpha channel by the + * given amount + * + * @param integer $alpha + * + * @return ColorInterface + */ + public function dissolve($alpha); + + /** + * Returns a copy of the current color, lightened by the specified number + * of shades + * + * @param integer $shade + * + * @return ColorInterface + */ + public function lighten($shade); + + /** + * Returns a copy of the current color, darkened by the specified number of + * shades + * + * @param integer $shade + * + * @return ColorInterface + */ + public function darken($shade); + + /** + * Returns a gray related to the current color + * + * @return ColorInterface + */ + public function grayscale(); + + /** + * Checks if the current color is opaque + * + * @return Boolean + */ + public function isOpaque(); +} diff --git a/src/Symfony/Component/Image/Image/Palette/Color/Gray.php b/src/Symfony/Component/Image/Image/Palette/Color/Gray.php new file mode 100644 index 0000000000000..c0be1212c5d2a --- /dev/null +++ b/src/Symfony/Component/Image/Image/Palette/Color/Gray.php @@ -0,0 +1,164 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image\Palette\Color; + +use Symfony\Component\Image\Image\Palette\Grayscale; +use Symfony\Component\Image\Exception\InvalidArgumentException; + +final class Gray implements ColorInterface +{ + /** + * @var integer + */ + private $gray; + + /** + * @var integer + */ + private $alpha; + + /** + * + * @var Grayscale + */ + private $palette; + + public function __construct(Grayscale $palette, array $color, $alpha) + { + $this->palette = $palette; + $this->setColor($color); + $this->setAlpha($alpha); + } + + /** + * {@inheritdoc} + */ + public function getValue($component) + { + switch ($component) { + case ColorInterface::COLOR_GRAY: + return $this->getGray(); + default: + throw new InvalidArgumentException(sprintf('Color component %s is not valid', $component)); + } + } + + /** + * Returns Gray value of the color + * + * @return integer + */ + public function getGray() + { + return $this->gray; + } + + /** + * {@inheritdoc} + */ + public function getPalette() + { + return $this->palette; + } + + /** + * {@inheritdoc} + */ + public function getAlpha() + { + return $this->alpha; + } + + /** + * {@inheritdoc} + */ + public function dissolve($alpha) + { + return $this->palette->color( + array($this->gray), $this->alpha + $alpha + ); + } + + /** + * {@inheritdoc} + */ + public function lighten($shade) + { + return $this->palette->color(array(min(255, $this->gray + $shade)), $this->alpha); + } + + /** + * {@inheritdoc} + */ + public function darken($shade) + { + return $this->palette->color(array(max(0, $this->gray - $shade)), $this->alpha); + } + + /** + * {@inheritdoc} + */ + public function grayscale() + { + return $this; + } + + /** + * {@inheritdoc} + */ + public function isOpaque() + { + return 100 === $this->alpha; + } + + /** + * Returns hex representation of the color + * + * @return string + */ + public function __toString() + { + return sprintf('#%02x%02x%02x', $this->gray, $this->gray, $this->gray); + } + + /** + * Performs checks for validity of given alpha value and sets it + * + * @param integer $alpha + * + * @throws InvalidArgumentException + */ + private function setAlpha($alpha) + { + if (!is_int($alpha) || $alpha < 0 || $alpha > 100) { + throw new InvalidArgumentException(sprintf('Alpha must be an integer between 0 and 100, %s given', $alpha)); + } + + $this->alpha = $alpha; + } + + /** + * Performs checks for color validity (array of array(gray)) + * + * @param array $color + * + * @throws InvalidArgumentException + */ + private function setColor(array $color) + { + if (count($color) !== 1) { + throw new InvalidArgumentException('Color argument must look like array(gray), where gray is the integer value between 0 and 255 for the grayscale'); + } + + list($this->gray) = array_values($color); + } +} diff --git a/src/Symfony/Component/Image/Image/Palette/Color/RGB.php b/src/Symfony/Component/Image/Image/Palette/Color/RGB.php new file mode 100644 index 0000000000000..7cf5e16569826 --- /dev/null +++ b/src/Symfony/Component/Image/Image/Palette/Color/RGB.php @@ -0,0 +1,214 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image\Palette\Color; + +use Symfony\Component\Image\Image\Palette\RGB as RGBPalette; +use Symfony\Component\Image\Exception\InvalidArgumentException; + +final class RGB implements ColorInterface +{ + /** + * @var integer + */ + private $r; + + /** + * @var integer + */ + private $g; + + /** + * @var integer + */ + private $b; + + /** + * @var integer + */ + private $alpha; + + /** + * + * @var RGBPalette + */ + private $palette; + + public function __construct(RGBPalette $palette, array $color, $alpha) + { + $this->palette = $palette; + $this->setColor($color); + $this->setAlpha($alpha); + } + + /** + * {@inheritdoc} + */ + public function getValue($component) + { + switch ($component) { + case ColorInterface::COLOR_RED: + return $this->getRed(); + case ColorInterface::COLOR_GREEN: + return $this->getGreen(); + case ColorInterface::COLOR_BLUE: + return $this->getBlue(); + default: + throw new InvalidArgumentException(sprintf('Color component %s is not valid', $component)); + } + } + + /** + * Returns RED value of the color + * + * @return integer + */ + public function getRed() + { + return $this->r; + } + + /** + * Returns GREEN value of the color + * + * @return integer + */ + public function getGreen() + { + return $this->g; + } + + /** + * Returns BLUE value of the color + * + * @return integer + */ + public function getBlue() + { + return $this->b; + } + + /** + * {@inheritdoc} + */ + public function getPalette() + { + return $this->palette; + } + + /** + * {@inheritdoc} + */ + public function getAlpha() + { + return $this->alpha; + } + + /** + * {@inheritdoc} + */ + public function dissolve($alpha) + { + return $this->palette->color(array($this->r, $this->g, $this->b), $this->alpha + $alpha); + } + + /** + * {@inheritdoc} + */ + public function lighten($shade) + { + return $this->palette->color( + array( + min(255, $this->r + $shade), + min(255, $this->g + $shade), + min(255, $this->b + $shade), + ), $this->alpha + ); + } + + /** + * {@inheritdoc} + */ + public function darken($shade) + { + return $this->palette->color( + array( + max(0, $this->r - $shade), + max(0, $this->g - $shade), + max(0, $this->b - $shade), + ), $this->alpha + ); + } + + /** + * {@inheritdoc} + */ + public function grayscale() + { + $gray = min(255, round(0.299 * $this->getRed() + 0.114 * $this->getBlue() + 0.587 * $this->getGreen())); + + return $this->palette->color(array($gray, $gray, $gray), $this->alpha); + } + + /** + * {@inheritdoc} + */ + public function isOpaque() + { + return 100 === $this->alpha; + } + + /** + * Returns hex representation of the color + * + * @return string + */ + public function __toString() + { + return sprintf('#%02x%02x%02x', $this->r, $this->g, $this->b); + } + + /** + * Internal + * + * Performs checks for validity of given alpha value and sets it + * + * @param integer $alpha + * + * @throws InvalidArgumentException + */ + private function setAlpha($alpha) + { + if (!is_int($alpha) || $alpha < 0 || $alpha > 100) { + throw new InvalidArgumentException(sprintf('Alpha must be an integer between 0 and 100, %s given', $alpha)); + } + + $this->alpha = $alpha; + } + + /** + * Internal + * + * Performs checks for color validity (array of array(R, G, B)) + * + * @param array $color + * + * @throws InvalidArgumentException + */ + private function setColor(array $color) + { + if (count($color) !== 3) { + throw new InvalidArgumentException('Color argument must look like array(R, G, B), where R, G, B are the integer values between 0 and 255 for red, green and blue color indexes accordingly'); + } + + list($this->r, $this->g, $this->b) = array_values($color); + } +} diff --git a/src/Symfony/Component/Image/Image/Palette/ColorParser.php b/src/Symfony/Component/Image/Image/Palette/ColorParser.php new file mode 100644 index 0000000000000..e63ad588e4c97 --- /dev/null +++ b/src/Symfony/Component/Image/Image/Palette/ColorParser.php @@ -0,0 +1,153 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image\Palette; + +use Symfony\Component\Image\Exception\InvalidArgumentException; + +class ColorParser +{ + /** + * Parses a color to a RGB tuple + * + * @param string|array|integer $color + * + * @return array + * + * @throws InvalidArgumentException + */ + public function parseToRGB($color) + { + $color = $this->parse($color); + + if (4 === count($color)) { + $color = array( + 255 * (1 - $color[0] / 100) * (1 - $color[3] / 100), + 255 * (1 - $color[1] / 100) * (1 - $color[3] / 100), + 255 * (1 - $color[2] / 100) * (1 - $color[3] / 100), + ); + } + + return $color; + } + + /** + * Parses a color to a CMYK tuple + * + * @param string|array|integer $color + * + * @return array + * + * @throws InvalidArgumentException + */ + public function parseToCMYK($color) + { + $color = $this->parse($color); + + if (3 === count($color)) { + $r = $color[0] / 255; + $g = $color[1] / 255; + $b = $color[2] / 255; + + $k = 1 - max($r, $g, $b); + + $color = array( + 1 === $k ? 0 : round((1 - $r - $k) / (1- $k) * 100), + 1 === $k ? 0 : round((1 - $g - $k) / (1- $k) * 100), + 1 === $k ? 0 : round((1 - $b - $k) / (1- $k) * 100), + round($k * 100) + ); + } + + return $color; + } + + /** + * Parses a color to a grayscale value + * + * @param string|array|integer $color + * + * @return array + * + * @throws InvalidArgumentException + */ + public function parseToGrayscale($color) + { + if (is_array($color) && 1 === count($color)) { + return array_values($color); + } + + $color = array_unique($this->parse($color)); + + if (1 !== count($color)) { + throw new InvalidArgumentException('The provided color has different values of red, green and blue components. Grayscale colors must have the same values for these.'); + } + + return $color; + } + + /** + * Parses a color + * + * @param string|array|integer $color + * + * @return array + * + * @throws InvalidArgumentException + */ + private function parse($color) + { + if (!is_string($color) && !is_array($color) && !is_int($color)) { + throw new InvalidArgumentException(sprintf('Color must be specified as a hexadecimal string, array or integer, %s given', gettype($color))); + } + + if (is_array($color)) { + if (3 === count($color) || 4 === count($color)) { + return array_values($color); + } + throw new InvalidArgumentException('Color argument if array, must look like array(R, G, B), or array(C, M, Y, K) where R, G, B are the integer values between 0 and 255 for red, green and blue or cyan, magenta, yellow and black color indexes accordingly'); + } + + if (is_string($color)) { + if (0 === strpos($color, 'cmyk(')) { + $substrColor = substr($color, 5, strlen($color) - 6); + + $components = array_map(function ($component) { + return round(trim($component, ' %')); + }, explode(',', $substrColor)); + + if (count($components) !== 4) { + throw new InvalidArgumentException(sprintf('Unable to parse color %s', $color)); + } + + return $components; + } else { + $color = ltrim($color, '#'); + + if (strlen($color) !== 3 && strlen($color) !== 6) { + throw new InvalidArgumentException(sprintf('Color must be a hex value in regular (6 characters) or short (3 characters) notation, "%s" given', $color)); + } + + if (strlen($color) === 3) { + $color = $color[0] . $color[0] . $color[1] . $color[1] . $color[2] . $color[2]; + } + + $color = array_map('hexdec', str_split($color, 2)); + } + } + + if (is_int($color)) { + $color = array(255 & ($color >> 16), 255 & ($color >> 8), 255 & $color); + } + + return $color; + } +} diff --git a/src/Symfony/Component/Image/Image/Palette/Grayscale.php b/src/Symfony/Component/Image/Image/Palette/Grayscale.php new file mode 100644 index 0000000000000..01f34eaba02e0 --- /dev/null +++ b/src/Symfony/Component/Image/Image/Palette/Grayscale.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image\Palette; + +use Symfony\Component\Image\Image\Palette\Color\Gray as GrayColor; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\ProfileInterface; +use Symfony\Component\Image\Image\Profile; +use Symfony\Component\Image\Exception\RuntimeException; + +class Grayscale implements PaletteInterface +{ + /** + * @var ColorParser + */ + private $parser; + + /** + * @var ProfileInterface + */ + private $profile; + + /** + * @var array + */ + protected static $colors = array(); + + public function __construct() + { + $this->parser = new ColorParser(); + } + + /** + * {@inheritdoc} + */ + public function name() + { + return PaletteInterface::PALETTE_GRAYSCALE; + } + + /** + * {@inheritdoc} + */ + public function pixelDefinition() + { + return array(ColorInterface::COLOR_GRAY); + } + + /** + * {@inheritdoc} + */ + public function supportsAlpha() + { + return true; + } + + /** + * {@inheritdoc} + */ + public function useProfile(ProfileInterface $profile) + { + $this->profile = $profile; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function profile() + { + if (!$this->profile) { + $this->profile = Profile::fromPath(__DIR__ . '/../../Resources/colormanagement.org/ISOcoated_v2_grey1c_bas.ICC'); + } + + return $this->profile; + } + + /** + * {@inheritdoc} + */ + public function color($color, $alpha = null) + { + if (null === $alpha) { + $alpha = 0; + } + + $color = $this->parser->parseToGrayscale($color); + $index = sprintf('#%02x%02x%02x-%d', $color[0], $color[0], $color[0], $alpha); + + if (false === array_key_exists($index, static::$colors)) { + static::$colors[$index] = new GrayColor($this, $color, $alpha); + } + + return static::$colors[$index]; + } + + /** + * {@inheritdoc} + */ + public function blend(ColorInterface $color1, ColorInterface $color2, $amount) + { + if (!$color1 instanceof GrayColor || ! $color2 instanceof GrayColor) { + throw new RuntimeException('Grayscale palette can only blend Grayscale colors'); + } + + return $this->color( + array( + (int) min(255, min($color1->getGray(), $color2->getGray()) + round(abs($color2->getGray() - $color1->getGray()) * $amount)), + ), + (int) min(100, min($color1->getAlpha(), $color2->getAlpha()) + round(abs($color2->getAlpha() - $color1->getAlpha()) * $amount)) + ); + } +} diff --git a/src/Symfony/Component/Image/Image/Palette/PaletteInterface.php b/src/Symfony/Component/Image/Image/Palette/PaletteInterface.php new file mode 100644 index 0000000000000..04d8aae3f8ba1 --- /dev/null +++ b/src/Symfony/Component/Image/Image/Palette/PaletteInterface.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image\Palette; + +use Symfony\Component\Image\Image\ProfileInterface; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; + +interface PaletteInterface +{ + const PALETTE_GRAYSCALE = 'gray'; + const PALETTE_RGB = 'rgb'; + const PALETTE_CMYK = 'cmyk'; + + /** + * Returns a color given some values + * + * @param string|array|integer $color A color + * @param integer|null $alpha Set alpha to null to disable it + * + * @return ColorInterface + * + * @throws InvalidArgumentException In case you pass an alpha value to a + * Palette that does not support alpha + */ + public function color($color, $alpha = null); + + /** + * Blend two colors given an amount + * + * @param ColorInterface $color1 + * @param ColorInterface $color2 + * @param float $amount The amount of color2 in color1 + * + * @return ColorInterface + */ + public function blend(ColorInterface $color1, ColorInterface $color2, $amount); + + /** + * Attachs an ICC profile to this Palette. + * + * (A default profile is provided by default) + * + * @param ProfileInterface $profile + * + * @return PaletteInterface + */ + public function useProfile(ProfileInterface $profile); + + /** + * Returns the ICC profile attached to this Palette. + * + * @return ProfileInterface + */ + public function profile(); + + /** + * Returns the name of this Palette, one of PaletteInterface::PALETTE_* + * constants + * + * @return String + */ + public function name(); + + /** + * Returns an array containing ColorInterface::COLOR_* constants that + * define the structure of colors for a pixel. + * + * @return array + */ + public function pixelDefinition(); + + /** + * Tells if alpha channel is supported in this palette + * + * @return Boolean + */ + public function supportsAlpha(); +} diff --git a/src/Symfony/Component/Image/Image/Palette/RGB.php b/src/Symfony/Component/Image/Image/Palette/RGB.php new file mode 100644 index 0000000000000..639aaa27d74c4 --- /dev/null +++ b/src/Symfony/Component/Image/Image/Palette/RGB.php @@ -0,0 +1,129 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image\Palette; + +use Symfony\Component\Image\Image\Palette\Color\RGB as RGBColor; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\ProfileInterface; +use Symfony\Component\Image\Image\Profile; +use Symfony\Component\Image\Exception\RuntimeException; + +class RGB implements PaletteInterface +{ + /** + * @var ColorParser + */ + private $parser; + + /** + * @var ProfileInterface + */ + private $profile; + + /** + * @var array + */ + protected static $colors = array(); + + public function __construct() + { + $this->parser = new ColorParser(); + } + + /** + * {@inheritdoc} + */ + public function name() + { + return PaletteInterface::PALETTE_RGB; + } + + /** + * {@inheritdoc} + */ + public function pixelDefinition() + { + return array( + ColorInterface::COLOR_RED, + ColorInterface::COLOR_GREEN, + ColorInterface::COLOR_BLUE, + ); + } + + /** + * {@inheritdoc} + */ + public function supportsAlpha() + { + return true; + } + + /** + * {@inheritdoc} + */ + public function useProfile(ProfileInterface $profile) + { + $this->profile = $profile; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function profile() + { + if (!$this->profile) { + $this->profile = Profile::fromPath(__DIR__ . '/../../Resources/color.org/sRGB_IEC61966-2-1_black_scaled.icc'); + } + + return $this->profile; + } + + /** + * {@inheritdoc} + */ + public function color($color, $alpha = null) + { + if (null === $alpha) { + $alpha = 100; + } + + $color = $this->parser->parseToRGB($color); + $index = sprintf('#%02x%02x%02x-%d', $color[0], $color[1], $color[2], $alpha); + + if (false === array_key_exists($index, static::$colors)) { + static::$colors[$index] = new RGBColor($this, $color, $alpha); + } + + return static::$colors[$index]; + } + + /** + * {@inheritdoc} + */ + public function blend(ColorInterface $color1, ColorInterface $color2, $amount) + { + if (!$color1 instanceof RGBColor || ! $color2 instanceof RGBColor) { + throw new RuntimeException('RGB palette can only blend RGB colors'); + } + + return $this->color( + array( + (int) min(255, min($color1->getRed(), $color2->getRed()) + round(abs($color2->getRed() - $color1->getRed()) * $amount)), + (int) min(255, min($color1->getGreen(), $color2->getGreen()) + round(abs($color2->getGreen() - $color1->getGreen()) * $amount)), + (int) min(255, min($color1->getBlue(), $color2->getBlue()) + round(abs($color2->getBlue() - $color1->getBlue()) * $amount)), + ), + (int) min(100, min($color1->getAlpha(), $color2->getAlpha()) + round(abs($color2->getAlpha() - $color1->getAlpha()) * $amount)) + ); + } +} diff --git a/src/Symfony/Component/Image/Image/Point.php b/src/Symfony/Component/Image/Image/Point.php new file mode 100644 index 0000000000000..a33f3eada01f5 --- /dev/null +++ b/src/Symfony/Component/Image/Image/Point.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image; + +use Symfony\Component\Image\Exception\InvalidArgumentException; + +/** + * The point class + */ +final class Point implements PointInterface +{ + /** + * @var integer + */ + private $x; + + /** + * @var integer + */ + private $y; + + /** + * Constructs a point of coordinates + * + * @param integer $x + * @param integer $y + * + * @throws InvalidArgumentException + */ + public function __construct($x, $y) + { + if ($x < 0 || $y < 0) { + throw new InvalidArgumentException(sprintf('A coordinate cannot be positioned outside of a bounding box (x: %s, y: %s given)', $x, $y)); + } + + $this->x = $x; + $this->y = $y; + } + + /** + * {@inheritdoc} + */ + public function getX() + { + return $this->x; + } + + /** + * {@inheritdoc} + */ + public function getY() + { + return $this->y; + } + + /** + * {@inheritdoc} + */ + public function in(BoxInterface $box) + { + return $this->x < $box->getWidth() && $this->y < $box->getHeight(); + } + + /** + * {@inheritdoc} + */ + public function move($amount) + { + return new Point($this->x + $amount, $this->y + $amount); + } + + /** + * {@inheritdoc} + */ + public function __toString() + { + return sprintf('(%d, %d)', $this->x, $this->y); + } +} diff --git a/src/Symfony/Component/Image/Image/Point/Center.php b/src/Symfony/Component/Image/Image/Point/Center.php new file mode 100644 index 0000000000000..9a28b70828f71 --- /dev/null +++ b/src/Symfony/Component/Image/Image/Point/Center.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image\Point; + +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Image\Point as OriginalPoint; +use Symfony\Component\Image\Image\PointInterface; + +/** + * Point center + */ +final class Center implements PointInterface +{ + /** + * @var BoxInterface + */ + private $box; + + /** + * Constructs coordinate with size instance, it needs to be relative to + * + * @param BoxInterface $box + */ + public function __construct(BoxInterface $box) + { + $this->box = $box; + } + + /** + * {@inheritdoc} + */ + public function getX() + { + return ceil($this->box->getWidth() / 2); + } + + /** + * {@inheritdoc} + */ + public function getY() + { + return ceil($this->box->getHeight() / 2); + } + + /** + * {@inheritdoc} + */ + public function in(BoxInterface $box) + { + return $this->getX() < $box->getWidth() && $this->getY() < $box->getHeight(); + } + + /** + * {@inheritdoc} + */ + public function move($amount) + { + return new OriginalPoint($this->getX() + $amount, $this->getY() + $amount); + } + + /** + * {@inheritdoc} + */ + public function __toString() + { + return sprintf('(%d, %d)', $this->getX(), $this->getY()); + } +} diff --git a/src/Symfony/Component/Image/Image/PointInterface.php b/src/Symfony/Component/Image/Image/PointInterface.php new file mode 100644 index 0000000000000..f42217e27c19d --- /dev/null +++ b/src/Symfony/Component/Image/Image/PointInterface.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image; + +/** + * The point interface + */ +interface PointInterface +{ + /** + * Gets points x coordinate + * + * @return integer + */ + public function getX(); + + /** + * Gets points y coordinate + * + * @return integer + */ + public function getY(); + + /** + * Checks if current coordinate is inside a given box + * + * @param BoxInterface $box + * + * @return Boolean + */ + public function in(BoxInterface $box); + + /** + * Returns another point, moved by a given amount from current coordinates + * + * @param integer $amount + * @return ImageInterface + */ + public function move($amount); + + /** + * Gets a string representation for the current point + * + * @return string + */ + public function __toString(); +} diff --git a/src/Symfony/Component/Image/Image/Profile.php b/src/Symfony/Component/Image/Image/Profile.php new file mode 100644 index 0000000000000..7374e6f525d36 --- /dev/null +++ b/src/Symfony/Component/Image/Image/Profile.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image; + +use Symfony\Component\Image\Exception\InvalidArgumentException; + +class Profile implements ProfileInterface +{ + private $data; + private $name; + + public function __construct($name, $data) + { + $this->name = $name; + $this->data = $data; + } + + /** + * {@inheritdoc} + */ + public function name() + { + return $this->name; + } + + /** + * {@inheritdoc} + */ + public function data() + { + return $this->data; + } + + /** + * Creates a profile from a path to a file + * + * @param String $path + * + * @return Profile + * + * @throws InvalidArgumentException In case the provided path is not valid + */ + public static function fromPath($path) + { + if (!file_exists($path) || !is_file($path) || !is_readable($path)) { + throw new InvalidArgumentException(sprintf('Path %s is an invalid profile file or is not readable', $path)); + } + + return new static(basename($path), file_get_contents($path)); + } +} diff --git a/src/Symfony/Component/Image/Image/ProfileInterface.php b/src/Symfony/Component/Image/Image/ProfileInterface.php new file mode 100644 index 0000000000000..3e09656c75ea7 --- /dev/null +++ b/src/Symfony/Component/Image/Image/ProfileInterface.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Image; + +interface ProfileInterface +{ + /** + * Returns the name of the profile + * + * @return String + */ + public function name(); + + /** + * Returns the profile data + * + * @return String + */ + public function data(); +} diff --git a/src/Symfony/Component/Image/Imagick/Drawer.php b/src/Symfony/Component/Image/Imagick/Drawer.php new file mode 100644 index 0000000000000..f91d68f942c0e --- /dev/null +++ b/src/Symfony/Component/Image/Imagick/Drawer.php @@ -0,0 +1,404 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Imagick; + +use Symfony\Component\Image\Draw\DrawerInterface; +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Exception\RuntimeException; +use Symfony\Component\Image\Image\AbstractFont; +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\Point; +use Symfony\Component\Image\Image\PointInterface; + +/** + * Drawer implementation using the Imagick PHP extension + */ +final class Drawer implements DrawerInterface +{ + /** + * @var \Imagick + */ + private $imagick; + + /** + * @param \Imagick $imagick + */ + public function __construct(\Imagick $imagick) + { + $this->imagick = $imagick; + } + + /** + * {@inheritdoc} + */ + public function arc(PointInterface $center, BoxInterface $size, $start, $end, ColorInterface $color, $thickness = 1) + { + $x = $center->getX(); + $y = $center->getY(); + $width = $size->getWidth(); + $height = $size->getHeight(); + + try { + $pixel = $this->getColor($color); + $arc = new \ImagickDraw(); + + $arc->setStrokeColor($pixel); + $arc->setStrokeWidth(max(1, (int) $thickness)); + $arc->setFillColor('transparent'); + $arc->arc($x - $width / 2, $y - $height / 2, $x + $width / 2, $y + $height / 2, $start, $end); + + $this->imagick->drawImage($arc); + + $pixel->clear(); + $pixel->destroy(); + + $arc->clear(); + $arc->destroy(); + } catch (\ImagickException $e) { + throw new RuntimeException('Draw arc operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function chord(PointInterface $center, BoxInterface $size, $start, $end, ColorInterface $color, $fill = false, $thickness = 1) + { + $x = $center->getX(); + $y = $center->getY(); + $width = $size->getWidth(); + $height = $size->getHeight(); + + try { + $pixel = $this->getColor($color); + $chord = new \ImagickDraw(); + + $chord->setStrokeColor($pixel); + $chord->setStrokeWidth(max(1, (int) $thickness)); + + if ($fill) { + $chord->setFillColor($pixel); + } else { + $this->line( + new Point(round($x + $width / 2 * cos(deg2rad($start))), round($y + $height / 2 * sin(deg2rad($start)))), + new Point(round($x + $width / 2 * cos(deg2rad($end))), round($y + $height / 2 * sin(deg2rad($end)))), + $color + ); + + $chord->setFillColor('transparent'); + } + + $chord->arc( + $x - $width / 2, + $y - $height / 2, + $x + $width / 2, + $y + $height / 2, + $start, + $end + ); + + $this->imagick->drawImage($chord); + + $pixel->clear(); + $pixel->destroy(); + + $chord->clear(); + $chord->destroy(); + } catch (\ImagickException $e) { + throw new RuntimeException('Draw chord operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function ellipse(PointInterface $center, BoxInterface $size, ColorInterface $color, $fill = false, $thickness = 1) + { + $width = $size->getWidth(); + $height = $size->getHeight(); + + try { + $pixel = $this->getColor($color); + $ellipse = new \ImagickDraw(); + + $ellipse->setStrokeColor($pixel); + $ellipse->setStrokeWidth(max(1, (int) $thickness)); + + if ($fill) { + $ellipse->setFillColor($pixel); + } else { + $ellipse->setFillColor('transparent'); + } + + $ellipse->ellipse( + $center->getX(), + $center->getY(), + $width / 2, + $height / 2, + 0, 360 + ); + + if (false === $this->imagick->drawImage($ellipse)) { + throw new RuntimeException('Ellipse operation failed'); + } + + $pixel->clear(); + $pixel->destroy(); + + $ellipse->clear(); + $ellipse->destroy(); + } catch (\ImagickException $e) { + throw new RuntimeException('Draw ellipse operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function line(PointInterface $start, PointInterface $end, ColorInterface $color, $thickness = 1) + { + try { + $pixel = $this->getColor($color); + $line = new \ImagickDraw(); + + $line->setStrokeColor($pixel); + $line->setStrokeWidth(max(1, (int) $thickness)); + $line->setFillColor($pixel); + $line->line( + $start->getX(), + $start->getY(), + $end->getX(), + $end->getY() + ); + + $this->imagick->drawImage($line); + + $pixel->clear(); + $pixel->destroy(); + + $line->clear(); + $line->destroy(); + } catch (\ImagickException $e) { + throw new RuntimeException('Draw line operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function pieSlice(PointInterface $center, BoxInterface $size, $start, $end, ColorInterface $color, $fill = false, $thickness = 1) + { + $width = $size->getWidth(); + $height = $size->getHeight(); + + $x1 = round($center->getX() + $width / 2 * cos(deg2rad($start))); + $y1 = round($center->getY() + $height / 2 * sin(deg2rad($start))); + $x2 = round($center->getX() + $width / 2 * cos(deg2rad($end))); + $y2 = round($center->getY() + $height / 2 * sin(deg2rad($end))); + + if ($fill) { + $this->chord($center, $size, $start, $end, $color, true, $thickness); + $this->polygon( + array( + $center, + new Point($x1, $y1), + new Point($x2, $y2), + ), + $color, + true, + $thickness + ); + } else { + $this->arc($center, $size, $start, $end, $color, $thickness); + $this->line($center, new Point($x1, $y1), $color, $thickness); + $this->line($center, new Point($x2, $y2), $color, $thickness); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function dot(PointInterface $position, ColorInterface $color) + { + $x = $position->getX(); + $y = $position->getY(); + + try { + $pixel = $this->getColor($color); + $point = new \ImagickDraw(); + + $point->setFillColor($pixel); + $point->point($x, $y); + + $this->imagick->drawimage($point); + + $pixel->clear(); + $pixel->destroy(); + + $point->clear(); + $point->destroy(); + } catch (\ImagickException $e) { + throw new RuntimeException('Draw point operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function polygon(array $coordinates, ColorInterface $color, $fill = false, $thickness = 1) + { + if (count($coordinates) < 3) { + throw new InvalidArgumentException(sprintf('Polygon must consist of at least 3 coordinates, %d given', count($coordinates))); + } + + $points = array_map(function (PointInterface $p) { + return array('x' => $p->getX(), 'y' => $p->getY()); + }, $coordinates); + + try { + $pixel = $this->getColor($color); + $polygon = new \ImagickDraw(); + + $polygon->setStrokeColor($pixel); + $polygon->setStrokeWidth(max(1, (int) $thickness)); + + if ($fill) { + $polygon->setFillColor($pixel); + } else { + $polygon->setFillColor('transparent'); + } + + $polygon->polygon($points); + $this->imagick->drawImage($polygon); + + $pixel->clear(); + $pixel->destroy(); + + $polygon->clear(); + $polygon->destroy(); + } catch (\ImagickException $e) { + throw new RuntimeException('Draw polygon operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function text($string, AbstractFont $font, PointInterface $position, $angle = 0, $width = null) + { + try { + $pixel = $this->getColor($font->getColor()); + $text = new \ImagickDraw(); + + $text->setFont($font->getFile()); + /** + * @see http://www.php.net/manual/en/imagick.queryfontmetrics.php#101027 + * + * ensure font resolution is the same as GD's hard-coded 96 + */ + if (version_compare(phpversion("imagick"), "3.0.2", ">=")) { + $text->setResolution(96, 96); + $text->setFontSize($font->getSize()); + } else { + $text->setFontSize((int) ($font->getSize() * (96 / 72))); + } + $text->setFillColor($pixel); + $text->setTextAntialias(true); + + $info = $this->imagick->queryFontMetrics($text, $string); + $rad = deg2rad($angle); + $cos = cos($rad); + $sin = sin($rad); + + // round(0 * $cos - 0 * $sin) + $x1 = 0; + $x2 = round($info['characterWidth'] * $cos - $info['characterHeight'] * $sin); + // round(0 * $sin + 0 * $cos) + $y1 = 0; + $y2 = round($info['characterWidth'] * $sin + $info['characterHeight'] * $cos); + + $xdiff = 0 - min($x1, $x2); + $ydiff = 0 - min($y1, $y2); + + if ($width !== null) { + $string = $this->wrapText($string, $text, $angle, $width); + } + + $this->imagick->annotateImage( + $text, $position->getX() + $x1 + $xdiff, + $position->getY() + $y2 + $ydiff, $angle, $string + ); + + $pixel->clear(); + $pixel->destroy(); + + $text->clear(); + $text->destroy(); + } catch (\ImagickException $e) { + throw new RuntimeException('Draw text operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * Gets specifically formatted color string from ColorInterface instance + * + * @param ColorInterface $color + * + * @return string + */ + private function getColor(ColorInterface $color) + { + $pixel = new \ImagickPixel((string) $color); + $pixel->setColorValue(\Imagick::COLOR_ALPHA, $color->getAlpha() / 100); + + return $pixel; + } + + /** + * Internal + * + * Fits a string into box with given width + */ + private function wrapText($string, $text, $angle, $width) + { + $result = ''; + $words = explode(' ', $string); + foreach ($words as $word) { + $teststring = $result . ' ' . $word; + $testbox = $this->imagick->queryFontMetrics($text, $teststring, true); + if ($testbox['textWidth'] > $width) { + $result .= ($result == '' ? '' : "\n") . $word; + } else { + $result .= ($result == '' ? '' : ' ') . $word; + } + } + + return $result; + } +} diff --git a/src/Symfony/Component/Image/Imagick/Effects.php b/src/Symfony/Component/Image/Imagick/Effects.php new file mode 100644 index 0000000000000..0c484b713a45f --- /dev/null +++ b/src/Symfony/Component/Image/Imagick/Effects.php @@ -0,0 +1,119 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Imagick; + +use Symfony\Component\Image\Effects\EffectsInterface; +use Symfony\Component\Image\Exception\NotSupportedException; +use Symfony\Component\Image\Exception\RuntimeException; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\Palette\Color\RGB; + +/** + * Effects implementation using the Imagick PHP extension + */ +class Effects implements EffectsInterface +{ + private $imagick; + + public function __construct(\Imagick $imagick) + { + $this->imagick = $imagick; + } + + /** + * {@inheritdoc} + */ + public function gamma($correction) + { + try { + $this->imagick->gammaImage($correction, \Imagick::CHANNEL_ALL); + } catch (\ImagickException $e) { + throw new RuntimeException('Failed to apply gamma correction to the image', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function negative() + { + try { + $this->imagick->negateImage(false, \Imagick::CHANNEL_ALL); + } catch (\ImagickException $e) { + throw new RuntimeException('Failed to negate the image', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function grayscale() + { + try { + $this->imagick->setImageType(\Imagick::IMGTYPE_GRAYSCALE); + } catch (\ImagickException $e) { + throw new RuntimeException('Failed to grayscale the image', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function colorize(ColorInterface $color) + { + if (!$color instanceof RGB) { + throw new NotSupportedException('Colorize with non-rgb color is not supported'); + } + + try { + $this->imagick->colorizeImage((string) $color, new \ImagickPixel(sprintf('rgba(%d, %d, %d, 1)', $color->getRed(), $color->getGreen(), $color->getBlue()))); + } catch (\ImagickException $e) { + throw new RuntimeException('Failed to colorize the image', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function sharpen() + { + try { + $this->imagick->sharpenImage(2, 1); + } catch (\ImagickException $e) { + throw new RuntimeException('Failed to sharpen the image', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function blur($sigma = 1) + { + try { + $this->imagick->gaussianBlurImage(0, $sigma); + } catch (\ImagickException $e) { + throw new RuntimeException('Failed to blur the image', $e->getCode(), $e); + } + + return $this; + } +} diff --git a/src/Symfony/Component/Image/Imagick/Font.php b/src/Symfony/Component/Image/Imagick/Font.php new file mode 100644 index 0000000000000..d6093cbd9b5cf --- /dev/null +++ b/src/Symfony/Component/Image/Imagick/Font.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Imagick; + +use Symfony\Component\Image\Image\AbstractFont; +use Symfony\Component\Image\Image\Box; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; + +/** + * Font implementation using the Imagick PHP extension + */ +final class Font extends AbstractFont +{ + /** + * @var \Imagick + */ + private $imagick; + + /** + * @param \Imagick $imagick + * @param string $file + * @param integer $size + * @param ColorInterface $color + */ + public function __construct(\Imagick $imagick, $file, $size, ColorInterface $color) + { + $this->imagick = $imagick; + + parent::__construct($file, $size, $color); + } + + /** + * {@inheritdoc} + */ + public function box($string, $angle = 0) + { + $text = new \ImagickDraw(); + + $text->setFont($this->file); + + /** + * @see http://www.php.net/manual/en/imagick.queryfontmetrics.php#101027 + * + * ensure font resolution is the same as GD's hard-coded 96 + */ + if (version_compare(phpversion("imagick"), "3.0.2", ">=")) { + $text->setResolution(96, 96); + $text->setFontSize($this->size); + } else { + $text->setFontSize((int) ($this->size * (96 / 72))); + } + + $info = $this->imagick->queryFontMetrics($text, $string); + + $box = new Box($info['textWidth'], $info['textHeight']); + + return $box; + } +} diff --git a/src/Symfony/Component/Image/Imagick/Image.php b/src/Symfony/Component/Image/Imagick/Image.php new file mode 100644 index 0000000000000..afb17ca4ebdec --- /dev/null +++ b/src/Symfony/Component/Image/Imagick/Image.php @@ -0,0 +1,900 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Imagick; + +use Symfony\Component\Image\Exception\OutOfBoundsException; +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Exception\RuntimeException; +use Symfony\Component\Image\Image\AbstractImage; +use Symfony\Component\Image\Image\Box; +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Image\Metadata\MetadataBag; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\Fill\FillInterface; +use Symfony\Component\Image\Image\Fill\Gradient\Horizontal; +use Symfony\Component\Image\Image\Fill\Gradient\Linear; +use Symfony\Component\Image\Image\Point; +use Symfony\Component\Image\Image\PointInterface; +use Symfony\Component\Image\Image\ProfileInterface; +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\Palette\PaletteInterface; + +/** + * Image implementation using the Imagick PHP extension + */ +final class Image extends AbstractImage +{ + /** + * @var \Imagick + */ + private $imagick; + /** + * @var Layers + */ + private $layers; + /** + * @var PaletteInterface + */ + private $palette; + + /** + * @var Boolean + */ + private static $supportsColorspaceConversion; + + private static $colorspaceMapping = array( + PaletteInterface::PALETTE_CMYK => \Imagick::COLORSPACE_CMYK, + PaletteInterface::PALETTE_RGB => \Imagick::COLORSPACE_RGB, + PaletteInterface::PALETTE_GRAYSCALE => \Imagick::COLORSPACE_GRAY, + ); + + /** + * Constructs a new Image instance + * + * @param \Imagick $imagick + * @param PaletteInterface $palette + * @param MetadataBag $metadata + */ + public function __construct(\Imagick $imagick, PaletteInterface $palette, MetadataBag $metadata) + { + $this->metadata = $metadata; + $this->detectColorspaceConversionSupport(); + $this->imagick = $imagick; + if (static::$supportsColorspaceConversion) { + $this->setColorspace($palette); + } + $this->palette = $palette; + $this->layers = new Layers($this, $this->palette, $this->imagick); + } + + /** + * Destroys allocated imagick resources + */ + public function __destruct() + { + if ($this->imagick instanceof \Imagick) { + $this->imagick->clear(); + $this->imagick->destroy(); + } + } + + /** + * Returns the underlying \Imagick instance + * + * @return \Imagick + */ + public function getImagick() + { + return $this->imagick; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function copy() + { + try { + if (version_compare(phpversion("imagick"), "3.1.0b1", ">=") || defined("HHVM_VERSION")) { + $clone = clone $this->imagick; + } else { + $clone = $this->imagick->clone(); + } + } catch (\ImagickException $e) { + throw new RuntimeException('Copy operation failed', $e->getCode(), $e); + } + + return new self($clone, $this->palette, clone $this->metadata); + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function crop(PointInterface $start, BoxInterface $size) + { + if (!$start->in($this->getSize())) { + throw new OutOfBoundsException('Crop coordinates must start at minimum 0, 0 position from top left corner, crop height and width must be positive integers and must not exceed the current image borders'); + } + try { + if ($this->layers()->count() > 1) { + // Crop each layer separately + $this->imagick = $this->imagick->coalesceImages(); + foreach ($this->imagick as $frame) { + $frame->cropImage($size->getWidth(), $size->getHeight(), $start->getX(), $start->getY()); + // Reset canvas for gif format + $frame->setImagePage(0, 0, 0, 0); + } + $this->imagick = $this->imagick->deconstructImages(); + } else { + $this->imagick->cropImage($size->getWidth(), $size->getHeight(), $start->getX(), $start->getY()); + // Reset canvas for gif format + $this->imagick->setImagePage(0, 0, 0, 0); + } + } catch (\ImagickException $e) { + throw new RuntimeException('Crop operation failed', $e->getCode(), $e); + } + return $this; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function flipHorizontally() + { + try { + $this->imagick->flopImage(); + } catch (\ImagickException $e) { + throw new RuntimeException('Horizontal Flip operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function flipVertically() + { + try { + $this->imagick->flipImage(); + } catch (\ImagickException $e) { + throw new RuntimeException('Vertical flip operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function strip() + { + try { + try { + $this->profile($this->palette->profile()); + } catch (\Exception $e) { + // here we discard setting the profile as the previous incorporated profile + // is corrupted, let's now strip the image + } + $this->imagick->stripImage(); + } catch (\ImagickException $e) { + throw new RuntimeException('Strip operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function paste(ImageInterface $image, PointInterface $start) + { + if (!$image instanceof self) { + throw new InvalidArgumentException(sprintf('Imagick\Image can only paste() Imagick\Image instances, %s given', get_class($image))); + } + + if (!$this->getSize()->contains($image->getSize(), $start)) { + throw new OutOfBoundsException('Cannot paste image of the given size at the specified position, as it moves outside of the current image\'s box'); + } + + try { + $this->imagick->compositeImage($image->imagick, \Imagick::COMPOSITE_DEFAULT, $start->getX(), $start->getY()); + } catch (\ImagickException $e) { + throw new RuntimeException('Paste operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function resize(BoxInterface $size, $filter = ImageInterface::FILTER_UNDEFINED) + { + try { + if ($this->layers->count() > 1) { + $this->imagick = $this->imagick->coalesceImages(); + foreach ($this->imagick as $frame) { + $frame->resizeImage($size->getWidth(), $size->getHeight(), $this->getFilter($filter), 1); + } + $this->imagick = $this->imagick->deconstructImages(); + } else { + $this->imagick->resizeImage($size->getWidth(), $size->getHeight(), $this->getFilter($filter), 1); + } + } catch (\ImagickException $e) { + throw new RuntimeException('Resize operation failed', $e->getCode(), $e); + } + return $this; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function rotate($angle, ColorInterface $background = null) + { + $color = $background ? $background : $this->palette->color('fff'); + + try { + $pixel = $this->getColor($color); + + $this->imagick->rotateimage($pixel, $angle); + + $pixel->clear(); + $pixel->destroy(); + } catch (\ImagickException $e) { + throw new RuntimeException('Rotate operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function save($path = null, array $options = array()) + { + $path = null === $path ? $this->imagick->getImageFilename() : $path; + if (null === $path) { + throw new RuntimeException('You can omit save path only if image has been open from a file'); + } + + try { + $this->prepareOutput($options, $path); + $this->imagick->writeImages($path, true); + } catch (\ImagickException $e) { + throw new RuntimeException('Save operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function show($format, array $options = array()) + { + header('Content-type: '.$this->getMimeType($format)); + echo $this->get($format, $options); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function get($format, array $options = array()) + { + try { + $options['format'] = $format; + $this->prepareOutput($options); + } catch (\ImagickException $e) { + throw new RuntimeException('Get operation failed', $e->getCode(), $e); + } + + return $this->imagick->getImagesBlob(); + } + + /** + * {@inheritdoc} + */ + public function interlace($scheme) + { + static $supportedInterlaceSchemes = array( + ImageInterface::INTERLACE_NONE => \Imagick::INTERLACE_NO, + ImageInterface::INTERLACE_LINE => \Imagick::INTERLACE_LINE, + ImageInterface::INTERLACE_PLANE => \Imagick::INTERLACE_PLANE, + ImageInterface::INTERLACE_PARTITION => \Imagick::INTERLACE_PARTITION, + ); + + if (!array_key_exists($scheme, $supportedInterlaceSchemes)) { + throw new InvalidArgumentException('Unsupported interlace type'); + } + + $this->imagick->setInterlaceScheme($supportedInterlaceSchemes[$scheme]); + + return $this; + } + + /** + * @param array $options + * @param string $path + */ + private function prepareOutput(array $options, $path = null) + { + if (isset($options['format'])) { + $this->imagick->setImageFormat($options['format']); + } + + if (isset($options['animated']) && true === $options['animated']) { + $format = isset($options['format']) ? $options['format'] : 'gif'; + $delay = isset($options['animated.delay']) ? $options['animated.delay'] : null; + $loops = isset($options['animated.loops']) ? $options['animated.loops'] : 0; + + $options['flatten'] = false; + + $this->layers->animate($format, $delay, $loops); + } else { + $this->layers->merge(); + } + $this->applyImageOptions($this->imagick, $options, $path); + + // flatten only if image has multiple layers + if ((!isset($options['flatten']) || $options['flatten'] === true) && count($this->layers) > 1) { + $this->flatten(); + } + } + + /** + * {@inheritdoc} + */ + public function __toString() + { + return $this->get('png'); + } + + /** + * {@inheritdoc} + */ + public function draw() + { + return new Drawer($this->imagick); + } + + /** + * {@inheritdoc} + */ + public function effects() + { + return new Effects($this->imagick); + } + + /** + * {@inheritdoc} + */ + public function getSize() + { + try { + $i = $this->imagick->getIteratorIndex(); + $this->imagick->rewind(); + $width = $this->imagick->getImageWidth(); + $height = $this->imagick->getImageHeight(); + $this->imagick->setIteratorIndex($i); + } catch (\ImagickException $e) { + throw new RuntimeException('Could not get size', $e->getCode(), $e); + } + + return new Box($width, $height); + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function applyMask(ImageInterface $mask) + { + if (!$mask instanceof self) { + throw new InvalidArgumentException('Can only apply instances of Symfony\Component\Image\Imagick\Image as masks'); + } + + $size = $this->getSize(); + $maskSize = $mask->getSize(); + + if ($size != $maskSize) { + throw new InvalidArgumentException(sprintf('The given mask doesn\'t match current image\'s size, Current mask\'s dimensions are %s, while image\'s dimensions are %s', $maskSize, $size)); + } + + $mask = $mask->mask(); + $mask->imagick->negateImage(true); + + try { + // remove transparent areas of the original from the mask + $mask->imagick->compositeImage($this->imagick, \Imagick::COMPOSITE_DSTIN, 0, 0); + $this->imagick->compositeImage($mask->imagick, \Imagick::COMPOSITE_COPYOPACITY, 0, 0); + + $mask->imagick->clear(); + $mask->imagick->destroy(); + } catch (\ImagickException $e) { + throw new RuntimeException('Apply mask operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function mask() + { + $mask = $this->copy(); + + try { + $mask->imagick->modulateImage(100, 0, 100); + $mask->imagick->setImageMatte(false); + } catch (\ImagickException $e) { + throw new RuntimeException('Mask operation failed', $e->getCode(), $e); + } + + return $mask; + } + + /** + * {@inheritdoc} + * + * @return ImageInterface + */ + public function fill(FillInterface $fill) + { + try { + if ($this->isLinearOpaque($fill)) { + $this->applyFastLinear($fill); + } else { + $iterator = $this->imagick->getPixelIterator(); + + foreach ($iterator as $y => $pixels) { + foreach ($pixels as $x => $pixel) { + $color = $fill->getColor(new Point($x, $y)); + + $pixel->setColor((string) $color); + $pixel->setColorValue(\Imagick::COLOR_ALPHA, $color->getAlpha() / 100); + } + + $iterator->syncIterator(); + } + } + } catch (\ImagickException $e) { + throw new RuntimeException('Fill operation failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function histogram() + { + try { + $pixels = $this->imagick->getImageHistogram(); + } catch (\ImagickException $e) { + throw new RuntimeException('Error while fetching histogram', $e->getCode(), $e); + } + + $image = $this; + + return array_map(function (\ImagickPixel $pixel) use ($image) { + return $image->pixelToColor($pixel); + },$pixels); + } + + /** + * {@inheritdoc} + */ + public function getColorAt(PointInterface $point) + { + if (!$point->in($this->getSize())) { + throw new RuntimeException(sprintf('Error getting color at point [%s,%s]. The point must be inside the image of size [%s,%s]', $point->getX(), $point->getY(), $this->getSize()->getWidth(), $this->getSize()->getHeight())); + } + + try { + $pixel = $this->imagick->getImagePixelColor($point->getX(), $point->getY()); + } catch (\ImagickException $e) { + throw new RuntimeException('Error while getting image pixel color', $e->getCode(), $e); + } + + return $this->pixelToColor($pixel); + } + + /** + * Returns a color given a pixel, depending the Palette context + * + * Note : this method is public for PHP 5.3 compatibility + * + * @param \ImagickPixel $pixel + * + * @return ColorInterface + * + * @throws InvalidArgumentException In case a unknown color is requested + */ + public function pixelToColor(\ImagickPixel $pixel) + { + static $colorMapping = array( + ColorInterface::COLOR_RED => \Imagick::COLOR_RED, + ColorInterface::COLOR_GREEN => \Imagick::COLOR_GREEN, + ColorInterface::COLOR_BLUE => \Imagick::COLOR_BLUE, + ColorInterface::COLOR_CYAN => \Imagick::COLOR_CYAN, + ColorInterface::COLOR_MAGENTA => \Imagick::COLOR_MAGENTA, + ColorInterface::COLOR_YELLOW => \Imagick::COLOR_YELLOW, + ColorInterface::COLOR_KEYLINE => \Imagick::COLOR_BLACK, + // There is no gray component in \Imagick, let's use one of the RGB comp + ColorInterface::COLOR_GRAY => \Imagick::COLOR_RED, + ); + + $alpha = $this->palette->supportsAlpha() ? (int) round($pixel->getColorValue(\Imagick::COLOR_ALPHA) * 100) : null; + $palette = $this->palette(); + + return $this->palette->color(array_map(function ($color) use ($palette, $pixel, $colorMapping) { + if (!isset($colorMapping[$color])) { + throw new InvalidArgumentException(sprintf('Color %s is not mapped in Imagick', $color)); + } + $multiplier = 255; + if ($palette->name() === PaletteInterface::PALETTE_CMYK) { + $multiplier = 100; + } + + return $pixel->getColorValue($colorMapping[$color]) * $multiplier; + }, $this->palette->pixelDefinition()), $alpha); + } + + /** + * {@inheritdoc} + */ + public function layers() + { + return $this->layers; + } + + /** + * {@inheritdoc} + */ + public function usePalette(PaletteInterface $palette) + { + if (!isset(static::$colorspaceMapping[$palette->name()])) { + throw new InvalidArgumentException(sprintf('The palette %s is not supported by Imagick driver', $palette->name())); + } + + if ($this->palette->name() === $palette->name()) { + return $this; + } + + if (!static::$supportsColorspaceConversion) { + throw new RuntimeException('Your version of Imagick does not support colorspace conversions.'); + } + + try { + try { + $hasICCProfile = (Boolean) $this->imagick->getImageProfile('icc'); + } catch (\ImagickException $e) { + $hasICCProfile = false; + } + + if (!$hasICCProfile) { + $this->profile($this->palette->profile()); + } + + $this->profile($palette->profile()); + $this->setColorspace($palette); + } catch (\ImagickException $e) { + throw new RuntimeException('Failed to set colorspace', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function palette() + { + return $this->palette; + } + + /** + * {@inheritdoc} + */ + public function profile(ProfileInterface $profile) + { + try { + $this->imagick->profileImage('icc', $profile->data()); + } catch (\ImagickException $e) { + throw new RuntimeException(sprintf('Unable to add profile %s to image', $profile->name()), $e->getCode(), $e); + } + + return $this; + } + + /** + * Internal + * + * Flatten the image. + */ + private function flatten() + { + /** + * @see https://github.com/mkoppanen/imagick/issues/45 + */ + try { + if (method_exists($this->imagick, 'mergeImageLayers') && defined('Imagick::LAYERMETHOD_UNDEFINED')) { + $this->imagick = $this->imagick->mergeImageLayers(\Imagick::LAYERMETHOD_UNDEFINED); + } elseif (method_exists($this->imagick, 'flattenImages')) { + $this->imagick = $this->imagick->flattenImages(); + } + } catch (\ImagickException $e) { + throw new RuntimeException('Flatten operation failed', $e->getCode(), $e); + } + } + + /** + * Internal + * + * Applies options before save or output + * + * @param \Imagick $image + * @param array $options + * @param string $path + * + * @throws InvalidArgumentException + * @throws RuntimeException + */ + private function applyImageOptions(\Imagick $image, array $options, $path) + { + if (isset($options['format'])) { + $format = $options['format']; + } elseif ('' !== $extension = pathinfo($path, \PATHINFO_EXTENSION)) { + $format = $extension; + } else { + $format = pathinfo($image->getImageFilename(), \PATHINFO_EXTENSION); + } + + $format = strtolower($format); + + $options = $this->updateSaveOptions($options); + + if (isset($options['jpeg_quality']) && in_array($format, array('jpeg', 'jpg', 'pjpeg'))) { + $image->setImageCompressionQuality($options['jpeg_quality']); + } + + if ((isset($options['png_compression_level']) || isset($options['png_compression_filter'])) && $format === 'png') { + // first digit: compression level (default: 7) + if (isset($options['png_compression_level'])) { + if ($options['png_compression_level'] < 0 || $options['png_compression_level'] > 9) { + throw new InvalidArgumentException('png_compression_level option should be an integer from 0 to 9'); + } + $compression = $options['png_compression_level'] * 10; + } else { + $compression = 70; + } + + // second digit: compression filter (default: 5) + if (isset($options['png_compression_filter'])) { + if ($options['png_compression_filter'] < 0 || $options['png_compression_filter'] > 9) { + throw new InvalidArgumentException('png_compression_filter option should be an integer from 0 to 9'); + } + $compression += $options['png_compression_filter']; + } else { + $compression += 5; + } + + $image->setImageCompressionQuality($compression); + } + + if (isset($options['resolution-units']) && isset($options['resolution-x']) && isset($options['resolution-y'])) { + if ($options['resolution-units'] == ImageInterface::RESOLUTION_PIXELSPERCENTIMETER) { + $image->setImageUnits(\Imagick::RESOLUTION_PIXELSPERCENTIMETER); + } elseif ($options['resolution-units'] == ImageInterface::RESOLUTION_PIXELSPERINCH) { + $image->setImageUnits(\Imagick::RESOLUTION_PIXELSPERINCH); + } else { + throw new RuntimeException('Unsupported image unit format'); + } + + $filter = ImageInterface::FILTER_UNDEFINED; + if (!empty($options['resampling-filter'])) { + $filter = $options['resampling-filter']; + } + + $image->setImageResolution($options['resolution-x'], $options['resolution-y']); + $image->resampleImage($options['resolution-x'], $options['resolution-y'], $this->getFilter($filter), 0); + } + } + + /** + * Gets specifically formatted color string from Color instance + * + * @param ColorInterface $color + * + * @return \ImagickPixel + */ + private function getColor(ColorInterface $color) + { + $pixel = new \ImagickPixel((string) $color); + $pixel->setColorValue(\Imagick::COLOR_ALPHA, $color->getAlpha() / 100); + + return $pixel; + } + + /** + * Checks whether given $fill is linear and opaque + * + * @param FillInterface $fill + * + * @return Boolean + */ + private function isLinearOpaque(FillInterface $fill) + { + return $fill instanceof Linear && $fill->getStart()->isOpaque() && $fill->getEnd()->isOpaque(); + } + + /** + * Performs optimized gradient fill for non-opaque linear gradients + * + * @param Linear $fill + */ + private function applyFastLinear(Linear $fill) + { + $gradient = new \Imagick(); + $size = $this->getSize(); + $color = sprintf('gradient:%s-%s', (string) $fill->getStart(), (string) $fill->getEnd()); + + if ($fill instanceof Horizontal) { + $gradient->newPseudoImage($size->getHeight(), $size->getWidth(), $color); + $gradient->rotateImage(new \ImagickPixel(), 90); + } else { + $gradient->newPseudoImage($size->getWidth(), $size->getHeight(), $color); + } + + $this->imagick->compositeImage($gradient, \Imagick::COMPOSITE_OVER, 0, 0); + $gradient->clear(); + $gradient->destroy(); + } + + /** + * Internal + * + * Get the mime type based on format. + * + * @param string $format + * + * @return string mime-type + * + * @throws RuntimeException + */ + private function getMimeType($format) + { + static $mimeTypes = array( + 'jpeg' => 'image/jpeg', + 'jpg' => 'image/jpeg', + 'gif' => 'image/gif', + 'png' => 'image/png', + 'wbmp' => 'image/vnd.wap.wbmp', + 'xbm' => 'image/xbm', + ); + + if (!isset($mimeTypes[$format])) { + throw new RuntimeException(sprintf('Unsupported format given. Only %s are supported, %s given', implode(", ", array_keys($mimeTypes)), $format)); + } + + return $mimeTypes[$format]; + } + + /** + * Sets colorspace and image type, assigns the palette. + * + * @param PaletteInterface $palette + * + * @throws InvalidArgumentException + */ + private function setColorspace(PaletteInterface $palette) + { + $typeMapping = array( + // We use Matte variants to preserve alpha + // + // (the constants \Imagick::IMGTYPE_TRUECOLORMATTE and \Imagick::IMGTYPE_GRAYSCALEMATTE do not exist anymore in Imagick 7, + // to fix this the former values are hard coded here, the documentation under http://php.net/manual/en/imagick.settype.php + // doesn't tell us which constants to use and the alternative constants listed under + // https://pecl.php.net/package/imagick/3.4.3RC1 do not exist either, so we found no other way to fix it as to hard code + // the values here) + PaletteInterface::PALETTE_CMYK => defined('\Imagick::IMGTYPE_TRUECOLORMATTE') ? \Imagick::IMGTYPE_TRUECOLORMATTE : 7, + PaletteInterface::PALETTE_RGB => defined('\Imagick::IMGTYPE_TRUECOLORMATTE') ? \Imagick::IMGTYPE_TRUECOLORMATTE : 7, + PaletteInterface::PALETTE_GRAYSCALE => defined('\Imagick::IMGTYPE_GRAYSCALEMATTE') ? \Imagick::IMGTYPE_GRAYSCALEMATTE : 3, + ); + + if (!isset(static::$colorspaceMapping[$palette->name()])) { + throw new InvalidArgumentException(sprintf('The palette %s is not supported by Imagick driver', $palette->name())); + } + + $this->imagick->setType($typeMapping[$palette->name()]); + $this->imagick->setColorspace(static::$colorspaceMapping[$palette->name()]); + $this->palette = $palette; + } + + /** + * Older imagemagick versions does not support colorspace conversions. + * Let's detect if it is supported. + * + * @return Boolean + */ + private function detectColorspaceConversionSupport() + { + if (null !== static::$supportsColorspaceConversion) { + return static::$supportsColorspaceConversion; + } + + return static::$supportsColorspaceConversion = method_exists('Imagick', 'setColorspace'); + } + + /** + * Returns the filter if it's supported. + * + * @param string $filter + * + * @return string + * + * @throws InvalidArgumentException If the filter is unsupported. + */ + private function getFilter($filter = ImageInterface::FILTER_UNDEFINED) + { + static $supportedFilters = array( + ImageInterface::FILTER_UNDEFINED => \Imagick::FILTER_UNDEFINED, + ImageInterface::FILTER_BESSEL => \Imagick::FILTER_BESSEL, + ImageInterface::FILTER_BLACKMAN => \Imagick::FILTER_BLACKMAN, + ImageInterface::FILTER_BOX => \Imagick::FILTER_BOX, + ImageInterface::FILTER_CATROM => \Imagick::FILTER_CATROM, + ImageInterface::FILTER_CUBIC => \Imagick::FILTER_CUBIC, + ImageInterface::FILTER_GAUSSIAN => \Imagick::FILTER_GAUSSIAN, + ImageInterface::FILTER_HANNING => \Imagick::FILTER_HANNING, + ImageInterface::FILTER_HAMMING => \Imagick::FILTER_HAMMING, + ImageInterface::FILTER_HERMITE => \Imagick::FILTER_HERMITE, + ImageInterface::FILTER_LANCZOS => \Imagick::FILTER_LANCZOS, + ImageInterface::FILTER_MITCHELL => \Imagick::FILTER_MITCHELL, + ImageInterface::FILTER_POINT => \Imagick::FILTER_POINT, + ImageInterface::FILTER_QUADRATIC => \Imagick::FILTER_QUADRATIC, + ImageInterface::FILTER_SINC => \Imagick::FILTER_SINC, + ImageInterface::FILTER_TRIANGLE => \Imagick::FILTER_TRIANGLE + ); + + if (!array_key_exists($filter, $supportedFilters)) { + throw new InvalidArgumentException(sprintf( + 'The resampling filter "%s" is not supported by Imagick driver.', + $filter + )); + } + + return $supportedFilters[$filter]; + } +} diff --git a/src/Symfony/Component/Image/Imagick/Layers.php b/src/Symfony/Component/Image/Imagick/Layers.php new file mode 100644 index 0000000000000..860ee8f6f5331 --- /dev/null +++ b/src/Symfony/Component/Image/Imagick/Layers.php @@ -0,0 +1,271 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Imagick; + +use Symfony\Component\Image\Image\AbstractLayers; +use Symfony\Component\Image\Image\Metadata\MetadataBag; +use Symfony\Component\Image\Exception\RuntimeException; +use Symfony\Component\Image\Exception\OutOfBoundsException; +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Image\Palette\PaletteInterface; + +class Layers extends AbstractLayers +{ + /** + * @var Image + */ + private $image; + /** + * @var \Imagick + */ + private $resource; + /** + * @var integer + */ + private $offset = 0; + /** + * @var array + */ + private $layers = array(); + + private $palette; + + public function __construct(Image $image, PaletteInterface $palette, \Imagick $resource) + { + $this->image = $image; + $this->resource = $resource; + $this->palette = $palette; + } + + /** + * {@inheritdoc} + */ + public function merge() + { + foreach ($this->layers as $offset => $image) { + try { + $this->resource->setIteratorIndex($offset); + $this->resource->setImage($image->getImagick()); + } catch (\ImagickException $e) { + throw new RuntimeException('Failed to substitute layer', $e->getCode(), $e); + } + } + } + + /** + * {@inheritdoc} + */ + public function animate($format, $delay, $loops) + { + if ('gif' !== strtolower($format)) { + throw new InvalidArgumentException('Animated picture is currently only supported on gif'); + } + + if (!is_int($loops) || $loops < 0) { + throw new InvalidArgumentException('Loops must be a positive integer.'); + } + + if (null !== $delay && (!is_int($delay) || $delay < 0)) { + throw new InvalidArgumentException('Delay must be either null or a positive integer.'); + } + + try { + foreach ($this as $offset => $layer) { + $this->resource->setIteratorIndex($offset); + $this->resource->setFormat($format); + + if (null !== $delay) { + $layer->getImagick()->setImageDelay($delay / 10); + $layer->getImagick()->setImageTicksPerSecond(100); + } + $layer->getImagick()->setImageIterations($loops); + + $this->resource->setImage($layer->getImagick()); + } + } catch (\ImagickException $e) { + throw new RuntimeException('Failed to animate layers', $e->getCode(), $e); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function coalesce() + { + try { + $coalescedResource = $this->resource->coalesceImages(); + } catch (\ImagickException $e) { + throw new RuntimeException('Failed to coalesce layers', $e->getCode(), $e); + } + + $count = $coalescedResource->getNumberImages(); + for ($offset = 0; $offset < $count; $offset++) { + try { + $coalescedResource->setIteratorIndex($offset); + $this->layers[$offset] = new Image($coalescedResource->getImage(), $this->palette, new MetadataBag()); + } catch (\ImagickException $e) { + throw new RuntimeException('Failed to retrieve layer', $e->getCode(), $e); + } + } + } + + /** + * {@inheritdoc} + */ + public function current() + { + return $this->extractAt($this->offset); + } + + /** + * Tries to extract layer at given offset + * + * @param integer $offset + * + * @return Image + * @throws RuntimeException + */ + private function extractAt($offset) + { + if (!isset($this->layers[$offset])) { + try { + $this->resource->setIteratorIndex($offset); + $this->layers[$offset] = new Image($this->resource->getImage(), $this->palette, new MetadataBag()); + } catch (\ImagickException $e) { + throw new RuntimeException(sprintf('Failed to extract layer %d', $offset), $e->getCode(), $e); + } + } + + return $this->layers[$offset]; + } + + /** + * {@inheritdoc} + */ + public function key() + { + return $this->offset; + } + + /** + * {@inheritdoc} + */ + public function next() + { + ++$this->offset; + } + + /** + * {@inheritdoc} + */ + public function rewind() + { + $this->offset = 0; + } + + /** + * {@inheritdoc} + */ + public function valid() + { + return $this->offset < count($this); + } + + /** + * {@inheritdoc} + */ + public function count() + { + try { + return $this->resource->getNumberImages(); + } catch (\ImagickException $e) { + throw new RuntimeException('Failed to count the number of layers', $e->getCode(), $e); + } + } + + /** + * {@inheritdoc} + */ + public function offsetExists($offset) + { + return is_int($offset) && $offset >= 0 && $offset < count($this); + } + + /** + * {@inheritdoc} + */ + public function offsetGet($offset) + { + return $this->extractAt($offset); + } + + /** + * {@inheritdoc} + */ + public function offsetSet($offset, $image) + { + if (!$image instanceof Image) { + throw new InvalidArgumentException('Only an Imagick Image can be used as layer'); + } + + if (null === $offset) { + $offset = count($this) - 1; + } else { + if (!is_int($offset)) { + throw new InvalidArgumentException('Invalid offset for layer, it must be an integer'); + } + + if (count($this) < $offset || 0 > $offset) { + throw new OutOfBoundsException(sprintf('Invalid offset for layer, it must be a value between 0 and %d, %d given', count($this), $offset)); + } + + if (isset($this[$offset])) { + unset($this[$offset]); + $offset = $offset - 1; + } + } + + $frame = $image->getImagick(); + + try { + if (count($this) > 0) { + $this->resource->setIteratorIndex($offset); + } + $this->resource->addImage($frame); + } catch (\ImagickException $e) { + throw new RuntimeException('Unable to set the layer', $e->getCode(), $e); + } + + $this->layers = array(); + } + + /** + * {@inheritdoc} + */ + public function offsetUnset($offset) + { + try { + $this->extractAt($offset); + } catch (RuntimeException $e) { + return; + } + + try { + $this->resource->setIteratorIndex($offset); + $this->resource->removeImage(); + } catch (\ImagickException $e) { + throw new RuntimeException('Unable to remove layer', $e->getCode(), $e); + } + } +} diff --git a/src/Symfony/Component/Image/Imagick/Loader.php b/src/Symfony/Component/Image/Imagick/Loader.php new file mode 100644 index 0000000000000..46991c797473a --- /dev/null +++ b/src/Symfony/Component/Image/Imagick/Loader.php @@ -0,0 +1,184 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Imagick; + +use Symfony\Component\Image\Exception\NotSupportedException; +use Symfony\Component\Image\Image\AbstractLoader; +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Image\Metadata\MetadataBag; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Exception\RuntimeException; +use Symfony\Component\Image\Image\Palette\CMYK; +use Symfony\Component\Image\Image\Palette\RGB; +use Symfony\Component\Image\Image\Palette\Grayscale; + +/** + * Loader implementation using the Imagick PHP extension + */ +final class Loader extends AbstractLoader +{ + /** + * @throws RuntimeException + */ + public function __construct() + { + if (!class_exists('Imagick')) { + throw new RuntimeException('Imagick not installed'); + } + + $version = $this->getVersion(new \Imagick()); + + if (version_compare('6.2.9', $version) > 0) { + throw new RuntimeException(sprintf('ImageMagick version 6.2.9 or higher is required, %s provided', $version)); + } + } + + /** + * {@inheritdoc} + */ + public function open($path) + { + $path = $this->checkPath($path); + + try { + $imagick = new \Imagick($path); + $image = new Image($imagick, $this->createPalette($imagick), $this->getMetadataReader()->readFile($path)); + } catch (\Exception $e) { + throw new RuntimeException(sprintf('Unable to open image %s', $path), $e->getCode(), $e); + } + + return $image; + } + + /** + * {@inheritdoc} + */ + public function create(BoxInterface $size, ColorInterface $color = null) + { + $width = $size->getWidth(); + $height = $size->getHeight(); + + $palette = null !== $color ? $color->getPalette() : new RGB(); + $color = null !== $color ? $color : $palette->color('fff'); + + try { + $pixel = new \ImagickPixel((string) $color); + $pixel->setColorValue(\Imagick::COLOR_ALPHA, $color->getAlpha() / 100); + + $imagick = new \Imagick(); + $imagick->newImage($width, $height, $pixel); + $imagick->setImageMatte(true); + $imagick->setImageBackgroundColor($pixel); + + if (version_compare('6.3.1', $this->getVersion($imagick)) < 0) { + if (method_exists($imagick, 'setImageAlpha')) { + $imagick->setImageAlpha($pixel->getColorValue(\Imagick::COLOR_ALPHA)); + } else { + $imagick->setImageOpacity($pixel->getColorValue(\Imagick::COLOR_ALPHA)); + } + } + + $pixel->clear(); + $pixel->destroy(); + + return new Image($imagick, $palette, new MetadataBag()); + } catch (\ImagickException $e) { + throw new RuntimeException('Could not create empty image', $e->getCode(), $e); + } + } + + /** + * {@inheritdoc} + */ + public function load($string) + { + try { + $imagick = new \Imagick(); + + $imagick->readImageBlob($string); + $imagick->setImageMatte(true); + + return new Image($imagick, $this->createPalette($imagick), $this->getMetadataReader()->readData($string)); + } catch (\ImagickException $e) { + throw new RuntimeException('Could not load image from string', $e->getCode(), $e); + } + } + + /** + * {@inheritdoc} + */ + public function read($resource) + { + if (!is_resource($resource)) { + throw new InvalidArgumentException('Variable does not contain a stream resource'); + } + + $content = stream_get_contents($resource); + + try { + $imagick = new \Imagick(); + $imagick->readImageBlob($content); + } catch (\ImagickException $e) { + throw new RuntimeException('Could not read image from resource', $e->getCode(), $e); + } + + return new Image($imagick, $this->createPalette($imagick), $this->getMetadataReader()->readData($content, $resource)); + } + + /** + * {@inheritdoc} + */ + public function font($file, $size, ColorInterface $color) + { + return new Font(new \Imagick(), $file, $size, $color); + } + + /** + * Returns the palette corresponding to an \Imagick resource colorspace + * + * @param \Imagick $imagick + * + * @return CMYK|Grayscale|RGB + * + * @throws NotSupportedException + */ + private function createPalette(\Imagick $imagick) + { + switch ($imagick->getImageColorspace()) { + case \Imagick::COLORSPACE_RGB: + case \Imagick::COLORSPACE_SRGB: + return new RGB(); + case \Imagick::COLORSPACE_CMYK: + return new CMYK(); + case \Imagick::COLORSPACE_GRAY: + return new Grayscale(); + default: + throw new NotSupportedException('Only RGB and CMYK colorspace are currently supported'); + } + } + + /** + * Returns ImageMagick version + * + * @param \Imagick $imagick + * + * @return string + */ + private function getVersion(\Imagick $imagick) + { + $v = $imagick->getVersion(); + list($version) = sscanf($v['versionString'], 'ImageMagick %s %04d-%02d-%02d %s %s'); + + return $version; + } +} diff --git a/src/Symfony/Component/Image/LICENSE b/src/Symfony/Component/Image/LICENSE new file mode 100644 index 0000000000000..ce39894f6a9a2 --- /dev/null +++ b/src/Symfony/Component/Image/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2016-2017 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/Image/README.md b/src/Symfony/Component/Image/README.md new file mode 100644 index 0000000000000..a31e215cd9b1f --- /dev/null +++ b/src/Symfony/Component/Image/README.md @@ -0,0 +1,3 @@ +Symfony Image Manipulation Component +==================================== + diff --git a/src/Symfony/Component/Image/Resources/Adobe/CMYK/CoatedFOGRA27.icc b/src/Symfony/Component/Image/Resources/Adobe/CMYK/CoatedFOGRA27.icc new file mode 100644 index 0000000000000..086ac9d92d3e7 Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/CMYK/CoatedFOGRA27.icc differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/CMYK/CoatedFOGRA39.icc b/src/Symfony/Component/Image/Resources/Adobe/CMYK/CoatedFOGRA39.icc new file mode 100644 index 0000000000000..61cb86a595564 Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/CMYK/CoatedFOGRA39.icc differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/CMYK/CoatedGRACoL2006.icc b/src/Symfony/Component/Image/Resources/Adobe/CMYK/CoatedGRACoL2006.icc new file mode 100644 index 0000000000000..1f360b7ed702b Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/CMYK/CoatedGRACoL2006.icc differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/CMYK/JapanColor2001Coated.icc b/src/Symfony/Component/Image/Resources/Adobe/CMYK/JapanColor2001Coated.icc new file mode 100644 index 0000000000000..5841b46fd6b8c Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/CMYK/JapanColor2001Coated.icc differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/CMYK/JapanColor2001Uncoated.icc b/src/Symfony/Component/Image/Resources/Adobe/CMYK/JapanColor2001Uncoated.icc new file mode 100644 index 0000000000000..8ade70de0fb46 Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/CMYK/JapanColor2001Uncoated.icc differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/CMYK/JapanColor2002Newspaper.icc b/src/Symfony/Component/Image/Resources/Adobe/CMYK/JapanColor2002Newspaper.icc new file mode 100644 index 0000000000000..18307e270928a Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/CMYK/JapanColor2002Newspaper.icc differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/CMYK/JapanColor2003WebCoated.icc b/src/Symfony/Component/Image/Resources/Adobe/CMYK/JapanColor2003WebCoated.icc new file mode 100644 index 0000000000000..733dc1dfd872c Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/CMYK/JapanColor2003WebCoated.icc differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/CMYK/JapanWebCoated.icc b/src/Symfony/Component/Image/Resources/Adobe/CMYK/JapanWebCoated.icc new file mode 100644 index 0000000000000..004b8b9d374b0 Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/CMYK/JapanWebCoated.icc differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/CMYK/USWebCoatedSWOP.icc b/src/Symfony/Component/Image/Resources/Adobe/CMYK/USWebCoatedSWOP.icc new file mode 100644 index 0000000000000..078a6443a712f Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/CMYK/USWebCoatedSWOP.icc differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/CMYK/USWebUncoated.icc b/src/Symfony/Component/Image/Resources/Adobe/CMYK/USWebUncoated.icc new file mode 100644 index 0000000000000..75efcb259a48c Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/CMYK/USWebUncoated.icc differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/CMYK/UncoatedFOGRA29.icc b/src/Symfony/Component/Image/Resources/Adobe/CMYK/UncoatedFOGRA29.icc new file mode 100644 index 0000000000000..70e13f5618793 Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/CMYK/UncoatedFOGRA29.icc differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/CMYK/WebCoatedFOGRA28.icc b/src/Symfony/Component/Image/Resources/Adobe/CMYK/WebCoatedFOGRA28.icc new file mode 100644 index 0000000000000..06bf0e63bf08b Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/CMYK/WebCoatedFOGRA28.icc differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/CMYK/WebCoatedSWOP2006Grade3.icc b/src/Symfony/Component/Image/Resources/Adobe/CMYK/WebCoatedSWOP2006Grade3.icc new file mode 100644 index 0000000000000..ad24acb77680d Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/CMYK/WebCoatedSWOP2006Grade3.icc differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/CMYK/WebCoatedSWOP2006Grade5.icc b/src/Symfony/Component/Image/Resources/Adobe/CMYK/WebCoatedSWOP2006Grade5.icc new file mode 100644 index 0000000000000..11a82c8fba85f Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/CMYK/WebCoatedSWOP2006Grade5.icc differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/Color Profile Bundling License_10.15.08.pdf b/src/Symfony/Component/Image/Resources/Adobe/Color Profile Bundling License_10.15.08.pdf new file mode 100644 index 0000000000000..3c508c1857b6c Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/Color Profile Bundling License_10.15.08.pdf differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/Profile Information.pdf b/src/Symfony/Component/Image/Resources/Adobe/Profile Information.pdf new file mode 100644 index 0000000000000..14afaca1e4aaf Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/Profile Information.pdf differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/RGB/AdobeRGB1998.icc b/src/Symfony/Component/Image/Resources/Adobe/RGB/AdobeRGB1998.icc new file mode 100644 index 0000000000000..a79f576b5972a Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/RGB/AdobeRGB1998.icc differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/RGB/AppleRGB.icc b/src/Symfony/Component/Image/Resources/Adobe/RGB/AppleRGB.icc new file mode 100644 index 0000000000000..ff59d23524b60 Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/RGB/AppleRGB.icc differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/RGB/ColorMatchRGB.icc b/src/Symfony/Component/Image/Resources/Adobe/RGB/ColorMatchRGB.icc new file mode 100644 index 0000000000000..df927eac71f24 Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/RGB/ColorMatchRGB.icc differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/RGB/PAL_SECAM.icc b/src/Symfony/Component/Image/Resources/Adobe/RGB/PAL_SECAM.icc new file mode 100644 index 0000000000000..88c1717d81377 Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/RGB/PAL_SECAM.icc differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/RGB/SMPTE-C.icc b/src/Symfony/Component/Image/Resources/Adobe/RGB/SMPTE-C.icc new file mode 100644 index 0000000000000..8eeeb30532077 Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/RGB/SMPTE-C.icc differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/RGB/VideoHD.icc b/src/Symfony/Component/Image/Resources/Adobe/RGB/VideoHD.icc new file mode 100644 index 0000000000000..57f121a5aa0ad Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/RGB/VideoHD.icc differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/RGB/VideoNTSC.icc b/src/Symfony/Component/Image/Resources/Adobe/RGB/VideoNTSC.icc new file mode 100644 index 0000000000000..78d1ff371331e Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/RGB/VideoNTSC.icc differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/RGB/VideoPAL.icc b/src/Symfony/Component/Image/Resources/Adobe/RGB/VideoPAL.icc new file mode 100644 index 0000000000000..0b7ed9bf0368b Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/RGB/VideoPAL.icc differ diff --git a/src/Symfony/Component/Image/Resources/Adobe/Trademark Information.pdf b/src/Symfony/Component/Image/Resources/Adobe/Trademark Information.pdf new file mode 100644 index 0000000000000..03a293ac25f96 Binary files /dev/null and b/src/Symfony/Component/Image/Resources/Adobe/Trademark Information.pdf differ diff --git a/src/Symfony/Component/Image/Resources/color.org/sRGB_IEC61966-2-1_black_scaled.icc b/src/Symfony/Component/Image/Resources/color.org/sRGB_IEC61966-2-1_black_scaled.icc new file mode 100644 index 0000000000000..71e33830223c4 Binary files /dev/null and b/src/Symfony/Component/Image/Resources/color.org/sRGB_IEC61966-2-1_black_scaled.icc differ diff --git a/src/Symfony/Component/Image/Resources/color.org/sRGB_IEC61966-2-1_no_black_scaling.icc b/src/Symfony/Component/Image/Resources/color.org/sRGB_IEC61966-2-1_no_black_scaling.icc new file mode 100644 index 0000000000000..d0ef5738a80ac Binary files /dev/null and b/src/Symfony/Component/Image/Resources/color.org/sRGB_IEC61966-2-1_no_black_scaling.icc differ diff --git a/src/Symfony/Component/Image/Resources/colormanagement.org/ISOcoated_v2_grey1c_bas.ICC b/src/Symfony/Component/Image/Resources/colormanagement.org/ISOcoated_v2_grey1c_bas.ICC new file mode 100644 index 0000000000000..24adafeb16d5f Binary files /dev/null and b/src/Symfony/Component/Image/Resources/colormanagement.org/ISOcoated_v2_grey1c_bas.ICC differ diff --git a/src/Symfony/Component/Image/Tests/Constraint/IsImageEqual.php b/src/Symfony/Component/Image/Tests/Constraint/IsImageEqual.php new file mode 100644 index 0000000000000..75fbd1f2809f9 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Constraint/IsImageEqual.php @@ -0,0 +1,162 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Constraint; + +use PHPUnit\Framework\Constraint\Constraint; +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\Histogram\Bucket; +use Symfony\Component\Image\Image\Histogram\Range; + +if (class_exists(\PHPUnit_Framework_Constraint::class)) { + abstract class PHPUnitConstraint extends \PHPUnit_Framework_Constraint{} +} else { + abstract class PHPUnitConstraint extends Constraint{} +} + +class IsImageEqual extends PHPUnitConstraint +{ + /** + * @var \Symfony\Component\Image\Image\ImageInterface + */ + private $value; + + /** + * @var float + */ + private $delta; + + /** + * @var integer + */ + private $buckets; + + /** + * @param \Symfony\Component\Image\Image\ImageInterface $value + * @param float $delta + * @param integer $buckets + * + * @throws InvalidArgumentException + */ + public function __construct($value, $delta = 0.1, $buckets = 4) + { + if (!$value instanceof ImageInterface) { + throw \PHPUnit_Util_InvalidArgumentHelper::factory(1, ImageInterface::class); + } + + if (!is_numeric($delta)) { + throw \PHPUnit_Util_InvalidArgumentHelper::factory(2, 'numeric'); + } + + if (!is_integer($buckets) || $buckets <= 0) { + throw \PHPUnit_Util_InvalidArgumentHelper::factory(3, 'integer'); + } + + $this->value = $value; + $this->delta = $delta; + $this->buckets = $buckets; + } + + /** + * {@inheritdoc} + */ + public function evaluate($other, $description = '', $returnResult = false) + { + if (!$other instanceof ImageInterface) { + throw \PHPUnit_Util_InvalidArgumentHelper::factory(1, ImageInterface::class); + } + + list($currentRed, $currentGreen, $currentBlue, $currentAlpha) = $this->normalize($this->value); + list($otherRed, $otherGreen, $otherBlue, $otherAlpha) = $this->normalize($other); + + $total = 0; + + foreach ($currentRed as $bucket => $count) { + $total += abs($count - $otherRed[$bucket]); + } + + foreach ($currentGreen as $bucket => $count) { + $total += abs($count - $otherGreen[$bucket]); + } + + foreach ($currentBlue as $bucket => $count) { + $total += abs($count - $otherBlue[$bucket]); + } + + foreach ($currentAlpha as $bucket => $count) { + $total += abs($count - $otherAlpha[$bucket]); + } + + return $total <= $this->delta; + } + + /** + * {@inheritdoc} + */ + public function toString() + { + return sprintf('contains color histogram identical to expected %s', \PHPUnit_Util_Type::toString($this->value)); + } + + /** + * @param \Symfony\Component\Image\Image\ImageInterface $image + * + * @return array + */ + private function normalize(ImageInterface $image) + { + $step = (int) round(255 / $this->buckets); + + $red = + $green = + $blue = + $alpha = array(); + + for ($i = 1; $i <= $this->buckets; $i++) { + $range = new Range(($i - 1) * $step, $i * $step); + $red[] = new Bucket($range); + $green[] = new Bucket($range); + $blue[] = new Bucket($range); + $alpha[] = new Bucket($range); + } + + foreach ($image->histogram() as $color) { + foreach ($red as $bucket) { + $bucket->add($color->getRed()); + } + + foreach ($green as $bucket) { + $bucket->add($color->getGreen()); + } + + foreach ($blue as $bucket) { + $bucket->add($color->getBlue()); + } + + foreach ($alpha as $bucket) { + $bucket->add($color->getAlpha()); + } + } + + $total = $image->getSize()->square(); + + $callback = function (Bucket $bucket) use ($total) { + return count($bucket) / $total; + }; + + return array( + array_map($callback, $red), + array_map($callback, $green), + array_map($callback, $blue), + array_map($callback, $alpha), + ); + } +} diff --git a/src/Symfony/Component/Image/Tests/Draw/AbstractDrawerTest.php b/src/Symfony/Component/Image/Tests/Draw/AbstractDrawerTest.php new file mode 100644 index 0000000000000..f3717cb33733f --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Draw/AbstractDrawerTest.php @@ -0,0 +1,252 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Draw; + +use Symfony\Component\Image\Image\Box; +use Symfony\Component\Image\Image\Font; +use Symfony\Component\Image\Image\Palette\RGB; +use Symfony\Component\Image\Image\Point; +use Symfony\Component\Image\Image\Point\Center; +use Symfony\Component\Image\Image\LoaderInterface; +use Symfony\Component\Image\Tests\TestCase; + +abstract class AbstractDrawerTest extends TestCase +{ + public function testDrawASmileyFace() + { + $imagine = $this->getLoader(); + + $canvas = $imagine->create(new Box(400, 300), $this->getColor('000')); + + $canvas->draw() + ->chord(new Point(200, 200), new Box(200, 150), 0, 180, $this->getColor('fff'), false) + ->ellipse(new Point(125, 100), new Box(50, 50), $this->getColor('fff')) + ->ellipse(new Point(275, 100), new Box(50, 50), $this->getColor('fff'), true); + + $canvas->save(__DIR__.'/../Fixtures/smiley.png'); + + $this->assertTrue(file_exists(__DIR__.'/../Fixtures/smiley.png')); + + unlink(__DIR__.'/../Fixtures/smiley.png'); + } + + public function testDrawAnEllipse() + { + $imagine = $this->getLoader(); + + $canvas = $imagine->create(new Box(400, 300), $this->getColor('000')); + + $canvas->draw() + ->ellipse(new Center($canvas->getSize()), new Box(300, 200), $this->getColor('fff'), true); + + $canvas->save(__DIR__.'/../Fixtures/ellipse.png'); + + $this->assertTrue(file_exists(__DIR__.'/../Fixtures/ellipse.png')); + + unlink(__DIR__.'/../Fixtures/ellipse.png'); + } + + public function testDrawAPieSlice() + { + $imagine = $this->getLoader(); + + $canvas = $imagine->create(new Box(400, 300), $this->getColor('000')); + + $canvas->draw() + ->pieSlice(new Point(200, 150), new Box(100, 200), 45, 135, $this->getColor('fff'), true); + + $canvas->save(__DIR__.'/../Fixtures/pie.png'); + + $this->assertTrue(file_exists(__DIR__.'/../Fixtures/pie.png')); + + unlink(__DIR__.'/../Fixtures/pie.png'); + } + + public function testDrawAChord() + { + $imagine = $this->getLoader(); + + $canvas = $imagine->create(new Box(400, 300), $this->getColor('000')); + + $canvas->draw() + ->chord(new Point(200, 150), new Box(100, 200), 45, 135, $this->getColor('fff'), true); + + $canvas->save(__DIR__.'/../Fixtures/chord.png'); + + $this->assertTrue(file_exists(__DIR__.'/../Fixtures/chord.png')); + + unlink(__DIR__.'/../Fixtures/chord.png'); + } + + public function testDrawALine() + { + $imagine = $this->getLoader(); + + $canvas = $imagine->create(new Box(400, 300), $this->getColor('000')); + + $canvas->draw() + ->line(new Point(50, 50), new Point(350, 250), $this->getColor('fff')) + ->line(new Point(50, 250), new Point(350, 50), $this->getColor('fff')); + + $canvas->save(__DIR__.'/../Fixtures/lines.png'); + + $this->assertTrue(file_exists(__DIR__.'/../Fixtures/lines.png')); + + unlink(__DIR__.'/../Fixtures/lines.png'); + } + + public function testDrawAPolygon() + { + $imagine = $this->getLoader(); + + $canvas = $imagine->create(new Box(400, 300), $this->getColor('000')); + + $canvas->draw() + ->polygon(array( + new Point(50, 20), + new Point(350, 20), + new Point(350, 280), + new Point(50, 280), + ), $this->getColor('fff'), true); + + $canvas->save(__DIR__.'/../Fixtures/polygon.png'); + + $this->assertTrue(file_exists(__DIR__.'/../Fixtures/polygon.png')); + + unlink(__DIR__.'/../Fixtures/polygon.png'); + } + + public function testDrawADot() + { + $imagine = $this->getLoader(); + + $canvas = $imagine->create(new Box(400, 300), $this->getColor('000')); + + $canvas->draw() + ->dot(new Point(200, 150), $this->getColor('fff')) + ->dot(new Point(200, 151), $this->getColor('fff')) + ->dot(new Point(200, 152), $this->getColor('fff')) + ->dot(new Point(200, 153), $this->getColor('fff')); + + $canvas->save(__DIR__.'/../Fixtures/dot.png'); + + $this->assertTrue(file_exists(__DIR__.'/../Fixtures/dot.png')); + + unlink(__DIR__.'/../Fixtures/dot.png'); + } + + public function testDrawAnArc() + { + $imagine = $this->getLoader(); + + $canvas = $imagine->create(new Box(400, 300), $this->getColor('000')); + $size = $canvas->getSize(); + + $canvas->draw() + ->arc(new Center($size), $size->scale(0.5), 0, 180, $this->getColor('fff')); + + $canvas->save(__DIR__.'/../Fixtures/arc.png'); + + $this->assertTrue(file_exists(__DIR__.'/../Fixtures/arc.png')); + + unlink(__DIR__.'/../Fixtures/arc.png'); + } + + public function testDrawText() + { + if (!$this->isFontTestSupported()) { + $this->markTestSkipped('This install does not support font tests'); + } + + $path = __DIR__.'/../Fixtures/font/Arial.ttf'; + $black = $this->getColor('000'); + $file36 = __DIR__.'/../Fixtures/bulat36.png'; + $file24 = __DIR__.'/../Fixtures/bulat24.png'; + $file18 = __DIR__.'/../Fixtures/bulat18.png'; + $file12 = __DIR__.'/../Fixtures/bulat12.png'; + + $imagine = $this->getLoader(); + $canvas = $imagine->create(new Box(400, 300), $this->getColor('fff')); + $font = $imagine->font($path, 36, $black); + + $canvas->draw() + ->text('Bulat', $font, new Point(0, 0), 135); + + $canvas->save($file36); + + unset($canvas); + + $this->assertTrue(file_exists($file36)); + + unlink($file36); + + $canvas = $imagine->create(new Box(400, 300), $this->getColor('fff')); + $font = $imagine->font($path, 24, $black); + + $canvas->draw() + ->text('Bulat', $font, new Point(24, 24)); + + $canvas->save($file24); + + unset($canvas); + + $this->assertTrue(file_exists($file24)); + + unlink($file24); + + $canvas = $imagine->create(new Box(400, 300), $this->getColor('fff')); + $font = $imagine->font($path, 18, $black); + + $canvas->draw() + ->text('Bulat', $font, new Point(18, 18)); + + $canvas->save($file18); + + unset($canvas); + + $this->assertTrue(file_exists($file18)); + + unlink($file18); + + $canvas = $imagine->create(new Box(400, 300), $this->getColor('fff')); + $font = $imagine->font($path, 12, $black); + + $canvas->draw() + ->text('Bulat', $font, new Point(12, 12)); + + $canvas->save($file12); + + unset($canvas); + + $this->assertTrue(file_exists($file12)); + + unlink($file12); + } + + private function getColor($color) + { + static $palette; + + if (!$palette) { + $palette = new RGB(); + } + + return $palette->color($color); + } + + /** + * @return LoaderInterface + */ + abstract protected function getLoader(); + + abstract protected function isFontTestSupported(); +} diff --git a/src/Symfony/Component/Image/Tests/Effects/AbstractEffectsTest.php b/src/Symfony/Component/Image/Tests/Effects/AbstractEffectsTest.php new file mode 100644 index 0000000000000..1f9428c2381b2 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Effects/AbstractEffectsTest.php @@ -0,0 +1,141 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Effects; + +use Symfony\Component\Image\Image\Box; +use Symfony\Component\Image\Image\Point; +use Symfony\Component\Image\Image\LoaderInterface; +use Symfony\Component\Image\Image\Palette\RGB; +use Symfony\Component\Image\Tests\TestCase; + +abstract class AbstractEffectsTest extends TestCase +{ + + public function testNegate() + { + $palette = new RGB(); + $imagine = $this->getLoader(); + + $image = $imagine->create(new Box(20, 20), $palette->color('ff0')); + $image->effects() + ->negative(); + + $this->assertEquals('#0000ff', (string) $image->getColorAt(new Point(10, 10))); + + $image->effects() + ->negative(); + + $this->assertEquals('#ffff00', (string) $image->getColorAt(new Point(10, 10))); + } + + public function testGamma() + { + $palette = new RGB(); + $imagine = $this->getLoader(); + + $r = 20; + $g = 90; + $b = 240; + + $image = $imagine->create(new Box(20, 20), $palette->color(array($r, $g, $b))); + $image->effects() + ->gamma(1.2); + + $pixel = $image->getColorAt(new Point(10, 10)); + + $this->assertNotEquals($r, $pixel->getRed()); + $this->assertNotEquals($g, $pixel->getGreen()); + $this->assertNotEquals($b, $pixel->getBlue()); + } + + public function testGrayscale() + { + $palette = new RGB(); + $imagine = $this->getLoader(); + + $r = 20; + $g = 90; + $b = 240; + + $image = $imagine->create(new Box(20, 20), $palette->color(array($r, $g, $b))); + $image->effects() + ->grayscale(); + + $pixel = $image->getColorAt(new Point(10, 10)); + + $this->assertEquals($this->getGrayValue(), (string) $pixel); + + $greyR = (int) $pixel->getRed(); + $greyG = (int) $pixel->getGreen(); + $greyB = (int) $pixel->getBlue(); + + $this->assertEquals($greyR, $this->getComponentGrayValue()); + $this->assertEquals($greyR, $greyG); + $this->assertEquals($greyR, $greyB); + $this->assertEquals($greyG, $greyB); + } + + protected function getGrayValue() + { + return '#565656'; + } + + protected function getComponentGrayValue() + { + return 86; + } + + public function testColorize() + { + $palette = new RGB(); + $imagine = $this->getLoader(); + + $blue = $palette->color('#0000FF'); + + $image = $imagine->create(new Box(15, 15), $palette->color('000')); + $image->effects() + ->colorize($blue); + + $pixel = $image->getColorAt(new Point(10, 10)); + + $this->assertEquals((string) $blue, (string) $pixel); + + $this->assertEquals($blue->getRed(), $pixel->getRed()); + $this->assertEquals($blue->getGreen(), $pixel->getGreen()); + $this->assertEquals($blue->getBlue(), $pixel->getBlue()); + } + + public function testBlur() + { + $palette = new RGB(); + $imagine = $this->getLoader(); + + $image = $imagine->create(new Box(20, 20), $palette->color('#fff')); + + $image->draw() + ->line(new Point(10, 0), new Point(10, 20), $palette->color('#000'), 1); + + $image->effects() + ->blur(); + + $pixel = $image->getColorAt(new Point(9, 10)); + + $this->assertNotEquals(255, $pixel->getRed()); + $this->assertNotEquals(255, $pixel->getGreen()); + $this->assertNotEquals(255, $pixel->getBlue()); + } + + /** + * @return LoaderInterface + */ + abstract protected function getLoader(); +} diff --git a/src/Symfony/Component/Image/Tests/Filter/Advanced/BorderTest.php b/src/Symfony/Component/Image/Tests/Filter/Advanced/BorderTest.php new file mode 100644 index 0000000000000..18b82364f9053 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Filter/Advanced/BorderTest.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Filter\Advanced; + +use Symfony\Component\Image\Filter\Advanced\Border; +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Tests\Filter\FilterTestCase; + +class BorderTest extends FilterTestCase +{ + public function testBorderImage() + { + $color = $this->getMockBuilder(ColorInterface::class)->getMock(); + $width = 2; + $height = 4; + $image = $this->getImage(); + + $size = $this->getMockBuilder(BoxInterface::class)->getMock(); + $size->expects($this->once()) + ->method('getWidth') + ->will($this->returnValue($width)); + + $size->expects($this->once()) + ->method('getHeight') + ->will($this->returnValue($height)); + + $draw = $this->getDrawer(); + $draw->expects($this->exactly(4)) + ->method('line') + ->will($this->returnValue($draw)); + + $image->expects($this->once()) + ->method('getSize') + ->will($this->returnValue($size)); + + $image->expects($this->once()) + ->method('draw') + ->will($this->returnValue($draw)); + + $filter = new Border($color, $width, $height); + + $this->assertSame($image, $filter->apply($image)); + } +} diff --git a/src/Symfony/Component/Image/Tests/Filter/Advanced/CanvasTest.php b/src/Symfony/Component/Image/Tests/Filter/Advanced/CanvasTest.php new file mode 100644 index 0000000000000..b85566cc66c92 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Filter/Advanced/CanvasTest.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Filter\Advanced; + +use Symfony\Component\Image\Image\Box; +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Filter\Advanced\Canvas; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\Point; +use Symfony\Component\Image\Image\PointInterface; +use Symfony\Component\Image\Tests\Filter\FilterTestCase; + +class CanvasTest extends FilterTestCase +{ + /** + * @covers \Symfony\Component\Image\Filter\Advanced\Canvas::apply + * + * @dataProvider getDataSet + * + * @param BoxInterface $size + * @param PointInterface $placement + * @param ColorInterface $background + */ + public function testShouldCanvasImageAndReturnResult(BoxInterface $size, PointInterface $placement = null, ColorInterface $background = null) + { + $placement = $placement ?: new Point(0, 0); + $image = $this->getImage(); + + $canvas = $this->getImage(); + $canvas->expects($this->once())->method('paste')->with($image, $placement); + + $imagine = $this->getLoader(); + $imagine->expects($this->once())->method('create')->with($size, $background)->will($this->returnValue($canvas)); + + $command = new Canvas($imagine, $size, $placement, $background); + + $this->assertSame($canvas, $command->apply($image)); + } + + /** + * Data provider for testShouldCanvasImageAndReturnResult + * + * @return array + */ + public function getDataSet() + { + return array( + array(new Box(50, 15), new Point(10, 10), $this->getColor()), + array(new Box(300, 25), new Point(15, 15)), + array(new Box(123, 23)), + ); + } +} diff --git a/src/Symfony/Component/Image/Tests/Filter/Advanced/GrayscaleTest.php b/src/Symfony/Component/Image/Tests/Filter/Advanced/GrayscaleTest.php new file mode 100644 index 0000000000000..0d3f457dafd62 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Filter/Advanced/GrayscaleTest.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Filter\Advanced; + +use Symfony\Component\Image\Filter\Advanced\Grayscale; +use Symfony\Component\Image\Image\Box; +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Image\Point; +use Symfony\Component\Image\Tests\Filter\FilterTestCase; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; + +class GrayscaleTest extends FilterTestCase +{ + /** + * @covers \Symfony\Component\Image\Filter\Advanced\Grayscale::apply + * + * @dataProvider getDataSet + * + * @param BoxInterface $size + * @param ColorInterface $color + * @param ColorInterface $filteredColor + */ + public function testGrayscaling(BoxInterface $size, ColorInterface $color, ColorInterface $filteredColor) + { + $image = $this->getImage(); + $imageWidth = $size->getWidth(); + $imageHeight = $size->getHeight(); + + $size = $this->getMockBuilder(BoxInterface::class)->getMock(); + $size->expects($this->once()) + ->method('getWidth') + ->will($this->returnValue($imageWidth)); + + $size->expects($this->once()) + ->method('getHeight') + ->will($this->returnValue($imageHeight)); + + $image->expects($this->any()) + ->method('getSize') + ->will($this->returnValue($size)); + + $image->expects($this->exactly($imageWidth*$imageHeight)) + ->method('getColorAt') + ->will($this->returnValue($color)); + + $color->expects($this->exactly($imageWidth*$imageHeight)) + ->method('grayscale') + ->will($this->returnValue($filteredColor)); + + $draw = $this->getDrawer(); + $draw->expects($this->exactly($imageWidth*$imageHeight)) + ->method('dot') + ->with($this->isInstanceOf(Point::class), $this->equalTo($filteredColor)); + + $image->expects($this->exactly($imageWidth*$imageHeight)) + ->method('draw') + ->will($this->returnValue($draw)); + + $filter = new Grayscale(); + $this->assertSame($image, $filter->apply($image)); + } + + /** + * Data provider for testShouldCanvasImageAndReturnResult + * + * @return array + */ + public function getDataSet() + { + return array( + array(new Box(20, 10), $this->getColor(), $this->getColor()), + array(new Box(10, 15), $this->getColor(), $this->getColor()), + array(new Box(12, 23), $this->getColor(), $this->getColor()), + ); + } +} diff --git a/src/Symfony/Component/Image/Tests/Filter/Basic/AutorotateTest.php b/src/Symfony/Component/Image/Tests/Filter/Basic/AutorotateTest.php new file mode 100644 index 0000000000000..b13fd4793fcfb --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Filter/Basic/AutorotateTest.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Filter\Basic; + +USE Symfony\Component\Image\Filter\Basic\Autorotate; +use Symfony\Component\Image\Image\Metadata\MetadataBag; +use Symfony\Component\Image\Tests\Filter\FilterTestCase; + +class AutorotateTest extends FilterTestCase +{ + /** + * @dataProvider provideMetadataAndRotations + */ + public function testApply($expectedRotation, $hFlipExpected, MetadataBag $metadata) + { + $image = $this->getImage(); + $image->expects($this->any()) + ->method('metadata') + ->will($this->returnValue($metadata)); + + if (null === $expectedRotation) { + $image->expects($this->never()) + ->method('rotate'); + } else { + $image->expects($this->once()) + ->method('rotate') + ->with($expectedRotation); + } + + $image->expects($hFlipExpected ? $this->once() : $this->never()) + ->method('flipHorizontally'); + + $filter = new Autorotate($this->getColor()); + $filter->apply($image); + } + + public function provideMetadataAndRotations() + { + return array( + array(null, false, new MetadataBag(array())), + array(null, false, new MetadataBag(array('ifd0.Orientation' => null))), + array(null, false, new MetadataBag(array('ifd0.Orientation' => 0))), + array(null, false, new MetadataBag(array('ifd0.Orientation' => 1))), + array(null, true, new MetadataBag(array('ifd0.Orientation' => 2))), + array(180, false, new MetadataBag(array('ifd0.Orientation' => 3))), + array(180, true, new MetadataBag(array('ifd0.Orientation' => 4))), + array(-90, true, new MetadataBag(array('ifd0.Orientation' => 5))), + array(90, false, new MetadataBag(array('ifd0.Orientation' => 6))), + array(90, true, new MetadataBag(array('ifd0.Orientation' => 7))), + array(-90, false, new MetadataBag(array('ifd0.Orientation' => 8))), + ); + } +} diff --git a/src/Symfony/Component/Image/Tests/Filter/Basic/CopyTest.php b/src/Symfony/Component/Image/Tests/Filter/Basic/CopyTest.php new file mode 100644 index 0000000000000..76fb138006761 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Filter/Basic/CopyTest.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Filter\Basic; + +use Symfony\Component\Image\Tests\Filter\FilterTestCase; +use Symfony\Component\Image\Filter\Basic\Copy; + +class CopyTest extends FilterTestCase +{ + public function testShouldCopyAndReturnResultingImage() + { + $command = new Copy(); + $image = $this->getImage(); + $clone = $this->getImage(); + + $image->expects($this->once()) + ->method('copy') + ->will($this->returnValue($clone)); + + $this->assertSame($clone, $command->apply($image)); + } +} diff --git a/src/Symfony/Component/Image/Tests/Filter/Basic/CropTest.php b/src/Symfony/Component/Image/Tests/Filter/Basic/CropTest.php new file mode 100644 index 0000000000000..4ce0acc61f426 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Filter/Basic/CropTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Filter\Basic; + +use Symfony\Component\Image\Image\Box; +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Filter\Basic\Crop; +use Symfony\Component\Image\Image\Point; +use Symfony\Component\Image\Image\PointInterface; +use Symfony\Component\Image\Tests\Filter\FilterTestCase; + +class CropTest extends FilterTestCase +{ + /** + * @covers \Symfony\Component\Image\Filter\Basic\Crop::apply + * + * @dataProvider getDataSet + * + * @param PointInterface $start + * @param BoxInterface $size + */ + public function testShouldApplyCropAndReturnResult(PointInterface $start, BoxInterface $size) + { + $image = $this->getImage(); + + $command = new Crop($start, $size); + + $image->expects($this->once()) + ->method('crop') + ->with($start, $size) + ->will($this->returnValue($image)); + + $this->assertSame($image, $command->apply($image)); + } + + /** + * Provides coordinates and sizes for testShouldApplyCropAndReturnResult + * + * @return array + */ + public function getDataSet() + { + return array( + array(new Point(0, 0), new Box(40, 50)), + array(new Point(0, 15), new Box(50, 32)) + ); + } +} diff --git a/src/Symfony/Component/Image/Tests/Filter/Basic/FlipHorizontallyTest.php b/src/Symfony/Component/Image/Tests/Filter/Basic/FlipHorizontallyTest.php new file mode 100644 index 0000000000000..6f8236f9863c2 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Filter/Basic/FlipHorizontallyTest.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Filter\Basic; + +use Symfony\Component\Image\Filter\Basic\FlipHorizontally; +use Symfony\Component\Image\Tests\Filter\FilterTestCase; + +class FlipHorizontallyTest extends FilterTestCase +{ + public function testShouldFlipImage() + { + $image = $this->getImage(); + $filter = new FlipHorizontally(); + + $image->expects($this->once()) + ->method('flipHorizontally') + ->will($this->returnValue($image)); + + $this->assertSame($image, $filter->apply($image)); + } +} diff --git a/src/Symfony/Component/Image/Tests/Filter/Basic/FlipVerticallyTest.php b/src/Symfony/Component/Image/Tests/Filter/Basic/FlipVerticallyTest.php new file mode 100644 index 0000000000000..c1014c27f6cd3 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Filter/Basic/FlipVerticallyTest.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Filter\Basic; + +use Symfony\Component\Image\Filter\Basic\FlipVertically; +use Symfony\Component\Image\Tests\Filter\FilterTestCase; + +class FlipVerticallyTest extends FilterTestCase +{ + public function testShouldFlipImage() + { + $image = $this->getImage(); + $filter = new FlipVertically(); + + $image->expects($this->once()) + ->method('flipVertically') + ->will($this->returnValue($image)); + + $this->assertSame($image, $filter->apply($image)); + } +} diff --git a/src/Symfony/Component/Image/Tests/Filter/Basic/PasteTest.php b/src/Symfony/Component/Image/Tests/Filter/Basic/PasteTest.php new file mode 100644 index 0000000000000..096d9147b4da7 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Filter/Basic/PasteTest.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Filter\Basic; + +use Symfony\Component\Image\Image\Point; +use Symfony\Component\Image\Filter\Basic\Paste; +use Symfony\Component\Image\Tests\Filter\FilterTestCase; + +class PasteTest extends FilterTestCase +{ + public function testShouldFlipImage() + { + $start = new Point(0, 0); + $image = $this->getImage(); + $toPaste = $this->getImage(); + $filter = new Paste($toPaste, $start); + + $image->expects($this->once()) + ->method('paste') + ->with($toPaste, $start) + ->will($this->returnValue($image)); + + $this->assertSame($image, $filter->apply($image)); + } +} diff --git a/src/Symfony/Component/Image/Tests/Filter/Basic/ResizeTest.php b/src/Symfony/Component/Image/Tests/Filter/Basic/ResizeTest.php new file mode 100644 index 0000000000000..2c0569298331c --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Filter/Basic/ResizeTest.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Filter\Basic; + +use Symfony\Component\Image\Filter\Basic\Resize; +use Symfony\Component\Image\Image\Box; +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Tests\Filter\FilterTestCase; + +class ResizeTest extends FilterTestCase +{ + /** + * @covers \Symfony\Component\Image\Filter\Basic\Resize::apply + * + * @dataProvider getDataSet + * + * @param BoxInterface $size + */ + public function testShouldResizeImageAndReturnResult(BoxInterface $size) + { + $image = $this->getImage(); + + $image->expects($this->once()) + ->method('resize') + ->with($size) + ->will($this->returnValue($image)); + + $command = new Resize($size); + + $this->assertSame($image, $command->apply($image)); + } + + /** + * Data provider for testShouldResizeImageAndReturnResult + * + * @return array + */ + public function getDataSet() + { + return array( + array(new Box(50, 15)), + array(new Box(300, 25)), + array(new Box(123, 23)), + array(new Box(45, 23)) + ); + } +} diff --git a/src/Symfony/Component/Image/Tests/Filter/Basic/RotateTest.php b/src/Symfony/Component/Image/Tests/Filter/Basic/RotateTest.php new file mode 100644 index 0000000000000..e9ee8e0572ceb --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Filter/Basic/RotateTest.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Filter\Basic; + +use Symfony\Component\Image\Filter\Basic\Rotate; +use Symfony\Component\Image\Tests\Filter\FilterTestCase; + +class RotateTest extends FilterTestCase +{ + public function testShouldRotateImageAndReturnResult() + { + $image = $this->getImage(); + $angle = 90; + $command = new Rotate($angle); + + $image->expects($this->once()) + ->method('rotate') + ->with($angle) + ->will($this->returnValue($image)); + + $this->assertSame($image, $command->apply($image)); + } +} diff --git a/src/Symfony/Component/Image/Tests/Filter/Basic/SaveTest.php b/src/Symfony/Component/Image/Tests/Filter/Basic/SaveTest.php new file mode 100644 index 0000000000000..b80987a5791f3 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Filter/Basic/SaveTest.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Filter\Basic; + +use Symfony\Component\Image\Filter\Basic\Save; +use Symfony\Component\Image\Tests\Filter\FilterTestCase; + +class SaveTest extends FilterTestCase +{ + public function testShouldSaveImageAndReturnResult() + { + $image = $this->getImage(); + $path = '/path/to/image.jpg'; + $command = new Save($path); + + $image->expects($this->once()) + ->method('save') + ->with($path) + ->will($this->returnValue($image)); + + $this->assertSame($image, $command->apply($image)); + } +} diff --git a/src/Symfony/Component/Image/Tests/Filter/Basic/ShowTest.php b/src/Symfony/Component/Image/Tests/Filter/Basic/ShowTest.php new file mode 100644 index 0000000000000..38e5aebf541ed --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Filter/Basic/ShowTest.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Filter\Basic; + +use Symfony\Component\Image\Filter\Basic\Show; +use Symfony\Component\Image\Tests\Filter\FilterTestCase; + +class ShowTest extends FilterTestCase +{ + public function testShouldShowImageAndReturnResult() + { + $image = $this->getImage(); + $format = 'jpg'; + $command = new Show($format); + + $image->expects($this->once()) + ->method('show') + ->with($format) + ->will($this->returnValue($image)); + + $this->assertSame($image, $command->apply($image)); + } +} diff --git a/src/Symfony/Component/Image/Tests/Filter/Basic/StripTest.php b/src/Symfony/Component/Image/Tests/Filter/Basic/StripTest.php new file mode 100644 index 0000000000000..208ee36b2ddc6 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Filter/Basic/StripTest.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Filter\Basic; + +use Symfony\Component\Image\Filter\Basic\Strip; +use Symfony\Component\Image\Tests\Filter\FilterTestCase; + +class StripTest extends FilterTestCase +{ + public function testShouldStripImage() + { + $image = $this->getImage(); + $filter = new Strip(); + + $image->expects($this->once()) + ->method('strip') + ->will($this->returnValue($image)); + + $this->assertSame($image, $filter->apply($image)); + } +} diff --git a/src/Symfony/Component/Image/Tests/Filter/Basic/ThumbnailTest.php b/src/Symfony/Component/Image/Tests/Filter/Basic/ThumbnailTest.php new file mode 100644 index 0000000000000..b24242c2e6b2c --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Filter/Basic/ThumbnailTest.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Filter\Basic; + +use Symfony\Component\Image\Filter\Basic\Thumbnail; +use Symfony\Component\Image\Image\Box; +use Symfony\Component\Image\Image\ManipulatorInterface; +use Symfony\Component\Image\Tests\Filter\FilterTestCase; + +class ThumbnailTest extends FilterTestCase +{ + public function testShouldMakeAThumbnail() + { + $image = $this->getImage(); + $thumbnail = $this->getImage(); + $size = new Box(50, 50); + $filter = new Thumbnail($size); + + $image->expects($this->once()) + ->method('thumbnail') + ->with($size, ManipulatorInterface::THUMBNAIL_INSET) + ->will($this->returnValue($thumbnail)); + + $this->assertSame($thumbnail, $filter->apply($image)); + } +} diff --git a/src/Symfony/Component/Image/Tests/Filter/Basic/WebOptimizationTest.php b/src/Symfony/Component/Image/Tests/Filter/Basic/WebOptimizationTest.php new file mode 100644 index 0000000000000..6be7368926ccc --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Filter/Basic/WebOptimizationTest.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Filter\Basic; + +use Symfony\Component\Image\Filter\Basic\WebOptimization; +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\Palette\RGB; +use Symfony\Component\Image\Tests\Filter\FilterTestCase; + +class WebOptimizationTest extends FilterTestCase +{ + public function testShouldNotSave() + { + $image = $this->getImage(); + $filter = new WebOptimization(); + + $image->expects($this->once()) + ->method('usePalette') + ->with($this->isInstanceOf(RGB::class)) + ->will($this->returnValue($image)); + + $image->expects($this->once()) + ->method('strip') + ->will($this->returnValue($image)); + + $image->expects($this->never()) + ->method('save'); + + $this->assertSame($image, $filter->apply($image)); + } + + public function testShouldSaveWithCallbackAndCustomOption() + { + $image = $this->getImage(); + $result = '/path/to/ploum'; + $path = function (ImageInterface $image) use ($result) { return $result; }; + $filter = new WebOptimization($path, array( + 'custom-option' => 'custom-value', + 'resolution-y' => 100, + )); + $capturedOptions = null; + + $image->expects($this->once()) + ->method('usePalette') + ->with($this->isInstanceOf(RGB::class)) + ->will($this->returnValue($image)); + + $image->expects($this->once()) + ->method('strip') + ->will($this->returnValue($image)); + + $image->expects($this->once()) + ->method('save') + ->with($this->equalTo($result), $this->isType('array')) + ->will($this->returnCallback(function ($path, $options) use (&$capturedOptions, $image) { + $capturedOptions = $options; + + return $image; + })); + + $this->assertSame($image, $filter->apply($image)); + + $this->assertCount(4, $capturedOptions); + $this->assertEquals('custom-value', $capturedOptions['custom-option']); + $this->assertEquals(ImageInterface::RESOLUTION_PIXELSPERINCH, $capturedOptions['resolution-units']); + $this->assertEquals(72, $capturedOptions['resolution-x']); + $this->assertEquals(100, $capturedOptions['resolution-y']); + } + + public function testShouldSaveWithPathAndCustomOption() + { + $image = $this->getImage(); + $path = '/path/to/dest'; + $filter = new WebOptimization($path, array( + 'custom-option' => 'custom-value', + 'resolution-y' => 100, + )); + $capturedOptions = null; + + $image->expects($this->once()) + ->method('usePalette') + ->with($this->isInstanceOf(RGB::class)) + ->will($this->returnValue($image)); + + $image->expects($this->once()) + ->method('strip') + ->will($this->returnValue($image)); + + $image->expects($this->once()) + ->method('save') + ->with($this->equalTo($path), $this->isType('array')) + ->will($this->returnCallback(function ($path, $options) use (&$capturedOptions, $image) { + $capturedOptions = $options; + + return $image; + })); + + $this->assertSame($image, $filter->apply($image)); + + $this->assertCount(4, $capturedOptions); + $this->assertEquals('custom-value', $capturedOptions['custom-option']); + $this->assertEquals(ImageInterface::RESOLUTION_PIXELSPERINCH, $capturedOptions['resolution-units']); + $this->assertEquals(72, $capturedOptions['resolution-x']); + $this->assertEquals(100, $capturedOptions['resolution-y']); + } +} diff --git a/src/Symfony/Component/Image/Tests/Filter/DummyLoaderAwareFilter.php b/src/Symfony/Component/Image/Tests/Filter/DummyLoaderAwareFilter.php new file mode 100644 index 0000000000000..aadc75159864f --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Filter/DummyLoaderAwareFilter.php @@ -0,0 +1,24 @@ +getLoader()->create(new Box(200, 200)); + } +} diff --git a/src/Symfony/Component/Image/Tests/Filter/FilterTestCase.php b/src/Symfony/Component/Image/Tests/Filter/FilterTestCase.php new file mode 100644 index 0000000000000..cdaeb3475696e --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Filter/FilterTestCase.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Filter; + +use Symfony\Component\Image\Draw\DrawerInterface; +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\LoaderInterface; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\Palette\PaletteInterface; +use Symfony\Component\Image\Tests\TestCase; + +abstract class FilterTestCase extends TestCase +{ + protected function getImage() + { + return $this->getMockBuilder(ImageInterface::class)->getMock(); + } + + protected function getLoader() + { + return $this->getMockBuilder(LoaderInterface::class)->getMock(); + } + + protected function getDrawer() + { + return $this->getMockBuilder(DrawerInterface::class)->getMock(); + } + + protected function getPalette() + { + return $this->getMockBuilder(PaletteInterface::class)->getMock(); + } + + protected function getColor() + { + return $this->getMockBuilder(ColorInterface::class)->getMock(); + } +} diff --git a/src/Symfony/Component/Image/Tests/Filter/LoaderAwareTest.php b/src/Symfony/Component/Image/Tests/Filter/LoaderAwareTest.php new file mode 100644 index 0000000000000..6176a1475c2e8 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Filter/LoaderAwareTest.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Filter; + +use Symfony\Component\Image\Filter\Transformation; +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\LoaderInterface; + +/** + * LoaderAwareTest. + */ +class LoaderAwareTest extends FilterTestCase +{ + /** + * Test if filter works when passing Loader instance directly. + */ + public function testFilterWorksWhenPassedLoaderAndCalledDirectly() + { + $loaderMock = $this->getLoaderMock(); + + $filter = new DummyLoaderAwareFilter(); + $filter->setLoader($loaderMock); + $image = $filter->apply($this->getImage()); + + $this->assertInstanceOf(ImageInterface::class, $image); + } + + /** + * Test if filter works when passing Loader instance via + * Transformation. + */ + public function testFilterWorksWhenPassedLoaderViaTransformation() + { + $loaderMock = $this->getLoaderMock(); + + $filters = new Transformation($loaderMock); + $filters->add(new DummyLoaderAwareFilter()); + $image = $filters->apply($this->getImage()); + + $this->assertInstanceOf(ImageInterface::class, $image); + } + + /** + * Test if filter throws exception when called directly without + * passing Loader instance. + * + * @expectedException \Symfony\Component\Image\Exception\InvalidArgumentException + */ + public function testFilterThrowsExceptionWhenCalledDirectly() + { + $filter = new DummyLoaderAwareFilter(); + $filter->apply($this->getImage()); + } + + /** + * Test if filter throws exception via Transformation without + * passing Loader instance. + * + * @expectedException \Symfony\Component\Image\Exception\InvalidArgumentException + */ + public function testFilterThrowsExceptionViaTransformation() + { + $filters = new Transformation(); + $filters->add(new DummyLoaderAwareFilter()); + $filters->apply($this->getImage()); + } + + protected function getLoaderMock() + { + $imagineMock = $this->getMockBuilder(LoaderInterface::class)->getMock(); + $imagineMock->expects($this->once()) + ->method('create') + ->will($this->returnValue($this->getImage())); + + return $imagineMock; + } +} diff --git a/src/Symfony/Component/Image/Tests/Filter/TransformationTest.php b/src/Symfony/Component/Image/Tests/Filter/TransformationTest.php new file mode 100644 index 0000000000000..956ce09b93623 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Filter/TransformationTest.php @@ -0,0 +1,182 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Filter; + +use Symfony\Component\Image\Filter\FilterInterface; +use Symfony\Component\Image\Filter\Transformation; +use Symfony\Component\Image\Image\Box; +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\Point; +use Symfony\Component\Image\Image\ManipulatorInterface; + +class TransformationTest extends FilterTestCase +{ + public function testSimpleStack() + { + $image = $this->getImage(); + $size = new Box(50, 50); + $path = sys_get_temp_dir(); + + $image->expects($this->once()) + ->method('resize') + ->with($size) + ->will($this->returnValue($image)); + + $image->expects($this->once()) + ->method('save') + ->with($path) + ->will($this->returnValue($image)); + + $transformation = new Transformation(); + $this->assertSame($image, $transformation->resize($size) + ->save($path) + ->apply($image) + ); + } + + public function testComplexFlow() + { + $image = $this->getImage(); + $clone = $this->getImage(); + $thumbnail = $this->getImage(); + $path = sys_get_temp_dir(); + $size = new Box(50, 50); + $resize = new Box(200, 200); + $angle = 90; + $background = $this->getPalette()->color('fff'); + + $image->expects($this->once()) + ->method('resize') + ->with($resize) + ->will($this->returnValue($image)); + + $image->expects($this->once()) + ->method('copy') + ->will($this->returnValue($clone)); + + $clone->expects($this->once()) + ->method('rotate') + ->with($angle, $background) + ->will($this->returnValue($clone)); + + $clone->expects($this->once()) + ->method('thumbnail') + ->with($size, ManipulatorInterface::THUMBNAIL_INSET) + ->will($this->returnValue($thumbnail)); + + $thumbnail->expects($this->once()) + ->method('save') + ->with($path) + ->will($this->returnValue($thumbnail)); + + $transformation = new Transformation(); + + $transformation->resize($resize) + ->copy() + ->rotate($angle, $background) + ->thumbnail($size, ManipulatorInterface::THUMBNAIL_INSET) + ->save($path); + + $this->assertSame($thumbnail, $transformation->apply($image)); + } + + public function testCropFlipPasteShow() + { + $img1 = $this->getImage(); + $img2 = $this->getImage(); + $start = new Point(0, 0); + $size = new Box(50, 50); + + $img1->expects($this->once()) + ->method('paste') + ->with($img2, $start) + ->will($this->returnValue($img1)); + + $img1->expects($this->once()) + ->method('show') + ->with('png') + ->will($this->returnValue($img1)); + + $img2->expects($this->once()) + ->method('flipHorizontally') + ->will($this->returnValue($img2)); + + $img2->expects($this->once()) + ->method('flipVertically') + ->will($this->returnValue($img2)); + + $img2->expects($this->once()) + ->method('crop') + ->with($start, $size) + ->will($this->returnValue($img2)); + + $transformation2 = new Transformation(); + $transformation2->flipHorizontally() + ->flipVertically() + ->crop($start, $size); + + $transformation1 = new Transformation(); + $transformation1->paste($transformation2->apply($img2), $start) + ->show('png') + ->apply($img1); + } + + public function testFilterSorting() + { + $filter1 = new TestFilter(); + $filter2 = new TestFilter(); + $filter3 = new TestFilter(); + + $transformation1 = new Transformation(); + $transformation1 + ->add($filter1, 5) + ->add($filter2, -3) + ->add($filter3); + + $expected1 = array( + $filter2, + $filter3, + $filter1, + ); + + $transformation2 = new Transformation(); + $transformation2 + ->add($filter1) + ->add($filter2) + ->add($filter3); + + $expected2 = array( + $filter1, + $filter2, + $filter3, + ); + + $this->assertSame($expected1, $transformation1->getFilters()); + $this->assertSame($expected2, $transformation2->getFilters()); + } + + public function testGetEmptyFilters() + { + $transformation = new Transformation(); + $this->assertSame(array(), $transformation->getFilters()); + } +} + +class TestFilter implements FilterInterface +{ + /** + * {@inheritdoc} + */ + public function apply(ImageInterface $image) + { + } +} diff --git a/src/Symfony/Component/Image/Tests/Fixtures/100-percent-black.png b/src/Symfony/Component/Image/Tests/Fixtures/100-percent-black.png new file mode 100644 index 0000000000000..168f5f726c4ce Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/100-percent-black.png differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/65-percent-black.png b/src/Symfony/Component/Image/Tests/Fixtures/65-percent-black.png new file mode 100644 index 0000000000000..61b0eb6630636 Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/65-percent-black.png differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/anima.gif b/src/Symfony/Component/Image/Tests/Fixtures/anima.gif new file mode 100644 index 0000000000000..5bca0266ffbbf Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/anima.gif differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/anima2.gif b/src/Symfony/Component/Image/Tests/Fixtures/anima2.gif new file mode 100644 index 0000000000000..6af13d882cc6d Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/anima2.gif differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/anima3.gif b/src/Symfony/Component/Image/Tests/Fixtures/anima3.gif new file mode 100644 index 0000000000000..e70888089bf67 Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/anima3.gif differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/blue.gif b/src/Symfony/Component/Image/Tests/Fixtures/blue.gif new file mode 100644 index 0000000000000..5af85c92f4976 Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/blue.gif differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/cat.gif b/src/Symfony/Component/Image/Tests/Fixtures/cat.gif new file mode 100644 index 0000000000000..961fe9686ca4a Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/cat.gif differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/crop/anima3-topleft.gif b/src/Symfony/Component/Image/Tests/Fixtures/crop/anima3-topleft.gif new file mode 100644 index 0000000000000..08abb5a869b62 Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/crop/anima3-topleft.gif differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/example.svg b/src/Symfony/Component/Image/Tests/Fixtures/example.svg new file mode 100644 index 0000000000000..438734c1707c6 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Fixtures/example.svg @@ -0,0 +1,69 @@ + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/src/Symfony/Component/Image/Tests/Fixtures/exifOrientation/90.jpg b/src/Symfony/Component/Image/Tests/Fixtures/exifOrientation/90.jpg new file mode 100644 index 0000000000000..335a69731a775 Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/exifOrientation/90.jpg differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/font/Arial.ttf b/src/Symfony/Component/Image/Tests/Fixtures/font/Arial.ttf new file mode 100644 index 0000000000000..ab68fb197d447 Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/font/Arial.ttf differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/google.png b/src/Symfony/Component/Image/Tests/Fixtures/google.png new file mode 100644 index 0000000000000..763f5627995f6 Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/google.png differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/invalid-icc-profile.jpg b/src/Symfony/Component/Image/Tests/Fixtures/invalid-icc-profile.jpg new file mode 100644 index 0000000000000..924ff27cd38e3 Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/invalid-icc-profile.jpg differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/invalid-image.jpg b/src/Symfony/Component/Image/Tests/Fixtures/invalid-image.jpg new file mode 100644 index 0000000000000..c12adcc160aab --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Fixtures/invalid-image.jpg @@ -0,0 +1 @@ +This file is not a valid image. \ No newline at end of file diff --git a/src/Symfony/Component/Image/Tests/Fixtures/large.jpg b/src/Symfony/Component/Image/Tests/Fixtures/large.jpg new file mode 100644 index 0000000000000..81c47e565bf28 Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/large.jpg differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/pink.gif b/src/Symfony/Component/Image/Tests/Fixtures/pink.gif new file mode 100644 index 0000000000000..3711f47a6894f Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/pink.gif differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/pixel-CMYK.jpg b/src/Symfony/Component/Image/Tests/Fixtures/pixel-CMYK.jpg new file mode 100644 index 0000000000000..4841c149b040b Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/pixel-CMYK.jpg differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/pixel-GBR.jpg b/src/Symfony/Component/Image/Tests/Fixtures/pixel-GBR.jpg new file mode 100644 index 0000000000000..4cc22656a8dae Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/pixel-GBR.jpg differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/pixel-grayscale.jpg b/src/Symfony/Component/Image/Tests/Fixtures/pixel-grayscale.jpg new file mode 100644 index 0000000000000..89aebea6cec53 Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/pixel-grayscale.jpg differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/resize/210-design-19933.jpg b/src/Symfony/Component/Image/Tests/Fixtures/resize/210-design-19933.jpg new file mode 100755 index 0000000000000..bcb9dad990c4b Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/resize/210-design-19933.jpg differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/resize/anima3-150x100.gif b/src/Symfony/Component/Image/Tests/Fixtures/resize/anima3-150x100.gif new file mode 100644 index 0000000000000..e5c52ebb07443 Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/resize/anima3-150x100.gif differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/results/in_out/.placeholder b/src/Symfony/Component/Image/Tests/Fixtures/results/in_out/.placeholder new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/Symfony/Component/Image/Tests/Fixtures/sample.gif b/src/Symfony/Component/Image/Tests/Fixtures/sample.gif new file mode 100644 index 0000000000000..91c686d9f356c Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/sample.gif differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/trans.gif b/src/Symfony/Component/Image/Tests/Fixtures/trans.gif new file mode 100644 index 0000000000000..b58aec91fb3e9 Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/trans.gif differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/trans.png b/src/Symfony/Component/Image/Tests/Fixtures/trans.png new file mode 100644 index 0000000000000..89d9bf1724f29 Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/trans.png differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/xparent.gif b/src/Symfony/Component/Image/Tests/Fixtures/xparent.gif new file mode 100644 index 0000000000000..bed5790521060 Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/xparent.gif differ diff --git a/src/Symfony/Component/Image/Tests/Fixtures/yellow.gif b/src/Symfony/Component/Image/Tests/Fixtures/yellow.gif new file mode 100644 index 0000000000000..56d595b50597b Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Fixtures/yellow.gif differ diff --git a/src/Symfony/Component/Image/Tests/Functional/GdTransparentGifHandlingTest.php b/src/Symfony/Component/Image/Tests/Functional/GdTransparentGifHandlingTest.php new file mode 100644 index 0000000000000..e1faf863ca029 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Functional/GdTransparentGifHandlingTest.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Functional; + +use Symfony\Component\Image\Image\Point; +use Symfony\Component\Image\Gd\Loader; +use Symfony\Component\Image\Exception\RuntimeException; +use Symfony\Component\Image\Tests\TestCase; + +class GdTransparentGifHandlingTest extends TestCase +{ + private function getLoader() + { + try { + $imagine = new Loader(); + } catch (RuntimeException $e) { + $this->markTestSkipped($e->getMessage()); + } + + return $imagine; + } + + public function testShouldResize() + { + $imagine = $this->getLoader(); + $new = sys_get_temp_dir()."/sample.jpeg"; + + $image = $imagine->open(__DIR__.'/../Fixtures/xparent.gif'); + $size = $image->getSize()->scale(0.5); + + $image + ->resize($size) + ; + + $image = $imagine + ->create($size) + ->paste($image, new Point(0, 0)) + ->save($new) + ; + + $this->assertSame(272, $image->getSize()->getWidth()); + $this->assertSame(171, $image->getSize()->getHeight()); + + unlink($new); + } +} diff --git a/src/Symfony/Component/Image/Tests/Gd/DrawerTest.php b/src/Symfony/Component/Image/Tests/Gd/DrawerTest.php new file mode 100644 index 0000000000000..63feeaa14c051 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Gd/DrawerTest.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Gd; + +use Symfony\Component\Image\Gd\Loader; +use Symfony\Component\Image\Tests\Draw\AbstractDrawerTest; + +class DrawerTest extends AbstractDrawerTest +{ + protected function setUp() + { + parent::setUp(); + + if (!function_exists('gd_info')) { + $this->markTestSkipped('Gd not installed'); + } + } + + protected function getLoader() + { + return new Loader(); + } + + protected function isFontTestSupported() + { + $infos = gd_info(); + + return isset($infos['FreeType Support']) ? $infos['FreeType Support'] : false; + } +} diff --git a/src/Symfony/Component/Image/Tests/Gd/EffectsTest.php b/src/Symfony/Component/Image/Tests/Gd/EffectsTest.php new file mode 100644 index 0000000000000..ef00329915a43 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Gd/EffectsTest.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Gd; + +use Symfony\Component\Image\Gd\Loader; +use Symfony\Component\Image\Tests\Effects\AbstractEffectsTest; + +class EffectsTest extends AbstractEffectsTest +{ + protected function setUp() + { + parent::setUp(); + + if (!function_exists('gd_info')) { + $this->markTestSkipped('Gd not installed'); + } + } + + protected function getLoader() + { + return new Loader(); + } +} diff --git a/src/Symfony/Component/Image/Tests/Gd/ImageTest.php b/src/Symfony/Component/Image/Tests/Gd/ImageTest.php new file mode 100644 index 0000000000000..e00387f88ac34 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Gd/ImageTest.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Gd; + +use Symfony\Component\Image\Gd\Loader; +use Symfony\Component\Image\Image\Palette\RGB; +use Symfony\Component\Image\Tests\Image\AbstractImageTest; +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Exception\RuntimeException; + +class ImageTest extends AbstractImageTest +{ + protected function setUp() + { + parent::setUp(); + + if (!function_exists('gd_info')) { + $this->markTestSkipped('Gd not installed'); + } + } + + public function testImageResolutionChange() + { + $this->markTestSkipped('GD driver does not support resolution options'); + } + + public function provideFilters() + { + return array( + array(ImageInterface::FILTER_UNDEFINED), + ); + } + + public function providePalettes() + { + return array( + array(RGB::class, array(255, 0, 0)), + ); + } + + public function provideFromAndToPalettes() + { + return array( + array( + RGB::class, + RGB::class, + array(10, 10, 10), + ), + ); + } + + public function testProfile() + { + try { + parent::testProfile(); + $this->fail('A RuntimeException should have been raised'); + } catch (RuntimeException $e) { + $this->assertSame('GD driver does not support color profiles', $e->getMessage()); + } + } + + public function testPaletteIsGrayIfGrayImage() + { + $this->markTestSkipped('Gd does not support Gray colorspace'); + } + + public function testPaletteIsCMYKIfCMYKImage() + { + $this->markTestSkipped('GD driver does not recognize CMYK images properly'); + } + + public function testGetColorAtCMYK() + { + $this->markTestSkipped('GD driver does not recognize CMYK images properly'); + } + + public function testChangeColorSpaceAndStripImage() + { + $this->markTestSkipped('GD driver does not support ICC profiles'); + } + + public function testStripImageWithInvalidProfile() + { + $this->markTestSkipped('GD driver does not support ICC profiles'); + } + + public function testStripGBRImageHasGoodColors() + { + $this->markTestSkipped('GD driver does not support ICC profiles'); + } + + protected function getLoader() + { + return new Loader(); + } + + protected function supportMultipleLayers() + { + return false; + } + + public function testRotateWithNoBackgroundColor() + { + if (version_compare(PHP_VERSION, '5.5', '>=')) { + // see https://bugs.php.net/bug.php?id=65148 + $this->markTestSkipped('Disabling test while bug #65148 is open'); + } + + parent::testRotateWithNoBackgroundColor(); + } + + /** + * @dataProvider provideVariousSources + */ + public function testResolutionOnSave($source) + { + $this->markTestSkipped('Gd only supports 72 dpi resolution'); + } + + protected function getImageResolution(ImageInterface $image) + { + } +} diff --git a/src/Symfony/Component/Image/Tests/Gd/LayersTest.php b/src/Symfony/Component/Image/Tests/Gd/LayersTest.php new file mode 100644 index 0000000000000..4752d2f0176ce --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Gd/LayersTest.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Gd; + +use Symfony\Component\Image\Gd\Layers; +use Symfony\Component\Image\Gd\Image; +use Symfony\Component\Image\Gd\Loader; +use Symfony\Component\Image\Image\Metadata\MetadataBag; +use Symfony\Component\Image\Image\Palette\PaletteInterface; +use Symfony\Component\Image\Tests\Image\AbstractLayersTest; +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\Palette\RGB; + +class LayersTest extends AbstractLayersTest +{ + protected function setUp() + { + parent::setUp(); + + if (!function_exists('gd_info')) { + $this->markTestSkipped('Gd not installed'); + } + } + + public function testCount() + { + $resource = imagecreate(20, 20); + $palette = $this->getMockBuilder(PaletteInterface::class)->getMock(); + $layers = new Layers(new Image($resource, $palette, new MetadataBag()), $palette, $resource); + + $this->assertCount(1, $layers); + } + + public function testGetLayer() + { + $resource = imagecreate(20, 20); + $palette = $this->getMockBuilder(PaletteInterface::class)->getMock(); + $layers = new Layers(new Image($resource, $palette, new MetadataBag()), $palette, $resource); + + foreach ($layers as $layer) { + $this->assertInstanceOf(ImageInterface::class, $layer); + } + } + + public function testLayerArrayAccess() + { + $image = $this->getImage(__DIR__ . "/../Fixtures/pink.gif"); + $layers = $image->layers(); + + $this->assertLayersEquals($image, $layers[0]); + $this->assertTrue(isset($layers[0])); + } + + public function testLayerAddGetSetRemove() + { + $image = $this->getImage(__DIR__ . "/../Fixtures/pink.gif"); + $layers = $image->layers(); + + $this->assertLayersEquals($image, $layers->get(0)); + $this->assertTrue($layers->has(0)); + } + + public function testLayerArrayAccessInvalidArgumentExceptions($offset = null) + { + $this->markTestSkipped('Gd does not fully support layers array access'); + } + + public function testLayerArrayAccessOutOfBoundsExceptions($offset = null) + { + $this->markTestSkipped('Gd does not fully support layers array access'); + } + + public function testAnimateEmpty() + { + $this->markTestSkipped('Gd does not support animated gifs'); + } + + public function testAnimateLoaded() + { + $this->markTestSkipped('Gd does not support animated gifs'); + } + + /** + * @dataProvider provideAnimationParameters + */ + public function testAnimateWithParameters($delay, $loops) + { + $this->markTestSkipped('Gd does not support animated gifs'); + } + + /** + * @dataProvider provideAnimationParameters + */ + public function testAnimateWithWrongParameters($delay, $loops) + { + $this->markTestSkipped('Gd does not support animated gifs'); + } + + public function getImage($path = null) + { + return new Image(imagecreatetruecolor(10, 10), new RGB(), new MetadataBag()); + } + + public function getLayers(ImageInterface $image, $resource) + { + return new Layers($image, new RGB(), $resource); + } + + public function getLoader() + { + return new Loader(); + } + + protected function assertLayersEquals($expected, $actual) + { + $this->assertEquals($expected->getGdResource(), $actual->getGdResource()); + } +} diff --git a/src/Symfony/Component/Image/Tests/Gd/LoaderTest.php b/src/Symfony/Component/Image/Tests/Gd/LoaderTest.php new file mode 100644 index 0000000000000..aab0bb3e5f24a --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Gd/LoaderTest.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Gd; + +use Symfony\Component\Image\Gd\Loader; +use Symfony\Component\Image\Tests\Image\AbstractLoaderTest; +use Symfony\Component\Image\Image\Box; + +class LoaderTest extends AbstractLoaderTest +{ + protected function setUp() + { + parent::setUp(); + + if (!function_exists('gd_info')) { + $this->markTestSkipped('Gd not installed'); + } + } + + protected function getEstimatedFontBox() + { + if (defined('HHVM_VERSION_ID')) { + return new Box(114, 46); + } + + if (PHP_VERSION_ID >= 70000) { + return new Box(112, 45); + } + + return new Box(112, 46); + } + + protected function getLoader() + { + return new Loader(); + } + + protected function isFontTestSupported() + { + $infos = gd_info(); + + return isset($infos['FreeType Support']) ? $infos['FreeType Support'] : false; + } +} diff --git a/src/Symfony/Component/Image/Tests/Gmagick/DrawerTest.php b/src/Symfony/Component/Image/Tests/Gmagick/DrawerTest.php new file mode 100644 index 0000000000000..329ac5a2d2a1d --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Gmagick/DrawerTest.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Gmagick; + +use Symfony\Component\Image\Gmagick\Loader; +use Symfony\Component\Image\Tests\Draw\AbstractDrawerTest; + +class DrawerTest extends AbstractDrawerTest +{ + protected function setUp() + { + parent::setUp(); + + if (!class_exists('Gmagick')) { + $this->markTestSkipped('Gmagick is not installed'); + } + } + + protected function getLoader() + { + return new Loader(); + } + + protected function isFontTestSupported() + { + return true; + } +} diff --git a/src/Symfony/Component/Image/Tests/Gmagick/EffectsTest.php b/src/Symfony/Component/Image/Tests/Gmagick/EffectsTest.php new file mode 100644 index 0000000000000..302acc894a899 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Gmagick/EffectsTest.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Gmagick; + +use Symfony\Component\Image\Gmagick\Loader; +use Symfony\Component\Image\Tests\Effects\AbstractEffectsTest; + +class EffectsTest extends AbstractEffectsTest +{ + protected function setUp() + { + parent::setUp(); + + if (!class_exists('Gmagick')) { + $this->markTestSkipped('Gmagick is not installed'); + } + } + + public function testColorize() + { + $this->setExpectedException(\RuntimeException::class); + parent::testColorize(); + } + + protected function getLoader() + { + return new Loader(); + } +} diff --git a/src/Symfony/Component/Image/Tests/Gmagick/ImageTest.php b/src/Symfony/Component/Image/Tests/Gmagick/ImageTest.php new file mode 100644 index 0000000000000..9eb4a0924c3c9 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Gmagick/ImageTest.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Gmagick; + +use Symfony\Component\Image\Gmagick\Loader; +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\Palette\CMYK; +use Symfony\Component\Image\Image\Palette\RGB; +use Symfony\Component\Image\Image\Point; +use Symfony\Component\Image\Tests\Image\AbstractImageTest; + +class ImageTest extends AbstractImageTest +{ + protected function setUp() + { + parent::setUp(); + + // disable GC while https://bugs.php.net/bug.php?id=63677 is still open + // If GC enabled, Gmagick unit tests fail + gc_disable(); + + if (!class_exists('Gmagick')) { + $this->markTestSkipped('Gmagick is not installed'); + } + } + + // We redeclare this test because Gmagick does not support alpha + public function testGetColorAt() + { + $color = $this + ->getLoader() + ->open(__DIR__.'/../Fixtures/65-percent-black.png') + ->getColorAt(new Point(0, 0)); + + $this->assertEquals('#000000', (string) $color); + // Gmagick does not supports alpha + $this->assertTrue($color->isOpaque()); + } + + public function provideFromAndToPalettes() + { + return array( + array( + RGB::class, + CMYK::class, + array(10, 10, 10), + ), + array( + CMYK::class, + RGB::class, + array(10, 10, 10, 0), + ), + ); + } + + public function providePalettes() + { + return array( + array(RGB::class, array(255, 0, 0)), + array(CMYK::class, array(10, 0, 0, 0)), + ); + } + + public function testPaletteIsGrayIfGrayImage() + { + $this->markTestSkipped('Gmagick does not support Gray colorspace, because of the lack omg image type support'); + } + + public function testGetColorAtCMYK() + { + $this->markTestSkipped('Gmagick fails to read CMYK colors properly, see https://bugs.php.net/bug.php?id=67435'); + } + + public function testImageCreatedAlpha() + { + $this->markTestSkipped('Alpha transparency is not supported by Gmagick'); + } + + public function testFillAlphaPrecision() + { + $this->markTestSkipped('Alpha transparency is not supported by Gmagick'); + } + + + protected function getLoader() + { + return new Loader(); + } + + protected function supportMultipleLayers() + { + return true; + } + + protected function getImageResolution(ImageInterface $image) + { + return $image->getGmagick()->getimageresolution(); + } +} diff --git a/src/Symfony/Component/Image/Tests/Gmagick/LayersTest.php b/src/Symfony/Component/Image/Tests/Gmagick/LayersTest.php new file mode 100644 index 0000000000000..36f23e4dd3efb --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Gmagick/LayersTest.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Gmagick; + +use Symfony\Component\Image\Gmagick\Layers; +use Symfony\Component\Image\Gmagick\Image; +use Symfony\Component\Image\Gmagick\Loader; +use Symfony\Component\Image\Image\Metadata\MetadataBag; +use Symfony\Component\Image\Tests\Image\AbstractLayersTest; +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\Palette\RGB; + +class LayersTest extends AbstractLayersTest +{ + protected function setUp() + { + parent::setUp(); + + if (!class_exists('Gmagick')) { + $this->markTestSkipped('Gmagick is not installed'); + } + } + + public function testCount() + { + $palette = new RGB(); + $resource = $this->getMockBuilder('\Gmagick')->getMock(); + + $resource->expects($this->once()) + ->method('getnumberimages') + ->will($this->returnValue(42)); + + $layers = new Layers(new Image($resource, $palette, new MetadataBag()), $palette, $resource); + + $this->assertCount(42, $layers); + } + + public function testGetLayer() + { + $palette = new RGB(); + $resource = $this->getMockBuilder('\Gmagick')->getMock(); + + $resource->expects($this->any()) + ->method('getnumberimages') + ->will($this->returnValue(2)); + + $layer = $this->getMockBuilder('\Gmagick')->getMock(); + + $resource->expects($this->any()) + ->method('getimage') + ->will($this->returnValue($layer)); + + $layers = new Layers(new Image($resource, $palette, new MetadataBag()), $palette, $resource); + + foreach ($layers as $layer) { + $this->assertInstanceOf(ImageInterface::class, $layer); + } + } + + public function testAnimateEmpty() + { + $this->markTestSkipped('Animate empty is skipped due to https://bugs.php.net/bug.php?id=62309'); + } + + public function getImage($path = null) + { + if ($path) { + return new Image(new \Gmagick($path), new RGB(), new MetadataBag()); + } else { + return new Image(new \Gmagick(), new RGB(), new MetadataBag()); + } + } + + public function getLoader() + { + return new Loader(); + } + + public function getLayers(ImageInterface $image, $resource) + { + return new Layers($image, $resource, new MetadataBag()); + } + + protected function assertLayersEquals($expected, $actual) + { + $this->assertEquals($expected->getGmagick(), $actual->getGmagick()); + } +} diff --git a/src/Symfony/Component/Image/Tests/Gmagick/LoaderTest.php b/src/Symfony/Component/Image/Tests/Gmagick/LoaderTest.php new file mode 100644 index 0000000000000..c21264af08a89 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Gmagick/LoaderTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Gmagick; + +use Symfony\Component\Image\Gmagick\Loader; +use Symfony\Component\Image\Tests\Image\AbstractLoaderTest; +use Symfony\Component\Image\Image\Box; + +class LoaderTest extends AbstractLoaderTest +{ + protected function setUp() + { + parent::setUp(); + + if (!class_exists('Gmagick')) { + $this->markTestSkipped('Gmagick is not installed'); + } + } + + public function testCreateAlphaPrecision() + { + $this->markTestSkipped('Alpha transparency is not supported by Gmagick'); + } + + protected function getEstimatedFontBox() + { + return new Box(117, 55); + } + + protected function getLoader() + { + return new Loader(); + } + + protected function isFontTestSupported() + { + return true; + } +} diff --git a/src/Symfony/Component/Image/Tests/Image/AbstractImageTest.php b/src/Symfony/Component/Image/Tests/Image/AbstractImageTest.php new file mode 100644 index 0000000000000..e2b915563ad3c --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/AbstractImageTest.php @@ -0,0 +1,817 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image; + +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Exception\RuntimeException; +use Symfony\Component\Image\Image\Box; +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\LayersInterface; +use Symfony\Component\Image\Image\Metadata\MetadataBag; +use Symfony\Component\Image\Image\Palette\CMYK; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\Palette\Grayscale; +use Symfony\Component\Image\Image\Point; +use Symfony\Component\Image\Image\Fill\Gradient\Horizontal; +use Symfony\Component\Image\Image\Point\Center; +use Symfony\Component\Image\Tests\TestCase; +use Symfony\Component\Image\Image\Palette\RGB; +use Symfony\Component\Image\Image\Profile; +use Symfony\Component\Image\Imagick\Image as ImagickImage; +use Symfony\Component\Image\Gmagick\Image as GmagickImage; +use Symfony\Component\Image\Imagick\Loader as ImagickLoader; +use Symfony\Component\Image\Gmagick\Loader as GmagickLoader; + +abstract class AbstractImageTest extends TestCase +{ + public function testPaletteIsRGBIfRGBImage() + { + $image = $this->getLoader()->open(__DIR__ . '/../Fixtures/google.png'); + $this->assertInstanceOf(RGB::class, $image->palette()); + } + + public function testPaletteIsCMYKIfCMYKImage() + { + $image = $this->getLoader()->open(__DIR__ . '/../Fixtures/pixel-CMYK.jpg'); + $this->assertInstanceOf(CMYK::class, $image->palette()); + } + + public function testPaletteIsGrayIfGrayImage() + { + $image = $this->getLoader()->open(__DIR__ . '/../Fixtures/pixel-grayscale.jpg'); + $this->assertInstanceOf(Grayscale::class, $image->palette()); + } + + public function testDefaultPaletteCreationIsRGB() + { + $image = $this->getLoader()->create(new Box(10, 10)); + $this->assertInstanceOf(RGB::class, $image->palette()); + } + + /** + * @dataProvider providePalettes + */ + public function testPaletteAssociatedIsRelatedToGivenColor($paletteClass, $input) + { + $palette = new $paletteClass(); + + $image = $this + ->getLoader() + ->create(new Box(10, 10), $palette->color($input)); + + $this->assertEquals($palette, $image->palette()); + } + + public function providePalettes() + { + return array( + array(RGB::class, array(255, 0, 0)), + array(CMYK::class, array(10, 0, 0, 0)), + array(Grayscale::class, array(25)), + ); + } + + /** + * @dataProvider provideFromAndToPalettes + */ + public function testUsePalette($from, $to, $color) + { + $palette = new $from(); + + $image = $this + ->getLoader() + ->create(new Box(10, 10), $palette->color($color)); + + $targetPalette = new $to(); + + $image->usePalette($targetPalette); + + $this->assertEquals($targetPalette, $image->palette()); + $image->save(__DIR__ . '/tmp.jpg'); + + $image = $this->getLoader()->open(__DIR__ . '/tmp.jpg'); + + $this->assertInstanceOf($to, $image->palette()); + unlink(__DIR__ . '/tmp.jpg'); + } + + public function testSaveWithoutFormatShouldSaveInOriginalFormat() + { + if (!extension_loaded('exif')) { + $this->markTestSkipped('The EXIF extension is required for this test'); + } + + $tmpFile = __DIR__ . '/tmpfile'; + + $this + ->getLoader() + ->open(__DIR__ . '/../Fixtures/large.jpg') + ->save($tmpFile); + + $data = exif_read_data($tmpFile); + $this->assertEquals('image/jpeg', $data['MimeType']); + unlink($tmpFile); + } + + public function testSaveWithoutPathFileFromImageLoadShouldBeOkay() + { + $source = __DIR__ . '/../Fixtures/google.png'; + $tmpFile = __DIR__ . '/../Fixtures/google.tmp.png'; + + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + + copy($source, $tmpFile); + + $this->assertEquals(md5_file($source), md5_file($tmpFile)); + + $this + ->getLoader() + ->open($tmpFile) + ->resize(new Box(20, 20)) + ->save(); + + $this->assertNotEquals(md5_file($source), md5_file($tmpFile)); + unlink($tmpFile); + } + + public function testSaveWithoutPathFileFromImageCreationShouldFail() + { + $image = $this->getLoader()->create(new Box(20, 20)); + $this->setExpectedException(RuntimeException::class); + $image->save(); + } + + public function provideFromAndToPalettes() + { + return array( + array( + RGB::class, + CMYK::class, + array(10, 10, 10), + ), + array( + RGB::class, + Grayscale::class, + array(10, 10, 10), + ), + array( + CMYK::class, + RGB::class, + array(10, 10, 10, 0), + ), + array( + CMYK::class, + Grayscale::class, + array(10, 10, 10, 0), + ), + array( + Grayscale::class, + RGB::class, + array(10), + ), + array( + Grayscale::class, + CMYK::class, + array(10), + ), + ); + } + + public function testProfile() + { + $image = $this + ->getLoader() + ->create(new Box(10, 10)) + ->profile(Profile::fromPath(__DIR__ . '/../../Resources/Adobe/RGB/VideoHD.icc')); + + $color = $image->getColorAt(new Point(0, 0)); + + $this->assertInstanceOf(RGB::class, $color->getPalette()); + $this->assertSame(255, $color->getValue(ColorInterface::COLOR_RED)); + $this->assertSame(255, $color->getValue(ColorInterface::COLOR_GREEN)); + $this->assertSame(255, $color->getValue(ColorInterface::COLOR_BLUE)); + $this->assertSame(100, $color->getAlpha()); + } + + public function testRotateWithNoBackgroundColor() + { + $factory = $this->getLoader(); + + $image = $factory->open(__DIR__.'/../Fixtures/google.png'); + $image->rotate(90); + + $size = $image->getSize(); + + $this->assertSame(126, $size->getWidth()); + $this->assertSame(364, $size->getHeight()); + } + + public function testCopyResizedImageToImage() + { + $factory = $this->getLoader(); + + $image = $factory->open(__DIR__.'/../Fixtures/google.png'); + $size = $image->getSize(); + + $image = $image->paste( + $image->copy() + ->resize($size->scale(0.5)) + ->flipVertically(), + new Center($size) + ); + + $this->assertSame(364, $image->getSize()->getWidth()); + $this->assertSame(126, $image->getSize()->getHeight()); + } + + /** + * @dataProvider provideFilters + */ + public function testResizeWithVariousFilters($filter) + { + $factory = $this->getLoader(); + $image = $factory->open(__DIR__.'/../Fixtures/google.png'); + + $image = $image->resize(new Box(30, 30), $filter); + + $this->assertSame(30, $image->getSize()->getWidth()); + $this->assertSame(30, $image->getSize()->getHeight()); + } + + public function testResizeWithInvalidFilter() + { + $factory = $this->getLoader(); + $image = $factory->open(__DIR__.'/../Fixtures/google.png'); + + $this->setExpectedException(InvalidArgumentException::class); + $image->resize(new Box(30, 30), 'no filter'); + } + + public function provideFilters() + { + return array( + array(ImageInterface::FILTER_UNDEFINED), + array(ImageInterface::FILTER_POINT), + array(ImageInterface::FILTER_BOX), + array(ImageInterface::FILTER_TRIANGLE), + array(ImageInterface::FILTER_HERMITE), + array(ImageInterface::FILTER_HANNING), + array(ImageInterface::FILTER_HAMMING), + array(ImageInterface::FILTER_BLACKMAN), + array(ImageInterface::FILTER_GAUSSIAN), + array(ImageInterface::FILTER_QUADRATIC), + array(ImageInterface::FILTER_CUBIC), + array(ImageInterface::FILTER_CATROM), + array(ImageInterface::FILTER_MITCHELL), + array(ImageInterface::FILTER_LANCZOS), + array(ImageInterface::FILTER_BESSEL), + array(ImageInterface::FILTER_SINC), + ); + } + + public function testThumbnailShouldReturnACopy() + { + $factory = $this->getLoader(); + + $image = $factory->open(__DIR__.'/../Fixtures/google.png'); + $thumbnail = $image->thumbnail(new Box(20, 20)); + + $this->assertNotSame($image, $thumbnail); + } + + public function testThumbnailWithInvalidModeShouldThrowAnException() + { + $factory = $this->getLoader(); + $image = $factory->open(__DIR__.'/../Fixtures/google.png'); + $this->setExpectedException(InvalidArgumentException::class, 'Invalid mode specified'); + $image->thumbnail(new Box(20, 20), "boumboum"); + } + + public function testResizeShouldReturnTheImage() + { + $factory = $this->getLoader(); + + $image = $factory->open(__DIR__.'/../Fixtures/google.png'); + $resized = $image->resize(new Box(20, 20)); + + $this->assertSame($image, $resized); + } + + /** + * @dataProvider provideDimensionsAndModesForThumbnailGeneration + */ + public function testThumbnailGeneration($sourceW, $sourceH, $thumbW, $thumbH, $mode, $expectedW, $expectedH) + { + $factory = $this->getLoader(); + $image = $factory->create(new Box($sourceW, $sourceH)); + $inset = $image->thumbnail(new Box($thumbW, $thumbH), $mode); + + $size = $inset->getSize(); + + $this->assertEquals($expectedW, $size->getWidth()); + $this->assertEquals($expectedH, $size->getHeight()); + } + + public function provideDimensionsAndModesForThumbnailGeneration() + { + return array( + // landscape with smaller portrait + array(320, 240, 32, 48, ImageInterface::THUMBNAIL_INSET, 32, round(32 * 240 / 320)), + array(320, 240, 32, 48, ImageInterface::THUMBNAIL_OUTBOUND, 32, 48), + // landscape with smaller landscape + array(320, 240, 32, 16, ImageInterface::THUMBNAIL_INSET, round(16 * 320 / 240), 16), + array(320, 240, 32, 16, ImageInterface::THUMBNAIL_OUTBOUND, 32, 16), + + // portait with smaller portrait + array(240, 320, 24, 48, ImageInterface::THUMBNAIL_INSET, 24, round(24 * 320 / 240)), + array(240, 320, 24, 48, ImageInterface::THUMBNAIL_OUTBOUND, 24, 48), + // portait with smaller landscape + array(240, 320, 24, 16, ImageInterface::THUMBNAIL_INSET, round(16 * 240 / 320), 16), + array(240, 320, 24, 16, ImageInterface::THUMBNAIL_OUTBOUND, 24, 16), + + // landscape with larger portrait + array(32, 24, 320, 300, ImageInterface::THUMBNAIL_INSET, 32, 24), + array(32, 24, 320, 300, ImageInterface::THUMBNAIL_OUTBOUND, 32, 24), + // landscape with larger landscape + array(32, 24, 320, 200, ImageInterface::THUMBNAIL_INSET, 32, 24), + array(32, 24, 320, 200, ImageInterface::THUMBNAIL_OUTBOUND, 32, 24), + + // portait with larger portrait + array(24, 32, 240, 300, ImageInterface::THUMBNAIL_INSET, 24, 32), + array(24, 32, 240, 300, ImageInterface::THUMBNAIL_OUTBOUND, 24, 32), + // portait with larger landscape + array(24, 32, 240, 400, ImageInterface::THUMBNAIL_INSET, 24, 32), + array(24, 32, 240, 400, ImageInterface::THUMBNAIL_OUTBOUND, 24, 32), + + // landscape with intersect portrait + array(320, 240, 340, 220, ImageInterface::THUMBNAIL_INSET, round(220 * 320 / 240), 220), + array(320, 240, 340, 220, ImageInterface::THUMBNAIL_OUTBOUND, 320, 220), + // landscape with intersect portrait + array(320, 240, 300, 360, ImageInterface::THUMBNAIL_INSET, 300, round(300 / 320 * 240)), + array(320, 240, 300, 360, ImageInterface::THUMBNAIL_OUTBOUND, 300, 240), + ); + } + + public function testThumbnailGenerationToDimensionsLergestThanSource() + { + $test_image = __DIR__.'/../Fixtures/google.png'; + $test_image_width = 364; + $test_image_height = 126; + $width = $test_image_width + 1; + $height = $test_image_height + 1; + + $factory = $this->getLoader(); + $image = $factory->open($test_image); + $size = $image->getSize(); + + $this->assertEquals($test_image_width, $size->getWidth()); + $this->assertEquals($test_image_height, $size->getHeight()); + + $inset = $image->thumbnail(new Box($width, $height), ImageInterface::THUMBNAIL_INSET); + $size = $inset->getSize(); + unset($inset); + + $this->assertEquals($test_image_width, $size->getWidth()); + $this->assertEquals($test_image_height, $size->getHeight()); + + $outbound = $image->thumbnail(new Box($width, $height), ImageInterface::THUMBNAIL_OUTBOUND); + $size = $outbound->getSize(); + unset($outbound); + unset($image); + + $this->assertEquals($test_image_width, $size->getWidth()); + $this->assertEquals($test_image_height, $size->getHeight()); + } + + public function testCropResizeFlip() + { + $factory = $this->getLoader(); + + $image = $factory->open(__DIR__.'/../Fixtures/google.png') + ->crop(new Point(0, 0), new Box(126, 126)) + ->resize(new Box(200, 200)) + ->flipHorizontally(); + + $size = $image->getSize(); + + unset($image); + + $this->assertEquals(200, $size->getWidth()); + $this->assertEquals(200, $size->getHeight()); + } + + public function testCreateAndSaveEmptyImage() + { + $factory = $this->getLoader(); + + $palette = new RGB(); + + $image = $factory->create(new Box(400, 300), $palette->color('000')); + + $size = $image->getSize(); + + unset($image); + + $this->assertEquals(400, $size->getWidth()); + $this->assertEquals(300, $size->getHeight()); + } + + public function testCreateTransparentGradient() + { + $factory = $this->getLoader(); + + $palette = new RGB(); + + $size = new Box(100, 50); + $image = $factory->create($size, $palette->color('f00')); + + $image->paste( + $factory->create($size, $palette->color('ff0')) + ->applyMask( + $factory->create($size) + ->fill( + new Horizontal( + $image->getSize()->getWidth(), + $palette->color('fff'), + $palette->color('000') + ) + ) + ), + new Point(0, 0) + ); + + $size = $image->getSize(); + + unset($image); + + $this->assertEquals(100, $size->getWidth()); + $this->assertEquals(50, $size->getHeight()); + } + + public function testMask() + { + $factory = $this->getLoader(); + + $image = $factory->open(__DIR__.'/../Fixtures/google.png'); + + $image->applyMask($image->mask()) + ->save(__DIR__.'/../Fixtures/mask.png'); + + $size = $factory->open(__DIR__.'/../Fixtures/mask.png') + ->getSize(); + + $this->assertEquals(364, $size->getWidth()); + $this->assertEquals(126, $size->getHeight()); + + unlink(__DIR__.'/../Fixtures/mask.png'); + } + + public function testColorHistogram() + { + $factory = $this->getLoader(); + + $image = $factory->open(__DIR__.'/../Fixtures/google.png'); + + $this->assertEquals(6438, count($image->histogram())); + } + + public function testImageResolutionChange() + { + $imagine = $this->getLoader(); + $image = $imagine->open(__DIR__.'/../Fixtures/resize/210-design-19933.jpg'); + $outfile = __DIR__.'/../Fixtures/resize/reduced.jpg'; + $image->save($outfile, array( + 'resolution-units' => ImageInterface::RESOLUTION_PIXELSPERINCH, + 'resolution-x' => 144, + 'resolution-y' => 144 + )); + + if ($imagine instanceof ImagickLoader) { + $i = new \Imagick($outfile); + $info = $i->identifyimage(); + $this->assertEquals(144, $info['resolution']['x']); + $this->assertEquals(144, $info['resolution']['y']); + } + if ($imagine instanceof GmagickLoader) { + $i = new \Gmagick($outfile); + $info = $i->getimageresolution(); + $this->assertEquals(144, $info['x']); + $this->assertEquals(144, $info['y']); + } + + unlink($outfile); + } + + public function testInOutResult() + { + $this->processInOut("trans", "png","png"); + $this->processInOut("trans", "png","gif"); + $this->processInOut("trans", "png","jpg"); + $this->processInOut("anima", "gif","png"); + $this->processInOut("anima", "gif","gif"); + $this->processInOut("anima", "gif","jpg"); + $this->processInOut("trans", "gif","png"); + $this->processInOut("trans", "gif","gif"); + $this->processInOut("trans", "gif","jpg"); + $this->processInOut("large", "jpg","png"); + $this->processInOut("large", "jpg","gif"); + $this->processInOut("large", "jpg","jpg"); + } + + public function testLayerReturnsALayerInterface() + { + $factory = $this->getLoader(); + + $image = $factory->open(__DIR__.'/../Fixtures/google.png'); + + $this->assertInstanceOf(LayersInterface::class, $image->layers()); + } + + public function testCountAMonoLayeredImage() + { + $this->assertEquals(1, count($this->getMonoLayeredImage()->layers())); + } + + public function testCountAMultiLayeredImage() + { + if (!$this->supportMultipleLayers()) { + $this->markTestSkipped('This driver does not support multiple layers'); + } + + $this->assertGreaterThan(1, count($this->getMultiLayeredImage()->layers())); + } + + public function testLayerOnMonoLayeredImage() + { + foreach ($this->getMonoLayeredImage()->layers() as $layer) { + $this->assertInstanceOf(ImageInterface::class, $layer); + $this->assertCount(1, $layer->layers()); + } + } + + public function testLayerOnMultiLayeredImage() + { + foreach ($this->getMultiLayeredImage()->layers() as $layer) { + $this->assertInstanceOf(ImageInterface::class, $layer); + $this->assertCount(1, $layer->layers()); + } + } + + public function testChangeColorSpaceAndStripImage() + { + $color = $this + ->getLoader() + ->open(__DIR__.'/../Fixtures/pixel-CMYK.jpg') + ->usePalette(new RGB()) + ->strip() + ->getColorAt(new Point(0, 0)); + + $this->assertEquals('#0082a2', (string) $color); + } + + public function testStripImageWithInvalidProfile() + { + $image = $this + ->getLoader() + ->open(__DIR__.'/../Fixtures/invalid-icc-profile.jpg'); + + $color = $image->getColorAt(new Point(0, 0)); + $image->strip(); + $afterColor = $image->getColorAt(new Point(0, 0)); + + $this->assertEquals((string) $color, (string) $afterColor); + } + + public function testGetColorAt() + { + $color = $this + ->getLoader() + ->open(__DIR__.'/../Fixtures/65-percent-black.png') + ->getColorAt(new Point(0, 0)); + + $this->assertEquals('#000000', (string) $color); + $this->assertFalse($color->isOpaque()); + $this->assertEquals('65', $color->getAlpha()); + } + + public function testGetColorAtGrayScale() + { + $color = $this + ->getLoader() + ->open(__DIR__.'/../Fixtures/pixel-grayscale.jpg') + ->getColorAt(new Point(0, 0)); + + $this->assertEquals('#4d4d4d', (string) $color); + $this->assertTrue($color->isOpaque()); + } + + public function testGetColorAtCMYK() + { + $color = $this + ->getLoader() + ->open(__DIR__.'/../Fixtures/pixel-CMYK.jpg') + ->getColorAt(new Point(0, 0)); + + $this->assertEquals('cmyk(98%, 0%, 30%, 23%)', (string) $color); + $this->assertTrue($color->isOpaque()); + } + + public function testGetColorAtOpaque() + { + $color = $this + ->getLoader() + ->open(__DIR__.'/../Fixtures/100-percent-black.png') + ->getColorAt(new Point(0, 0)); + + $this->assertEquals('#000000', (string) $color); + $this->assertTrue($color->isOpaque()); + + $this->assertSame(0, $color->getRed()); + $this->assertSame(0, $color->getGreen()); + $this->assertSame(0, $color->getBlue()); + } + + public function testStripGBRImageHasGoodColors() + { + $color = $this + ->getLoader() + ->open(__DIR__.'/../Fixtures/pixel-GBR.jpg') + ->strip() + ->getColorAt(new Point(0, 0)); + + $this->assertEquals('#d07560', (string) $color); + } + + // Test whether a simple action such as resizing a GIF works + // Using the original animated GIF and a slightly more complex one as reference + // anima2.gif courtesy of Cyndi Norrie (http://cyndipop.tumblr.com/) via 15 Folds (http://15folds.com) + public function testResizeAnimatedGifResizeResult() + { + if (!$this->supportMultipleLayers()) { + $this->markTestSkipped('This driver does not support multiple layers'); + } + + $imagine = $this->getLoader(); + + $image = $imagine->open(__DIR__.'/../Fixtures/anima.gif'); + + // Imagick requires the images to be coalesced first! + if ($image instanceof ImagickImage) { + $image->layers()->coalesce(); + } + + foreach ($image->layers() as $frame) { + $frame->resize(new Box(121, 124)); + } + + $image->save(__DIR__.'/../Fixtures/results/anima-half-size.gif', array('animated' => true)); + @unlink(__DIR__.'/../Fixtures/results/anima-half-size.gif'); + + $image = $imagine->open(__DIR__.'/../Fixtures/anima2.gif'); + + // Imagick requires the images to be coalesced first! + if ($image instanceof ImagickImage) { + $image->layers()->coalesce(); + } + + foreach ($image->layers() as $frame) { + $frame->resize(new Box(200, 144)); + } + + $target = __DIR__.'/../Fixtures/results/anima2-half-size.gif'; + $image->save($target, array('animated' => true)); + + $this->assertFileExists($target); + + @unlink($target); + } + + public function testMetadataReturnsMetadataInstance() + { + $this->assertInstanceOf(MetadataBag::class, $this->getMonoLayeredImage()->metadata()); + } + + public function testCloningImageResultsInNewMetadataInstance() + { + $image = $this->getMonoLayeredImage(); + $originalMetadata = $image->metadata(); + $clone = clone $image; + $this->assertNotSame($originalMetadata, $clone->metadata(), 'The image\'s metadata is the same after cloning the image, but must be a new instance.'); + } + + public function testImageSizeOnAnimatedGif() + { + $imagine = $this->getLoader(); + + $image = $imagine->open(__DIR__.'/../Fixtures/anima3.gif'); + + $size = $image->getSize(); + + $this->assertEquals(300, $size->getWidth()); + $this->assertEquals(200, $size->getHeight()); + } + + /** + * @dataProvider provideVariousSources + */ + public function testResolutionOnSave($source) + { + $file = __DIR__ . '/test-resolution.jpg'; + + $image = $this->getLoader()->open($source); + $image->save($file, array( + 'resolution-units' => ImageInterface::RESOLUTION_PIXELSPERINCH, + 'resolution-x' => 150, + 'resolution-y' => 120, + 'resampling-filter' => ImageInterface::FILTER_LANCZOS, + )); + + $saved = $this->getLoader()->open($file); + $this->assertEquals(array('x' => 150, 'y' => 120), $this->getImageResolution($saved)); + unlink($file); + } + + public function provideVariousSources() + { + return array( + array(__DIR__.'/../Fixtures/example.svg'), + array(__DIR__.'/../Fixtures/100-percent-black.png'), + ); + } + + public function testFillAlphaPrecision() + { + $imagine = $this->getLoader(); + $palette = new RGB(); + $image = $imagine->create(new Box(1, 1), $palette->color("#f00")); + $fill = new Horizontal(100, $palette->color("#f00", 17), $palette->color("#f00", 73)); + $image->fill($fill); + + $actualColor = $image->getColorAt(new Point(0, 0)); + $this->assertEquals(17, $actualColor->getAlpha()); + } + + public function testImageCreatedAlpha() + { + $palette = new RGB(); + $image = $this->getLoader()->create(new Box(1, 1), $palette->color("#7f7f7f", 10)); + $actualColor = $image->getColorAt(new Point(0, 0)); + + $this->assertEquals("#7f7f7f", (string) $actualColor); + $this->assertEquals(10, $actualColor->getAlpha()); + } + + abstract protected function getImageResolution(ImageInterface $image); + + private function getMonoLayeredImage() + { + return $this->getLoader()->open(__DIR__.'/../Fixtures/google.png'); + } + + private function getMultiLayeredImage() + { + return $this->getLoader()->open(__DIR__.'/../Fixtures/cat.gif'); + } + + private function getInconsistentMultiLayeredImage() + { + return $this->getLoader()->open(__DIR__.'/../Fixtures/anima.gif'); + } + + protected function processInOut($file, $in, $out) + { + $factory = $this->getLoader(); + $class = preg_replace('/\\\\/', "_", get_called_class()); + $image = $factory->open(__DIR__.'/../Fixtures/'.$file.'.'.$in); + $thumb = $image->thumbnail(new Box(50, 50), ImageInterface::THUMBNAIL_OUTBOUND); + if (!is_dir(__DIR__.'/../Fixtures/results/in_out')) { + mkdir(__DIR__.'/../Fixtures/results/in_out', 0777, true); + } + $target = __DIR__."/../Fixtures/results/in_out/{$class}_{$file}_from_{$in}_to.{$out}"; + $thumb->save($target); + + $this->assertFileExists($target); + unlink($target); + } + + /** + * @return \Symfony\Component\Image\Image\LoaderInterface + */ + abstract protected function getLoader(); + + /** + * @return boolean + */ + abstract protected function supportMultipleLayers(); +} diff --git a/src/Symfony/Component/Image/Tests/Image/AbstractLayersTest.php b/src/Symfony/Component/Image/Tests/Image/AbstractLayersTest.php new file mode 100644 index 0000000000000..2a4f49b561a6d --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/AbstractLayersTest.php @@ -0,0 +1,282 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image; + +use Symfony\Component\Image\Image\Box; +use Symfony\Component\Image\Image\Point; +use Symfony\Component\Image\Image\Palette\RGB; +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Exception\OutOfBoundsException; +use Symfony\Component\Image\Image\LoaderInterface; +use Symfony\Component\Image\Tests\TestCase; + +abstract class AbstractLayersTest extends TestCase +{ + public function testMerge() + { + $palette = new RGB(); + $image = $this->getLoader()->create(new Box(20, 20), $palette->color('#FFFFFF')); + foreach ($image->layers() as $layer) { + $layer + ->draw() + ->polygon(array(new Point(0, 0),new Point(0, 20),new Point(20, 20),new Point(20, 0)), $palette->color('#FF0000'), true); + } + $image->layers()->merge(); + + $this->assertEquals('#ff0000', (string) $image->getColorAt(new Point(5,5))); + } + + public function testLayerArrayAccess() + { + if (defined('HHVM_VERSION_ID')) { + $this->markTestSkipped(sprintf('%s is not supported on HHVM', __METHOD__)); + } + + $firstImage = $this->getImage(__DIR__ . "/../Fixtures/pink.gif"); + $secondImage = $this->getImage(__DIR__ . "/../Fixtures/yellow.gif"); + $thirdImage = $this->getImage(__DIR__ . "/../Fixtures/blue.gif"); + + $layers = $firstImage->layers(); + + $this->assertCount(1, $layers); + + $layers[] = $secondImage; + + $this->assertCount(2, $layers); + $this->assertLayersEquals($firstImage, $layers[0]); + $this->assertLayersEquals($secondImage, $layers[1]); + + $layers[1] = $thirdImage; + + $this->assertCount(2, $layers); + $this->assertLayersEquals($firstImage, $layers[0]); + $this->assertLayersEquals($thirdImage, $layers[1]); + + $layers[] = $secondImage; + + $this->assertCount(3, $layers); + $this->assertLayersEquals($firstImage, $layers[0]); + $this->assertLayersEquals($thirdImage, $layers[1]); + $this->assertLayersEquals($secondImage, $layers[2]); + + $this->assertTrue(isset($layers[2])); + $this->assertTrue(isset($layers[1])); + $this->assertTrue(isset($layers[0])); + + unset($layers[1]); + + $this->assertCount(2, $layers); + $this->assertLayersEquals($firstImage, $layers[0]); + $this->assertLayersEquals($secondImage, $layers[1]); + + $this->assertFalse(isset($layers[2])); + $this->assertTrue(isset($layers[1])); + $this->assertTrue(isset($layers[0])); + } + + public function testLayerAddGetSetRemove() + { + if (defined('HHVM_VERSION_ID')) { + $this->markTestSkipped(sprintf('%s is not supported on HHVM', __METHOD__)); + } + + $firstImage = $this->getImage(__DIR__ . "/../Fixtures/pink.gif"); + $secondImage = $this->getImage(__DIR__ . "/../Fixtures/yellow.gif"); + $thirdImage = $this->getImage(__DIR__ . "/../Fixtures/blue.gif"); + + $layers = $firstImage->layers(); + + $this->assertCount(1, $layers); + + $layers->add($secondImage); + + $this->assertCount(2, $layers); + $this->assertLayersEquals($firstImage, $layers->get(0)); + $this->assertLayersEquals($secondImage, $layers->get(1)); + + $layers->set(1, $thirdImage); + + $this->assertCount(2, $layers); + $this->assertLayersEquals($firstImage, $layers->get(0)); + $this->assertLayersEquals($thirdImage, $layers->get(1)); + + $layers->add($secondImage); + + $this->assertCount(3, $layers); + $this->assertLayersEquals($firstImage, $layers->get(0)); + $this->assertLayersEquals($thirdImage, $layers->get(1)); + $this->assertLayersEquals($secondImage, $layers->get(2)); + + $this->assertTrue($layers->has(2)); + $this->assertTrue($layers->has(1)); + $this->assertTrue($layers->has(0)); + + $layers->remove(1); + + $this->assertCount(2, $layers); + $this->assertLayersEquals($firstImage, $layers->get(0)); + $this->assertLayersEquals($secondImage, $layers->get(1)); + + $this->assertFalse($layers->has(2)); + $this->assertTrue($layers->has(1)); + $this->assertTrue($layers->has(0)); + } + + /** + * @dataProvider provideInvalidArguments + */ + public function testLayerArrayAccessInvalidArgumentExceptions($offset) + { + $firstImage = $this->getImage(__DIR__ . "/../Fixtures/pink.gif"); + $secondImage = $this->getImage(__DIR__ . "/../Fixtures/pink.gif"); + + $layers = $firstImage->layers(); + + try { + $layers[$offset] = $secondImage; + $this->fail('An exception should have been raised'); + } catch (InvalidArgumentException $e) { + $this->assertSame('Invalid offset for layer, it must be an integer', $e->getMessage()); + } + } + + /** + * @dataProvider provideOutOfBoundsArguments + */ + public function testLayerArrayAccessOutOfBoundsExceptions($offset) + { + $firstImage = $this->getImage(__DIR__ . "/../Fixtures/pink.gif"); + $secondImage = $this->getImage(__DIR__ . "/../Fixtures/pink.gif"); + + $layers = $firstImage->layers(); + + try { + $layers[$offset] = $secondImage; + $this->fail('An exception should have been raised'); + } catch (OutOfBoundsException $e) { + $this->assertSame(sprintf('Invalid offset for layer, it must be a value between 0 and 1, %s given', $offset), $e->getMessage()); + } + } + + public function testAnimateEmpty() + { + $image = $this->getImage(); + $layers = $image->layers(); + + $layers[] = $this->getImage(__DIR__ . "/../Fixtures/pink.gif"); + $layers[] = $this->getImage(__DIR__ . "/../Fixtures/yellow.gif"); + $layers[] = $this->getImage(__DIR__ . "/../Fixtures/blue.gif"); + + $target = __DIR__ . '/../Fixtures/temporary-gif.gif'; + + $image->save($target, array( + 'animated' => true, + )); + + $this->assertFileExists($target); + + @unlink($target); + } + + /** + * @dataProvider provideAnimationParameters + */ + public function testAnimateWithParameters($delay, $loops) + { + $image = $this->getImage(__DIR__ . "/../Fixtures/pink.gif"); + $layers = $image->layers(); + + $layers[] = $this->getImage(__DIR__ . "/../Fixtures/yellow.gif"); + $layers[] = $this->getImage(__DIR__ . "/../Fixtures/blue.gif"); + + $target = __DIR__ . '/../Fixtures/temporary-gif.gif'; + + $image->save($target, array( + 'animated' => true, + 'animated.delay' => $delay, + 'animated.loops' => $loops, + )); + + $this->assertFileExists($target); + + @unlink($target); + } + + public function provideAnimationParameters() + { + return array( + array(0, 0), + array(500, 0), + array(0, 10), + array(5000, 10), + ); + } + + /** + * @expectedException \Symfony\Component\Image\Exception\InvalidArgumentException + * @dataProvider provideWrongAnimationParameters + */ + public function testAnimateWithWrongParameters($delay, $loops) + { + $image = $this->getImage(__DIR__ . "/../Fixtures/pink.gif"); + $layers = $image->layers(); + + $layers[] = $this->getImage(__DIR__ . "/../Fixtures/yellow.gif"); + $layers[] = $this->getImage(__DIR__ . "/../Fixtures/blue.gif"); + + $target = __DIR__ . '/../Fixtures/temporary-gif.gif'; + + $image->save($target, array( + 'animated' => true, + 'animated.delay' => $delay, + 'animated.loops' => $loops, + )); + + @unlink($target); + } + + public function provideWrongAnimationParameters() + { + return array( + array(-1, 0), + array(500, -1), + array(-1, 10), + array(0, -1), + ); + } + + public function provideInvalidArguments() + { + return array( + array('lambda'), + array('0'), + array('1'), + array(1.0), + ); + } + + public function provideOutOfBoundsArguments() + { + return array( + array(-1), + array(2), + ); + } + + abstract protected function getImage($path = null); + + /** + * @return LoaderInterface + */ + abstract protected function getLoader(); + abstract protected function assertLayersEquals($expected, $actual); +} diff --git a/src/Symfony/Component/Image/Tests/Image/AbstractLoaderTest.php b/src/Symfony/Component/Image/Tests/Image/AbstractLoaderTest.php new file mode 100644 index 0000000000000..a3795b5c60070 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/AbstractLoaderTest.php @@ -0,0 +1,206 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image; + +use Symfony\Component\Image\Exception\InvalidArgumentException; +use Symfony\Component\Image\Exception\RuntimeException; +use Symfony\Component\Image\Image\Box; +use Symfony\Component\Image\Image\Color; +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\Point; +use Symfony\Component\Image\Tests\TestCase; +use Symfony\Component\Image\Image\Palette\RGB; +use Symfony\Component\Image\Image\LoaderInterface; + +abstract class AbstractLoaderTest extends TestCase +{ + public function testShouldCreateEmptyImage() + { + $factory = $this->getLoader(); + $image = $factory->create(new Box(50, 50)); + $size = $image->getSize(); + + $this->assertInstanceOf(ImageInterface::class, $image); + $this->assertEquals(50, $size->getWidth()); + $this->assertEquals(50, $size->getHeight()); + } + + public function testShouldOpenAnImage() + { + $source = __DIR__.'/../Fixtures/google.png'; + $factory = $this->getLoader(); + $image = $factory->open($source); + $size = $image->getSize(); + + $this->assertInstanceOf(ImageInterface::class, $image); + $this->assertEquals(364, $size->getWidth()); + $this->assertEquals(126, $size->getHeight()); + + $metadata = $image->metadata(); + + $this->assertEquals($source, $metadata['uri']); + $this->assertEquals(realpath($source), $metadata['filepath']); + } + + public function testShouldOpenAnSplFileResource() + { + $source = __DIR__.'/../Fixtures/google.png'; + $resource = new \SplFileInfo($source); + $factory = $this->getLoader(); + $image = $factory->open($resource); + $size = $image->getSize(); + + $this->assertInstanceOf(ImageInterface::class, $image); + $this->assertEquals(364, $size->getWidth()); + $this->assertEquals(126, $size->getHeight()); + + $metadata = $image->metadata(); + + $this->assertEquals($source, $metadata['uri']); + $this->assertEquals(realpath($source), $metadata['filepath']); + } + + public function testShouldFailOnUnknownImage() + { + $invalidResource = __DIR__.'/path/that/does/not/exist'; + + $this->setExpectedException(InvalidArgumentException::class, sprintf('File %s does not exist.', $invalidResource)); + $this->getLoader()->open($invalidResource); + } + + public function testShouldFailOnInvalidImage() + { + $source = __DIR__.'/../Fixtures/invalid-image.jpg'; + + $this->setExpectedException(RuntimeException::class, sprintf('Unable to open image %s', $source)); + $this->getLoader()->open($source); + } + + public function testShouldOpenAnHttpImage() + { + $factory = $this->getLoader(); + $image = $factory->open(self::HTTP_IMAGE); + $size = $image->getSize(); + + $this->assertInstanceOf(ImageInterface::class, $image); + $this->assertEquals(280, $size->getWidth()); + $this->assertEquals(140, $size->getHeight()); + + $metadata = $image->metadata(); + + $this->assertEquals(self::HTTP_IMAGE, $metadata['uri']); + $this->assertArrayNotHasKey('filepath', $metadata); + } + + public function testShouldCreateImageFromString() + { + $factory = $this->getLoader(); + $image = $factory->load(file_get_contents(__DIR__.'/../Fixtures/google.png')); + $size = $image->getSize(); + + $this->assertInstanceOf(ImageInterface::class, $image); + $this->assertEquals(364, $size->getWidth()); + $this->assertEquals(126, $size->getHeight()); + + $metadata = $image->metadata(); + + $this->assertArrayNotHasKey('uri', $metadata); + $this->assertArrayNotHasKey('filepath', $metadata); + } + + public function testShouldCreateImageFromStreamWithMetadata() + { + $source = 'http://imagine.readthedocs.org/en/latest/_static/exit-90-test.jpg'; + $resource = fopen($source, 'r'); + + $factory = $this->getLoader(); + $image = $factory->read($resource); + $size = $image->getSize(); + + $this->assertInstanceOf(ImageInterface::class, $image); + $this->assertEquals(50, $size->getWidth()); + $this->assertEquals(50, $size->getHeight()); + + $metadata = $image->metadata(); + + $this->assertEquals($source, $metadata['uri']); + $this->assertEquals(realpath($source), $metadata['filepath']); + $this->assertEquals(6, $metadata['ifd0.Orientation']); + } + + public function testShouldCreateImageFromResource() + { + $source = __DIR__.'/../Fixtures/google.png'; + $factory = $this->getLoader(); + $resource = fopen($source, 'r'); + $image = $factory->read($resource); + $size = $image->getSize(); + + $this->assertInstanceOf(ImageInterface::class, $image); + $this->assertEquals(364, $size->getWidth()); + $this->assertEquals(126, $size->getHeight()); + + $metadata = $image->metadata(); + + $this->assertEquals($source, $metadata['uri']); + $this->assertEquals(realpath($source), $metadata['filepath']); + } + + public function testShouldCreateImageFromHttpResource() + { + $factory = $this->getLoader(); + $resource = fopen(self::HTTP_IMAGE, 'r'); + $image = $factory->read($resource); + $size = $image->getSize(); + + $this->assertInstanceOf(ImageInterface::class, $image); + $this->assertEquals(280, $size->getWidth()); + $this->assertEquals(140, $size->getHeight()); + + $metadata = $image->metadata(); + + $this->assertEquals(self::HTTP_IMAGE, $metadata['uri']); + $this->assertArrayNotHasKey('filepath', $metadata); + } + + public function testShouldDetermineFontSize() + { + if (!$this->isFontTestSupported()) { + $this->markTestSkipped('This install does not support font tests'); + } + + $palette = new RGB(); + $path = __DIR__.'/../Fixtures/font/Arial.ttf'; + $black = $palette->color('000'); + $factory = $this->getLoader(); + + $this->assertEquals($this->getEstimatedFontBox(), $factory->font($path, 36, $black)->box('string')); + } + + public function testCreateAlphaPrecision() + { + $imagine = $this->getLoader(); + $palette = new RGB(); + $image = $imagine->create(new Box(1, 1), $palette->color("#f00", 17)); + $actualColor = $image->getColorAt(new Point(0, 0)); + $this->assertEquals(17, $actualColor->getAlpha()); + } + + abstract protected function getEstimatedFontBox(); + + /** + * @return LoaderInterface + */ + abstract protected function getLoader(); + + abstract protected function isFontTestSupported(); +} diff --git a/src/Symfony/Component/Image/Tests/Image/BoxTest.php b/src/Symfony/Component/Image/Tests/Image/BoxTest.php new file mode 100644 index 0000000000000..29c1c1406f2ec --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/BoxTest.php @@ -0,0 +1,187 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image; + +use Symfony\Component\Image\Image\PointInterface; +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Image\Box; +use Symfony\Component\Image\Image\Point; +use Symfony\Component\Image\Tests\TestCase; + +class BoxTest extends TestCase +{ + /** + * @covers \Symfony\Component\Image\Image\Box::getWidth + * @covers \Symfony\Component\Image\Image\Box::getHeight + * + * @dataProvider getSizes + * + * @param integer $width + * @param integer $height + */ + public function testShouldAssignWidthAndHeight($width, $height) + { + $size = new Box($width, $height); + + $this->assertEquals($width, $size->getWidth()); + $this->assertEquals($height, $size->getHeight()); + } + + /** + * Data provider for testShouldAssignWidthAndHeight + * + * @return array + */ + public function getSizes() + { + return array( + array(1, 1), + array(10, 10), + array(15, 36) + ); + } + + /** + * @covers \Symfony\Component\Image\Image\Box::__construct + * + * @expectedException \Symfony\Component\Image\Exception\InvalidArgumentException + * + * @dataProvider getInvalidSizes + * + * @param integer $width + * @param integer $height + */ + public function testShouldThrowExceptionOnInvalidSize($width, $height) + { + new Box($width, $height); + } + + /** + * Data provider for testShouldThrowExceptionOnInvalidSize + * + * @return array + */ + public function getInvalidSizes() + { + return array( + array(0, 0), + array(15, 0), + array(0, 25), + array(-1, 4) + ); + } + + /** + * @covers \Symfony\Component\Image\Image\Box::contains + * + * @dataProvider getSizeBoxStartAndExpected + * + * @param BoxInterface $size + * @param BoxInterface $box + * @param PointInterface $start + * @param Boolean $expected + */ + public function testShouldDetermineIfASizeContainsABoxAtAStartPosition( + BoxInterface $size, + BoxInterface $box, + PointInterface $start, + $expected + ) { + $this->assertEquals($expected, $size->contains($box, $start)); + } + + /** + * Data provider for testShouldDetermineIfASizeContainsABoxAtAStartPosition + * + * @return array + */ + public function getSizeBoxStartAndExpected() + { + return array( + array(new Box(50, 50), new Box(30, 30), new Point(0, 0), true), + array(new Box(50, 50), new Box(30, 30), new Point(20, 20), true), + array(new Box(50, 50), new Box(30, 30), new Point(21, 21), false), + array(new Box(50, 50), new Box(30, 30), new Point(21, 20), false), + array(new Box(50, 50), new Box(30, 30), new Point(20, 22), false), + ); + } + + /** + * @cover Symfony\Component\Image\Image\Box::__toString + */ + public function testToString() + { + $this->assertEquals('100x100 px', (string) new Box(100, 100)); + } + + public function testShouldScaleBox() + { + $box = new Box(10, 20); + + $this->assertEquals(new Box(100, 200), $box->scale(10)); + } + + public function testShouldIncreaseBox() + { + $box = new Box(10, 20); + + $this->assertEquals(new Box(15, 25), $box->increase(5)); + } + + /** + * @dataProvider getSizesAndSquares + * + * @param integer $width + * @param integer $height + * @param integer $square + */ + public function testShouldCalculateSquare($width, $height, $square) + { + $box = new Box($width, $height); + + $this->assertEquals($square, $box->square()); + } + + public function getSizesAndSquares() + { + return array( + array(10, 15, 150), + array(2, 2, 4), + array(9, 8, 72), + ); + } + + /** + * @dataProvider getDimensionsAndTargets + * + * @param integer $width + * @param integer $height + * @param integer $targetWidth + * @param integer $targetHeight + */ + public function testShouldResizeToTargetWidthAndHeight($width, $height, $targetWidth, $targetHeight) + { + $box = new Box($width, $height); + $expected = new Box($targetWidth, $targetHeight); + + $this->assertEquals($expected, $box->widen($targetWidth)); + $this->assertEquals($expected, $box->heighten($targetHeight)); + } + + public function getDimensionsAndTargets() + { + return array( + array(10, 50, 50, 250), + array(25, 40, 50, 80), + ); + } +} diff --git a/src/Symfony/Component/Image/Tests/Image/Fill/Gradient/HorizontalTest.php b/src/Symfony/Component/Image/Tests/Image/Fill/Gradient/HorizontalTest.php new file mode 100644 index 0000000000000..65669f9faa624 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/Fill/Gradient/HorizontalTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Symfony\Component\Image\Tests\Image\Fill\Gradient; + +use Symfony\Component\Image\Image\Fill\Gradient\Horizontal; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\Point; + +class HorizontalTest extends LinearTest +{ + /** + * (non-PHPdoc) + * @see Symfony\Component\Image\Image\Fill\Gradient\LinearTest::getEnd() + */ + protected function getEnd() + { + return $this->getColor('fff'); + } + + /** + * (non-PHPdoc) + * @see Symfony\Component\Image\Image\Fill\Gradient\LinearTest::getStart() + */ + protected function getStart() + { + return $this->getColor('000'); + } + + /** + * (non-PHPdoc) + * @see Symfony\Component\Image\Image\Fill\Gradient\LinearTest::getMask() + */ + protected function getFill(ColorInterface $start, ColorInterface $end) + { + return new Horizontal(100, $start, $end); + } + + /** + * (non-PHPdoc) + * @see Symfony\Component\Image\Image\Fill\Gradient\LinearTest::getPointsAndShades() + */ + public function getPointsAndColors() + { + return array( + array($this->getColor('fff'), new Point(100, 5)), + array($this->getColor('000'), new Point(0, 15)), + array($this->getColor(array(128, 128, 128)), new Point(50, 25)) + ); + } +} diff --git a/src/Symfony/Component/Image/Tests/Image/Fill/Gradient/LinearTest.php b/src/Symfony/Component/Image/Tests/Image/Fill/Gradient/LinearTest.php new file mode 100644 index 0000000000000..cf85e74ac1490 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/Fill/Gradient/LinearTest.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image\Fill\Gradient; + +use Symfony\Component\Image\Image\Palette\RGB; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\PointInterface; +use Symfony\Component\Image\Tests\TestCase; + +abstract class LinearTest extends TestCase +{ + /** + * @var \Symfony\Component\Image\Image\Fill\FillInterface + */ + private $fill; + + /** + * @var ColorInterface + */ + private $start; + + /** + * @var ColorInterface + */ + private $end; + protected $palette; + + protected function setUp() + { + $this->start = $this->getStart(); + $this->end = $this->getEnd(); + $this->fill = $this->getFill($this->start, $this->end); + } + + /** + * @dataProvider getPointsAndColors + * + * @param integer $shade + * @param \Symfony\Component\Image\Image\PointInterface $position + */ + public function testShouldProvideCorrectColorsValues(ColorInterface $color, PointInterface $position) + { + $this->assertEquals($color, $this->fill->getColor($position)); + } + + /** + * @covers \Symfony\Component\Image\Image\Fill\Gradient\Linear::getStart + * @covers \Symfony\Component\Image\Image\Fill\Gradient\Linear::getEnd + */ + public function testShouldReturnCorrectStartAndEnd() + { + $this->assertSame($this->start, $this->fill->getStart()); + $this->assertSame($this->end, $this->fill->getEnd()); + } + + protected function getColor($color) + { + static $palette; + + if (!$palette) { + $palette = new RGB(); + } + + return $palette->color($color); + } + + /** + * @param ColorInterface $start + * @param ColorInterface $end + * + * @return Symfony\Component\Image\Image\Fill\FillInterface + */ + abstract protected function getFill(ColorInterface $start, ColorInterface $end); + + /** + * @return ColorInterface + */ + abstract protected function getStart(); + + /** + * @return ColorInterface + */ + abstract protected function getEnd(); + + /** + * @return array + */ + abstract public function getPointsAndColors(); +} diff --git a/src/Symfony/Component/Image/Tests/Image/Fill/Gradient/VerticalTest.php b/src/Symfony/Component/Image/Tests/Image/Fill/Gradient/VerticalTest.php new file mode 100644 index 0000000000000..fcb7fc1fb4f02 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/Fill/Gradient/VerticalTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Symfony\Component\Image\Tests\Image\Fill\Gradient; + +use Symfony\Component\Image\Image\Fill\Gradient\Vertical; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\Point; + +class VerticalTest extends LinearTest +{ + /** + * (non-PHPdoc) + * @see Symfony\Component\Image\Image\Fill\Gradient\LinearTest::getEnd() + */ + protected function getEnd() + { + return $this->getColor('fff'); + } + + /** + * (non-PHPdoc) + * @see Symfony\Component\Image\Image\Fill\Gradient\LinearTest::getStart() + */ + protected function getStart() + { + return $this->getColor('000'); + } + + /** + * (non-PHPdoc) + * @see Symfony\Component\Image\Image\Fill\Gradient\LinearTest::getMask() + */ + protected function getFill(ColorInterface $start, ColorInterface $end) + { + return new Vertical(100, $start, $end); + } + + /** + * (non-PHPdoc) + * @see Symfony\Component\Image\Image\Fill\Gradient\LinearTest::getPointsAndShades() + */ + public function getPointsAndColors() + { + return array( + array($this->getColor('fff'), new Point(5, 100)), + array($this->getColor('000'), new Point(15, 0)), + array($this->getColor(array(128, 128, 128)), new Point(25, 50)) + ); + } +} diff --git a/src/Symfony/Component/Image/Tests/Image/Histogram/BucketTest.php b/src/Symfony/Component/Image/Tests/Image/Histogram/BucketTest.php new file mode 100644 index 0000000000000..4c2b73b44a19c --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/Histogram/BucketTest.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image\Histogram; + +use Symfony\Component\Image\Image\Histogram\Bucket; +use Symfony\Component\Image\Image\Histogram\Range; +use Symfony\Component\Image\Tests\TestCase; + +class BucketTest extends TestCase +{ + private $bucket; + + protected function setUp() + { + $this->bucket = new Bucket(new Range(0, 63)); + $this->assertInstanceOf('Countable', $this->bucket); + } + + /** + * @dataProvider getCountAndValues + * + * @param integer $count + * @param array $values + */ + public function testShouldOnlyRegisterValuesInRange($count, array $values) + { + foreach ($values as $value) { + $this->bucket->add($value); + } + + $this->assertEquals($count, $this->bucket->count()); + } + + public function getCountAndValues() + { + return array( + array(3, array(12, 123, 232, 142, 152, 172, 93, 35, 44)), + array(6, array(12, 123, 23, 14, 152, 17, 93, 35, 44)), + array(8, array(12, 12, 12, 23, 14, 152, 17, 93, 35, 44)), + array(0, array(121, 123, 234, 145, 152, 176, 93, 135, 144)), + ); + } +} diff --git a/src/Symfony/Component/Image/Tests/Image/Histogram/RangeTest.php b/src/Symfony/Component/Image/Tests/Image/Histogram/RangeTest.php new file mode 100644 index 0000000000000..552900b2b89aa --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/Histogram/RangeTest.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image\Histogram; + +use Symfony\Component\Image\Image\Histogram\Range; +use Symfony\Component\Image\Tests\TestCase; + +class RangeTest extends TestCase +{ + private $start = 0; + private $end = 63; + + /** + * @dataProvider getExpectedResultsAndValues + * + * @param Boolean $contains + * @param integer $value + */ + public function testShouldDetermineIfContainsValue($contains, $value) + { + $range = new Range($this->start, $this->end); + + $this->assertEquals($contains, $range->contains($value)); + } + + public function getExpectedResultsAndValues() + { + return array( + array(true, 12), + array(true, 0), + array(false, 128), + array(false, 63), + ); + } + + /** + * @expectedException \Symfony\Component\Image\Exception\OutOfBoundsException + */ + public function testShouldThrowExceptionIfEndIsSmallerThanStart() + { + new Range($this->end, $this->start); + } +} diff --git a/src/Symfony/Component/Image/Tests/Image/Metadata/DefaultMetadataReaderTest.php b/src/Symfony/Component/Image/Tests/Image/Metadata/DefaultMetadataReaderTest.php new file mode 100644 index 0000000000000..66a2e30fffc1a --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/Metadata/DefaultMetadataReaderTest.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image\Metadata; + +use Symfony\Component\Image\Image\Metadata\DefaultMetadataReader; + +class DefaultMetadataReaderTest extends MetadataReaderTestCase +{ + protected function getReader() + { + return new DefaultMetadataReader(); + } +} diff --git a/src/Symfony/Component/Image/Tests/Image/Metadata/ExifMetadataReaderTest.php b/src/Symfony/Component/Image/Tests/Image/Metadata/ExifMetadataReaderTest.php new file mode 100644 index 0000000000000..2efe02fac864e --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/Metadata/ExifMetadataReaderTest.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image\Metadata; + +use Symfony\Component\Image\Image\Metadata\ExifMetadataReader; + +class ExifMetadataReaderTest extends MetadataReaderTestCase +{ + protected function getReader() + { + return new ExifMetadataReader(); + } + + public function testExifDataAreReadWithReadFile() + { + $metadata = $this->getReader()->readFile(__DIR__ . '/../../Fixtures/exifOrientation/90.jpg'); + $this->assertTrue(isset($metadata['ifd0.Orientation'])); + $this->assertEquals(6, $metadata['ifd0.Orientation']); + } + + public function testExifDataAreReadWithReadHttpFile() + { + $source = self::HTTP_IMAGE; + + $metadata = $this->getReader()->readFile($source); + $this->assertEquals(1, $metadata['ifd0.Orientation']); + } + + public function testExifDataAreReadWithReadData() + { + $metadata = $this->getReader()->readData(file_get_contents(__DIR__ . '/../../Fixtures/exifOrientation/90.jpg')); + $this->assertTrue(isset($metadata['ifd0.Orientation'])); + $this->assertEquals(6, $metadata['ifd0.Orientation']); + } + + public function testExifDataAreReadWithReadStream() + { + $metadata = $this->getReader()->readStream(fopen(__DIR__ . '/../../Fixtures/exifOrientation/90.jpg', 'r')); + $this->assertTrue(isset($metadata['ifd0.Orientation'])); + $this->assertEquals(6, $metadata['ifd0.Orientation']); + } +} diff --git a/src/Symfony/Component/Image/Tests/Image/Metadata/MetadataBagTest.php b/src/Symfony/Component/Image/Tests/Image/Metadata/MetadataBagTest.php new file mode 100644 index 0000000000000..d94b96ff3d61b --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/Metadata/MetadataBagTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image\Metadata; + +use Symfony\Component\Image\Image\Metadata\MetadataBag; +use Symfony\Component\Image\Tests\TestCase; + +class MetadataBagTest extends TestCase +{ + public function testArrayAccessImplementation() + { + $data = array('key1' => 'value1', 'key2' => 'value2'); + $bag = new MetadataBag($data); + + $this->assertFalse(isset($bag['key3'])); + $this->assertTrue(isset($bag['key1'])); + $bag['key3'] = 'value3'; + $this->assertTrue(isset($bag['key3'])); + unset($bag['key3']); + $this->assertFalse(isset($bag['key3'])); + $bag['key1'] = 'valuetest'; + $this->assertEquals('valuetest', $bag['key1']); + $this->assertEquals('value2', $bag['key2']); + } + + public function testIteratorAggregateImplementation() + { + $data = array('key1' => 'value1', 'key2' => 'value2'); + $bag = new MetadataBag($data); + + $this->assertEquals(new \ArrayIterator($data), $bag->getIterator()); + } +} diff --git a/src/Symfony/Component/Image/Tests/Image/Metadata/MetadataReaderTestCase.php b/src/Symfony/Component/Image/Tests/Image/Metadata/MetadataReaderTestCase.php new file mode 100644 index 0000000000000..94becbf98c05d --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/Metadata/MetadataReaderTestCase.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image\Metadata; + +use Symfony\Component\Image\Image\Metadata\MetadataBag; +use Symfony\Component\Image\Image\Metadata\MetadataReaderInterface; +use Symfony\Component\Image\Tests\TestCase; + +/** + */ +abstract class MetadataReaderTestCase extends TestCase +{ + /** + * @return MetadataReaderInterface + */ + abstract protected function getReader(); + + public function testReadFromFile() + { + $source = __DIR__ . '/../../Fixtures/pixel-CMYK.jpg'; + $metadata = $this->getReader()->readFile($source); + $this->assertInstanceOf(MetadataBag::class, $metadata); + $this->assertEquals(realpath($source), $metadata['filepath']); + $this->assertEquals($source, $metadata['uri']); + } + + public function testReadFromExifUncompatibleFile() + { + $source = __DIR__ . '/../../Fixtures/trans.png'; + $metadata = $this->getReader()->readFile($source); + $this->assertInstanceOf(MetadataBag::class, $metadata); + $this->assertEquals(realpath($source), $metadata['filepath']); + $this->assertEquals($source, $metadata['uri']); + } + + public function testReadFromHttpFile() + { + $source = self::HTTP_IMAGE; + $metadata = $this->getReader()->readFile($source); + $this->assertInstanceOf(MetadataBag::class, $metadata); + $this->assertFalse(isset($metadata['filepath'])); + $this->assertEquals($source, $metadata['uri']); + } + + /** + * @expectedException \Symfony\Component\Image\Exception\InvalidArgumentException + * @expectedExceptionMessage File /path/to/no/file does not exist. + */ + public function testReadFromInvalidFileThrowsAnException() + { + $this->getReader()->readFile('/path/to/no/file'); + } + + public function testReadFromData() + { + $source = __DIR__ . '/../../Fixtures/pixel-CMYK.jpg'; + $metadata = $this->getReader()->readData(file_get_contents($source)); + $this->assertInstanceOf(MetadataBag::class, $metadata); + } + + public function testReadFromInvalidDataDoesNotThrowException() + { + $metadata = $this->getReader()->readData('this is nonsense'); + $this->assertInstanceOf(MetadataBag::class, $metadata); + } + + public function testReadFromStream() + { + $source = __DIR__ . '/../../Fixtures/pixel-CMYK.jpg'; + $resource = fopen($source, 'r'); + $metadata = $this->getReader()->readStream($resource); + $this->assertInstanceOf(MetadataBag::class, $metadata); + $this->assertEquals(realpath($source), $metadata['filepath']); + $this->assertEquals($source, $metadata['uri']); + } + + /** + * @expectedException \Symfony\Component\Image\Exception\InvalidArgumentException + * @expectedExceptionMessage Invalid resource provided. + */ + public function testReadFromInvalidStreamThrowsAnException() + { + $metadata = $this->getReader()->readStream(false); + $this->assertInstanceOf(MetadataBag::class, $metadata); + } +} diff --git a/src/Symfony/Component/Image/Tests/Image/Palette/AbstractPaletteTest.php b/src/Symfony/Component/Image/Tests/Image/Palette/AbstractPaletteTest.php new file mode 100644 index 0000000000000..9b65b2821b556 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/Palette/AbstractPaletteTest.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image\Palette; + +use Symfony\Component\Image\Image\ProfileInterface; +use Symfony\Component\Image\Tests\TestCase; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; + +abstract class AbstractPaletteTest extends TestCase +{ + /** + * @dataProvider provideColorAndAlphaTuples + */ + public function testColor($expected, $color, $alpha) + { + $result = $this->getPalette()->color($color, $alpha); + $this->assertInstanceOf(ColorInterface::class, $result); + $this->assertEquals((string) $expected, (string) $result); + } + + /** + * @dataProvider provideColorAndAlpha + */ + public function testColorIsCached($color, $alpha) + { + $this->assertSame($this->getPalette()->color($color, $alpha), $this->getPalette()->color($color, $alpha)); + } + + /** + * @dataProvider provideColorAndAlpha + */ + public function testColorWithDifferentAlphasAreNotSame($color, $alpha) + { + $this->assertNotSame($this->getPalette()->color($color, 2), $this->getPalette()->color($color, 0)); + } + + /** + * @dataProvider provideColorsForBlending + */ + public function testBlend($expected, $color1, $color2, $amount) + { + $result = $this->getPalette()->blend($color1, $color2, $amount); + $this->assertInstanceOf(ColorInterface::class, $result); + $this->assertEquals((string) $expected, (string) $result); + } + + public function testUseProfile() + { + $this->getMockBuilder(ProfileInterface::class)->getMock(); + + $palette = $this->getPalette(); + + $new = $this->getMockBuilder(ProfileInterface::class)->getMock(); + $palette->useProfile($new); + + $this->assertEquals($new, $palette->profile()); + + } + + public function testProfile() + { + $this->assertInstanceOf(ProfileInterface::class, $this->getPalette()->profile()); + } + + public function testName() + { + $this->assertInternalType('string', $this->getPalette()->name()); + } + + public function testPixelDefinition() + { + $this->assertInternalType('array', $this->getPalette()->pixelDefinition()); + + $available = array( + ColorInterface::COLOR_RED, + ColorInterface::COLOR_GREEN, + ColorInterface::COLOR_BLUE, + ColorInterface::COLOR_CYAN, + ColorInterface::COLOR_MAGENTA, + ColorInterface::COLOR_YELLOW, + ColorInterface::COLOR_KEYLINE, + ColorInterface::COLOR_GRAY, + ); + + foreach ($this->getPalette()->pixelDefinition() as $color) { + $this->assertTrue(in_array($color, $available)); + } + } + + public function testSupportsAlpha() + { + $this->assertInternalType('boolean', $this->getPalette()->supportsAlpha()); + } + + abstract public function provideColorAndAlphaTuples(); + + abstract public function provideColorsForBlending(); + + /** + * @return PaletteInterface + */ + abstract protected function getPalette(); +} diff --git a/src/Symfony/Component/Image/Tests/Image/Palette/CMYKTest.php b/src/Symfony/Component/Image/Tests/Image/Palette/CMYKTest.php new file mode 100644 index 0000000000000..91daf77009713 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/Palette/CMYKTest.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image\Palette; + +use Symfony\Component\Image\Image\Palette\CMYK; +use Symfony\Component\Image\Image\Palette\Color\CMYK as CMYKColor; + +class CMYKTest extends AbstractPaletteTest +{ + public function provideColorAndAlphaTuples() + { + $palette = $this->getPalette(); + + return array( + array(new CMYKColor($palette, array(1, 2, 3, 4)), array(1, 2, 3, 4), null), + array(new CMYKColor($palette, array(4, 3, 2, 1)), array(4, 3, 2, 1), null), + array(new CMYKColor($palette, array(0, 33, 67, 99)), array(3, 2, 1), null), + array(new CMYKColor($palette, array(0, 0, 0, 0)), array(255, 255, 255), null), + array(new CMYKColor($palette, array(0, 0, 0, 100)), array(0, 0, 0), null), + ); + } + + public function provideColorAndAlpha() + { + return array( + array(array(4, 3, 2, 1), null) + ); + } + + public function testColorWithDifferentAlphasAreNotSame($color = null, $alpha = null) + { + $this->markTestSkipped('CMYK does not support alpha'); + } + + public function provideColorsForBlending() + { + $palette = $this->getPalette(); + + return array( + array( + new CMYKColor($palette, array(56, 29, 38, 48)), + new CMYKColor($palette, array(1, 2, 3, 4)), + new CMYKColor($palette, array(50, 25, 32, 40)), + 1.1, + ), + array( + new CMYKColor($palette, array(21, 12, 15, 20)), + new CMYKColor($palette, array(1, 2, 3, 4)), + new CMYKColor($palette, array(50, 25, 32, 40)), + 0.4, + ), + ); + } + + protected function getPalette() + { + return new CMYK(); + } +} diff --git a/src/Symfony/Component/Image/Tests/Image/Palette/Color/AbstractColorTest.php b/src/Symfony/Component/Image/Tests/Image/Palette/Color/AbstractColorTest.php new file mode 100644 index 0000000000000..967879cab9982 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/Palette/Color/AbstractColorTest.php @@ -0,0 +1,130 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image\Palette\Color; + +use Symfony\Component\Image\Image\Palette\PaletteInterface; +use Symfony\Component\Image\Tests\TestCase; + +abstract class AbstractColorTest extends TestCase +{ + /** + * @dataProvider provideColorAndAlphaTuples + */ + public function testGetAlpha($expected, $color) + { + $this->assertEquals($expected, $color->getAlpha()); + } + + public function testGetPalette() + { + $this->assertInstanceOf(PaletteInterface::class, $this->getColor()->getPalette()); + } + + /** + * @dataProvider provideColorAndValueComponents + */ + public function testGetvalue($expected, $color) + { + $data = array(); + + foreach ($color->getPalette()->pixelDefinition() as $component) { + $data[$component] = $color->getValue($component); + } + + $this->assertEquals($expected, $data); + } + + public function testDissolve() + { + $color = $this->getColor(); + $alpha = $color->getAlpha(); + $signature = (string) $color; + + $color = $color->dissolve(2); + + $this->assertEquals(2 + $alpha, $color->getAlpha()); + $this->assertEquals($signature, (string) $color); + } + + public function testLighten() + { + $color = $this->getColor(); + + $data = array(); + + foreach ($color->getPalette()->pixelDefinition() as $component) { + $data[$component] = $color->getValue($component); + } + + $color->lighten(4); + + foreach ($color->getPalette()->pixelDefinition() as $component) { + $this->assertLessThanOrEqual($data[$component], $color->getValue($component)); + } + } + + public function testDarken() + { + $color = $this->getColor(); + + $data = array(); + + foreach ($color->getPalette()->pixelDefinition() as $component) { + $data[$component] = $color->getValue($component); + } + + $color->darken(4); + + foreach ($color->getPalette()->pixelDefinition() as $component) { + $this->assertGreaterThanOrEqual($data[$component], $color->getValue($component)); + } + } + + /** + * @dataProvider provideGrayscaleData + */ + public function testGrayscale($expected, $color) + { + $this->assertEquals($expected, (string) $color->grayscale()); + } + + /** + * @dataProvider provideOpaqueColors + */ + public function testIsOpaque($color) + { + $this->assertTrue($color->isOpaque()); + } + + /** + * @dataProvider provideNotOpaqueColors + */ + public function testIsNotOpaque($color) + { + $this->assertFalse($color->isOpaque()); + } + + abstract public function provideColorAndValueComponents(); + + abstract public function provideOpaqueColors(); + + abstract public function provideNotOpaqueColors(); + + abstract public function provideGrayscaleData(); + + abstract public function provideColorAndAlphaTuples(); + + /** + * @return ColorInterface + */ + abstract protected function getColor(); +} diff --git a/src/Symfony/Component/Image/Tests/Image/Palette/Color/CMYKTest.php b/src/Symfony/Component/Image/Tests/Image/Palette/Color/CMYKTest.php new file mode 100644 index 0000000000000..38d7f492479e8 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/Palette/Color/CMYKTest.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image\Palette\Color; + +use Symfony\Component\Image\Image\Palette\Color\CMYK; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\Palette\CMYK as CMYKPalette; + +class CMYKTest extends AbstractColorTest +{ + /** + * @expectedException \Symfony\Component\Image\Exception\RuntimeException + */ + public function testDissolve() + { + $this->getColor()->dissolve(1); + } + + public function provideOpaqueColors() + { + return array( + array($this->getColor()), + ); + } + + public function testIsNotOpaque($color = null) + { + $this->markTestSkipped('CMYK color can not be not opaque'); + } + + public function provideNotOpaqueColors() + { + $this->markTestSkipped('CMYK color can not be not opaque'); + } + + public function provideGrayscaleData() + { + return array( + array('cmyk(42%, 42%, 42%, 25%)', $this->getColor()), + ); + } + + public function provideColorAndAlphaTuples() + { + return array( + array(null, $this->getColor()) + ); + } + + protected function getColor() + { + return new CMYK(new CMYKPalette(), array(12, 23, 45, 25)); + } + + public function provideColorAndValueComponents() + { + return array( + array(array( + ColorInterface::COLOR_CYAN => 12, + ColorInterface::COLOR_MAGENTA => 23, + ColorInterface::COLOR_YELLOW => 45, + ColorInterface::COLOR_KEYLINE => 25, + ), $this->getColor()), + ); + } +} diff --git a/src/Symfony/Component/Image/Tests/Image/Palette/Color/GrayTest.php b/src/Symfony/Component/Image/Tests/Image/Palette/Color/GrayTest.php new file mode 100644 index 0000000000000..efb5d7739046f --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/Palette/Color/GrayTest.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image\Palette\Color; + +use Symfony\Component\Image\Image\Palette\Color\Gray; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\Palette\Grayscale; + +class GrayTest extends AbstractColorTest +{ + public function provideOpaqueColors() + { + return array( + array(new Gray(new Grayscale(), array(12), 100)), + array(new Gray(new Grayscale(), array(0), 100)), + array(new Gray(new Grayscale(), array(255), 100)), + ); + } + public function provideNotOpaqueColors() + { + return array( + array($this->getColor()), + array(new Gray(new Grayscale(), array(12), 23)), + array(new Gray(new Grayscale(), array(0), 45)), + array(new Gray(new Grayscale(), array(255), 0)), + ); + } + + public function provideGrayscaleData() + { + return array( + array('#0c0c0c', $this->getColor()), + ); + } + + public function provideColorAndAlphaTuples() + { + return array( + array(14, $this->getColor()) + ); + } + + protected function getColor() + { + return new Gray(new Grayscale(), array(12), 14); + } + + public function provideColorAndValueComponents() + { + return array( + array(array( + ColorInterface::COLOR_GRAY => 12, + ), $this->getColor()), + ); + } +} diff --git a/src/Symfony/Component/Image/Tests/Image/Palette/Color/RGBTest.php b/src/Symfony/Component/Image/Tests/Image/Palette/Color/RGBTest.php new file mode 100644 index 0000000000000..de7fa13f28f96 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/Palette/Color/RGBTest.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image\Palette\Color; + +use Symfony\Component\Image\Image\Palette\Color\RGB; +use Symfony\Component\Image\Image\Palette\Color\ColorInterface; +use Symfony\Component\Image\Image\Palette\RGB as RGBPalette; + +class RGBTest extends AbstractColorTest +{ + public function provideOpaqueColors() + { + return array( + array(new RGB(new RGBPalette(), array(12, 123, 245), 100)), + array(new RGB(new RGBPalette(), array(0, 0, 0), 100)), + array(new RGB(new RGBPalette(), array(255, 255, 255), 100)), + ); + } + public function provideNotOpaqueColors() + { + return array( + array($this->getColor()), + array(new RGB(new RGBPalette(), array(12, 123, 245), 23)), + array(new RGB(new RGBPalette(), array(0, 0, 0), 45)), + array(new RGB(new RGBPalette(), array(255, 255, 255), 0)), + ); + } + + public function provideGrayscaleData() + { + return array( + array('#686868', $this->getColor()), + ); + } + + public function provideColorAndAlphaTuples() + { + return array( + array(14, $this->getColor()) + ); + } + + protected function getColor() + { + return new RGB(new RGBPalette(), array(12, 123, 245), 14); + } + + public function provideColorAndValueComponents() + { + return array( + array(array( + ColorInterface::COLOR_RED => 12, + ColorInterface::COLOR_GREEN => 123, + ColorInterface::COLOR_BLUE => 245, + ), $this->getColor()), + ); + } +} diff --git a/src/Symfony/Component/Image/Tests/Image/Palette/ColorParserTest.php b/src/Symfony/Component/Image/Tests/Image/Palette/ColorParserTest.php new file mode 100644 index 0000000000000..0a49973c93445 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/Palette/ColorParserTest.php @@ -0,0 +1,165 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image\Palette; + +use Symfony\Component\Image\Image\Palette\ColorParser; +use Symfony\Component\Image\Tests\TestCase; + +class ColorParserTest extends TestCase +{ + /** + * @dataProvider provideRGBdataToParse + */ + public function testParseToRGB($expected, $value) + { + $parser = new ColorParser(); + + $this->assertEquals($expected, $parser->parseToRGB($value)); + } + + /** + * @dataProvider provideRGBdataThatFail + * @expectedException \Symfony\Component\Image\Exception\InvalidArgumentException + */ + public function testParseToRGBThatFails($value) + { + $parser = new ColorParser(); + $parser->parseToRGB($value); + } + + /** + * @dataProvider provideCMYKdataToParse + */ + public function testParseToCMYK($expected, $value) + { + $parser = new ColorParser(); + + $this->assertEquals($expected, $parser->parseToCMYK($value)); + } + + /** + * @dataProvider provideCMYKdataThatFail + * @expectedException \Symfony\Component\Image\Exception\InvalidArgumentException + */ + public function testParseToCMYKThatFails($value) + { + $parser = new ColorParser(); + $parser->parseToCMYK($value); + } + + public function provideRGBdataToParse() + { + return array( + array(array(255, 255, 0), 'ff0'), + array(array(255, 255, 0), '#ff0'), + array(array(205, 162, 52), 'CDA234'), + array(array(205, 162, 52), '#CDA234'), + array(array(205, 162, 52), 13476404), + array(array(124, 32, 125), array(124, 32, 125)), + ); + } + + public function provideCMYKdataToParse() + { + return array( + array(array(0, 0, 0, 0), 'FFFFFF'), + array(array(0, 0, 0, 100), '000000'), + array(array(0, 21, 75, 20), 'CDA234'), + array(array(0, 21, 75, 20), '#CDA234'), + array(array(0, 21, 75, 20), 'cmyk(0, 21, 75, 20)'), + array(array(0, 21, 75, 20), 'cmyk(0,21,75,20)'), + array(array(0, 21, 75, 20), 'cmyk(0%, 21%, 75%, 20%)'), + array(array(0, 21, 75, 20), 'cmyk(0%,21%,75%,20%)'), + array(array(0, 21, 75, 20), 13476404), + array(array(100, 0, 100, 0), '#00FF00'), + array(array(24, 32, 75, 12), array(24, 32, 75, 12)), + ); + } + + public function provideRGBdataThatFail() + { + $data = array( + array(array(0, 1)), + array(array(0, 1, 0, 1, 0)), + array('1234'), + array('#1234'), + ); + + if (function_exists('imagecreatetruecolor')) { + $data[] = array(imagecreatetruecolor(10, 10)); + } + + return $data; + } + + public function provideCMYKdataThatFail() + { + $data = array( + array(array(0, 1)), + array(array(0, 1, 0, 1, 0)), + array('1234'), + array('#1234'), + ); + + if (function_exists('imagecreatetruecolor')) { + $data[] = array(imagecreatetruecolor(10, 10)); + } + + return $data; + } + + /** + * @dataProvider provideGrayscaledataToParse + */ + public function testParseToGrayscale($expected, $value) + { + $parser = new ColorParser(); + + $this->assertEquals($expected, $parser->parseToGrayscale($value)); + } + + /** + * @dataProvider provideGrayscaledataThatFail + * @expectedException \Symfony\Component\Image\Exception\InvalidArgumentException + */ + public function testParseToGrayscaleThatFails($value) + { + $parser = new ColorParser(); + $parser->parseToGrayscale($value); + } + + public function provideGrayscaledataToParse() + { + return array( + array(array(23), array(23, 23, 23)), + array(array(0), array(0, 0, 0)), + array(array(255), array(255, 255, 255)), + array(array(23), array(23)), + array(array(0), array(0)), + array(array(255), array(255)), + array(array(136), '#888888'), + array(array(153), '999999'), + array(array(0), '#000000'), + array(array(255), 'FFFFFF'), + ); + } + + public function provideGrayscaledataThatFail() + { + return array( + array(array(23, 23, 24)), + array(array(0, 0, 1)), + array('#656666'), + array('777677'), + ); + } +} diff --git a/src/Symfony/Component/Image/Tests/Image/Palette/GrayscaleTest.php b/src/Symfony/Component/Image/Tests/Image/Palette/GrayscaleTest.php new file mode 100644 index 0000000000000..d5d88fc7813a4 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/Palette/GrayscaleTest.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image\Palette; + +use Symfony\Component\Image\Image\Palette\Grayscale; +use Symfony\Component\Image\Image\Palette\Color\Gray; + +class GrayscaleTest extends AbstractPaletteTest +{ + public function provideColorAndAlphaTuples() + { + $palette = $this->getPalette(); + + return array( + array(new Gray($palette, array(23), 0), array(23, 23, 23), null), + array(new Gray($palette, array(24), 3), array(24, 24, 24), 3), + array(new Gray($palette, array(23), 0), array(23), null), + array(new Gray($palette, array(24), 3), array(24), 3), + array(new Gray($palette, array(255), 0), array(255), null), + array(new Gray($palette, array(0), 0), array(0), null), + ); + } + + public function provideColorAndAlpha() + { + return array( + array(array(23, 23, 23), 0.5), + ); + } + + public function provideColorsForBlending() + { + $palette = $this->getPalette(); + + return array( + array( + new Gray($palette, array(55), 0), + new Gray($palette, array(1), 0), + new Gray($palette, array(50), 0), + 1.1, + ), + array( + new Gray($palette, array(21), 0), + new Gray($palette, array(1), 0), + new Gray($palette, array(50), 0), + 0.4, + ), + ); + } + + protected function getPalette() + { + return new Grayscale(); + } +} diff --git a/src/Symfony/Component/Image/Tests/Image/Palette/RGBTest.php b/src/Symfony/Component/Image/Tests/Image/Palette/RGBTest.php new file mode 100644 index 0000000000000..95cb4412b67c8 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/Palette/RGBTest.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image\Palette; + +use Symfony\Component\Image\Image\Palette\RGB; +use Symfony\Component\Image\Image\Palette\Color\RGB as RGBColor; + +class RGBTest extends AbstractPaletteTest +{ + public function provideColorAndAlphaTuples() + { + $palette = $this->getPalette(); + + return array( + array(new RGBColor($palette, array(23, 24, 0), 0), array(23, 24, 0), null), + array(new RGBColor($palette, array(23, 24, 0), 0), array(23, 24, 0), 0), + array(new RGBColor($palette, array(23, 24, 0), 3), array(23, 24, 0), 3), + array(new RGBColor($palette, array(129, 127, 168), 3), array(23, 24, 0, 34), 3), + array(new RGBColor($palette, array(255, 255, 255), 0), array(0, 0, 0, 0), null), + array(new RGBColor($palette, array(0, 0, 0), 0), array(0, 0, 0, 100), null), + ); + } + + public function provideColorAndAlpha() + { + return array( + array(array(23, 24, 0), 0.5), + ); + } + + public function provideColorsForBlending() + { + $palette = $this->getPalette(); + + return array( + array( + new RGBColor($palette, array(240, 0, 0), 0), + new RGBColor($palette, array(230, 0, 0), 0), + new RGBColor($palette, array(128, 0, 0), 0), + 1.1, + ), + array( + new RGBColor($palette, array(21, 11, 15), 0), + new RGBColor($palette, array(1, 2, 3), 0), + new RGBColor($palette, array(50, 25, 32), 0), + 0.4, + ), + ); + } + + protected function getPalette() + { + return new RGB(); + } +} diff --git a/src/Symfony/Component/Image/Tests/Image/Point/CenterTest.php b/src/Symfony/Component/Image/Tests/Image/Point/CenterTest.php new file mode 100644 index 0000000000000..5ca41d5f00b98 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/Point/CenterTest.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image\Point; + +use Symfony\Component\Image\Image\Point\Center; +use Symfony\Component\Image\Image\Point; +use Symfony\Component\Image\Image\PointInterface; +use Symfony\Component\Image\Image\Box; +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Tests\TestCase; + +class CenterTest extends TestCase +{ + /** + * @covers \Symfony\Component\Image\Image\Point\Center::getX + * @covers \Symfony\Component\Image\Image\Point\Center::getY + * + * @dataProvider getSizesAndCoordinates + * + * @param \Symfony\Component\Image\Image\BoxInterface $box + * @param \Symfony\Component\Image\Image\PointInterface $expected + */ + public function testShouldGetCenterCoordinates(BoxInterface $box, PointInterface $expected) + { + $point = new Center($box); + + $this->assertEquals($expected->getX(), $point->getX()); + $this->assertEquals($expected->getY(), $point->getY()); + } + + /** + * Data provider for testShouldGetCenterCoordinates + * + * @return array + */ + public function getSizesAndCoordinates() + { + return array( + array(new Box(10, 15), new Point(5, 8)), + array(new Box(40, 23), new Point(20, 12)), + array(new Box(14, 8), new Point(7, 4)), + ); + } + + /** + * @covers \Symfony\Component\Image\Image\Point::getX + * @covers \Symfony\Component\Image\Image\Point::getY + * @covers \Symfony\Component\Image\Image\Point::move + * + * @dataProvider getMoves + * + * @param \Symfony\Component\Image\Image\BoxInterface $box + * @param integer $move + * @param integer $x1 + * @param integer $y1 + */ + public function testShouldMoveByGivenAmount(BoxInterface $box, $move, $x1, $y1) + { + $point = new Center($box); + $shift = $point->move($move); + + $this->assertEquals($x1, $shift->getX()); + $this->assertEquals($y1, $shift->getY()); + } + + public function getMoves() + { + return array( + array(new Box(10, 20), 5, 10, 15), + array(new Box(5, 37), 2, 5, 21), + ); + } + + /** + * @covers \Symfony\Component\Image\Image\Point\Center::__toString + */ + public function testToString() + { + $this->assertEquals('(50, 50)', (string) new Center(new Box(100, 100))); + } +} diff --git a/src/Symfony/Component/Image/Tests/Image/PointTest.php b/src/Symfony/Component/Image/Tests/Image/PointTest.php new file mode 100644 index 0000000000000..1afdc03503960 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/PointTest.php @@ -0,0 +1,125 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image; + +use Symfony\Component\Image\Image\Box; +use Symfony\Component\Image\Image\BoxInterface; +use Symfony\Component\Image\Image\Point; +use Symfony\Component\Image\Tests\TestCase; + +class PointTest extends TestCase +{ + /** + * @covers \Symfony\Component\Image\Image\Point::getX + * @covers \Symfony\Component\Image\Image\Point::getY + * @covers \Symfony\Component\Image\Image\Point::in + * + * @dataProvider getCoordinates + * + * @param integer $x + * @param integer $y + * @param BoxInterface $box + * @param Boolean $expected + */ + public function testShouldAssignXYCoordinates($x, $y, BoxInterface $box, $expected) + { + $coordinate = new Point($x, $y); + + $this->assertEquals($x, $coordinate->getX()); + $this->assertEquals($y, $coordinate->getY()); + + $this->assertEquals($expected, $coordinate->in($box)); + } + + /** + * Data provider for testShouldAssignXYCoordinates + * + * @return array + */ + public function getCoordinates() + { + return array( + array(0, 0, new Box(5, 5), true), + array(5, 15, new Box(5, 5), false), + array(10, 23, new Box(10, 10), false), + array(42, 30, new Box(50, 50), true), + array(81, 16, new Box(50, 10), false), + ); + } + + /** + * @covers \Symfony\Component\Image\Image\Point::__construct + * + * @expectedException \Symfony\Component\Image\Exception\InvalidArgumentException + * + * @dataProvider getInvalidCoordinates + * + * @param integer $x + * @param integer $y + */ + public function testShouldThrowExceptionOnInvalidCoordinates($x, $y) + { + new Point($x, $y); + } + + /** + * Data provider for testShouldThrowExceptionOnInvalidCoordinates + * + * @return array + */ + public function getInvalidCoordinates() + { + return array( + array(-1, 0), + array(0, -1) + ); + } + + /** + * @covers \Symfony\Component\Image\Image\Point::getX + * @covers \Symfony\Component\Image\Image\Point::getY + * @covers \Symfony\Component\Image\Image\Point::move + * + * @dataProvider getMoves + * + * @param integer $x + * @param integer $y + * @param integer $move + * @param integer $x1 + * @param integer $y1 + */ + public function testShouldMoveByGivenAmount($x, $y, $move, $x1, $y1) + { + $point = new Point($x, $y); + $shift = $point->move($move); + + $this->assertEquals($x1, $shift->getX()); + $this->assertEquals($y1, $shift->getY()); + } + + public function getMoves() + { + return array( + array(0, 0, 5, 5, 5), + array(20, 30, 5, 25, 35), + array(0, 2, 7, 7, 9), + ); + } + + /** + * @covers \Symfony\Component\Image\Image\Point::__toString + */ + public function testToString() + { + $this->assertEquals('(50, 50)', (string) new Point(50, 50)); + } +} diff --git a/src/Symfony/Component/Image/Tests/Image/ProfileTest.php b/src/Symfony/Component/Image/Tests/Image/ProfileTest.php new file mode 100644 index 0000000000000..504bbc5e9b6d0 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Image/ProfileTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Image; + +use Symfony\Component\Image\Image\Profile; +use Symfony\Component\Image\Tests\TestCase; + +class ProfileTest extends TestCase +{ + public function testName() + { + $profile = new Profile('romain', 'neutron'); + $this->assertEquals('romain', $profile->name()); + } + + public function testData() + { + $profile = new Profile('romain', 'neutron'); + $this->assertEquals('neutron', $profile->data()); + } + + public function testFromPath() + { + $file = __DIR__ . '/../../Resources/Adobe/CMYK/JapanColor2001Uncoated.icc'; + $profile = Profile::fromPath($file); + + $this->assertEquals(basename($file), $profile->name()); + $this->assertEquals(file_get_contents($file), $profile->data()); + } + + /** + * @expectedException \Symfony\Component\Image\Exception\InvalidArgumentException + */ + public function testFromInvalidPath() + { + $file = __DIR__ . '/non-existent-profile.icc'; + Profile::fromPath($file); + } +} diff --git a/src/Symfony/Component/Image/Tests/Imagick/DrawerTest.php b/src/Symfony/Component/Image/Tests/Imagick/DrawerTest.php new file mode 100644 index 0000000000000..ecef159ee360b --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Imagick/DrawerTest.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Imagick; + +use Symfony\Component\Image\Imagick\Loader; +use Symfony\Component\Image\Tests\Draw\AbstractDrawerTest; + +class DrawerTest extends AbstractDrawerTest +{ + protected function setUp() + { + parent::setUp(); + + if (!class_exists('Imagick')) { + $this->markTestSkipped('Imagick is not installed'); + } + } + + protected function getLoader() + { + return new Loader(); + } + + protected function isFontTestSupported() + { + return true; + } +} diff --git a/src/Symfony/Component/Image/Tests/Imagick/EffectsTest.php b/src/Symfony/Component/Image/Tests/Imagick/EffectsTest.php new file mode 100644 index 0000000000000..322657a07c072 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Imagick/EffectsTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Imagick; + +use Symfony\Component\Image\Imagick\Loader; +use Symfony\Component\Image\Tests\Effects\AbstractEffectsTest; + +class EffectsTest extends AbstractEffectsTest +{ + protected function setUp() + { + parent::setUp(); + + if (!class_exists('Imagick')) { + $this->markTestSkipped('Imagick is not installed'); + } + } + + protected function getLoader() + { + return new Loader(); + } + + protected function getGrayValue() + { + return '#555555'; + } + + protected function getComponentGrayValue() + { + return 85; + } +} diff --git a/src/Symfony/Component/Image/Tests/Imagick/ImageTest.php b/src/Symfony/Component/Image/Tests/Imagick/ImageTest.php new file mode 100644 index 0000000000000..22951c46a6fed --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Imagick/ImageTest.php @@ -0,0 +1,154 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Imagick; + +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\Metadata\MetadataBag; +use Symfony\Component\Image\Image\Point; +use Symfony\Component\Image\Imagick\Loader; +use Symfony\Component\Image\Imagick\Image; +use Symfony\Component\Image\Image\Palette\CMYK; +use Symfony\Component\Image\Image\Palette\RGB; +use Symfony\Component\Image\Tests\Image\AbstractImageTest; +use Symfony\Component\Image\Image\Box; +use Symfony\Component\Image\Imagick\Image as ImagickImage; + +class ImageTest extends AbstractImageTest +{ + protected function setUp() + { + parent::setUp(); + + if (!class_exists('Imagick')) { + $this->markTestSkipped('Imagick is not installed'); + } + } + + protected function tearDown() + { + if (class_exists('Imagick')) { + $prop = new \ReflectionProperty(ImagickImage::class, 'supportsColorspaceConversion'); + $prop->setAccessible(true); + $prop->setValue(null); + } + + parent::tearDown(); + } + + protected function getLoader() + { + return new Loader(); + } + + public function testImageResizeUsesProperMethodBasedOnInputAndOutputSizes() + { + $imagine = $this->getLoader(); + + $image = $imagine->open(__DIR__.'/../Fixtures/resize/210-design-19933.jpg'); + + $image + ->resize(new Box(1500, 750)) + ->save(__DIR__.'/../Fixtures/resize/large.png') + ; + + $this->assertSame(1500, $image->getSize()->getWidth()); + $this->assertSame(750, $image->getSize()->getHeight()); + + $image + ->resize(new Box(100, 50)) + ->save(__DIR__.'/../Fixtures/resize/small.png') + ; + + $this->assertSame(100, $image->getSize()->getWidth()); + $this->assertSame(50, $image->getSize()->getHeight()); + + unlink(__DIR__.'/../Fixtures/resize/large.png'); + unlink(__DIR__.'/../Fixtures/resize/small.png'); + } + + public function testAnimatedGifResize() + { + $imagine = $this->getLoader(); + $image = $imagine->open(__DIR__.'/../Fixtures/anima3.gif'); + $image + ->resize(new Box(150, 100)) + ->save(__DIR__.'/../Fixtures/resize/anima3-150x100-actual.gif', array('animated' => true)) + ; + $this->assertImageEquals( + $imagine->open(__DIR__.'/../Fixtures/resize/anima3-150x100.gif'), + $imagine->open(__DIR__.'/../Fixtures/resize/anima3-150x100-actual.gif') + ); + unlink(__DIR__.'/../Fixtures/resize/anima3-150x100-actual.gif'); + } + + // Older imagemagick versions does not support colorspace conversion + public function testOlderImageMagickDoesNotAffectColorspaceUsageOnConstruct() + { + if (!$this->supportsMockingImagick()) { + $this->markTestSkipped('Imagick can not be mocked on this platform'); + } + + $palette = new CMYK(); + $imagick = $this->getMockBuilder('\Imagick')->disableOriginalConstructor()->getMock(); + $imagick->expects($this->any()) + ->method('setColorspace') + ->will($this->throwException(new \RuntimeException('Method not supported'))); + + $prop = new \ReflectionProperty(ImagickImage::class, 'supportsColorspaceConversion'); + $prop->setAccessible(true); + $prop->setValue(false); + + // Avoid test marked as risky + $this->assertTrue(true); + + return new Image($imagick, $palette, new MetadataBag()); + } + + /** + * @depends testOlderImageMagickDoesNotAffectColorspaceUsageOnConstruct + * @expectedException \Symfony\Component\Image\Exception\RuntimeException + * @expectedExceptionMessage Your version of Imagick does not support colorspace conversions. + */ + public function testOlderImageMagickDoesNotAffectColorspaceUsageOnPaletteChange($image) + { + $image->usePalette(new RGB()); + } + + public function testAnimatedGifCrop() + { + $imagine = $this->getLoader(); + $image = $imagine->open(__DIR__.'/../Fixtures/anima3.gif'); + $image + ->crop( + new Point(0, 0), + new Box(150, 100) + ) + ->save(__DIR__.'/../Fixtures/crop/anima3-topleft-actual.gif', array('animated' => true)) + ; + $this->assertImageEquals( + $imagine->open(__DIR__.'/../Fixtures/crop/anima3-topleft.gif'), + $imagine->open(__DIR__.'/../Fixtures/crop/anima3-topleft-actual.gif') + ); + unlink(__DIR__.'/../Fixtures/crop/anima3-topleft-actual.gif'); + } + + + protected function supportMultipleLayers() + { + return true; + } + + protected function getImageResolution(ImageInterface $image) + { + return $image->getImagick()->getImageResolution(); + } +} diff --git a/src/Symfony/Component/Image/Tests/Imagick/LayersTest.php b/src/Symfony/Component/Image/Tests/Imagick/LayersTest.php new file mode 100644 index 0000000000000..afad59e7a7ec4 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Imagick/LayersTest.php @@ -0,0 +1,125 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Imagick; + +use Symfony\Component\Image\Image\Metadata\MetadataBag; +use Symfony\Component\Image\Imagick\Image; +use Symfony\Component\Image\Imagick\Layers; +use Symfony\Component\Image\Imagick\Loader; +use Symfony\Component\Image\Tests\Image\AbstractLayersTest; +use Symfony\Component\Image\Image\ImageInterface; +use Symfony\Component\Image\Image\Palette\RGB; + +class LayersTest extends AbstractLayersTest +{ + protected function setUp() + { + parent::setUp(); + + if (!class_exists('Imagick')) { + $this->markTestSkipped('Imagick is not installed'); + } + } + + public function testCount() + { + if (!$this->supportsMockingImagick()) { + $this->markTestSkipped('Imagick can not be mocked on this platform'); + } + + $palette = new RGB(); + $resource = $this->getMockBuilder('\Imagick')->getMock(); + + $resource->expects($this->once()) + ->method('getNumberImages') + ->will($this->returnValue(42)); + + $layers = new Layers(new Image($resource, $palette, new MetadataBag()), $palette, $resource); + + $this->assertCount(42, $layers); + } + + public function testGetLayer() + { + if (!$this->supportsMockingImagick()) { + $this->markTestSkipped('Imagick can not be mocked on this platform'); + } + + $palette = new RGB(); + $resource = $this->getMockBuilder('\Imagick')->getMock(); + + $resource->expects($this->any()) + ->method('getNumberImages') + ->will($this->returnValue(2)); + + $layer = $this->getMockBuilder('\Imagick')->getMock(); + + $resource->expects($this->any()) + ->method('getImage') + ->will($this->returnValue($layer)); + + $layers = new Layers(new Image($resource, $palette, new MetadataBag()), $palette, $resource); + + foreach ($layers as $layer) { + $this->assertInstanceOf(ImageInterface::class, $layer); + } + } + + public function testCoalesce() + { + $width = null; + $height = null; + + $resource = new \Imagick; + $palette = new RGB(); + $resource->newImage(20, 10, new \ImagickPixel("black")); + $resource->newImage(10, 10, new \ImagickPixel("black")); + + $layers = new Layers(new Image($resource, $palette, new MetadataBag()), $palette, $resource); + $layers->coalesce(); + + foreach ($layers as $layer) { + $size = $layer->getSize(); + + if ($width === null) { + $width = $size->getWidth(); + } else { + $this->assertEquals($width, $size->getWidth()); + } + + if ($height === null) { + $height = $size->getHeight(); + } else { + $this->assertEquals($height, $size->getHeight()); + } + } + } + + public function getImage($path = null) + { + if ($path) { + return new Image(new \Imagick($path), new RGB(), new MetadataBag()); + } else { + return new Image(new \Imagick(), new RGB(), new MetadataBag()); + } + } + + protected function getLoader() + { + return new Loader(); + } + + protected function assertLayersEquals($expected, $actual) + { + $this->assertEquals($expected->getImagick(), $actual->getImagick()); + } +} diff --git a/src/Symfony/Component/Image/Tests/Imagick/LoaderTest.php b/src/Symfony/Component/Image/Tests/Imagick/LoaderTest.php new file mode 100644 index 0000000000000..a37b8420b9359 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Imagick/LoaderTest.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests\Imagick; + +use Symfony\Component\Image\Imagick\Loader; +use Symfony\Component\Image\Tests\Image\AbstractLoaderTest; +use Symfony\Component\Image\Image\Box; + +class LoaderTest extends AbstractLoaderTest +{ + protected function setUp() + { + parent::setUp(); + + if (!class_exists('Imagick')) { + $this->markTestSkipped('Imagick is not installed'); + } + } + + public function testShouldOpenAnHttpImage() + { + if (defined('HHVM_VERSION_ID')) { + $this->markTestSkipped('Imagick on HHVM does not support opening URLs'); + } + + return parent::testShouldOpenAnHttpImage(); + } + + protected function getEstimatedFontBox() + { + return new Box(117, 55); + } + + protected function getLoader() + { + return new Loader(); + } + + protected function isFontTestSupported() + { + return true; + } +} diff --git a/src/Symfony/Component/Image/Tests/Issues/Issue131Test.php b/src/Symfony/Component/Image/Tests/Issues/Issue131Test.php new file mode 100644 index 0000000000000..bb332345fae7c --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Issues/Issue131Test.php @@ -0,0 +1,106 @@ +isFile()) { + $filenames[] = $fileinfo->getPathname(); + } + } + + return $filenames; + } + + private function getImagickLoader($file) + { + try { + $imagine = new ImagickLoader(); + $image = $imagine->open($file); + } catch (RuntimeException $e) { + $this->markTestSkipped($e->getMessage()); + } + + return $image; + } + + private function getGmagickLoader($file) + { + try { + $imagine = new GmagickLoader(); + $image = $imagine->open($file); + } catch (RuntimeException $e) { + $this->markTestSkipped($e->getMessage()); + } + + return $image; + } + + public function testShouldSaveOneFileWithImagick() + { + $dir = realpath($this->getTemporaryDir()); + $targetFile = $dir . '/myfile.png'; + + $imagine = $this->getImagickLoader(__DIR__ . '/multi-layer.psd'); + + $imagine->save($targetFile); + + if ( ! $this->probeOneFileAndCleanup($dir, $targetFile)) { + $this->fail('Imagick failed to generate one file'); + } + } + + public function testShouldSaveOneFileWithGmagick() + { + $dir = realpath($this->getTemporaryDir()); + $targetFile = $dir . '/myfile.png'; + + $imagine = $this->getGmagickLoader(__DIR__ . '/multi-layer.psd'); + + $imagine->save($targetFile); + + if ( ! $this->probeOneFileAndCleanup($dir, $targetFile)) { + $this->fail('Gmagick failed to generate one file'); + } + } + + private function probeOneFileAndCleanup($dir, $targetFile) + { + $this->assertFileExists($targetFile); + + $retval = true; + $files = $this->getDirContent($dir); + $retval = $retval && count($files) === 1; + $file = current($files); + $retval = $retval && $targetFile === $file; + + foreach ($files as $file) { + unlink($file); + } + + rmdir($dir); + + return $retval; + } +} diff --git a/src/Symfony/Component/Image/Tests/Issues/Issue17Test.php b/src/Symfony/Component/Image/Tests/Issues/Issue17Test.php new file mode 100644 index 0000000000000..bbf41bc9c486d --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Issues/Issue17Test.php @@ -0,0 +1,41 @@ +markTestSkipped($e->getMessage()); + } + + return $imagine; + } + + public function testShouldResize() + { + $size = new Box(100, 10); + $imagine = $this->getLoader(); + + $imagine->open(__DIR__.'/../Fixtures/large.jpg') + ->thumbnail($size, ImageInterface::THUMBNAIL_OUTBOUND) + ->save(__DIR__.'/../Fixtures/resized.jpg'); + + $this->assertTrue(file_exists(__DIR__.'/../Fixtures/resized.jpg')); + $this->assertEquals( + $size, + $imagine->open(__DIR__.'/../Fixtures/resized.jpg')->getSize() + ); + + unlink(__DIR__.'/../Fixtures/resized.jpg'); + } +} diff --git a/src/Symfony/Component/Image/Tests/Issues/Issue59Test.php b/src/Symfony/Component/Image/Tests/Issues/Issue59Test.php new file mode 100644 index 0000000000000..39d794ca3abaa --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Issues/Issue59Test.php @@ -0,0 +1,37 @@ +markTestSkipped($e->getMessage()); + } + + return $imagine; + } + + public function testShouldSaveGifImageWithMoreThan256TransparentPixels() + { + $imagine = $this->getLoader(); + $new = sys_get_temp_dir()."/sample.jpeg"; + + $image = $imagine + ->open(__DIR__.'/../Fixtures/sample.gif') + ->save($new) + ; + + $this->assertSame(700, $image->getSize()->getWidth()); + $this->assertSame(440, $image->getSize()->getHeight()); + + unlink($new); + } +} diff --git a/src/Symfony/Component/Image/Tests/Issues/Issue67Test.php b/src/Symfony/Component/Image/Tests/Issues/Issue67Test.php new file mode 100644 index 0000000000000..148fb8363b180 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/Issues/Issue67Test.php @@ -0,0 +1,34 @@ +markTestSkipped($e->getMessage()); + } + + return $imagine; + } + + /** + * @expectedException \Symfony\Component\Image\Exception\RuntimeException + */ + public function testShouldThrowExceptionNotError() + { + $invalidPath = '/thispathdoesnotexist'; + + $imagine = $this->getLoader(); + + $imagine->open(__DIR__.'/../Fixtures/large.jpg') + ->save($invalidPath . '/myfile.jpg'); + } +} diff --git a/src/Symfony/Component/Image/Tests/Issues/multi-layer.psd b/src/Symfony/Component/Image/Tests/Issues/multi-layer.psd new file mode 100644 index 0000000000000..14e6e872349a8 Binary files /dev/null and b/src/Symfony/Component/Image/Tests/Issues/multi-layer.psd differ diff --git a/src/Symfony/Component/Image/Tests/TestCase.php b/src/Symfony/Component/Image/Tests/TestCase.php new file mode 100644 index 0000000000000..2da59f9815767 --- /dev/null +++ b/src/Symfony/Component/Image/Tests/TestCase.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Image\Tests; + +use PHPUnit\Framework\TestCase as PHPUnitTestCase; +use Symfony\Component\Image\Tests\Constraint\IsImageEqual; + +class TestCase extends PHPUnitTestCase +{ + const HTTP_IMAGE = 'http://imagine.readthedocs.org/en/latest/_static/logo.jpg'; + + private static $supportMockingImagick; + + /** + * Asserts that two images are equal using color histogram comparison method + * + * @param ImageInterface $expected + * @param ImageInterface $actual + * @param string $message + * @param float $delta + * @param integer $buckets + */ + public static function assertImageEquals($expected, $actual, $message = '', $delta = 0.1, $buckets = 4) + { + $constraint = new IsImageEqual($expected, $delta, $buckets); + + self::assertThat($actual, $constraint, $message); + } + + public function setExpectedException($exception, $message = null, $code = null) + { + if (method_exists(parent::class, 'expectException')) { + parent::expectException($exception); + if (null !== $message) { + parent::expectExceptionMessage($message); + } + if (null !== $code) { + parent::expectExceptionCode($code); + } + } else { + return parent::setExpectedException($exception, $message, $code); + } + } + + /** + * Actually it's not possible on some HHVM versions + */ + protected function supportsMockingImagick() + { + if (null !== self::$supportMockingImagick) { + return self::$supportMockingImagick; + } + + try { + @$this->getMockBuilder('\Imagick')->disableOriginalConstructor()->getMock(); + } catch (\Exception $e) { + return self::$supportMockingImagick = false; + } + + return self::$supportMockingImagick = true; + } +} diff --git a/src/Symfony/Component/Image/composer.json b/src/Symfony/Component/Image/composer.json new file mode 100644 index 0000000000000..40eb1c0e4b829 --- /dev/null +++ b/src/Symfony/Component/Image/composer.json @@ -0,0 +1,48 @@ +{ + "name": "symfony/image", + "type": "library", + "description": "Symfony Image Component", + "keywords": [ + "image manipulation", + "image processing", + "drawing", + "graphics" + ], + "homepage": "https://symfony.com/", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + }, + { + "name": "Bulat Shakirzyanov", + "email": "mallluhuct@gmail.com", + "homepage": "http://avalanche123.com" + } + ], + "require": { + "php": ">=5.5.9" + }, + "suggest": { + "ext-gd": "to use the Symfony Image GD implementation", + "ext-imagick": "to use the Symfony Image Imagick implementation", + "ext-gmagick": "to use the Symfony Image Gmagick implementation" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Image\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "3.3-dev" + } + } +} diff --git a/src/Symfony/Component/Image/phpunit.xml.dist b/src/Symfony/Component/Image/phpunit.xml.dist new file mode 100644 index 0000000000000..16f33f76e4fdd --- /dev/null +++ b/src/Symfony/Component/Image/phpunit.xml.dist @@ -0,0 +1,28 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Tests + ./vendor + + + +