diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 36bbaa0..cdc4464 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -8,7 +8,7 @@ on: jobs: tests: - runs-on: ubuntu-24.04 + runs-on: ubuntu-22.04 strategy: fail-fast: false matrix: @@ -44,7 +44,7 @@ jobs: uses: codecov/codecov-action@v3 coding-style: - runs-on: ubuntu-24.04 + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 @@ -61,7 +61,7 @@ jobs: vendor/bin/php-cs-fixer fix --dry-run --diff --allow-risky=yes static-analysis: - runs-on: ubuntu-24.04 + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 diff --git a/composer.json b/composer.json index a10af1c..7d22d30 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ { "name": "Jonathan Reinink", "email": "jonathan@reinink.ca", - "homepage": "http://reinink.ca" + "homepage": "https://reinink.ca/" }, { "name": "Titouan Galopin", diff --git a/src/Api/Api.php b/src/Api/Api.php index 2f2219f..7a218ff 100644 --- a/src/Api/Api.php +++ b/src/Api/Api.php @@ -26,8 +26,8 @@ class Api implements ApiInterface /** * Create API instance. * - * @param ImageManager $imageManager Intervention image manager. - * @param array $manipulators Collection of manipulators. + * @param ImageManager $imageManager Intervention image manager. + * @param list $manipulators Collection of manipulators. */ public function __construct(ImageManager $imageManager, array $manipulators) { @@ -59,16 +59,12 @@ public function getImageManager(): ImageManager * Set the manipulators. * * @param array $manipulators Collection of manipulators. + * + * @throws \InvalidArgumentException */ public function setManipulators(array $manipulators): void { - foreach ($manipulators as $manipulator) { - if (!($manipulator instanceof ManipulatorInterface)) { - throw new \InvalidArgumentException('Not a valid manipulator.'); - } - } - - $this->manipulators = $manipulators; + $this->manipulators = array_filter($manipulators, fn ($manipulator) => $manipulator instanceof ManipulatorInterface || throw new \InvalidArgumentException('Not a valid manipulator.')); } /** diff --git a/src/Api/Encoder.php b/src/Api/Encoder.php index 80aad01..8c3188d 100644 --- a/src/Api/Encoder.php +++ b/src/Api/Encoder.php @@ -6,6 +6,7 @@ use Intervention\Image\Interfaces\EncodedImageInterface; use Intervention\Image\Interfaces\ImageInterface; +use Intervention\Image\MediaType; /** * Encoder Api class to convert a given image to a specific format. @@ -60,36 +61,42 @@ public function getParam(string $name): mixed */ public function run(ImageInterface $image): EncodedImageInterface { - $format = $this->getFormat($image); - $quality = $this->getQuality(); - $shouldInterlace = filter_var($this->getParam('interlace'), FILTER_VALIDATE_BOOLEAN); + $encoderOptions = []; + $mediaType = $this->getMediaType($image); - if ('pjpg' === $format) { - $shouldInterlace = true; - $format = 'jpg'; + if (MediaType::IMAGE_PJPEG === $mediaType) { + $encoderOptions['progressive'] = true; } - $encoderOptions = []; - switch ($format) { - case 'avif': - case 'heic': - case 'tiff': - case 'webp': - $encoderOptions['quality'] = $quality; - break; - case 'jpg': - $encoderOptions['quality'] = $quality; - $encoderOptions['progressive'] = $shouldInterlace; - break; - case 'gif': - case 'png': - $encoderOptions['interlaced'] = $shouldInterlace; - break; - default: - throw new \Exception("Invalid format provided: {$format}"); + if ($this->allowsQuality($mediaType)) { + $encoderOptions['quality'] = $this->getQuality(); } - return $image->encodeByExtension($format, ...$encoderOptions); + if ($this->allowsInterlaced($mediaType)) { + $encoderOptions['interlaced'] = filter_var($this->getParam('interlace'), FILTER_VALIDATE_BOOLEAN); + } + + return $mediaType->format()->encoder(...array_filter($encoderOptions))->encode($image); + } + + /** + * Resolve media type. + * + * @throws \Exception + */ + public function getMediaType(ImageInterface $image): MediaType + { + $fm = (string) $this->getParam('fm'); + + if ('' !== $fm) { + return self::supportedMediaTypes()[$fm] ?? throw new \Exception("Invalid format provided: {$fm}"); + } + + try { + return MediaType::from($image->origin()->mediaType()); + } catch (\ValueError) { + return MediaType::IMAGE_JPEG; + } } /** @@ -98,17 +105,18 @@ public function run(ImageInterface $image): EncodedImageInterface * @param ImageInterface $image The source image. * * @return string The resolved format. + * + * @psalm-suppress RiskyTruthyFalsyComparison */ public function getFormat(ImageInterface $image): string { - $fm = (string) $this->getParam('fm'); + try { + $mediaType = $this->getMediaType($image); - if ($fm && array_key_exists($fm, static::supportedFormats())) { - return $fm; + return array_search($mediaType->value, self::supportedFormats(), true) ?: 'jpg'; + } catch (\Exception) { + return 'jpg'; } - - /** @psalm-suppress RiskyTruthyFalsyComparison */ - return array_search($image->origin()->mediaType(), static::supportedFormats(), true) ?: 'jpg'; } /** @@ -117,19 +125,62 @@ public function getFormat(ImageInterface $image): string * @return array */ public static function supportedFormats(): array + { + return array_map(fn (MediaType $mediaType) => $mediaType->value, self::supportedMediaTypes()); + } + + /** + * Get a list of supported image formats and media types. + * + * @return array + */ + public static function supportedMediaTypes(): array { return [ - 'avif' => 'image/avif', - 'gif' => 'image/gif', - 'jpg' => 'image/jpeg', - 'pjpg' => 'image/jpeg', - 'png' => 'image/png', - 'webp' => 'image/webp', - 'tiff' => 'image/tiff', - 'heic' => 'image/heic', + 'avif' => MediaType::IMAGE_AVIF, + 'bmp' => MediaType::IMAGE_BMP, + 'gif' => MediaType::IMAGE_GIF, + 'heic' => MediaType::IMAGE_HEIC, + 'jpg' => MediaType::IMAGE_JPEG, + 'pjpg' => MediaType::IMAGE_PJPEG, + 'png' => MediaType::IMAGE_PNG, + 'tiff' => MediaType::IMAGE_TIFF, + 'webp' => MediaType::IMAGE_WEBP, ]; } + /** + * Checks if we can pass the quality parameter to the encoder. + */ + public function allowsQuality(MediaType $mediaType): bool + { + return !in_array($mediaType, [ + MediaType::IMAGE_GIF, + MediaType::IMAGE_PNG, + MediaType::IMAGE_X_PNG, + MediaType::IMAGE_BMP, + MediaType::IMAGE_MS_BMP, + MediaType::IMAGE_X_BITMAP, + MediaType::IMAGE_X_BMP, + MediaType::IMAGE_X_MS_BMP, + MediaType::IMAGE_X_XBITMAP, + MediaType::IMAGE_X_WINDOWS_BMP, + MediaType::IMAGE_X_WIN_BITMAP, + ]); + } + + /** + * hecks if we can pass the interlaced parameter to the encoder. + */ + public function allowsInterlaced(MediaType $mediaType): bool + { + return in_array($mediaType, [ + MediaType::IMAGE_PNG, + MediaType::IMAGE_X_PNG, + MediaType::IMAGE_GIF, + ]); + } + /** * Resolve quality. * diff --git a/src/ServerFactory.php b/src/ServerFactory.php index abb42be..3e66a93 100644 --- a/src/ServerFactory.php +++ b/src/ServerFactory.php @@ -18,6 +18,7 @@ use League\Glide\Manipulators\Filter; use League\Glide\Manipulators\Flip; use League\Glide\Manipulators\Gamma; +use League\Glide\Manipulators\ManipulatorInterface; use League\Glide\Manipulators\Orientation; use League\Glide\Manipulators\Pixelate; use League\Glide\Manipulators\Sharpen; @@ -237,7 +238,7 @@ public function getImageManager(): ImageManager /** * Get image manipulators. * - * @return array Image manipulators. + * @return list Image manipulators. */ public function getManipulators(): array { diff --git a/tests/Api/ApiTest.php b/tests/Api/ApiTest.php index b102b6c..a95f6f9 100644 --- a/tests/Api/ApiTest.php +++ b/tests/Api/ApiTest.php @@ -5,14 +5,16 @@ namespace League\Glide\Api; use Intervention\Image\ImageManager; +use Intervention\Image\Interfaces\DriverInterface; use Intervention\Image\Interfaces\EncodedImageInterface; use Intervention\Image\Interfaces\ImageInterface; +use Intervention\Image\Origin; use League\Glide\Manipulators\ManipulatorInterface; use PHPUnit\Framework\TestCase; class ApiTest extends TestCase { - private $api; + private Api $api; public function setUp(): void { @@ -24,30 +26,30 @@ public function tearDown(): void \Mockery::close(); } - public function testCreateInstance() + public function testCreateInstance(): void { $this->assertInstanceOf(Api::class, $this->api); } - public function testSetImageManager() + public function testSetImageManager(): void { $this->api->setImageManager(ImageManager::gd()); $this->assertInstanceOf(ImageManager::class, $this->api->getImageManager()); } - public function testGetImageManager() + public function testGetImageManager(): void { $this->assertInstanceOf(ImageManager::class, $this->api->getImageManager()); } - public function testSetManipulators() + public function testSetManipulators(): void { $this->api->setManipulators([\Mockery::mock(ManipulatorInterface::class)]); $manipulators = $this->api->getManipulators(); $this->assertInstanceOf(ManipulatorInterface::class, $manipulators[0]); } - public function testSetInvalidManipulator() + public function testSetInvalidManipulator(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Not a valid manipulator.'); @@ -55,19 +57,23 @@ public function testSetInvalidManipulator() $this->api->setManipulators([new \stdClass()]); } - public function testGetManipulators() + public function testGetManipulators(): void { $this->assertEquals([], $this->api->getManipulators()); } - public function testRun() + public function testRun(): void { $image = \Mockery::mock(ImageInterface::class, function ($mock) { - $mock->shouldReceive('origin')->andReturn(\Mockery::mock('\Intervention\Image\Origin', function ($mock) { + $mock->shouldReceive('origin')->andReturn(\Mockery::mock(Origin::class, function ($mock) { $mock->shouldReceive('mediaType')->andReturn('image/png'); })); - $mock->shouldReceive('encodeByExtension')->with('png')->andReturn(\Mockery::mock(EncodedImageInterface::class, function ($mock) { + $mock->shouldReceive('driver')->andReturn(\Mockery::mock(DriverInterface::class, function ($mock) { + $mock->shouldReceive('supports'); + })); + + $mock->shouldReceive('encode')->andReturn(\Mockery::mock(EncodedImageInterface::class, function ($mock) { $mock->shouldReceive('toString')->andReturn('encoded'); })); }); @@ -82,7 +88,7 @@ public function testRun() $api = new Api($manager, [$manipulator]); $this->assertEquals('encoded', $api->run( - file_get_contents(dirname(__FILE__, 2).'/files/red-pixel.png'), + (string) file_get_contents(dirname(__FILE__, 2).'/files/red-pixel.png'), [] )); } diff --git a/tests/Api/EncoderTest.php b/tests/Api/EncoderTest.php index 6a4723a..b1d76b0 100644 --- a/tests/Api/EncoderTest.php +++ b/tests/Api/EncoderTest.php @@ -6,8 +6,11 @@ use Intervention\Image\Encoders\MediaTypeEncoder; use Intervention\Image\ImageManager; +use Intervention\Image\Interfaces\DriverInterface; use Intervention\Image\Interfaces\EncodedImageInterface; use Intervention\Image\Interfaces\ImageInterface; +use Intervention\Image\MediaType; +use Intervention\Image\Origin; use Mockery; use PHPUnit\Framework\TestCase; @@ -19,6 +22,7 @@ class EncoderTest extends TestCase private ImageInterface $gif; private ImageInterface $tif; private ImageInterface $webp; + private ImageInterface $webpx; private ImageInterface $avif; private ImageInterface $heic; @@ -39,6 +43,9 @@ public function setUp(): void $this->webp = $manager->read( $manager->create(100, 100)->encode(new MediaTypeEncoder('image/webp'))->toFilePointer() ); + $this->webpx = $manager->read( + $manager->create(100, 100)->encode(new MediaTypeEncoder('image/x-webp'))->toFilePointer() + ); } if (function_exists('imagecreatefromavif')) { @@ -87,6 +94,12 @@ public function testRun(): void $this->assertSame('image/webp', $this->getMime($this->encoder->setParams(['fm' => 'webp'])->run($this->png))); $this->assertSame('image/webp', $this->getMime($this->encoder->setParams(['fm' => 'webp'])->run($this->gif))); $this->assertSame('image/webp', $this->getMime($this->encoder->setParams(['fm' => 'webp'])->run($this->webp))); + + $this->assertSame('image/jpeg', $this->getMime($this->encoder->setParams(['fm' => 'jpg'])->run($this->webpx))); + $this->assertSame('image/jpeg', $this->getMime($this->encoder->setParams(['fm' => 'pjpg'])->run($this->webpx))); + $this->assertSame('image/png', $this->getMime($this->encoder->setParams(['fm' => 'png'])->run($this->webpx))); + $this->assertSame('image/gif', $this->getMime($this->encoder->setParams(['fm' => 'gif'])->run($this->webpx))); + $this->assertSame('image/webp', $this->getMime($this->encoder->setParams(['fm' => 'webp'])->run($this->webpx))); } if (function_exists('imagecreatefromavif')) { $this->assertSame('image/jpeg', $this->getMime($this->encoder->setParams(['fm' => 'jpg'])->run($this->avif))); @@ -102,53 +115,52 @@ public function testRun(): void if (function_exists('imagecreatefromwebp') && function_exists('imagecreatefromavif')) { $this->assertSame('image/webp', $this->getMime($this->encoder->setParams(['fm' => 'webp'])->run($this->avif))); $this->assertSame('image/avif', $this->getMime($this->encoder->setParams(['fm' => 'avif'])->run($this->webp))); + $this->assertSame('image/avif', $this->getMime($this->encoder->setParams(['fm' => 'avif'])->run($this->webpx))); } } public function testGetFormat(): void { - /** - * @psalm-suppress MissingClosureParamType - */ - $image = \Mockery::mock(ImageInterface::class, function ($mock) { - /* - * @var Mock $mock - */ - $this->assertMediaType($mock, 'image/jpeg')->once(); - $this->assertMediaType($mock, 'image/png')->once(); - $this->assertMediaType($mock, 'image/gif')->once(); - $this->assertMediaType($mock, 'image/bmp')->once(); - $this->assertMediaType($mock, 'image/jpeg')->twice(); - - if (function_exists('imagecreatefromwebp')) { - $this->assertMediaType($mock, 'image/webp')->once(); - } - - if (function_exists('imagecreatefromavif')) { - $this->assertMediaType($mock, 'image/avif')->once(); - } - }); + $this->assertSame('jpg', $this->encoder->setParams(['fm' => 'jpg'])->getFormat($this->getImageByMimeType('image/jpeg'))); + $this->assertSame('png', $this->encoder->setParams(['fm' => 'png'])->getFormat($this->getImageByMimeType('image/png'))); + $this->assertSame('gif', $this->encoder->setParams(['fm' => 'gif'])->getFormat($this->getImageByMimeType('image/gif'))); + $this->assertSame('bmp', $this->encoder->setParams(['fm' => 'bmp'])->getFormat($this->getImageByMimeType('image/bmp'))); + + // Make sure 'fm' parameter takes precedence + $this->assertSame('png', $this->encoder->setParams(['fm' => 'png'])->getFormat($this->getImageByMimeType('image/jpeg'))); + $this->assertSame('gif', $this->encoder->setParams(['fm' => 'gif'])->getFormat($this->getImageByMimeType('image/jpeg'))); + $this->assertSame('bmp', $this->encoder->setParams(['fm' => 'bmp'])->getFormat($this->getImageByMimeType('image/jpeg'))); + $this->assertSame('pjpg', $this->encoder->setParams(['fm' => 'pjpg'])->getFormat($this->getImageByMimeType('image/jpeg'))); - $this->assertSame('jpg', $this->encoder->setParams(['fm' => 'jpg'])->getFormat($image)); - $this->assertSame('png', $this->encoder->setParams(['fm' => 'png'])->getFormat($image)); - $this->assertSame('gif', $this->encoder->setParams(['fm' => 'gif'])->getFormat($image)); - $this->assertSame('jpg', $this->encoder->setParams(['fm' => null])->getFormat($image)); - $this->assertSame('png', $this->encoder->setParams(['fm' => null])->getFormat($image)); - $this->assertSame('gif', $this->encoder->setParams(['fm' => null])->getFormat($image)); - $this->assertSame('jpg', $this->encoder->setParams(['fm' => null])->getFormat($image)); + // Make sure we keep the current format if no format is provided + $this->assertSame('jpg', $this->encoder->setParams(['fm' => null])->getFormat($this->getImageByMimeType('image/jpeg'))); + $this->assertSame('png', $this->encoder->setParams(['fm' => null])->getFormat($this->getImageByMimeType('image/png'))); + $this->assertSame('gif', $this->encoder->setParams(['fm' => null])->getFormat($this->getImageByMimeType('image/gif'))); + $this->assertSame('bmp', $this->encoder->setParams(['fm' => null])->getFormat($this->getImageByMimeType('image/bmp'))); + $this->assertSame('jpg', $this->encoder->setParams(['fm' => 'null'])->getFormat($this->getImageByMimeType('image/pjpeg'))); - $this->assertSame('jpg', $this->encoder->setParams(['fm' => ''])->getFormat($image)); - $this->assertSame('jpg', $this->encoder->setParams(['fm' => 'invalid'])->getFormat($image)); + $this->assertSame('jpg', $this->encoder->setParams(['fm' => ''])->getFormat($this->getImageByMimeType('image/jpeg'))); + $this->assertSame('png', $this->encoder->setParams(['fm' => ''])->getFormat($this->getImageByMimeType('image/png'))); + $this->assertSame('jpg', $this->encoder->setParams(['fm' => 'invalid'])->getFormat($this->getImageByMimeType('image/jpeg'))); if (function_exists('imagecreatefromwebp')) { - $this->assertSame('webp', $this->encoder->setParams(['fm' => null])->getFormat($image)); + $this->assertSame('webp', $this->encoder->setParams(['fm' => null])->getFormat($this->getImageByMimeType('image/webp'))); + $this->assertSame('webp', $this->encoder->setParams(['fm' => 'webp'])->getFormat($this->getImageByMimeType('image/jpeg'))); } if (function_exists('imagecreatefromavif')) { - $this->assertSame('avif', $this->encoder->setParams(['fm' => null])->getFormat($image)); + $this->assertSame('avif', $this->encoder->setParams(['fm' => null])->getFormat($this->getImageByMimeType('image/avif'))); + $this->assertSame('avif', $this->encoder->setParams(['fm' => 'avif'])->getFormat($this->getImageByMimeType('image/jpeg'))); } } + protected function getImageByMimeType(string $mimeType): ImageInterface + { + return \Mockery::mock(ImageInterface::class, function ($mock) use ($mimeType) { + $this->assertMediaType($mock, $mimeType); + }); + } + public function testGetQuality(): void { $this->assertSame(100, $this->encoder->setParams(['q' => '100'])->getQuality()); @@ -171,6 +183,7 @@ public function testWithImagick(): void ); } $manager = ImageManager::imagick(); + // These need to be recreated with the imagick driver selected in the manager $this->jpg = $manager->read($manager->create(100, 100)->encode(new MediaTypeEncoder('image/jpeg'))->toFilePointer()); $this->png = $manager->read($manager->create(100, 100)->encode(new MediaTypeEncoder('image/png'))->toFilePointer()); @@ -188,18 +201,36 @@ public function testSupportedFormats(): void { $expected = [ 'avif' => 'image/avif', + 'bmp' => 'image/bmp', 'gif' => 'image/gif', + 'heic' => 'image/heic', 'jpg' => 'image/jpeg', - 'pjpg' => 'image/jpeg', + 'pjpg' => 'image/pjpeg', 'png' => 'image/png', - 'webp' => 'image/webp', 'tiff' => 'image/tiff', - 'heic' => 'image/heic', + 'webp' => 'image/webp', ]; $this->assertSame($expected, Encoder::supportedFormats()); } + public function testSupportedMediaTypes(): void + { + $expected = [ + 'avif' => MediaType::IMAGE_AVIF, + 'bmp' => MediaType::IMAGE_BMP, + 'gif' => MediaType::IMAGE_GIF, + 'heic' => MediaType::IMAGE_HEIC, + 'jpg' => MediaType::IMAGE_JPEG, + 'pjpg' => MediaType::IMAGE_PJPEG, + 'png' => MediaType::IMAGE_PNG, + 'tiff' => MediaType::IMAGE_TIFF, + 'webp' => MediaType::IMAGE_WEBP, + ]; + + $this->assertSame($expected, Encoder::supportedMediaTypes()); + } + protected function getMime(EncodedImageInterface $image): string { return $image->mediaType(); @@ -208,19 +239,20 @@ protected function getMime(EncodedImageInterface $image): string /** * Creates an assertion to check media type. * - * @param Mock $mock - * @param string $mediaType + * @param Mockery\Mock $mock * * @psalm-suppress MoreSpecificReturnType */ - protected function assertMediaType($mock, $mediaType): Mockery\CompositeExpectation + protected function assertMediaType($mock, string $mediaType): Mockery\CompositeExpectation { - /* - * @var Mock $mock - */ /** * @psalm-suppress LessSpecificReturnStatement, UndefinedMagicMethod */ - return $mock->shouldReceive('origin')->andReturn(\Mockery::mock('Intervention\Image\Origin', ['mediaType' => $mediaType])); + return $mock->shouldReceive('origin') + ->andReturn(\Mockery::mock(Origin::class, ['mediaType' => $mediaType])) + ->shouldReceive('driver') + ->andReturn(\Mockery::mock(DriverInterface::class, function ($mock) { + $mock->shouldReceive('supports'); + })); } }