From 89613cdb79d47f87cb5a58883b301662674b526b Mon Sep 17 00:00:00 2001 From: Aleksey Razbakov Date: Sun, 23 Nov 2025 16:21:13 +0100 Subject: [PATCH 1/4] feat: ux-image --- src/Image/.gitignore | 8 + src/Image/.php-cs-fixer.php | 19 + src/Image/CHANGELOG.md | 5 + src/Image/LICENSE | 19 + src/Image/README.md | 625 ++++++++++++++++++ src/Image/composer.json | 80 +++ src/Image/config/packages/liip_imagine.yaml | 13 + src/Image/config/services.yaml | 42 ++ src/Image/config/ux_image.yaml | 90 +++ src/Image/doc/index.rst | 548 +++++++++++++++ src/Image/phpunit.xml.dist | 26 + src/Image/phpunit.xml.dist.bak | 36 + src/Image/providers.md | 105 +++ src/Image/public/images/image-not-found.png | Bin 0 -> 809 bytes .../Compiler/ProviderPass.php | 36 + .../src/DependencyInjection/Configuration.php | 122 ++++ .../DependencyInjection/ImageExtension.php | 77 +++ .../PreloadInjectorSubscriber.php | 73 ++ .../Exception/ProviderNotFoundException.php | 19 + src/Image/src/ImageBundle.php | 45 ++ src/Image/src/Provider/CloudinaryProvider.php | 190 ++++++ src/Image/src/Provider/FastlyProvider.php | 94 +++ .../src/Provider/LiipImagineProvider.php | 135 ++++ .../src/Provider/PlaceholderProvider.php | 110 +++ src/Image/src/Provider/ProviderInterface.php | 44 ++ src/Image/src/Provider/ProviderRegistry.php | 71 ++ src/Image/src/Service/PreloadManager.php | 65 ++ src/Image/src/Service/Transformer.php | 363 ++++++++++ src/Image/src/Twig/Components/Img.php | 387 +++++++++++ src/Image/src/Twig/Components/Picture.php | 165 +++++ src/Image/templates/components/img.html.twig | 12 + .../templates/components/picture.html.twig | 20 + .../DependencyInjection/ConfigurationTest.php | 173 +++++ .../ImageExtensionTest.php | 167 +++++ .../tests/Provider/CloudinaryProviderTest.php | 127 ++++ src/Image/tests/Service/TransformerTest.php | 326 +++++++++ src/Image/tests/TestHelper/HtmlTestHelper.php | 76 +++ src/Image/tests/TestKernel.php | 32 + src/Image/tests/Twig/Components/ImgTest.php | 619 +++++++++++++++++ .../tests/Twig/Components/PictureTest.php | 257 +++++++ src/Image/tests/config/config.yaml | 47 ++ 41 files changed, 5468 insertions(+) create mode 100644 src/Image/.gitignore create mode 100644 src/Image/.php-cs-fixer.php create mode 100644 src/Image/CHANGELOG.md create mode 100644 src/Image/LICENSE create mode 100644 src/Image/README.md create mode 100644 src/Image/composer.json create mode 100644 src/Image/config/packages/liip_imagine.yaml create mode 100644 src/Image/config/services.yaml create mode 100644 src/Image/config/ux_image.yaml create mode 100644 src/Image/doc/index.rst create mode 100644 src/Image/phpunit.xml.dist create mode 100644 src/Image/phpunit.xml.dist.bak create mode 100644 src/Image/providers.md create mode 100644 src/Image/public/images/image-not-found.png create mode 100644 src/Image/src/DependencyInjection/Compiler/ProviderPass.php create mode 100644 src/Image/src/DependencyInjection/Configuration.php create mode 100644 src/Image/src/DependencyInjection/ImageExtension.php create mode 100644 src/Image/src/EventListener/PreloadInjectorSubscriber.php create mode 100644 src/Image/src/Exception/ProviderNotFoundException.php create mode 100644 src/Image/src/ImageBundle.php create mode 100644 src/Image/src/Provider/CloudinaryProvider.php create mode 100644 src/Image/src/Provider/FastlyProvider.php create mode 100644 src/Image/src/Provider/LiipImagineProvider.php create mode 100644 src/Image/src/Provider/PlaceholderProvider.php create mode 100644 src/Image/src/Provider/ProviderInterface.php create mode 100644 src/Image/src/Provider/ProviderRegistry.php create mode 100644 src/Image/src/Service/PreloadManager.php create mode 100644 src/Image/src/Service/Transformer.php create mode 100644 src/Image/src/Twig/Components/Img.php create mode 100644 src/Image/src/Twig/Components/Picture.php create mode 100644 src/Image/templates/components/img.html.twig create mode 100644 src/Image/templates/components/picture.html.twig create mode 100644 src/Image/tests/DependencyInjection/ConfigurationTest.php create mode 100644 src/Image/tests/DependencyInjection/ImageExtensionTest.php create mode 100644 src/Image/tests/Provider/CloudinaryProviderTest.php create mode 100644 src/Image/tests/Service/TransformerTest.php create mode 100644 src/Image/tests/TestHelper/HtmlTestHelper.php create mode 100644 src/Image/tests/TestKernel.php create mode 100644 src/Image/tests/Twig/Components/ImgTest.php create mode 100644 src/Image/tests/Twig/Components/PictureTest.php create mode 100644 src/Image/tests/config/config.yaml diff --git a/src/Image/.gitignore b/src/Image/.gitignore new file mode 100644 index 00000000000..a8d98c3cbe8 --- /dev/null +++ b/src/Image/.gitignore @@ -0,0 +1,8 @@ +/assets/node_modules/ +/vendor/ +/composer.lock +/phpunit.xml +/.phpunit.result.cache + +/var +.phpunit.cache diff --git a/src/Image/.php-cs-fixer.php b/src/Image/.php-cs-fixer.php new file mode 100644 index 00000000000..38702b9ca3b --- /dev/null +++ b/src/Image/.php-cs-fixer.php @@ -0,0 +1,19 @@ +setUsingCache(true) + ->setRiskyAllowed(true) + ->setFinder( + (new Finder()) + ->in([ + __DIR__.'/src', + __DIR__.'/tests', + ]) + ) + ->setRules([ + '@Symfony' => true, + '@Symfony:risky' => true, + ]); diff --git a/src/Image/CHANGELOG.md b/src/Image/CHANGELOG.md new file mode 100644 index 00000000000..1eca336a257 --- /dev/null +++ b/src/Image/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + +## 2.32.0 + +- Add component diff --git a/src/Image/LICENSE b/src/Image/LICENSE new file mode 100644 index 00000000000..c6be5bbf4aa --- /dev/null +++ b/src/Image/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/Image/README.md b/src/Image/README.md new file mode 100644 index 00000000000..7aa0418de53 --- /dev/null +++ b/src/Image/README.md @@ -0,0 +1,625 @@ +# Symfony UX Image + +A Symfony UX component that provides two components for optimized images: + +- `` - For simple responsive images with automatic WebP conversion +- `` - For art direction with different crops per breakpoint + +**Key Features:** + +- 🖼️ Automatic responsive image generation +- 🎯 Smart cropping with focal points +- 🔄 WebP format conversion +- 🚀 Performance optimization +- ⚡ Image preloading support + +**Benefits:** + +- 📱 Better user experience across all devices and screen sizes +- ⚡ Faster page loads with optimized image delivery +- 🎨 Maintain image quality while reducing file sizes +- 📊 Improved Core Web Vitals scores +- 💻 Less developer time spent on image optimization + +## Table of Contents + +1. [Requirements](#requirements) +2. [Installation](#installation) +3. [Components](#components) + - [Img Component](#img-component) + - [Picture Component](#picture-component) +4. [Configuration](#configuration) + - [Preloading Images](#preloading-images) + - [Responsive Images](#responsive-images) + - [Density Support](#density-support) + - [Fit Options](#fit-options-cropping-and-resizing) + - [Fallback Options](#fallback-options) + - [Placeholder Options](#placeholder-options) + - [Art Direction](#art-direction) +5. [Common Use Cases](#common-use-cases) +6. [Using Presets](#using-presets) +7. [Settings](#settings) +8. [Providers](#providers) +9. [Error Handling](#error-handling) +10. [Security](#security) +11. [Development](#development) +12. [License](#license) +13. [Credits](#credits) + +## Requirements + +- PHP 8.1 or higher +- Symfony 6.0 or higher +- GD extension or Imagick extension + +## Installation + +```bash +composer require symfony/ux-image +``` + +Register the bundle in `config/bundles.php`: + +```php +return [ + // ... + Symfony\UX\Image\ImageBundle::class => ['all' => true], +]; +``` + +## Components + +Choose the approach that best fits your needs: + +- Use `` when you need different sizes of the same image +- Use `` when you need different crops/ratios per breakpoint + +### Img Component + +Basic usage: + +```twig +{# Simple responsive image #} + + +{# With aspect ratio and focal point #} + + +{# Optimized hero image #} + +``` + +#### Available Attributes + +The `` component supports the following attributes: + +```twig + +``` + +### Picture Component + +Basic usage: + +```twig +{# Different crops per breakpoint #} + +``` + +#### Available Attributes + +```twig + +``` + +## Configuration + +### Preloading Images + +To preload critical images for better LCP (Largest Contentful Paint), simply mark images with the `preload` attribute: + +```twig + +``` + +The bundle automatically injects preload `` tags into the `` section via an event subscriber. No additional configuration needed! + +**Generated HTML:** + +```html + + + My Page + + + + + Hero + +``` + +**Note:** For responsive images, the bundle uses `imagesrcset` and `imagesizes` attributes on the `` tag, which mirror the `srcset` and `sizes` attributes on the `` tag. This allows browsers to preload the appropriate image variant based on viewport size and pixel density. See [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/API/HTMLLinkElement/imageSrcset) for browser support details. + +**Best Practices:** + +- Only preload your LCP (Largest Contentful Paint) image - typically 1-2 images per page +- Combine with `fetchpriority="high"` for maximum effect +- Don't preload too many images - it can slow down the initial page load + +### Responsive Images + +```twig + +``` + +This will automatically: + +- Generate appropriate image widths based on your design system's breakpoints +- Create the correct srcset and sizes attributes +- Optimize image delivery for each viewport size + +The sizes syntax follows this pattern: + +- Start with default size (applies to smallest screens) +- Add breakpoint:size pairs for larger screens +- Each size applies from that breakpoint up +- Example: `"100vw sm:50vw md:400px lg:800"` + - `100vw` - full width (640px) on mobile (<640px) + - `sm:50vw` - half width (384px) from sm breakpoint (≥640px) + - `md:400px` - fixed 400px from md breakpoint (≥768px) + - `lg:800` - fixed 800px from lg breakpoint (≥1024px) + +Default breakpoints: + +- default: <640px - Mobile portrait +- sm: >= 640px - Mobile landscape +- md: >= 768px - Tablet portrait +- lg: >= 1024px - Tablet landscape +- xl: >= 1280px - Desktop +- 2xl: >= 1536px - Large desktop + +Transformation rules: + +- If default width is not set, it will be taken from the smallest breakpoint, i.e `sm:50vw md:400px` is translated to `50vw md:400px`. +- Dynamic width `vw` will generate all sizes from smallest breakpoint to image size but not larger than largest breakpoint. +- Fixed width wll be used until there is a breakpoint with `vw` width set, from which point it will use dynamic rule. + +| Width string | Image versions | +| ------------------- | ------------------------------------ | +| 100 | 100px | +| 1000 | 1000px | +| sm:50 md:100 lg:200 | 50px, 100px, 200px | +| 100vw | 640px, 768px, 1024px, 1280px, 1536px | +| 50vw lg:400px | 320px, 384px, 400px | +| 100 lg:100vw | 100px, 1024px, 1280px, 1536px | +| 100vw md:100 | 640px, 768px, 100px | +| 1000 lg:100vw | 1000px, 1024px, 1280px, 1536px | + +Width and height are automatically calculated from: + +- Original image dimensions when no ratio specified +- When ratio specified: + - Original width and calculated height if no width/height set + - Width and calculated height if width set (width="800" ratio="16:9") + - Calculated width and height if height set (height="600" ratio="16:9") + - Override both with width/height if needed (width="800" height="600") + +The bundle uses your design system's breakpoints (configurable in `ux_image.yaml`). + +### Density Support + +To generate special versions of images for high-DPI displays (like Retina), use the `densities` attribute: + +```twig + +``` + +This will generate: + +```html +Logo +``` + +You can combine densities with responsive sizes: + +```twig + +``` + +The component will: + +- Generate 1x and 2x versions for each size +- Include both width (w) descriptors in srcset +- Automatically calculate the correct dimensions for each density + +### Fit Options (Cropping and Resizing) + +The `fit` property specifies how the image should be resized to fit the target dimensions. There are five standard values: + +- `cover` (default) - Preserving aspect ratio, ensures the image covers both provided dimensions by cropping/clipping to fit +- `contain` - Preserving aspect ratio, contains image within both provided dimensions using "letterboxing" where necessary +- `fill` - Ignores the aspect ratio of the input and stretches to both provided dimensions +- `inside` - Preserving aspect ratio, resizes the image to be as large as possible while ensuring its dimensions are less than or equal to both those specified +- `outside` - Preserving aspect ratio, resizes the image to be as small as possible while ensuring its dimensions are greater than or equal to both those specified +- `none` - Uses original image dimensions + +### Fallback Options + +The `fallback-format` property controls format selection for older browsers: + +- `auto` (default): Chooses based on original image + - PNG fallback if original has transparency (PNG, WebP, GIF) + - JPEG fallback for all other formats +- `jpg`: Force JPEG as fallback format +- `png`: Force PNG as fallback format +- `empty`: Return empty GIF image + +The `fallback` property controls which breakpoint is used for the fallback image. +By default it is set to `lg`, assuming older browsers that don't support `srcset` are more likely to be desktop and have a resolution of 1024px. + +### Placeholder Options + +The `placeholder` property controls image loading placeholders: + +```twig + +``` + +Placeholder options: + +- `none` (default) - No placeholder +- `blur` - Blurred version of the image +- `dominant` - Dominant color of the image +- Array syntax for custom dimensions: + - `[size]` - Square placeholder (e.g. `[200]`) + - `[width,height]` - Custom dimensions (e.g. `[200,150]`) + - `[width,height,quality,blur]` - Full control (e.g. `[200,150,70,3]`) + +The placeholder image is automatically: + +- Converted to a lightweight Base64 data URI +- Shown while the main image loads +- Faded out when the main image loads +- Optimized for performance + +Example with blur placeholder: + +```twig + +``` + +### Art Direction + +Use `` when you need different versions of the image: + +```twig + +``` + +## Common Use Cases + +### Product Image (Contained) + +```twig + +``` + +### Portrait with Smart Cropping + +```twig + +``` + +### Integration with Ibexa + +```twig + +``` + +## Using Presets + +Presets allow you to reuse common configurations: + +```yaml +# config/packages/ux_image.yaml +ux_image: + presets: + thumbnail: + width: 200 + height: 200 + fit: cover + quality: 90 + + hero: + ratio: "16:9" + sizes: "100vw sm:50vw md:400px" + fetchpriority: high + preload: true + + avatar: + width: 48 + height: 48 + fit: cover + placeholder: blur + + product: + ratio: "1:1" + fit: contain + background: "#ffffff" + placeholder: dominant +``` + +Using presets in templates: + +```twig +{# Using a preset #} + + +{# Override preset values #} + +``` + +You can define your own presets in the configuration. Preset values can be overridden by directly setting properties on the component. + +## Settings + +Default settings in `config/packages/ux_image.yaml`: + +```yaml +ux_image: + provider: "liip_imagine" # see below for providers + missing_image_placeholder: "/path/to/404-placeholder.jpg" + defaults: + breakpoints: + xs: 320 + sm: 640 + md: 768 + lg: 1024 + xl: 1280 + 2xl: 1536 + format: "webp" + quality: 80 + loading: lazy + fetchpriority: low + fit: "cover" + focal: "center" + placeholder: "none" + placeholder-class: "lazy-placeholder" +``` + +## Providers + +The bundle supports multiple providers for image transformation and optimization. Each provider is responsible for generating optimized image URLs and handling transformations. See [Providers](providers.md) for more information. + +**Currently supported providers:** + +- `placeholder` - Uses placeholder service for testing +- `cloudinary` +- `fastly` +- `liip_imagine` + +**Coming soon:** + +- `aliyun` +- `aws_amplify` +- `bunny` +- `caisy` +- `cloudflare` +- `cloudimage` +- `contentful` +- `directus` +- `edgio` +- `glide` +- `gumlet` +- `hygraph` +- `imageengine` +- `imagekit` +- `imgix` +- `ipx` +- `netlify` +- `prepr` +- `prismic` +- `sanity` +- `sirv` +- `storyblok` +- `strapi` +- `twicpics` +- `unsplash` +- `uploadcare` +- `vercel` +- `weserv` + +## Error Handling + +The bundle provides several error handling mechanisms: + +- Missing images return a 404 placeholder +- Invalid configurations throw `InvalidConfigurationException` +- Processing errors are logged to Symfony's error log + +## Security + +- Allowed image types: jpg, jpeg, png, gif, webp +- Maximum upload size: Configured through PHP's upload_max_filesize +- Path validation prevents directory traversal attacks +- Image validation ensures file integrity + +## Development + +### Setup + +```bash +# Clone repository +git clone https://github.com/symfony/ux +cd ux/src/Image + +# Install dependencies +composer install + +# Run tests +composer test +``` + +## License + +This bundle is available under the MIT license. + +## Credits + +Inspired by [NuxtImg](https://image.nuxtjs.org/). diff --git a/src/Image/composer.json b/src/Image/composer.json new file mode 100644 index 00000000000..fcda991a542 --- /dev/null +++ b/src/Image/composer.json @@ -0,0 +1,80 @@ +{ + "name": "symfony/ux-image", + "type": "symfony-bundle", + "description": "A Symfony bundle for responsive images with automatic WebP conversion, smart cropping, and Core Web Vitals optimization", + "keywords": [ + "symfony-ux", + "symfony", + "image", + "responsive", + "webp", + "optimization", + "core web vitals" + ], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Aleksey Razbakov", + "email": "aleksey@razbakov.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.1", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/twig-bundle": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "twig/twig": "^3.0", + "liip/imagine-bundle": "^2.11", + "symfony/options-resolver": "^6.4|^7.0|^8.0", + "symfony/ux-twig-component": "^2.21" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.65", + "phpunit/phpunit": "^9.5|^10.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/debug-bundle": "^6.4|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "suggest": { + "liip/imagine-bundle": "For local image processing using LiipImagineBundle" + }, + "autoload": { + "psr-4": { + "Symfony\\UX\\Image\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\UX\\Image\\Tests\\": "tests/" + } + }, + "config": { + "sort-packages": true + }, + "scripts": { + "test": "phpunit", + "coverage": "phpunit --coverage-html coverage", + "review": "php-cs-fixer fix --dry-run --diff", + "format": "php-cs-fixer fix" + }, + "conflict": { + "symfony/flex": "<1.13", + "symfony/ux-twig-component": "<2.21" + }, + "extra": { + "thanks": { + "name": "symfony/ux", + "url": "https://github.com/symfony/ux" + } + }, + "minimum-stability": "dev" +} diff --git a/src/Image/config/packages/liip_imagine.yaml b/src/Image/config/packages/liip_imagine.yaml new file mode 100644 index 00000000000..aee8ad2f8a3 --- /dev/null +++ b/src/Image/config/packages/liip_imagine.yaml @@ -0,0 +1,13 @@ +liip_imagine: + driver: "gd" + filter_sets: + default: + quality: 85 + filters: + auto_rotate: ~ + strip: ~ + scale: ~ + thumbnail: ~ + upscale: ~ + relative_resize: ~ + background: ~ diff --git a/src/Image/config/services.yaml b/src/Image/config/services.yaml new file mode 100644 index 00000000000..5243a422ee9 --- /dev/null +++ b/src/Image/config/services.yaml @@ -0,0 +1,42 @@ +parameters: + env(CLOUDINARY_URL): "https://res.cloudinary.com/your-cloud-name/image/upload" + env(FASTLY_URL): "https://www.fastly.io" + +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + Symfony\UX\Image\: + resource: "../src/*" + exclude: + - "../src/DependencyInjection/" + + Symfony\UX\Image\EventListener\PreloadInjectorSubscriber: + tags: + - { name: kernel.event_subscriber } + + ux.image.provider_registry: + class: Symfony\UX\Image\Provider\ProviderRegistry + public: true + arguments: + $defaultProvider: "%ux_image.provider%" + + Symfony\UX\Image\Provider\ProviderRegistry: "@ux.image.provider_registry" + + Symfony\UX\Image\Twig\Components\Img: + tags: ["twig.component"] + + Symfony\UX\Image\Twig\Components\Picture: + tags: ["twig.component"] + + Symfony\UX\Image\Service\PreloadManager: + public: true + + Symfony\UX\Image\Provider\LiipImagineProvider: + arguments: + $urlGenerator: "@router" + $signer: "@?liip_imagine.cache.signer" + tags: + - { name: "ux.image.provider" } diff --git a/src/Image/config/ux_image.yaml b/src/Image/config/ux_image.yaml new file mode 100644 index 00000000000..dd152592323 --- /dev/null +++ b/src/Image/config/ux_image.yaml @@ -0,0 +1,90 @@ +ux_image: + # Image provider (liip_imagine, cloudinary, etc.) + provider: placeholder + + # Path to the image shown when source image is missing + missing_image_placeholder: "src/Resources/public/images/image-not-found.png" + + breakpoints: + sm: 640 + md: 768 + lg: 1024 + xl: 1280 + 2xl: 1536 + + # Default settings for all images + defaults: + # Image format and quality + format: webp # webp, jpg, png, avif + quality: 80 + + # Loading behavior + loading: lazy + fetchpriority: auto + + # Image fitting + fit: cover + + # Placeholder settings + placeholder: none + + # Fallback settings + fallback: lg + fallback_format: auto + + # Provider-specific configuration + providers: + liip_imagine: + default_filter: "default" + defaults: + format: webp + quality: 80 + + cloudinary: + base_url: "%env(CLOUDINARY_URL)%" + defaults: + format: auto + quality: auto + + placeholder: + defaults: + width: 600 + height: null + background: "868e96" + text: null + text_color: "FFFFFF" + ratio: null + + fastly: + base_url: "%env(FASTLY_URL)%" + defaults: + format: webp + quality: 85 + default_transformations: + - ["format", "auto"] + - ["quality", "85"] + + # Predefined presets for common use cases + presets: + thumbnail: + width: 200 + height: 200 + quality: 90 + + hero: + ratio: "16:9" + width: "100vw sm:50vw md:400px" + fetchpriority: high + preload: true + + avatar: + width: 48 + height: 48 + placeholder: blur + + product: + ratio: "1:1" + fit: contain + quality: 85 + placeholder: dominant + diff --git a/src/Image/doc/index.rst b/src/Image/doc/index.rst new file mode 100644 index 00000000000..57714ed0e75 --- /dev/null +++ b/src/Image/doc/index.rst @@ -0,0 +1,548 @@ +Symfony UX Image +================ + +Symfony UX Image is a Symfony bundle providing optimized responsive image +components with automatic format conversion, smart cropping, and Core Web +Vitals optimization. + +The bundle provides two Twig components: + +* ```` - For simple responsive images with automatic WebP conversion +* ```` - For art direction with different crops per breakpoint + +Installation +------------ + +.. code-block:: terminal + + $ composer require symfony/ux-image + +If you're using Symfony Flex, the bundle will be automatically enabled. Otherwise, +enable it manually in your ``config/bundles.php`` file: + +.. code-block:: php + + return [ + // ... + Symfony\UX\Image\ImageBundle::class => ['all' => true], + ]; + +Components +---------- + +Img Component +~~~~~~~~~~~~~ + +The ```` component generates optimized responsive images with +automatic srcset generation: + +.. code-block:: html+twig + + {# Simple responsive image #} + + + {# With aspect ratio and focal point #} + + + {# Optimized hero image #} + + +Available Attributes +^^^^^^^^^^^^^^^^^^^^ + +Required Attributes +""""""""""""""""""" + +``src`` + Path to the source image + +``alt`` + Alternative text for accessibility + +Common Attributes +""""""""""""""""" + +``width`` + Responsive widths using breakpoint syntax (e.g., ``100vw sm:50vw md:400px``) + +``densities`` + Pixel density variants to generate (e.g., ``x1 x2``) + +``ratio`` + Aspect ratio for cropping (e.g., ``16:9``, ``4:3``, ``1:1``) + +``preset`` + Name of predefined configuration preset + +Optimization Attributes +""""""""""""""""""""""" + +``preload`` + Set to ``true`` to add preload link for critical images + +``fetchpriority`` + Set to ``high`` for LCP images, ``low`` for below-the-fold images + +``loading`` + Set to ``lazy`` for lazy loading (default), or ``eager`` for immediate loading + +Image Processing Attributes +""""""""""""""""""""""""""" + +``format`` + Output format (``webp``, ``jpg``, ``png``). Default: ``webp`` + +``quality`` + Image quality from 0-100. Default: ``80`` + +``focal`` + Focus point for smart cropping: ``center``, ``top``, ``bottom``, ``left``, ``right``, or coordinates like ``0.5,0.3`` + +``fit`` + How image should fit dimensions: ``cover`` (default), ``contain``, ``fill``, ``inside``, ``outside`` + +``fallback`` + Fallback breakpoint for browsers without srcset support. Default: ``lg`` + +``fallback-format`` + Format for fallback image. Default: ``auto`` + +``background`` + Background color for ``contain`` fit mode (e.g., ``#ffffff``) + +Picture Component +~~~~~~~~~~~~~~~~~ + +The ```` component provides art direction capabilities with +different image crops for different viewport sizes: + +.. code-block:: html+twig + + {# Different aspect ratios per breakpoint #} + + +Configuration +------------- + +Preloading Images +~~~~~~~~~~~~~~~~~ + +To preload critical images for better LCP (Largest Contentful Paint), simply +mark them with the ``preload`` attribute: + +.. code-block:: html+twig + + + +The bundle automatically injects preload ```` tags into the ```` +section via an event subscriber. No additional configuration needed! + +**Best Practices:** + +* Only preload your LCP (Largest Contentful Paint) image - typically 1-2 images per page +* Combine with ``fetchpriority="high"`` for maximum effect +* Don't preload too many images - it can slow down the initial page load + +The generated HTML will include: + +.. code-block:: html + + + + My Page + + + + + Hero + + +.. note:: + + For responsive images, the bundle uses ``imagesrcset`` and ``imagesizes`` + attributes on the ```` tag, which mirror the ``srcset`` and ``sizes`` + attributes on the ```` tag. This allows browsers to preload the + appropriate image variant based on viewport size and pixel density. + +Default Configuration +~~~~~~~~~~~~~~~~~~~~~ + +Create a configuration file at ``config/packages/ux_image.yaml``: + +.. code-block:: yaml + + ux_image: + provider: 'liip_imagine' + + missing_image_placeholder: '/images/image-not-found.png' + + defaults: + format: 'webp' + quality: 80 + fallback: 'lg' + fallback_format: 'auto' + + breakpoints: + sm: 640 + md: 768 + lg: 1024 + xl: 1280 + '2xl': 1536 + +Using Presets +~~~~~~~~~~~~~ + +Define reusable image configurations: + +.. code-block:: yaml + + ux_image: + presets: + hero: + width: '100vw' + ratio: '16:9' + quality: 85 + focal: 'center' + + thumbnail: + width: '300' + ratio: '1:1' + fit: 'cover' + quality: 75 + + avatar: + width: '128' + ratio: '1:1' + fit: 'cover' + format: 'webp' + +Use presets in your templates: + +.. code-block:: html+twig + + + + + +Responsive Widths +~~~~~~~~~~~~~~~~~ + +Width Syntax +^^^^^^^^^^^^ + +Define different widths for different breakpoints: + +.. code-block:: html+twig + + {# Full width on mobile, half width on tablet and up #} + + + {# Fixed width on mobile, viewport width on desktop #} + + + {# Viewport width until large, then fixed #} + + +Generated Image Versions +^^^^^^^^^^^^^^^^^^^^^^^^ + +The bundle automatically generates appropriate image sizes based on your +width configuration: + +==================== ===================================== +Width String Generated Versions +==================== ===================================== +``100`` 100px +``1000`` 1000px +``sm:50 md:100`` 50px, 100px, 200px +``100vw`` 640px, 768px, 1024px, 1280px, 1536px +``50vw lg:400px`` 320px, 384px, 400px +``100 lg:100vw`` 100px, 1024px, 1280px, 1536px +==================== ===================================== + +Fit Options +~~~~~~~~~~~ + +Control how images are resized and cropped: + +``cover`` (default) + Crop to fill, maintaining aspect ratio + +.. code-block:: html+twig + + + +``contain`` + Fit within dimensions, maintaining aspect ratio (may add letterboxing) + +.. code-block:: html+twig + + + +``fill`` + Stretch to fill dimensions (may distort) + +``inside`` + Resize to fit within dimensions + +``outside`` + Resize to cover dimensions + +Focal Points +~~~~~~~~~~~~ + +Control which part of the image stays in view when cropping: + +Named Positions +^^^^^^^^^^^^^^^ + +.. code-block:: html+twig + + + + + +Coordinate Positions +^^^^^^^^^^^^^^^^^^^^ + +Use normalized coordinates (0.0 to 1.0) for precise control: + +.. code-block:: html+twig + + {# Focus on point 50% from left, 30% from top #} + + +Providers +--------- + +The bundle supports multiple image providers for different deployment +scenarios. + +LiipImagine Provider +~~~~~~~~~~~~~~~~~~~~ + +Local image processing using LiipImagineBundle: + +.. code-block:: yaml + + ux_image: + provider: 'liip_imagine' + providers: + liip_imagine: + enabled: true + +Cloudinary Provider +~~~~~~~~~~~~~~~~~~~ + +Cloud-based image processing: + +.. code-block:: yaml + + ux_image: + provider: 'cloudinary' + providers: + cloudinary: + enabled: true + cloud_name: 'your-cloud-name' + base_url: 'https://res.cloudinary.com/your-cloud-name/image/upload' + +Fastly Provider +~~~~~~~~~~~~~~~ + +CDN-based image optimization: + +.. code-block:: yaml + + ux_image: + provider: 'fastly' + providers: + fastly: + enabled: true + base_url: 'https://www.fastly.io' + +Placeholder Provider +~~~~~~~~~~~~~~~~~~~~ + +For development and testing: + +.. code-block:: yaml + + ux_image: + provider: 'placeholder' + providers: + placeholder: + enabled: true + base_url: 'https://via.placeholder.com' + +Common Use Cases +---------------- + +Hero Images +~~~~~~~~~~~ + +Large, full-width hero images optimized for Core Web Vitals: + +.. code-block:: html+twig + + + +Thumbnails +~~~~~~~~~~ + +Fixed-size thumbnails with smart cropping: + +.. code-block:: html+twig + + + +Avatar Images +~~~~~~~~~~~~~ + +User profile images: + +.. code-block:: html+twig + + + +Responsive Gallery +~~~~~~~~~~~~~~~~~~ + +Image gallery with different layouts per viewport: + +.. code-block:: html+twig + + + +Art Direction +~~~~~~~~~~~~~ + +Use ```` when you need different aspect ratios for different +viewport sizes. The ratio values cascade across breakpoints (like CSS): + +.. code-block:: html+twig + + + +This generates exclusive media query ranges to ensure the browser selects +the correct aspect ratio: + +* **sm (640px-767px)**: Square images (1:1 ratio) +* **md and above (768px+)**: Widescreen images (16:9 ratio) + +.. note:: + + Breakpoint-specific ``focal`` and ``fit`` attributes are coming soon! + +Error Handling +-------------- + +Missing Images +~~~~~~~~~~~~~~ + +Configure a placeholder for missing images: + +.. code-block:: yaml + + ux_image: + missing_image_placeholder: '/images/image-not-found.png' + +Invalid Images +~~~~~~~~~~~~~~ + +The bundle will throw an exception for invalid image paths in development +mode. In production, it will use the configured placeholder. + +Security +-------- + +The bundle includes built-in security features: + +* Image paths are validated and sanitized +* Only configured directories are accessible +* Image dimensions are limited to prevent abuse +* File type validation prevents malicious uploads + +Backward Compatibility Promise +------------------------------- + +This bundle follows `Symfony's Backward Compatibility Promise`_. + +.. _Symfony's Backward Compatibility Promise: https://symfony.com/doc/current/contributing/code/bc.html + diff --git a/src/Image/phpunit.xml.dist b/src/Image/phpunit.xml.dist new file mode 100644 index 00000000000..cfcbe7778c8 --- /dev/null +++ b/src/Image/phpunit.xml.dist @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + tests + + + + + src + + + src/Resources + + + diff --git a/src/Image/phpunit.xml.dist.bak b/src/Image/phpunit.xml.dist.bak new file mode 100644 index 00000000000..89ce659dbf0 --- /dev/null +++ b/src/Image/phpunit.xml.dist.bak @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + tests + + + + + + src + + + src/Resources + + + + + + + \ No newline at end of file diff --git a/src/Image/providers.md b/src/Image/providers.md new file mode 100644 index 00000000000..cad552890dd --- /dev/null +++ b/src/Image/providers.md @@ -0,0 +1,105 @@ +# Providers + +## Available Providers + +### LiipImagine (Default) + +Local image processing using LiipImagineBundle: + +```yaml +# config/packages/ux_image.yaml +ux_image: + provider: liip_imagine + liip_imagine: + driver: gd # or imagick + cache: default +``` + +### Cloudinary + +Cloud-based image processing using Cloudinary: + +```yaml +# config/packages/ux_image.yaml +ux_image: + provider: cloudinary + cloudinary: + cloud_name: your_cloud_name + api_key: your_api_key + api_secret: your_api_secret +``` + +## Using Providers in Templates + +You can specify a provider per image: + +```twig +{# Use default provider #} + + +{# Use specific provider #} + +``` + +## Provider Configuration + +Each provider can be configured in your `ux_image.yaml`: + +```yaml +# config/packages/ux_image.yaml +ux_image: + provider: liip_imagine + providers: + liip_imagine: + driver: gd + cache: default + filters: + # LiipImagine filters configuration + + cloudinary: + cloud_name: "%env(CLOUDINARY_CLOUD_NAME)%" + api_key: "%env(CLOUDINARY_API_KEY)%" + api_secret: "%env(CLOUDINARY_API_SECRET)%" + secure: true +``` + +## Custom Providers + +You can create your own provider by implementing the `ProviderInterface`: + +```php +namespace App\Provider; + +use Symfony\UX\Image\Provider\ProviderInterface; + +class CustomProvider implements ProviderInterface +{ + public function getName(): string + { + return 'custom'; + } + + public function generateUrl(string $src, array $options): string + { + // Your URL generation logic here + } +} +``` + +Register your provider: + +```yaml +# config/services.yaml +services: + App\Provider\CustomProvider: + tags: ["ux.image.provider"] +``` diff --git a/src/Image/public/images/image-not-found.png b/src/Image/public/images/image-not-found.png new file mode 100644 index 0000000000000000000000000000000000000000..9804d3c71c4a64a63ff26aaa3ca48322440ac66f GIT binary patch literal 809 zcmeAS@N?(olHy`uVBq!ia0vp^4?&oN8Ax*GDtQ1Y%K)Dc*Uw+R?c8^2+wK!*FW$U* z>C|^61ISqbD!#J9PH`!{@v9pT2SX;kWNUK7IN2?)|5e=dK+(e(~b9JNr!z zaxgG3t@d&R-~RFI$!jkj5}D6+yS#f*eeI^LQ4xPveORPqDEnQ*wz_t6 zN|dh5r}HMyE1$;9Uv%?o(y6_%Vb!&}KW^OpC|zzz`u-W;Zi&2ock9pJ!e2?2EB@YH zDAT=b+l*RXW8b?ki-k7CQh zvYWy&Gm{RMFA7utVRN*I`*rueD!$_B5qS%*U+`2Oc!k&v1#n<1M>JbhPk8#e@zhmS0xplUB#$JP``LSNQ zYc!TjaN^Qj^1+`g_x^IpLuJkv%fEgO^jP@w(3P3h+9%%sR{QYQ^3KUa9MA2!|DD*K zeeX#3rujAhH*6Gm@#^J+_Y + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Image\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; + +/** + * @author Aleksey Razbakov + */ +final class ProviderPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + if (!$container->hasDefinition('ux.image.provider_registry')) { + return; + } + + $registryDefinition = $container->getDefinition('ux.image.provider_registry'); + $taggedServices = $container->findTaggedServiceIds('ux.image.provider'); + + foreach ($taggedServices as $id => $tags) { + $registryDefinition->addMethodCall('addProvider', [new Reference($id)]); + } + } +} diff --git a/src/Image/src/DependencyInjection/Configuration.php b/src/Image/src/DependencyInjection/Configuration.php new file mode 100644 index 00000000000..b766535e4f6 --- /dev/null +++ b/src/Image/src/DependencyInjection/Configuration.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Image\DependencyInjection; + +use Symfony\Component\Config\Definition\Builder\TreeBuilder; +use Symfony\Component\Config\Definition\ConfigurationInterface; + +/** + * @author Aleksey Razbakov + */ +final class Configuration implements ConfigurationInterface +{ + public function getConfigTreeBuilder(): TreeBuilder + { + $treeBuilder = new TreeBuilder('ux_image'); + $rootNode = $treeBuilder->getRootNode(); + + $rootNode + ->children() + ->scalarNode('provider') + ->info('Image provider to use') + ->isRequired() + ->end() + ->scalarNode('missing_image_placeholder') + ->info('Path to the image shown when source image is missing') + ->isRequired() + ->end() + ->arrayNode('breakpoints') + ->isRequired() + ->requiresAtLeastOneElement() + ->useAttributeAsKey('name') + ->prototype('integer') + ->min(1) + ->end() + ->end() + ->arrayNode('defaults') + ->children() + ->enumNode('format') + ->values(['webp', 'jpg', 'png', 'avif']) + ->end() + ->integerNode('quality') + ->min(1) + ->max(100) + ->end() + ->enumNode('loading') + ->values(['lazy', 'eager']) + ->end() + ->enumNode('fetchpriority') + ->values(['high', 'low', 'auto']) + ->end() + ->enumNode('fit') + ->values(['cover', 'contain', 'fill', 'inside', 'outside']) + ->end() + ->enumNode('placeholder') + ->values(['none', 'blur', 'dominant']) + ->end() + ->scalarNode('fallback') + ->defaultValue('lg') + ->info('Default breakpoint to use for fallback image') + ->end() + ->enumNode('fallback_format') + ->values(['auto', 'jpg', 'png', 'empty']) + ->defaultValue('auto') + ->info('Default format to use for fallback image') + ->end() + ->end() + ->end() + ->arrayNode('providers') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->useAttributeAsKey('name') + ->variablePrototype() + ->end() + ->end() + ->end() + ->arrayNode('presets') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('width') + ->end() + ->integerNode('height') + ->min(1) + ->end() + ->scalarNode('ratio')->end() + ->enumNode('fit') + ->values(['cover', 'contain', 'fill', 'inside', 'outside']) + ->end() + ->enumNode('loading') + ->values(['lazy', 'eager']) + ->end() + ->enumNode('fetchpriority') + ->values(['high', 'low', 'auto']) + ->end() + ->enumNode('placeholder') + ->values(['none', 'blur', 'dominant']) + ->end() + ->integerNode('quality') + ->min(1) + ->max(100) + ->end() + ->booleanNode('preload') + ->defaultFalse() + ->end() + ->end() + ->end() + ->end() + ->end() + ; + + return $treeBuilder; + } +} diff --git a/src/Image/src/DependencyInjection/ImageExtension.php b/src/Image/src/DependencyInjection/ImageExtension.php new file mode 100644 index 00000000000..9a8d1279f21 --- /dev/null +++ b/src/Image/src/DependencyInjection/ImageExtension.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Image\DependencyInjection; + +use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\Extension; +use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; +use Symfony\Component\Yaml\Yaml; +use Symfony\UX\Image\Provider\ProviderInterface; + +/** + * @author Aleksey Razbakov + */ +final class ImageExtension extends Extension +{ + private bool $loadDefaultConfig = true; + + public function setLoadDefaultConfig(bool $load): void + { + $this->loadDefaultConfig = $load; + } + + public function load(array $configs, ContainerBuilder $container): void + { + // Load services configuration + $loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../../config')); + $loader->load('services.yaml'); + + if ($this->loadDefaultConfig) { + // Load default configuration using Yaml component + $defaultConfigFile = __DIR__.'/../../config/ux_image.yaml'; + $defaultConfig = Yaml::parseFile($defaultConfigFile); + + // Merge default config with user configs + $configs = array_merge([$defaultConfig['ux_image']], $configs); + } + + $configuration = $this->getConfiguration($configs, $container); + $config = $this->processConfiguration($configuration, $configs); + + // Register the provider interface for autoconfiguration + $container->registerForAutoconfiguration(ProviderInterface::class) + ->addTag('ux.image.provider'); + + // Set parameters + $container->setParameter('ux_image.provider', $config['provider']); + $container->setParameter('ux_image.missing_image_placeholder', $config['missing_image_placeholder']); + $container->setParameter('ux_image.defaults', $config['defaults']); + $container->setParameter('ux_image.providers', $config['providers']); + $container->setParameter('ux_image.presets', $config['presets'] ?? []); + $container->setParameter('ux_image.breakpoints', $config['breakpoints']); + + // Configure providers + foreach ($config['providers'] as $name => $providerConfig) { + $providerId = \sprintf('ux.image.provider.%s', $name); + if ($container->hasDefinition($providerId)) { + $providerDef = $container->getDefinition($providerId); + $providerDef->addMethodCall('configure', [$providerConfig]); + } + } + } + + public function getAlias(): string + { + return 'ux_image'; + } +} diff --git a/src/Image/src/EventListener/PreloadInjectorSubscriber.php b/src/Image/src/EventListener/PreloadInjectorSubscriber.php new file mode 100644 index 00000000000..296340a3a79 --- /dev/null +++ b/src/Image/src/EventListener/PreloadInjectorSubscriber.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Image\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\UX\Image\Service\PreloadManager; + +/** + * Automatically injects image preload tags into HTML responses. + * + * @author Aleksey Razbakov + */ +final class PreloadInjectorSubscriber implements EventSubscriberInterface +{ + public function __construct( + private PreloadManager $preloadManager, + ) { + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::RESPONSE => ['onKernelResponse', -128], + ]; + } + + public function onKernelResponse(ResponseEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + $response = $event->getResponse(); + $contentType = $response->headers->get('Content-Type'); + + // Only process HTML responses + if (!$contentType || !str_contains($contentType, 'text/html')) { + return; + } + + $preloadTags = $this->preloadManager->getPreloadTags(); + + if (empty($preloadTags)) { + return; + } + + $content = $response->getContent(); + + // Inject preload tags before + if (false !== $headPos = stripos($content, '')) { + $content = substr_replace( + $content, + "\n" . $preloadTags . "\n", + $headPos, + 0 + ); + + $response->setContent($content); + } + } +} + diff --git a/src/Image/src/Exception/ProviderNotFoundException.php b/src/Image/src/Exception/ProviderNotFoundException.php new file mode 100644 index 00000000000..96e3a5e65f2 --- /dev/null +++ b/src/Image/src/Exception/ProviderNotFoundException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Image\Exception; + +/** + * @author Aleksey Razbakov + */ +final class ProviderNotFoundException extends \RuntimeException +{ +} diff --git a/src/Image/src/ImageBundle.php b/src/Image/src/ImageBundle.php new file mode 100644 index 00000000000..ea4af3b28a7 --- /dev/null +++ b/src/Image/src/ImageBundle.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Image; + +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\ExtensionInterface; +use Symfony\Component\HttpKernel\Bundle\Bundle; +use Symfony\UX\Image\DependencyInjection\Compiler\ProviderPass; +use Symfony\UX\Image\DependencyInjection\ImageExtension; + +/** + * @author Aleksey Razbakov + */ +final class ImageBundle extends Bundle +{ + public function build(ContainerBuilder $container): void + { + parent::build($container); + + $container->addCompilerPass(new ProviderPass()); + } + + public function getPath(): string + { + return \dirname(__DIR__); + } + + public function getContainerExtension(): ?ExtensionInterface + { + if (null === $this->extension) { + $this->extension = new ImageExtension(); + } + + return $this->extension; + } +} diff --git a/src/Image/src/Provider/CloudinaryProvider.php b/src/Image/src/Provider/CloudinaryProvider.php new file mode 100644 index 00000000000..d9671ef8ec6 --- /dev/null +++ b/src/Image/src/Provider/CloudinaryProvider.php @@ -0,0 +1,190 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Image\Provider; + +/** + * @author Aleksey Razbakov + */ +final class CloudinaryProvider implements ProviderInterface +{ + private string $baseUrl = ''; + private array $defaults = []; + + private const KEY_MAP = [ + 'width' => 'w', + 'height' => 'h', + 'format' => 'f', + 'quality' => 'q', + 'fit' => 'c', + 'focal' => 'g', + 'background' => 'b', + 'ratio' => 'ar', + 'roundCorner' => 'r', + 'gravity' => 'g', + 'rotate' => 'a', + 'effect' => 'e', + 'color' => 'co', + 'flags' => 'fl', + 'dpr' => 'dpr', + 'opacity' => 'o', + 'overlay' => 'l', + 'underlay' => 'u', + 'transformation' => 't', + 'zoom' => 'z', + 'colorSpace' => 'cs', + 'customFunc' => 'fn', + 'density' => 'dn', + 'aspectRatio' => 'ar', + 'blur' => 'e_blur', + ]; + + private const VALUE_MAP = [ + 'fit' => [ + 'fill' => 'fill', + 'inside' => 'pad', + 'outside' => 'lpad', + 'cover' => 'lfill', + 'contain' => 'scale', + 'minCover' => 'mfit', + 'minInside' => 'mpad', + 'thumbnail' => 'thumb', + 'cropping' => 'crop', + 'coverLimit' => 'limit', + ], + 'format' => [ + 'jpeg' => 'jpg', + ], + 'gravity' => [ + 'auto' => 'auto', + 'subject' => 'auto:subject', + 'face' => 'face', + 'sink' => 'sink', + 'faceCenter' => 'face:center', + 'multipleFaces' => 'faces', + 'multipleFacesCenter' => 'faces:center', + 'north' => 'north', + 'northEast' => 'north_east', + 'northWest' => 'north_west', + 'west' => 'west', + 'southWest' => 'south_west', + 'south' => 'south', + 'southEast' => 'south_east', + 'east' => 'east', + 'center' => 'center', + ], + ]; + + public function configure(array $config): void + { + $this->baseUrl = $config['base_url'] ?? ''; + $this->defaults = $config['defaults'] ?? []; + } + + public function getName(): string + { + return 'cloudinary'; + } + + public function getImage(string $src, array $modifiers): string + { + // Remove any leading slashes + $src = ltrim($src, '/'); + + // Check if the source is an external URL + if (str_starts_with($src, 'http://') || str_starts_with($src, 'https://')) { + if (str_contains($src, '/upload/')) { + // Extract the domain part up to /upload/ + $this->baseUrl = substr($src, 0, strpos($src, '/upload/') + 8); + // Extract the path after /upload/ + $src = substr($src, strpos($src, '/upload/') + 8); + } + } + + $transformations = []; + + // Merge defaults with provided modifiers + $modifiers = array_merge($this->defaults, $modifiers); + + // Define the order of transformations + $orderPriority = [ + 'width' => 1, + 'height' => 2, + 'quality' => 3, + 'format' => 4, + // Add other keys with their priority if needed + ]; + + // Process modifiers and store them with their priority + $prioritizedTransformations = []; + foreach ($modifiers as $key => $value) { + if (!isset(self::KEY_MAP[$key])) { + continue; + } + + $cloudinaryKey = self::KEY_MAP[$key]; + + // Handle special value mappings + if (isset(self::VALUE_MAP[$key])) { + if (isset(self::VALUE_MAP[$key][$value])) { + $value = self::VALUE_MAP[$key][$value]; + } + } + + // Rest of the switch case for special handling... + switch ($key) { + case 'background': + case 'color': + $value = $this->convertHexToRgb($value); + break; + case 'roundCorner': + if ('max' === $value) { + $value = 'max'; + } elseif (str_contains($value, ':')) { + $value = str_replace(':', '_', $value); + } + break; + case 'blur': + $cloudinaryKey = 'e'; + $value = 'blur:'.$value; + break; + } + + // Format the transformation + $transformation = str_contains($cloudinaryKey, '_') + ? $cloudinaryKey.':'.$value + : $cloudinaryKey.'_'.$value; + + // Store with priority if defined, otherwise use a high number + $priority = $orderPriority[$key] ?? 999; + $prioritizedTransformations[$priority] = $transformation; + } + + // Sort by priority and get final transformations + ksort($prioritizedTransformations); + $transformations = array_values($prioritizedTransformations); + + // Build the final URL + $transformationString = implode(',', $transformations); + + return \sprintf( + '%s/%s%s', + rtrim($this->baseUrl, '/'), + $transformationString ? $transformationString.'/' : '', + $src + ); + } + + private function convertHexToRgb(string $value): string + { + return str_starts_with($value, '#') ? 'rgb_'.ltrim($value, '#') : $value; + } +} diff --git a/src/Image/src/Provider/FastlyProvider.php b/src/Image/src/Provider/FastlyProvider.php new file mode 100644 index 00000000000..c29e0006618 --- /dev/null +++ b/src/Image/src/Provider/FastlyProvider.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Image\Provider; + +/** + * @author Aleksey Razbakov + */ +final class FastlyProvider implements ProviderInterface +{ + private string $baseUrl = ''; + private array $defaults = []; + + private const KEY_MAP = [ + 'width' => 'width', + 'height' => 'height', + 'format' => 'format', + 'quality' => 'quality', + 'fit' => 'fit', + 'background' => 'bg-color', + 'ratio' => 'aspect-ratio', + ]; + + private const VALUE_MAP = [ + 'fit' => [ + 'fill' => 'crop', + 'inside' => 'crop', + 'outside' => 'crop', + 'cover' => 'bounds', + 'contain' => 'bounds', + ], + ]; + + public function configure(array $config): void + { + $this->baseUrl = $config['base_url'] ?? ''; + $this->defaults = $config['defaults'] ?? []; + } + + public function getName(): string + { + return 'fastly'; + } + + public function getImage(string $src, array $modifiers): string + { + $src = ltrim($src, '/'); + + // Merge defaults with provided modifiers + $modifiers = array_merge($this->defaults, $modifiers); + + // Process modifiers + $transformations = []; + foreach ($modifiers as $key => $value) { + if (!isset(self::KEY_MAP[$key])) { + continue; + } + + $fastlyKey = self::KEY_MAP[$key]; + + // Handle special value mappings + if (isset(self::VALUE_MAP[$key]) && isset(self::VALUE_MAP[$key][$value])) { + $value = self::VALUE_MAP[$key][$value]; + } + + // Handle special cases + if ('background' === $key) { + $value = ltrim($value, '#'); + } elseif ('ratio' === $key && preg_match('/^(\d+):(\d+)$/', $value, $matches)) { + $value = $matches[1].'/'.$matches[2]; + } + + $transformations[] = $fastlyKey.'='.$value; + } + + // Build the final URL + $queryString = implode('&', $transformations); + + return \sprintf( + '%s/%s%s', + rtrim($this->baseUrl, '/'), + $src, + $queryString ? '?'.$queryString : '' + ); + } +} diff --git a/src/Image/src/Provider/LiipImagineProvider.php b/src/Image/src/Provider/LiipImagineProvider.php new file mode 100644 index 00000000000..1fb966ae487 --- /dev/null +++ b/src/Image/src/Provider/LiipImagineProvider.php @@ -0,0 +1,135 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Image\Provider; + +use Liip\ImagineBundle\Imagine\Cache\SignerInterface; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; + +/** + * @author Aleksey Razbakov + */ +final class LiipImagineProvider implements ProviderInterface +{ + private string $defaultFilter = 'default'; + private array $defaults = []; + + private UrlGeneratorInterface $urlGenerator; + private ?SignerInterface $signer; + + private const KEY_MAP = [ + 'width' => 'size', + 'height' => 'size', + 'quality' => 'quality', + 'fit' => 'mode', + 'background' => 'background', + 'format' => 'format', + 'ratio' => 'ratio', + ]; + + private const VALUE_MAP = [ + 'fit' => [ + 'fill' => 'outbound', + 'inside' => 'inset', + 'outside' => 'outbound', + 'cover' => 'outbound', + 'contain' => 'inset', + ], + 'format' => [ + 'jpeg' => 'jpg', + 'auto' => 'jpg', + ], + ]; + + public function __construct( + UrlGeneratorInterface $urlGenerator, + ?SignerInterface $signer = null, + ) { + $this->urlGenerator = $urlGenerator; + $this->signer = $signer; + } + + public function configure(array $config): void + { + $this->defaultFilter = $config['default_filter'] ?? 'default'; + $this->defaults = $config['defaults'] ?? []; + } + + public function getName(): string + { + return 'liip_imagine'; + } + + public function getImage(string $src, array $modifiers): string + { + $src = ltrim($src, '/'); + $filterName = $this->defaultFilter; + + return $this->generateSecureUrl($src, $filterName, $modifiers); + } + + private function generateSecureUrl(string $path, string $filter, array $modifiers): string + { + if (null === $this->signer) { + throw new \LogicException('LiipImagineBundle is not installed. Please install it first: composer require liip/imagine-bundle'); + } + + $runtimeConfig = $this->buildRuntimeConfig($modifiers); + $hash = $this->signer->sign($path, $runtimeConfig); + + return $this->urlGenerator->generate('liip_imagine_filter', [ + 'filter' => $filter, + 'path' => $path, + 'hash' => $hash, + 'filters' => $runtimeConfig, + ], UrlGeneratorInterface::ABSOLUTE_PATH); + } + + private function buildRuntimeConfig(array $modifiers): array + { + $runtimeConfig = []; + $modifiers = array_merge($this->defaults, $modifiers); + + foreach ($modifiers as $key => $value) { + if (!isset(self::KEY_MAP[$key])) { + continue; + } + + $liipKey = self::KEY_MAP[$key]; + + if ('width' === $key || 'height' === $key) { + $runtimeConfig['size'] = $runtimeConfig['size'] ?? [ + 'width' => null, + 'height' => null, + ]; + $runtimeConfig['size'][$key] = $value; + continue; + } + + // Handle value mappings + if (isset(self::VALUE_MAP[$key][$value])) { + $value = self::VALUE_MAP[$key][$value]; + } + + // Handle ratio parsing + if ('ratio' === $key && preg_match('/^(\d+):(\d+)$/', $value, $matches)) { + $value = [ + 'width' => (int) $matches[1], + 'height' => (int) $matches[2], + ]; + } + + $runtimeConfig[$liipKey] = $value; + } + + return $runtimeConfig; + } +} diff --git a/src/Image/src/Provider/PlaceholderProvider.php b/src/Image/src/Provider/PlaceholderProvider.php new file mode 100644 index 00000000000..00221e1875f --- /dev/null +++ b/src/Image/src/Provider/PlaceholderProvider.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Image\Provider; + +/** + * @author Aleksey Razbakov + */ +final class PlaceholderProvider implements ProviderInterface +{ + private array $defaults = []; + + private const BASE_URL = 'https://placehold.co'; + + private const KEY_MAP = [ + 'width' => 'width', + 'height' => 'height', + 'background' => 'background', + 'text' => 'text', + 'text_color' => 'textColor', + 'ratio' => 'ratio', + ]; + + public function configure(array $config): void + { + $this->defaults = $config['defaults'] ?? []; + } + + public function getName(): string + { + return 'placeholder'; + } + + public function getImage(string $src, array $modifiers): string + { + $params = $this->processModifiers($modifiers); + + return $this->buildUrl($params); + } + + private function processModifiers(array $modifiers): array + { + $params = []; + + // Process each modifier using KEY_MAP + foreach ($modifiers as $key => $value) { + if (!isset(self::KEY_MAP[$key])) { + continue; + } + + $placeholderKey = self::KEY_MAP[$key]; + $params[$placeholderKey] = $value; + } + + // Apply defaults for missing parameters + foreach ($this->defaults as $key => $defaultValue) { + if (!isset($params[self::KEY_MAP[$key]])) { + $params[self::KEY_MAP[$key]] = $defaultValue; + } + } + + // Handle ratio if specified + if (isset($params['ratio']) && null !== $params['ratio']) { + if (preg_match('/^(\d+):(\d+)$/', $params['ratio'], $matches)) { + $ratioWidth = (int) $matches[1]; + $ratioHeight = (int) $matches[2]; + if (!isset($params['height']) || null === $params['height']) { + $params['height'] = (int) ($params['width'] * $ratioHeight / $ratioWidth); + } + } + } + + // Set height to width if still not specified + if (!isset($params['height']) || null === $params['height']) { + $params['height'] = $params['width']; + } + + // Set default text if not specified + if (!isset($params['text']) || null === $params['text']) { + $params['text'] = \sprintf('%dx%d', $params['width'], $params['height']); + } + + // Ensure background and textColor are set with defaults if needed + $params['background'] = isset($params['background']) ? ltrim($params['background'], '#') : '000000'; + $params['textColor'] = isset($params['textColor']) ? ltrim($params['textColor'], '#') : 'FFFFFF'; + + return $params; + } + + private function buildUrl(array $params): string + { + return \sprintf( + '%s/%dx%d/%s/%s?text=%s', + self::BASE_URL, + $params['width'], + $params['height'], + $params['background'], + $params['textColor'], + urlencode($params['text']) + ); + } +} diff --git a/src/Image/src/Provider/ProviderInterface.php b/src/Image/src/Provider/ProviderInterface.php new file mode 100644 index 00000000000..308ebb7c792 --- /dev/null +++ b/src/Image/src/Provider/ProviderInterface.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Image\Provider; + +/** + * @author Aleksey Razbakov + */ +interface ProviderInterface +{ + /** + * Get the provider name. + */ + public function getName(): string; + + /** + * Configure the provider with the given configuration. + */ + public function configure(array $config): void; + + /** + * Generates the URL for the image with the specified options. + * + * This method takes the source image path and an array of transformation options, + * and returns the URL of the transformed image. The transformation options can include + * parameters such as image size, format, quality, and other modifications defined in the + * image component or as a preset. + * + * @param string $src The path to the source image + * @param array $modifiers list of image modifiers that are defined in the image component + * or as a preset + * + * @return string Absolute or relative url of optimized image + */ + public function getImage(string $src, array $modifiers): string; +} diff --git a/src/Image/src/Provider/ProviderRegistry.php b/src/Image/src/Provider/ProviderRegistry.php new file mode 100644 index 00000000000..faf185375da --- /dev/null +++ b/src/Image/src/Provider/ProviderRegistry.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Image\Provider; + +use Symfony\UX\Image\Exception\ProviderNotFoundException; + +/** + * @author Aleksey Razbakov + */ +final class ProviderRegistry +{ + /** + * @var array + */ + private array $providers = []; + + private ?string $defaultProvider = null; + + public function __construct(?string $defaultProvider = null) + { + if ($defaultProvider) { + $this->setDefaultProvider($defaultProvider); + } + } + + public function setDefaultProvider(string $providerName): void + { + $this->defaultProvider = $providerName; + } + + public function addProvider(ProviderInterface $provider): void + { + $this->providers[$provider->getName()] = $provider; + } + + public function getProvider(?string $name = null): ProviderInterface + { + $provider = $name ?? $this->defaultProvider; + + if (null === $provider) { + throw new ProviderNotFoundException('No provider specified and no default provider configured.'); + } + + if (empty($this->providers)) { + throw new ProviderNotFoundException('No providers configured.'); + } + + if (!isset($this->providers[$provider])) { + throw new ProviderNotFoundException(\sprintf('Provider "%s" not found. Available providers: %s', $provider, implode(', ', array_keys($this->providers)))); + } + + return $this->providers[$provider]; + } + + /** + * @return array + */ + public function getProviders(): array + { + return $this->providers; + } +} diff --git a/src/Image/src/Service/PreloadManager.php b/src/Image/src/Service/PreloadManager.php new file mode 100644 index 00000000000..fdf78c986ec --- /dev/null +++ b/src/Image/src/Service/PreloadManager.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Image\Service; + +/** + * @author Aleksey Razbakov + */ +final class PreloadManager +{ + private array $preloadImages = []; + + public function addPreloadImage(string $src, array $options = []): void + { + $this->preloadImages[] = [ + 'src' => $src, + 'options' => $options, + ]; + } + + public function getPreloadTags(): string + { + if (empty($this->preloadImages)) { + return ''; + } + + $tags = []; + + foreach ($this->preloadImages as $image) { + $src = $image['src']; + $options = $image['options']; + + // If we have srcset/sizes, create appropriate preload tag + if (!empty($options['srcset'])) { + $tags[] = \sprintf( + '', + $src, + $options['srcset'], + !empty($options['sizes']) ? ' imagesizes="' . $options['sizes'] . '"' : '' + ); + } else { + // Simple preload for single image + $tags[] = \sprintf( + '', + $src + ); + } + } + + return implode("\n", $tags); + } + + public function reset(): void + { + $this->preloadImages = []; + } +} diff --git a/src/Image/src/Service/Transformer.php b/src/Image/src/Service/Transformer.php new file mode 100644 index 00000000000..4903fef676a --- /dev/null +++ b/src/Image/src/Service/Transformer.php @@ -0,0 +1,363 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Image\Service; + +/** + * @author Aleksey Razbakov + */ +final class Transformer +{ + private const BREAKPOINT_ORDER = ['default', 'sm', 'md', 'lg', 'xl', '2xl']; + private array $breakpoints; + + public function __construct(array $breakpoints = [ + 'sm' => 640, + 'md' => 768, + 'lg' => 1024, + 'xl' => 1280, + '2xl' => 1536, + ]) + { + $this->breakpoints = $breakpoints; + } + + public function parseWidth(string $width): array + { + $parts = preg_split('/\s+/', trim($width)); + $widths = []; + $smallestBreakpoint = null; + $firstVwAfterFixed = null; + $firstFixedAfterVw = null; + + // First pass: collect explicit values and find transitions + foreach ($parts as $part) { + if (str_contains($part, ':')) { + [$breakpoint, $value] = explode(':', $part); + $normalized = $this->normalizeWidthValue($value, $breakpoint); + $widths[$breakpoint] = $normalized; + + // Track transitions + if ('0' !== $normalized['vw'] && isset($widths['default']) && '0' === $widths['default']['vw']) { + $firstVwAfterFixed = $breakpoint; + } + if ('0' === $normalized['vw'] && isset($widths['default']) && '0' !== $widths['default']['vw']) { + $firstFixedAfterVw = $breakpoint; + } + + // Track the smallest breakpoint + if ( + !$smallestBreakpoint + || array_search($breakpoint, self::BREAKPOINT_ORDER) < array_search($smallestBreakpoint, self::BREAKPOINT_ORDER) + ) { + $smallestBreakpoint = $breakpoint; + } + } else { + $widths['default'] = $this->normalizeWidthValue($part, 'default'); + } + } + + // If no default width is set but we have breakpoints, use the smallest breakpoint as default + if (!isset($widths['default']) && $smallestBreakpoint) { + $widths['default'] = $widths[$smallestBreakpoint]; + } + + // Handle viewport width calculations and transitions + if (isset($widths['default']) && '0' !== $widths['default']['vw']) { + $vwPercentage = (int) $widths['default']['vw']; + + // Pre-calculate all viewport widths up to fixed width transition + foreach (self::BREAKPOINT_ORDER as $breakpoint) { + if ($firstFixedAfterVw && $breakpoint === $firstFixedAfterVw) { + // Found fixed width transition point, propagate fixed width to remaining breakpoints + $fixedValue = $widths[$firstFixedAfterVw]; + foreach (self::BREAKPOINT_ORDER as $nextBreakpoint) { + if ( + array_search($nextBreakpoint, self::BREAKPOINT_ORDER) >= array_search($breakpoint, self::BREAKPOINT_ORDER) + && !isset($widths[$nextBreakpoint]) + ) { + $widths[$nextBreakpoint] = $fixedValue; + } + } + break; + } + + if (!isset($widths[$breakpoint])) { + $breakpointWidth = 'default' === $breakpoint ? + $this->breakpoints['sm'] : + $this->breakpoints[$breakpoint]; + + $pixelWidth = (int) ($breakpointWidth * ($vwPercentage / 100)); + + $widths[$breakpoint] = [ + 'value' => $pixelWidth, + 'vw' => (string) $vwPercentage, + ]; + } + } + } + // Handle fixed width cases + elseif (isset($widths['default']) && '0' === $widths['default']['vw']) { + $lastValue = $widths['default']; + + // Propagate fixed width to all breakpoints + foreach (self::BREAKPOINT_ORDER as $breakpoint) { + if ($firstVwAfterFixed && $breakpoint === $firstVwAfterFixed) { + // Found viewport width transition point + $vwPercentage = (int) $widths[$breakpoint]['vw']; + + // Calculate viewport widths for remaining breakpoints + foreach (self::BREAKPOINT_ORDER as $vwBreakpoint) { + if ( + array_search($vwBreakpoint, self::BREAKPOINT_ORDER) >= array_search($breakpoint, self::BREAKPOINT_ORDER) + && !isset($widths[$vwBreakpoint]) + ) { + $breakpointWidth = $this->breakpoints[$vwBreakpoint]; + $pixelWidth = (int) ($breakpointWidth * ($vwPercentage / 100)); + + $widths[$vwBreakpoint] = [ + 'value' => $pixelWidth, + 'vw' => (string) $vwPercentage, + ]; + } + } + break; + } + + if (!isset($widths[$breakpoint])) { + $widths[$breakpoint] = $lastValue; + } else { + $lastValue = $widths[$breakpoint]; + } + } + } + + return $widths; + } + + private function normalizeWidthValue(string $value, string $breakpoint = 'default'): array + { + $isVw = str_ends_with($value, 'vw'); + $numericValue = (int) preg_replace('/[^0-9]/', '', $value); + + if ($isVw) { + $breakpointWidth = 'default' === $breakpoint ? + $this->breakpoints['sm'] : + $this->breakpoints[$breakpoint]; + + $pixelWidth = (int) ($breakpointWidth * ($numericValue / 100)); + + return [ + 'value' => $pixelWidth, + 'vw' => (string) $numericValue, + ]; + } + + return [ + 'value' => $numericValue, + 'vw' => '0', + ]; + } + + public function getSizes(array $widths): string + { + // Special case: if it's just a viewport width with no breakpoints + // and all breakpoints have the same vw value + if (isset($widths['default']) && '100' === $widths['default']['vw']) { + $allSame = true; + foreach ($widths as $key => $width) { + if ('default' !== $key && isset($width['vw']) && '100' !== $width['vw']) { + $allSame = false; + break; + } + } + if ($allSame) { + return '100vw'; + } + } + + $sizes = []; + $breakpointKeys = array_keys($this->breakpoints); + + // Find the largest explicit value for default size (no media query) + $largestValue = null; + foreach (array_reverse($breakpointKeys) as $key) { + if (isset($widths[$key])) { + $largestValue = $widths[$key]; + break; + } + } + + // Process breakpoints from largest to smallest + $sizeVariants = []; + + foreach (array_reverse($breakpointKeys) as $i => $key) { + if (isset($widths[$key])) { + // Find the next breakpoint that has a value + $nextValue = null; + for ($j = $i + 1; $j < \count($breakpointKeys); ++$j) { + $nextKey = array_reverse($breakpointKeys)[$j]; + if (isset($widths[$nextKey])) { + $nextValue = $widths[$nextKey]; + break; + } + } + + // If no next breakpoint value found and we have a default value + if (!$nextValue && isset($widths['default'])) { + $nextValue = $widths['default']; + } + + // Add current value to size variants + $sizeVariants[] = [ + 'size' => $this->formatSizeValue($widths[$key]), + 'screenMaxWidth' => $this->breakpoints[$key], + 'media' => \sprintf('(max-width: %dpx)', $this->breakpoints[$key]), + ]; + + // If next value is different, add it at this breakpoint + if ($nextValue && !$this->isSameValue($widths[$key], $nextValue)) { + $sizeVariants[] = [ + 'size' => $this->formatSizeValue($nextValue), + 'screenMaxWidth' => $this->breakpoints[$key], + 'media' => \sprintf('(max-width: %dpx)', $this->breakpoints[$key]), + ]; + } + } + } + + // Sort variants by screen width (largest to smallest) + usort($sizeVariants, fn($a, $b) => $b['screenMaxWidth'] - $a['screenMaxWidth']); + + // Add size variants to sizes array (media queries come first) + foreach ($sizeVariants as $variant) { + $sizes[] = $variant['media'] . ' ' . $variant['size']; + } + + // Add default value if it exists and differs from sm breakpoint + if ( + isset($widths['default']) + && (!isset($widths['sm']) || !$this->isSameValue($widths['default'], $widths['sm'])) + ) { + $sizes[] = \sprintf( + '(max-width: %dpx) %s', + $this->breakpoints['sm'], + $this->formatSizeValue($widths['default']) + ); + } + + // Add the largest value as the default size at the END (no media query) + // This is the fallback size that browsers use when no media queries match + if ($largestValue) { + $sizes[] = $this->formatSizeValue($largestValue); + } + + return implode(', ', array_unique($sizes)); + } + + public function getSrcset(string $src, array $widths, callable $imageCallback): string + { + $srcset = []; + $seenWidths = []; + + foreach ($widths as $width) { + // Only include positive widths and deduplicate by width value + if ($width['value'] > 0 && !isset($seenWidths[$width['value']])) { + $srcset[] = \sprintf( + '%s %sw', + $imageCallback(['width' => $width['value']]), + $width['value'] + ); + $seenWidths[$width['value']] = true; + } + } + + return implode(', ', $srcset); + } + + private function isSameValue(array $value1, array $value2): bool + { + return $value1['value'] === $value2['value'] && $value1['vw'] === $value2['vw']; + } + + private function formatSizeValue(array $width): string + { + return '0' !== $width['vw'] + ? $width['vw'] . 'vw' + : $width['value'] . 'px'; + } + + public function getInitialWidth(array $widths, string $pattern): int + { + if (preg_match('/^\d+vw/', $pattern)) { + // If pattern starts with viewport width + $smallestWidth = \PHP_INT_MAX; + foreach ($widths as $width) { + if ($width['value'] < $smallestWidth && '0' !== $width['vw']) { + $smallestWidth = $width['value']; + } + } + + return $smallestWidth; + } + + // For fixed widths or patterns starting with fixed width + return $widths['default']['value']; + } + + // Add new method to handle density-based width calculations + public function getDensityBasedWidths(int $baseWidth, string $densities): array + { + $densityMultipliers = array_map( + fn($d) => (float) str_replace('x', '', trim($d)), + explode(' ', $densities) + ); + + $widths = []; + foreach ($densityMultipliers as $multiplier) { + $widths[] = (int) ($baseWidth * $multiplier); + } + + sort($widths); + + return $widths; + } + + public function getBreakpoints(): array + { + return $this->breakpoints; + } + + public function parseRatio(?string $ratio): array + { + if (!$ratio) { + return []; + } + + $parts = preg_split('/\s+/', trim($ratio)); + $ratios = []; + + foreach ($parts as $part) { + // Check if it's a breakpoint-specific ratio (e.g., "sm:1:1" or "md:16:9") + // Match pattern: breakpoint:(number):(number) + if (preg_match('/^(' . implode('|', array_keys($this->breakpoints)) . '):(\d+:\d+)$/', $part, $matches)) { + $breakpoint = $matches[1]; + $ratioValue = $matches[2]; + $ratios[$breakpoint] = $ratioValue; + } elseif (preg_match('/^\d+:\d+$/', $part)) { + // It's a default ratio value (e.g., "16:9") + $ratios['default'] = $part; + } + } + + return $ratios; + } +} diff --git a/src/Image/src/Twig/Components/Img.php b/src/Image/src/Twig/Components/Img.php new file mode 100644 index 00000000000..0912384f831 --- /dev/null +++ b/src/Image/src/Twig/Components/Img.php @@ -0,0 +1,387 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Image\Twig\Components; + +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\UX\Image\Provider\ProviderRegistry; +use Symfony\UX\Image\Service\PreloadManager; +use Symfony\UX\Image\Service\Transformer; +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; +use Symfony\UX\TwigComponent\Attribute\PreMount; + +/** + * @author Aleksey Razbakov + */ +#[AsTwigComponent('img', template: '@Image/components/img.html.twig')] +class Img +{ + public const EMPTY_GIF = 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='; + + public string $src; + public ?string $alt = null; + public ?string $width = null; + public ?int $widthComputed = null; + public ?int $height = null; + public ?string $ratio = null; + public ?string $fit = null; + public ?string $focal = null; + public ?string $quality = null; + public ?string $format = null; + public ?string $loading = null; + public ?string $fetchpriority = null; + public ?bool $preload = null; + public ?string $background = null; + public ?string $fallback = null; + public ?string $fallbackFormat = null; + public ?string $class = null; + public ?string $preset = null; + public ?string $placeholder = null; + public ?string $placeholderClass = null; + public ?string $sizes = null; + public ?string $srcset = null; + public ?string $densities = null; + public ?array $modifiers = null; + public ?string $provider = null; + public ?string $fallbackImage = null; + + protected array $widths = []; + + public function __construct( + protected ParameterBagInterface $params, + protected ProviderRegistry $providerRegistry, + protected Transformer $transformer, + protected PreloadManager $preloadManager, + ) { + $this->params = $params; + $defaults = $this->params->get('ux_image.defaults'); + $this->fallback = $defaults['fallback']; + $this->fallbackFormat = $defaults['fallback_format']; + } + + #[PreMount] + public function preMount(array $data): array + { + $resolver = new OptionsResolver(); + + $resolver + ->setDefined([ + 'alt', + 'width', + 'height', + 'ratio', + 'fit', + 'focal', + 'quality', + 'loading', + 'fetchpriority', + 'preload', + 'background', + 'fallback', + 'fallbackFormat', + 'fallback-format', + 'class', + 'preset', + 'placeholder', + 'placeholderClass', + 'placeholder-class', + 'srcset', + 'id', + 'referrerpolicy', + 'sizes', + 'style', + 'title', + 'crossorigin', + 'decoding', + 'format', + 'densities', + 'modifiers', + 'provider', + ]); + + // Allow any data-* and aria-* attributes + $resolver->setDefaults([]); + foreach ($data as $key => $value) { + if (str_starts_with($key, 'data-') || str_starts_with($key, 'aria-')) { + $resolver->setDefined($key); + $resolver->setAllowedTypes($key, ['string', 'null']); + } + } + + $resolver->setRequired('src'); + + $resolver->setAllowedTypes('src', 'string'); + $resolver->setAllowedTypes('alt', ['string', 'null']); + $resolver->setAllowedTypes('width', ['string', 'int', 'null']); + $resolver->setAllowedTypes('height', ['int', 'null']); + $resolver->setAllowedTypes('ratio', ['string', 'null']); + $resolver->setAllowedTypes('fit', ['string', 'null']); + $resolver->setAllowedTypes('focal', ['string', 'null']); + $resolver->setAllowedTypes('quality', ['string', 'null']); + $resolver->setAllowedTypes('loading', ['string', 'null']); + $resolver->setAllowedTypes('fetchpriority', ['string', 'null']); + $resolver->setAllowedTypes('preload', ['bool', 'null']); + $resolver->setAllowedTypes('background', ['string', 'null']); + $resolver->setAllowedTypes('fallback', ['string', 'null']); + $resolver->setAllowedTypes('fallback-format', ['string', 'null']); + $resolver->setAllowedTypes('class', ['string', 'null']); + $resolver->setAllowedTypes('preset', ['string', 'null']); + $resolver->setAllowedTypes('placeholder', ['string', 'null']); + $resolver->setAllowedTypes('placeholder-class', ['string', 'null']); + $resolver->setAllowedTypes('sizes', ['string', 'null']); + $resolver->setAllowedTypes('id', ['string', 'null']); + $resolver->setAllowedTypes('referrerpolicy', ['string', 'null']); + $resolver->setAllowedTypes('style', ['string', 'null']); + $resolver->setAllowedTypes('title', ['string', 'null']); + $resolver->setAllowedTypes('crossorigin', ['string', 'null']); + $resolver->setAllowedTypes('decoding', ['string', 'null']); + $resolver->setAllowedTypes('densities', ['string', 'null']); + $resolver->setAllowedTypes('modifiers', ['array', 'null']); + $resolver->setAllowedTypes('provider', ['string', 'null']); + + if (isset($data['preset'])) { + $presetName = $data['preset']; + $presets = $this->params->get('ux_image.presets'); + + if (isset($presets[$presetName])) { + $data = array_merge($presets[$presetName], $data); + } + } + + // Normalize width value but preserve original format + if (isset($data['width'])) { + if (is_numeric($data['width'])) { + $data['width'] = (string) $data['width']; + } + } + + if (isset($data['fallback-format'])) { + $data['fallbackFormat'] = $data['fallback-format']; + unset($data['fallback-format']); + } + + return $resolver->resolve($data) + $data; + } + + public function mount( + string $src, + $width = null, + ?bool $preload = null, + ?string $format = null, + ?string $quality = null, + ?string $fit = null, + ?string $focal = null, + ?string $fallback = null, + ?string $background = null, + ?string $ratio = null, + ?string $densities = null, + ?array $modifiers = null, + ?string $provider = null, + ?string $fallbackFormat = null, + ): void { + if (empty($src)) { + throw new \InvalidArgumentException('Image src cannot be empty'); + } + + $this->src = $src; + $this->width = $width; + $this->format = $format; + $this->quality = $quality; + $this->fit = $fit; + $this->focal = $focal; + $this->fallback = $fallback; + $this->background = $background; + $this->ratio = $ratio; + $this->densities = $densities; + $this->modifiers = $modifiers; + $this->provider = $provider; + + if (null !== $fallbackFormat) { + $this->fallbackFormat = $fallbackFormat; + } + + if (null !== $preload) { + $this->preload = $preload; + } + + if ($this->width) { + // Get sizes from transformer + $this->widths = $this->transformer->parseWidth($this->width); + + // Use new transformer method to determine initial width + $this->widthComputed = $this->transformer->getInitialWidth($this->widths, $this->width); + + // For the main src, use the specified format + $this->fallbackImage = $this->getImage(['width' => $this->widthComputed], false); + + // For srcset images, also use specified format + if ($this->densities) { + if (!str_contains($this->width, 'vw') && !str_contains($this->width, ':')) { + // For fixed widths, get density-based widths + $widthsForSrcset = $this->transformer->getDensityBasedWidths($this->widthComputed, $this->densities); + + // Build srcset manually for fixed widths - don't apply fallback for srcset + $srcsetParts = []; + foreach ($widthsForSrcset as $w) { + $srcsetParts[] = $this->getImage(['width' => $w], false).' '.$w.'w'; + } + $this->srcset = implode(', ', $srcsetParts); + } else { + // For responsive widths, merge with density-based widths + $densityWidths = $this->transformer->getDensityBasedWidths($this->widthComputed, $this->densities); + $this->widths = array_unique(array_merge($this->widths, $densityWidths)); + sort($this->widths); + + // Generate srcset with all widths + $this->srcset = $this->transformer->getSrcset( + $this->src, + $this->widths, + fn ($modifiers) => $this->getImage($modifiers, false) + ); + } + } + // Generate srcset and sizes for responsive widths or breakpoint patterns + elseif (str_contains($this->width, 'vw') || str_contains($this->width, ':')) { + $this->srcset = $this->transformer->getSrcset( + $this->src, + $this->widths, + fn ($modifiers) => $this->getImage($modifiers, false) + ); + $this->sizes = $this->transformer->getSizes($this->widths); + } + } else { + $this->fallbackImage = $this->getImage([], true); + } + + if ($this->preload) { + $this->preloadManager->addPreloadImage($this->fallbackImage, [ + 'srcset' => $this->srcset, + 'sizes' => $this->sizes, + ]); + } + } + + protected function getImage(array $modifiers = [], bool $applyFallback = false): string + { + // Add custom modifiers if they exist + if ($this->modifiers) { + $modifiers = array_merge($modifiers, $this->modifiers); + } + + // First apply the explicitly specified format if it exists + if ($this->format && !$applyFallback) { + $modifiers['format'] = $this->format; + } + // Then handle fallback formats if we're in fallback mode + elseif ($applyFallback) { + if ('empty' === $this->fallbackFormat) { + return self::EMPTY_GIF; + } elseif ('auto' === $this->fallbackFormat) { + // Auto fallback logic based on original image format + $extension = $this->getImageExtension(); + $modifiers['format'] = \in_array($extension, ['png', 'webp', 'gif']) ? 'png' : 'jpg'; + } elseif ($this->fallbackFormat) { + $modifiers['format'] = $this->fallbackFormat; + } elseif ($this->fallback) { + // If fallback is specified, use that format + $modifiers['format'] = $this->fallback; + } + } + + // Add other modifiers + if ($this->quality) { + $modifiers['quality'] = $this->quality; + } + + if ($this->fit) { + $modifiers['fit'] = $this->fit; + } + + if ($this->focal) { + $modifiers['focal'] = $this->focal; + } + + if ($this->background) { + $modifiers['background'] = $this->background; + } + + if ($this->ratio) { + $modifiers['ratio'] = $this->ratio; + } + + if (isset($modifiers['width'])) { + $modifiers['width'] = (int) $modifiers['width']; + } + + return $this->providerRegistry->getProvider($this->provider)->getImage($this->src, $modifiers); + } + + protected function getImageExtension(): string + { + return strtolower(pathinfo($this->src, \PATHINFO_EXTENSION)); + } + + public function getSrc(): string + { + if (str_contains($this->width, 'vw')) { + $breakpoints = $this->transformer->getBreakpoints(); + + return $this->getImage(['width' => $breakpoints[$this->fallback]], true); + } + + $widths = $this->transformer->parseWidth($this->width); + $width = $widths['default'] ?? array_shift($widths); + + return $this->getImage(['width' => $width['value']], true); + } + + public function getSrcComputed(): string + { + // For empty fallback, return empty GIF + if ('empty' === $this->fallbackFormat) { + return self::EMPTY_GIF; + } + + // For other cases, return the fallback image with fallback format + if ($this->width) { + $getFallback = str_contains($this->width, 'w') ? true : false; + + return $this->getImage(['width' => $this->widthComputed], $getFallback); + } + + return $this->getImage([], false); + } + + /** + * Get width as HTML attribute (only if it's a simple numeric value). + */ + public function getHtmlWidth(): ?string + { + if ($this->width && preg_match('/^\d+$/', $this->width)) { + return $this->width; + } + + return null; + } + + /** + * Get height as HTML attribute (only if it's a simple numeric value). + */ + public function getHtmlHeight(): ?string + { + if ($this->height && preg_match('/^\d+$/', (string) $this->height)) { + return (string) $this->height; + } + + return null; + } +} diff --git a/src/Image/src/Twig/Components/Picture.php b/src/Image/src/Twig/Components/Picture.php new file mode 100644 index 00000000000..40074a490c1 --- /dev/null +++ b/src/Image/src/Twig/Components/Picture.php @@ -0,0 +1,165 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Image\Twig\Components; + +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; + +/** + * @author Aleksey Razbakov + */ +#[AsTwigComponent('picture', template: '@Image/components/picture.html.twig')] +final class Picture extends Img +{ + protected function getImage(array $modifiers = [], bool $applyFallback = false): string + { + // For Picture component, don't automatically add $this->ratio + // as it might be breakpoint-specific and passed via modifiers + // Store original ratio and temporarily clear it + $originalRatio = $this->ratio; + $this->ratio = null; + + $result = parent::getImage($modifiers, $applyFallback); + + // Restore original ratio + $this->ratio = $originalRatio; + + return $result; + } + + public function getBreakpoints(): array + { + if (!$this->width) { + return []; + } + + // Parse ratios if they exist and cascade them + $parsedRatios = $this->ratio ? $this->transformer->parseRatio($this->ratio) : []; + $cascadedRatios = $this->cascadeRatios($parsedRatios); + $hasArtDirection = !empty($cascadedRatios) && count(array_unique($cascadedRatios)) > 1; + + // Handle viewport width units + if (str_contains($this->width, 'vw')) { + $breakpoints = []; + $parsedWidths = $this->transformer->parseWidth($this->width); + $sizesAttribute = $this->transformer->getSizes($parsedWidths); + $configuredBreakpoints = $this->transformer->getBreakpoints(); + $breakpointKeys = array_keys($configuredBreakpoints); + $index = 0; + + foreach ($configuredBreakpoints as $breakpoint => $size) { + $modifiers = ['width' => $size]; + + // Apply cascaded ratio for this breakpoint + if (isset($cascadedRatios[$breakpoint])) { + $modifiers['ratio'] = $cascadedRatios[$breakpoint]; + } + + // For art direction, use exclusive media query ranges + // This ensures the browser picks the right aspect ratio + if ($hasArtDirection) { + // For the last (largest) breakpoint, use only min-width + if ($index === count($breakpointKeys) - 1) { + $breakpoints[] = [ + 'media' => "(min-width: {$size}px)", + 'srcset' => $this->getImage($modifiers, false) . " {$size}w", + 'sizes' => $sizesAttribute, + ]; + } else { + // Create exclusive range: min-width to just below next breakpoint + $nextSize = $configuredBreakpoints[$breakpointKeys[$index + 1]]; + $breakpoints[] = [ + 'media' => "(min-width: {$size}px) and (max-width: " . ($nextSize - 1) . "px)", + 'srcset' => $this->getImage($modifiers, false) . " {$size}w", + 'sizes' => $sizesAttribute, + ]; + } + } else { + // Without art direction, use simple min-width + $breakpoints[] = [ + 'media' => "(min-width: {$size}px)", + 'srcset' => $this->getImage($modifiers, false) . " {$size}w", + 'sizes' => $sizesAttribute, + ]; + } + + ++$index; + } + + return $breakpoints; + } + + // Handle regular breakpoints + if (!str_contains($this->width, ':')) { + return []; + } + + $breakpoints = []; + $widths = $this->transformer->parseWidth($this->width); + $configuredBreakpoints = $this->transformer->getBreakpoints(); + + foreach ($widths as $breakpoint => $width) { + if ('default' === $breakpoint) { + continue; + } + + if (isset($configuredBreakpoints[$breakpoint])) { + $modifiers = ['width' => $width['value']]; + + // Apply cascaded ratio for this breakpoint + if (isset($cascadedRatios[$breakpoint])) { + $modifiers['ratio'] = $cascadedRatios[$breakpoint]; + } + + $breakpoints[] = [ + 'media' => "(max-width: {$configuredBreakpoints[$breakpoint]}px)", + 'srcset' => $this->getImage($modifiers, false), + ]; + } + } + + return $breakpoints; + } + + private function cascadeRatios(array $parsedRatios): array + { + if (empty($parsedRatios)) { + return []; + } + + $breakpointOrder = ['sm', 'md', 'lg', 'xl', '2xl']; + $cascaded = []; + $currentRatio = $parsedRatios['default'] ?? null; + + foreach ($breakpointOrder as $breakpoint) { + // If this breakpoint has a specific ratio, use it and update current + if (isset($parsedRatios[$breakpoint])) { + $currentRatio = $parsedRatios[$breakpoint]; + } + + // Apply the current ratio to this breakpoint + if ($currentRatio) { + $cascaded[$breakpoint] = $currentRatio; + } + } + + return $cascaded; + } + + public function getSizes(): ?string + { + if (str_contains($this->width, 'vw')) { + return $this->width; + } + + return null; + } +} diff --git a/src/Image/templates/components/img.html.twig b/src/Image/templates/components/img.html.twig new file mode 100644 index 00000000000..b8ef6401b96 --- /dev/null +++ b/src/Image/templates/components/img.html.twig @@ -0,0 +1,12 @@ + diff --git a/src/Image/templates/components/picture.html.twig b/src/Image/templates/components/picture.html.twig new file mode 100644 index 00000000000..c39f0f589f7 --- /dev/null +++ b/src/Image/templates/components/picture.html.twig @@ -0,0 +1,20 @@ + + {% for breakpoint in this.breakpoints %} + + {% endfor %} + + + diff --git a/src/Image/tests/DependencyInjection/ConfigurationTest.php b/src/Image/tests/DependencyInjection/ConfigurationTest.php new file mode 100644 index 00000000000..86ee9d81c0e --- /dev/null +++ b/src/Image/tests/DependencyInjection/ConfigurationTest.php @@ -0,0 +1,173 @@ +configuration = new Configuration(); + $this->processor = new Processor(); + } + + public function testDefaultValues(): void + { + $config = [ + 'provider' => 'liip_imagine', + 'missing_image_placeholder' => '/path/to/404.jpg', + 'breakpoints' => ['sm' => 640], + 'defaults' => [ + 'format' => 'webp', + 'quality' => 80, + 'loading' => 'lazy', + 'fetchpriority' => 'low', + 'fit' => 'cover', + 'placeholder' => 'none', + ], + ]; + + $processedConfig = $this->processor->processConfiguration( + $this->configuration, + [$config] + ); + + $this->assertEquals('liip_imagine', $processedConfig['provider']); + $this->assertEquals('/path/to/404.jpg', $processedConfig['missing_image_placeholder']); + $this->assertEquals(['sm' => 640], $processedConfig['breakpoints']); + } + + public function testRequiredValues(): void + { + $this->expectException(InvalidConfigurationException::class); + + $config = []; + $this->processor->processConfiguration($this->configuration, [$config]); + } + + public function testInvalidFormat(): void + { + $this->expectException(InvalidConfigurationException::class); + + $config = [ + 'provider' => 'liip_imagine', + 'missing_image_placeholder' => '/path/to/404.jpg', + 'breakpoints' => ['sm' => 640], + 'defaults' => [ + 'format' => 'invalid', // Invalid format + 'quality' => 80, + 'loading' => 'lazy', + 'fetchpriority' => 'low', + 'fit' => 'cover', + 'placeholder' => 'none', + ], + ]; + + $this->processor->processConfiguration($this->configuration, [$config]); + } + + public function testInvalidQuality(): void + { + $this->expectException(InvalidConfigurationException::class); + + $config = [ + 'provider' => 'liip_imagine', + 'missing_image_placeholder' => '/path/to/404.jpg', + 'breakpoints' => ['sm' => 640], + 'defaults' => [ + 'format' => 'webp', + 'quality' => 101, // Invalid quality (> 100) + 'loading' => 'lazy', + 'fetchpriority' => 'low', + 'fit' => 'cover', + 'placeholder' => 'none', + ], + ]; + + $this->processor->processConfiguration($this->configuration, [$config]); + } + + public function testValidProviders(): void + { + $config = [ + 'provider' => 'liip_imagine', + 'missing_image_placeholder' => '/path/to/404.jpg', + 'breakpoints' => ['sm' => 640], + 'defaults' => [ + 'format' => 'webp', + 'quality' => 80, + 'loading' => 'lazy', + 'fetchpriority' => 'low', + 'fit' => 'cover', + 'placeholder' => 'none', + ], + 'providers' => [ + 'liip_imagine' => [ + 'driver' => 'gd', + 'cache' => 'default', + ], + 'cloudinary' => [ + 'cloud_name' => 'test', + 'api_key' => 'key', + 'api_secret' => 'secret', + ], + ], + ]; + + $processedConfig = $this->processor->processConfiguration( + $this->configuration, + [$config] + ); + + $this->assertArrayHasKey('liip_imagine', $processedConfig['providers']); + $this->assertArrayHasKey('cloudinary', $processedConfig['providers']); + } + + public function testValidPresets(): void + { + $config = [ + 'provider' => 'liip_imagine', + 'missing_image_placeholder' => '/path/to/404.jpg', + 'breakpoints' => ['sm' => 640], + 'defaults' => [ + 'format' => 'webp', + 'quality' => 80, + 'loading' => 'lazy', + 'fetchpriority' => 'low', + 'fit' => 'cover', + 'placeholder' => 'none', + ], + 'presets' => [ + 'thumbnail' => [ + 'width' => 200, + 'height' => 200, + 'fit' => 'cover', + 'quality' => 90, + ], + 'hero' => [ + 'ratio' => '16:9', + 'width' => '100vw', + 'loading' => 'eager', + 'fetchpriority' => 'high', + ], + ], + ]; + + $processedConfig = $this->processor->processConfiguration( + $this->configuration, + [$config] + ); + + $this->assertArrayHasKey('thumbnail', $processedConfig['presets']); + $this->assertArrayHasKey('hero', $processedConfig['presets']); + $this->assertEquals(200, $processedConfig['presets']['thumbnail']['width']); + $this->assertEquals('16:9', $processedConfig['presets']['hero']['ratio']); + } +} diff --git a/src/Image/tests/DependencyInjection/ImageExtensionTest.php b/src/Image/tests/DependencyInjection/ImageExtensionTest.php new file mode 100644 index 00000000000..9e5f0abddc6 --- /dev/null +++ b/src/Image/tests/DependencyInjection/ImageExtensionTest.php @@ -0,0 +1,167 @@ +container = new ContainerBuilder(); + $this->extension = new ImageExtension(); + $this->extension->setLoadDefaultConfig(false); + } + + public function testLoadSetParameters(): void + { + $config = [ + 'provider' => 'liip_imagine', + 'missing_image_placeholder' => '/path/to/404.jpg', + 'breakpoints' => ['sm' => 640], + 'defaults' => [ + 'format' => 'webp', + 'quality' => 80, + 'loading' => 'lazy', + 'fetchpriority' => 'low', + 'fit' => 'cover', + 'placeholder' => 'none', + ], + ]; + + $this->extension->load([$config], $this->container); + + $this->assertTrue($this->container->hasParameter('ux_image.provider'), 'Provider is not set'); + $this->assertTrue($this->container->hasParameter('ux_image.missing_image_placeholder'), 'Missing image placeholder is not set'); + $this->assertTrue($this->container->hasParameter('ux_image.defaults'), 'Defaults are not set'); + $this->assertTrue($this->container->hasParameter('ux_image.breakpoints'), 'Breakpoints are not set'); + + $this->assertEquals('liip_imagine', $this->container->getParameter('ux_image.provider')); + $this->assertEquals('/path/to/404.jpg', $this->container->getParameter('ux_image.missing_image_placeholder')); + $this->assertEquals(['sm' => 640], $this->container->getParameter('ux_image.breakpoints')); + } + + public function testLoadRegistersProviderRegistry(): void + { + $config = [ + 'provider' => 'liip_imagine', + 'missing_image_placeholder' => '/path/to/404.jpg', + 'breakpoints' => ['sm' => 640], + 'defaults' => [ + 'format' => 'webp', + 'quality' => 80, + 'loading' => 'lazy', + 'fetchpriority' => 'low', + 'fit' => 'cover', + 'placeholder' => 'none', + ], + ]; + + $this->extension->load([$config], $this->container); + + $this->assertTrue($this->container->hasDefinition('ux.image.provider_registry')); + + $registryDef = $this->container->getDefinition('ux.image.provider_registry'); + $this->assertEquals(ProviderRegistry::class, $registryDef->getClass()); + } + + public function testLoadRegistersAutoconfigurationForProviders(): void + { + $config = [ + 'provider' => 'liip_imagine', + 'missing_image_placeholder' => '/path/to/404.jpg', + 'breakpoints' => ['sm' => 640], + 'defaults' => [ + 'format' => 'webp', + 'quality' => 80, + 'loading' => 'lazy', + 'fetchpriority' => 'low', + 'fit' => 'cover', + 'placeholder' => 'none', + ], + ]; + + $this->extension->load([$config], $this->container); + + $autoconfigured = $this->container->getAutoconfiguredInstanceof(); + + $this->assertArrayHasKey(ProviderInterface::class, $autoconfigured); + $this->assertTrue($autoconfigured[ProviderInterface::class]->hasTag('ux.image.provider')); + } + + public function testLoadWithProviders(): void + { + $config = [ + 'provider' => 'liip_imagine', + 'missing_image_placeholder' => '/path/to/404.jpg', + 'breakpoints' => ['sm' => 640], + 'defaults' => [ + 'format' => 'webp', + 'quality' => 80, + 'loading' => 'lazy', + 'fetchpriority' => 'low', + 'fit' => 'cover', + 'placeholder' => 'none', + ], + 'providers' => [ + 'liip_imagine' => [ + 'driver' => 'gd', + 'cache' => 'default', + ], + 'cloudinary' => [ + 'cloud_name' => 'test', + 'api_key' => 'key', + 'api_secret' => 'secret', + ], + ], + ]; + + $this->extension->load([$config], $this->container); + + $this->assertTrue($this->container->hasParameter('ux_image.providers')); + + $providers = $this->container->getParameter('ux_image.providers'); + $this->assertArrayHasKey('liip_imagine', $providers); + $this->assertArrayHasKey('cloudinary', $providers); + } + + public function testLoadWithPresets(): void + { + $config = [ + 'provider' => 'liip_imagine', + 'missing_image_placeholder' => '/path/to/404.jpg', + 'breakpoints' => ['sm' => 640], + 'defaults' => [ + 'format' => 'webp', + 'quality' => 80, + 'loading' => 'lazy', + 'fetchpriority' => 'low', + 'fit' => 'cover', + 'placeholder' => 'none', + ], + 'presets' => [ + 'thumbnail' => [ + 'width' => 200, + 'height' => 200, + 'fit' => 'cover', + 'quality' => 90, + ], + ], + ]; + + $this->extension->load([$config], $this->container); + + $this->assertTrue($this->container->hasParameter('ux_image.presets')); + + $presets = $this->container->getParameter('ux_image.presets'); + $this->assertArrayHasKey('thumbnail', $presets); + $this->assertEquals(200, $presets['thumbnail']['width']); + } +} diff --git a/src/Image/tests/Provider/CloudinaryProviderTest.php b/src/Image/tests/Provider/CloudinaryProviderTest.php new file mode 100644 index 00000000000..de6775be2f5 --- /dev/null +++ b/src/Image/tests/Provider/CloudinaryProviderTest.php @@ -0,0 +1,127 @@ +provider = new CloudinaryProvider(); + $this->provider->configure([ + 'base_url' => 'https://res.cloudinary.com/demo', + 'defaults' => [ + 'quality' => '80', + 'format' => 'jpeg', + ], + ]); + } + + public function testGetName(): void + { + $this->assertEquals('cloudinary', $this->provider->getName()); + } + + public function testBasicImageTransformation(): void + { + $result = $this->provider->getImage('sample.jpg', [ + 'width' => '300', + 'height' => '200', + ]); + + $this->assertEquals( + 'https://res.cloudinary.com/demo/w_300,h_200,q_80,f_jpg/sample.jpg', + $result + ); + } + + public function testFitModifierMapping(): void + { + $result = $this->provider->getImage('sample.jpg', [ + 'width' => '300', + 'fit' => 'cover', + ]); + + $this->assertEquals( + 'https://res.cloudinary.com/demo/w_300,q_80,f_jpg,c_lfill/sample.jpg', + $result + ); + } + + public function testGravityModifierMapping(): void + { + $result = $this->provider->getImage('sample.jpg', [ + 'gravity' => 'face', + ]); + + $this->assertEquals( + 'https://res.cloudinary.com/demo/q_80,f_jpg,g_face/sample.jpg', + $result + ); + } + + public function testColorConversion(): void + { + $result = $this->provider->getImage('sample.jpg', [ + 'background' => '#ff0000', + ]); + + $this->assertEquals( + 'https://res.cloudinary.com/demo/q_80,f_jpg,b_rgb_ff0000/sample.jpg', + $result + ); + } + + public function testRoundCornerModifier(): void + { + $result = $this->provider->getImage('sample.jpg', [ + 'roundCorner' => '20:40', + ]); + + $this->assertEquals( + 'https://res.cloudinary.com/demo/q_80,f_jpg,r_20_40/sample.jpg', + $result + ); + } + + public function testBlurEffect(): void + { + $result = $this->provider->getImage('sample.jpg', [ + 'blur' => '500', + ]); + + $this->assertEquals( + 'https://res.cloudinary.com/demo/q_80,f_jpg,e_blur:500/sample.jpg', + $result + ); + } + + public function testIgnoresUnsupportedModifiers(): void + { + $result = $this->provider->getImage('sample.jpg', [ + 'width' => '300', + 'unsupported' => 'value', + ]); + + $this->assertEquals( + 'https://res.cloudinary.com/demo/w_300,q_80,f_jpg/sample.jpg', + $result + ); + } + + public function testHandlesLeadingSlashInSource(): void + { + $result = $this->provider->getImage('/sample.jpg', [ + 'width' => '300', + ]); + + $this->assertEquals( + 'https://res.cloudinary.com/demo/w_300,q_80,f_jpg/sample.jpg', + $result + ); + } +} diff --git a/src/Image/tests/Service/TransformerTest.php b/src/Image/tests/Service/TransformerTest.php new file mode 100644 index 00000000000..2ab98aa8bc3 --- /dev/null +++ b/src/Image/tests/Service/TransformerTest.php @@ -0,0 +1,326 @@ +transformer = new Transformer([ + // 'xs' => 320, Mobile Portrait BrowserStack + 'sm' => 640, + 'md' => 768, + 'lg' => 1024, + 'xl' => 1280, + // 1366 Top Common Screen Resolutions Worldwide in 2024 + // 1440 Mobile Portrait BrowserStack + // 1512 MacBook Pro 14” 2021 + '2xl' => 1536, // Top Common Screen Resolutions Worldwide in 2024 + // '3xl' => 1920, // DELL U2515H, Top Common Screen Resolutions Worldwide in 2024 + // 1600 DELL U2515H + // 1800 MacBook Pro 14” 2021 + // 2048 DELL U2515H + // '4xl' => 2560 // DELL U2515H, Tablet Landscape + ]); + } + + /** + * @dataProvider provideWidthStrings + */ + public function testParseWidth(string $input, array $expected): void + { + $result = $this->transformer->parseWidth($input); + $this->assertEquals($expected, $result); + } + + public static function provideWidthStrings(): array + { + return [ + 'fixed width' => [ + '300', + [ + 'default' => ['value' => 300, 'vw' => '0'], + 'sm' => ['value' => 300, 'vw' => '0'], + 'md' => ['value' => 300, 'vw' => '0'], + 'lg' => ['value' => 300, 'vw' => '0'], + 'xl' => ['value' => 300, 'vw' => '0'], + '2xl' => ['value' => 300, 'vw' => '0'], + ], + ], + 'fixed width large' => [ + '1000', + [ + 'default' => ['value' => 1000, 'vw' => '0'], + 'sm' => ['value' => 1000, 'vw' => '0'], + 'md' => ['value' => 1000, 'vw' => '0'], + 'lg' => ['value' => 1000, 'vw' => '0'], + 'xl' => ['value' => 1000, 'vw' => '0'], + '2xl' => ['value' => 1000, 'vw' => '0'], + ], + ], + 'fixed breakpoints' => [ + 'sm:50 md:100 lg:200', + [ + 'default' => ['value' => 50, 'vw' => '0'], + 'sm' => ['value' => 50, 'vw' => '0'], + 'md' => ['value' => 100, 'vw' => '0'], + 'lg' => ['value' => 200, 'vw' => '0'], + 'xl' => ['value' => 200, 'vw' => '0'], + '2xl' => ['value' => 200, 'vw' => '0'], + ], + ], + 'fullscreen' => [ + '100vw', + [ + 'default' => ['value' => 640, 'vw' => '100'], + 'sm' => ['value' => 640, 'vw' => '100'], + 'md' => ['value' => 768, 'vw' => '100'], + 'lg' => ['value' => 1024, 'vw' => '100'], + 'xl' => ['value' => 1280, 'vw' => '100'], + '2xl' => ['value' => 1536, 'vw' => '100'], + ], + ], + 'halfscreen and fixed' => [ + '50vw lg:400px', + [ + 'default' => ['value' => 320, 'vw' => '50'], + 'sm' => ['value' => 320, 'vw' => '50'], + 'md' => ['value' => 384, 'vw' => '50'], + 'lg' => ['value' => 400, 'vw' => '0'], + 'xl' => ['value' => 400, 'vw' => '0'], + '2xl' => ['value' => 400, 'vw' => '0'], + ], + ], + 'mixed values' => [ + '400 sm:500 md:100vw', + [ + 'default' => ['value' => 400, 'vw' => '0'], + 'sm' => ['value' => 500, 'vw' => '0'], + 'md' => ['value' => 768, 'vw' => '100'], + 'lg' => ['value' => 1024, 'vw' => '100'], + 'xl' => ['value' => 1280, 'vw' => '100'], + '2xl' => ['value' => 1536, 'vw' => '100'], + ], + ], + 'mixed values with gap' => [ + '100 lg:100vw', + [ + 'default' => ['value' => 100, 'vw' => '0'], + 'sm' => ['value' => 100, 'vw' => '0'], + 'md' => ['value' => 100, 'vw' => '0'], + 'lg' => ['value' => 1024, 'vw' => '100'], + 'xl' => ['value' => 1280, 'vw' => '100'], + '2xl' => ['value' => 1536, 'vw' => '100'], + ], + ], + 'vw to fixed width' => [ + '100vw md:100', + [ + 'default' => ['value' => 640, 'vw' => '100'], + 'sm' => ['value' => 640, 'vw' => '100'], + 'md' => ['value' => 100, 'vw' => '0'], + 'lg' => ['value' => 100, 'vw' => '0'], + 'xl' => ['value' => 100, 'vw' => '0'], + '2xl' => ['value' => 100, 'vw' => '0'], + ], + ], + 'large fixed to vw' => [ + '1000 lg:100vw', + [ + 'default' => ['value' => 1000, 'vw' => '0'], + 'sm' => ['value' => 1000, 'vw' => '0'], + 'md' => ['value' => 1000, 'vw' => '0'], + 'lg' => ['value' => 1024, 'vw' => '100'], + 'xl' => ['value' => 1280, 'vw' => '100'], + '2xl' => ['value' => 1536, 'vw' => '100'], + ], + ], + ]; + } + + /** + * @dataProvider provideSizesStrings + */ + public function testGetSizes(array $widths, string $expected): void + { + $result = $this->transformer->getSizes($widths); + $this->assertEquals($expected, $result); + } + + public static function provideSizesStrings(): array + { + return [ + 'fullscreen' => [ + [ + 'default' => ['value' => 640, 'vw' => '100'], + 'sm' => ['value' => 640, 'vw' => '100'], + 'md' => ['value' => 768, 'vw' => '100'], + 'lg' => ['value' => 1024, 'vw' => '100'], + 'xl' => ['value' => 1280, 'vw' => '100'], + '2xl' => ['value' => 1536, 'vw' => '100'], + ], + '100vw', + ], + 'default value appears at end (W3C compliant)' => [ + [ + 'default' => ['value' => 640, 'vw' => '100'], + 'sm' => ['value' => 640, 'vw' => '100'], + 'md' => ['value' => 768, 'vw' => '80'], + 'lg' => ['value' => 1024, 'vw' => '80'], + 'xl' => ['value' => 1280, 'vw' => '80'], + '2xl' => ['value' => 1536, 'vw' => '80'], + ], + '(max-width: 1536px) 80vw, (max-width: 1280px) 80vw, (max-width: 1024px) 80vw, (max-width: 768px) 80vw, (max-width: 768px) 100vw, (max-width: 640px) 100vw, 80vw', + ], + 'mixed viewport and fixed widths' => [ + [ + 'default' => ['value' => 320, 'vw' => '50'], + 'sm' => ['value' => 320, 'vw' => '50'], + 'md' => ['value' => 384, 'vw' => '50'], + 'lg' => ['value' => 400, 'vw' => '0'], + 'xl' => ['value' => 400, 'vw' => '0'], + '2xl' => ['value' => 400, 'vw' => '0'], + ], + '(max-width: 1536px) 400px, (max-width: 1280px) 400px, (max-width: 1024px) 400px, (max-width: 1024px) 50vw, (max-width: 768px) 50vw, (max-width: 640px) 50vw, 400px', + ], + 'viewport widths with breakpoint transitions' => [ + [ + 'default' => ['value' => 640, 'vw' => '100'], + 'sm' => ['value' => 640, 'vw' => '100'], + 'md' => ['value' => 768, 'vw' => '100'], + 'lg' => ['value' => 1024, 'vw' => '100'], + 'xl' => ['value' => 1152, 'vw' => '90'], + '2xl' => ['value' => 1382, 'vw' => '90'], + ], + '(max-width: 1536px) 90vw, (max-width: 1280px) 90vw, (max-width: 1280px) 100vw, (max-width: 1024px) 100vw, (max-width: 768px) 100vw, (max-width: 640px) 100vw, 90vw', + ], + 'fixed to viewport width transition' => [ + [ + 'default' => ['value' => 1000, 'vw' => '0'], + 'sm' => ['value' => 1000, 'vw' => '0'], + 'md' => ['value' => 1000, 'vw' => '0'], + 'lg' => ['value' => 1024, 'vw' => '100'], + 'xl' => ['value' => 1280, 'vw' => '100'], + '2xl' => ['value' => 1536, 'vw' => '100'], + ], + '(max-width: 1536px) 100vw, (max-width: 1280px) 100vw, (max-width: 1024px) 100vw, (max-width: 1024px) 1000px, (max-width: 768px) 1000px, (max-width: 640px) 1000px, 100vw', + ], + ]; + } + + /** + * @dataProvider provideSrcsetData + */ + public function testGetSrcset(string $src, array $widths, string $expected): void + { + $result = $this->transformer->getSrcset( + $src, + $widths, + fn($modifiers) => $src . '?' . http_build_query($modifiers) + ); + $this->assertEquals($expected, $result); + } + + public static function provideSrcsetData(): array + { + return [ + 'basic widths' => [ + '/image.jpg', + [ + 'default' => ['value' => 300, 'vw' => '0'], + 'sm' => ['value' => 400, 'vw' => '0'], + ], + '/image.jpg?width=300 300w, /image.jpg?width=400 400w', + ], + 'duplicate widths removed (W3C compliant)' => [ + '/image.jpg', + [ + 'default' => ['value' => 640, 'vw' => '0'], + 'sm' => ['value' => 640, 'vw' => '0'], + 'md' => ['value' => 640, 'vw' => '0'], + 'lg' => ['value' => 640, 'vw' => '0'], + ], + '/image.jpg?width=640 640w', + ], + 'partial duplicates removed' => [ + '/image.jpg', + [ + 'default' => ['value' => 300, 'vw' => '0'], + 'sm' => ['value' => 300, 'vw' => '0'], + 'md' => ['value' => 400, 'vw' => '0'], + 'lg' => ['value' => 400, 'vw' => '0'], + 'xl' => ['value' => 500, 'vw' => '0'], + ], + '/image.jpg?width=300 300w, /image.jpg?width=400 400w, /image.jpg?width=500 500w', + ], + 'mixed unique and duplicate widths' => [ + '/image.jpg', + [ + 'default' => ['value' => 100, 'vw' => '0'], + 'sm' => ['value' => 200, 'vw' => '0'], + 'md' => ['value' => 200, 'vw' => '0'], + 'lg' => ['value' => 300, 'vw' => '0'], + 'xl' => ['value' => 300, 'vw' => '0'], + '2xl' => ['value' => 400, 'vw' => '0'], + ], + '/image.jpg?width=100 100w, /image.jpg?width=200 200w, /image.jpg?width=300 300w, /image.jpg?width=400 400w', + ], + ]; + } + + /** + * @dataProvider provideInitialWidthData + */ + public function testGetInitialWidth(array $widths, string $pattern, int $expected): void + { + $result = $this->transformer->getInitialWidth($widths, $pattern); + $this->assertEquals($expected, $result); + } + + public static function provideInitialWidthData(): array + { + return [ + 'viewport width pattern' => [ + [ + 'default' => ['value' => 640, 'vw' => '100'], + 'sm' => ['value' => 640, 'vw' => '100'], + 'md' => ['value' => 768, 'vw' => '100'], + ], + '100vw', + 640, + ], + 'fixed width pattern' => [ + [ + 'default' => ['value' => 300, 'vw' => '0'], + 'sm' => ['value' => 400, 'vw' => '0'], + ], + '300', + 300, + ], + 'mixed pattern starting with fixed' => [ + [ + 'default' => ['value' => 400, 'vw' => '0'], + 'md' => ['value' => 768, 'vw' => '100'], + ], + '400 md:100vw', + 400, + ], + ]; + } + + public function testGetDensityBasedWidths(): void + { + $transformer = new Transformer(); + + $widths = $transformer->getDensityBasedWidths(100, 'x1 x2'); + $this->assertEquals([100, 200], $widths); + + $widths = $transformer->getDensityBasedWidths(100, '1x 2x 3x'); + $this->assertEquals([100, 200, 300], $widths); + } +} diff --git a/src/Image/tests/TestHelper/HtmlTestHelper.php b/src/Image/tests/TestHelper/HtmlTestHelper.php new file mode 100644 index 00000000000..8bb9b936c19 --- /dev/null +++ b/src/Image/tests/TestHelper/HtmlTestHelper.php @@ -0,0 +1,76 @@ +'.$html.''; + @$dom->loadHTML($html, \LIBXML_HTML_NOIMPLIED | \LIBXML_HTML_NODEFDTD); + + $img = $dom->getElementsByTagName('img')->item(0); + + if (!$img) { + $picture = $dom->getElementsByTagName('picture')->item(0); + if ($picture) { + $img = $picture->getElementsByTagName('img')->item(0); + } + } + + if (!$img) { + throw new \RuntimeException('No img element found in HTML'); + } + + $attributes = []; + foreach ($img->attributes as $attr) { + $attributes[$attr->name] = $attr->value; + } + + if (isset($attributes['src'])) { + $parsedUrl = parse_url($attributes['src']); + $attributes['src_path'] = $parsedUrl['path'] ?? null; + $attributes['src_scheme'] = $parsedUrl['scheme'] ?? null; + + if (isset($parsedUrl['query'])) { + parse_str(html_entity_decode($parsedUrl['query']), $params); + $attributes['src_params'] = $params; + } + } + + return $attributes; + } + + protected function assertImageAttribute(string $html, string $attribute, string $expected): void + { + $attributes = $this->parseImageAttributes($html); + $this->assertArrayHasKey($attribute, $attributes, "Image is missing attribute '$attribute'"); + $this->assertEquals($expected, $attributes[$attribute], "Image attribute '$attribute' does not match expected value"); + } + + protected function assertImageSrcParam(string $html, string $param, string $expected): void + { + $attributes = $this->parseImageAttributes($html); + $this->assertArrayHasKey('src_params', $attributes, 'Image src has no parameters'); + $this->assertArrayHasKey($param, $attributes['src_params'], "Image src is missing parameter '$param'"); + $this->assertEquals($expected, $attributes['src_params'][$param], "Image src parameter '$param' does not match expected value"); + } + + protected function assertSourceAttribute(string $html, string $attribute, string $expected, int $index = 0): void + { + $dom = new \DOMDocument(); + $html = ''.$html.''; + @$dom->loadHTML($html, \LIBXML_HTML_NOIMPLIED | \LIBXML_HTML_NODEFDTD); + + $sources = $dom->getElementsByTagName('source'); + + if ($sources->length <= $index) { + throw new \RuntimeException("No source element found at index $index"); + } + + $source = $sources->item($index); + $this->assertTrue($source->hasAttribute($attribute), "Source is missing attribute '$attribute'"); + $this->assertEquals($expected, $source->getAttribute($attribute), "Source attribute '$attribute' does not match expected value"); + } +} diff --git a/src/Image/tests/TestKernel.php b/src/Image/tests/TestKernel.php new file mode 100644 index 00000000000..88369a9a86f --- /dev/null +++ b/src/Image/tests/TestKernel.php @@ -0,0 +1,32 @@ +load(__DIR__.'/config/config.yaml'); + } + + protected function configureContainer(ContainerBuilder $container, LoaderInterface $loader): void + { + $loader->load(__DIR__.'/../config/services.yaml'); + } +} diff --git a/src/Image/tests/Twig/Components/ImgTest.php b/src/Image/tests/Twig/Components/ImgTest.php new file mode 100644 index 00000000000..3e46fee58e0 --- /dev/null +++ b/src/Image/tests/Twig/Components/ImgTest.php @@ -0,0 +1,619 @@ +registry = $container->get(ProviderRegistry::class); + $this->preloadManager = $container->get(PreloadManager::class); + $this->preloadManager->reset(); + + // Setup default mock provider + $this->provider = $this->createMock(ProviderInterface::class); + $this->provider->method('getName')->willReturn('mock'); + $this->provider + ->method('getImage') + ->willReturnCallback(function ($src, $modifiers) { + return $src . '?' . http_build_query($modifiers); + }); + + $this->registry->addProvider($this->provider); + $this->registry->setDefaultProvider('mock'); + } + + public function testComponentMount(): void + { + $component = $this->mountTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + ] + ); + + $this->assertInstanceOf(Img::class, $component); + $this->assertSame('/image.jpg', $component->src); + } + + public function testEmptySrcThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Image src cannot be empty'); + + $this->mountTwigComponent( + name: 'img', + data: [ + 'src' => '', + ] + ); + } + + public function testComponentRenders(): void + { + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'alt' => 'Test image', + 'class' => 'img-fluid rounded', + 'referrerpolicy' => 'origin', + 'id' => 'image', + 'data-controller' => 'responsive-image', + 'width' => 100, + 'height' => 100, + 'loading' => 'lazy', + 'fetchpriority' => 'auto', + 'fallback' => 'auto', + 'format' => 'webp', + 'quality' => '80', + 'fit' => 'cover', + 'focal' => 'center', + ] + ); + + // Test standard HTML attributes + $this->assertImageAttribute($rendered, 'alt', 'Test image'); + $this->assertImageAttribute($rendered, 'class', 'img-fluid rounded'); + $this->assertImageAttribute($rendered, 'referrerpolicy', 'origin'); + $this->assertImageAttribute($rendered, 'id', 'image'); + $this->assertImageAttribute($rendered, 'data-controller', 'responsive-image'); + $this->assertImageAttribute($rendered, 'width', '100'); + $this->assertImageAttribute($rendered, 'height', '100'); + $this->assertImageAttribute($rendered, 'loading', 'lazy'); + $this->assertImageAttribute($rendered, 'fetchpriority', 'auto'); + } + + public function testPresetConfiguration(): void + { + $component = $this->mountTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'preset' => 'hero', + ] + ); + + $this->assertStringContainsString('high', $component->fetchpriority); + $this->assertStringContainsString('16:9', $component->ratio); + $this->assertStringContainsString('100vw sm:50vw md:400px', $component->width); + $this->assertTrue($component->preload); + } + + public function testPlaceholderRendering(): void + { + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'placeholder' => 'blur', + 'placeholder-class' => 'custom-placeholder', + ] + ); + + $this->assertStringContainsString('class="custom-placeholder"', $rendered); + } + + public function testFixedWidth(): void + { + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => '100', + ] + ); + + $this->assertImageSrcParam($rendered, 'width', '100'); + } + + public function testFixedWidthPx(): void + { + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => '100px', + ] + ); + + $this->assertImageSrcParam($rendered, 'width', '100'); + $this->assertStringNotContainsString('sizes="', $rendered); + $this->assertStringNotContainsString('srcset="', $rendered); + } + + public function testFixedWidthLarge(): void + { + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => '1000', + ] + ); + + $this->assertImageSrcParam($rendered, 'width', '1000'); + $this->assertStringNotContainsString('sizes="', $rendered); + $this->assertStringNotContainsString('srcset="', $rendered); + } + + public function testFixedWidthBreakpoints(): void + { + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => 'sm:50 md:100 lg:200', + ] + ); + + $attributes = $this->parseImageAttributes($rendered); + $this->assertImageSrcParam($rendered, 'width', '50'); + $this->assertStringContainsString('/image.jpg?width=50 50w', $attributes['srcset']); + $this->assertStringContainsString('/image.jpg?width=100 100w', $attributes['srcset']); + $this->assertStringContainsString('/image.jpg?width=200 200w', $attributes['srcset']); + $this->assertStringContainsString('(max-width: 640px) 50px', $attributes['sizes']); + $this->assertStringContainsString('(max-width: 768px) 100px', $attributes['sizes']); + $this->assertStringContainsString('(max-width: 1024px) 200px', $attributes['sizes']); + } + + public function testFullscreen(): void + { + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => '100vw', + ] + ); + + $attributes = $this->parseImageAttributes($rendered); + $this->assertImageSrcParam($rendered, 'width', '640'); + + // Test srcset values + $this->assertStringContainsString('/image.jpg?width=640 640w', $attributes['srcset']); + $this->assertStringContainsString('/image.jpg?width=768 768w', $attributes['srcset']); + $this->assertStringContainsString('/image.jpg?width=1024 1024w', $attributes['srcset']); + $this->assertStringContainsString('/image.jpg?width=1280 1280w', $attributes['srcset']); + $this->assertStringContainsString('/image.jpg?width=1536 1536w', $attributes['srcset']); + + // Test sizes attribute + $this->assertStringContainsString('100vw', $attributes['sizes']); + } + + public function testHalfscreenAndFixed(): void + { + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => '50vw lg:400px', + ] + ); + + $this->assertImageSrcParam($rendered, 'width', '320'); + $this->assertStringContainsString('/image.jpg?width=320 320w', $rendered); + $this->assertStringContainsString('/image.jpg?width=400 400w', $rendered); + $this->assertStringContainsString('(max-width: 1024px) 50vw', $rendered); + $this->assertStringContainsString('400px', $rendered); + } + + public function testMixedValues(): void + { + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => '400 sm:500 md:100vw', + ] + ); + + $this->assertImageSrcParam($rendered, 'width', '400'); + $this->assertStringContainsString('/image.jpg?width=400 400w', $rendered); + $this->assertStringContainsString('/image.jpg?width=500 500w', $rendered); + $this->assertStringContainsString('/image.jpg?width=768 768w', $rendered); + $this->assertStringContainsString('/image.jpg?width=1024 1024w', $rendered); + $this->assertStringContainsString('/image.jpg?width=1280 1280w', $rendered); + $this->assertStringContainsString('/image.jpg?width=1536 1536w', $rendered); + $this->assertStringContainsString('(max-width: 640px) 400px', $rendered); + $this->assertStringContainsString('(max-width: 768px) 500px', $rendered); + $this->assertStringContainsString('100vw', $rendered); + } + + public function testDensities(): void + { + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => 100, + 'densities' => 'x1 x2', + ] + ); + + $this->assertImageSrcParam($rendered, 'width', '100'); + $this->assertStringContainsString('/image.jpg?width=100 100w', $rendered); + $this->assertStringContainsString('/image.jpg?width=200 200w', $rendered); + } + + public function testPreloadSimpleImage(): void + { + // First verify the PreloadManager service exists + $preloadManager = static::getContainer()->get(PreloadManager::class); + $this->assertInstanceOf(PreloadManager::class, $preloadManager); + + // Reset the PreloadManager + $preloadManager->reset(); + + // Mount component with preload=true + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => '400', + 'preload' => true, + ] + ); + + $this->assertImageSrcParam($rendered, 'width', '400'); + + $preloadTags = $preloadManager->getPreloadTags(); + + $this->assertStringContainsString( + 'assertStringContainsString('imagesizes="100vw"', $preloadTags); + $this->assertStringContainsString('/image.jpg?width=640 640w', $preloadTags); + $this->assertStringContainsString('/image.jpg?width=1536 1536w', $preloadTags); + } + + public function testPreloadDisabledByDefault(): void + { + $this->mountTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => '400', + ] + ); + + $preloadManager = static::getContainer()->get(PreloadManager::class); + $preloadTags = $preloadManager->getPreloadTags(); + + $this->assertEmpty($preloadTags); + } + + public function testFormatParameter(): void + { + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => '400', + 'format' => 'avif', + ] + ); + + $this->assertImageSrcParam($rendered, 'width', '400'); + $this->assertImageSrcParam($rendered, 'format', 'avif'); + } + + public function testQualityParameter(): void + { + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => '400', + 'quality' => '90', + ] + ); + + $this->assertImageSrcParam($rendered, 'width', '400'); + $this->assertImageSrcParam($rendered, 'quality', '90'); + } + + public function testFitParameter(): void + { + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => '400', + 'fit' => 'contain', + ] + ); + + $this->assertImageSrcParam($rendered, 'fit', 'contain'); + } + + public function testBackgroundParameter(): void + { + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => '400', + 'background' => '#ffffff', + ] + ); + + $this->assertImageSrcParam($rendered, 'width', '400'); + $this->assertImageSrcParam($rendered, 'background', '#ffffff'); + } + + public function testFocalParameter(): void + { + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => '400', + 'focal' => 'top', + ] + ); + + $this->assertImageSrcParam($rendered, 'width', '400'); + $this->assertImageSrcParam($rendered, 'focal', 'top'); + } + + public function testFallbackParameter(): void + { + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => '100w', + 'format' => 'webp', + 'fallback' => 'jpg', + ] + ); + + $this->assertImageSrcParam($rendered, 'width', '100'); + $this->assertImageSrcParam($rendered, 'format', 'jpg'); + } + + public function testDefaultFallbackParameter(): void + { + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.webp', + 'width' => '100w', + 'format' => 'webp', + 'fallback' => 'auto', + ] + ); + + $this->assertImageSrcParam($rendered, 'width', '100'); + $this->assertImageSrcParam($rendered, 'format', 'png'); + } + + public function testEmptyFallbackParameter(): void + { + $component = $this->mountTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => '400', + 'format' => 'webp', + 'fallback-format' => 'empty', + ] + ); + + $this->assertEquals(Img::EMPTY_GIF, $component->getSrcComputed()); + } + + public function testRatioParameter(): void + { + // Mount the component to test the computed values + $component = $this->mountTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => '400', + 'ratio' => '16:9', + ] + ); + + // Test the computed values directly + $this->assertEquals('400', $component->width); + $this->assertEquals('16:9', $component->ratio); + + // Test the rendered HTML + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => '400', + 'ratio' => '16:9', + ] + ); + + // Test using our helper methods + $this->assertImageSrcParam($rendered, 'width', '400'); + $this->assertImageSrcParam($rendered, 'ratio', '16:9'); + } + + public function testCustomProvider(): void + { + $this->customProvider = $this->createMock(ProviderInterface::class); + $this->customProvider->method('getName')->willReturn('custom'); + $this->customProvider + ->method('getImage') + ->willReturnCallback(function ($src, $modifiers) { + return 'custom://' . $src . '?' . http_build_query($modifiers); + }); + + $this->registry->addProvider($this->customProvider); + + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => 'image.jpg', + 'width' => '400', + 'provider' => 'custom', + ] + ); + + $this->assertStringContainsString('custom://image.jpg?width=400', $rendered); + } + + public function testResponsiveWidthNotOutputAsHtmlAttribute(): void + { + // Test that responsive width syntax is NOT output as HTML width attribute + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => '100vw', + 'alt' => 'Test', + ] + ); + + $attributes = $this->parseImageAttributes($rendered); + + // Should NOT have width attribute (invalid HTML) + $this->assertArrayNotHasKey('width', $attributes, 'Responsive width "100vw" should not be output as HTML width attribute'); + + // Should have sizes attribute with the responsive value + $this->assertArrayHasKey('sizes', $attributes); + $this->assertStringContainsString('100vw', $attributes['sizes']); + } + + public function testNumericWidthOutputAsHtmlAttribute(): void + { + // Test that numeric width IS output as HTML width attribute + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => '800', + 'height' => 600, + 'alt' => 'Test', + ] + ); + + $attributes = $this->parseImageAttributes($rendered); + + // Should have valid HTML width and height attributes + $this->assertImageAttribute($rendered, 'width', '800'); + $this->assertImageAttribute($rendered, 'height', '600'); + } + + public function testBreakpointWidthNotOutputAsHtmlAttribute(): void + { + // Test that breakpoint syntax is NOT output as HTML width attribute + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => 'sm:50vw md:800px', + 'alt' => 'Test', + ] + ); + + $attributes = $this->parseImageAttributes($rendered); + + // Should NOT have width attribute (invalid HTML) + $this->assertArrayNotHasKey('width', $attributes, 'Breakpoint width syntax should not be output as HTML width attribute'); + + // Should have sizes attribute with the responsive value + $this->assertArrayHasKey('sizes', $attributes); + } + + public function testEmptyLoadingAttributeNotRendered(): void + { + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => '100', + 'loading' => '', + ] + ); + + // Empty loading attribute should not be rendered (W3C compliant) + $this->assertStringNotContainsString('loading=""', $rendered); + $this->assertStringNotContainsString('loading= ', $rendered); + } + + public function testValidLoadingAttributeRendered(): void + { + $rendered = $this->renderTwigComponent( + name: 'img', + data: [ + 'src' => '/image.jpg', + 'width' => '100', + 'loading' => 'lazy', + ] + ); + + // Valid loading attribute should be rendered + $this->assertStringContainsString('loading="lazy"', $rendered); + } +} diff --git a/src/Image/tests/Twig/Components/PictureTest.php b/src/Image/tests/Twig/Components/PictureTest.php new file mode 100644 index 00000000000..a92005078ea --- /dev/null +++ b/src/Image/tests/Twig/Components/PictureTest.php @@ -0,0 +1,257 @@ +get(ProviderRegistry::class); + + $this->provider = $this->createMock(ProviderInterface::class); + $this->provider->method('getName')->willReturn('mock'); + $this->provider + ->method('getImage') + ->willReturnCallback(function ($src, $modifiers) { + return $src . '?' . http_build_query($modifiers); + }); + + $registry->addProvider($this->provider); + $registry->setDefaultProvider('mock'); + } + + public function testComponentMount(): void + { + $component = $this->mountTwigComponent( + name: 'picture', + data: [ + 'src' => '/image.jpg', + ] + ); + + $this->assertInstanceOf(Picture::class, $component); + $this->assertSame('/image.jpg', $component->src); + } + + public function testEmptySrcThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Image src cannot be empty'); + + $this->mountTwigComponent( + name: 'picture', + data: [ + 'src' => '', + ] + ); + } + + public function testComponentRenders(): void + { + $rendered = $this->renderTwigComponent( + name: 'picture', + data: [ + 'src' => '/image.jpg', + 'alt' => 'Test image', + 'class' => 'img-fluid rounded', + 'referrerpolicy' => 'origin', + 'id' => 'image', + 'data-controller' => 'responsive-image', + 'width' => 100, + 'height' => 100, + 'loading' => 'lazy', + 'fetchpriority' => 'auto', + 'sizes' => '(max-width: 768px) 100vw, 50vw', + 'fallback' => 'auto', + 'class' => 'img-fluid rounded', + ] + ); + + $this->assertStringContainsString('alt="Test image"', $rendered); + $this->assertStringContainsString('class="img-fluid rounded"', $rendered); + $this->assertStringContainsString('referrerpolicy="origin"', $rendered); + $this->assertStringContainsString('id="image"', $rendered); + $this->assertStringContainsString('data-controller="responsive-image"', $rendered); + $this->assertStringContainsString('width="100"', $rendered); + $this->assertStringContainsString('height="100"', $rendered); + } + + public function testFixedWidth(): void + { + $rendered = $this->renderTwigComponent( + name: 'picture', + data: [ + 'src' => '/image.jpg', + 'width' => '100', + ] + ); + + $this->assertImageSrcParam($rendered, 'width', '100'); + $this->assertStringContainsString('width="100"', $rendered); + } + + public function testResponsiveWidth(): void + { + $rendered = $this->renderTwigComponent( + name: 'picture', + data: [ + 'src' => '/image.jpg', + 'width' => 'sm:50 md:100 lg:200', + ] + ); + + $this->assertImageSrcParam($rendered, 'width', '50'); + $this->assertSourceAttribute($rendered, 'media', '(max-width: 640px)', 0); + $this->assertSourceAttribute($rendered, 'media', '(max-width: 768px)', 1); + $this->assertSourceAttribute($rendered, 'media', '(max-width: 1024px)', 2); + $this->assertSourceAttribute($rendered, 'srcset', '/image.jpg?width=50', 0); + $this->assertSourceAttribute($rendered, 'srcset', '/image.jpg?width=100', 1); + $this->assertSourceAttribute($rendered, 'srcset', '/image.jpg?width=200', 2); + } + + public function testViewportWidthUnit(): void + { + $rendered = $this->renderTwigComponent( + name: 'picture', + data: [ + 'src' => '/image.jpg', + 'width' => '100vw', + ] + ); + + $this->assertImageSrcParam($rendered, 'width', '640'); + $this->assertStringContainsString('srcset="/image.jpg?width=640 640w"', $rendered); + $this->assertStringContainsString('srcset="/image.jpg?width=768 768w"', $rendered); + $this->assertStringContainsString('srcset="/image.jpg?width=1024 1024w"', $rendered); + $this->assertStringContainsString('srcset="/image.jpg?width=1280 1280w"', $rendered); + $this->assertStringContainsString('srcset="/image.jpg?width=1536 1536w"', $rendered); + $this->assertStringContainsString('sizes="100vw"', $rendered); + } + + public function testEmptyLoadingAttributeNotRendered(): void + { + $rendered = $this->renderTwigComponent( + name: 'picture', + data: [ + 'src' => '/image.jpg', + 'width' => '100', + 'loading' => '', + ] + ); + + // Empty loading attribute should not be rendered + $this->assertStringNotContainsString('loading=""', $rendered); + $this->assertStringNotContainsString('loading= ', $rendered); + } + + public function testValidLoadingAttributeRendered(): void + { + $rendered = $this->renderTwigComponent( + name: 'picture', + data: [ + 'src' => '/image.jpg', + 'width' => '100', + 'loading' => 'lazy', + ] + ); + + // Valid loading attribute should be rendered + $this->assertStringContainsString('loading="lazy"', $rendered); + } + + public function testArtDirectionWithBreakpointSpecificRatios(): void + { + $rendered = $this->renderTwigComponent( + name: 'picture', + data: [ + 'src' => '/image.jpg', + 'width' => '100vw md:80vw', + 'ratio' => 'sm:1:1 md:16:9', + ] + ); + + // Check that the first breakpoint (sm) uses 1:1 ratio (square images) + // For 640px breakpoint with 1:1 ratio: height should equal width (640x640) + $this->assertStringContainsString('ratio=1%3A1', $rendered); + + // Check that larger breakpoints (md+) use 16:9 ratio + // For 768px breakpoint with 16:9 ratio: height should be width*9/16 (768x432) + $this->assertStringContainsString('ratio=16%3A9', $rendered); + + // Verify exclusive media queries are generated for art direction + $this->assertStringContainsString('media="(min-width: 640px) and (max-width: 767px)"', $rendered); + $this->assertStringContainsString('media="(min-width: 768px) and (max-width: 1023px)"', $rendered); + } + + public function testPictureWithoutArtDirectionUsesSimpleMediaQueries(): void + { + // When all breakpoints use the same ratio, we don't need exclusive ranges + $rendered = $this->renderTwigComponent( + name: 'picture', + data: [ + 'src' => '/image.jpg', + 'width' => '100vw', + 'ratio' => '16:9', + ] + ); + + // Should use simple min-width queries without max-width + $this->assertStringContainsString('media="(min-width: 640px)"', $rendered); + $this->assertStringNotContainsString('max-width', $rendered); + } + + public function testPictureWithSingleBreakpointRatio(): void + { + $rendered = $this->renderTwigComponent( + name: 'picture', + data: [ + 'src' => '/image.jpg', + 'width' => '100vw', + 'ratio' => 'md:16:9', + ] + ); + + // md and larger should use 16:9 + $this->assertStringContainsString('ratio=16%3A9', $rendered); + + // sm should not have a ratio modifier (no ratio specified for it) + $this->assertStringNotContainsString('/image.jpg?width=640&ratio=', $rendered); + } + + public function testRatioCascadesAcrossBreakpoints(): void + { + $rendered = $this->renderTwigComponent( + name: 'picture', + data: [ + 'src' => '/image.jpg', + 'width' => '100vw', + 'ratio' => 'sm:1:1 lg:16:9', + ] + ); + + // sm and md should use 1:1 (sm cascades to md) + $this->assertStringContainsString('width=640&ratio=1%3A1', $rendered); + $this->assertStringContainsString('width=768&ratio=1%3A1', $rendered); + + // lg and larger should use 16:9 (lg cascades to xl, 2xl) + $this->assertStringContainsString('width=1024&ratio=16%3A9', $rendered); + $this->assertStringContainsString('width=1280&ratio=16%3A9', $rendered); + $this->assertStringContainsString('width=1536&ratio=16%3A9', $rendered); + } +} diff --git a/src/Image/tests/config/config.yaml b/src/Image/tests/config/config.yaml new file mode 100644 index 00000000000..95537bf9ba9 --- /dev/null +++ b/src/Image/tests/config/config.yaml @@ -0,0 +1,47 @@ +framework: + test: true + secret: test + router: + resource: ~ + utf8: true + http_method_override: false + php_errors: + log: true + +twig_component: + anonymous_template_directory: "components/" + defaults: + Symfony\UX\Image\Twig\Components\: components/ + +twig: + default_path: "%kernel.project_dir%/templates" + debug: "%kernel.debug%" + strict_variables: "%kernel.debug%" + +services: + _defaults: + autowire: true + autoconfigure: true + public: true + + Symfony\UX\Image\Service\PreloadManager: + public: true + +ux_image: + provider: placeholder + missing_image_placeholder: "404.png" + breakpoints: + sm: 640 + md: 768 + lg: 1024 + xl: 1280 + 2xl: 1536 + defaults: + fallback: lg + fallback_format: auto + presets: + hero: + ratio: "16:9" + width: "100vw sm:50vw md:400px" + fetchpriority: high + preload: true From f11600a25cdbccf82960798d3e678b4c5ffc7f22 Mon Sep 17 00:00:00 2001 From: Aleksey Razbakov Date: Sun, 23 Nov 2025 17:12:10 +0100 Subject: [PATCH 2/4] sort max-width queries --- src/Image/src/Service/Transformer.php | 122 ++++++++++---------- src/Image/tests/Service/TransformerTest.php | 8 +- src/Image/tests/Twig/Components/ImgTest.php | 6 +- 3 files changed, 65 insertions(+), 71 deletions(-) diff --git a/src/Image/src/Service/Transformer.php b/src/Image/src/Service/Transformer.php index 4903fef676a..ca75c0236b2 100644 --- a/src/Image/src/Service/Transformer.php +++ b/src/Image/src/Service/Transformer.php @@ -187,80 +187,79 @@ public function getSizes(array $widths): string $sizes = []; $breakpointKeys = array_keys($this->breakpoints); - // Find the largest explicit value for default size (no media query) - $largestValue = null; - foreach (array_reverse($breakpointKeys) as $key) { - if (isset($widths[$key])) { - $largestValue = $widths[$key]; - break; - } - } + // Sort breakpoints by value ascending for max-width logic + $sortedBreakpoints = $this->breakpoints; + asort($sortedBreakpoints); + $sortedKeys = array_keys($sortedBreakpoints); - // Process breakpoints from largest to smallest - $sizeVariants = []; - - foreach (array_reverse($breakpointKeys) as $i => $key) { - if (isset($widths[$key])) { - // Find the next breakpoint that has a value - $nextValue = null; - for ($j = $i + 1; $j < \count($breakpointKeys); ++$j) { - $nextKey = array_reverse($breakpointKeys)[$j]; - if (isset($widths[$nextKey])) { - $nextValue = $widths[$nextKey]; - break; - } - } + // Iterate from smallest to largest + foreach ($sortedKeys as $i => $key) { + // Determine the value applicable for the range ENDING at this breakpoint. + // For sm (640), the range is 0-640. This corresponds to 'default' width (mobile). + // For md (768), the range is 640-768. This corresponds to 'sm' width. - // If no next breakpoint value found and we have a default value - if (!$nextValue && isset($widths['default'])) { - $nextValue = $widths['default']; - } + $sourceKey = 0 === $i ? 'default' : $sortedKeys[$i - 1]; - // Add current value to size variants - $sizeVariants[] = [ - 'size' => $this->formatSizeValue($widths[$key]), - 'screenMaxWidth' => $this->breakpoints[$key], - 'media' => \sprintf('(max-width: %dpx)', $this->breakpoints[$key]), + if (isset($widths[$sourceKey])) { + $currentValue = $widths[$sourceKey]; + + $sizes[] = [ + 'media' => sprintf('(max-width: %dpx)', $this->breakpoints[$key]), + 'value' => $this->formatSizeValue($currentValue), ]; + } + } - // If next value is different, add it at this breakpoint - if ($nextValue && !$this->isSameValue($widths[$key], $nextValue)) { - $sizeVariants[] = [ - 'size' => $this->formatSizeValue($nextValue), - 'screenMaxWidth' => $this->breakpoints[$key], - 'media' => \sprintf('(max-width: %dpx)', $this->breakpoints[$key]), - ]; + $optimizedSizes = []; + $count = count($sizes); + for ($i = 0; $i < $count; $i++) { + $current = $sizes[$i]; + $keep = true; + + // Look ahead for same value + for ($j = $i + 1; $j < $count; $j++) { + $next = $sizes[$j]; + // Check if the formatted value string is the same + if ($current['value'] === $next['value']) { + // Found a larger breakpoint with same value. Current is redundant. + $keep = false; + break; + } else { + // Value changed. Current is boundary. Keep it. + break; } } - } - // Sort variants by screen width (largest to smallest) - usort($sizeVariants, fn($a, $b) => $b['screenMaxWidth'] - $a['screenMaxWidth']); + if ($keep) { + $optimizedSizes[] = $current['media'] . ' ' . $current['value']; + } + } - // Add size variants to sizes array (media queries come first) - foreach ($sizeVariants as $variant) { - $sizes[] = $variant['media'] . ' ' . $variant['size']; + // Find fallback (default) + // This corresponds to the range starting at the largest breakpoint. + // i.e. widths[largest_breakpoint] + $fallback = null; + $largestKey = end($sortedKeys); + if (isset($widths[$largestKey])) { + $fallback = $this->formatSizeValue($widths[$largestKey]); } - // Add default value if it exists and differs from sm breakpoint - if ( - isset($widths['default']) - && (!isset($widths['sm']) || !$this->isSameValue($widths['default'], $widths['sm'])) - ) { - $sizes[] = \sprintf( - '(max-width: %dpx) %s', - $this->breakpoints['sm'], - $this->formatSizeValue($widths['default']) - ); + // If fallback matches the last size query, remove that query? + // (max-width: 1536) 200px, 200px. + if (!empty($optimizedSizes) && $fallback) { + $lastSize = end($optimizedSizes); + // $lastSize string format: "(max-width: ...) val" + // Check if val matches fallback. + if (str_ends_with($lastSize, ' ' . $fallback)) { + array_pop($optimizedSizes); + } } - // Add the largest value as the default size at the END (no media query) - // This is the fallback size that browsers use when no media queries match - if ($largestValue) { - $sizes[] = $this->formatSizeValue($largestValue); + if ($fallback) { + $optimizedSizes[] = $fallback; } - return implode(', ', array_unique($sizes)); + return implode(', ', $optimizedSizes); } public function getSrcset(string $src, array $widths, callable $imageCallback): string @@ -283,11 +282,6 @@ public function getSrcset(string $src, array $widths, callable $imageCallback): return implode(', ', $srcset); } - private function isSameValue(array $value1, array $value2): bool - { - return $value1['value'] === $value2['value'] && $value1['vw'] === $value2['vw']; - } - private function formatSizeValue(array $width): string { return '0' !== $width['vw'] diff --git a/src/Image/tests/Service/TransformerTest.php b/src/Image/tests/Service/TransformerTest.php index 2ab98aa8bc3..23724146b64 100644 --- a/src/Image/tests/Service/TransformerTest.php +++ b/src/Image/tests/Service/TransformerTest.php @@ -175,7 +175,7 @@ public static function provideSizesStrings(): array 'xl' => ['value' => 1280, 'vw' => '80'], '2xl' => ['value' => 1536, 'vw' => '80'], ], - '(max-width: 1536px) 80vw, (max-width: 1280px) 80vw, (max-width: 1024px) 80vw, (max-width: 768px) 80vw, (max-width: 768px) 100vw, (max-width: 640px) 100vw, 80vw', + '(max-width: 768px) 100vw, 80vw', ], 'mixed viewport and fixed widths' => [ [ @@ -186,7 +186,7 @@ public static function provideSizesStrings(): array 'xl' => ['value' => 400, 'vw' => '0'], '2xl' => ['value' => 400, 'vw' => '0'], ], - '(max-width: 1536px) 400px, (max-width: 1280px) 400px, (max-width: 1024px) 400px, (max-width: 1024px) 50vw, (max-width: 768px) 50vw, (max-width: 640px) 50vw, 400px', + '(max-width: 1024px) 50vw, 400px', ], 'viewport widths with breakpoint transitions' => [ [ @@ -197,7 +197,7 @@ public static function provideSizesStrings(): array 'xl' => ['value' => 1152, 'vw' => '90'], '2xl' => ['value' => 1382, 'vw' => '90'], ], - '(max-width: 1536px) 90vw, (max-width: 1280px) 90vw, (max-width: 1280px) 100vw, (max-width: 1024px) 100vw, (max-width: 768px) 100vw, (max-width: 640px) 100vw, 90vw', + '(max-width: 1280px) 100vw, 90vw', ], 'fixed to viewport width transition' => [ [ @@ -208,7 +208,7 @@ public static function provideSizesStrings(): array 'xl' => ['value' => 1280, 'vw' => '100'], '2xl' => ['value' => 1536, 'vw' => '100'], ], - '(max-width: 1536px) 100vw, (max-width: 1280px) 100vw, (max-width: 1024px) 100vw, (max-width: 1024px) 1000px, (max-width: 768px) 1000px, (max-width: 640px) 1000px, 100vw', + '(max-width: 1024px) 1000px, 100vw', ], ]; } diff --git a/src/Image/tests/Twig/Components/ImgTest.php b/src/Image/tests/Twig/Components/ImgTest.php index 3e46fee58e0..6b2851b6926 100644 --- a/src/Image/tests/Twig/Components/ImgTest.php +++ b/src/Image/tests/Twig/Components/ImgTest.php @@ -197,9 +197,9 @@ public function testFixedWidthBreakpoints(): void $this->assertStringContainsString('/image.jpg?width=50 50w', $attributes['srcset']); $this->assertStringContainsString('/image.jpg?width=100 100w', $attributes['srcset']); $this->assertStringContainsString('/image.jpg?width=200 200w', $attributes['srcset']); - $this->assertStringContainsString('(max-width: 640px) 50px', $attributes['sizes']); - $this->assertStringContainsString('(max-width: 768px) 100px', $attributes['sizes']); - $this->assertStringContainsString('(max-width: 1024px) 200px', $attributes['sizes']); + $this->assertStringContainsString('(max-width: 768px) 50px', $attributes['sizes']); + $this->assertStringContainsString('(max-width: 1024px) 100px', $attributes['sizes']); + $this->assertStringContainsString('200px', $attributes['sizes']); } public function testFullscreen(): void From 4bb0fcf66f27b7a6773ae206ab3567258bf1725a Mon Sep 17 00:00:00 2001 From: Aleksey Razbakov Date: Sun, 23 Nov 2025 17:16:44 +0100 Subject: [PATCH 3/4] cleanup --- src/Image/src/Service/Transformer.php | 16 ---------------- src/Image/tests/Service/TransformerTest.php | 11 +++++++++++ 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/Image/src/Service/Transformer.php b/src/Image/src/Service/Transformer.php index ca75c0236b2..07f9a6b5e3e 100644 --- a/src/Image/src/Service/Transformer.php +++ b/src/Image/src/Service/Transformer.php @@ -169,23 +169,7 @@ private function normalizeWidthValue(string $value, string $breakpoint = 'defaul public function getSizes(array $widths): string { - // Special case: if it's just a viewport width with no breakpoints - // and all breakpoints have the same vw value - if (isset($widths['default']) && '100' === $widths['default']['vw']) { - $allSame = true; - foreach ($widths as $key => $width) { - if ('default' !== $key && isset($width['vw']) && '100' !== $width['vw']) { - $allSame = false; - break; - } - } - if ($allSame) { - return '100vw'; - } - } - $sizes = []; - $breakpointKeys = array_keys($this->breakpoints); // Sort breakpoints by value ascending for max-width logic $sortedBreakpoints = $this->breakpoints; diff --git a/src/Image/tests/Service/TransformerTest.php b/src/Image/tests/Service/TransformerTest.php index 23724146b64..838098a49c8 100644 --- a/src/Image/tests/Service/TransformerTest.php +++ b/src/Image/tests/Service/TransformerTest.php @@ -166,6 +166,17 @@ public static function provideSizesStrings(): array ], '100vw', ], + 'halfscreen' => [ + [ + 'default' => ['value' => 320, 'vw' => '50'], + 'sm' => ['value' => 320, 'vw' => '50'], + 'md' => ['value' => 384, 'vw' => '50'], + 'lg' => ['value' => 512, 'vw' => '50'], + 'xl' => ['value' => 640, 'vw' => '50'], + '2xl' => ['value' => 768, 'vw' => '50'], + ], + '50vw', + ], 'default value appears at end (W3C compliant)' => [ [ 'default' => ['value' => 640, 'vw' => '100'], From b04419975007b326ee181264999e7284f1feadbd Mon Sep 17 00:00:00 2001 From: Aleksey Razbakov Date: Sun, 23 Nov 2025 17:22:55 +0100 Subject: [PATCH 4/4] custom breakpoints --- src/Image/src/Service/Transformer.php | 26 +++++++++++------- src/Image/tests/Service/TransformerTest.php | 29 +++++++++++++++++++++ 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/Image/src/Service/Transformer.php b/src/Image/src/Service/Transformer.php index 07f9a6b5e3e..194dcc610a1 100644 --- a/src/Image/src/Service/Transformer.php +++ b/src/Image/src/Service/Transformer.php @@ -16,8 +16,8 @@ */ final class Transformer { - private const BREAKPOINT_ORDER = ['default', 'sm', 'md', 'lg', 'xl', '2xl']; private array $breakpoints; + private array $breakpointOrder; public function __construct(array $breakpoints = [ 'sm' => 640, @@ -28,6 +28,12 @@ public function __construct(array $breakpoints = [ ]) { $this->breakpoints = $breakpoints; + + // Build dynamic breakpoint order based on provided breakpoints + // Sort by value to maintain ascending order + $sortedBreakpoints = $breakpoints; + asort($sortedBreakpoints); + $this->breakpointOrder = ['default', ...array_keys($sortedBreakpoints)]; } public function parseWidth(string $width): array @@ -56,7 +62,7 @@ public function parseWidth(string $width): array // Track the smallest breakpoint if ( !$smallestBreakpoint - || array_search($breakpoint, self::BREAKPOINT_ORDER) < array_search($smallestBreakpoint, self::BREAKPOINT_ORDER) + || array_search($breakpoint, $this->breakpointOrder) < array_search($smallestBreakpoint, $this->breakpointOrder) ) { $smallestBreakpoint = $breakpoint; } @@ -75,13 +81,13 @@ public function parseWidth(string $width): array $vwPercentage = (int) $widths['default']['vw']; // Pre-calculate all viewport widths up to fixed width transition - foreach (self::BREAKPOINT_ORDER as $breakpoint) { + foreach ($this->breakpointOrder as $breakpoint) { if ($firstFixedAfterVw && $breakpoint === $firstFixedAfterVw) { // Found fixed width transition point, propagate fixed width to remaining breakpoints $fixedValue = $widths[$firstFixedAfterVw]; - foreach (self::BREAKPOINT_ORDER as $nextBreakpoint) { + foreach ($this->breakpointOrder as $nextBreakpoint) { if ( - array_search($nextBreakpoint, self::BREAKPOINT_ORDER) >= array_search($breakpoint, self::BREAKPOINT_ORDER) + array_search($nextBreakpoint, $this->breakpointOrder) >= array_search($breakpoint, $this->breakpointOrder) && !isset($widths[$nextBreakpoint]) ) { $widths[$nextBreakpoint] = $fixedValue; @@ -92,7 +98,7 @@ public function parseWidth(string $width): array if (!isset($widths[$breakpoint])) { $breakpointWidth = 'default' === $breakpoint ? - $this->breakpoints['sm'] : + reset($this->breakpoints) : $this->breakpoints[$breakpoint]; $pixelWidth = (int) ($breakpointWidth * ($vwPercentage / 100)); @@ -109,15 +115,15 @@ public function parseWidth(string $width): array $lastValue = $widths['default']; // Propagate fixed width to all breakpoints - foreach (self::BREAKPOINT_ORDER as $breakpoint) { + foreach ($this->breakpointOrder as $breakpoint) { if ($firstVwAfterFixed && $breakpoint === $firstVwAfterFixed) { // Found viewport width transition point $vwPercentage = (int) $widths[$breakpoint]['vw']; // Calculate viewport widths for remaining breakpoints - foreach (self::BREAKPOINT_ORDER as $vwBreakpoint) { + foreach ($this->breakpointOrder as $vwBreakpoint) { if ( - array_search($vwBreakpoint, self::BREAKPOINT_ORDER) >= array_search($breakpoint, self::BREAKPOINT_ORDER) + array_search($vwBreakpoint, $this->breakpointOrder) >= array_search($breakpoint, $this->breakpointOrder) && !isset($widths[$vwBreakpoint]) ) { $breakpointWidth = $this->breakpoints[$vwBreakpoint]; @@ -150,7 +156,7 @@ private function normalizeWidthValue(string $value, string $breakpoint = 'defaul if ($isVw) { $breakpointWidth = 'default' === $breakpoint ? - $this->breakpoints['sm'] : + reset($this->breakpoints) : $this->breakpoints[$breakpoint]; $pixelWidth = (int) ($breakpointWidth * ($numericValue / 100)); diff --git a/src/Image/tests/Service/TransformerTest.php b/src/Image/tests/Service/TransformerTest.php index 838098a49c8..7b5d034edba 100644 --- a/src/Image/tests/Service/TransformerTest.php +++ b/src/Image/tests/Service/TransformerTest.php @@ -334,4 +334,33 @@ public function testGetDensityBasedWidths(): void $widths = $transformer->getDensityBasedWidths(100, '1x 2x 3x'); $this->assertEquals([100, 200, 300], $widths); } + + /** + * Tests that custom breakpoint names work correctly without filling in standard breakpoint names. + */ + public function testCustomBreakpointNames(): void + { + $transformer = new Transformer([ + 'mobile' => 640, + 'tablet' => 768, + 'desktop' => 1024, + ]); + + $result = $transformer->parseWidth('mobile:200 tablet:400 desktop:800'); + + // Expected: Only custom breakpoints and default + $this->assertEquals([ + 'default' => ['value' => 200, 'vw' => '0'], + 'mobile' => ['value' => 200, 'vw' => '0'], + 'tablet' => ['value' => 400, 'vw' => '0'], + 'desktop' => ['value' => 800, 'vw' => '0'], + ], $result); + + // Standard breakpoints should NOT be present when using custom names + $this->assertArrayNotHasKey('sm', $result); + $this->assertArrayNotHasKey('md', $result); + $this->assertArrayNotHasKey('lg', $result); + $this->assertArrayNotHasKey('xl', $result); + $this->assertArrayNotHasKey('2xl', $result); + } }