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
- );
- }
-}