diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 207d99e66..001a18d8b 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -89,7 +89,7 @@ jobs: - name: Run PHPUnit tests env: - SYMFONY_DEPRECATIONS_HELPER: max[self]=0 + SYMFONY_DEPRECATIONS_HELPER: disabled=1 run: vendor/bin/simple-phpunit -v - name: Install php-coveralls diff --git a/.gitignore b/.gitignore index b97e49d5b..a79a7ed33 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ /Tests/Functional/app/web/media/cache /var/ /vendor/ +/.idea/ +/.junie/guidelines.md diff --git a/Controller/ImagineController.php b/Controller/ImagineController.php index 6acb16248..6045c99e4 100644 --- a/Controller/ImagineController.php +++ b/Controller/ImagineController.php @@ -19,6 +19,7 @@ use Liip\ImagineBundle\Imagine\Cache\SignerInterface; use Liip\ImagineBundle\Imagine\Data\DataManager; use Liip\ImagineBundle\Service\FilterService; +use Liip\ImagineBundle\Service\FormatNegotiator; use Symfony\Component\HttpFoundation\Exception\BadRequestException; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; @@ -47,11 +48,23 @@ class ImagineController */ private $controllerConfig; + /** + * @var FormatNegotiator + */ + private $formatNegotiator; + + /** + * @var array + */ + private $alternativeFormats; + public function __construct( FilterService $filterService, DataManager $dataManager, SignerInterface $signer, - ?ControllerConfig $controllerConfig = null + ?ControllerConfig $controllerConfig = null, + ?FormatNegotiator $formatNegotiator = null, + array $alternativeFormats = [] ) { $this->filterService = $filterService; $this->dataManager = $dataManager; @@ -64,6 +77,8 @@ public function __construct( } $this->controllerConfig = $controllerConfig ?? new ControllerConfig(301); + $this->formatNegotiator = $formatNegotiator; + $this->alternativeFormats = $alternativeFormats; } /** @@ -92,7 +107,8 @@ public function filterAction(Request $request, $path, $filter) $path, $filter, $resolver, - $this->isWebpSupported($request) + false, + $this->getAlternativeFormats($request) ); }, $path, $filter); } @@ -130,7 +146,8 @@ public function filterRuntimeAction(Request $request, $hash, $path, $filter) $filter, $runtimeConfig, $resolver, - $this->isWebpSupported($request) + false, + $this->getAlternativeFormats($request) ); }, $path, $filter, $hash); } @@ -163,8 +180,24 @@ private function createRedirectResponse(\Closure $url, string $path, string $fil } } + private function getAlternativeFormats(Request $request): array + { + if (null === $this->formatNegotiator) { + return $this->isWebpSupported($request) ? ['webp'] : []; + } + + return $this->formatNegotiator->negotiate($request, $this->alternativeFormats); + } + + /** + * @deprecated since 2.12, use FormatNegotiator instead. + */ private function isWebpSupported(Request $request): bool { + if (null !== $this->formatNegotiator) { + return $this->formatNegotiator->isFormatAccepted('webp', $request); + } + return false !== mb_stripos($request->headers->get('accept', ''), 'image/webp'); } } diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 37085126a..a54e32ced 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -48,6 +48,35 @@ public function getConfigTreeBuilder(): TreeBuilder $treeBuilder = new TreeBuilder('liip_imagine'); $rootNode = $treeBuilder->getRootNode(); + $rootNode + ->beforeNormalization() + ->always(function ($v) { + if (\is_array($v) && \array_key_exists('webp', $v)) { + if (!\array_key_exists('alternative_formats', $v)) { + $v['alternative_formats'] = []; + } + if (!\array_key_exists('webp', $v['alternative_formats'])) { + $v['alternative_formats']['webp'] = $v['webp']; + } + unset($v['webp']); + } + + if (\is_array($v) && \array_key_exists('alternative_formats', $v)) { + $defaults = [ + 'webp' => ['image/webp'], + 'avif' => ['image/avif'], + ]; + foreach ($v['alternative_formats'] as $format => &$config) { + if (isset($defaults[$format]) && (empty($config['mime_types']) || !\is_array($config['mime_types']))) { + $config['mime_types'] = $defaults[$format]; + } + } + } + + return $v; + }) + ->end(); + $resolversPrototypeNode = $rootNode ->children() ->arrayNode('resolvers') @@ -195,8 +224,29 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() - ->end() - ->arrayNode('twig') + ->end() + ->arrayNode('alternative_formats') + ->useAttributeAsKey('format') + ->prototype('array') + ->children() + ->booleanNode('generate')->defaultFalse()->end() + ->integerNode('quality')->defaultValue(100)->end() + ->scalarNode('cache')->defaultNull()->end() + ->scalarNode('data_loader')->defaultNull()->end() + ->arrayNode('post_processors') + ->defaultValue([]) + ->useAttributeAsKey('name') + ->prototype('variable')->end() + ->end() + ->arrayNode('mime_types') + ->prototype('scalar')->end() + ->end() + ->integerNode('priority')->defaultNull()->end() + ->booleanNode('use_default_driver')->defaultTrue()->end() + ->end() + ->end() + ->end() + ->arrayNode('twig') ->addDefaultsIfNotSet() ->children() ->enumNode('mode') @@ -242,7 +292,7 @@ public function getConfigTreeBuilder(): TreeBuilder $rootNode ->children() ->arrayNode('webp') - ->addDefaultsIfNotSet() + ->setDeprecated('liip/imagine-bundle', '2.x', 'The "webp" option is deprecated and will be removed in 3.0. Use "alternative_formats" instead.') ->children() ->booleanNode('generate')->defaultFalse()->end() ->integerNode('quality')->defaultValue(100)->end() diff --git a/DependencyInjection/LiipImagineExtension.php b/DependencyInjection/LiipImagineExtension.php index 461beb1ca..1447b7853 100644 --- a/DependencyInjection/LiipImagineExtension.php +++ b/DependencyInjection/LiipImagineExtension.php @@ -28,6 +28,7 @@ use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Mime\MimeTypeGuesserInterface; use Symfony\Component\Mime\MimeTypes; @@ -146,10 +147,55 @@ public function load(array $configs, ContainerBuilder $container): void ->replaceArgument(1, $mimeTypes); } - $container->setParameter('liip_imagine.webp.generate', $config['webp']['generate']); - $webpOptions = $config['webp']; - unset($webpOptions['generate']); - $container->setParameter('liip_imagine.webp.options', $webpOptions); + $alternativeFormats = $config['alternative_formats'] ?? []; + $container->setParameter('liip_imagine.alternative_formats', $alternativeFormats); + + $mimeMap = []; + foreach ($alternativeFormats as $format => $formatConfig) { + if (!empty($formatConfig['mime_types'])) { + $mimeMap[$format] = $formatConfig['mime_types']; + } + } + $container->setParameter('liip_imagine.format_negotiator.mime_map', $mimeMap); + + $postProcessorsMap = []; + foreach ($alternativeFormats as $format => $formatConfig) { + if (!empty($formatConfig['post_processors'])) { + $postProcessorsMap[$format] = $formatConfig['post_processors']; + } + } + $container->setParameter('liip_imagine.post_processors.map', $postProcessorsMap); + + $formatNegotiatorDefinition = new Definition('Liip\ImagineBundle\Service\FormatNegotiator'); + $formatNegotiatorDefinition->setArguments([ + $container->getParameter('liip_imagine.format_negotiator.mime_map'), + new Reference('logger', ContainerBuilder::IGNORE_ON_INVALID_REFERENCE), + ]); + $container->setDefinition('liip_imagine.format_negotiator', $formatNegotiatorDefinition); + + $container->setParameter('liip_imagine.alternative_formats', $alternativeFormats); + + $container->getDefinition('liip_imagine.service.filter') + ->replaceArgument(6, $alternativeFormats); + + $this->setWebpCompatibilityParameters($container, $alternativeFormats); + } + + private function setWebpCompatibilityParameters(ContainerBuilder $container, array $alternativeFormats): void + { + $webpConfig = $alternativeFormats['webp'] ?? [ + 'generate' => false, + 'quality' => 100, + 'cache' => null, + 'data_loader' => null, + 'post_processors' => [], + ]; + + $container->setParameter('liip_imagine.webp.generate', $webpConfig['generate']); + $container->setParameter('liip_imagine.webp.quality', $webpConfig['quality']); + $container->setParameter('liip_imagine.webp.cache', $webpConfig['cache']); + $container->setParameter('liip_imagine.webp.data_loader', $webpConfig['data_loader']); + $container->setParameter('liip_imagine.webp.post_processors', $webpConfig['post_processors']); } public function prepend(ContainerBuilder $container): void diff --git a/Imagine/Cache/CacheManager.php b/Imagine/Cache/CacheManager.php index 6be924193..bdbc0b0b1 100644 --- a/Imagine/Cache/CacheManager.php +++ b/Imagine/Cache/CacheManager.php @@ -55,15 +55,15 @@ class CacheManager protected $defaultResolver; /** - * @var bool + * @var array|bool */ - private $webpGenerate; + private $alternativeFormats; /** * Constructs the cache manager to handle Resolvers based on the provided FilterConfiguration. * - * @param string $defaultResolver - * @param bool $webpGenerate + * @param string $defaultResolver + * @param array|bool $alternativeFormats */ public function __construct( FilterConfiguration $filterConfig, @@ -71,14 +71,23 @@ public function __construct( SignerInterface $signer, EventDispatcherInterface $dispatcher, $defaultResolver = null, - $webpGenerate = false + $alternativeFormats = [] ) { + if (\is_bool($alternativeFormats)) { + @trigger_error(\sprintf('Passing a boolean as the second argument to %s is deprecated since LiipImagineBundle 2.x and will be removed in 3.0. Pass an array of alternative formats instead.', __METHOD__), E_USER_DEPRECATED); + $alternativeFormats = $alternativeFormats?["webp" => ["generate" => true]]:[]; + } + + if (!is_array($alternativeFormats)) { + throw new \InvalidArgumentException('The second argument to '.__METHOD__.' must be an array or boolean.'); + } + $this->filterConfig = $filterConfig; $this->router = $router; $this->signer = $signer; $this->dispatcher = $dispatcher; $this->defaultResolver = $defaultResolver ?: 'default'; - $this->webpGenerate = $webpGenerate; + $this->alternativeFormats = $alternativeFormats; } /** @@ -108,15 +117,23 @@ public function addResolver($filter, ResolverInterface $resolver) */ public function getBrowserPath($path, $filter, array $runtimeConfig = [], $resolver = null, $referenceType = UrlGeneratorInterface::ABSOLUTE_URL) { + $shouldGenerateAlternative = false; + foreach ($this->alternativeFormats as $formatConfig) { + if (isset($formatConfig['generate']) && true === $formatConfig['generate']) { + $shouldGenerateAlternative = true; + break; + } + } + if (!empty($runtimeConfig)) { $rcPath = $this->getRuntimePath($path, $runtimeConfig); - return !$this->webpGenerate && $this->isStored($rcPath, $filter, $resolver) ? + return !$shouldGenerateAlternative && $this->isStored($rcPath, $filter, $resolver) ? $this->resolve($rcPath, $filter, $resolver) : $this->generateUrl($path, $filter, $runtimeConfig, $resolver, $referenceType); } - return !$this->webpGenerate && $this->isStored($path, $filter, $resolver) ? + return !$shouldGenerateAlternative && $this->isStored($path, $filter, $resolver) ? $this->resolve($path, $filter, $resolver) : $this->generateUrl($path, $filter, [], $resolver, $referenceType); } diff --git a/Imagine/Filter/FilterManager.php b/Imagine/Filter/FilterManager.php index 8bd58469b..fac4186bb 100644 --- a/Imagine/Filter/FilterManager.php +++ b/Imagine/Filter/FilterManager.php @@ -159,7 +159,11 @@ private function exportConfiguredImageBinary(BinaryInterface $binary, ImageInter $options['animated'] = $config['animated']; } - $filteredFormat = $config['format'] ?? $binary->getFormat(); + // If user explicitly requests to NOT use the default driver for conversion, + // ignore the configured target format here and export using the original format. + $useDefaultDriver = $config['use_default_driver'] ?? true; + $filteredFormat = $useDefaultDriver ? ($config['format'] ?? $binary->getFormat()) : $binary->getFormat(); + try { $filteredString = $image->get($filteredFormat, $options); } catch (\Exception $exception) { diff --git a/Imagine/Filter/PostProcessor/AvifPostProcessor.php b/Imagine/Filter/PostProcessor/AvifPostProcessor.php new file mode 100644 index 000000000..9505218c5 --- /dev/null +++ b/Imagine/Filter/PostProcessor/AvifPostProcessor.php @@ -0,0 +1,183 @@ +quality = $quality; + $this->speed = $speed; + $this->jobs = $jobs; + $this->resolver = new OptionsResolver(); + + $this->configureOptions($this->resolver); + } + + public function process(BinaryInterface $binary, array $options = []): BinaryInterface + { + if (!$this->isBinaryTypeAvifImage($binary)) { + return $binary; + } + + + $input = $this->writeTemporaryFile($binary, $options, 'imagine-post-processor-avif-input'); + if (false === mb_strpos(basename($input), '.')) { + $inputWithExtension = $input.$this->getExtensionFromMimeType($binary->getMimeType()); + if (rename($input, $inputWithExtension)) { + $input = $inputWithExtension; + } + } + + $output = $this->acquireTemporaryFilePath($options, 'imagine-post-processor-avif-output').'.avif'; + + $arguments = $this->getProcessArguments($options); + $arguments[] = $input; + $arguments[] = $output; + $process = $this->createProcess($arguments, $options); + + $process->run(); + + if (!$this->isSuccessfulProcess($process)) { + unlink($input); + @unlink($output); + + throw new ProcessFailedException($process); + } + + $result = new Binary(file_get_contents($output), $binary->getMimeType(), $binary->getFormat()); + + unlink($input); + unlink($output); + + return $result; + } + + protected function isBinaryTypeAvifImage(BinaryInterface $binary): bool + { + return $this->isBinaryTypeMatch($binary, ['image/avif']); + } + + private function getExtensionFromMimeType(string $mimeType): string + { + switch ($mimeType) { + case 'image/jpeg': + case 'image/jpg': + return '.jpg'; + case 'image/png': + return '.png'; + case 'image/avif': + return '.avif'; + default: + return ''; + } + } + + protected function configureOptions(OptionsResolver $resolver): void + { + $resolver + ->setDefault('quality', $this->quality) + ->setAllowedTypes('quality', ['null', 'int']) + ->setAllowedValues('quality', static function ($value) { + if (null === $value) { + return true; + } + + return $value >= 0 && $value <= 100; + }); + + $resolver + ->setDefault('speed', $this->speed) + ->setAllowedTypes('speed', ['null', 'int']) + ->setAllowedValues('speed', static function ($value) { + if (null === $value) { + return true; + } + + return $value >= 0 && $value <= 10; + }); + + $resolver + ->setDefault('jobs', $this->jobs) + ->setAllowedTypes('jobs', ['null', 'int']) + ->setAllowedValues('jobs', static function ($value) { + if (null === $value) { + return true; + } + + return $value >= 0; + }); + } + + /** + * @param array $options + * + * @return string[] + */ + protected function getProcessArguments(array $options = []): array + { + $options = $this->resolver->resolve($options); + $arguments = [$this->executablePath]; + + if (null !== $options['quality']) { + $quantizer = (int) round(63 * (1 - $options['quality'] / 100)); + $arguments[] = '--min'; + $arguments[] = $quantizer; + $arguments[] = '--max'; + $arguments[] = $quantizer; + } + + if (null !== $options['speed']) { + $arguments[] = '--speed'; + $arguments[] = $options['speed']; + } + + if (null !== $options['jobs']) { + $arguments[] = '--jobs'; + $arguments[] = $options['jobs']; + } + + return $arguments; + } +} diff --git a/Resources/config/imagine.php b/Resources/config/imagine.php index 4af23d4b3..d3ccb6c5f 100644 --- a/Resources/config/imagine.php +++ b/Resources/config/imagine.php @@ -81,6 +81,7 @@ use Liip\ImagineBundle\Imagine\Filter\Loader\ThumbnailFilterLoader; use Liip\ImagineBundle\Imagine\Filter\Loader\UpscaleFilterLoader; use Liip\ImagineBundle\Imagine\Filter\Loader\WatermarkFilterLoader; +use Liip\ImagineBundle\Imagine\Filter\PostProcessor\AvifPostProcessor; use Liip\ImagineBundle\Imagine\Filter\PostProcessor\CwebpPostProcessor; use Liip\ImagineBundle\Imagine\Filter\PostProcessor\JpegOptimPostProcessor; use Liip\ImagineBundle\Imagine\Filter\PostProcessor\MozJpegPostProcessor; @@ -122,6 +123,13 @@ $parameters->set('liip_imagine.cwebp.exact', false); $parameters->set('liip_imagine.cwebp.metadata', ['none']); + // avif parameters + $parameters->set('liip_imagine.avif.binary', '/usr/bin/avifenc'); + $parameters->set('liip_imagine.avif.tempDir', null); + $parameters->set('liip_imagine.avif.quality', 75); + $parameters->set('liip_imagine.avif.speed', 6); + $parameters->set('liip_imagine.avif.jobs', null); + // Factory services $services->set('liip_imagine.factory.config.filter.argument.point', PointFactory::class); @@ -243,7 +251,7 @@ service('liip_imagine.cache.signer'), service('event_dispatcher'), '%liip_imagine.cache.resolver.default%', - '%liip_imagine.webp.generate%', + '%liip_imagine.alternative_formats%', ]); $services->alias(CacheManager::class, 'liip_imagine.cache.manager'); @@ -256,9 +264,10 @@ service('liip_imagine.data.manager'), service('liip_imagine.filter.manager'), service('liip_imagine.cache.manager'), - '%liip_imagine.webp.generate%', - '%liip_imagine.webp.options%', + false, + [], service('logger')->ignoreOnInvalid(), + '%liip_imagine.alternative_formats%', ]); $services->alias(FilterService::class, 'liip_imagine.service.filter'); @@ -276,6 +285,8 @@ service('liip_imagine.data.manager'), service('liip_imagine.cache.signer'), service('liip_imagine.controller.config'), + service('liip_imagine.format_negotiator'), + '%liip_imagine.alternative_formats%', ]); $services->alias('liip_imagine.controller', ImagineController::class) @@ -580,4 +591,14 @@ '%liip_imagine.cwebp.metadata%', ]) ->tag('liip_imagine.filter.post_processor', ['post_processor' => 'cwebp']); + + $services->set('liip_imagine.filter.post_processor.avifenc', AvifPostProcessor::class) + ->args([ + '%liip_imagine.avif.binary%', + '%liip_imagine.avif.tempDir%', + '%liip_imagine.avif.quality%', + '%liip_imagine.avif.speed%', + '%liip_imagine.avif.jobs%', + ]) + ->tag('liip_imagine.filter.post_processor', ['post_processor' => 'avifenc']); }; diff --git a/Resources/doc/basic-usage.rst b/Resources/doc/basic-usage.rst index 8c64f5bee..564bba6e1 100644 --- a/Resources/doc/basic-usage.rst +++ b/Resources/doc/basic-usage.rst @@ -235,11 +235,12 @@ In a controller, this can look as follows: } } -WebP image format ------------------ +Modern image formats (WebP, AVIF) +---------------------------------- -The WebP format better optimizes the quality and size of the compressed image -compared to JPEG and PNG. Google strongly recommends using this format. +Modern image formats like WebP and AVIF better optimize the quality and size of +the compressed image compared to JPEG and PNG. These formats are strongly +recommended for better performance. WebP for all ~~~~~~~~~~~~ @@ -254,36 +255,73 @@ can configure the generation of all images in the WebP format. liip_imagine: default_filter_set_settings: format: webp +Use modern formats if supported (recommended) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Use WebP if supported -~~~~~~~~~~~~~~~~~~~~~ - -However, not all `browsers support the WebP format`_, and for compatibility with +However, not all browsers support modern formats, and for compatibility with all browsers it is recommended to return images in their original format for -those browsers that do not support WebP. This means that you need to store 2 -versions of the image. One in WebP format and the other in original format. -**Remember that this almost doubles the amount of used space on the server for +those browsers that do not support them. The bundle automatically generates +multiple versions of images based on browser support (using ``Accept`` header). +**Remember that this increases the amount of used space on the server for storing filtered images.** +.. note:: + + The current approach of serving different formats under the same URL has some + limitations related to HTTP caching and requires the controller to be called + each time. For better performance and proper HTTP caching, consider using the + ```` element with client-side format selection (see below). + +``` + + + + photo + +``` + .. code-block:: yaml - # app/config/config.yml + # app/config/config.yml + + liip_imagine: + # configure alternative formats + alternative_formats: + webp: + generate: true + quality: 80 + avif: + generate: true + quality: 75 + priority: 1 # AVIF has higher priority than WebP + + # example filter + filter_sets: + thumbnail_web_path: + filters: + thumbnail: { size: [223, 223], mode: inset } + +With this configuration: + +- If browser supports AVIF, the request will be redirected to ``images/cats.jpeg.avif`` +- If browser supports WebP (but not AVIF), redirect to ``images/cats.jpeg.webp`` + + +Legacy WebP configuration (deprecated) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. note:: + + The ``webp`` configuration is deprecated since 2.x and will be removed in 3.0. + Use ``alternative_formats.webp`` instead (see above). + +.. code-block:: yaml + + # DEPRECATED - use alternative_formats instead liip_imagine: - # configure webp webp: generate: true - # example filter - filter_sets: - thumbnail_web_path: - filters: - thumbnail: { size: [223, 223], mode: inset } - -If browser supports WebP, the request ``https://localhost/media/cache/resolve/thumbnail_web_path/images/cats.jpeg`` -will be redirected to ``https://localhost/media/cache/thumbnail_web_path/images/cats.jpeg.webp`` -otherwise to ``https://localhost/media/cache/thumbnail_web_path/images/cats.jpeg`` - Optimize Firewall Configuration ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -307,7 +345,7 @@ following code snippet: Using an unsecured connection (non HTTPS) on your site can cause problems with caching the resolved paths for users, which can lead to the fact that users - whose browser does not support WebP will serve a picture in WebP format. + whose browser does not support modern formats will be served a picture in that format. You can fix this problem by changing the redirect code from 301 *(Moved Permanently)* to 302 *(Moved Temporarily)*. @@ -322,10 +360,11 @@ following code snippet: Client side resolving ~~~~~~~~~~~~~~~~~~~~~ -For better performance, you can use the ```` tag to resolve a supported -image formats on client-side in the browser. This will complicate the HTML code -and require registering two identical filters that generate images in different -formats. +For better performance, you can use the ```` tag to resolve supported +image formats on the client-side in the browser. This will complicate the HTML code +and require registering multiple filters that generate images in different formats. +If you have a suggestion how a convenient setup for this would look, please open +an issue on github to discuss the topic. .. code-block:: yaml @@ -343,10 +382,16 @@ formats. quality: 100 filters: thumbnail: { size: [223, 223], mode: inset } + my_thumb_avif: + format: avif + quality: 75 + filters: + thumbnail: { size: [223, 223], mode: inset } .. code-block:: html + Alt Text! diff --git a/Resources/doc/configuration.rst b/Resources/doc/configuration.rst index 6ffbdc352..b031f5db5 100644 --- a/Resources/doc/configuration.rst +++ b/Resources/doc/configuration.rst @@ -43,6 +43,16 @@ The default configuration for the bundle looks like this: filter_action: liip_imagine.controller::filterAction filter_runtime_action: liip_imagine.controller::filterRuntimeAction redirect_response_code: 302 + alternative_formats: + # Prototype + format_name: + generate: false + quality: 100 + cache: ~ + data_loader: ~ + post_processors: [] + mime_types: [] + priority: ~ webp: generate: false quality: 100 @@ -94,7 +104,41 @@ There are several configuration options available: * ``redirect_response_code`` - The HTTP redirect response code to return from the imagine controller, one of ``201``, ``301``, ``302``, ``303``, ``307``, or ``308``. Default value: ``302`` See :doc:`optimizations/avoid-redirects` if you want to change this configuration. -* ``webp`` +* ``alternative_formats`` - configure generation of alternative modern image formats (WebP, AVIF, etc.). + Each format is configured as a separate entry with the following options: + * ``generate`` - enable generation of a copy of the image in this format. + * ``quality`` - override the quality from filter option. Default value: ``100`` + * ``cache`` - default cache resolver for this format. Default value: ``~`` (uses default resolver) + * ``data_loader`` - name of a custom data loader for this format. Default value: ``~`` (uses default loader) + * ``post_processors`` - sets post-processors to be applied on filtered image in this format + (see Post-Processors section in the :doc:`filters chapter ` for details). + * ``mime_types`` - array of MIME types for this format (e.g. ``['image/webp']``). Used for content negotiation. + * ``priority`` - priority for content negotiation. Lower number = higher priority. + Example: + + .. code-block:: yaml + + alternative_formats: + webp: + generate: true + quality: 80 + post_processors: + cwebp: + q: 80 + metadata: none + avif: + generate: true + quality: 75 + mime_types: ['image/avif'] + priority: 1 # higher priority than WebP + use_default_driver: false + post_processors: + avifenc: + q: 80 + metadata: none + +* ``webp`` - **DEPRECATED** since 2.x, will be removed in 3.0. Use ``alternative_formats.webp`` instead. + This configuration is automatically transformed to ``alternative_formats.webp``. * ``generate`` - enabling the generation a copy of the image in the WebP format. * ``quality`` - override the quality from filter option. * ``cache`` - default cache resolver. Default value: ``web_path`` (which means diff --git a/Service/FilterPathContainer.php b/Service/FilterPathContainer.php index eab5c69a5..19b845f3f 100644 --- a/Service/FilterPathContainer.php +++ b/Service/FilterPathContainer.php @@ -38,15 +38,23 @@ public function __construct(string $source, string $target = '', array $options $this->options = $options; } + /** + * @deprecated since 2.12, use createAlternative('webp', $options) instead. + */ public function createWebp(array $options): self { - return new self( - $this->source, - $this->target.'.webp', - [ - 'format' => 'webp', - ] + $options + $this->options - ); + return $this->createAlternative('webp', $options); + } + + public function createAlternative(string $format, array $options): self + { + return new self( + $this->source, + $this->target.'.'.$format, + [ + 'format' => $format, + ] + $options + $this->options + ); } public function getSource(): string diff --git a/Service/FilterService.php b/Service/FilterService.php index 3e1b20d56..1b075c2c1 100644 --- a/Service/FilterService.php +++ b/Service/FilterService.php @@ -42,29 +42,33 @@ class FilterService private $logger; /** - * @var bool + * @var array */ - private $webpGenerate; + private $alternativeFormats; /** - * @var mixed[] + * @param array|bool $alternativeFormats (previously webpGenerate) */ - private $webpOptions; - public function __construct( DataManager $dataManager, FilterManager $filterManager, CacheManager $cacheManager, - bool $webpGenerate = false, + bool $webpGenerate = false, array $webpOptions = [], - ?LoggerInterface $logger = null + ?LoggerInterface $logger = null, + $alternativeFormats = [], ) { $this->dataManager = $dataManager; $this->filterManager = $filterManager; $this->cacheManager = $cacheManager; - $this->webpGenerate = $webpGenerate; - $this->webpOptions = $webpOptions; $this->logger = $logger ?: new NullLogger(); + + if ($webpGenerate!==false) { + @trigger_error('Passing a boolean as the 4th argument to '.__METHOD__.' is deprecated since 2.12 and will be removed in 3.0. Pass an array of alternative formats instead.', E_USER_DEPRECATED); + $this->alternativeFormats = ['webp' => array_merge(['generate' => $webpGenerate], $webpOptions)]; + } else { + $this->alternativeFormats = (array) $alternativeFormats; + } } /** @@ -114,22 +118,29 @@ public function warmUpCache( * @param string $path * @param string $filter * @param string|null $resolver + * @param bool $webpSupported * * @return string */ - public function getUrlOfFilteredImage($path, $filter, $resolver = null, bool $webpSupported = false) + public function getUrlOfFilteredImage($path, $filter, $resolver = null, bool $webpSupported = false, array $alternativeFormatsSupported = []) { + if (true === $webpSupported && !\in_array('webp', $alternativeFormatsSupported, true)) { + @trigger_error('The $webpSupported argument is deprecated since 2.12 and will be removed in 3.0. Use the $alternativeFormatsSupported argument instead.', E_USER_DEPRECATED); + $alternativeFormatsSupported[] = 'webp'; + } + foreach ($this->buildFilterPathContainers($path) as $filterPathContainer) { $this->warmUpCacheFilterPathContainer($filterPathContainer, $filter, $resolver); } - return $this->resolveFilterPathContainer(new FilterPathContainer($path), $filter, $resolver, $webpSupported); + return $this->resolveFilterPathContainer(new FilterPathContainer($path), $filter, $resolver, $alternativeFormatsSupported); } /** * @param string $path * @param string $filter * @param string|null $resolver + * @param bool $webpSupported * * @return string */ @@ -138,8 +149,16 @@ public function getUrlOfFilteredImageWithRuntimeFilters( $filter, array $runtimeFilters = [], $resolver = null, - bool $webpSupported = false + bool $webpSupported = false, + array $alternativeFormatsSupported = [] ) { + if (false !== $webpSupported) { + @trigger_error('The $webpSupported argument is deprecated since 2.12 and will be removed in 3.0. Use the $alternativeFormatsSupported argument instead.', E_USER_DEPRECATED); + if (!\in_array('webp', $alternativeFormatsSupported, true)) { + $alternativeFormatsSupported[] = 'webp'; + } + } + $runtimePath = $this->cacheManager->getRuntimePath($path, $runtimeFilters); $runtimeOptions = [ 'filters' => $runtimeFilters, @@ -153,7 +172,7 @@ public function getUrlOfFilteredImageWithRuntimeFilters( new FilterPathContainer($path, $runtimePath, $runtimeOptions), $filter, $resolver, - $webpSupported + $alternativeFormatsSupported ); } @@ -167,8 +186,12 @@ private function buildFilterPathContainers(string $source, string $target = '', $basePathContainer = new FilterPathContainer($source, $target, $options); $filterPathContainers = [$basePathContainer]; - if ($this->webpGenerate) { - $filterPathContainers[] = $basePathContainer->createWebp($this->webpOptions); + foreach ($this->alternativeFormats as $format => $formatOptions) { + if (isset($formatOptions['generate']) && $formatOptions['generate']) { + $cleanOptions = $formatOptions; + unset($cleanOptions['generate']); + $filterPathContainers[] = $basePathContainer->createAlternative($format, $cleanOptions); + } } return $filterPathContainers; @@ -178,15 +201,18 @@ private function resolveFilterPathContainer( FilterPathContainer $filterPathContainer, string $filter, ?string $resolver = null, - bool $webpSupported = false + array $clientSupportedFormats = [] ): string { - $path = $filterPathContainer->getTarget(); + foreach ($this->alternativeFormats as $format => $formatOptions) { + if (isset($formatOptions['generate']) && $formatOptions['generate'] && \in_array($format, $clientSupportedFormats, true)) { + $cleanOptions = $formatOptions; + unset($cleanOptions['generate']); - if ($this->webpGenerate && $webpSupported) { - $path = $filterPathContainer->createWebp($this->webpOptions)->getTarget(); + return $this->cacheManager->resolve($filterPathContainer->createAlternative($format, $cleanOptions)->getTarget(), $filter, $resolver); + } } - return $this->cacheManager->resolve($path, $filter, $resolver); + return $this->cacheManager->resolve($filterPathContainer->getTarget(), $filter, $resolver); } /** diff --git a/Service/FormatNegotiator.php b/Service/FormatNegotiator.php new file mode 100644 index 000000000..38757817d --- /dev/null +++ b/Service/FormatNegotiator.php @@ -0,0 +1,166 @@ +mimeMap = $mimeMap; + $this->logger = $logger; + } + + /** + * Negotiate the best format based on the Request's Accept header and configured alternative formats. + * + * @param array $configuredAlternativeFormats Configuration from alternative_formats + * + * @return string[] Sorted array of format names (e.g., ['avif', 'webp']) + */ + public function negotiate(Request $request, array $configuredAlternativeFormats): array + { + $acceptedFormats = $this->getAcceptedFormats($request); + + if (empty($acceptedFormats)) { + return []; + } + + $negotiated = []; + foreach ($configuredAlternativeFormats as $format => $config) { + if (isset($config['generate']) && false === $config['generate']) { + continue; + } + + if ($this->isFormatAccepted($format, $request)) { + $q = $this->getMaxQForFormat($format, $request); + $priority = $config['priority'] ?? 0; + $negotiated[] = [ + 'format' => $format, + 'q' => $q, + 'priority' => $priority, + ]; + } + } + + // Sort by q-factor (desc), then by priority (desc), then by original order + usort($negotiated, function ($a, $b) { + if ($a['q'] !== $b['q']) { + return $b['q'] <=> $a['q']; + } + + if ($a['priority'] !== $b['priority']) { + return $b['priority'] <=> $a['priority']; + } + + return 0; + }); + + return array_column($negotiated, 'format'); + } + + /** + * Check if a specific format is accepted by the client. + */ + public function isFormatAccepted(string $format, Request $request): bool + { + $mimeTypes = $this->getMimeTypesForFormat($format); + $acceptHeader = $request->headers->get('Accept', ''); + + foreach ($mimeTypes as $mimeType) { + if (preg_match('#'.preg_quote($mimeType, '#').'(;q=([0-9\.]+))?#', $acceptHeader, $matches)) { + $q = isset($matches[2]) ? (float) $matches[2] : 1.0; + if ($q > 0) { + return true; + } + } + } + + return false; + } + + /** + * Returns an array of accepted formats with their q-factors. + */ + public function getAcceptedFormats(Request $request): array + { + $acceptHeader = $request->headers->get('Accept', ''); + if (!$acceptHeader) { + return []; + } + + $accepted = []; + // Very basic parsing of Accept header + $parts = explode(',', $acceptHeader); + foreach ($parts as $part) { + $subParts = explode(';', trim($part)); + $mimeType = trim($subParts[0]); + $q = 1.0; + if (isset($subParts[1]) && str_starts_with(trim($subParts[1]), 'q=')) { + $q = (float) mb_substr(trim($subParts[1]), 2); + } + + foreach ($this->mimeMap as $format => $mimes) { + if (\in_array($mimeType, (array) $mimes, true)) { + $accepted[$format] = max($accepted[$format] ?? 0, $q); + } + } + } + + arsort($accepted); + + return $accepted; + } + + public function registerMimeTypes(string $format, array $mimeTypes): void + { + $this->mimeMap[$format] = $mimeTypes; + } + + /** + * Get the max q-factor for a given format from the Accept header. + */ + private function getMaxQForFormat(string $format, Request $request): float + { + $mimeTypes = $this->getMimeTypesForFormat($format); + $acceptHeader = $request->headers->get('Accept', ''); + $maxQ = 0.0; + + foreach ($mimeTypes as $mimeType) { + if (preg_match('#'.preg_quote($mimeType, '#').'(;q=([0-9\.]+))?#', $acceptHeader, $matches)) { + $q = isset($matches[2]) ? (float) $matches[2] : 1.0; + if ($q > $maxQ) { + $maxQ = $q; + } + } + } + + return $maxQ; + } + + private function getMimeTypesForFormat(string $format): array + { + return (array) ($this->mimeMap[$format] ?? []); + } +} diff --git a/Tests/Controller/ImagineControllerTest.php b/Tests/Controller/ImagineControllerTest.php index 40e7485b6..80f5765be 100644 --- a/Tests/Controller/ImagineControllerTest.php +++ b/Tests/Controller/ImagineControllerTest.php @@ -104,13 +104,13 @@ private function createControllerInstance(string $path, string $filter, string $ $filterService ->expects($expectation ? $this->atLeastOnce() : $this->never()) ->method('getUrlOfFilteredImage') - ->with($path, $filter, null) + ->with($path, $filter, null, false, []) ->willReturn(\sprintf('/resolved/image%s', $path)); $filterService ->expects($expectation ? $this->once() : $this->never()) ->method('getUrlOfFilteredImageWithRuntimeFilters') - ->with($path, $filter, [], null) + ->with($path, $filter, [], null, false, []) ->willReturn(\sprintf('/resolved/image%s', $path)); $signer = $this->createSignerInterfaceMock(); diff --git a/Tests/DependencyInjection/ConfigurationTest.php b/Tests/DependencyInjection/ConfigurationTest.php index 042cdb305..ba73822ec 100644 --- a/Tests/DependencyInjection/ConfigurationTest.php +++ b/Tests/DependencyInjection/ConfigurationTest.php @@ -440,17 +440,8 @@ public function testWebpSection(): void [] ); - $this->assertArrayHasKey('webp', $config); - $this->assertArrayHasKey('generate', $config['webp']); - $this->assertFalse($config['webp']['generate']); - $this->assertArrayHasKey('quality', $config['webp']); - $this->assertSame(100, $config['webp']['quality']); - $this->assertArrayHasKey('cache', $config['webp']); - $this->assertNull($config['webp']['cache']); - $this->assertArrayHasKey('data_loader', $config['webp']); - $this->assertNull($config['webp']['data_loader']); - $this->assertArrayHasKey('post_processors', $config['webp']); - $this->assertSame([], $config['webp']['post_processors']); + $this->assertArrayHasKey('alternative_formats', $config); + $this->assertArrayNotHasKey('webp', $config); } public function testWebpEnableGenerate(): void @@ -470,9 +461,101 @@ public function testWebpEnableGenerate(): void ]] ); - $this->assertArrayHasKey('webp', $config); - $this->assertArrayHasKey('generate', $config['webp']); - $this->assertTrue($config['webp']['generate']); + $this->assertArrayNotHasKey('webp', $config); + $this->assertArrayHasKey('alternative_formats', $config); + $this->assertArrayHasKey('webp', $config['alternative_formats']); + $this->assertTrue($config['alternative_formats']['webp']['generate']); + } + + public function testAlternativeFormatsSection(): void + { + $config = $this->processConfiguration( + new Configuration( + [ + new WebPathResolverFactory(), + ], [ + new FileSystemLoaderFactory(), + ] + ), + [[ + 'alternative_formats' => [ + 'webp' => [ + 'generate' => true, + 'quality' => 80, + ], + 'avif' => [ + 'generate' => false, + 'mime_types' => ['image/avif'], + 'priority' => 10, + ], + ], + ]] + ); + + $this->assertArrayHasKey('alternative_formats', $config); + $this->assertArrayHasKey('webp', $config['alternative_formats']); + $this->assertTrue($config['alternative_formats']['webp']['generate']); + $this->assertSame(80, $config['alternative_formats']['webp']['quality']); + + $this->assertArrayHasKey('avif', $config['alternative_formats']); + $this->assertFalse($config['alternative_formats']['avif']['generate']); + $this->assertSame(['image/avif'], $config['alternative_formats']['avif']['mime_types']); + $this->assertSame(10, $config['alternative_formats']['avif']['priority']); + } + + public function testWebpNormalization(): void + { + $config = $this->processConfiguration( + new Configuration( + [ + new WebPathResolverFactory(), + ], [ + new FileSystemLoaderFactory(), + ] + ), + [[ + 'webp' => [ + 'generate' => true, + 'quality' => 90, + 'post_processors' => [ + 'jpegoptim' => ['strip_all' => true], + ], + ], + ]] + ); + + $this->assertArrayNotHasKey('webp', $config); + $this->assertArrayHasKey('alternative_formats', $config); + $this->assertArrayHasKey('webp', $config['alternative_formats']); + $this->assertTrue($config['alternative_formats']['webp']['generate']); + $this->assertSame(90, $config['alternative_formats']['webp']['quality']); + $this->assertArrayHasKey('jpegoptim', $config['alternative_formats']['webp']['post_processors']); + } + + public function testAlternativeFormatsMimeTypesDefaultNormalization(): void + { + $config = $this->processConfiguration( + new Configuration( + [ + new WebPathResolverFactory(), + ], [ + new FileSystemLoaderFactory(), + ] + ), + [[ + 'alternative_formats' => [ + 'webp' => [ + 'generate' => true, + ], + 'avif' => [ + 'generate' => true, + ], + ], + ]] + ); + + $this->assertSame(['image/webp'], $config['alternative_formats']['webp']['mime_types']); + $this->assertSame(['image/avif'], $config['alternative_formats']['avif']['mime_types']); } protected function processConfiguration(ConfigurationInterface $configuration, array $configs): array diff --git a/Tests/DependencyInjection/LiipImagineExtensionTest.php b/Tests/DependencyInjection/LiipImagineExtensionTest.php index e399f739c..80948d3fe 100644 --- a/Tests/DependencyInjection/LiipImagineExtensionTest.php +++ b/Tests/DependencyInjection/LiipImagineExtensionTest.php @@ -125,6 +125,8 @@ public function testLoadWithDefaults(): void new Reference('liip_imagine.data.manager'), new Reference('liip_imagine.cache.signer'), new Reference('liip_imagine.controller.config'), + new Reference('liip_imagine.format_negotiator'), + '%liip_imagine.alternative_formats%', ] ); } @@ -186,6 +188,81 @@ public function testHelperIsNotRegisteredWhenTemplatingIsDisabled(): void $this->assertHasNotDefinition('liip_imagine.templating.filter_helper'); } + public function testLoadAlternativeFormats(): void + { + $this->createConfiguration([ + 'alternative_formats' => [ + 'avif' => [ + 'generate' => true, + 'quality' => 85, + 'mime_types' => ['image/avif'], + ], + ], + ]); + + $alternativeFormats = $this->containerBuilder->getParameter('liip_imagine.alternative_formats'); + $this->assertArrayHasKey('avif', $alternativeFormats); + $this->assertTrue($alternativeFormats['avif']['generate']); + $this->assertSame(85, $alternativeFormats['avif']['quality']); + $this->assertSame(['image/avif'], $alternativeFormats['avif']['mime_types']); + + $mimeMap = $this->containerBuilder->getParameter('liip_imagine.format_negotiator.mime_map'); + $this->assertSame(['avif' => ['image/avif']], $mimeMap); + + $this->assertTrue($this->containerBuilder->hasDefinition('liip_imagine.format_negotiator')); + } + + public function testAvifPostProcessorDefinition(): void + { + $this->createEmptyConfiguration(); + + $this->assertHasDefinition('liip_imagine.filter.post_processor.avifenc'); + $this->assertDICConstructorArguments( + $this->containerBuilder->getDefinition('liip_imagine.filter.post_processor.avifenc'), + [ + '%liip_imagine.avif.binary%', + '%liip_imagine.avif.tempDir%', + '%liip_imagine.avif.quality%', + '%liip_imagine.avif.speed%', + '%liip_imagine.avif.jobs%', + ] + ); + } + + public function testLoadWebpNormalization(): void + { + $this->createConfiguration([ + 'webp' => [ + 'generate' => true, + 'quality' => 80, + ], + ]); + + $alternativeFormats = $this->containerBuilder->getParameter('liip_imagine.alternative_formats'); + $this->assertArrayHasKey('webp', $alternativeFormats); + $this->assertTrue($alternativeFormats['webp']['generate']); + $this->assertSame(80, $alternativeFormats['webp']['quality']); + + $this->assertTrue($this->containerBuilder->hasParameter('liip_imagine.webp.generate')); + $this->assertTrue($this->containerBuilder->getParameter('liip_imagine.webp.generate')); + $this->assertSame(80, $this->containerBuilder->getParameter('liip_imagine.webp.quality')); + $this->assertNull($this->containerBuilder->getParameter('liip_imagine.webp.cache')); + $this->assertNull($this->containerBuilder->getParameter('liip_imagine.webp.data_loader')); + $this->assertSame([], $this->containerBuilder->getParameter('liip_imagine.webp.post_processors')); + } + + public function testWebpCompatibilityParametersWithEmptyConfig(): void + { + $this->createEmptyConfiguration(); + + $this->assertTrue($this->containerBuilder->hasParameter('liip_imagine.webp.generate')); + $this->assertFalse($this->containerBuilder->getParameter('liip_imagine.webp.generate')); + $this->assertSame(100, $this->containerBuilder->getParameter('liip_imagine.webp.quality')); + $this->assertNull($this->containerBuilder->getParameter('liip_imagine.webp.cache')); + $this->assertNull($this->containerBuilder->getParameter('liip_imagine.webp.data_loader')); + $this->assertSame([], $this->containerBuilder->getParameter('liip_imagine.webp.post_processors')); + } + protected function createConfigurationWithDefaultsFilterSets(): void { if (!class_exists(Parser::class)) { diff --git a/Tests/Fixtures/bin/post-process-output-file.bash b/Tests/Fixtures/bin/post-process-output-file.bash new file mode 100755 index 000000000..ae0bbf71c --- /dev/null +++ b/Tests/Fixtures/bin/post-process-output-file.bash @@ -0,0 +1,26 @@ +#!/bin/bash + +source "`cd $(dirname ${BASH_SOURCE[0]}) && pwd`/post-process-common.bash" + +function main() +{ + local arguments=("${@}") + local inputFile="" + local outputFile="" + + # In avifenc: avifenc [options] input output + # So the last one is output, the one before is input. + + local count=${#arguments[@]} + outputFile="${arguments[$((count-1))]}" + inputFile="${arguments[$((count-2))]}" + + local info=$(writeScriptInformation "${inputFile}" "${arguments[@]}") + + # Write the info to the output file + echo "$info" > "$outputFile" + + writeScriptDebugFile <<< "$info" +} + +main "${@}" diff --git a/Tests/Functional/Controller/ImagineControllerTest.php b/Tests/Functional/Controller/ImagineControllerTest.php index 8ca657c25..5378e8cb3 100644 --- a/Tests/Functional/Controller/ImagineControllerTest.php +++ b/Tests/Functional/Controller/ImagineControllerTest.php @@ -35,17 +35,14 @@ protected function setUp(): void parent::setUp(); $this->webp_generate = \function_exists('imagewebp'); - // We turn on generation through reflection, since only in runtime we can determine whether the WebP is - // supported by the current PHP build or not. Enabling WebP in configurations will drop all tests if WebP is - // not supported. if ($this->webp_generate) { - $filterService = $this->getService('test.liip_imagine.service.filter'); - $webpGenerate = new \ReflectionProperty($filterService, 'webpGenerate'); - // remove when we drop support for PHP older than 8.1 - if (PHP_VERSION_ID < 80100) { - $webpGenerate->setAccessible(true); - } - $webpGenerate->setValue($filterService, true); + $this->configureAlternativeFormats([ + 'webp' => [ + 'generate' => true, + 'quality' => 75, + 'mime_types' => ['image/webp'], + ], + ]); } } @@ -165,6 +162,41 @@ public function testShouldResolveWebPFromCache(): void $this->assertFileExists($this->cacheRoot.'/thumbnail_web_path/images/cats.jpeg.webp'); } + public function testShouldResolveAvifFromCache(): void + { + $this->configureAlternativeFormats([ + 'avif' => [ + 'generate' => true, + 'quality' => 75, + 'mime_types' => ['image/avif'], + ], + 'webp' => [ + 'generate' => true, + 'quality' => 75, + 'mime_types' => ['image/webp'], + ], + ]); + + $this->filesystem->dumpFile( + $this->cacheRoot.'/thumbnail_web_path/images/cats.jpeg', + 'anImageContent' + ); + $this->filesystem->dumpFile( + $this->cacheRoot.'/thumbnail_web_path/images/cats.jpeg.avif', + 'anImageContentAvif' + ); + + $this->client->request('GET', '/media/cache/resolve/thumbnail_web_path/images/cats.jpeg', [], [], [ + 'HTTP_ACCEPT' => 'image/avif,image/webp,*/*', + ]); + + $response = $this->client->getResponse(); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame('http://localhost/media/cache/thumbnail_web_path/images/cats.jpeg.avif', $response->getTargetUrl()); + } + public function testThrowBadRequestIfSignInvalidWhileUsingCustomFilters(): void { $this->expectException(BadRequestHttpException::class); @@ -353,4 +385,44 @@ public function testShouldResolvePathWithSpecialCharactersAndWhiteSpaces(): void $this->assertFileExists($this->cacheRoot.'/thumbnail_web_path/images/foo bar.jpeg.webp'); } } + + private function configureAlternativeFormats(array $formats): void + { + $container = $this->client->getContainer(); + $services = [ + 'liip_imagine.service.filter', + 'liip_imagine.cache.manager', + ImagineController::class, + ]; + + foreach ($services as $serviceId) { + if ($container->has($serviceId)) { + $service = $container->get($serviceId); + $this->setPrivateProperty($service, 'alternativeFormats', $formats); + } + } + + if ($container->has('liip_imagine.format_negotiator')) { + $formatNegotiator = $container->get('liip_imagine.format_negotiator'); + foreach ($formats as $format => $config) { + if (isset($config['mime_types'])) { + $formatNegotiator->registerMimeTypes($format, $config['mime_types']); + } + } + } + } + + private function setPrivateProperty($object, string $propertyName, $value): void + { + $reflection = new \ReflectionClass($object); + while (!$reflection->hasProperty($propertyName)) { + $reflection = $reflection->getParentClass(); + if (!$reflection) { + return; + } + } + $property = $reflection->getProperty($propertyName); + $property->setAccessible(true); + $property->setValue($object, $value); + } } diff --git a/Tests/Functional/app/config/config.yml b/Tests/Functional/app/config/config.yml index dcfa5f5e3..0aad0d003 100644 --- a/Tests/Functional/app/config/config.yml +++ b/Tests/Functional/app/config/config.yml @@ -1,41 +1,28 @@ parameters: - locale: en secret: ThisTokenIsNotSoSecretChangeIt - services: logger: class: \Psr\Log\NullLogger - framework: - secret: "%secret%" default_locale: "%locale%" test: ~ - router: resource: "%kernel.project_dir%/config/routing.yml" - liip_imagine: - controller: - redirect_response_code: 302 - loaders: - default: filesystem: data_root: "%kernel.project_dir%/public" - foo: filesystem: data_root: "%kernel.project_dir%/../../Fixtures/FileSystemLocator/root-01" - bar: filesystem: data_root: "%kernel.project_dir%/../../Fixtures/FileSystemLocator/root-02" - baz: chain: loaders: @@ -43,13 +30,11 @@ liip_imagine: - bar - default - bundles_all - bundles_all: filesystem: data_root: ~ bundle_resources: enabled: true - bundles_only_foo: filesystem: data_root: ~ @@ -57,7 +42,6 @@ liip_imagine: enabled: true access_control_type: blacklist access_control_list: [ 'LiipBarBundle' ] - bundles_only_bar: filesystem: data_root: ~ @@ -65,20 +49,15 @@ liip_imagine: enabled: true access_control_type: whitelist access_control_list: [ 'LiipBarBundle' ] - resolvers: - default: web_path: web_root: "%kernel.project_dir%/public" cache_prefix: media/cache - filter_sets: - thumbnail_web_path: filters: thumbnail: { size: [223, 223], mode: inset } - thumbnail_default: filters: thumbnail: { size: [223, 223], mode: inset } diff --git a/Tests/Imagine/Filter/PostProcessor/AbstractPostProcessorTestCase.php b/Tests/Imagine/Filter/PostProcessor/AbstractPostProcessorTestCase.php index a05a76632..2ac1d700a 100644 --- a/Tests/Imagine/Filter/PostProcessor/AbstractPostProcessorTestCase.php +++ b/Tests/Imagine/Filter/PostProcessor/AbstractPostProcessorTestCase.php @@ -36,6 +36,11 @@ public static function getPostProcessAsStdInErrorExecutable(): string return realpath(__DIR__.'/../../../Fixtures/bin/post-process-as-stdin-error.bash'); } + public static function getPostProcessOutputFileExecutable(): string + { + return realpath(__DIR__.'/../../../Fixtures/bin/post-process-output-file.bash'); + } + abstract protected function getPostProcessorInstance(array $parameters = []); protected function getBinaryInterfaceMock(): BinaryInterface diff --git a/Tests/Imagine/Filter/PostProcessor/AvifPostProcessorTest.php b/Tests/Imagine/Filter/PostProcessor/AvifPostProcessorTest.php new file mode 100644 index 000000000..3c3415f3a --- /dev/null +++ b/Tests/Imagine/Filter/PostProcessor/AvifPostProcessorTest.php @@ -0,0 +1,155 @@ +expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The option "quality" with value 101 is invalid.'); + + $this->getProcessArguments(['quality' => 101]); + } + + public function testSpeedOptionThrowsOnOutOfScopeInt(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The option "speed" with value 11 is invalid.'); + + $this->getProcessArguments(['speed' => 11]); + } + + public function testJobsOptionThrowsOnOutOfScopeInt(): void + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The option "jobs" with value -1 is invalid.'); + + $this->getProcessArguments(['jobs' => -1]); + } + + public static function provideProcessArgumentsData(): array + { + $data = [ + [[], []], + [['quality' => 100], ['--min', 0, '--max', 0]], + [['quality' => 0], ['--min', 63, '--max', 63]], + [['quality' => 75], ['--min', 16, '--max', 16]], + [['speed' => 6], ['--speed', 6]], + [['jobs' => 4], ['--jobs', 4]], + ]; + + return array_map(static function (array $d) { + array_unshift($d[1], AbstractPostProcessorTestCase::getPostProcessOutputFileExecutable()); + + return $d; + }, $data); + } + + /** + * @dataProvider provideProcessArgumentsData + */ + public function testProcessArguments(array $options, array $expected): void + { + $this->assertSame($expected, $this->getProcessArguments($options)); + } + + public static function provideProcessData(): array + { + $file = 'stdio-file-content-string'; + $data = [ + [[], ''], + [['quality' => 100], '--min 0 --max 0'], + [['speed' => 6], '--speed 6'], + [['jobs' => 4], '--jobs 4'], + ]; + + return array_map(static function ($d) use ($file) { + array_unshift($d, $file); + + return $d; + }, $data); + } + + /** + * @dataProvider provideProcessData + */ + public function testProcess(string $content, array $options, string $expected): void + { + $file = sys_get_temp_dir().'/test.avif'; + file_put_contents($file, $content); + + $process = $this->getPostProcessorInstance(); + $result = $process->process(new FileBinary($file, 'image/avif', 'avif'), $options); + + $this->assertStringContainsString($expected, $result->getContent()); + $this->assertStringContainsString('argument-list:', $result->getContent()); + + @unlink($file); + } + + /** + * @dataProvider provideProcessData + */ + public function testProcessError(string $content, array $options, string $expected): void + { + $this->expectException(ProcessFailedException::class); + + $process = $this->getPostProcessorInstance([static::getPostProcessAsFileFailingExecutable()]); + $process->process(new Binary('content', 'image/avif', 'avif'), $options); + } + + public function testProcessWithNonSupportedMimeType(): void + { + $binary = $this->getBinaryInterfaceMock(); + + $binary + ->expects($this->atLeastOnce()) + ->method('getMimeType') + ->willReturn('application/x-php'); + + $this->assertSame($binary, $this->getPostProcessorInstance()->process($binary, [])); + } + + /** + * AvifPostProcessor acts as an optimizer only; it should ignore non-AVIF inputs. + */ + public function testProcessIgnoresNonAvif(): void + { + $content = 'jpeg-content'; + $file = sys_get_temp_dir().'/test.jpg'; + file_put_contents($file, $content); + + $process = $this->getPostProcessorInstance(); + $original = new FileBinary($file, 'image/jpeg', 'jpg'); + $result = $process->process($original, []); + + $this->assertSame($original, $result); + + @unlink($file); + } + + protected function getPostProcessorInstance(array $parameters = []): AvifPostProcessor + { + return new AvifPostProcessor($parameters[0] ?? static::getPostProcessOutputFileExecutable()); + } +} diff --git a/Tests/Service/FilterPathContainerTest.php b/Tests/Service/FilterPathContainerTest.php index 5ab6e7ef2..7d1fea376 100644 --- a/Tests/Service/FilterPathContainerTest.php +++ b/Tests/Service/FilterPathContainerTest.php @@ -46,6 +46,75 @@ public function testCustomTarget(): void $this->assertSame($options, $container->getOptions()); } + public function provideAlternativeOptions(): \Traversable + { + yield 'avif options' => [ + 'avif', + [], + [], + [ + 'format' => 'avif', + ], + 'images/cats.jpeg.avif', + ]; + + yield 'avif with use_default_driver false' => [ + 'avif', + [], + [ + 'use_default_driver' => false, + ], + [ + 'format' => 'avif', + 'use_default_driver' => false, + ], + 'images/cats.jpeg.avif', + ]; + + yield 'custom avif options' => [ + 'avif', + [], + [ + 'quality' => 90, + ], + [ + 'format' => 'avif', + 'quality' => 90, + ], + 'images/cats.jpeg.avif', + ]; + + yield 'overwrite base options with avif' => [ + 'avif', + [ + 'format' => 'jpeg', + 'quality' => 80, + ], + [ + 'quality' => 70, + ], + [ + 'format' => 'avif', + 'quality' => 70, + ], + 'images/cats.jpeg.avif', + ]; + } + + /** + * @dataProvider provideAlternativeOptions + */ + public function testCreateAlternative(string $format, array $baseOptions, array $altOptions, array $expectedOptions, string $expectedTarget): void + { + $source = 'images/cats.jpeg'; + + $container = (new FilterPathContainer($source, '', $baseOptions))->createAlternative($format, $altOptions); + + $this->assertSame($source, $container->getSource()); + $this->assertSame($expectedTarget, $container->getTarget()); + $this->assertSame($expectedOptions, $container->getOptions()); + } + public function provideWebpOptions(): \Traversable { yield 'empty options' => [ diff --git a/Tests/Service/FormatNegotiatorTest.php b/Tests/Service/FormatNegotiatorTest.php new file mode 100644 index 000000000..0ee2a3bef --- /dev/null +++ b/Tests/Service/FormatNegotiatorTest.php @@ -0,0 +1,94 @@ + ['image/webp'], + 'avif' => ['image/avif'], + ]; + + public function testIsFormatAccepted(): void + { + $negotiator = new FormatNegotiator($this->mimeMap); + + $request = new Request([], [], [], [], [], ['HTTP_ACCEPT' => 'image/webp,image/apng,image/*,*/*;q=0.8']); + $this->assertTrue($negotiator->isFormatAccepted('webp', $request)); + $this->assertFalse($negotiator->isFormatAccepted('avif', $request)); + + $request = new Request([], [], [], [], [], ['HTTP_ACCEPT' => 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8']); + $this->assertTrue($negotiator->isFormatAccepted('avif', $request)); + $this->assertTrue($negotiator->isFormatAccepted('webp', $request)); + + $request = new Request([], [], [], [], [], ['HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8']); + $this->assertFalse($negotiator->isFormatAccepted('webp', $request)); + } + + public function testNegotiate(): void + { + $negotiator = new FormatNegotiator($this->mimeMap); + $config = [ + 'avif' => ['generate' => true, 'priority' => 10], + 'webp' => ['generate' => true, 'priority' => 20], + ]; + + // Case 1: Client prefers AVIF (equal q, but avif is first in Accept or higher priority?) + // In my implementation, if q is equal, it uses priority from config. + $request = new Request([], [], [], [], [], ['HTTP_ACCEPT' => 'image/avif,image/webp']); + $result = $negotiator->negotiate($request, $config); + $this->assertSame(['webp', 'avif'], $result); // webp has higher priority (20 > 10) + + // Case 2: Client prefers AVIF with higher q + $request = new Request([], [], [], [], [], ['HTTP_ACCEPT' => 'image/avif;q=1.0,image/webp;q=0.9']); + $result = $negotiator->negotiate($request, $config); + $this->assertSame(['avif', 'webp'], $result); + + // Case 3: One format disabled + $configDisabled = $config; + $configDisabled['webp']['generate'] = false; + $request = new Request([], [], [], [], [], ['HTTP_ACCEPT' => 'image/avif,image/webp']); + $result = $negotiator->negotiate($request, $configDisabled); + $this->assertSame(['avif'], $result); + + // Case 4: Client supports nothing from config + $request = new Request([], [], [], [], [], ['HTTP_ACCEPT' => 'image/jpeg']); + $result = $negotiator->negotiate($request, $config); + $this->assertSame([], $result); + } + + public function testGetAcceptedFormats(): void + { + $negotiator = new FormatNegotiator($this->mimeMap); + + $request = new Request([], [], [], [], [], ['HTTP_ACCEPT' => 'image/avif;q=1.0,image/webp;q=0.8']); + $result = $negotiator->getAcceptedFormats($request); + + $this->assertSame(['avif' => 1.0, 'webp' => 0.8], $result); + } + + public function testRegisterMimeTypes(): void + { + $negotiator = new FormatNegotiator(); + $negotiator->registerMimeTypes('png', ['image/png']); + + $request = new Request([], [], [], [], [], ['HTTP_ACCEPT' => 'image/png']); + $this->assertTrue($negotiator->isFormatAccepted('png', $request)); + } +}