diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae25cba..c5d467f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,9 +92,6 @@ jobs: - name: Checkout uses: actions/checkout@v6 - - name: Install gifsicle - run: sudo apt-get update && sudo apt-get install -y gifsicle - - name: Setup PHP, with composer and extensions uses: shivammathur/setup-php@v2 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 37afbb8..e19d60d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased * Added support for Symfony 8.2 +* Replaced the `gifexception:optimize` command and `bin/optimizer.php` by the `castor optimize` task ## 1.11.1 (2026-05-26) diff --git a/Command/GifOptimizerCommand.php b/Command/GifOptimizerCommand.php deleted file mode 100644 index e0e244d..0000000 --- a/Command/GifOptimizerCommand.php +++ /dev/null @@ -1,144 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Joli\GifExceptionBundle\Command; - -use Spatie\ImageOptimizer\OptimizerChain; -use Spatie\ImageOptimizer\OptimizerChainFactory; -use Spatie\ImageOptimizer\Optimizers\Gifsicle; -use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; - -#[AsCommand(name: 'gifexception:optimize', description: 'Optimize gifs')] -class GifOptimizerCommand extends Command -{ - private const DEFAULT_OPTIMIZATION_LEVEL = '-O3'; - private const DEFAULT_WIDTH = 145; - - private OptimizerChain $optimizer; - - protected function configure(): void - { - $this - ->addArgument( - 'image_dir', - InputArgument::OPTIONAL, - 'Location of images', - __DIR__ . '/../Resources/public/images' - ) - ->addOption( - 'optimization_level', - null, - InputOption::VALUE_OPTIONAL, - 'What level optimization do you require?', - self::DEFAULT_OPTIMIZATION_LEVEL - ) - ->addOption( - 'resize_width', - null, - InputOption::VALUE_OPTIONAL, - 'Width you would like to resize to?', - self::DEFAULT_WIDTH - ) - ->setDescription('Optimize gifs') - ; - - $this->setHidden(true); - } - - protected function initialize(InputInterface $input, OutputInterface $output): void - { - $optimizationLevel = $input->getOption('optimization_level'); - $width = $input->getOption('resize_width'); - - if (!is_numeric($width)) { - throw new \Exception('Invalid width.'); - } - - $this->optimizer = OptimizerChainFactory::create() - ->setOptimizers([ - new Gifsicle([ - '-b', - $optimizationLevel, - \sprintf('--resize-width=%s', (string) $width), - ]), - ]) - ; - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $imageDir = $input->getArgument('image_dir'); - - if (!\is_string($imageDir) || !is_dir($imageDir)) { - throw new \RuntimeException('image_dir is not a valid directory'); - } - - $pattern = $imageDir . '/*/*.gif'; - $images = glob($pattern); - - if (!$images) { - throw new \RuntimeException('No images found in ' . $pattern); - } - - foreach ($images as $path) { - $realPath = realpath($path); - - if (!$realPath) { - throw new \RuntimeException('Could not find ' . $path); - } - - $originalFileSize = filesize($realPath); - - if (!$originalFileSize) { - throw new \RuntimeException('Could not get file size for ' . $realPath); - } - - $output->writeln(\sprintf('Optimizing image: %s', $realPath)); - $output->writeln(\sprintf('Before: %s', $this->formatBytes($originalFileSize))); - - $this->optimizer->optimize($realPath); - - // File size information is cached, so make sure we clear this to get the file save difference. - clearstatcache(true, $realPath); - - $optimizedFileSize = filesize($realPath); - - if (!$optimizedFileSize) { - throw new \RuntimeException('Could not get file size for ' . $realPath); - } - - $output->writeln(\sprintf('After: %s', $this->formatBytes($optimizedFileSize))); - - $percentage = 100 - (($optimizedFileSize / $originalFileSize) * 100); - $output->writeln(\sprintf('Saving: %s%%', round($percentage))); - } - - return 0; - } - - private function formatBytes(int $bytes, bool $useStandard = true): string - { - $unit = $useStandard ? 1024 : 1000; - if ($bytes <= $unit) { - return $bytes . ' B'; - } - $exp = (int) (log($bytes) / log($unit)); - $pre = ($useStandard ? 'kMGTPE' : 'KMGTPE'); - $pre = $pre[$exp - 1] . ($useStandard ? '' : 'i'); - - return \sprintf('%.1f %sB', $bytes / ($unit ** $exp), $pre); - } -} diff --git a/README.md b/README.md index 1b384ac..da70380 100644 --- a/README.md +++ b/README.md @@ -33,21 +33,14 @@ more gifs in [Resources/public/images/](Resources/public/images/), it will be ve ## Optimizing gifs -If you are adding gifs we strongly recommend optimizing them. There is a tool to aid with this (you will need to install [gifsicle](http://www.lcdf.org/gifsicle/)). +Every gif in the repository must be optimized and resized to 145px wide, the +test suite enforces it. Before committing a new gif, run the +[Castor](https://castor.jolicode.com/) task (you will need to install +[gifsicle](https://www.lcdf.org/gifsicle/)): +```shell +castor optimize ``` -bin/optimizer.php -``` - -You can also run this command directly from your Symfony application: - -``` -bin/console gifexception:optimize -``` - -Although it would probably make more sense to use the former and push up the optimized gifs. - -It uses [gifsicle](https://www.lcdf.org/gifsicle/) to optimize gifs via the [image-optimizer](https://github.com/psliwa/image-optimizer) library. ## Development @@ -63,6 +56,7 @@ castor test # Run the test suite castor cs # Fix PHP coding standards castor cs --dry-run # Check PHP coding standards castor phpstan # Run static analysis +castor optimize # Optimize and resize gifs ``` ## Further documentation diff --git a/Resources/config/services.php b/Resources/config/services.php index 16de822..4a37be0 100644 --- a/Resources/config/services.php +++ b/Resources/config/services.php @@ -11,7 +11,6 @@ namespace Joli\GifExceptionBundle\DependencyInjection\Loader\Configurator; -use Joli\GifExceptionBundle\Command\GifOptimizerCommand; use Joli\GifExceptionBundle\EventListener\ReplaceImageListener; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; @@ -29,8 +28,4 @@ ]) ->tag('kernel.event_subscriber') ; - $container->services() - ->set(GifOptimizerCommand::class) - ->tag('console.command') - ; }; diff --git a/bin/optimizer.php b/bin/optimizer.php deleted file mode 100755 index 1773bf6..0000000 --- a/bin/optimizer.php +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env php - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -use Joli\GifExceptionBundle\Command\GifOptimizerCommand; -use Symfony\Component\Console\Application; - -if (file_exists(__DIR__ . '/../vendor/autoload.php')) { - $loader = require __DIR__ . '/../vendor/autoload.php'; -} elseif (file_exists(__DIR__ . '/../../../../vendor/autoload.php')) { - $loader = require __DIR__ . '/../../../../vendor/autoload.php'; -} else { - throw new RuntimeException('Unable to load autoloader.'); -} - -$application = new Application('gifexception'); -$application->addCommand(new GifOptimizerCommand()); -$application - ->setDefaultCommand('gifexception:optimize', true) - ->run() -; diff --git a/castor.php b/castor.php index d283680..328c439 100644 --- a/castor.php +++ b/castor.php @@ -11,6 +11,7 @@ use Castor\Attribute\AsTask; +use function Castor\io; use function Castor\run; #[AsTask(description: 'Fix CS', aliases: ['cs'])] @@ -36,3 +37,16 @@ function phpstan(): void { run('vendor/bin/phpstan analyse -c phpstan.neon'); } + +#[AsTask(description: 'Optimize and resize gifs with gifsicle')] +function optimize(string $dir = 'Resources/public/images', int $width = 145, string $level = '-O3'): void +{ + foreach (glob("{$dir}/*/*.gif") ?: [] as $gif) { + $before = filesize($gif); + run(['gifsicle', '-b', $level, "--resize-width={$width}", $gif], quiet: true); + clearstatcache(true, $gif); + $after = filesize($gif); + + io()->writeln(sprintf('%s: %d KB -> %d KB (%d%%)', $gif, $before / 1024, $after / 1024, 100 - $after * 100 / $before)); + } +} diff --git a/composer.json b/composer.json index 6e08de9..d6f4fbd 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,6 @@ "doctrine/annotations": "^2.0", "friendsofphp/php-cs-fixer": "^3.17", "phpstan/phpstan": "^2.1", - "spatie/image-optimizer": "^1.8", "symfony/asset": "^6.4 || ^7.3 || ^8.0", "symfony/browser-kit": "^6.4 || ^7.3 || ^8.0", "symfony/config": "^6.4 || ^7.3 || ^8.0", diff --git a/tests/Command/GifOptimizerCommandTest.php b/tests/Command/GifOptimizerCommandTest.php deleted file mode 100644 index 5db3d00..0000000 --- a/tests/Command/GifOptimizerCommandTest.php +++ /dev/null @@ -1,83 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Joli\GifExceptionBundle\Tests\Command; - -use ImageOptimizer\Exception\CommandNotFound; -use Joli\GifExceptionBundle\Tests\KernelTestCase; - -class GifOptimizerCommandTest extends KernelTestCase -{ - private string $prototypeGif; - private string $testGif; - - protected function setUp(): void - { - parent::setUp(); - - $this->prototypeGif = __DIR__ . '/original.gif'; - $this->testGif = __DIR__ . '/gifs/test.gif'; - } - - protected function tearDown(): void - { - parent::tearDown(); - copy($this->prototypeGif, $this->testGif); - } - - public function testExceptionRaisedForInvalidImageDir(): void - { - $this->expectException(\RuntimeException::class); - - $this->getOutputForCommand('gifexception:optimize', ['image_dir' => 'foobar']); - } - - public function testGifIsResizedToExpectedWidth(): void - { - [$originalWidth] = getimagesize($this->testGif); - - $expectedWidth = 145; - $args = ['image_dir' => __DIR__]; - $options = ['--resize_width' => $expectedWidth]; - - try { - $this->getOutputForCommand('gifexception:optimize', $args, $options); - } catch (CommandNotFound $e) { - $this->markTestSkipped('Gif optimizer tool is not executable'); - } - - clearstatcache(true, $this->testGif); - - [$optimizedWidth] = getimagesize($this->testGif); - - self::assertSame($expectedWidth, $optimizedWidth); - self::assertNotSame($originalWidth, $optimizedWidth); - } - - public function testGifIsSmallerFileSize(): void - { - $originalSize = filesize($this->testGif); - - $args = ['image_dir' => __DIR__]; - - try { - $this->getOutputForCommand('gifexception:optimize', $args); - } catch (CommandNotFound $e) { - $this->markTestSkipped('Gif optimizer tool is not executable'); - } - - clearstatcache(true, $this->testGif); - - $optimizedSize = filesize($this->testGif); - - self::assertLessThanOrEqual($originalSize, $optimizedSize); - } -} diff --git a/tests/Command/gifs/test.gif b/tests/Command/gifs/test.gif deleted file mode 100644 index 99886f5..0000000 Binary files a/tests/Command/gifs/test.gif and /dev/null differ diff --git a/tests/Command/original.gif b/tests/Command/original.gif deleted file mode 100644 index 99886f5..0000000 Binary files a/tests/Command/original.gif and /dev/null differ diff --git a/tests/GifsTest.php b/tests/GifsTest.php new file mode 100644 index 0000000..be21ef1 --- /dev/null +++ b/tests/GifsTest.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 Joli\GifExceptionBundle\Tests; + +use PHPUnit\Framework\TestCase; + +class GifsTest extends TestCase +{ + public function testAllGifsAreResized(): void + { + $gifs = glob(__DIR__ . '/../Resources/public/images/*/*.gif'); + + self::assertNotEmpty($gifs); + + foreach ($gifs as $gif) { + [$width] = getimagesize($gif); + + self::assertSame(145, $width, basename($gif) . ' is not resized, run `castor optimize`'); + } + } +} diff --git a/tests/KernelTestCase.php b/tests/KernelTestCase.php deleted file mode 100644 index db6bc67..0000000 --- a/tests/KernelTestCase.php +++ /dev/null @@ -1,49 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Joli\GifExceptionBundle\Tests; - -use Joli\GifExceptionBundle\Tests\app\src\Kernel; -use Symfony\Bundle\FrameworkBundle\Console\Application; -use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase as BaseKernelTestCase; -use Symfony\Component\Console\Tester\ApplicationTester; -use Symfony\Component\HttpKernel\KernelInterface; - -abstract class KernelTestCase extends BaseKernelTestCase -{ - protected ApplicationTester $tester; - - protected function setUp(): void - { - self::bootKernel(); - - $application = new Application(static::$kernel); - $application->setCatchExceptions(false); - $application->setAutoExit(false); - - $this->tester = new ApplicationTester($application); - } - - protected function getOutputForCommand(string $commandName, array $args = [], array $options = []) - { - $this->tester->run(array_merge(['command' => $commandName], $args, $options)); - - return $this->tester->getDisplay(); - } - - protected static function createKernel(array $options = []): KernelInterface - { - return new Kernel( - $options['environment'] ?? 'test', - $options['debug'] ?? true - ); - } -}