diff --git a/composer.json b/composer.json index 881c9b4a0..6ff2145d1 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "laravel/prompts": "0.*", "league/commonmark": "^2.4", "masterminds/html5": "^2.8", - "spatie/image-optimizer": "^1.6", + "spatie/laravel-image-optimizer": "^1.8", "symfony/html-sanitizer": "^7.3", "tightenco/ziggy": "^2.0" }, diff --git a/demo/composer.json b/demo/composer.json index f60888a46..9f4df6f65 100644 --- a/demo/composer.json +++ b/demo/composer.json @@ -15,7 +15,7 @@ "laravel/tinker": "^3.0", "masterminds/html5": "^2.9", "pragmarx/google2fa": "^8.0", - "spatie/image-optimizer": "^1.7", + "spatie/laravel-image-optimizer": "^1.8", "spatie/laravel-passkeys": "^1.6", "spatie/laravel-translatable": "^6.13", "symfony/html-sanitizer": "^7.3", diff --git a/demo/composer.lock b/demo/composer.lock index 5994d0950..884e28db3 100644 --- a/demo/composer.lock +++ b/demo/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2221840f079c1be1388507f2013756dc", + "content-hash": "b7d5c71c9fed55cf367da4f828439458", "packages": [ { "name": "bacon/bacon-qr-code", @@ -4405,6 +4405,74 @@ }, "time": "2025-11-26T10:57:19+00:00" }, + { + "name": "spatie/laravel-image-optimizer", + "version": "1.8.3", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-image-optimizer.git", + "reference": "abc476add8b41d10185a07377ce7f64657b3ed91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-image-optimizer/zipball/abc476add8b41d10185a07377ce7f64657b3ed91", + "reference": "abc476add8b41d10185a07377ce7f64657b3ed91", + "shasum": "" + }, + "require": { + "laravel/framework": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0", + "spatie/image-optimizer": "^1.2.0" + }, + "require-dev": { + "orchestra/testbench": "^6.23|^7.0|^8.0|^9.0|^10.0|^11.0", + "phpunit/phpunit": "^9.4|^10.5|^11.5.3|^12.5.12" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "ImageOptimizer": "Spatie\\LaravelImageOptimizer\\Facades\\ImageOptimizer" + }, + "providers": [ + "Spatie\\LaravelImageOptimizer\\ImageOptimizerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\LaravelImageOptimizer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Optimize images in your Laravel app", + "homepage": "https://github.com/spatie/laravel-image-optimizer", + "keywords": [ + "laravel-image-optimizer", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/laravel-image-optimizer/tree/1.8.3" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + } + ], + "time": "2026-02-21T21:35:45+00:00" + }, { "name": "spatie/laravel-package-tools", "version": "1.93.0", @@ -6269,16 +6337,16 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -6329,7 +6397,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -6349,7 +6417,7 @@ "type": "tidelift" } ], - "time": "2025-01-02T08:10:11+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php84", @@ -6596,16 +6664,16 @@ }, { "name": "symfony/process", - "version": "v8.0.5", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674" + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", - "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", + "url": "https://api.github.com/repos/symfony/process/zipball/26d89e459f037d2873300605d0a07e7a8ef84db0", + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0", "shasum": "" }, "require": { @@ -6637,7 +6705,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.0.5" + "source": "https://github.com/symfony/process/tree/v8.0.11" }, "funding": [ { @@ -6657,7 +6725,7 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:08:38+00:00" + "time": "2026-05-11T16:56:32+00:00" }, { "name": "symfony/property-access", diff --git a/demo/config/image-optimizer.php b/demo/config/image-optimizer.php new file mode 100644 index 000000000..35196d607 --- /dev/null +++ b/demo/config/image-optimizer.php @@ -0,0 +1,66 @@ + [ + + Jpegoptim::class => [ + '-m85', // set maximum quality to 85% + '--strip-all', // this strips out all text information such as comments and EXIF data + '--all-progressive', // this will make sure the resulting image is a progressive one + ], + + Pngquant::class => [ + '--force', // required parameter for this package + ], + + Optipng::class => [ + '-i0', // this will result in a non-interlaced, progressive scanned image + '-o2', // this set the optimization level to two (multiple IDAT compression trials) + '-quiet', // required parameter for this package + ], + + Svgo::class => [ + '--disable=cleanupIDs', // disabling because it is know to cause troubles + ], + + Gifsicle::class => [ + '-b', // required parameter for this package + '-O3', // this produces the slowest but best results + ], + + Cwebp::class => [ + '-m 6', // for the slowest compression method in order to get the best compression. + '-pass 10', // for maximizing the amount of analysis pass. + '-mt', // multithreading for some speed improvements. + '-q 90', // quality factor that brings the least noticeable changes. + ], + ], + + /* + * The directory where your binaries are stored. + * Only use this when you binaries are not accessible in the global environment. + */ + 'binary_path' => env('IMAGE_OPTIMIZER_PATH', ''), + + /* + * The maximum time in seconds each optimizer is allowed to run separately. + */ + 'timeout' => 60, + + /* + * If set to `true` all output of the optimizer binaries will be appended to the default log. + * You can also set this to a class that implements `Psr\Log\LoggerInterface`. + */ + 'log_optimizer_activity' => true, +]; diff --git a/docs/guide/form-fields/upload.md b/docs/guide/form-fields/upload.md index ba2ebe952..0611fb9ed 100644 --- a/docs/guide/form-fields/upload.md +++ b/docs/guide/form-fields/upload.md @@ -95,7 +95,7 @@ If true and if the upload has a thumbnail, it is limited to 60px high (to compac ### `setImageOptimize(bool $imageOptimize = true)` -If true, some optimization will be applied on the uploaded images (in order to reduce files weight). It relies on spatie's [image-optimizer](https://github.com/spatie/image-optimizer). Please note that you will need some of these packages on your system: +If true, some optimization will be applied on the uploaded images (in order to reduce files weight). It relies on spatie's [laravel-image-optimizer](https://github.com/spatie/laravel-image-optimizer). Please note that you will need some of these packages on your system: - [JpegOptim](http://freecode.com/projects/jpegoptim) - [Optipng](http://optipng.sourceforge.net/) - [Pngquant 2](https://pngquant.org/) @@ -103,7 +103,11 @@ If true, some optimization will be applied on the uploaded images (in order to r - [Gifsicle](http://www.lcdf.org/gifsicle/) - [cwebp](https://developers.google.com/speed/webp/docs/precompiled) -Check their documentation for [more instructions](https://github.com/spatie/image-optimizer#optimization-tools) on how to install. +Check their documentation for [more instructions](https://github.com/spatie/image-optimizer#optimization-tools) on how to install. + +::: info +If a JPG image with EXIF orientation is uploaded, it will use your configured upload thumbnail driver (GD or Imagick) instead of JpegOptim. This will ensure that the orientation is preserved after optimization. +::: ## Validation diff --git a/src/Http/Jobs/HandleUploadedFileJob.php b/src/Http/Jobs/HandleUploadedFileJob.php index 7710d6c9c..73809c2d0 100644 --- a/src/Http/Jobs/HandleUploadedFileJob.php +++ b/src/Http/Jobs/HandleUploadedFileJob.php @@ -8,7 +8,6 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Support\Facades\Storage; -use Spatie\ImageOptimizer\OptimizerChainFactory; class HandleUploadedFileJob implements ShouldQueue { @@ -36,11 +35,10 @@ public function handle(): void ); if ($this->shouldOptimizeImage) { - // We do not need to check for exception nor file format because - // the package will not throw any errors and just operate silently. - app(OptimizerChainFactory::class) - ->create() - ->optimize(Storage::disk($tmpDisk)->path($tmpFilePath)); + OptimizeImageJob::dispatchSync( + disk: $tmpDisk, + filePath: $tmpFilePath, + ); } if ($this->transformFilters) { @@ -48,14 +46,14 @@ public function handle(): void HandleTransformedFileJob::dispatchSync( disk: $tmpDisk, filePath: $tmpFilePath, - transformFilters: $this->transformFilters + transformFilters: $this->transformFilters, ); } if ($this->shouldSanitizeSvg && Storage::disk($tmpDisk)->mimeType($tmpFilePath) === 'image/svg+xml') { SanitizeSvgJob::dispatchSync( disk: $tmpDisk, - filePath: $tmpFilePath + filePath: $tmpFilePath, ); } diff --git a/src/Http/Jobs/OptimizeImageJob.php b/src/Http/Jobs/OptimizeImageJob.php new file mode 100644 index 000000000..14f6d966f --- /dev/null +++ b/src/Http/Jobs/OptimizeImageJob.php @@ -0,0 +1,82 @@ +optimizeWithIntervention()) { + return; + } + + // We do not need to check for exception nor file format because + // the package will not throw any errors and just operate silently. + $chain = app(OptimizerChain::class); + + if ($pngquant = collect($chain->getOptimizers())->whereInstanceOf(Pngquant::class)->first()) { + if (! collect($pngquant->options)->some(fn ($option) => str_starts_with($option, '--quality'))) { + $pngquant->options[] = '--quality=85'; + } + } + + if (! collect($chain->getOptimizers())->whereInstanceOf(Avifenc::class)->first()) { + $chain->addOptimizer(new Avifenc([ + '-a cq-level=23', + '-j all', + '--min 0', + '--max 63', + '--minalpha 0', + '--maxalpha 63', + '-a end-usage=q', + '-a tune=ssim', + ])); + } + + $chain->optimize(Storage::disk($this->disk)->path($this->filePath)); + } + + protected function optimizeWithIntervention(): bool + { + $imageManager = app(SharpImageManager::class); + $localPath = Storage::disk($this->disk)->path($this->filePath); + + if (Storage::disk($this->disk)->mimeType($this->filePath) === 'image/jpeg' + && ($exif = $this->getExifData($localPath)) + && ($exif['Orientation'] ?? 1) !== 1 + ) { + Storage::disk($this->disk)->put( + $this->filePath, + $imageManager->read($localPath)->orient()->encode(new JpegEncoder(quality: 85, progressive: true, strip: true)), + ); + + return true; + } + + return false; + } + + protected function getExifData(string $path): ?array + { + return @exif_read_data($path) ?: null; + } +} diff --git a/tests/Http/Jobs/HandleUploadedFileJobTest.php b/tests/Http/Jobs/HandleUploadedFileJobTest.php index f648ac1a9..5cc222440 100644 --- a/tests/Http/Jobs/HandleUploadedFileJobTest.php +++ b/tests/Http/Jobs/HandleUploadedFileJobTest.php @@ -2,9 +2,10 @@ use Code16\Sharp\Exceptions\Form\SharpFormUpdateException; use Code16\Sharp\Http\Jobs\HandleUploadedFileJob; +use Code16\Sharp\Http\Jobs\OptimizeImageJob; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Storage; -use Spatie\ImageOptimizer\OptimizerChainFactory; beforeEach(function () { Storage::fake('local'); @@ -56,32 +57,7 @@ ->throws(SharpFormUpdateException::class); it('optimizes uploaded images if configured', function () { - $optimizer = new class() - { - public bool $wasOptimized = false; - - public function optimize(): bool - { - $this->wasOptimized = true; - - return true; - } - }; - - app()->bind(OptimizerChainFactory::class, fn () => new class($optimizer) - { - private $optimizer; - - public function __construct(&$optimizer) - { - $this->optimizer = $optimizer; - } - - public function create() - { - return $this->optimizer; - } - }); + Bus::fake([OptimizeImageJob::class]); UploadedFile::fake() ->image('image.jpg') @@ -94,8 +70,9 @@ public function create() shouldOptimizeImage: true, ); - Storage::disk('local')->assertExists('data/image.jpg'); - expect($optimizer->wasOptimized)->toBeTrue(); + Bus::assertDispatchedSync(function (OptimizeImageJob $job) { + return $job->disk === 'local' && $job->filePath === 'tmp/image.jpg'; + }); }); it('handles image transformations on a newly uploaded file if isTransformOriginal is configured', function () { diff --git a/tests/Http/Jobs/OptimizeImageJobTest.php b/tests/Http/Jobs/OptimizeImageJobTest.php new file mode 100644 index 000000000..2a72aa034 --- /dev/null +++ b/tests/Http/Jobs/OptimizeImageJobTest.php @@ -0,0 +1,102 @@ +singleton(OptimizerChain::class, fn () => new class() extends OptimizerChain + { + public ?string $optimizedPathToImage = null; + + public function __construct() + { + parent::__construct(); + + foreach (config('image-optimizer.optimizers') as $optimizer => $optimizerConfig) { + $this->addOptimizer(new $optimizer($optimizerConfig)); + } + } + + public function optimize(string $pathToImage, ?string $pathToOutput = null): bool + { + $this->optimizedPathToImage = $pathToImage; + + return true; + } + }); + + Storage::fake('local'); + $path = UploadedFile::fake() + ->image('image.jpg') + ->storeAs('data', 'image.jpg', ['disk' => 'local']); + + OptimizeImageJob::dispatch( + disk: 'local', + filePath: 'data/image.jpg', + ); + + expect(collect(app(OptimizerChain::class)->getOptimizers())->whereInstanceOf(Avifenc::class)) + ->not->toBeEmpty(); + + expect(app(OptimizerChain::class)->optimizedPathToImage)->toEqual(Storage::disk('local')->path($path)); +}); + +it('rotates image with intervention if it has orientation EXIF tag', function () { + $imageManager = Mockery::mock(SharpImageManager::class); + $image = Mockery::mock(ImageInterface::class); + $encodedImage = Mockery::mock(EncodedImageInterface::class); + + app()->bind(SharpImageManager::class, fn () => $imageManager); + + $optimizerChain = new class() extends OptimizerChain + { + public bool $optimized = false; + + public function optimize(string $pathToImage, ?string $pathToOutput = null): bool + { + $this->optimized = true; + + return true; + } + }; + app()->singleton(OptimizerChain::class, fn () => $optimizerChain); + + Storage::fake('local'); + $path = UploadedFile::fake() + ->image('image.jpg') + ->storeAs('data', 'image.jpg', ['disk' => 'local']); + $localPath = Storage::disk('local')->path($path); + + $imageManager->shouldReceive('read') + ->with($localPath) + ->andReturn($image); + + $image->shouldReceive('orient') + ->andReturnSelf(); + + $image->shouldReceive('encode') + ->andReturn($encodedImage); + + $encodedImage->shouldReceive('__toString') + ->andReturn('fake-image-content'); + + $job = Mockery::mock(OptimizeImageJob::class, ['local', 'data/image.jpg']) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + + $job->shouldReceive('getExifData') + ->with($localPath) + ->andReturn(['Orientation' => 3]); + + $job->handle(); + + Storage::disk('local')->assertExists('data/image.jpg'); + expect(Storage::disk('local')->get('data/image.jpg'))->toBe('fake-image-content'); + expect($optimizerChain->optimized)->toBeFalse(); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 1f2cf072b..632ac2fc9 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,6 +8,7 @@ use Code16\Sharp\SharpInternalServiceProvider; use Orchestra\Testbench\Pest\WithPest; use Orchestra\Testbench\TestCase as Orchestra; +use Spatie\LaravelImageOptimizer\ImageOptimizerServiceProvider; class TestCase extends Orchestra { @@ -26,6 +27,7 @@ protected function getPackageProviders($app): array SharpInternalServiceProvider::class, ContentRendererServiceProvider::class, BladeIconsServiceProvider::class, + ImageOptimizerServiceProvider::class, ]; }