From 14a2c65225a2c225d07022ad1b33dc0a716bef16 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 12 Jun 2026 10:37:21 -0400 Subject: [PATCH 1/5] Add edge image editor saves --- composer.json | 2 +- composer.lock | 904 ++++++++++-------- src/Module.php | 9 + src/imagetransforms/ImageEditor.php | 219 +++++ .../ImageTransformBehavior.php | 8 +- src/imagetransforms/ImageTransformer.php | 142 +++ tests/unit/ImageTransformTest.php | 312 ++++++ 7 files changed, 1178 insertions(+), 418 deletions(-) create mode 100644 src/imagetransforms/ImageEditor.php diff --git a/composer.json b/composer.json index 96977e4c..ff4970fd 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "ext-curl": "*", "bref/bref": "^3", "bref/extra-php-extensions": "^3", - "craftcms/cms": "^4.6 || ^5", + "craftcms/cms": "^4.18.2 || ^5.10.6", "craftcms/flysystem": "^1.0.0 || ^2.0.0", "craftcms/http-message-signatures": "^1.0", "guzzlehttp/guzzle": "^7.4.5", diff --git a/composer.lock b/composer.lock index e1602cb0..5b853bac 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "46f338fb48697e1600687619ae69a5fe", + "content-hash": "fb044d9b789ab4676758b59eb27ad384", "packages": [ { "name": "aws/aws-crt-php", @@ -159,28 +159,29 @@ }, { "name": "bacon/bacon-qr-code", - "version": "2.0.8", + "version": "v3.1.1", "source": { "type": "git", "url": "https://github.com/Bacon/BaconQrCode.git", - "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22" + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22", - "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", "shasum": "" }, "require": { "dasprid/enum": "^1.0.3", "ext-iconv": "*", - "php": "^7.1 || ^8.0" + "php": "^8.1" }, "require-dev": { - "phly/keep-a-changelog": "^2.1", - "phpunit/phpunit": "^7 | ^8 | ^9", - "spatie/phpunit-snapshot-assertions": "^4.2.9", - "squizlabs/php_codesniffer": "^3.4" + "phly/keep-a-changelog": "^2.12", + "phpunit/phpunit": "^10.5.11 || ^11.0.4", + "spatie/phpunit-snapshot-assertions": "^5.1.5", + "spatie/pixelmatch-php": "^1.2.0", + "squizlabs/php_codesniffer": "^3.9" }, "suggest": { "ext-imagick": "to generate QR code images" @@ -207,9 +208,9 @@ "homepage": "https://github.com/Bacon/BaconQrCode", "support": { "issues": "https://github.com/Bacon/BaconQrCode/issues", - "source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8" + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1" }, - "time": "2022-12-07T17:46:57+00:00" + "time": "2026-04-05T21:06:35+00:00" }, { "name": "bakame/http-structured-fields", @@ -433,23 +434,22 @@ }, { "name": "brick/math", - "version": "0.14.8", + "version": "0.18.0", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", - "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", + "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", "shasum": "" }, "require": { "php": "^8.2" }, "require-dev": { - "php-coveralls/php-coveralls": "^2.2", "phpstan/phpstan": "2.1.22", "phpunit/phpunit": "^11.5" }, @@ -481,7 +481,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.8" + "source": "https://github.com/brick/math/tree/0.18.0" }, "funding": [ { @@ -489,7 +489,7 @@ "type": "github" } ], - "time": "2026-02-10T14:33:43+00:00" + "time": "2026-06-14T18:21:03+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -846,24 +846,25 @@ }, { "name": "craftcms/cms", - "version": "5.9.17", + "version": "5.10.12", "source": { "type": "git", "url": "https://github.com/craftcms/cms.git", - "reference": "e2000a68a29718608a56f6a21357d0ca875347f0" + "reference": "4edc686e69515ad42f5608580e351c765b664f80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/cms/zipball/e2000a68a29718608a56f6a21357d0ca875347f0", - "reference": "e2000a68a29718608a56f6a21357d0ca875347f0", + "url": "https://api.github.com/repos/craftcms/cms/zipball/4edc686e69515ad42f5608580e351c765b664f80", + "reference": "4edc686e69515ad42f5608580e351c765b664f80", "shasum": "" }, "require": { - "bacon/bacon-qr-code": "^2.0", + "bacon/bacon-qr-code": "^3.0", "commerceguys/addressing": "^2.1.1", "composer/semver": "^3.3.2", "craftcms/plugin-installer": "~1.6.0", - "craftcms/server-check": "~5.0.1", + "craftcms/server-check": "~5.1.0", + "craftcms/url-validator": "^1.0", "creocoder/yii2-nested-sets": "~0.9.0", "elvanto/litemoji": "~4.3.0", "enshrined/svg-sanitize": "~0.22.0", @@ -887,7 +888,8 @@ "php": "^8.2", "phpdocumentor/reflection-docblock": "^5.3", "phpoffice/phpspreadsheet": "^5.3", - "pixelandtonic/imagine": "~1.3.3.1", + "pixelandtonic/graphql-php": "~14.11.10.1", + "pixelandtonic/imagine": "~1.5.2.1", "pragmarx/google2fa": "^8.0", "pragmarx/recovery": "^0.2.1", "samdark/yii2-psr-log-target": "^1.1.3", @@ -902,11 +904,10 @@ "symfony/var-dumper": "^5.0|^6.0|^7.0", "symfony/yaml": "^5.2.3|^6.0|^7.0", "theiconic/name-parser": "^1.2", - "twig/twig": "~3.21.1", + "twig/twig": "~3.27.0", "voku/portable-ascii": "^2.0", - "web-auth/webauthn-lib": "~5.2.4", - "webonyx/graphql-php": "~14.11.10", - "yiisoft/yii2": "~2.0.54.0", + "web-auth/webauthn-lib": "~5.3.5", + "yiisoft/yii2": "~2.0.55.0", "yiisoft/yii2-debug": "~2.1.27.0", "yiisoft/yii2-queue": "~2.3.2", "yiisoft/yii2-symfonymailer": "^4.0.0" @@ -971,7 +972,7 @@ "rss": "https://github.com/craftcms/cms/releases.atom", "source": "https://github.com/craftcms/cms" }, - "time": "2026-03-17T17:27:38+00:00" + "time": "2026-07-22T16:28:34+00:00" }, { "name": "craftcms/flysystem", @@ -1136,16 +1137,16 @@ }, { "name": "craftcms/server-check", - "version": "5.0.4", + "version": "5.1.0", "source": { "type": "git", "url": "https://github.com/craftcms/server-check.git", - "reference": "3b1f239c1cc781710978b0baa3e3bc99410d1973" + "reference": "7a4f1720c4fe1f0731254a82e63060a217f51cdc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/server-check/zipball/3b1f239c1cc781710978b0baa3e3bc99410d1973", - "reference": "3b1f239c1cc781710978b0baa3e3bc99410d1973", + "url": "https://api.github.com/repos/craftcms/server-check/zipball/7a4f1720c4fe1f0731254a82e63060a217f51cdc", + "reference": "7a4f1720c4fe1f0731254a82e63060a217f51cdc", "shasum": "" }, "type": "library", @@ -1174,7 +1175,63 @@ "rss": "https://github.com/craftcms/server-check/releases.atom", "source": "https://github.com/craftcms/server-check" }, - "time": "2025-07-23T14:22:43+00:00" + "time": "2026-04-07T16:48:35+00:00" + }, + { + "name": "craftcms/url-validator", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/craftcms/url-validator.git", + "reference": "3918c21317a856d6313b3fbf40d70ce013cdd715" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/craftcms/url-validator/zipball/3918c21317a856d6313b3fbf40d70ce013cdd715", + "reference": "3918c21317a856d6313b3fbf40d70ce013cdd715", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.0.2" + }, + "require-dev": { + "laravel/pint": "^1.29", + "nunomaduro/collision": "^8.1", + "pestphp/pest": "^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "CraftCms\\UrlValidator\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Pixel & Tonic", + "homepage": "https://pixelandtonic.com/" + } + ], + "description": "Validate URLs and IP addresses against SSRF, DNS rebinding, and cloud-metadata attacks.", + "homepage": "https://github.com/craftcms/url-validator", + "keywords": [ + "IP", + "craftcms", + "security", + "ssrf", + "url", + "validation" + ], + "support": { + "issues": "https://github.com/craftcms/url-validator/issues", + "source": "https://github.com/craftcms/url-validator/tree/1.1.0" + }, + "time": "2026-07-06T20:35:29+00:00" }, { "name": "craftcms/yii2-cache-cascade", @@ -3044,16 +3101,16 @@ }, { "name": "maennchen/zipstream-php", - "version": "3.2.1", + "version": "3.2.2", "source": { "type": "git", "url": "https://github.com/maennchen/ZipStream-PHP.git", - "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5" + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5", - "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", "shasum": "" }, "require": { @@ -3110,7 +3167,7 @@ ], "support": { "issues": "https://github.com/maennchen/ZipStream-PHP/issues", - "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1" + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2" }, "funding": [ { @@ -3118,7 +3175,7 @@ "type": "github" } ], - "time": "2025-12-10T09:58:31+00:00" + "time": "2026-04-11T18:38:28+00:00" }, { "name": "markbaker/complex", @@ -3229,16 +3286,16 @@ }, { "name": "masterminds/html5", - "version": "2.10.0", + "version": "2.10.1", "source": { "type": "git", "url": "https://github.com/Masterminds/html5-php.git", - "reference": "fcf91eb64359852f00d921887b219479b4f21251" + "reference": "fd5018f6815fff903946d0564977b44ce8010e29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", - "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29", "shasum": "" }, "require": { @@ -3246,7 +3303,7 @@ "php": ">=5.3.0" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10" }, "type": "library", "extra": { @@ -3290,9 +3347,9 @@ ], "support": { "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + "source": "https://github.com/Masterminds/html5-php/tree/2.10.1" }, - "time": "2025-07-25T09:04:22+00:00" + "time": "2026-06-23T18:43:15+00:00" }, { "name": "mikehaertl/php-shellcommand", @@ -3342,16 +3399,16 @@ }, { "name": "moneyphp/money", - "version": "v4.8.0", + "version": "v4.9.0", "source": { "type": "git", "url": "https://github.com/moneyphp/money.git", - "reference": "b358727ea5a5cd2d7475e59c31dfc352440ae7ec" + "reference": "d49ee625c6ba79b9d7a228ce153b02fc1032152b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/moneyphp/money/zipball/b358727ea5a5cd2d7475e59c31dfc352440ae7ec", - "reference": "b358727ea5a5cd2d7475e59c31dfc352440ae7ec", + "url": "https://api.github.com/repos/moneyphp/money/zipball/d49ee625c6ba79b9d7a228ce153b02fc1032152b", + "reference": "d49ee625c6ba79b9d7a228ce153b02fc1032152b", "shasum": "" }, "require": { @@ -3426,9 +3483,9 @@ ], "support": { "issues": "https://github.com/moneyphp/money/issues", - "source": "https://github.com/moneyphp/money/tree/v4.8.0" + "source": "https://github.com/moneyphp/money/tree/v4.9.0" }, - "time": "2025-10-23T07:55:09+00:00" + "time": "2026-05-04T20:23:15+00:00" }, { "name": "monolog/monolog", @@ -4089,16 +4146,16 @@ }, { "name": "phpoffice/phpspreadsheet", - "version": "5.5.0", + "version": "5.9.0", "source": { "type": "git", "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", - "reference": "eecd31b885a1c8192f12738130f85bbc6e8906ba" + "reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/eecd31b885a1c8192f12738130f85bbc6e8906ba", - "reference": "eecd31b885a1c8192f12738130f85bbc6e8906ba", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/05e99ebf61238a70227b4d9cc02d0030d34f6339", + "reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339", "shasum": "" }, "require": { @@ -4120,7 +4177,7 @@ "maennchen/zipstream-php": "^2.1 || ^3.0", "markbaker/complex": "^3.0", "markbaker/matrix": "^3.0", - "php": "^8.1", + "php": "^8.2", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" }, "require-dev": { @@ -4134,7 +4191,7 @@ "phpstan/phpstan": "^1.1 || ^2.0", "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0", "phpstan/phpstan-phpunit": "^1.0 || ^2.0", - "phpunit/phpunit": "^10.5", + "phpunit/phpunit": "^10.5 || ^11.0", "squizlabs/php_codesniffer": "^3.7", "tecnickcom/tcpdf": "^6.5" }, @@ -4192,22 +4249,22 @@ ], "support": { "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", - "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.5.0" + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.9.0" }, - "time": "2026-03-01T00:58:56+00:00" + "time": "2026-07-12T19:17:39+00:00" }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.2", + "version": "2.3.3", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", "shasum": "" }, "require": { @@ -4239,26 +4296,90 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" }, - "time": "2026-01-25T14:56:51+00:00" + "time": "2026-07-08T07:01:06+00:00" + }, + { + "name": "pixelandtonic/graphql-php", + "version": "v14.11.10.1", + "source": { + "type": "git", + "url": "https://github.com/pixelandtonic/graphql-php.git", + "reference": "fdb4a288878fc9ee449245e17209d676fc7c57fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pixelandtonic/graphql-php/zipball/fdb4a288878fc9ee449245e17209d676fc7c57fd", + "reference": "fdb4a288878fc9ee449245e17209d676fc7c57fd", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "php": "^7.1 || ^8" + }, + "require-dev": { + "amphp/amp": "^2.3", + "doctrine/coding-standard": "^6.0", + "nyholm/psr7": "^1.2", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "0.12.82", + "phpstan/phpstan-phpunit": "0.12.18", + "phpstan/phpstan-strict-rules": "0.12.9", + "phpunit/phpunit": "^7.2 || ^8.5", + "psr/http-message": "^1.0", + "react/promise": "2.*", + "simpod/php-coveralls-mirror": "^3.0" + }, + "suggest": { + "psr/http-message": "To use standard GraphQL server", + "react/promise": "To leverage async resolving on React PHP platform" + }, + "type": "library", + "autoload": { + "psr-4": { + "GraphQL\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP port of GraphQL reference implementation", + "homepage": "https://github.com/webonyx/graphql-php", + "keywords": [ + "api", + "graphql" + ], + "support": { + "source": "https://github.com/pixelandtonic/graphql-php/tree/v14.11.10.1" + }, + "funding": [ + { + "url": "https://opencollective.com/webonyx-graphql-php", + "type": "open_collective" + } + ], + "time": "2026-04-14T15:27:52+00:00" }, { "name": "pixelandtonic/imagine", - "version": "1.3.3.1", + "version": "1.5.2.1", "source": { "type": "git", "url": "https://github.com/pixelandtonic/Imagine.git", - "reference": "4d9bb596ff60504e37ccf9103c0bb705dba7fec6" + "reference": "8e6c5cf929400142724b31482da51dc556277e15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pixelandtonic/Imagine/zipball/4d9bb596ff60504e37ccf9103c0bb705dba7fec6", - "reference": "4d9bb596ff60504e37ccf9103c0bb705dba7fec6", + "url": "https://api.github.com/repos/pixelandtonic/Imagine/zipball/8e6c5cf929400142724b31482da51dc556277e15", + "reference": "8e6c5cf929400142724b31482da51dc556277e15", "shasum": "" }, "require": { - "php": ">=5.5" + "php": ">=7.1" }, "require-dev": { "phpunit/phpunit": "^4.8 || ^5.7 || ^6.5 || ^7.5 || ^8.4 || ^9.3" @@ -4291,7 +4412,7 @@ "homepage": "http://avalanche123.com" } ], - "description": "Image processing for PHP 5.3", + "description": "Image processing for PHP", "homepage": "http://imagine.readthedocs.org/", "keywords": [ "drawing", @@ -4300,9 +4421,9 @@ "image processing" ], "support": { - "source": "https://github.com/pixelandtonic/Imagine/tree/1.3.3.1" + "source": "https://github.com/pixelandtonic/Imagine/tree/1.5.2.1" }, - "time": "2023-01-03T19:18:06+00:00" + "time": "2026-02-25T23:13:43+00:00" }, { "name": "pragmarx/google2fa", @@ -5241,20 +5362,20 @@ }, { "name": "spomky-labs/cbor-php", - "version": "3.2.2", + "version": "3.3.0", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/cbor-php.git", - "reference": "2a5fb86aacfe1004611370ead6caa2bfc88435d0" + "reference": "013d13da69cf28b1ae501887daceccc850ca1c76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/2a5fb86aacfe1004611370ead6caa2bfc88435d0", - "reference": "2a5fb86aacfe1004611370ead6caa2bfc88435d0", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/013d13da69cf28b1ae501887daceccc850ca1c76", + "reference": "013d13da69cf28b1ae501887daceccc850ca1c76", "shasum": "" }, "require": { - "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14", + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18", "ext-mbstring": "*", "php": ">=8.0" }, @@ -5296,7 +5417,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/cbor-php/issues", - "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.2.2" + "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.3.0" }, "funding": [ { @@ -5308,24 +5429,24 @@ "type": "patreon" } ], - "time": "2025-11-13T13:00:34+00:00" + "time": "2026-07-15T18:56:27+00:00" }, { "name": "spomky-labs/pki-framework", - "version": "1.4.1", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/pki-framework.git", - "reference": "f0e9a548df4e3942886adc9b7830581a46334631" + "reference": "e0d61661962560c1cedfef02b51b431e720aae78" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/f0e9a548df4e3942886adc9b7830581a46334631", - "reference": "f0e9a548df4e3942886adc9b7830581a46334631", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/e0d61661962560c1cedfef02b51b431e720aae78", + "reference": "e0d61661962560c1cedfef02b51b431e720aae78", "shasum": "" }, "require": { - "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14", + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18", "ext-mbstring": "*", "php": ">=8.1" }, @@ -5345,7 +5466,7 @@ "roave/security-advisories": "dev-latest", "symfony/string": "^6.4|^7.0|^8.0", "symfony/var-dumper": "^6.4|^7.0|^8.0", - "symplify/easy-coding-standard": "^12.0" + "symplify/easy-coding-standard": "^12.0 || ^13.0" }, "suggest": { "ext-bcmath": "For better performance (or GMP)", @@ -5405,7 +5526,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/pki-framework/issues", - "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.1" + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.5.0" }, "funding": [ { @@ -5417,20 +5538,20 @@ "type": "patreon" } ], - "time": "2025-12-20T12:57:40+00:00" + "time": "2026-07-16T10:28:45+00:00" }, { "name": "symfony/clock", - "version": "v7.4.0", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "9169f24776edde469914c1e7a1442a50f7a4e110" + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/9169f24776edde469914c1e7a1442a50f7a4e110", - "reference": "9169f24776edde469914c1e7a1442a50f7a4e110", + "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", "shasum": "" }, "require": { @@ -5475,7 +5596,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v7.4.0" + "source": "https://github.com/symfony/clock/tree/v7.4.8" }, "funding": [ { @@ -5495,20 +5616,20 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:39:26+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/css-selector", - "version": "v7.4.6", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "2e7c52c647b406e2107dd867db424a4dbac91864" + "reference": "b75663ed96cf4756e28e3105476f220f92886cc4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/2e7c52c647b406e2107dd867db424a4dbac91864", - "reference": "2e7c52c647b406e2107dd867db424a4dbac91864", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/b75663ed96cf4756e28e3105476f220f92886cc4", + "reference": "b75663ed96cf4756e28e3105476f220f92886cc4", "shasum": "" }, "require": { @@ -5544,7 +5665,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.4.6" + "source": "https://github.com/symfony/css-selector/tree/v7.4.9" }, "funding": [ { @@ -5564,20 +5685,20 @@ "type": "tidelift" } ], - "time": "2026-02-17T07:53:42+00:00" + "time": "2026-04-18T13:18:21+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -5590,7 +5711,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -5615,7 +5736,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -5626,25 +5747,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/dom-crawler", - "version": "v7.4.6", + "version": "v7.4.12", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "487ba8fa43da9a8e6503fe939b45ecd96875410e" + "reference": "b59b59122690976550fd142c23fab62c84738db6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/487ba8fa43da9a8e6503fe939b45ecd96875410e", - "reference": "487ba8fa43da9a8e6503fe939b45ecd96875410e", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/b59b59122690976550fd142c23fab62c84738db6", + "reference": "b59b59122690976550fd142c23fab62c84738db6", "shasum": "" }, "require": { @@ -5683,7 +5808,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v7.4.6" + "source": "https://github.com/symfony/dom-crawler/tree/v7.4.12" }, "funding": [ { @@ -5703,20 +5828,20 @@ "type": "tidelift" } ], - "time": "2026-02-17T07:53:42+00:00" + "time": "2026-05-20T07:20:23+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.4.4", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "dc2c0eba1af673e736bb851d747d266108aea746" + "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/dc2c0eba1af673e736bb851d747d266108aea746", - "reference": "dc2c0eba1af673e736bb851d747d266108aea746", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/51fe3d170227be8d1772214b82ae506e15ed78ff", + "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff", "shasum": "" }, "require": { @@ -5768,7 +5893,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.4" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.14" }, "funding": [ { @@ -5788,20 +5913,20 @@ "type": "tidelift" } ], - "time": "2026-01-05T11:45:34+00:00" + "time": "2026-06-06T11:10:32+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.6.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -5815,7 +5940,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -5848,7 +5973,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -5859,25 +5984,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/filesystem", - "version": "v6.4.34", + "version": "v6.4.39", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "01ffe0411b842f93c571e5c391f289c3fdd498c3" + "reference": "c507b077756b4e3e09adbbe7975fac81cd3722ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/01ffe0411b842f93c571e5c391f289c3fdd498c3", - "reference": "01ffe0411b842f93c571e5c391f289c3fdd498c3", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/c507b077756b4e3e09adbbe7975fac81cd3722ca", + "reference": "c507b077756b4e3e09adbbe7975fac81cd3722ca", "shasum": "" }, "require": { @@ -5914,7 +6043,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.34" + "source": "https://github.com/symfony/filesystem/tree/v6.4.39" }, "funding": [ { @@ -5934,20 +6063,20 @@ "type": "tidelift" } ], - "time": "2026-02-24T17:51:06+00:00" + "time": "2026-05-07T13:11:42+00:00" }, { "name": "symfony/http-client", - "version": "v7.4.7", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "1010624285470eb60e88ed10035102c75b4ea6af" + "reference": "f6bc6b5a54ff5afac4725cacec9bf2f52eb15920" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/1010624285470eb60e88ed10035102c75b4ea6af", - "reference": "1010624285470eb60e88ed10035102c75b4ea6af", + "url": "https://api.github.com/repos/symfony/http-client/zipball/f6bc6b5a54ff5afac4725cacec9bf2f52eb15920", + "reference": "f6bc6b5a54ff5afac4725cacec9bf2f52eb15920", "shasum": "" }, "require": { @@ -6015,7 +6144,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.7" + "source": "https://github.com/symfony/http-client/tree/v7.4.14" }, "funding": [ { @@ -6035,20 +6164,20 @@ "type": "tidelift" } ], - "time": "2026-03-05T11:16:58+00:00" + "time": "2026-06-16T11:50:14+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.6.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "75d7043853a42837e68111812f4d964b01e5101c" + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", - "reference": "75d7043853a42837e68111812f4d964b01e5101c", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", "shasum": "" }, "require": { @@ -6061,7 +6190,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -6097,7 +6226,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" }, "funding": [ { @@ -6108,25 +6237,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-29T11:18:49+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.6", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9" + "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/b02726f39a20bc65e30364f5c750c4ddbf1f58e9", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9", + "url": "https://api.github.com/repos/symfony/mailer/zipball/f88ce03ae73e3edb5c176ce1f337709996e88495", + "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495", "shasum": "" }, "require": { @@ -6177,7 +6310,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.6" + "source": "https://github.com/symfony/mailer/tree/v7.4.14" }, "funding": [ { @@ -6197,20 +6330,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-06-13T08:51:35+00:00" }, { "name": "symfony/mime", - "version": "v7.4.7", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1" + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/da5ab4fde3f6c88ab06e96185b9922f48b677cd1", - "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1", + "url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770", + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770", "shasum": "" }, "require": { @@ -6266,7 +6399,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.7" + "source": "https://github.com/symfony/mime/tree/v7.4.13" }, "funding": [ { @@ -6286,20 +6419,20 @@ "type": "tidelift" } ], - "time": "2026-03-05T15:24:09+00:00" + "time": "2026-05-23T16:22:37+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -6349,7 +6482,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -6369,20 +6502,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.33.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", "shasum": "" }, "require": { @@ -6431,7 +6564,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" }, "funding": [ { @@ -6451,20 +6584,20 @@ "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-05-26T05:58:03+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.33.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + "reference": "dc21118016c039a66235cf93d96b435ffb282412" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", "shasum": "" }, "require": { @@ -6518,7 +6651,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" }, "funding": [ { @@ -6538,20 +6671,20 @@ "type": "tidelift" } ], - "time": "2024-09-10T14:38:51+00:00" + "time": "2026-05-25T15:22:23+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.33.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { @@ -6603,7 +6736,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -6623,20 +6756,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { @@ -6688,7 +6821,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -6708,20 +6841,20 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -6772,7 +6905,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -6792,20 +6925,20 @@ "type": "tidelift" } ], - "time": "2025-01-02T08:10:11+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.33.0", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", "shasum": "" }, "require": { @@ -6852,7 +6985,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" }, "funding": [ { @@ -6872,20 +7005,20 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-05-27T06:51:48+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.33.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { @@ -6932,7 +7065,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -6952,20 +7085,20 @@ "type": "tidelift" } ], - "time": "2025-06-24T13:30:11+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", "shasum": "" }, "require": { @@ -7015,7 +7148,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" }, "funding": [ { @@ -7035,7 +7168,7 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/process", @@ -7104,16 +7237,16 @@ }, { "name": "symfony/property-access", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/property-access.git", - "reference": "fa49bf1ca8fce1ba0e2dba4e4658554cfb9364b1" + "reference": "b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-access/zipball/fa49bf1ca8fce1ba0e2dba4e4658554cfb9364b1", - "reference": "fa49bf1ca8fce1ba0e2dba4e4658554cfb9364b1", + "url": "https://api.github.com/repos/symfony/property-access/zipball/b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc", + "reference": "b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc", "shasum": "" }, "require": { @@ -7161,7 +7294,7 @@ "reflection" ], "support": { - "source": "https://github.com/symfony/property-access/tree/v7.4.4" + "source": "https://github.com/symfony/property-access/tree/v7.4.8" }, "funding": [ { @@ -7181,20 +7314,20 @@ "type": "tidelift" } ], - "time": "2026-01-05T08:47:25+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/property-info", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "02501d75fd834345da3ecdd8e3200ced39e370f8" + "reference": "ac5e82528b986c4f7cfccbf7764b5d2e824d6175" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/02501d75fd834345da3ecdd8e3200ced39e370f8", - "reference": "02501d75fd834345da3ecdd8e3200ced39e370f8", + "url": "https://api.github.com/repos/symfony/property-info/zipball/ac5e82528b986c4f7cfccbf7764b5d2e824d6175", + "reference": "ac5e82528b986c4f7cfccbf7764b5d2e824d6175", "shasum": "" }, "require": { @@ -7251,7 +7384,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v7.4.7" + "source": "https://github.com/symfony/property-info/tree/v7.4.8" }, "funding": [ { @@ -7271,20 +7404,20 @@ "type": "tidelift" } ], - "time": "2026-03-04T15:53:26+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/serializer", - "version": "v7.4.7", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "bd395bbc6fabd136a48e1a6f91b09f88b5050b0b" + "reference": "55acb01b9c8a5211dfbaf68c314d90d0ed2cc3d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/bd395bbc6fabd136a48e1a6f91b09f88b5050b0b", - "reference": "bd395bbc6fabd136a48e1a6f91b09f88b5050b0b", + "url": "https://api.github.com/repos/symfony/serializer/zipball/55acb01b9c8a5211dfbaf68c314d90d0ed2cc3d1", + "reference": "55acb01b9c8a5211dfbaf68c314d90d0ed2cc3d1", "shasum": "" }, "require": { @@ -7297,7 +7430,7 @@ "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", "symfony/dependency-injection": "<6.4", - "symfony/property-access": "<6.4", + "symfony/property-access": "<6.4.31|>=7.0,<7.4.2|>=8.0,<8.0.2", "symfony/property-info": "<6.4", "symfony/type-info": "<7.2.5", "symfony/uid": "<6.4", @@ -7319,7 +7452,7 @@ "symfony/http-kernel": "^6.4|^7.0|^8.0", "symfony/messenger": "^6.4|^7.0|^8.0", "symfony/mime": "^6.4|^7.0|^8.0", - "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4.31|^7.4.2|^8.0.2", "symfony/property-info": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3", "symfony/type-info": "^7.2.5|^8.0", @@ -7355,7 +7488,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v7.4.7" + "source": "https://github.com/symfony/serializer/tree/v7.4.14" }, "funding": [ { @@ -7375,20 +7508,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:15:18+00:00" + "time": "2026-06-27T08:31:18+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { @@ -7406,7 +7539,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -7442,7 +7575,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -7462,20 +7595,20 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { "name": "symfony/string", - "version": "v7.4.6", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "9f209231affa85aa930a5e46e6eb03381424b30b" + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/9f209231affa85aa930a5e46e6eb03381424b30b", - "reference": "9f209231affa85aa930a5e46e6eb03381424b30b", + "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", "shasum": "" }, "require": { @@ -7533,7 +7666,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.6" + "source": "https://github.com/symfony/string/tree/v7.4.13" }, "funding": [ { @@ -7553,20 +7686,20 @@ "type": "tidelift" } ], - "time": "2026-02-09T09:33:46+00:00" + "time": "2026-05-23T15:23:29+00:00" }, { "name": "symfony/translation", - "version": "v6.4.34", + "version": "v6.4.42", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "d07d117db41341511671b0a1a2be48f2772189ce" + "reference": "fef99cef37890b350976f5f492854faefadd4e15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/d07d117db41341511671b0a1a2be48f2772189ce", - "reference": "d07d117db41341511671b0a1a2be48f2772189ce", + "url": "https://api.github.com/repos/symfony/translation/zipball/fef99cef37890b350976f5f492854faefadd4e15", + "reference": "fef99cef37890b350976f5f492854faefadd4e15", "shasum": "" }, "require": { @@ -7632,7 +7765,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v6.4.34" + "source": "https://github.com/symfony/translation/tree/v6.4.42" }, "funding": [ { @@ -7652,20 +7785,20 @@ "type": "tidelift" } ], - "time": "2026-02-16T20:44:03+00:00" + "time": "2026-06-05T16:46:18+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.6.1", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977" + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", "shasum": "" }, "require": { @@ -7678,7 +7811,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -7714,7 +7847,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" }, "funding": [ { @@ -7734,20 +7867,20 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/type-info", - "version": "v7.4.7", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "31f1e40cbf7851c7354281c90eb1b352c4cb8269" + "reference": "cafeedbf157b890e94ac5b83eaed85595106d5d6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/31f1e40cbf7851c7354281c90eb1b352c4cb8269", - "reference": "31f1e40cbf7851c7354281c90eb1b352c4cb8269", + "url": "https://api.github.com/repos/symfony/type-info/zipball/cafeedbf157b890e94ac5b83eaed85595106d5d6", + "reference": "cafeedbf157b890e94ac5b83eaed85595106d5d6", "shasum": "" }, "require": { @@ -7797,7 +7930,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v7.4.7" + "source": "https://github.com/symfony/type-info/tree/v7.4.9" }, "funding": [ { @@ -7817,20 +7950,20 @@ "type": "tidelift" } ], - "time": "2026-03-04T12:49:16+00:00" + "time": "2026-04-22T15:21:55+00:00" }, { "name": "symfony/uid", - "version": "v7.4.4", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" + "reference": "2676b524340abcfe4d6151ec698463cebafee439" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", + "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", + "reference": "2676b524340abcfe4d6151ec698463cebafee439", "shasum": "" }, "require": { @@ -7875,7 +8008,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.4" + "source": "https://github.com/symfony/uid/tree/v7.4.9" }, "funding": [ { @@ -7895,20 +8028,20 @@ "type": "tidelift" } ], - "time": "2026-01-03T23:30:35+00:00" + "time": "2026-04-30T15:19:22+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.6", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291" + "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", + "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", "shasum": "" }, "require": { @@ -7962,7 +8095,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.6" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.14" }, "funding": [ { @@ -7982,20 +8115,20 @@ "type": "tidelift" } ], - "time": "2026-02-15T10:53:20+00:00" + "time": "2026-06-08T20:24:16+00:00" }, { "name": "symfony/yaml", - "version": "v7.4.6", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "58751048de17bae71c5aa0d13cb19d79bca26391" + "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/58751048de17bae71c5aa0d13cb19d79bca26391", - "reference": "58751048de17bae71c5aa0d13cb19d79bca26391", + "url": "https://api.github.com/repos/symfony/yaml/zipball/f8f328665ace2370d1e10645b807ba1646dc7dcc", + "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc", "shasum": "" }, "require": { @@ -8038,7 +8171,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.6" + "source": "https://github.com/symfony/yaml/tree/v7.4.14" }, "funding": [ { @@ -8058,7 +8191,7 @@ "type": "tidelift" } ], - "time": "2026-02-09T09:33:46+00:00" + "time": "2026-06-08T20:24:16+00:00" }, { "name": "theiconic/name-parser", @@ -8110,16 +8243,16 @@ }, { "name": "twig/twig", - "version": "v3.21.1", + "version": "v3.27.1", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "285123877d4dd97dd7c11842ac5fb7e86e60d81d" + "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/285123877d4dd97dd7c11842ac5fb7e86e60d81d", - "reference": "285123877d4dd97dd7c11842ac5fb7e86e60d81d", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/ae2071bffb38f04847fc0864d730c94b9cb8ab74", + "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74", "shasum": "" }, "require": { @@ -8129,7 +8262,8 @@ "symfony/polyfill-mbstring": "^1.3" }, "require-dev": { - "phpstan/phpstan": "^2.0", + "php-cs-fixer/shim": "^3.0@stable", + "phpstan/phpstan": "^2.0@stable", "psr/container": "^1.0|^2.0", "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" }, @@ -8173,7 +8307,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.21.1" + "source": "https://github.com/twigphp/Twig/tree/v3.27.1" }, "funding": [ { @@ -8185,27 +8319,27 @@ "type": "tidelift" } ], - "time": "2025-05-03T07:21:55+00:00" + "time": "2026-05-30T17:09:26+00:00" }, { "name": "voku/portable-ascii", - "version": "2.0.3", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/voku/portable-ascii.git", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", "shasum": "" }, "require": { - "php": ">=7.0.0" + "php": ">=7.1.0" }, "require-dev": { - "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" }, "suggest": { "ext-intl": "Use Intl for transliterator_transliterate() support" @@ -8235,7 +8369,7 @@ ], "support": { "issues": "https://github.com/voku/portable-ascii/issues", - "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" }, "funding": [ { @@ -8259,24 +8393,24 @@ "type": "tidelift" } ], - "time": "2024-11-21T01:49:47+00:00" + "time": "2026-04-26T05:33:54+00:00" }, { "name": "web-auth/cose-lib", - "version": "4.5.0", + "version": "4.6.0", "source": { "type": "git", "url": "https://github.com/web-auth/cose-lib.git", - "reference": "5adac6fe126994a3ee17ed9950efb4947ab132a9" + "reference": "3afe04df137baf97c5c3e28c5ee6f05536405148" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/5adac6fe126994a3ee17ed9950efb4947ab132a9", - "reference": "5adac6fe126994a3ee17ed9950efb4947ab132a9", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/3afe04df137baf97c5c3e28c5ee6f05536405148", + "reference": "3afe04df137baf97c5c3e28c5ee6f05536405148", "shasum": "" }, "require": { - "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14", + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18", "ext-json": "*", "ext-openssl": "*", "php": ">=8.1", @@ -8318,7 +8452,7 @@ ], "support": { "issues": "https://github.com/web-auth/cose-lib/issues", - "source": "https://github.com/web-auth/cose-lib/tree/4.5.0" + "source": "https://github.com/web-auth/cose-lib/tree/4.6.0" }, "funding": [ { @@ -8330,20 +8464,20 @@ "type": "patreon" } ], - "time": "2026-01-03T14:43:18+00:00" + "time": "2026-07-16T10:19:49+00:00" }, { "name": "web-auth/webauthn-lib", - "version": "5.2.4", + "version": "5.3.5", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-lib.git", - "reference": "c346c9812d4d4a641f5ff26cd5fa4d0bf2035eeb" + "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/c346c9812d4d4a641f5ff26cd5fa4d0bf2035eeb", - "reference": "c346c9812d4d4a641f5ff26cd5fa4d0bf2035eeb", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/9e0986d999f4102e24ac8a598d3a80d98b56c19f", + "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f", "shasum": "" }, "require": { @@ -8351,18 +8485,18 @@ "ext-openssl": "*", "paragonie/constant_time_encoding": "^2.6|^3.0", "php": ">=8.2", - "phpdocumentor/reflection-docblock": "^5.3", + "phpdocumentor/reflection-docblock": "^5.3|^6.0", "psr/clock": "^1.0", "psr/event-dispatcher": "^1.0", "psr/log": "^1.0|^2.0|^3.0", "spomky-labs/cbor-php": "^3.0", "spomky-labs/pki-framework": "^1.0", - "symfony/clock": "^6.4|^7.0", + "symfony/clock": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^3.2", - "symfony/property-access": "^6.4|^7.0", - "symfony/property-info": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0", - "symfony/uid": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", "web-auth/cose-lib": "^4.2.3" }, "suggest": { @@ -8404,7 +8538,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-lib/tree/5.2.4" + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.5" }, "funding": [ { @@ -8416,20 +8550,20 @@ "type": "patreon" } ], - "time": "2026-03-08T17:01:15+00:00" + "time": "2026-05-31T15:00:08+00:00" }, { "name": "webmozart/assert", - "version": "2.1.6", + "version": "2.4.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8" + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8", - "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", "shasum": "" }, "require": { @@ -8445,7 +8579,11 @@ }, "type": "library", "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, "branch-alias": { + "dev-master": "2.0-dev", "dev-feature/2-0": "2.0-dev" } }, @@ -8476,87 +8614,22 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.6" + "source": "https://github.com/webmozarts/assert/tree/2.4.1" }, - "time": "2026-02-27T10:28:38+00:00" - }, - { - "name": "webonyx/graphql-php", - "version": "v14.11.10", - "source": { - "type": "git", - "url": "https://github.com/webonyx/graphql-php.git", - "reference": "d9c2fdebc6aa01d831bc2969da00e8588cffef19" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/d9c2fdebc6aa01d831bc2969da00e8588cffef19", - "reference": "d9c2fdebc6aa01d831bc2969da00e8588cffef19", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-mbstring": "*", - "php": "^7.1 || ^8" - }, - "require-dev": { - "amphp/amp": "^2.3", - "doctrine/coding-standard": "^6.0", - "nyholm/psr7": "^1.2", - "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "0.12.82", - "phpstan/phpstan-phpunit": "0.12.18", - "phpstan/phpstan-strict-rules": "0.12.9", - "phpunit/phpunit": "^7.2 || ^8.5", - "psr/http-message": "^1.0", - "react/promise": "2.*", - "simpod/php-coveralls-mirror": "^3.0" - }, - "suggest": { - "psr/http-message": "To use standard GraphQL server", - "react/promise": "To leverage async resolving on React PHP platform" - }, - "type": "library", - "autoload": { - "psr-4": { - "GraphQL\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A PHP port of GraphQL reference implementation", - "homepage": "https://github.com/webonyx/graphql-php", - "keywords": [ - "api", - "graphql" - ], - "support": { - "issues": "https://github.com/webonyx/graphql-php/issues", - "source": "https://github.com/webonyx/graphql-php/tree/v14.11.10" - }, - "funding": [ - { - "url": "https://opencollective.com/webonyx-graphql-php", - "type": "open_collective" - } - ], - "time": "2023-07-05T14:23:37+00:00" + "time": "2026-06-15T15:31:57+00:00" }, { "name": "yiisoft/yii2", - "version": "2.0.54", + "version": "2.0.55", "source": { "type": "git", "url": "https://github.com/yiisoft/yii2-framework.git", - "reference": "99daebf2de0b031d129706a5db6ce0802c70bac9" + "reference": "b900eecdb225041a4c4e0f5e0e5336f606a23bdb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/yiisoft/yii2-framework/zipball/99daebf2de0b031d129706a5db6ce0802c70bac9", - "reference": "99daebf2de0b031d129706a5db6ce0802c70bac9", + "url": "https://api.github.com/repos/yiisoft/yii2-framework/zipball/b900eecdb225041a4c4e0f5e0e5336f606a23bdb", + "reference": "b900eecdb225041a4c4e0f5e0e5336f606a23bdb", "shasum": "" }, "require": { @@ -8664,7 +8737,7 @@ "type": "tidelift" } ], - "time": "2026-01-09T22:24:11+00:00" + "time": "2026-05-09T14:50:57+00:00" }, { "name": "yiisoft/yii2-composer", @@ -9832,20 +9905,19 @@ }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -9884,9 +9956,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "phar-io/manifest", @@ -11689,16 +11761,16 @@ }, { "name": "symfony/console", - "version": "v7.4.7", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", - "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", "shasum": "" }, "require": { @@ -11763,7 +11835,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.7" + "source": "https://github.com/symfony/console/tree/v7.4.14" }, "funding": [ { @@ -11783,20 +11855,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T14:06:20+00:00" + "time": "2026-06-16T11:50:14+00:00" }, { "name": "symfony/finder", - "version": "v7.4.6", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" + "reference": "13b38720174286f55d1761152b575a8d1436fc25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", + "reference": "13b38720174286f55d1761152b575a8d1436fc25", "shasum": "" }, "require": { @@ -11831,7 +11903,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.6" + "source": "https://github.com/symfony/finder/tree/v7.4.14" }, "funding": [ { @@ -11851,19 +11923,19 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:40:50+00:00" + "time": "2026-06-27T08:31:18+00:00" }, { "name": "symplify/easy-coding-standard", "version": "10.3.3", "source": { "type": "git", - "url": "https://github.com/symplify/easy-coding-standard.git", + "url": "https://github.com/ecsphp/ecs.git", "reference": "c93878b3c052321231519b6540e227380f90be17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symplify/easy-coding-standard/zipball/c93878b3c052321231519b6540e227380f90be17", + "url": "https://api.github.com/repos/ecsphp/ecs/zipball/c93878b3c052321231519b6540e227380f90be17", "reference": "c93878b3c052321231519b6540e227380f90be17", "shasum": "" }, diff --git a/src/Module.php b/src/Module.php index cf6b2435..562a06a0 100644 --- a/src/Module.php +++ b/src/Module.php @@ -6,6 +6,7 @@ use craft\base\Event; use craft\base\Model; use craft\cloud\fs\AssetsFs; +use craft\cloud\imagetransforms\ImageEditor; use craft\cloud\imagetransforms\ImageTransformBehavior; use craft\cloud\imagetransforms\ImageTransformer; use craft\cloud\signing\RequestSigner; @@ -15,6 +16,7 @@ use craft\cloud\web\assets\uploader\UploaderAsset; use craft\cloud\web\ResponseEventHandler; use craft\console\Application as ConsoleApplication; +use craft\controllers\AssetsController as CraftAssetsController; use craft\elements\Asset; use craft\events\DefineAssetThumbUrlEvent; use craft\events\DefineAssetUrlEvent; @@ -23,6 +25,7 @@ use craft\events\GenerateTransformEvent; use craft\events\RegisterComponentTypesEvent; use craft\events\RegisterTemplateRootsEvent; +use craft\events\SaveAssetImageEvent; use craft\helpers\App; use craft\helpers\ConfigHelper; use craft\log\MonologTarget; @@ -124,6 +127,12 @@ public function bootstrap($app): void if ($this->getConfig()->useAssetCdn) { $app->getImages()->supportedImageFormats = ImageTransformer::SUPPORTED_IMAGE_FORMATS; + Event::on( + CraftAssetsController::class, + CraftAssetsController::EVENT_BEFORE_SAVE_IMAGE, + static fn(SaveAssetImageEvent $event) => (new ImageEditor())->handleSaveImage($event), + ); + Event::on( Asset::class, Asset::EVENT_BEFORE_GENERATE_TRANSFORM, diff --git a/src/imagetransforms/ImageEditor.php b/src/imagetransforms/ImageEditor.php new file mode 100644 index 00000000..5de0521a --- /dev/null +++ b/src/imagetransforms/ImageEditor.php @@ -0,0 +1,219 @@ +asset ?? null; + + if (!$asset instanceof Asset || !$asset->getVolume()->getFs() instanceof AssetsFs) { + return; + } + + $event->handled = true; + + try { + $editedAsset = $this->editImage( + $asset, + $event->replace, + $event->viewportRotation, + $event->imageRotation, + $event->cropData, + $event->focalPoint, + $event->imageDimensions, + $event->flipData, + $event->zoom, + ); + } catch (NotSupportedException $e) { + throw new BadRequestHttpException($e->getMessage(), 0, $e); + } catch (RequestException $e) { + $message = trim((string)$e->getResponse()?->getBody()) ?: $e->getMessage(); + + throw new BadRequestHttpException($message, 0, $e); + } + + $event->newAssetId = $event->replace ? null : $editedAsset->id; + } + + public function editImage( + Asset $asset, + bool $replace, + int $viewportRotation, + float $imageRotation, + array $cropData, + ?array $focalPoint, + array $imageDimensions, + ?array $flipData, + float $zoom, + ): Asset { + $focal = $this->focalPoint($asset, $focalPoint, $viewportRotation, $imageRotation, $cropData, $imageDimensions, $zoom); + $transform = ImageTransformer::fromImageEditor($asset, $viewportRotation, $imageRotation, $cropData, $imageDimensions, $flipData, $zoom, $focalPoint); + + if ($transform === null && !$this->focalPointChanged($asset, $focal)) { + return $replace ? $asset : $this->createAsset($asset, null, $focal); + } + + if ($replace) { + $this->replaceAsset($asset, $transform, $focal); + + return $asset; + } + + return $this->createAsset($asset, $transform, $focal); + } + + protected function focalPoint(Asset $asset, ?array $focalPoint, int $viewportRotation, float $imageRotation, array $cropData, array $imageDimensions, float $zoom): ?array + { + if (!$focalPoint) { + return null; + } + + if (!$asset->width || !$asset->height) { + throw new NotSupportedException('Image dimensions are required to edit images.'); + } + + $rotation = $this->rotation($viewportRotation, $imageRotation); + $crop = $this->crop($asset, $rotation, $cropData, $imageDimensions, $zoom); + $editedDimensions = $this->rotatedDimensions($asset->width, $asset->height, $rotation); + $adjustmentRatio = min( + $asset->width / $focalPoint['imageDimensions']['width'], + $asset->height / $focalPoint['imageDimensions']['height'], + ); + + return [ + 'x' => (($editedDimensions['width'] / 2) + ($focalPoint['offsetX'] * $zoom * $adjustmentRatio) - $crop['left']) / $crop['width'], + 'y' => (($editedDimensions['height'] / 2) + ($focalPoint['offsetY'] * $zoom * $adjustmentRatio) - $crop['top']) / $crop['height'], + ]; + } + + protected function crop(Asset $asset, int $rotation, array $cropData, array $imageDimensions, float $zoom): array + { + $adjustmentRatio = min( + $asset->width / $imageDimensions['width'], + $asset->height / $imageDimensions['height'], + ); + $editedDimensions = $this->rotatedDimensions($asset->width, $asset->height, $rotation); + + $width = (int)round($cropData['width'] * $zoom * $adjustmentRatio); + $height = (int)round($cropData['height'] * $zoom * $adjustmentRatio); + + return [ + 'left' => (int)round(($editedDimensions['width'] / 2) + ($cropData['offsetX'] * $zoom * $adjustmentRatio) - ($width / 2)), + 'top' => (int)round(($editedDimensions['height'] / 2) + ($cropData['offsetY'] * $zoom * $adjustmentRatio) - ($height / 2)), + 'width' => $width, + 'height' => $height, + ]; + } + + protected function rotation(int $viewportRotation, float $imageRotation): int + { + return ((int)round($imageRotation + $viewportRotation) % 360 + 360) % 360; + } + + protected function rotatedDimensions(int|float $width, int|float $height, int $rotation): array + { + return in_array($rotation, [90, 270], true) + ? ['width' => (int)round($height), 'height' => (int)round($width)] + : ['width' => (int)round($width), 'height' => (int)round($height)]; + } + + protected function focalPointChanged(Asset $asset, ?array $focal): bool + { + $oldFocal = $asset->getHasFocalPoint() ? $asset->getFocalPoint() : null; + + return $focal !== $oldFocal; + } + + protected function replaceAsset(Asset $asset, ?ImageTransform $transform, ?array $focal): void + { + $focalPointChanged = $this->focalPointChanged($asset, $focal); + $tempPath = null; + + $asset->setFocalPoint($focal); + + if ($focalPointChanged) { + Craft::$app->getImageTransforms()->deleteCreatedTransformsForAsset($asset); + } + + try { + if ($transform !== null) { + $tempPath = $this->downloadEditedImage($asset, $transform); + $asset->sanitizeOnUpload = false; + Craft::$app->getAssets()->replaceAssetFile($asset, $tempPath, $asset->getFilename()); + return; + } + + Craft::$app->getElements()->saveElement($asset); + } finally { + if ($tempPath !== null && file_exists($tempPath)) { + unlink($tempPath); + } + } + } + + protected function createAsset(Asset $asset, ?ImageTransform $transform, ?array $focal): Asset + { + $tempPath = null; + $newAsset = new Asset(); + $newAsset->avoidFilenameConflicts = true; + $newAsset->setScenario(Asset::SCENARIO_CREATE); + $newAsset->sanitizeOnUpload = false; + $newAsset->setFilename($asset->getFilename()); + $newAsset->newFolderId = $asset->folderId; + $newAsset->setVolumeId($asset->volumeId); + $newAsset->setFocalPoint($focal); + + try { + if ($transform !== null) { + $tempPath = $this->downloadEditedImage($asset, $transform); + } + + $newAsset->tempFilePath = $tempPath ?? $asset->getCopyOfFile(); + + Craft::$app->getElements()->saveElement($newAsset); + } finally { + if ($tempPath !== null && file_exists($tempPath)) { + unlink($tempPath); + } + } + + return $newAsset; + } + + protected function downloadEditedImage(Asset $asset, ImageTransform $transform): string + { + $path = sprintf( + '%s/%s.%s', + Craft::$app->getPath()->getTempPath(), + StringHelper::UUID(), + $asset->getExtension(), + ); + + try { + Craft::createGuzzleClient()->get((new ImageTransformer())->getTransformUrl($asset, $transform, true), [ + RequestOptions::SINK => $path, + ]); + } catch (\Throwable $e) { + if (file_exists($path)) { + unlink($path); + } + + throw $e; + } + + return $path; + } +} diff --git a/src/imagetransforms/ImageTransformBehavior.php b/src/imagetransforms/ImageTransformBehavior.php index 04bef7e6..e8b130d7 100644 --- a/src/imagetransforms/ImageTransformBehavior.php +++ b/src/imagetransforms/ImageTransformBehavior.php @@ -8,7 +8,7 @@ use yii\base\Behavior; /** - * @see https://developers.cloudflare.com/images/transform-images/transform-via-workers/#fetch-options + * @see https://developers.cloudflare.com/images/optimization/features/#parameters * @see https://github.com/cloudflare/workerd/blob/main/types/defines/cf.d.ts * * @property ImageTransform $owner @@ -83,6 +83,11 @@ class ImageTransformBehavior extends Behavior */ public ?int $page = null; + /** + * @var int<1, 100>|'high'|'medium-high'|'medium-low'|'low'|null + */ + public int|string|null $quality = null; + /** * @var int|null */ @@ -126,6 +131,7 @@ public function toOptions(array|string|null $gravity = null): array $options['background'] = $this->computeBackground(); $options['gravity'] ??= $gravity ?? $this->computeGravity(); $options['height'] = $this->owner->height; + $options['quality'] ??= $this->owner->quality; $options['width'] = $this->owner->width; return Collection::make($options) diff --git a/src/imagetransforms/ImageTransformer.php b/src/imagetransforms/ImageTransformer.php index 67e170e4..f192959b 100644 --- a/src/imagetransforms/ImageTransformer.php +++ b/src/imagetransforms/ImageTransformer.php @@ -27,6 +27,148 @@ class ImageTransformer extends Component implements ImageTransformerInterface // Source asset extensions Cloudflare Images can accept for transformations. public const SUPPORTED_IMAGE_FORMATS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'avif', 'heic']; + public static function fromImageEditor( + Asset $asset, + int $viewportRotation, + float $imageRotation, + array $cropData, + array $imageDimensions, + ?array $flipData, + float $zoom, + ?array $focalPoint = null, + ): ?ImageTransform { + $rotation = ((int)round($imageRotation + $viewportRotation) % 360 + 360) % 360; + $flipX = !empty($flipData['x']); + $flipY = !empty($flipData['y']); + $flip = match (true) { + $flipX && $flipY => 'hv', + $flipX => 'h', + $flipY => 'v', + default => null, + }; + $sourceReferenceDimensions = self::rotatedDimensions($imageDimensions['width'], $imageDimensions['height'], 0); + $cropReferenceDimensions = self::rotatedDimensions($imageDimensions['width'], $imageDimensions['height'], $rotation); + $cropDimensions = [ + 'width' => (int)round($cropData['width']), + 'height' => (int)round($cropData['height']), + ]; + $imageCropped = $cropDimensions !== $sourceReferenceDimensions && $cropDimensions !== $cropReferenceDimensions; + $imageRotated = $rotation !== 0; + $imageFlipped = $flip !== null; + + if (!$imageCropped && !$imageRotated && !$imageFlipped) { + return null; + } + + if (!$asset->width || !$asset->height) { + throw new NotSupportedException('Image dimensions are required to edit images.'); + } + + $transform = new ImageTransform([ + 'width' => $asset->width, + 'height' => $asset->height, + ]); + + /** @var ImageTransformBehavior $behavior */ + $behavior = $transform->getBehavior('cloud'); + $behavior->fit = 'crop'; + + if ($zoom !== 1.0) { + $behavior->zoom = max(0, min(1, 1 - (1 / $zoom))); + } + + if ($imageCropped) { + $crop = self::crop($asset, $rotation, $cropData, $imageDimensions, $zoom); + + $transform->width = $crop['width']; + $transform->height = $crop['height']; + $behavior->trim = $crop; + } + + if ($imageRotated) { + $behavior->rotate = $rotation; + + if (in_array($rotation, [90, 270], true)) { + [$transform->width, $transform->height] = [$transform->height, $transform->width]; + } + } + + if ($imageFlipped) { + $behavior->flip = $flip; + } + + return $transform; + } + + private static function crop(Asset $asset, int $rotation, array $cropData, array $imageDimensions, float $zoom): array + { + $adjustmentRatio = min( + $asset->width / $imageDimensions['width'], + $asset->height / $imageDimensions['height'], + ); + $editedDimensions = self::rotatedDimensions($asset->width, $asset->height, $rotation); + + $width = (int)round($cropData['width'] * $zoom * $adjustmentRatio); + $height = (int)round($cropData['height'] * $zoom * $adjustmentRatio); + $crop = [ + 'left' => (int)round(($editedDimensions['width'] / 2) + ($cropData['offsetX'] * $zoom * $adjustmentRatio) - ($width / 2)), + 'top' => (int)round(($editedDimensions['height'] / 2) + ($cropData['offsetY'] * $zoom * $adjustmentRatio) - ($height / 2)), + 'width' => $width, + 'height' => $height, + ]; + + return self::constrainCrop( + self::sourceCrop($crop, $asset->width, $asset->height, $rotation), + $asset->width, + $asset->height, + ); + } + + private static function sourceCrop(array $crop, int $sourceWidth, int $sourceHeight, int $rotation): array + { + return match ($rotation) { + 90 => [ + 'left' => $crop['top'], + 'top' => $sourceHeight - $crop['left'] - $crop['width'], + 'width' => $crop['height'], + 'height' => $crop['width'], + ], + 180 => [ + 'left' => $sourceWidth - $crop['left'] - $crop['width'], + 'top' => $sourceHeight - $crop['top'] - $crop['height'], + 'width' => $crop['width'], + 'height' => $crop['height'], + ], + 270 => [ + 'left' => $sourceWidth - $crop['top'] - $crop['height'], + 'top' => $crop['left'], + 'width' => $crop['height'], + 'height' => $crop['width'], + ], + default => $crop, + }; + } + + private static function constrainCrop(array $crop, int $sourceWidth, int $sourceHeight): array + { + $left = max(0, $crop['left']); + $top = max(0, $crop['top']); + + return [ + 'left' => $left, + 'top' => $top, + 'width' => min($crop['width'], $sourceWidth - $left), + 'height' => min($crop['height'], $sourceHeight - $top), + ]; + } + + private static function rotatedDimensions(int|float $width, int|float $height, int $rotation): array + { + return in_array($rotation, [90, 270], true) + ? ['width' => (int)round($height), 'height' => (int)round($width)] + : ['width' => (int)round($width), 'height' => (int)round($height)]; + } + public function getTransformUrl(Asset $asset, mixed $transform, bool $immediately): string { $imageTransform = $this->normalizeTransform($transform); diff --git a/tests/unit/ImageTransformTest.php b/tests/unit/ImageTransformTest.php index 88785edd..7e9b63ee 100644 --- a/tests/unit/ImageTransformTest.php +++ b/tests/unit/ImageTransformTest.php @@ -7,6 +7,7 @@ use craft\base\FsInterface; use craft\base\MemoizableArray; use craft\cloud\fs\AssetsFs; +use craft\cloud\imagetransforms\ImageEditor; use craft\cloud\imagetransforms\ImageTransformBehavior; use craft\cloud\imagetransforms\ImageTransformer; use craft\cloud\Module as CloudModule; @@ -14,6 +15,7 @@ use craft\cloud\twig\CloudVariable; use craft\elements\Asset; use craft\events\GenerateTransformEvent; +use craft\events\SaveAssetImageEvent; use craft\fs\Local; use craft\helpers\Template; use craft\models\ImageTransform; @@ -22,6 +24,7 @@ use ReflectionProperty; use Twig\Markup; use yii\base\NotSupportedException; +use yii\web\BadRequestHttpException; class ImageTransformTest extends Unit { @@ -114,6 +117,16 @@ public function testInlineCloudPropertyDoesNotBreakBaseTransform(): void ], $this->behavior($transform)->toOptions()); } + public function testQualityMapsToCloudflareOptions(): void + { + $transform = new ImageTransform([ + 'width' => 200, + 'quality' => 72, + ]); + + $this->assertSame(72, $this->behavior($transform)->toOptions()['quality']); + } + public function testGetTransformUrlDoesNotLeakGravityBetweenAssets(): void { $transform = new ImageTransform([ @@ -442,6 +455,249 @@ public function testSupportedInputFormatsMatchCloudflareImages(): void ], ImageTransformer::SUPPORTED_IMAGE_FORMATS); } + public function testImageEditorCropMapsToTrimTransform(): void + { + $asset = $this->makeUrlAssetStub(1, 'image.jpg', 4000, 3000, ['x' => 0.5, 'y' => 0.5]); + $transform = ImageTransformer::fromImageEditor( + asset: $asset, + viewportRotation: 0, + imageRotation: 0.0, + cropData: [ + 'offsetX' => 200, + 'offsetY' => -100, + 'width' => 600, + 'height' => 400, + ], + imageDimensions: [ + 'width' => 1000, + 'height' => 750, + ], + flipData: null, + zoom: 1.0, + ); + + $this->assertInstanceOf(ImageTransform::class, $transform); + $this->assertSame(2400, $transform->width); + $this->assertSame(1600, $transform->height); + $this->assertSame([ + 'left' => 1600, + 'top' => 300, + 'width' => 2400, + 'height' => 1600, + ], $this->behavior($transform)->trim); + $this->assertSame('crop', $this->behavior($transform)->fit); + } + + public function testImageEditorRotationMapsToRotateTransform(): void + { + $asset = $this->makeUrlAssetStub(1, 'image.jpg', 4000, 3000, ['x' => 0.5, 'y' => 0.5]); + $transform = ImageTransformer::fromImageEditor( + asset: $asset, + viewportRotation: 90, + imageRotation: 0.0, + cropData: [ + 'offsetX' => 0, + 'offsetY' => 0, + 'width' => 1000, + 'height' => 750, + ], + imageDimensions: [ + 'width' => 1000, + 'height' => 750, + ], + flipData: null, + zoom: 1.0, + ); + + $this->assertInstanceOf(ImageTransform::class, $transform); + $this->assertSame(3000, $transform->width); + $this->assertSame(4000, $transform->height); + $this->assertSame(90, $this->behavior($transform)->rotate); + } + + public function testImageEditorRotationWithFullDisplayCropDoesNotTrim(): void + { + $asset = $this->makeUrlAssetStub(1, 'image.jpg', 568, 908, ['x' => 0.5, 'y' => 0.5]); + $transform = ImageTransformer::fromImageEditor( + asset: $asset, + viewportRotation: 270, + imageRotation: 0.0, + cropData: [ + 'offsetX' => 0, + 'offsetY' => 0, + 'width' => 908, + 'height' => 568, + ], + imageDimensions: [ + 'width' => 568, + 'height' => 908, + ], + flipData: null, + zoom: 1.0, + ); + + $this->assertInstanceOf(ImageTransform::class, $transform); + $this->assertSame(908, $transform->width); + $this->assertSame(568, $transform->height); + $this->assertSame(270, $this->behavior($transform)->rotate); + $this->assertNull($this->behavior($transform)->trim); + } + + public function testImageEditorRotatedCropMapsToSourceSpaceTrim(): void + { + $asset = $this->makeUrlAssetStub(1, 'image.jpg', 568, 908, ['x' => 0.5, 'y' => 0.5]); + $transform = ImageTransformer::fromImageEditor( + asset: $asset, + viewportRotation: 270, + imageRotation: 0.0, + cropData: [ + 'offsetX' => 0, + 'offsetY' => 0, + 'width' => 454, + 'height' => 284, + ], + imageDimensions: [ + 'width' => 568, + 'height' => 908, + ], + flipData: null, + zoom: 1.0, + ); + + $this->assertInstanceOf(ImageTransform::class, $transform); + $this->assertSame(454, $transform->width); + $this->assertSame(284, $transform->height); + $this->assertSame([ + 'left' => 142, + 'top' => 227, + 'width' => 284, + 'height' => 454, + ], $this->behavior($transform)->trim); + $this->assertSame(270, $this->behavior($transform)->rotate); + } + + public function testImageEditorCombinesCropRotationAndFlip(): void + { + $asset = $this->makeUrlAssetStub(1, 'image.jpg', 4000, 3000, ['x' => 0.5, 'y' => 0.5]); + + $transform = ImageTransformer::fromImageEditor( + asset: $asset, + viewportRotation: 90, + imageRotation: 0.0, + cropData: [ + 'offsetX' => 200, + 'offsetY' => -100, + 'width' => 600, + 'height' => 400, + ], + imageDimensions: [ + 'width' => 1000, + 'height' => 750, + ], + flipData: ['x' => true], + zoom: 1.0, + ); + + $this->assertInstanceOf(ImageTransform::class, $transform); + $this->assertSame(2400, $transform->width); + $this->assertSame(1600, $transform->height); + $this->assertSame([ + 'left' => 800, + 'top' => 0, + 'width' => 1600, + 'height' => 2400, + ], $this->behavior($transform)->trim); + $this->assertSame(90, $this->behavior($transform)->rotate); + $this->assertSame('h', $this->behavior($transform)->flip); + } + + public function testImageEditorMapsZoomToCloudflareZoom(): void + { + $asset = $this->makeUrlAssetStub(1, 'image.jpg', 4000, 3000, ['x' => 0.25, 'y' => 0.75]); + $transform = ImageTransformer::fromImageEditor( + asset: $asset, + viewportRotation: 0, + imageRotation: 0.0, + cropData: [ + 'offsetX' => 0, + 'offsetY' => 0, + 'width' => 1000, + 'height' => 750, + ], + imageDimensions: [ + 'width' => 1000, + 'height' => 750, + ], + flipData: ['x' => true], + zoom: 2.0, + ); + + $this->assertInstanceOf(ImageTransform::class, $transform); + $this->assertSame(0.5, $this->behavior($transform)->zoom); + } + + public function testImageEditorAllowsRotationWithFocalPoint(): void + { + $asset = $this->makeUrlAssetStub(1, 'image.jpg', 4000, 3000, ['x' => 0.5, 'y' => 0.5]); + + $transform = ImageTransformer::fromImageEditor( + asset: $asset, + viewportRotation: 0, + imageRotation: 135.0, + cropData: [ + 'offsetX' => 0, + 'offsetY' => 0, + 'width' => 1000, + 'height' => 750, + ], + imageDimensions: [ + 'width' => 1000, + 'height' => 750, + ], + flipData: null, + zoom: 1.0, + focalPoint: [ + 'offsetX' => 0, + 'offsetY' => 0, + 'imageDimensions' => [ + 'width' => 1000, + 'height' => 750, + ], + ], + ); + + $this->assertInstanceOf(ImageTransform::class, $transform); + $this->assertSame(135, $this->behavior($transform)->rotate); + } + + public function testImageEditorReportsMissingAssetDimensionsAsBadRequest(): void + { + $event = new SaveAssetImageEvent([ + 'asset' => new TransformDecisionAsset(), + 'replace' => true, + 'viewportRotation' => 0, + 'imageRotation' => 0.0, + 'cropData' => [ + 'offsetX' => 0, + 'offsetY' => 0, + 'width' => 500, + 'height' => 500, + ], + 'focalPoint' => null, + 'imageDimensions' => [ + 'width' => 1000, + 'height' => 750, + ], + 'flipData' => null, + 'zoom' => 1.0, + ]); + + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('Image dimensions are required to edit images.'); + + (new ImageEditor())->handleSaveImage($event); + } + private function setActionSegments(?array $actionSegments): void { $property = new ReflectionProperty(Craft::$app->getRequest(), '_actionSegments'); @@ -478,6 +734,62 @@ private function makeTransformUrlAsset(string $filename, array $focalPoint, bool return new TransformUrlAsset($filename, $focalPoint, $useLocalFs); } + private function makeUrlAssetStub(int $id, string $filename, int $width, int $height, array $focalPoint): Asset + { + return new class($id, $filename, $width, $height, $focalPoint) extends Asset { + public function __construct( + int $id, + private string $filenameValue, + private int $widthValue, + private int $heightValue, + private array $focalPointValue, + ) { + parent::__construct(); + $this->id = $id; + $this->kind = self::KIND_IMAGE; + } + + public function getHasFocalPoint(): bool + { + return true; + } + + public function getFocalPoint(bool $asCss = false): array|string|null + { + if ($asCss) { + return ($this->focalPointValue['x'] * 100) . '% ' . ($this->focalPointValue['y'] * 100) . '%'; + } + + return $this->focalPointValue; + } + + public function getWidth(array|string|\craft\models\ImageTransform|null $transform = null): ?int + { + return $this->widthValue; + } + + public function getHeight(mixed $transform = null): ?int + { + return $this->heightValue; + } + + public function getFilename(bool $withExtension = true): string + { + return $this->filenameValue; + } + + public function getPath(?string $filename = null): string + { + return 'tests/' . ($filename ?? $this->filenameValue); + } + + public function getMimeType(mixed $transform = null): ?string + { + return 'image/jpeg'; + } + }; + } + private function makePdfAssetStub(bool $useAssetCdn = true, bool $useLocalFs = false): Asset { return new class($useAssetCdn, $useLocalFs) extends Asset { From ee7128592b7f7d2f304706624a8aba4abadb02f4 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Thu, 23 Jul 2026 12:57:22 -0400 Subject: [PATCH 2/5] Address image editor review feedback --- src/imagetransforms/ImageEditor.php | 16 +++++-- src/imagetransforms/ImageTransformer.php | 10 ++++- tests/unit/ImageTransformTest.php | 56 ++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/src/imagetransforms/ImageEditor.php b/src/imagetransforms/ImageEditor.php index 5de0521a..ae6fbd1e 100644 --- a/src/imagetransforms/ImageEditor.php +++ b/src/imagetransforms/ImageEditor.php @@ -59,7 +59,7 @@ public function editImage( ?array $flipData, float $zoom, ): Asset { - $focal = $this->focalPoint($asset, $focalPoint, $viewportRotation, $imageRotation, $cropData, $imageDimensions, $zoom); + $focal = $this->focalPoint($asset, $focalPoint, $viewportRotation, $imageRotation, $cropData, $imageDimensions, $flipData, $zoom); $transform = ImageTransformer::fromImageEditor($asset, $viewportRotation, $imageRotation, $cropData, $imageDimensions, $flipData, $zoom, $focalPoint); if ($transform === null && !$this->focalPointChanged($asset, $focal)) { @@ -75,7 +75,7 @@ public function editImage( return $this->createAsset($asset, $transform, $focal); } - protected function focalPoint(Asset $asset, ?array $focalPoint, int $viewportRotation, float $imageRotation, array $cropData, array $imageDimensions, float $zoom): ?array + protected function focalPoint(Asset $asset, ?array $focalPoint, int $viewportRotation, float $imageRotation, array $cropData, array $imageDimensions, ?array $flipData, float $zoom): ?array { if (!$focalPoint) { return null; @@ -93,10 +93,20 @@ protected function focalPoint(Asset $asset, ?array $focalPoint, int $viewportRot $asset->height / $focalPoint['imageDimensions']['height'], ); - return [ + $focal = [ 'x' => (($editedDimensions['width'] / 2) + ($focalPoint['offsetX'] * $zoom * $adjustmentRatio) - $crop['left']) / $crop['width'], 'y' => (($editedDimensions['height'] / 2) + ($focalPoint['offsetY'] * $zoom * $adjustmentRatio) - $crop['top']) / $crop['height'], ]; + + if (!empty($flipData['x'])) { + $focal['x'] = 1 - $focal['x']; + } + + if (!empty($flipData['y'])) { + $focal['y'] = 1 - $focal['y']; + } + + return $focal; } protected function crop(Asset $asset, int $rotation, array $cropData, array $imageDimensions, float $zoom): array diff --git a/src/imagetransforms/ImageTransformer.php b/src/imagetransforms/ImageTransformer.php index f192959b..780bf46e 100644 --- a/src/imagetransforms/ImageTransformer.php +++ b/src/imagetransforms/ImageTransformer.php @@ -295,11 +295,17 @@ private function normalizeCloudOption(\ReflectionProperty $property, mixed $valu : ($type instanceof \ReflectionNamedType ? [$type->getName()] : []); if (in_array('int', $types, true)) { - $value = is_int($value) || is_string($value) + $intValue = is_int($value) || is_string($value) ? filter_var($value, FILTER_VALIDATE_INT) : false; - return [$value !== false && ($property->getName() !== 'page' || $value >= 1), $value]; + if ($intValue !== false) { + return [$property->getName() !== 'page' || $intValue >= 1, $intValue]; + } + + if (!in_array('string', $types, true)) { + return [false, $intValue]; + } } if (in_array('float', $types, true)) { diff --git a/tests/unit/ImageTransformTest.php b/tests/unit/ImageTransformTest.php index 7e9b63ee..af780269 100644 --- a/tests/unit/ImageTransformTest.php +++ b/tests/unit/ImageTransformTest.php @@ -127,6 +127,20 @@ public function testQualityMapsToCloudflareOptions(): void $this->assertSame(72, $this->behavior($transform)->toOptions()['quality']); } + public function testArrayTransformPreservesStringQuality(): void + { + $signedUrl = (new ImageTransformer())->getTransformUrl( + $this->makePdfAssetStub(), + [ + 'width' => 320, + 'quality' => 'high', + ], + true, + ); + + $this->assertSame('high', Query::fromUri($signedUrl)->parameters()['quality']); + } + public function testGetTransformUrlDoesNotLeakGravityBetweenAssets(): void { $transform = new ImageTransform([ @@ -636,6 +650,40 @@ public function testImageEditorMapsZoomToCloudflareZoom(): void $this->assertSame(0.5, $this->behavior($transform)->zoom); } + public function testImageEditorMirrorsFocalPointForFlip(): void + { + $focalPoint = (new TestImageEditor())->focalPointFromEditor( + asset: $this->makeUrlAssetStub(1, 'image.jpg', 4000, 3000, ['x' => 0.5, 'y' => 0.5]), + focalPoint: [ + 'offsetX' => 100, + 'offsetY' => -75, + 'imageDimensions' => [ + 'width' => 1000, + 'height' => 750, + ], + ], + viewportRotation: 0, + imageRotation: 0.0, + cropData: [ + 'offsetX' => 0, + 'offsetY' => 0, + 'width' => 1000, + 'height' => 750, + ], + imageDimensions: [ + 'width' => 1000, + 'height' => 750, + ], + flipData: [ + 'x' => true, + 'y' => true, + ], + zoom: 1.0, + ); + + $this->assertSame(['x' => 0.4, 'y' => 0.6], $focalPoint); + } + public function testImageEditorAllowsRotationWithFocalPoint(): void { $asset = $this->makeUrlAssetStub(1, 'image.jpg', 4000, 3000, ['x' => 0.5, 'y' => 0.5]); @@ -880,6 +928,14 @@ public function applyFocalPointGravity(Asset $asset, ImageTransform $imageTransf } } +class TestImageEditor extends ImageEditor +{ + public function focalPointFromEditor(Asset $asset, ?array $focalPoint, int $viewportRotation, float $imageRotation, array $cropData, array $imageDimensions, ?array $flipData, float $zoom): ?array + { + return $this->focalPoint($asset, $focalPoint, $viewportRotation, $imageRotation, $cropData, $imageDimensions, $flipData, $zoom); + } +} + class UrlTestImageTransformer extends ImageTransformer { public function buildTransformQuery(Asset $asset, ImageTransform $imageTransform): string From e9542b6a9a601f6e6f3d6613ed23d829dd39ac51 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Thu, 23 Jul 2026 14:50:59 -0400 Subject: [PATCH 3/5] Fall back for unsupported image editor rotations --- src/imagetransforms/ImageEditor.php | 4 +++ src/imagetransforms/ImageTransformer.php | 4 +++ tests/unit/ImageTransformTest.php | 35 +++++++++++++++++++++--- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/imagetransforms/ImageEditor.php b/src/imagetransforms/ImageEditor.php index ae6fbd1e..2a794fe2 100644 --- a/src/imagetransforms/ImageEditor.php +++ b/src/imagetransforms/ImageEditor.php @@ -23,6 +23,10 @@ public function handleSaveImage(SaveAssetImageEvent $event): void return; } + if (!in_array($this->rotation($event->viewportRotation, $event->imageRotation), [0, 90, 180, 270], true)) { + return; + } + $event->handled = true; try { diff --git a/src/imagetransforms/ImageTransformer.php b/src/imagetransforms/ImageTransformer.php index 780bf46e..8e926f25 100644 --- a/src/imagetransforms/ImageTransformer.php +++ b/src/imagetransforms/ImageTransformer.php @@ -60,6 +60,10 @@ public static function fromImageEditor( return null; } + if (!in_array($rotation, [0, 90, 180, 270], true)) { + throw new NotSupportedException('Only 90-degree image rotations are supported.'); + } + if (!$asset->width || !$asset->height) { throw new NotSupportedException('Image dimensions are required to edit images.'); } diff --git a/tests/unit/ImageTransformTest.php b/tests/unit/ImageTransformTest.php index af780269..773ad1b5 100644 --- a/tests/unit/ImageTransformTest.php +++ b/tests/unit/ImageTransformTest.php @@ -684,11 +684,14 @@ public function testImageEditorMirrorsFocalPointForFlip(): void $this->assertSame(['x' => 0.4, 'y' => 0.6], $focalPoint); } - public function testImageEditorAllowsRotationWithFocalPoint(): void + public function testImageEditorRejectsNonRightAngleRotationTransform(): void { $asset = $this->makeUrlAssetStub(1, 'image.jpg', 4000, 3000, ['x' => 0.5, 'y' => 0.5]); - $transform = ImageTransformer::fromImageEditor( + $this->expectException(NotSupportedException::class); + $this->expectExceptionMessage('Only 90-degree image rotations are supported.'); + + ImageTransformer::fromImageEditor( asset: $asset, viewportRotation: 0, imageRotation: 135.0, @@ -713,9 +716,33 @@ public function testImageEditorAllowsRotationWithFocalPoint(): void ], ], ); + } - $this->assertInstanceOf(ImageTransform::class, $transform); - $this->assertSame(135, $this->behavior($transform)->rotate); + public function testImageEditorFallsBackToCraftForNonRightAngleRotation(): void + { + $event = new SaveAssetImageEvent([ + 'asset' => new TransformDecisionAsset(), + 'replace' => true, + 'viewportRotation' => 0, + 'imageRotation' => 135.0, + 'cropData' => [ + 'offsetX' => 0, + 'offsetY' => 0, + 'width' => 1000, + 'height' => 750, + ], + 'focalPoint' => null, + 'imageDimensions' => [ + 'width' => 1000, + 'height' => 750, + ], + 'flipData' => null, + 'zoom' => 1.0, + ]); + + (new ImageEditor())->handleSaveImage($event); + + $this->assertFalse($event->handled); } public function testImageEditorReportsMissingAssetDimensionsAsBadRequest(): void From 4a94fbcdf5b7f632448ff021d41d59a89883e65a Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Thu, 23 Jul 2026 14:57:33 -0400 Subject: [PATCH 4/5] Address image editor validation feedback --- src/imagetransforms/ImageEditor.php | 21 +++++- src/imagetransforms/ImageTransformer.php | 27 +++++++- tests/unit/ImageTransformTest.php | 83 +++++++++++++++++++++--- 3 files changed, 121 insertions(+), 10 deletions(-) diff --git a/src/imagetransforms/ImageEditor.php b/src/imagetransforms/ImageEditor.php index 2a794fe2..4bcb186b 100644 --- a/src/imagetransforms/ImageEditor.php +++ b/src/imagetransforms/ImageEditor.php @@ -63,8 +63,14 @@ public function editImage( ?array $flipData, float $zoom, ): Asset { + $transform = ImageTransformer::fromImageEditor($asset, $viewportRotation, $imageRotation, $cropData, $imageDimensions, $flipData, $zoom); $focal = $this->focalPoint($asset, $focalPoint, $viewportRotation, $imageRotation, $cropData, $imageDimensions, $flipData, $zoom); - $transform = ImageTransformer::fromImageEditor($asset, $viewportRotation, $imageRotation, $cropData, $imageDimensions, $flipData, $zoom, $focalPoint); + + if ($transform !== null && $focal !== null) { + /** @var ImageTransformBehavior $behavior */ + $behavior = $transform->getBehavior('cloud'); + $behavior->gravity = $focal; + } if ($transform === null && !$this->focalPointChanged($asset, $focal)) { return $replace ? $asset : $this->createAsset($asset, null, $focal); @@ -89,6 +95,10 @@ protected function focalPoint(Asset $asset, ?array $focalPoint, int $viewportRot throw new NotSupportedException('Image dimensions are required to edit images.'); } + if (!$this->validDimensions($focalPoint['imageDimensions'] ?? [])) { + throw new NotSupportedException('Valid image editor dimensions are required to edit images.'); + } + $rotation = $this->rotation($viewportRotation, $imageRotation); $crop = $this->crop($asset, $rotation, $cropData, $imageDimensions, $zoom); $editedDimensions = $this->rotatedDimensions($asset->width, $asset->height, $rotation); @@ -144,6 +154,15 @@ protected function rotatedDimensions(int|float $width, int|float $height, int $r : ['width' => (int)round($width), 'height' => (int)round($height)]; } + protected function validDimensions(array $dimensions): bool + { + return isset($dimensions['width'], $dimensions['height']) && + is_numeric($dimensions['width']) && + is_numeric($dimensions['height']) && + (float)$dimensions['width'] > 0 && + (float)$dimensions['height'] > 0; + } + protected function focalPointChanged(Asset $asset, ?array $focal): bool { $oldFocal = $asset->getHasFocalPoint() ? $asset->getFocalPoint() : null; diff --git a/src/imagetransforms/ImageTransformer.php b/src/imagetransforms/ImageTransformer.php index 8e926f25..5a8cf47f 100644 --- a/src/imagetransforms/ImageTransformer.php +++ b/src/imagetransforms/ImageTransformer.php @@ -35,7 +35,6 @@ public static function fromImageEditor( array $imageDimensions, ?array $flipData, float $zoom, - ?array $focalPoint = null, ): ?ImageTransform { $rotation = ((int)round($imageRotation + $viewportRotation) % 360 + 360) % 360; $flipX = !empty($flipData['x']); @@ -46,6 +45,9 @@ public static function fromImageEditor( $flipY => 'v', default => null, }; + + self::ensureImageEditorData($cropData, $imageDimensions, $zoom); + $sourceReferenceDimensions = self::rotatedDimensions($imageDimensions['width'], $imageDimensions['height'], 0); $cropReferenceDimensions = self::rotatedDimensions($imageDimensions['width'], $imageDimensions['height'], $rotation); $cropDimensions = [ @@ -104,6 +106,29 @@ public static function fromImageEditor( return $transform; } + private static function ensureImageEditorData(array $cropData, array $imageDimensions, float $zoom): void + { + if ( + !isset($cropData['offsetX'], $cropData['offsetY']) || + !is_numeric($cropData['offsetX']) || + !is_numeric($cropData['offsetY']) || + !self::validDimensions($cropData) || + !self::validDimensions($imageDimensions) || + $zoom <= 0 + ) { + throw new NotSupportedException('Valid image editor dimensions are required to edit images.'); + } + } + + private static function validDimensions(array $dimensions): bool + { + return isset($dimensions['width'], $dimensions['height']) && + is_numeric($dimensions['width']) && + is_numeric($dimensions['height']) && + (float)$dimensions['width'] > 0 && + (float)$dimensions['height'] > 0; + } + private static function crop(Asset $asset, int $rotation, array $cropData, array $imageDimensions, float $zoom): array { $adjustmentRatio = min( diff --git a/tests/unit/ImageTransformTest.php b/tests/unit/ImageTransformTest.php index 773ad1b5..abc17ad6 100644 --- a/tests/unit/ImageTransformTest.php +++ b/tests/unit/ImageTransformTest.php @@ -684,6 +684,42 @@ public function testImageEditorMirrorsFocalPointForFlip(): void $this->assertSame(['x' => 0.4, 'y' => 0.6], $focalPoint); } + public function testImageEditorUsesNewFocalPointForTransformGravity(): void + { + $editor = new TestImageEditor(); + + $editor->editImage( + asset: $this->makeUrlAssetStub(1, 'image.jpg', 4000, 3000, ['x' => 0.9, 'y' => 0.9]), + replace: false, + viewportRotation: 0, + imageRotation: 0.0, + cropData: [ + 'offsetX' => 0, + 'offsetY' => 0, + 'width' => 500, + 'height' => 375, + ], + focalPoint: [ + 'offsetX' => 100, + 'offsetY' => -75, + 'imageDimensions' => [ + 'width' => 1000, + 'height' => 750, + ], + ], + imageDimensions: [ + 'width' => 1000, + 'height' => 750, + ], + flipData: null, + zoom: 1.0, + ); + + $this->assertInstanceOf(ImageTransform::class, $editor->createdTransform); + $this->assertSame($editor->createdFocalPoint, $this->behavior($editor->createdTransform)->gravity); + $this->assertNotSame(['x' => 0.9, 'y' => 0.9], $this->behavior($editor->createdTransform)->gravity); + } + public function testImageEditorRejectsNonRightAngleRotationTransform(): void { $asset = $this->makeUrlAssetStub(1, 'image.jpg', 4000, 3000, ['x' => 0.5, 'y' => 0.5]); @@ -707,17 +743,37 @@ public function testImageEditorRejectsNonRightAngleRotationTransform(): void ], flipData: null, zoom: 1.0, - focalPoint: [ - 'offsetX' => 0, - 'offsetY' => 0, - 'imageDimensions' => [ - 'width' => 1000, - 'height' => 750, - ], - ], ); } + public function testImageEditorRejectsInvalidEditorDimensions(): void + { + $asset = $this->makeUrlAssetStub(1, 'image.jpg', 4000, 3000, ['x' => 0.5, 'y' => 0.5]); + + foreach ([['width' => 0, 'height' => 750], ['width' => 1000, 'height' => 750, 'zoom' => 0.0]] as $imageDimensions) { + try { + ImageTransformer::fromImageEditor( + asset: $asset, + viewportRotation: 0, + imageRotation: 0.0, + cropData: [ + 'offsetX' => 0, + 'offsetY' => 0, + 'width' => 1000, + 'height' => 750, + ], + imageDimensions: $imageDimensions, + flipData: ['x' => true], + zoom: $imageDimensions['zoom'] ?? 1.0, + ); + + $this->fail('Expected invalid editor dimensions to be rejected.'); + } catch (NotSupportedException $e) { + $this->assertSame('Valid image editor dimensions are required to edit images.', $e->getMessage()); + } + } + } + public function testImageEditorFallsBackToCraftForNonRightAngleRotation(): void { $event = new SaveAssetImageEvent([ @@ -957,10 +1013,21 @@ public function applyFocalPointGravity(Asset $asset, ImageTransform $imageTransf class TestImageEditor extends ImageEditor { + public ?ImageTransform $createdTransform = null; + public ?array $createdFocalPoint = null; + public function focalPointFromEditor(Asset $asset, ?array $focalPoint, int $viewportRotation, float $imageRotation, array $cropData, array $imageDimensions, ?array $flipData, float $zoom): ?array { return $this->focalPoint($asset, $focalPoint, $viewportRotation, $imageRotation, $cropData, $imageDimensions, $flipData, $zoom); } + + protected function createAsset(Asset $asset, ?ImageTransform $transform, ?array $focal): Asset + { + $this->createdTransform = $transform; + $this->createdFocalPoint = $focal; + + return $asset; + } } class UrlTestImageTransformer extends ImageTransformer From 9422adffef635e00e63106fd1b5ed68f5de8f722 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Thu, 23 Jul 2026 14:59:33 -0400 Subject: [PATCH 5/5] Handle zoom-only image editor changes --- src/imagetransforms/ImageTransformer.php | 5 ++- tests/unit/ImageTransformTest.php | 50 ++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/imagetransforms/ImageTransformer.php b/src/imagetransforms/ImageTransformer.php index 5a8cf47f..9834270c 100644 --- a/src/imagetransforms/ImageTransformer.php +++ b/src/imagetransforms/ImageTransformer.php @@ -54,7 +54,10 @@ public static function fromImageEditor( 'width' => (int)round($cropData['width']), 'height' => (int)round($cropData['height']), ]; - $imageCropped = $cropDimensions !== $sourceReferenceDimensions && $cropDimensions !== $cropReferenceDimensions; + $imageCropped = ($cropDimensions !== $sourceReferenceDimensions && $cropDimensions !== $cropReferenceDimensions) || + $zoom !== 1.0 || + (float)$cropData['offsetX'] !== 0.0 || + (float)$cropData['offsetY'] !== 0.0; $imageRotated = $rotation !== 0; $imageFlipped = $flip !== null; diff --git a/tests/unit/ImageTransformTest.php b/tests/unit/ImageTransformTest.php index abc17ad6..8d5e0f2d 100644 --- a/tests/unit/ImageTransformTest.php +++ b/tests/unit/ImageTransformTest.php @@ -650,6 +650,56 @@ public function testImageEditorMapsZoomToCloudflareZoom(): void $this->assertSame(0.5, $this->behavior($transform)->zoom); } + public function testImageEditorZoomOnlyEditCreatesTransform(): void + { + $asset = $this->makeUrlAssetStub(1, 'image.jpg', 4000, 3000, ['x' => 0.25, 'y' => 0.75]); + $transform = ImageTransformer::fromImageEditor( + asset: $asset, + viewportRotation: 0, + imageRotation: 0.0, + cropData: [ + 'offsetX' => 0, + 'offsetY' => 0, + 'width' => 1000, + 'height' => 750, + ], + imageDimensions: [ + 'width' => 1000, + 'height' => 750, + ], + flipData: null, + zoom: 2.0, + ); + + $this->assertInstanceOf(ImageTransform::class, $transform); + $this->assertSame(0.5, $this->behavior($transform)->zoom); + } + + public function testImageEditorPanOnlyEditCreatesTransform(): void + { + $asset = $this->makeUrlAssetStub(1, 'image.jpg', 4000, 3000, ['x' => 0.25, 'y' => 0.75]); + $transform = ImageTransformer::fromImageEditor( + asset: $asset, + viewportRotation: 0, + imageRotation: 0.0, + cropData: [ + 'offsetX' => 100, + 'offsetY' => 0, + 'width' => 1000, + 'height' => 750, + ], + imageDimensions: [ + 'width' => 1000, + 'height' => 750, + ], + flipData: null, + zoom: 1.0, + ); + + $this->assertInstanceOf(ImageTransform::class, $transform); + $this->assertSame(['left' => 400, 'top' => 0, 'width' => 3600, 'height' => 3000], $this->behavior($transform)->trim); + } + public function testImageEditorMirrorsFocalPointForFlip(): void { $focalPoint = (new TestImageEditor())->focalPointFromEditor(