├── .codecov.yml ├── .editorconfig ├── .gitattributes ├── .gitignore ├── .php-cs-fixer.dist.php ├── LICENSE ├── README.md ├── composer.json ├── phpstan.neon ├── phpunit.xml.dist └── src ├── Image ├── Dimensions.php ├── Hash │ └── ThumbHash.php ├── Transformer.php └── Transformer │ ├── FileTransformer.php │ ├── GdImageTransformer.php │ ├── ImagickTransformer.php │ ├── ImagineTransformer.php │ ├── InterventionTransformer.php │ ├── MultiTransformer.php │ └── SpatieImageTransformer.php └── ImageFileInfo.php /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: auto 6 | threshold: 1% 7 | patch: 8 | default: 9 | target: auto 10 | threshold: 50% 11 | 12 | comment: false 13 | github_checks: 14 | annotations: false 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.yml] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.github export-ignore 2 | /tests export-ignore 3 | phpunit.dist.xml export-ignore 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /composer.lock 2 | /phpunit.xml 3 | /vendor/ 4 | /build/ 5 | /var/ 6 | /.php-cs-fixer.cache 7 | /.phpunit.result.cache 8 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | [!NOTE] 18 | > `Zenstruck\ImageFileInfo` extends `\SplFileInfo`. 19 | 20 | ```php 21 | use Zenstruck\ImageFileInfo; 22 | 23 | $image = ImageFileInfo::wrap('some/local.jpg'); // create from local file 24 | $image = ImageFileInfo::from($resource); // create from resource/stream (in a temp file) 25 | 26 | // dimensional information 27 | $image->dimensions()->height(); // int 28 | $image->dimensions()->width(); // int 29 | $image->dimensions()->aspectRatio(); // float 30 | $image->dimensions()->pixels(); // int 31 | $image->dimensions()->isSquare(); // bool 32 | $image->dimensions()->isLandscape(); // bool 33 | $image->dimensions()->isPortrait(); // bool 34 | 35 | // other metadata 36 | $image->mimeType(); // string (ie "image/jpeg") 37 | $image->guessExtension(); // string - the extension if available or guess from mime-type 38 | $image->iptc(); // array - IPTC data (if the image supports) 39 | $image->exif(); // array - EXIF data (if the image supports) 40 | 41 | // utility 42 | $image->refresh(); // self - clear any cached metadata 43 | $image->delete(); // void - delete the image file 44 | 45 | // access any \SplFileInfo methods 46 | $image->getMTime(); 47 | ``` 48 | 49 | > [!NOTE] 50 | > Images created with `ImageFileInfo::from()` are created in unique temporary files 51 | > and deleted at the end of the script. 52 | 53 | ### Transformations 54 | 55 | The following transformers are available: 56 | 57 | - [GD](https://www.php.net/manual/en/book.image.php) 58 | - [Imagick](https://www.php.net/manual/en/book.imagick.php) 59 | - [intervention\image](https://github.com/Intervention/image) 60 | - [imagine\imagine](https://github.com/php-imagine/Imagine) 61 | - [spatie\image](https://github.com/spatie/image) 62 | 63 | To use the desired transformer, type-hint the first parameter of the callable 64 | passed to `Zenstruck\ImageFileInfo::transform()` with the desired transformer's 65 | _image object_: 66 | 67 | - **GD**: `\GdImage` 68 | - **Imagick**: `\Imagick` 69 | - **intervention\image**: `Intervention\Image\Image` 70 | - **imagine\imagine**: `Imagine\Image\ImageInterface` 71 | - **spatie\image**: `Spatie\Image\Image` 72 | 73 | > [!NOTE] 74 | > The return value of the callable must be the same as the passed parameter. 75 | 76 | The following example uses `\GdImage` but any of the above type-hints can be used. 77 | 78 | ```php 79 | /** @var Zenstruck\ImageFileInfo $image */ 80 | 81 | $resized = $image->transform(function(\GdImage $image): \GdImage { 82 | // perform desired manipulations... 83 | 84 | return $image; 85 | }); // a new temporary Zenstruck\ImageFileInfo instance (deleted at the end of the script) 86 | 87 | // configure the format 88 | $resized = $image->transform( 89 | function(\GdImage $image): \GdImage { 90 | // perform desired manipulations... 91 | 92 | return $image; 93 | }, 94 | ['format' => 'png'] 95 | ); 96 | 97 | // configure the path for the created file 98 | $resized = $image->transform( 99 | function(\GdImage $image): \GdImage { 100 | // perform desired manipulations... 101 | 102 | return $image; 103 | }, 104 | ['output' => 'path/to/file.jpg'] 105 | ); 106 | ``` 107 | 108 | #### Transform "In Place" 109 | 110 | ```php 111 | /** @var Zenstruck\ImageFileInfo $image */ 112 | 113 | $resized = $image->transformInPlace(function(\GdImage $image): \GdImage { 114 | // perform desired manipulations... 115 | 116 | return $image; 117 | }); // overwrites the original image file 118 | ``` 119 | 120 | #### Filter Objects 121 | 122 | Both _Imagine_ and _Intervention_ have the concept of _filters_. These are objects 123 | that can be passed directly to `transform()` and `transformInPlace()`: 124 | 125 | ```php 126 | /** @var Imagine\Filter\FilterInterface $imagineFilter */ 127 | /** @var Intervention\Image\Filters\FilterInterface|Intervention\Image\Interfaces\ModifierInterface $interventionFilter */ 128 | /** @var Zenstruck\ImageFileInfo $image */ 129 | 130 | $transformed = $image->transform($imagineFilter); 131 | $transformed = $image->transform($interventionFilter); 132 | 133 | $image->transformInPlace($imagineFilter); 134 | $image->transformInPlace($interventionFilter); 135 | ``` 136 | 137 | ##### Custom Filter Objects 138 | 139 | Because `transform()` and `transformInPlace()` accept any callable, you can wrap complex 140 | transformations into invokable _filter objects_: 141 | 142 | ```php 143 | class GreyscaleThumbnail 144 | { 145 | public function __construct(private int $width, private int $height) 146 | { 147 | } 148 | 149 | public function __invoke(\GdImage $image): \GdImage 150 | { 151 | // greyscale and resize to $this->width/$this->height 152 | 153 | return $image; 154 | } 155 | } 156 | ``` 157 | 158 | To use, pass a new instance to `transform()` or `transformInPlace()`: 159 | 160 | ```php 161 | /** @var Zenstruck\ImageFileInfo $image */ 162 | 163 | $thumbnail = $image->transform(new GreyscaleThumbnail(200, 200)); 164 | 165 | $image->transformInPlace(new GreyscaleThumbnail(200, 200)); 166 | ``` 167 | 168 | #### Transformation Object 169 | 170 | `Zenstruck\ImageFileInfo::as()` returns a new instance of the desired 171 | transformation library's _image object_: 172 | 173 | ```php 174 | use Imagine\Image\ImageInterface; 175 | 176 | /** @var Zenstruck\ImageFileInfo $image */ 177 | 178 | $image->as(ImageInterface::class); // ImageInterface object for this image 179 | $image->as(\Imagick::class); // \Imagick object for this image 180 | ``` 181 | 182 | ### ThumbHash 183 | 184 | > A very compact representation of an image placeholder. Store it inline with your data and show 185 | > it while the real image is loading for a smoother loading experience. 186 | > 187 | > **-- [evanw.github.io/thumbhash](https://evanw.github.io/thumbhash/)** 188 | 189 | > [!NOTE] 190 | > [`srwiez/thumbhash`](https://github.com/SRWieZ/thumbhash) is required for this feature 191 | > (install with `composer require srwiez/thumbhash`). 192 | 193 | > [!NOTE] 194 | > [`Imagick`](https://www.php.net/manual/en/book.imagick.php) is required for this feature. 195 | 196 | #### Generate from Image 197 | 198 | ```php 199 | use Zenstruck\Image\Hash\ThumbHash; 200 | 201 | /** @var Zenstruck\ImageFileInfo $image */ 202 | 203 | $thumbHash = $image->thumbHash(); // ThumbHash 204 | 205 | $thumbHash->dataUri(); // string - the ThumbHash as a data-uri 206 | $thumbHash->approximateAspectRatio(); // float - the approximate aspect ratio 207 | $thumbHash->key(); // string - small string representation that can be cached/stored in a database 208 | ``` 209 | 210 | > [!CAUTION] 211 | > Generating from an image can be slow depending on the size of the source image. It is recommended 212 | > to cache the data-uri and/or key for subsequent requests of the same ThumbHash image. 213 | 214 | #### Generate from Key 215 | 216 | When generating from an image, the `ThumbHash::key()` method returns a small string that 217 | can be stored for later use. This key can be used to generate the ThumbHash without 218 | needing to re-process the image. 219 | 220 | ```php 221 | use Zenstruck\Image\Hash\ThumbHash; 222 | 223 | /** @var string $key */ 224 | 225 | $thumbHash = ThumbHash::fromKey($key); // ThumbHash 226 | 227 | $thumbHash->dataUri(); // string - the ThumbHash as a data-uri 228 | $thumbHash->approximateAspectRatio(); // float - the approximate aspect ratio 229 | ``` 230 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zenstruck/image", 3 | "description": "Image file wrapper with generic transformation support.", 4 | "homepage": "https://github.com/zenstruck/image", 5 | "type": "library", 6 | "license": "MIT", 7 | "keywords": ["image", "transformation", "manipulation", "gd", "imagick", "imagine", "intervention"], 8 | "authors": [ 9 | { 10 | "name": "Kevin Bond", 11 | "email": "kevinbond@gmail.com" 12 | } 13 | ], 14 | "require": { 15 | "php": ">=8.0", 16 | "symfony/polyfill-php81": "^1.26", 17 | "zenstruck/temp-file": "^1.0" 18 | }, 19 | "require-dev": { 20 | "imagine/imagine": "^1.3", 21 | "intervention/image": "^2.7|^3.0", 22 | "phpstan/phpstan": "^1.4", 23 | "phpunit/phpunit": "^9.6.19", 24 | "psr/container": "^1.0|^2.0", 25 | "spatie/image": "^2.0|^3.2", 26 | "srwiez/thumbhash": "^1.2", 27 | "symfony/phpunit-bridge": "^6.1|^7.0", 28 | "symfony/var-dumper": "^5.4|^6.0|^7.0" 29 | }, 30 | "config": { 31 | "preferred-install": "dist", 32 | "sort-packages": true 33 | }, 34 | "autoload": { 35 | "psr-4": { "Zenstruck\\": ["src/"] } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { "Zenstruck\\Image\\Tests\\": ["tests/"] } 39 | }, 40 | "suggest": { 41 | "imagine/imagine": "To use the Imagine image transformer.", 42 | "intervention/image": "To use the Intervention image transformer.", 43 | "srwiez/thumbhash": "To generate ThumbHashes." 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 8 3 | paths: 4 | - src 5 | ignoreErrors: 6 | - '#no value type specified in iterable type array#' 7 | 8 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | tests 19 | 20 | 21 | 22 | 23 | 24 | src 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/Image/Dimensions.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Image; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class Dimensions implements \JsonSerializable 18 | { 19 | /** @var array{0:int,1:int} */ 20 | private array $normalizedValues; 21 | 22 | /** 23 | * @param array{0:int,1:int}|array{width:int,height:int}|callable():(array{0:int,1:int}|array{width:int,height:int}) $values 24 | */ 25 | public function __construct(private $values) 26 | { 27 | } 28 | 29 | public function jsonSerialize(): array 30 | { 31 | return [ 32 | 'width' => $this->width(), 33 | 'height' => $this->height(), 34 | ]; 35 | } 36 | 37 | public function width(): int 38 | { 39 | return $this->values()[0]; 40 | } 41 | 42 | public function height(): int 43 | { 44 | return $this->values()[1]; 45 | } 46 | 47 | public function aspectRatio(): float 48 | { 49 | return $this->width() / $this->height(); 50 | } 51 | 52 | public function pixels(): int 53 | { 54 | return $this->width() * $this->height(); 55 | } 56 | 57 | public function isSquare(): bool 58 | { 59 | return $this->width() === $this->height(); 60 | } 61 | 62 | public function isPortrait(): bool 63 | { 64 | return $this->height() > $this->width(); 65 | } 66 | 67 | public function isLandscape(): bool 68 | { 69 | return $this->width() > $this->height(); 70 | } 71 | 72 | /** 73 | * @return array{0:int,1:int} 74 | */ 75 | private function values(): array 76 | { 77 | if (isset($this->normalizedValues)) { 78 | return $this->normalizedValues; 79 | } 80 | 81 | if (\is_callable($this->values)) { 82 | $this->values = ($this->values)(); 83 | } 84 | 85 | return $this->normalizedValues = [ 86 | $this->values['width'] ?? $this->values[0] ?? throw new \InvalidArgumentException('Could not determine width.'), 87 | $this->values['height'] ?? $this->values[1] ?? throw new \InvalidArgumentException('Could not determine height.'), 88 | ]; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Image/Hash/ThumbHash.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Image\Hash; 13 | 14 | use Zenstruck\ImageFileInfo; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class ThumbHash 20 | { 21 | /** @var list */ 22 | private array $hash; 23 | private string $dataUri; 24 | 25 | private function __construct(private \SplFileInfo|string $source) 26 | { 27 | if (!\class_exists(\Thumbhash\Thumbhash::class)) { 28 | throw new \LogicException(\sprintf('"%s" requires the "srwiez/thumbhash" package to be installed. Run "composer require srwiez/thumbhash".', self::class)); 29 | } 30 | 31 | if (!\class_exists(\Imagick::class)) { 32 | throw new \LogicException(\sprintf('"%s" requires the "imagick" extension to be installed.', self::class)); 33 | } 34 | } 35 | 36 | /** 37 | * Create from either an \SplFileInfo or a "key" string. 38 | */ 39 | public static function from(\SplFileInfo|string $source): self 40 | { 41 | return new self($source); 42 | } 43 | 44 | public function dataUri(): string 45 | { 46 | return $this->dataUri ??= \Thumbhash\Thumbhash::toDataURL($this->hash()); 47 | } 48 | 49 | public function key(): string 50 | { 51 | if (\is_string($this->source)) { 52 | return $this->source; 53 | } 54 | 55 | return $this->source = \Thumbhash\Thumbhash::convertHashToString($this->hash()); 56 | } 57 | 58 | /** 59 | * @return list 60 | */ 61 | public function hash(): array 62 | { 63 | if (isset($this->hash)) { 64 | return $this->hash; 65 | } 66 | 67 | if (\is_string($this->source)) { 68 | return $this->hash = \Thumbhash\Thumbhash::convertStringToHash($this->source); 69 | } 70 | 71 | [$width, $height, $pixels] = self::extractSizeAndPixels($this->source); 72 | 73 | return $this->hash = \Thumbhash\Thumbhash::RGBAToHash($width, $height, $pixels); 74 | } 75 | 76 | public function approximateAspectRatio(): float 77 | { 78 | return \Thumbhash\Thumbhash::toApproximateAspectRatio($this->hash()); 79 | } 80 | 81 | /** 82 | * @see \Thumbhash\extract_size_and_pixels_with_imagick() 83 | * 84 | * @return array{int, int, array} 85 | */ 86 | private static function extractSizeAndPixels(\SplFileInfo $file): array 87 | { 88 | $image = ImageFileInfo::wrap($file)->as(\Imagick::class); 89 | 90 | if ($image->getImageWidth() > 100 || $image->getImageHeight() > 100) { 91 | $image->scaleImage(100, 100, bestfit: true); 92 | } 93 | 94 | $width = $image->getImageWidth(); 95 | $height = $image->getImageHeight(); 96 | $pixels = []; 97 | 98 | for ($y = 0; $y < $height; ++$y) { 99 | for ($x = 0; $x < $width; ++$x) { 100 | $pixel = $image->getImagePixelColor($x, $y); 101 | $colors = $pixel->getColor(2); 102 | $pixels[] = $colors['r']; 103 | $pixels[] = $colors['g']; 104 | $pixels[] = $colors['b']; 105 | $pixels[] = $colors['a']; 106 | } 107 | } 108 | 109 | return [$width, $height, $pixels]; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Image/Transformer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Image; 13 | 14 | /** 15 | * @author Kevin Bond 16 | * 17 | * @internal 18 | * 19 | * @template T of object 20 | */ 21 | interface Transformer 22 | { 23 | /** 24 | * @param object|callable(T):T $filter 25 | */ 26 | public function transform(\SplFileInfo $image, callable|object $filter, array $options = []): \SplFileInfo; 27 | 28 | /** 29 | * @return T 30 | */ 31 | public function object(\SplFileInfo $image): object; 32 | } 33 | -------------------------------------------------------------------------------- /src/Image/Transformer/FileTransformer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Image\Transformer; 13 | 14 | use Zenstruck\Image\Transformer; 15 | use Zenstruck\ImageFileInfo; 16 | use Zenstruck\TempFile; 17 | 18 | /** 19 | * @author Kevin Bond 20 | * 21 | * @internal 22 | * 23 | * @template T of object 24 | * @implements Transformer 25 | */ 26 | abstract class FileTransformer implements Transformer 27 | { 28 | final public function transform(\SplFileInfo $image, callable|object $filter, array $options = []): \SplFileInfo 29 | { 30 | $filter = static::normalizeFilter($filter); 31 | $image = ImageFileInfo::wrap($image); 32 | $options['format'] ??= $image->guessExtension(); 33 | $output = $options['output'] ??= TempFile::withExtension($options['format']); 34 | $options['output'] = (string) $options['output']; 35 | 36 | $transformed = $filter($this->object($image)); 37 | 38 | if (!\is_a($transformed, static::expectedClass())) { 39 | throw new \LogicException(\sprintf('Filter callback must return a "%s" object.', static::expectedClass())); 40 | } 41 | 42 | $this->save($transformed, $options); 43 | 44 | return ImageFileInfo::wrap($output); 45 | } 46 | 47 | /** 48 | * @param object|callable(T):T $filter 49 | * 50 | * @return callable(T):T 51 | */ 52 | public static function normalizeFilter(callable|object $filter): callable 53 | { 54 | return \is_callable($filter) ? $filter : throw new \InvalidArgumentException(\sprintf('"%s" does not support "%s".', self::class, $filter::class)); 55 | } 56 | 57 | /** 58 | * @return class-string 59 | */ 60 | abstract protected static function expectedClass(): string; 61 | 62 | /** 63 | * @param T $object 64 | * @param array{format:string,output:string}|array $options 65 | */ 66 | abstract protected function save(object $object, array $options): void; 67 | } 68 | -------------------------------------------------------------------------------- /src/Image/Transformer/GdImageTransformer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Image\Transformer; 13 | 14 | /** 15 | * @author Jakub Caban 16 | * @author Kevin Bond 17 | * 18 | * @internal 19 | * 20 | * @extends FileTransformer<\GdImage> 21 | */ 22 | final class GdImageTransformer extends FileTransformer 23 | { 24 | public function __construct() 25 | { 26 | if (!\class_exists(\GdImage::class)) { 27 | throw new \LogicException('GD extension not available.'); 28 | } 29 | } 30 | 31 | public function object(\SplFileInfo $image): object 32 | { 33 | return @\imagecreatefromstring(\file_get_contents($image) ?: throw new \RuntimeException(\sprintf('Unable to read "%s".', $image))) ?: throw new \RuntimeException(\sprintf('Unable to create GdImage for "%s".', $image)); 34 | } 35 | 36 | protected static function expectedClass(): string 37 | { 38 | return \GdImage::class; 39 | } 40 | 41 | protected function save(object $object, array $options): void 42 | { 43 | /** @var string&callable $function */ 44 | $function = match ($options['format']) { 45 | 'png' => 'imagepng', 46 | 'jpg', 'jpeg' => 'imagejpeg', 47 | 'gif' => 'imagegif', 48 | 'webp' => 'imagewebp', 49 | 'avif' => 'imageavif', 50 | default => throw new \LogicException(\sprintf('Image format "%s" is invalid.', $options['format'])), 51 | }; 52 | 53 | if (!\function_exists($function)) { 54 | throw new \LogicException(\sprintf('The "%s" gd extension function is not available.', $function)); 55 | } 56 | 57 | $function($object, $options['output']); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Image/Transformer/ImagickTransformer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Image\Transformer; 13 | 14 | /** 15 | * @author Kevin Bond 16 | * @author Jakub Caban 17 | * 18 | * @internal 19 | * 20 | * @extends FileTransformer<\Imagick> 21 | */ 22 | final class ImagickTransformer extends FileTransformer 23 | { 24 | public function __construct() 25 | { 26 | if (!\class_exists(\Imagick::class)) { 27 | throw new \LogicException('Imagick extension not available.'); 28 | } 29 | } 30 | 31 | public function object(\SplFileInfo $image): object 32 | { 33 | $imagick = new \Imagick(); 34 | $imagick->readImage((string) $image); 35 | 36 | return $imagick; 37 | } 38 | 39 | protected static function expectedClass(): string 40 | { 41 | return \Imagick::class; 42 | } 43 | 44 | protected function save(object $object, array $options): void 45 | { 46 | $object->setImageFormat($options['format']); 47 | $object->writeImage($options['output']); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Image/Transformer/ImagineTransformer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Image\Transformer; 13 | 14 | use Imagine\Filter\FilterInterface; 15 | use Imagine\Gd\Image as GdImage; 16 | use Imagine\Gd\Imagine as GdImagine; 17 | use Imagine\Gmagick\Image as GmagickImage; 18 | use Imagine\Gmagick\Imagine as GmagickImagine; 19 | use Imagine\Image\ImageInterface; 20 | use Imagine\Image\ImagineInterface; 21 | use Imagine\Imagick\Image as ImagickImage; 22 | use Imagine\Imagick\Imagine as ImagickImagine; 23 | 24 | /** 25 | * @author Kevin Bond 26 | * 27 | * @internal 28 | * 29 | * @extends FileTransformer 30 | */ 31 | final class ImagineTransformer extends FileTransformer 32 | { 33 | public function __construct(private ImagineInterface $imagine) 34 | { 35 | } 36 | 37 | /** 38 | * @template T of ImageInterface 39 | * 40 | * @param class-string $class 41 | */ 42 | public static function createFor(string $class): self 43 | { 44 | if (!\interface_exists(ImageInterface::class)) { 45 | throw new \LogicException('imagine/imagine required. Install with "composer require imagine/imagine".'); 46 | } 47 | 48 | return match ($class) { 49 | ImageInterface::class, GdImage::class => new self(new GdImagine()), 50 | ImagickImage::class => new self(new ImagickImagine()), 51 | GmagickImage::class => new self(new GmagickImagine()), 52 | default => throw new \InvalidArgumentException('invalid class'), 53 | }; 54 | } 55 | 56 | public static function normalizeFilter(callable|object $filter): callable 57 | { 58 | if ($filter instanceof FilterInterface) { 59 | $filter = static fn(ImageInterface $i) => $filter->apply($i); 60 | } 61 | 62 | return parent::normalizeFilter($filter); 63 | } 64 | 65 | public function object(\SplFileInfo $image): object 66 | { 67 | return $this->imagine->open($image); 68 | } 69 | 70 | protected static function expectedClass(): string 71 | { 72 | return ImageInterface::class; 73 | } 74 | 75 | protected function save(object $object, array $options): void 76 | { 77 | $object->save($options['output'], $options); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Image/Transformer/InterventionTransformer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Image\Transformer; 13 | 14 | use Intervention\Image\Filters\FilterInterface; 15 | use Intervention\Image\Image as InterventionImage; 16 | use Intervention\Image\ImageManager; 17 | use Intervention\Image\ImageManagerStatic; 18 | use Intervention\Image\Interfaces\ImageInterface; 19 | use Intervention\Image\Interfaces\ModifierInterface; 20 | 21 | /** 22 | * @author Kevin Bond 23 | * @author Jakub Caban 24 | * 25 | * @internal 26 | * 27 | * @extends FileTransformer 28 | */ 29 | final class InterventionTransformer extends FileTransformer 30 | { 31 | public function __construct(private ?ImageManager $manager = null) 32 | { 33 | if (!\class_exists(ImageManager::class)) { 34 | throw new \LogicException('intervention/image required. Install with "composer require intervention/image".'); 35 | } 36 | } 37 | 38 | public static function normalizeFilter(callable|object $filter): callable 39 | { 40 | if ($filter instanceof FilterInterface) { // @phpstan-ignore-line 41 | $filter = static fn(InterventionImage $i) => $i->filter($filter); // @phpstan-ignore-line 42 | } 43 | 44 | if ($filter instanceof ModifierInterface) { 45 | $filter = static fn(InterventionImage $i) => $i->modify($filter); 46 | } 47 | 48 | return parent::normalizeFilter($filter); 49 | } 50 | 51 | public function object(\SplFileInfo $image): object 52 | { 53 | if (\interface_exists(ImageInterface::class)) { 54 | return $this->manager ? $this->manager->read($image) : ImageManager::gd()->read($image); 55 | } 56 | 57 | return $this->manager ? $this->manager->make($image) : ImageManagerStatic::make($image); // @phpstan-ignore-line 58 | } 59 | 60 | protected static function expectedClass(): string 61 | { 62 | if (\interface_exists(ImageInterface::class)) { 63 | return ImageInterface::class; 64 | } 65 | 66 | return InterventionImage::class; 67 | } 68 | 69 | protected function save(object $object, array $options): void 70 | { 71 | $object->save($options['output'], $options['quality'] ?? null, $options['format']); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Image/Transformer/MultiTransformer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Image\Transformer; 13 | 14 | use Imagine\Filter\FilterInterface as ImagineFilter; 15 | use Imagine\Gd\Image as GdImagineImage; 16 | use Imagine\Gmagick\Image as GmagickImagineImage; 17 | use Imagine\Image\ImageInterface as ImagineImage; 18 | use Imagine\Imagick\Image as ImagickImagineImage; 19 | use Intervention\Image\Filters\FilterInterface as InterventionFilter; 20 | use Intervention\Image\Image as InterventionImage; 21 | use Intervention\Image\Interfaces\ImageInterface as InterventionImageInterface; 22 | use Intervention\Image\Interfaces\ModifierInterface as InterventionModifier; 23 | use Psr\Container\ContainerInterface; 24 | use Spatie\Image\Image as SpatieImage; 25 | use Zenstruck\Image\Transformer; 26 | 27 | /** 28 | * @author Kevin Bond 29 | * 30 | * @internal 31 | * 32 | * @implements Transformer 33 | */ 34 | final class MultiTransformer implements Transformer 35 | { 36 | /** @var array> */ 37 | private static array $defaultTransformers = []; 38 | 39 | /** 40 | * @param array>|ContainerInterface $transformers 41 | */ 42 | public function __construct(private array|ContainerInterface $transformers = []) 43 | { 44 | } 45 | 46 | public function transform(\SplFileInfo $image, callable|object $filter, array $options = []): \SplFileInfo 47 | { 48 | if ($filter instanceof ImagineFilter) { 49 | return $this->get(ImagineImage::class)->transform($image, $filter, $options); 50 | } 51 | 52 | if ($filter instanceof InterventionFilter || $filter instanceof InterventionModifier) { // @phpstan-ignore-line 53 | return $this->get(InterventionImage::class)->transform($image, $filter, $options); 54 | } 55 | 56 | if (!\is_callable($filter)) { 57 | throw new \LogicException('Filter is not callable.'); 58 | } 59 | 60 | $ref = new \ReflectionFunction($filter instanceof \Closure ? $filter : \Closure::fromCallable($filter)); 61 | $type = ($ref->getParameters()[0] ?? null)?->getType(); 62 | 63 | if (!$type instanceof \ReflectionNamedType) { 64 | throw new \LogicException('Filter callback must have a single typed argument (union/intersection arguments are not allowed).'); 65 | } 66 | 67 | $type = $type->getName(); 68 | 69 | if (!\class_exists($type) && !\interface_exists($type)) { 70 | throw new \LogicException(\sprintf('First parameter type "%s" for filter callback is not a valid class/interface.', $type ?: '(none)')); 71 | } 72 | 73 | return $this->get($type)->transform($image, $filter, $options); 74 | } 75 | 76 | /** 77 | * @template T of object 78 | * 79 | * @param class-string|null $class 80 | * 81 | * @return T 82 | */ 83 | public function object(\SplFileInfo $image, ?string $class = null): object 84 | { 85 | if (!$class) { 86 | throw new \InvalidArgumentException(\sprintf('A class name must be provided when using %s().', __METHOD__)); 87 | } 88 | 89 | return $this->get($class)->object($image); 90 | } 91 | 92 | /** 93 | * @template T of object 94 | * 95 | * @param class-string $class 96 | * 97 | * @return Transformer 98 | */ 99 | private function get(string $class): Transformer 100 | { 101 | if (\is_array($this->transformers) && isset($this->transformers[$class])) { 102 | return $this->transformers[$class]; // @phpstan-ignore-line 103 | } 104 | 105 | if ($this->transformers instanceof ContainerInterface && $this->transformers->has($class)) { 106 | return $this->transformers->get($class); 107 | } 108 | 109 | return self::defaultTransformer($class); 110 | } 111 | 112 | /** 113 | * @template T of object 114 | * 115 | * @param class-string $class 116 | * 117 | * @return Transformer 118 | */ 119 | private static function defaultTransformer(string $class): Transformer 120 | { 121 | return self::$defaultTransformers[$class] ??= match ($class) { // @phpstan-ignore-line 122 | \GdImage::class => new GdImageTransformer(), 123 | \Imagick::class => new ImagickTransformer(), 124 | ImagineImage::class, GdImagineImage::class, ImagickImagineImage::class, GmagickImagineImage::class => ImagineTransformer::createFor($class), 125 | InterventionImage::class, InterventionImageInterface::class => new InterventionTransformer(), 126 | SpatieImage::class => new SpatieImageTransformer(), 127 | default => throw new \InvalidArgumentException(\sprintf('No transformer available for "%s".', $class)), 128 | }; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Image/Transformer/SpatieImageTransformer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Image\Transformer; 13 | 14 | use Spatie\Image\Enums\ImageDriver; 15 | use Spatie\Image\Image; 16 | 17 | /** 18 | * @author Kevin Bond 19 | * 20 | * @internal 21 | * 22 | * @extends FileTransformer 23 | */ 24 | final class SpatieImageTransformer extends FileTransformer 25 | { 26 | public function object(\SplFileInfo $image): object 27 | { 28 | if (!(new \ReflectionMethod(Image::class, 'useImageDriver'))->isStatic()) { 29 | // using spatie/image v2 30 | return Image::load($image->getPathname()); 31 | } 32 | 33 | return Image::useImageDriver(\class_exists(\Imagick::class) ? ImageDriver::Imagick : ImageDriver::Gd) 34 | ->loadFile($image) 35 | ; 36 | } 37 | 38 | protected static function expectedClass(): string 39 | { 40 | return Image::class; 41 | } 42 | 43 | protected function save(object $object, array $options): void 44 | { 45 | $object 46 | ->format($options['format']) 47 | ->save($options['output']) 48 | ; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/ImageFileInfo.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck; 13 | 14 | use Zenstruck\Image\Dimensions; 15 | use Zenstruck\Image\Hash\ThumbHash; 16 | use Zenstruck\Image\Transformer\MultiTransformer; 17 | 18 | /** 19 | * @author Kevin Bond 20 | */ 21 | final class ImageFileInfo extends \SplFileInfo 22 | { 23 | private const MIME_EXTENSION_MAP = [ 24 | 'image/jpeg' => 'jpg', 25 | 'image/gif' => 'gif', 26 | 'image/svg+xml' => 'svg', 27 | 'image/png' => 'png', 28 | 'image/bmp' => 'bmp', 29 | 'image/webp' => 'webp', 30 | 'image/vnd.wap.wbmp' => 'wbmp', 31 | ]; 32 | 33 | private static MultiTransformer $transformer; 34 | 35 | /** @var array{0:int,1:int,mime?:string,APP13?:string} */ 36 | private array $imageMetadata; 37 | 38 | private Dimensions $dimensions; 39 | 40 | /** @var array */ 41 | private array $iptc; 42 | 43 | /** @var array */ 44 | private array $exif; 45 | 46 | public static function wrap(self|string $file): self 47 | { 48 | return $file instanceof self ? $file : new self($file); 49 | } 50 | 51 | /** 52 | * Create a temporary image file that's deleted at the end of the script. 53 | * 54 | * @param string|resource|\SplFileInfo $what 55 | */ 56 | public static function from(mixed $what): self 57 | { 58 | return new self(TempFile::for($what)); 59 | } 60 | 61 | /** 62 | * @param object|callable(object):object $filter 63 | */ 64 | public function transform(object|callable $filter, array $options = []): self 65 | { 66 | $transformed = self::transformer()->transform($this, $filter, $options); 67 | 68 | return $transformed instanceof self ? $transformed->refresh() : new self($transformed); 69 | } 70 | 71 | /** 72 | * @param object|callable(object):object $filter 73 | */ 74 | public function transformInPlace(object|callable $filter, array $options = []): self 75 | { 76 | return $this->transform($filter, \array_merge($options, ['output' => $this])); 77 | } 78 | 79 | /** 80 | * @template T of object 81 | * 82 | * @param class-string $class 83 | * 84 | * @return T 85 | */ 86 | public function as(string $class): object 87 | { 88 | return self::transformer()->object($this, $class); 89 | } 90 | 91 | public function dimensions(): Dimensions 92 | { 93 | return $this->dimensions ??= new Dimensions(fn() => $this->imageMetadata()); 94 | } 95 | 96 | public function mimeType(): string 97 | { 98 | return $this->imageMetadata()['mime'] ?? throw new \RuntimeException(\sprintf('Unable to determine mime-type for "%s".', $this)); 99 | } 100 | 101 | public function guessExtension(): string 102 | { 103 | return $this->getExtension() ?: self::MIME_EXTENSION_MAP[$this->mimeType()] ?? throw new \RuntimeException(\sprintf('Unable to guess extension for "%s".', $this)); 104 | } 105 | 106 | /** 107 | * @copyright Bulat Shakirzyanov 108 | * @source https://github.com/php-imagine/Imagine/blob/9b9aacbffadce8f19abeb992b8d8d3a90cc2a52a/src/Image/Metadata/ExifMetadataReader.php 109 | */ 110 | public function exif(): array 111 | { 112 | if (!\function_exists('exif_read_data')) { 113 | throw new \LogicException('exif extension is not available.'); 114 | } 115 | 116 | if (isset($this->exif)) { 117 | return $this->exif; 118 | } 119 | 120 | if (false === $data = @\exif_read_data($this, as_arrays: true)) { 121 | return $this->exif = []; 122 | } 123 | 124 | $ret = []; 125 | 126 | foreach ($data as $section => $values) { 127 | if (!\is_array($values)) { 128 | continue; 129 | } 130 | 131 | if (array_is_list($values)) { 132 | $ret[\mb_strtolower($section)] = \implode("\n", $values); 133 | 134 | continue; 135 | } 136 | 137 | foreach ($values as $key => $value) { 138 | $ret[\sprintf('%s.%s', \mb_strtolower($section), $key)] = $value; 139 | } 140 | } 141 | 142 | return $this->exif = $ret; 143 | } 144 | 145 | /** 146 | * @copyright Oliver Vogel 147 | * @source https://github.com/Intervention/image/blob/54934ae8ea3661fd189437df90fb09ec3b679c74/src/Intervention/Image/Commands/IptcCommand.php 148 | */ 149 | public function iptc(): array 150 | { 151 | if (isset($this->iptc)) { 152 | return $this->iptc; 153 | } 154 | 155 | if (!\array_key_exists('APP13', $info = $this->imageMetadata())) { 156 | return $this->iptc = []; 157 | } 158 | 159 | if (false === $iptc = \iptcparse($info['APP13'])) { 160 | return $this->iptc = []; 161 | } 162 | 163 | return $this->iptc = \array_filter([ 164 | 'DocumentTitle' => $iptc['2#005'][0] ?? null, 165 | 'Urgency' => $iptc['2#010'][0] ?? null, 166 | 'Category' => $iptc['2#015'][0] ?? null, 167 | 'Subcategories' => $iptc['2#020'][0] ?? null, 168 | 'Keywords' => $iptc['2#025'][0] ?? null, 169 | 'ReleaseDate' => $iptc['2#030'][0] ?? null, 170 | 'ReleaseTime' => $iptc['2#035'][0] ?? null, 171 | 'SpecialInstructions' => $iptc['2#040'][0] ?? null, 172 | 'CreationDate' => $iptc['2#055'][0] ?? null, 173 | 'CreationTime' => $iptc['2#060'][0] ?? null, 174 | 'AuthorByline' => $iptc['2#080'][0] ?? null, 175 | 'AuthorTitle' => $iptc['2#085'][0] ?? null, 176 | 'City' => $iptc['2#090'][0] ?? null, 177 | 'SubLocation' => $iptc['2#092'][0] ?? null, 178 | 'State' => $iptc['2#095'][0] ?? null, 179 | 'Country' => $iptc['2#101'][0] ?? null, 180 | 'OTR' => $iptc['2#103'][0] ?? null, 181 | 'Headline' => $iptc['2#105'][0] ?? null, 182 | 'Source' => $iptc['2#110'][0] ?? null, 183 | 'PhotoSource' => $iptc['2#115'][0] ?? null, 184 | 'Copyright' => $iptc['2#116'][0] ?? null, 185 | 'Caption' => $iptc['2#120'][0] ?? null, 186 | 'CaptionWriter' => $iptc['2#122'][0] ?? null, 187 | ]); 188 | } 189 | 190 | public function refresh(): static 191 | { 192 | \clearstatcache(filename: $this); 193 | 194 | unset($this->imageMetadata, $this->exif, $this->iptc, $this->dimensions); 195 | 196 | return $this; 197 | } 198 | 199 | public function delete(): void 200 | { 201 | if (\file_exists($this)) { 202 | \unlink($this); 203 | } 204 | } 205 | 206 | public function thumbHash(): ThumbHash 207 | { 208 | return ThumbHash::from($this); 209 | } 210 | 211 | private static function transformer(): MultiTransformer 212 | { 213 | return self::$transformer ??= new MultiTransformer(); 214 | } 215 | 216 | /** 217 | * @return array{0:int,1:int,mime?:string,APP13?:string} 218 | */ 219 | private function imageMetadata(): array 220 | { 221 | if (isset($this->imageMetadata)) { 222 | return $this->imageMetadata; 223 | } 224 | 225 | if ('svg' === $this->getExtension()) { 226 | return $this->imageMetadata = self::parseSvg($this) ?? throw new \RuntimeException(\sprintf('Unable to load "%s" as svg.', $this)); 227 | } 228 | 229 | $info = []; 230 | 231 | if (false === $imageMetadata = @\getimagesize($this, $info)) { 232 | // try as svg 233 | return $this->imageMetadata = self::parseSvg($this) ?? throw new \RuntimeException(\sprintf('Unable to parse image metadata for "%s".', $this)); 234 | } 235 | 236 | return $this->imageMetadata = \array_merge($imageMetadata, $info); // @phpstan-ignore-line 237 | } 238 | 239 | /** 240 | * @return null|array{0:int,1:int,mime:string} 241 | */ 242 | private static function parseSvg(\SplFileInfo $file): ?array 243 | { 244 | if (false === $xml = \file_get_contents($file)) { 245 | return null; 246 | } 247 | 248 | if (false === $xml = @\simplexml_load_string($xml)) { 249 | return null; 250 | } 251 | 252 | if (!$xml = $xml->attributes()) { 253 | return null; 254 | } 255 | 256 | return [ 257 | (int) \round((float) $xml->width), 258 | (int) \round((float) $xml->height), 259 | 'mime' => 'image/svg+xml', 260 | ]; 261 | } 262 | } 263 | --------------------------------------------------------------------------------