From d982359ab8734ba3c6b7a3890b5568d019abee1c Mon Sep 17 00:00:00 2001 From: Oliver Vogel Date: Wed, 27 Mar 2024 15:30:28 +0100 Subject: [PATCH] Add Encoder Options for progressive Jpeg & interlaced GIF format --- composer.json | 2 +- src/Drivers/Gd/Encoders/GifEncoder.php | 7 ++- src/Drivers/Gd/Encoders/JpegEncoder.php | 1 + src/Drivers/Imagick/Encoders/GifEncoder.php | 4 ++ src/Drivers/Imagick/Encoders/JpegEncoder.php | 4 ++ src/Encoders/FileExtensionEncoder.php | 28 ++++----- src/Encoders/FilePathEncoder.php | 5 +- src/Encoders/GifEncoder.php | 2 +- src/Encoders/JpegEncoder.php | 6 +- src/Encoders/MediaTypeEncoder.php | 28 ++++----- src/Image.php | 2 +- src/Interfaces/ImageInterface.php | 2 +- tests/ImagickTestCase.php | 18 ++++++ tests/Traits/CanDetectProgressiveJpeg.php | 57 +++++++++++++++++++ .../Drivers/Gd/Encoders/GifEncoderTest.php | 26 +++++++++ .../Drivers/Gd/Encoders/JpegEncoderTest.php | 12 ++++ .../Imagick/Encoders/GifEncoderTest.php | 26 +++++++++ .../Imagick/Encoders/JpegEncoderTest.php | 12 ++++ 18 files changed, 203 insertions(+), 39 deletions(-) create mode 100644 tests/Traits/CanDetectProgressiveJpeg.php diff --git a/composer.json b/composer.json index 466bb9bc..10248768 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "require": { "php": "^8.1", "ext-mbstring": "*", - "intervention/gif": "^4.0.1" + "intervention/gif": "^4.1" }, "require-dev": { "phpunit/phpunit": "^10.0", diff --git a/src/Drivers/Gd/Encoders/GifEncoder.php b/src/Drivers/Gd/Encoders/GifEncoder.php index af29e899..1031a528 100644 --- a/src/Drivers/Gd/Encoders/GifEncoder.php +++ b/src/Drivers/Gd/Encoders/GifEncoder.php @@ -23,7 +23,9 @@ public function encode(ImageInterface $image): EncodedImage $gd = $image->core()->native(); $data = $this->buffered(function () use ($gd) { + imageinterlace($gd, $this->interlaced); imagegif($gd); + imageinterlace($gd, false); }); return new EncodedImage($data, 'image/gif'); @@ -41,8 +43,9 @@ protected function encodeAnimated(ImageInterface $image): EncodedImage foreach ($image as $frame) { $builder->addFrame( - (string) $this->encode($frame->toImage($image->driver())), - $frame->delay() + source: (string) $this->encode($frame->toImage($image->driver())), + delay: $frame->delay(), + interlaced: $this->interlaced ); } diff --git a/src/Drivers/Gd/Encoders/JpegEncoder.php b/src/Drivers/Gd/Encoders/JpegEncoder.php index 7860862b..dd0a4e71 100644 --- a/src/Drivers/Gd/Encoders/JpegEncoder.php +++ b/src/Drivers/Gd/Encoders/JpegEncoder.php @@ -17,6 +17,7 @@ public function encode(ImageInterface $image): EncodedImage $output = Cloner::cloneBlended($image->core()->native(), background: $image->blendingColor()); $data = $this->buffered(function () use ($output) { + imageinterlace($output, $this->progressive); imagejpeg($output, null, $this->quality); }); diff --git a/src/Drivers/Imagick/Encoders/GifEncoder.php b/src/Drivers/Imagick/Encoders/GifEncoder.php index 84a327a1..41783a39 100644 --- a/src/Drivers/Imagick/Encoders/GifEncoder.php +++ b/src/Drivers/Imagick/Encoders/GifEncoder.php @@ -24,6 +24,10 @@ public function encode(ImageInterface $image): EncodedImage $imagick->setCompression($compression); $imagick->setImageCompression($compression); + if ($this->interlaced) { + $imagick->setInterlaceScheme(Imagick::INTERLACE_LINE); + } + return new EncodedImage($imagick->getImagesBlob(), 'image/gif'); } } diff --git a/src/Drivers/Imagick/Encoders/JpegEncoder.php b/src/Drivers/Imagick/Encoders/JpegEncoder.php index 7e8d6ecd..b70f0e36 100644 --- a/src/Drivers/Imagick/Encoders/JpegEncoder.php +++ b/src/Drivers/Imagick/Encoders/JpegEncoder.php @@ -37,6 +37,10 @@ public function encode(ImageInterface $image): EncodedImage $imagick->setImageCompressionQuality($this->quality); $imagick->setImageAlphaChannel(Imagick::ALPHACHANNEL_REMOVE); + if ($this->progressive) { + $imagick->setInterlaceScheme(Imagick::INTERLACE_PLANE); + } + return new EncodedImage($imagick->getImagesBlob(), 'image/jpeg'); } } diff --git a/src/Encoders/FileExtensionEncoder.php b/src/Encoders/FileExtensionEncoder.php index 74952d80..5eabbf87 100644 --- a/src/Encoders/FileExtensionEncoder.php +++ b/src/Encoders/FileExtensionEncoder.php @@ -11,17 +11,17 @@ class FileExtensionEncoder extends AutoEncoder { + protected array $options = []; + /** * Create new encoder instance to encode to format of given file extension * * @param null|string $extension Target file extension for example "png" - * @param int $quality * @return void */ - public function __construct( - public ?string $extension = null, - public int $quality = self::DEFAULT_QUALITY - ) { + public function __construct(public ?string $extension = null, mixed ...$options) + { + $this->options = $options; } /** @@ -52,15 +52,15 @@ protected function encoderByFileExtension(?string $extension): EncoderInterface } return match (strtolower($extension)) { - 'webp' => new WebpEncoder(quality: $this->quality), - 'avif' => new AvifEncoder(quality: $this->quality), - 'jpeg', 'jpg' => new JpegEncoder(quality: $this->quality), - 'bmp' => new BmpEncoder(), - 'gif' => new GifEncoder(), - 'png' => new PngEncoder(), - 'tiff', 'tif' => new TiffEncoder(quality: $this->quality), - 'jp2', 'j2k', 'jpf', 'jpm', 'jpg2', 'j2c', 'jpc', 'jpx' => new Jpeg2000Encoder(quality: $this->quality), - 'heic', 'heif' => new HeicEncoder(quality: $this->quality), + 'webp' => new WebpEncoder(...$this->options), + 'avif' => new AvifEncoder(...$this->options), + 'jpeg', 'jpg' => new JpegEncoder(...$this->options), + 'bmp' => new BmpEncoder(...$this->options), + 'gif' => new GifEncoder(...$this->options), + 'png' => new PngEncoder(...$this->options), + 'tiff', 'tif' => new TiffEncoder(...$this->options), + 'jp2', 'j2k', 'jpf', 'jpm', 'jpg2', 'j2c', 'jpc', 'jpx' => new Jpeg2000Encoder(...$this->options), + 'heic', 'heif' => new HeicEncoder(...$this->options), default => throw new EncoderException('No encoder found for file extension (' . $extension . ').'), }; } diff --git a/src/Encoders/FilePathEncoder.php b/src/Encoders/FilePathEncoder.php index d528c0dc..5e807c08 100644 --- a/src/Encoders/FilePathEncoder.php +++ b/src/Encoders/FilePathEncoder.php @@ -13,14 +13,13 @@ class FilePathEncoder extends FileExtensionEncoder * Create new encoder instance to encode to format of file extension in given path * * @param null|string $path - * @param int $quality * @return void */ - public function __construct(protected ?string $path = null, public int $quality = self::DEFAULT_QUALITY) + public function __construct(protected ?string $path = null, mixed ...$options) { parent::__construct( is_null($path) ? $path : pathinfo($path, PATHINFO_EXTENSION), - $quality + ...$options ); } diff --git a/src/Encoders/GifEncoder.php b/src/Encoders/GifEncoder.php index 168cbf86..2f5d0744 100644 --- a/src/Encoders/GifEncoder.php +++ b/src/Encoders/GifEncoder.php @@ -8,7 +8,7 @@ class GifEncoder extends SpecializableEncoder { - public function __construct() + public function __construct(public bool $interlaced = false) { } } diff --git a/src/Encoders/JpegEncoder.php b/src/Encoders/JpegEncoder.php index ae9020b0..5753c249 100644 --- a/src/Encoders/JpegEncoder.php +++ b/src/Encoders/JpegEncoder.php @@ -8,7 +8,9 @@ class JpegEncoder extends SpecializableEncoder { - public function __construct(public int $quality = self::DEFAULT_QUALITY) - { + public function __construct( + public int $quality = self::DEFAULT_QUALITY, + public bool $progressive = false + ) { } } diff --git a/src/Encoders/MediaTypeEncoder.php b/src/Encoders/MediaTypeEncoder.php index 121a529e..f5629513 100644 --- a/src/Encoders/MediaTypeEncoder.php +++ b/src/Encoders/MediaTypeEncoder.php @@ -12,17 +12,17 @@ class MediaTypeEncoder extends AbstractEncoder { + protected array $options = []; + /** * Create new encoder instance * * @param null|string $mediaType Target media type for example "image/jpeg" - * @param int $quality * @return void */ - public function __construct( - public ?string $mediaType = null, - public int $quality = self::DEFAULT_QUALITY - ) { + public function __construct(public ?string $mediaType = null, mixed ...$options) + { + $this->options = $options; } /** @@ -50,12 +50,12 @@ protected function encoderByMediaType(string $mediaType): EncoderInterface { return match (strtolower($mediaType)) { 'image/webp', - 'image/x-webp' => new WebpEncoder(quality: $this->quality), + 'image/x-webp' => new WebpEncoder(...$this->options), 'image/avif', - 'image/x-avif' => new AvifEncoder(quality: $this->quality), + 'image/x-avif' => new AvifEncoder(...$this->options), 'image/jpeg', 'image/jpg', - 'image/pjpeg' => new JpegEncoder(quality: $this->quality), + 'image/pjpeg' => new JpegEncoder(...$this->options), 'image/bmp', 'image/ms-bmp', 'image/x-bitmap', @@ -63,16 +63,16 @@ protected function encoderByMediaType(string $mediaType): EncoderInterface 'image/x-ms-bmp', 'image/x-win-bitmap', 'image/x-windows-bmp', - 'image/x-xbitmap' => new BmpEncoder(), - 'image/gif' => new GifEncoder(), + 'image/x-xbitmap' => new BmpEncoder(...$this->options), + 'image/gif' => new GifEncoder(...$this->options), 'image/png', - 'image/x-png' => new PngEncoder(), - 'image/tiff' => new TiffEncoder(quality: $this->quality), + 'image/x-png' => new PngEncoder(...$this->options), + 'image/tiff' => new TiffEncoder(...$this->options), 'image/jp2', 'image/jpx', - 'image/jpm' => new Jpeg2000Encoder(quality: $this->quality), + 'image/jpm' => new Jpeg2000Encoder(...$this->options), 'image/heic', - 'image/heif', => new HeicEncoder(quality: $this->quality), + 'image/heif', => new HeicEncoder(...$this->options), default => throw new EncoderException('No encoder found for media type (' . $mediaType . ').'), }; } diff --git a/src/Image.php b/src/Image.php index c336fc2b..ffb39cb9 100644 --- a/src/Image.php +++ b/src/Image.php @@ -299,7 +299,7 @@ public function encode(EncoderInterface $encoder = new AutoEncoder()): EncodedIm * * @see ImageInterface::save() */ - public function save(?string $path = null, ...$options): ImageInterface + public function save(?string $path = null, mixed ...$options): ImageInterface { $path = is_null($path) ? $this->origin()->filePath() : $path; diff --git a/src/Interfaces/ImageInterface.php b/src/Interfaces/ImageInterface.php index 48357881..d4464a4b 100644 --- a/src/Interfaces/ImageInterface.php +++ b/src/Interfaces/ImageInterface.php @@ -88,7 +88,7 @@ public function encode(EncoderInterface $encoder = new AutoEncoder()): EncodedIm * @throws RuntimeException * @return ImageInterface */ - public function save(?string $path = null, ...$options): self; + public function save(?string $path = null, mixed ...$options): self; /** * Apply given modifier to current image diff --git a/tests/ImagickTestCase.php b/tests/ImagickTestCase.php index 414078df..c312b5a4 100644 --- a/tests/ImagickTestCase.php +++ b/tests/ImagickTestCase.php @@ -35,4 +35,22 @@ public function createTestImage(int $width, int $height): Image new Core($imagick) ); } + + public function createTestAnimation(): Image + { + $imagick = new Imagick(); + $imagick->setFormat('gif'); + + for ($i = 0; $i < 3; $i++) { + $frame = new Imagick(); + $frame->newImage(3, 2, new ImagickPixel('rgb(255, 0, 0)'), 'gif'); + $frame->setImageDelay(10); + $imagick->addImage($frame); + } + + return new Image( + new Driver(), + new Core($imagick) + ); + } } diff --git a/tests/Traits/CanDetectProgressiveJpeg.php b/tests/Traits/CanDetectProgressiveJpeg.php new file mode 100644 index 00000000..5bc8bd45 --- /dev/null +++ b/tests/Traits/CanDetectProgressiveJpeg.php @@ -0,0 +1,57 @@ +buildFilePointer($imagedata); + + while (!feof($f)) { + if (unpack('C', fread($f, 1))[1] !== 0xff) { + return false; + } + + $blockType = unpack('C', fread($f, 1))[1]; + + switch (true) { + case $blockType == 0xd8: + case $blockType >= 0xd0 && $blockType <= 0xd7: + break; + + case $blockType == 0xc0: + fclose($f); + return false; + + case $blockType == 0xc2: + fclose($f); + return true; + + case $blockType == 0xd9: + break 2; + + default: + $blockSize = unpack('n', fread($f, 2))[1]; + fseek($f, $blockSize - 2, SEEK_CUR); + break; + } + } + + fclose($f); + + return false; + } +} diff --git a/tests/Unit/Drivers/Gd/Encoders/GifEncoderTest.php b/tests/Unit/Drivers/Gd/Encoders/GifEncoderTest.php index 4bcd26a5..a4479c32 100644 --- a/tests/Unit/Drivers/Gd/Encoders/GifEncoderTest.php +++ b/tests/Unit/Drivers/Gd/Encoders/GifEncoderTest.php @@ -4,6 +4,7 @@ namespace Intervention\Image\Tests\Unit\Drivers\Gd\Encoders; +use Intervention\Gif\Decoder; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\RequiresPhpExtension; use Intervention\Image\Encoders\GifEncoder; @@ -20,5 +21,30 @@ public function testEncode(): void $encoder = new GifEncoder(); $result = $encoder->encode($image); $this->assertMediaType('image/gif', (string) $result); + $this->assertFalse( + Decoder::decode((string) $result)->getFirstFrame()->getImageDescriptor()->isInterlaced() + ); + } + + public function testEncodeInterlaced(): void + { + $image = $this->createTestImage(3, 2); + $encoder = new GifEncoder(interlaced: true); + $result = $encoder->encode($image); + $this->assertMediaType('image/gif', (string) $result); + $this->assertTrue( + Decoder::decode((string) $result)->getFirstFrame()->getImageDescriptor()->isInterlaced() + ); + } + + public function testEncodeInterlacedAnimation(): void + { + $image = $this->createTestAnimation(3, 2); + $encoder = new GifEncoder(interlaced: true); + $result = $encoder->encode($image); + $this->assertMediaType('image/gif', (string) $result); + $this->assertTrue( + Decoder::decode((string) $result)->getFirstFrame()->getImageDescriptor()->isInterlaced() + ); } } diff --git a/tests/Unit/Drivers/Gd/Encoders/JpegEncoderTest.php b/tests/Unit/Drivers/Gd/Encoders/JpegEncoderTest.php index fe2ec78e..c72667fe 100644 --- a/tests/Unit/Drivers/Gd/Encoders/JpegEncoderTest.php +++ b/tests/Unit/Drivers/Gd/Encoders/JpegEncoderTest.php @@ -8,12 +8,15 @@ use PHPUnit\Framework\Attributes\RequiresPhpExtension; use Intervention\Image\Encoders\JpegEncoder; use Intervention\Image\Tests\GdTestCase; +use Intervention\Image\Tests\Traits\CanDetectProgressiveJpeg; #[RequiresPhpExtension('gd')] #[CoversClass(\Intervention\Image\Encoders\JpegEncoder::class)] #[CoversClass(\Intervention\Image\Drivers\Gd\Encoders\JpegEncoder::class)] final class JpegEncoderTest extends GdTestCase { + use CanDetectProgressiveJpeg; + public function testEncode(): void { $image = $this->createTestImage(3, 2); @@ -21,4 +24,13 @@ public function testEncode(): void $result = $encoder->encode($image); $this->assertMediaType('image/jpeg', (string) $result); } + + public function testEncodeProgressive(): void + { + $image = $this->createTestImage(3, 2); + $encoder = new JpegEncoder(progressive: true); + $result = $encoder->encode($image); + $this->assertMediaType('image/jpeg', (string) $result); + $this->assertTrue($this->isProgressiveJpeg((string) $result)); + } } diff --git a/tests/Unit/Drivers/Imagick/Encoders/GifEncoderTest.php b/tests/Unit/Drivers/Imagick/Encoders/GifEncoderTest.php index ee61bf8a..1d3702c5 100644 --- a/tests/Unit/Drivers/Imagick/Encoders/GifEncoderTest.php +++ b/tests/Unit/Drivers/Imagick/Encoders/GifEncoderTest.php @@ -4,6 +4,7 @@ namespace Intervention\Image\Tests\Unit\Drivers\Imagick\Encoders; +use Intervention\Gif\Decoder; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\RequiresPhpExtension; use Intervention\Image\Encoders\GifEncoder; @@ -20,5 +21,30 @@ public function testEncode(): void $encoder = new GifEncoder(); $result = $encoder->encode($image); $this->assertMediaType('image/gif', (string) $result); + $this->assertFalse( + Decoder::decode((string) $result)->getFirstFrame()->getImageDescriptor()->isInterlaced() + ); + } + + public function testEncodeInterlaced(): void + { + $image = $this->createTestImage(3, 2); + $encoder = new GifEncoder(interlaced: true); + $result = $encoder->encode($image); + $this->assertMediaType('image/gif', (string) $result); + $this->assertTrue( + Decoder::decode((string) $result)->getFirstFrame()->getImageDescriptor()->isInterlaced() + ); + } + + public function testEncodeInterlacedAnimation(): void + { + $image = $this->createTestAnimation(); + $encoder = new GifEncoder(interlaced: true); + $result = $encoder->encode($image); + $this->assertMediaType('image/gif', (string) $result); + $this->assertTrue( + Decoder::decode((string) $result)->getFirstFrame()->getImageDescriptor()->isInterlaced() + ); } } diff --git a/tests/Unit/Drivers/Imagick/Encoders/JpegEncoderTest.php b/tests/Unit/Drivers/Imagick/Encoders/JpegEncoderTest.php index 3bfae7e4..b2244d06 100644 --- a/tests/Unit/Drivers/Imagick/Encoders/JpegEncoderTest.php +++ b/tests/Unit/Drivers/Imagick/Encoders/JpegEncoderTest.php @@ -8,12 +8,15 @@ use PHPUnit\Framework\Attributes\RequiresPhpExtension; use Intervention\Image\Encoders\JpegEncoder; use Intervention\Image\Tests\ImagickTestCase; +use Intervention\Image\Tests\Traits\CanDetectProgressiveJpeg; #[RequiresPhpExtension('imagick')] #[CoversClass(\Intervention\Image\Encoders\JpegEncoder::class)] #[CoversClass(\Intervention\Image\Drivers\Imagick\Encoders\JpegEncoder::class)] final class JpegEncoderTest extends ImagickTestCase { + use CanDetectProgressiveJpeg; + public function testEncode(): void { $image = $this->createTestImage(3, 2); @@ -21,4 +24,13 @@ public function testEncode(): void $result = $encoder->encode($image); $this->assertMediaType('image/jpeg', (string) $result); } + + public function testEncodeProgressive(): void + { + $image = $this->createTestImage(3, 2); + $encoder = new JpegEncoder(progressive: true); + $result = $encoder->encode($image); + $this->assertMediaType('image/jpeg', (string) $result); + $this->assertTrue($this->isProgressiveJpeg((string) $result)); + } }