├── .github ├── CODEOWNERS └── workflows │ ├── create-release.yml │ └── code-analysis.yaml ├── phpstan.neon ├── CHANGELOG.md ├── ecs.php ├── .gitignore ├── README.md ├── composer.json ├── src ├── templates │ └── settings │ │ └── image-transforms │ │ └── thumbor.twig └── imagetransforms │ └── ThumborImageTransform.php └── LICENSE.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @khalwat 2 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - %currentWorkingDirectory%/vendor/craftcms/phpstan/phpstan.neon 3 | 4 | parameters: 5 | level: 5 6 | paths: 7 | - src 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # ImageOptimize Thumbor Image Transform Changelog 2 | 3 | ## 5.0.0 - 2024.04.15 4 | ### Added 5 | * Stable release for Craft CMS 5 6 | 7 | ## 5.0.0-beta.1 - 2024.04.02 8 | ### Added 9 | * Initial Craft CMS 5 compatibility 10 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | paths([ 8 | __DIR__ . '/src', 9 | __FILE__, 10 | ]); 11 | $ecsConfig->parallel(); 12 | $ecsConfig->sets([SetList::CRAFT_CMS_4]); 13 | }; 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # CRAFT ENVIRONMENT 2 | .env.php 3 | .env.sh 4 | .env 5 | 6 | # COMPOSER 7 | /vendor 8 | 9 | # BUILD FILES 10 | /bower_components/* 11 | /node_modules/* 12 | /build/* 13 | /yarn-error.log 14 | 15 | # MISC FILES 16 | .cache 17 | .DS_Store 18 | .idea 19 | .project 20 | .settings 21 | *.esproj 22 | *.sublime-workspace 23 | *.sublime-project 24 | *.tmproj 25 | *.tmproject 26 | .vscode/* 27 | !.vscode/settings.json 28 | !.vscode/tasks.json 29 | !.vscode/launch.json 30 | !.vscode/extensions.json 31 | config.codekit3 32 | prepros-6.config 33 | 34 | # BUILD FILES 35 | -------------------------------------------------------------------------------- /.github/workflows/create-release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | run-name: Create release for ${{ github.event.client_payload.version }} 3 | 4 | on: 5 | repository_dispatch: 6 | types: 7 | - craftcms/new-release 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | steps: 15 | - uses: ncipollo/release-action@v1 16 | with: 17 | body: ${{ github.event.client_payload.notes }} 18 | makeLatest: ${{ github.event.client_payload.latest }} 19 | name: ${{ github.event.client_payload.version }} 20 | prerelease: ${{ github.event.client_payload.prerelease }} 21 | tag: ${{ github.event.client_payload.tag }} 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ImageOptimize Thumbor Image Transform 2 | 3 | ## Requirements 4 | 5 | * Craft CMS 3.0.0 or later 6 | * [ImageOptimize](https://github.com/nystudio107/craft-imageoptimize) plugin 1.6.0 or later 7 | 8 | ## Installation 9 | 10 | ``` 11 | composer require nystudio107/craft-imageoptimize-thumbor 12 | ``` 13 | 14 | ## ImageOptimize Thumbor Image Transform Overview 15 | 16 | This is an Image Transform for the [ImageOptimize](https://github.com/nystudio107/craft-imageoptimize) Craft CMS plugin that implements the [Thumbor](http://thumbor.org/) service. 17 | 18 | You shouldn't need to install this yourself normally; the ImageOptimize plugin will require it. 19 | 20 | Brought to you by [nystudio107](https://nystudio107.com) 21 | -------------------------------------------------------------------------------- /.github/workflows/code-analysis.yaml: -------------------------------------------------------------------------------- 1 | name: Code Analysis 2 | 3 | on: 4 | pull_request: null 5 | push: 6 | branches: 7 | - develop-v4 8 | workflow_dispatch: 9 | permissions: 10 | contents: read 11 | jobs: 12 | code_analysis: 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | actions: 17 | - name: 'PHPStan' 18 | run: composer phpstan 19 | - name: 'Coding Standards' 20 | run: composer fix-cs 21 | name: ${{ matrix.actions.name }} 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Cache Composer dependencies 26 | uses: actions/cache@v4 27 | with: 28 | path: /tmp/composer-cache 29 | key: ${{ runner.os }}-${{ hashFiles('**/composer.lock') }} 30 | - name: Setup PHP 31 | id: setup-php 32 | uses: shivammathur/setup-php@v2 33 | with: 34 | php-version: 8.2 35 | extensions: 'ctype,curl,dom,iconv,imagick,intl,json,mbstring,openssl,pcre,pdo,reflection,spl,zip' 36 | ini-values: post_max_size=256M, max_execution_time=180, memory_limit=512M 37 | tools: composer:v2 38 | - name: Install Composer dependencies 39 | run: composer install --no-interaction --no-ansi --no-progress 40 | - run: ${{ matrix.actions.run }} 41 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nystudio107/craft-imageoptimize-thumbor", 3 | "description": "Provides an Thumbor image transform type for the ImageOptimize plugin.", 4 | "type": "image-transform", 5 | "version": "5.0.0", 6 | "keywords": [ 7 | "craftcms", 8 | "thumbor", 9 | "image-optimize", 10 | "image-transform" 11 | ], 12 | "support": { 13 | "docs": "https://github.com/nystudio107/craft-imageoptimize-thumbor/", 14 | "issues": "https://github.com/nystudio107/craft-imageoptimize-thumbor/issues" 15 | }, 16 | "license": "proprietary", 17 | "authors": [ 18 | { 19 | "name": "nystudio107", 20 | "homepage": "https://nystudio107.com" 21 | } 22 | ], 23 | "minimum-stability": "dev", 24 | "prefer-stable": true, 25 | "require": { 26 | "php": "^8.2", 27 | "nystudio107/craft-imageoptimize": "^5.0.0", 28 | "webfactory/phumbor": "^1.2" 29 | }, 30 | "require-dev": { 31 | "craftcms/ecs": "dev-main", 32 | "craftcms/phpstan": "dev-main", 33 | "craftcms/rector": "dev-main", 34 | "craftcms/aws-s3": "^2.1.0" 35 | }, 36 | "scripts": { 37 | "phpstan": "phpstan --ansi --memory-limit=1G", 38 | "check-cs": "ecs check --ansi", 39 | "fix-cs": "ecs check --fix --ansi" 40 | }, 41 | "config": { 42 | "allow-plugins": { 43 | "craftcms/plugin-installer": true, 44 | "yiisoft/yii2-composer": true 45 | }, 46 | "optimize-autoloader": true, 47 | "sort-packages": true 48 | }, 49 | "autoload": { 50 | "psr-4": { 51 | "nystudio107\\imageoptimizethumbor\\": "src/" 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/templates/settings/image-transforms/thumbor.twig: -------------------------------------------------------------------------------- 1 | {% from 'image-optimize/_includes/macros' import configWarning %} 2 | 3 | {% import "_includes/forms" as forms %} 4 | 5 | 6 | {{ forms.autosuggestField({ 7 | label: 'Base URL', 8 | instructions: "The base URL to use for the Thumbor transforms."|t('image-optimize'), 9 | suggestEnvVars: true, 10 | id: 'baseUrl', 11 | name: 'baseUrl', 12 | value: imageTransform.baseUrl, 13 | warning: configWarning('imageTransformTypeSettings', 'image-optimize'), 14 | }) }} 15 | 16 | {{ forms.autosuggestField({ 17 | label: 'Security Key', 18 | instructions: "The optional [security key](https://thumbor.readthedocs.io/en/latest/security.html) used by Thumbor to create secure image urls."|t('image-optimize'), 19 | suggestEnvVars: true, 20 | id: 'securityKey', 21 | name: 'securityKey', 22 | value: imageTransform.securityKey, 23 | warning: configWarning('imageTransformTypeSettings', 'image-optimize'), 24 | }) }} 25 | 26 | 27 | {% if awsS3Installed %} 28 | {{ forms.lightswitchField({ 29 | label: 'Include Bucket Prefix', 30 | instructions: 'Optionally prefix your asset path with the bucket name. This is useful if your Thumbor configuration does not specify an explicit bucket. Only relevant for AWS S3 volumes at this time.'|t('image-optimize'), 31 | id: 'includeBucketPrefix', 32 | name: 'includeBucketPrefix', 33 | on: imageTransform.includeBucketPrefix, 34 | warning: configWarning('includeBucketPrefix', 'image-optimize'), 35 | }) }} 36 | {% endif %} 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © nystudio107 2 | 3 | Permission is hereby granted to any person obtaining a copy of this software 4 | (the “Software”) to use, copy, modify, merge, publish and/or distribute copies 5 | of the Software, and to permit persons to whom the Software is furnished to do 6 | so, subject to the following conditions: 7 | 8 | 1. **Don’t plagiarize.** The above copyright notice and this license shall be 9 | included in all copies or substantial portions of the Software. 10 | 11 | 2. **Don’t use the same license on more than one project.** Each licensed copy 12 | of the Software shall be actively installed in no more than one production 13 | environment at a time. 14 | 15 | 3. **Don’t mess with the licensing features.** Software features related to 16 | licensing shall not be altered or circumvented in any way, including (but 17 | not limited to) license validation, payment prompts, feature restrictions, 18 | and update eligibility. 19 | 20 | 4. **Pay up.** Payment shall be made immediately upon receipt of any notice, 21 | prompt, reminder, or other message indicating that a payment is owed. 22 | 23 | 5. **Follow the law.** All use of the Software shall not violate any applicable 24 | law or regulation, nor infringe the rights of any other person or entity. 25 | 26 | Failure to comply with the foregoing conditions will automatically and 27 | immediately result in termination of the permission granted hereby. This 28 | license does not include any right to receive updates to the Software or 29 | technical support. Licensees bear all risk related to the quality and 30 | performance of the Software and any modifications made or obtained to it, 31 | including liability for actual and consequential harm, such as loss or 32 | corruption of data, and any necessary service, repair, or correction. 33 | 34 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 35 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 36 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 37 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER 38 | LIABILITY, INCLUDING SPECIAL, INCIDENTAL AND CONSEQUENTIAL DAMAGES, WHETHER IN 39 | AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 40 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 41 | -------------------------------------------------------------------------------- /src/imagetransforms/ThumborImageTransform.php: -------------------------------------------------------------------------------- 1 | getExtension() === 'svg') { 67 | return null; 68 | } 69 | 70 | $this->baseUrl = App::parseEnv($this->baseUrl); 71 | $this->securityKey = App::parseEnv($this->securityKey); 72 | 73 | return (string)$this->getUrlBuilderForTransform($asset, $transform); 74 | } 75 | 76 | /** 77 | * @inheritdoc 78 | */ 79 | public function getWebPUrl(string $url, Asset $asset, CraftImageTransformModel|string|array|null $transform): ?string 80 | { 81 | $builder = $this->getUrlBuilderForTransform($asset, $transform) 82 | ->addFilter('format', 'webp'); 83 | 84 | return (string)$builder; 85 | } 86 | 87 | /** 88 | * @inheritdoc 89 | */ 90 | public function purgeUrl(string $url): bool 91 | { 92 | return false; 93 | } 94 | 95 | /** 96 | * @inheritdoc 97 | */ 98 | public function getAssetUri(Asset $asset): ?string 99 | { 100 | $uri = parent::getAssetUri($asset); 101 | try { 102 | $volumeFs = $asset->getVolume()->getFs(); 103 | } catch (InvalidConfigException $e) { 104 | Craft::error($e->getMessage(), __METHOD__); 105 | $volumeFs = null; 106 | } 107 | 108 | if ($this->includeBucketPrefix && ($volumeFs instanceof AwsFs)) { 109 | $bucket = App::parseEnv($volumeFs->bucket); 110 | $uri = $bucket . '/' . $uri; 111 | } 112 | 113 | return $uri; 114 | } 115 | 116 | /** 117 | * @inheritdoc 118 | */ 119 | public function getSettingsHtml(): ?string 120 | { 121 | return Craft::$app->getView()->renderTemplate('thumbor-image-transform/settings/image-transforms/thumbor.twig', [ 122 | 'imageTransform' => $this, 123 | 'awsS3Installed' => class_exists(AwsFs::class), 124 | ]); 125 | } 126 | 127 | /** 128 | * @inheritdoc 129 | */ 130 | public function rules(): array 131 | { 132 | $rules = parent::rules(); 133 | 134 | return array_merge($rules, [ 135 | [['baseUrl', 'securityKey'], 'default', 'value' => ''], 136 | [['baseUrl', 'securityKey'], 'string'], 137 | ]); 138 | } 139 | 140 | // Private Methods 141 | // ========================================================================= 142 | 143 | /** 144 | * @param Asset $asset 145 | * @param CraftImageTransformModel|string|array|null $transform 146 | * 147 | * @return UrlBuilder 148 | */ 149 | private function getUrlBuilderForTransform(Asset $asset, CraftImageTransformModel|string|array|null $transform): UrlBuilder 150 | { 151 | $assetUri = $this->getAssetUri($asset); 152 | $builder = UrlBuilder::construct($this->baseUrl, $this->securityKey, $assetUri); 153 | /** @var Settings $settings */ 154 | $settings = ImageOptimize::$plugin->getSettings(); 155 | 156 | if ($transform->mode === 'fit') { 157 | // https://thumbor.readthedocs.io/en/latest/usage.html#fit-in 158 | $builder->fitIn($transform->width, $transform->height); 159 | } elseif ($transform->mode === 'stretch') { 160 | // https://github.com/thumbor/thumbor/pull/1125 161 | $builder 162 | ->resize($transform->width, $transform->height) 163 | ->addFilter('stretch'); 164 | } else { 165 | 166 | // https://thumbor.readthedocs.io/en/latest/usage.html#image-size 167 | $builder->resize($transform->width, $transform->height); 168 | 169 | if ($focalPoint = $this->getFocalPoint($asset)) { 170 | // https://thumbor.readthedocs.io/en/latest/focal.html 171 | $builder->addFilter('focal', $focalPoint); 172 | } elseif (preg_match('/(top|center|bottom)-(left|center|right)/', $transform->position, $matches)) { 173 | $v = str_replace('center', 'middle', $matches[1]); 174 | $h = $matches[2]; 175 | 176 | // https://thumbor.readthedocs.io/en/latest/usage.html#horizontal-align 177 | $builder->valign($v)->halign($h); 178 | } 179 | } 180 | 181 | // https://thumbor.readthedocs.io/en/latest/format.html 182 | if ($format = $this->getFormat($transform)) { 183 | $builder->addFilter('format', $format); 184 | } 185 | 186 | // https://thumbor.readthedocs.io/en/latest/quality.html 187 | if ($quality = $this->getQuality($transform)) { 188 | $builder->addFilter('quality', $quality); 189 | } 190 | 191 | if (property_exists($transform, 'interlace')) { 192 | Craft::warning('Thumbor enables progressive JPEGs on the server-level, not as a request option. See https://thumbor.readthedocs.io/en/latest/jpegtran.html', __METHOD__); 193 | } 194 | 195 | if ($settings->autoSharpenScaledImages) { 196 | // See if the image has been scaled >= 50% 197 | $widthScale = (int)((($transform->width ?? $asset->getWidth()) / $asset->getWidth()) * 100); 198 | $heightScale = (int)((($transform->height ?? $asset->getHeight()) / $asset->getHeight()) * 100); 199 | if (($widthScale >= $settings->sharpenScaledImagePercentage) || ($heightScale >= $settings->sharpenScaledImagePercentage)) { 200 | // https://thumbor.readthedocs.io/en/latest/sharpen.html 201 | $builder->addFilter('sharpen', .5, .5, 'true'); 202 | } 203 | } 204 | 205 | return $builder; 206 | } 207 | 208 | /** 209 | * @param Asset $asset 210 | * @return string|null 211 | */ 212 | private function getFocalPoint(Asset $asset): ?string 213 | { 214 | $focalPoint = $asset->getFocalPoint(); 215 | 216 | if (!$focalPoint) { 217 | return null; 218 | } 219 | 220 | $box = array_map('intval', [ 221 | 'top' => $focalPoint['y'] * $asset->height - 1, 222 | 'left' => $focalPoint['x'] * $asset->width - 1, 223 | 'bottom' => $focalPoint['y'] * $asset->height + 1, 224 | 'right' => $focalPoint['x'] * $asset->width + 1, 225 | ]); 226 | 227 | return implode('', [ 228 | $box['left'], 229 | 'x', 230 | $box['top'], 231 | ':', 232 | $box['right'], 233 | 'x', 234 | $box['bottom'], 235 | ]); 236 | } 237 | 238 | /** 239 | * @param CraftImageTransformModel|string|array|null $transform 240 | * 241 | * @return ?string 242 | */ 243 | private function getFormat(CraftImageTransformModel|string|array|null $transform): ?string 244 | { 245 | $format = str_replace('jpg', 'jpeg', $transform->format); 246 | 247 | return $format ?: null; 248 | } 249 | 250 | /** 251 | * @param CraftImageTransformModel|string|array|null $transform 252 | * 253 | * @return int 254 | */ 255 | private function getQuality(CraftImageTransformModel|string|array|null $transform): int 256 | { 257 | return $transform->quality ?? Craft::$app->getConfig()->getGeneral()->defaultImageQuality; 258 | } 259 | } 260 | --------------------------------------------------------------------------------