Skip to content

Commit c5a7ee9

Browse files
committed
[Cropperjs] Upgrade to Intervention Image v4 and make the image driver configurable
Migrate the server-side cropping from intervention/image ^2.5 (v2) to ^4.0, which removes the indirect v2 deprecations reported in #3394. The Crop model now uses the v4 API (decodeBinary/scaleDown/encodeUsingFileExtension and ImageManagerInterface). Add a configurable image driver through a new bundle configuration: * cropperjs.driver: gd (default), imagick or vips * cropperjs.driver_service: a custom Intervention DriverInterface service, which takes precedence over driver The ImageManager is built via ImageManager::usingDriver(); selecting vips without the intervention/image-driver-vips package fails fast with a helpful message. GD remains the zero-config default, preserving current behavior. Tests are parameterized over the gd and imagick drivers and now cover the crop region, max-size downscaling and output format paths.
1 parent e48c395 commit c5a7ee9

10 files changed

Lines changed: 390 additions & 47 deletions

File tree

src/Cropperjs/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# CHANGELOG
22

3+
## 3.3
4+
5+
- Upgrade the `intervention/image` dependency to `^4.0` (drops support for v2,
6+
which fixes the indirect deprecations triggered by v2).
7+
- Add a configurable image driver: the new `cropperjs.driver` option accepts
8+
`gd` (default), `imagick` or `vips`. A custom driver service can be provided
9+
through the `cropperjs.driver_service` option.
10+
311
## 3.0.0
412

513
- Minimum required Symfony version is now 7.4

src/Cropperjs/composer.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
},
3030
"require": {
3131
"php": ">=8.4",
32-
"intervention/image": "^2.5",
32+
"intervention/image": "^4.0",
3333
"symfony/config": "^7.4|^8.0",
3434
"symfony/dependency-injection": "^7.4|^8.0",
3535
"symfony/deprecation-contracts": "^2.5|^3",
@@ -49,6 +49,9 @@
4949
"conflict": {
5050
"symfony/flex": "<1.13"
5151
},
52+
"suggest": {
53+
"intervention/image-driver-vips": "To use the 'vips' cropping driver (also requires the libvips system library and the ext-ffi PHP extension)"
54+
},
5255
"extra": {
5356
"thanks": {
5457
"name": "symfony/ux",

src/Cropperjs/doc/index.rst

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,43 @@ needed if you're using AssetMapper):
3030

3131
For more complex installation scenarios, you can install the JavaScript assets through the `@symfony/ux-cropperjs npm package`_
3232

33+
Configuring the Image Driver
34+
----------------------------
35+
36+
The server-side cropping (``Crop::getCroppedImage()`` and
37+
``Crop::getCroppedThumbnail()``) is powered by `Intervention Image`_. By
38+
default it uses the ``gd`` driver. You can switch to ``imagick`` or ``vips``
39+
through the bundle configuration:
40+
41+
.. code-block:: yaml
42+
43+
# config/packages/cropperjs.yaml
44+
cropperjs:
45+
driver: gd # "gd" (default), "imagick" or "vips"
46+
47+
The ``gd`` and ``imagick`` drivers ship with ``intervention/image`` (make sure
48+
the matching ``ext-gd`` or ``ext-imagick`` PHP extension is enabled). The
49+
``vips`` driver additionally requires the ``intervention/image-driver-vips``
50+
package, the libvips system library and the ``ext-ffi`` PHP extension:
51+
52+
.. code-block:: terminal
53+
54+
$ composer require intervention/image-driver-vips
55+
56+
Using a custom driver
57+
~~~~~~~~~~~~~~~~~~~~~~~
58+
59+
If you need full control over the Intervention Image driver, register your own
60+
service implementing ``Intervention\Image\Interfaces\DriverInterface`` and
61+
reference it with the ``driver_service`` option. When set, it takes precedence
62+
over ``driver``:
63+
64+
.. code-block:: yaml
65+
66+
# config/packages/cropperjs.yaml
67+
cropperjs:
68+
driver_service: App\Image\MyCustomDriver
69+
3370
Usage
3471
-----
3572

@@ -150,6 +187,7 @@ the Symfony framework:
150187
https://symfony.com/doc/current/contributing/code/bc.html
151188

152189
.. _`Cropper.js`: https://fengyuanchen.github.io/cropperjs/
190+
.. _`Intervention Image`: https://image.intervention.io/
153191
.. _`the Symfony UX initiative`: https://ux.symfony.com/
154192
.. _`the Cropper.js options`: https://github.com/fengyuanchen/cropperjs/blob/main/README.md#options
155193
.. _StimulusBundle configured in your app: https://symfony.com/bundles/StimulusBundle/current/index.html
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\Cropperjs\DependencyInjection;
13+
14+
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
15+
use Symfony\Component\Config\Definition\ConfigurationInterface;
16+
17+
/**
18+
* @internal
19+
*/
20+
final class Configuration implements ConfigurationInterface
21+
{
22+
public function getConfigTreeBuilder(): TreeBuilder
23+
{
24+
$treeBuilder = new TreeBuilder('cropperjs');
25+
26+
$treeBuilder->getRootNode()
27+
->children()
28+
->enumNode('driver')
29+
->info('The Intervention Image driver used for server-side cropping.')
30+
->values(['gd', 'imagick', 'vips'])
31+
->defaultValue('gd')
32+
->end()
33+
->scalarNode('driver_service')
34+
->info('Service id of a custom Intervention\Image\Interfaces\DriverInterface. When set, it takes precedence over "driver".')
35+
->defaultNull()
36+
->end()
37+
->end()
38+
;
39+
40+
return $treeBuilder;
41+
}
42+
}

src/Cropperjs/src/DependencyInjection/CropperjsExtension.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111

1212
namespace Symfony\UX\Cropperjs\DependencyInjection;
1313

14+
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
15+
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
16+
use Intervention\Image\Drivers\Vips\Driver as VipsDriver;
1417
use Intervention\Image\ImageManager;
1518
use Symfony\Component\AssetMapper\AssetMapperInterface;
1619
use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -31,6 +34,8 @@ class CropperjsExtension extends Extension implements PrependExtensionInterface
3134
{
3235
public function load(array $configs, ContainerBuilder $container): void
3336
{
37+
$config = $this->processConfiguration(new Configuration(), $configs);
38+
3439
$container
3540
->setDefinition('form.cropper', new Definition(CropperType::class))
3641
->addTag('form.type')
@@ -39,6 +44,8 @@ public function load(array $configs, ContainerBuilder $container): void
3944

4045
$container
4146
->setDefinition('cropper.image_manager', new Definition(ImageManager::class))
47+
->setFactory([ImageManager::class, 'usingDriver'])
48+
->setArguments([$this->resolveDriver($config)])
4249
->setPublic(false)
4350
;
4451

@@ -51,6 +58,26 @@ public function load(array $configs, ContainerBuilder $container): void
5158
$container->setAlias(CropperInterface::class, 'cropper')->setPublic(false);
5259
}
5360

61+
/**
62+
* @param array{driver: string, driver_service: string|null} $config
63+
*/
64+
private function resolveDriver(array $config): Reference|string
65+
{
66+
if (null !== $config['driver_service']) {
67+
return new Reference($config['driver_service']);
68+
}
69+
70+
if ('vips' === $config['driver'] && !class_exists(VipsDriver::class)) {
71+
throw new \LogicException('The "vips" cropperjs driver requires the "intervention/image-driver-vips" package. Try running "composer require intervention/image-driver-vips".');
72+
}
73+
74+
return match ($config['driver']) {
75+
'gd' => GdDriver::class,
76+
'imagick' => ImagickDriver::class,
77+
'vips' => VipsDriver::class,
78+
};
79+
}
80+
5481
public function prepend(ContainerBuilder $container): void
5582
{
5683
if (!$this->isAssetMapperAvailable($container)) {

src/Cropperjs/src/Factory/Cropper.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
namespace Symfony\UX\Cropperjs\Factory;
1313

14-
use Intervention\Image\ImageManager;
14+
use Intervention\Image\Interfaces\ImageManagerInterface;
1515
use Symfony\UX\Cropperjs\Model\Crop;
1616

1717
/**
@@ -21,9 +21,9 @@
2121
*/
2222
class Cropper implements CropperInterface
2323
{
24-
private $imageManager;
24+
private ImageManagerInterface $imageManager;
2525

26-
public function __construct(ImageManager $imageManager)
26+
public function __construct(ImageManagerInterface $imageManager)
2727
{
2828
$this->imageManager = $imageManager;
2929
}

src/Cropperjs/src/Model/Crop.php

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,8 @@
1111

1212
namespace Symfony\UX\Cropperjs\Model;
1313

14-
use Intervention\Image\Constraint;
15-
use Intervention\Image\Image;
16-
use Intervention\Image\ImageManager;
14+
use Intervention\Image\Interfaces\ImageInterface;
15+
use Intervention\Image\Interfaces\ImageManagerInterface;
1716
use Symfony\Component\Validator\Constraints as Assert;
1817

1918
/**
@@ -23,7 +22,7 @@
2322
*/
2423
class Crop
2524
{
26-
private $imageManager;
25+
private ImageManagerInterface $imageManager;
2726
private $filename;
2827

2928
/**
@@ -49,7 +48,7 @@ class Crop
4948
'rotate' => 0,
5049
];
5150

52-
public function __construct(ImageManager $imageManager, string $filename)
51+
public function __construct(ImageManagerInterface $imageManager, string $filename)
5352
{
5453
$this->imageManager = $imageManager;
5554
$this->filename = $filename;
@@ -59,18 +58,13 @@ public function getCroppedThumbnail(int $maxWidth, int $maxHeight, string $forma
5958
{
6059
$image = $this->createCroppedImage();
6160

62-
$image->resize($maxWidth, $maxHeight, static function ($constraint) {
63-
$constraint->aspectRatio();
64-
$constraint->upsize();
65-
});
61+
$image->scaleDown($maxWidth, $maxHeight);
6662

6763
if (!empty($this->options['rotate'])) {
68-
$image->rotate(-1 * $this->options['rotate']);
64+
$image->rotate(-1 * $this->options['rotate'], 'ffffff');
6965
}
7066

71-
$image->encode($format, $quality);
72-
73-
return $image->getEncoded();
67+
return (string) $image->encodeUsingFileExtension($format, quality: $quality);
7468
}
7569

7670
public function getCroppedImage(string $format = 'jpg', int $quality = 80): string
@@ -79,24 +73,19 @@ public function getCroppedImage(string $format = 'jpg', int $quality = 80): stri
7973

8074
// Max size
8175
if ($this->maxWidth && $this->maxHeight) {
82-
$image->resize($this->maxWidth, $this->maxHeight, static function (Constraint $constraint) {
83-
$constraint->aspectRatio();
84-
$constraint->upsize();
85-
});
76+
$image->scaleDown($this->maxWidth, $this->maxHeight);
8677
}
8778

8879
if (!empty($this->options['rotate'])) {
89-
$image->rotate(-1 * $this->options['rotate']);
80+
$image->rotate(-1 * $this->options['rotate'], 'ffffff');
9081
}
9182

92-
$image->encode($format, $quality);
93-
94-
return $image->getEncoded();
83+
return (string) $image->encodeUsingFileExtension($format, quality: $quality);
9584
}
9685

97-
private function createCroppedImage(): Image
86+
private function createCroppedImage(): ImageInterface
9887
{
99-
$image = $this->imageManager->make(file_get_contents($this->filename));
88+
$image = $this->imageManager->decodeBinary(file_get_contents($this->filename));
10089

10190
// Crop
10291
if ($this->options['width'] && $this->options['height']) {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\Cropperjs\Tests\DependencyInjection;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
16+
use Symfony\Component\Config\Definition\Processor;
17+
use Symfony\UX\Cropperjs\DependencyInjection\Configuration;
18+
19+
/**
20+
* @internal
21+
*/
22+
class ConfigurationTest extends TestCase
23+
{
24+
private function process(array $config): array
25+
{
26+
$processor = new Processor();
27+
28+
return $processor->processConfiguration(new Configuration(), [$config]);
29+
}
30+
31+
public function testDefaultConfiguration()
32+
{
33+
$config = $this->process([]);
34+
35+
$this->assertSame('gd', $config['driver']);
36+
$this->assertNull($config['driver_service']);
37+
}
38+
39+
/**
40+
* @return iterable<string, array{string}>
41+
*/
42+
public static function provideValidDrivers(): iterable
43+
{
44+
yield 'gd' => ['gd'];
45+
yield 'imagick' => ['imagick'];
46+
yield 'vips' => ['vips'];
47+
}
48+
49+
#[\PHPUnit\Framework\Attributes\DataProvider('provideValidDrivers')]
50+
public function testValidDriversAreAccepted(string $driver)
51+
{
52+
$config = $this->process(['driver' => $driver]);
53+
54+
$this->assertSame($driver, $config['driver']);
55+
}
56+
57+
public function testInvalidDriverIsRejected()
58+
{
59+
$this->expectException(InvalidConfigurationException::class);
60+
61+
$this->process(['driver' => 'bogus']);
62+
}
63+
}

0 commit comments

Comments
 (0)