├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── composer.json ├── config └── config.php ├── package.json ├── phpstan.neon ├── phpunit.xml ├── public ├── .gitkeep └── js │ ├── croppa.js │ └── test │ └── url.js ├── src ├── Commands │ └── Purge.php ├── CroppaServiceProvider.php ├── Exception.php ├── Facades │ └── Croppa.php ├── Filters │ ├── BlackWhite.php │ ├── Blur.php │ ├── Darkgray.php │ ├── FilterInterface.php │ ├── Negative.php │ ├── OrangeWarhol.php │ └── TurquoiseWarhol.php ├── Handler.php ├── Helpers.php ├── Image.php ├── Storage.php └── URL.php └── tests ├── TestDelete.php ├── TestListAllCrops.php ├── TestResizing.php ├── TestTooManyCrops.php ├── TestUrlGenerator.php ├── TestUrlMatching.php └── TestUrlParsing.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.phar 3 | composer.lock 4 | .DS_Store 5 | /node_modules 6 | /yarn.lock 7 | .idea 8 | .phpunit.result.cache 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 8.0 5 | - 8.1 6 | 7 | before_script: 8 | - composer install --dev 9 | - yarn add mocha 10 | 11 | script: 12 | - phpunit 13 | - yarn mocha public/js/test 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) BKWLD 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Croppa 2 | 3 | [![Packagist](https://img.shields.io/packagist/v/bkwld/croppa.svg)](https://packagist.org/packages/bkwld/croppa) 4 | 5 | Croppa is a thumbnail generator bundle for Laravel. It follows a different approach from libraries that store your thumbnail dimensions in the model. Instead, the resizing and cropping instructions come from specially formatted URLs. 6 | 7 | /storage/uploads/09/03/screenshot.png 8 | 9 | To produce a 300x200 thumbnail of this, you would change the path to: 10 | 11 | /storage/uploads/09/03/screenshot-300x200.png 12 | 13 | This file, of course, doesn’t exist yet. Croppa listens for specifically formatted image routes and builds this thumbnail on the fly, outputting the image data (with correct headers) to the browser instead of returning a 404 response. 14 | 15 | At the same time, it saves the newly cropped image to the disk in the same location (the "…-300x200.png" path) that you requested. As a result, **all future requests get served directly from the disk**, bypassing PHP and avoiding unnecessary overhead. In other words, **your app does not need to boot up just to serve an image**. This is a key differentiator compared to other similar libraries. 16 | 17 | Since version 4.0, Croppa allows images to be stored on remote disks such as S3, Dropbox, FTP, and more, thanks to [Flysystem integration](http://flysystem.thephpleague.com/). 18 | 19 | ## Server Requirements: 20 | 21 | - [gd](http://php.net/manual/en/book.image.php) 22 | - [exif](http://php.net/manual/en/book.exif.php) - Required if you want to have Croppa auto-rotate images from devices like mobile phones based on exif meta data. 23 | 24 | ### Nginx 25 | 26 | When using [Nginx HTTP server boilerplate configs](https://github.com/h5bp/server-configs-nginx), add `error_page 404 = /index.php?$query_string;` in the location block for Media, located in file h5bp/location/expires.conf. 27 | 28 | ```nginx 29 | # Media: images, icons, video, audio, HTC 30 | location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc)$ { 31 | error_page 404 = /index.php?$query_string; 32 | expires 1M; 33 | access_log off; 34 | add_header Cache-Control "public"; 35 | } 36 | ``` 37 | 38 | ## Installation 39 | 40 | Add Croppa to your project: `composer require bkwld/croppa` 41 | 42 | ## Configuration 43 | 44 | Read the [source of the config file](https://github.com/BKWLD/croppa/tree/master/config/config.php) for documentation of the config options. Here are some examples of common setups (additional [examples can be found here](https://github.com/BKWLD/croppa/wiki/Examples)): 45 | 46 | You can publish the config file into your app’s config directory, by running the following command: 47 | 48 | ```php 49 | php artisan vendor:publish --tag=croppa-config 50 | ``` 51 | 52 | #### Local src and crops directories 53 | 54 | The most common scenario, the src images and their crops are created in the default ”public” Laravel disk. 55 | 56 | ```php 57 | return [ 58 | 'src_disk' => 'public', 59 | 'crops_disk' => 'public', 60 | 'path' => 'storage/(.*)$', 61 | ]; 62 | ``` 63 | 64 | Thus, if you have ``, the returned URL will be `/storage/file-200x_.jpg`, the source image will be looked for at `'/storage/app/public/file.jpg'`, and the new crop will be created at `'/storage/app/public/file-200x_.jpg'`. And because the URL generated by `Croppa::url()` points to the location where the crop was created, the web server (Apache, etc) will directly serve it on the next request (your app won’t boot just to serve an image). 65 | 66 | #### Src images on S3, local crops 67 | 68 | This is a good solution for a load balanced environment. Each app server will end up with it’s own cache of cropped images, so there is some wasted space. But the web server (Apache, etc) can still serve the crops directly on subsequent crop requests. A tmp_disk must also be defined to temporarily store the source image. 69 | 70 | ```php 71 | // Croppa config.php 72 | return [ 73 | 'src_disk' => 's3', 74 | 'tmp_disk' => 'temp', 75 | 'crops_disk' => 'public', 76 | 'path' => 'storage/(.*)$', 77 | ]; 78 | ``` 79 | 80 | Thus, if you have ``, the returned URL will be `/storage/file-200x100.jpg`, the source image will be looked for immediately within the S3 bucket that was configured as part of the Flysystem instance, and the new crop will be created at `/storage/app/public/file-200x100.jpg`. 81 | 82 | ## Usage 83 | 84 | The URL schema that Croppa uses is: 85 | 86 | /path/to/image-widthxheight-option1-option2(arg1,arg2).ext 87 | 88 | So these are all valid: 89 | 90 | /storage/image-300x200.webp // Crop to fit in 300x200 91 | /storage/image-_x200.webp // Resize to height of 200px 92 | /storage/image-300x_.webp // Resize to width of 300px 93 | /storage/image-300x200-resize.webp // Resize to fit within 300x200 94 | /storage/image-300x200-quadrant(T).webp // See the quadrant description below 95 | 96 | #### Croppa::url($url, $width, $height, $options) 97 | 98 | To make preparing the URLs that Croppa expects an easier job, you can use the following view helper: 99 | 100 | ```php 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | ``` 113 | 114 | These are the arguments that Croppa::url() takes: 115 | 116 | - $url : The URL of your source image. The path to the image relative to the `src_disk` will be extracted using the `path` config regex. 117 | - $width : A number or null for wildcard 118 | - $height : A number or null for wildcard 119 | - $options - An array of key value pairs, where the value is an optional array of arguments for the option. Supported option are: 120 | - `resize` - Make the image fit in the provided width and height through resizing. When omitted, the default is to crop to fit in the bounds (unless one of sides is a wildcard). 121 | - `pad` - Pad an image to desired dimensions. Moves the image into the center and fills the rest with given color. If no color is given, it will use white [255,255,255] 122 | - `quadrant($quadrant)` - Crop the remaining overflow of an image using the passed quadrant heading. The supported `$quadrant` values are: `T` - Top (good for headshots), `B` - Bottom, `L` - Left, `R` - Right, `C` - Center (default). 123 | - `trim($x1, $y1, $x2, $y2)` - Crop the source image to the size defined by the two sets of coordinates ($x1, $y1, ...) BEFORE applying the $width and $height parameters. This is designed to be used with a frontend cropping UI like [jcrop](http://deepliquid.com/content/Jcrop.html) so that you can respect a cropping selection that the user has defined but then output thumbnails or sized down versions of that selection with Croppa. 124 | - `trim_perc($x1_perc, $y1_perc, $x2_perc, $y2_perc)` - Has the same effect as `trim()` but accepts coordinates as percentages. Thus, the the upper left of the image is "0" and the bottom right of the image is "1". So if you wanted to trim the image to half the size around the center, you would add an option of `trim_perc(0.25,0.25,0.75,0.75)` 125 | - `quality($int)` - Set the jpeg compression quality from 0 to 100. 126 | - `interlace($bool)` - Set to `1` or `0` to turn interlacing on or off 127 | - `upsize($bool)` - Set to `1` or `0` to allow images to be upsized. If falsey and you ask for a size bigger than the source, it will **only** create an image as big as the original source. 128 | 129 | #### Croppa::render($cropurl) 130 | 131 | If you want to create the image programmatically you can pass to this function the url generated by Croppa::url. 132 | This will only create the thumbnail and exit. 133 | 134 | ```php 135 | Croppa::render('image-300x200.png'); 136 | ``` 137 | 138 | or 139 | 140 | ```php 141 | Croppa::render(Croppa::url('image.png', 300, 200)); 142 | ``` 143 | 144 | #### Croppa::delete($url) 145 | 146 | You can delete a source image and all of its crops by running: 147 | 148 | ```php 149 | Croppa::delete('/path/to/src.png'); 150 | ``` 151 | 152 | #### Croppa::reset($url) 153 | 154 | Similar to `Croppa::delete()` except the source image is preserved, only the crops are deleted. 155 | 156 | ```php 157 | Croppa::reset('/path/to/src.png'); 158 | ``` 159 | 160 | ## Console commands 161 | 162 | #### `croppa:purge` 163 | 164 | Deletes **all** crops. This works by scanning the `crops_disk` recursively and matching all files that have the Croppa naming convention where a corresponding `src` file can be found. Accepts the following options: 165 | 166 | - `--filter` - Applies a whitelisting regex filter to the crops. For example: `--filter=^01/` matches all crops in the "./public/uploads/01/" directory 167 | - `--dry-run` - Ouputs the files that would be deleted to the console, but doesn’t actually remove 168 | 169 | ## croppa.js 170 | 171 | A module is included to prepare formatted URLs from JS. This can be helpful when you are creating views from JSON responses from an AJAX request; you don’t need to format the URLs on the server. It can be loaded via Require.js, CJS, or as browser global variable. 172 | 173 | ### croppa.url(url, width, height, options) 174 | 175 | Works just like the PHP `Croppa::url` except for how options get formatted (since JS doesn’t have associative arrays). 176 | 177 | ```js 178 | croppa.url('/path/to/img.jpg', 300, 200, ['resize']); 179 | croppa.url('/path/to/img.jpg', 300, 200, ['resize', { quadrant: 'T' }]); 180 | croppa.url('/path/to/img.jpg', 300, 200, ['resize', { quadrant: ['T'] }]); 181 | ``` 182 | 183 | Run `php artisan asset:publish bkwld/croppa` to have Laravel copy the JS to your public directory. It will go to /public/packages/bkwld/croppa/js by default. 184 | 185 | ## History 186 | 187 | Read the Github [project releases](https://github.com/BKWLD/croppa/releases) for release notes. 188 | 189 | This package uses [Intervention Image](https://image.intervention.io/) to do all the image resizing. "Crop" is equivalent to its cover() method and "resize" is scale(). Support for interacting with non-local disks provided by [Flysystem](http://flysystem.thephpleague.com/). 190 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bkwld/croppa", 3 | "description": "Image thumbnail creation through specially formatted URLs for Laravel", 4 | "keywords": [ 5 | "image", 6 | "thumb", 7 | "resize", 8 | "laravel" 9 | ], 10 | "homepage": "https://github.com/bkwld/croppa", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Robert Reinhard" 15 | }, 16 | { 17 | "name": "Samuel De Backer" 18 | } 19 | ], 20 | "require": { 21 | "php": "^8.1", 22 | "illuminate/console": "^9.0|^10.0|^11.0|^12.0", 23 | "illuminate/support": "^9.0|^10.0|^11.0|^12.0", 24 | "illuminate/routing": "^9.0|^10.0|^11.0|^12.0", 25 | "intervention/image": "^3.6", 26 | "league/flysystem": "^3.0", 27 | "symfony/http-foundation": "^6.0|^7.0", 28 | "symfony/http-kernel": "^6.0|^7.0", 29 | "ext-gd": "*" 30 | }, 31 | "require-dev": { 32 | "phpunit/phpunit": "^9.5.10|^11.5.3", 33 | "mockery/mockery": "^1.4.4", 34 | "nunomaduro/larastan": "^2.0", 35 | "orchestra/testbench": "^7.5|^10.0" 36 | }, 37 | "suggest": { 38 | "ext-exif": "Required if you want to have Croppa auto-rotate images from devices like mobile phones based on exif meta data." 39 | }, 40 | "autoload": { 41 | "psr-4": { 42 | "Bkwld\\Croppa\\": "src" 43 | } 44 | }, 45 | "autoload-dev": { 46 | "psr-4": { 47 | "Bkwld\\Croppa\\Test\\": "tests" 48 | } 49 | }, 50 | "scripts": { 51 | "test": "vendor/bin/phpunit" 52 | }, 53 | "extra": { 54 | "laravel": { 55 | "providers": [ 56 | "Bkwld\\Croppa\\CroppaServiceProvider" 57 | ], 58 | "aliases": { 59 | "Croppa": "Bkwld\\Croppa\\Facades\\Croppa" 60 | } 61 | } 62 | }, 63 | "minimum-stability": "dev", 64 | "prefer-stable": true 65 | } 66 | -------------------------------------------------------------------------------- /config/config.php: -------------------------------------------------------------------------------- 1 | 'public', 15 | 16 | /* 17 | * The disk where cropped images will be saved. 18 | */ 19 | 'crops_disk' => 'public', 20 | 21 | /* 22 | * If using a remote src_disk, a local disk must be specified where remote 23 | * source images are temporarily copied. 24 | * (The Intervention Image library can't download remote images - 25 | * see: https://image.intervention.io/v3/introduction/upgrade) 26 | * Set to false/null/empty-string to disable. 27 | */ 28 | 'tmp_disk' => 'public', 29 | 30 | /* 31 | * Maximum number of sizes to allow for a particular source file. This is to 32 | * limit scripts from filling up your hard drive with images. Set to false or 33 | * comment out to have no limit. This is disabled by default because the 34 | * `signing_key` is a better prevention of malicious usage. 35 | * 36 | * @var integer | boolean 37 | */ 38 | 'max_crops' => false, 39 | 40 | /* 41 | |----------------------------------------------------------------------------- 42 | | URL parsing and generation 43 | |----------------------------------------------------------------------------- 44 | */ 45 | 46 | /* 47 | * A regex pattern that is applied to both the src url passed to 48 | * `Croppa::url()` as well as the crop path received when handling a crop 49 | * request to find the path to the src image relative to both the src_disk 50 | * and crops_disks. This path will be used to find the source image in the 51 | * src_disk. The path component of the regex must exist in the first captured 52 | * subpattern. In other words, in the `preg_match` $matches[1]. 53 | * 54 | * @var string 55 | */ 56 | 'path' => 'storage/(.*)$', 57 | 58 | /* 59 | * A regex pattern that works like `path` except it is only used by the 60 | * `Croppa::url($url)` generator function. If the $path url matches, it is 61 | * passed through with no Croppa URL suffixes added. Thus, it will not be 62 | * cropped. This is designed, in particular, for animated gifs which lose 63 | * animation when cropped. 64 | * 65 | * @var string 66 | */ 67 | 'ignore' => '\.(gif|GIF)$', 68 | 69 | /* 70 | * Reject attempts to maliciously create images by signing the generated 71 | * request with a hash based on the request parameters and this signing key. 72 | * Set to 'app.key' to use Laravel's `app.key` config, any other string to use 73 | * THAT as the key, or false to disable. 74 | * 75 | * If you are generating URLs outside of `Croppa::url()`, like the croppa.js 76 | * module, you can disable this feature by setting the `signing_key` config 77 | * to false. 78 | * 79 | * @var string | boolean 80 | */ 81 | 'signing_key' => 'app.key', 82 | 83 | /* 84 | * The PHP memory limit used by the script to generate thumbnails. Some 85 | * images require a lot of memory to perform the resize, so you may increase 86 | * this memory limit. 87 | */ 88 | 'memory_limit' => '128M', 89 | 90 | /* 91 | |----------------------------------------------------------------------------- 92 | | Image settings 93 | |----------------------------------------------------------------------------- 94 | */ 95 | 96 | /* 97 | * The jpeg quality of generated images. The difference between 100 and 95 98 | * usually cuts the file size in half. Going down to 70 looks ok on photos 99 | * and will reduce filesize by more than another half but on vector files 100 | * there is noticeable aliasing. 101 | * 102 | * @var integer 103 | */ 104 | 'quality' => 95, 105 | 106 | /* 107 | * Turn on interlacing to make progessive jpegs. 108 | * 109 | * @var boolean 110 | */ 111 | 'interlace' => true, 112 | 113 | /* 114 | * If the source image is smaller than the requested size, allow Croppa to 115 | * scale up the image. This will reduce in quality loss. 116 | * 117 | * @var boolean 118 | */ 119 | 'upsize' => false, 120 | 121 | /* 122 | * Filters for adding additional GD effects to an image and using them as parameter 123 | * in the croppa image slug. 124 | * 125 | * @var array 126 | */ 127 | 'filters' => [ 128 | 'gray' => Bkwld\Croppa\Filters\BlackWhite::class, 129 | 'darkgray' => Bkwld\Croppa\Filters\Darkgray::class, 130 | 'blur' => Bkwld\Croppa\Filters\Blur::class, 131 | 'negative' => Bkwld\Croppa\Filters\Negative::class, 132 | 'orange' => Bkwld\Croppa\Filters\OrangeWarhol::class, 133 | 'turquoise' => Bkwld\Croppa\Filters\TurquoiseWarhol::class, 134 | ], 135 | ]; 136 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": {}, 3 | "devDependencies": { 4 | "mocha": "^3.5.3" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/nunomaduro/larastan/extension.neon 3 | 4 | parameters: 5 | 6 | paths: 7 | - src 8 | 9 | # The level 9 is the highest level 10 | level: 5 11 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests/ 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BKWLD/croppa/792c548bfe686285379c02f3d9bded754152e2c6/public/.gitkeep -------------------------------------------------------------------------------- /public/js/croppa.js: -------------------------------------------------------------------------------- 1 | // -------------------------------------------------- 2 | // Utilities for working with Croppa 3 | // 4 | // This is wrapped in the simplified returnExports UMD pattern 5 | // for use in Node, AMD, and web globals. 6 | // https://github.com/umdjs/umd/blob/master/returnExports.js 7 | // -------------------------------------------------- 8 | (function (root, factory) { 9 | if (typeof define === 'function' && define.amd) { define([], factory); } 10 | else if (typeof exports === 'object') { module.exports = factory(); } 11 | else { root.croppa = factory(); } 12 | }(this, function () { 13 | 14 | // Build a croppa formatted URL 15 | function url(src, width, height, options) { 16 | 17 | // Defaults 18 | if (!src) return; // Don't allow empty strings 19 | if (!width) width = '_'; 20 | else width = Math.round(width); 21 | if (!height) height = '_'; 22 | else height = Math.round(height); 23 | 24 | // Produce the croppa syntax 25 | var suffix = '-'+width+'x'+height; 26 | 27 | // Add options. If the key has no arguments (like resize), the key will be like [1] 28 | if (options && options instanceof Array) { 29 | var val, key; 30 | for (key in options) { 31 | val = options[key]; 32 | 33 | // A simple string option 34 | if (typeof val == 'string') suffix += '-'+val; 35 | 36 | // An object like {quadrant: 'T'} or {quadrant: ['T']} 37 | else if (typeof val === 'object') { 38 | for (key in val) { 39 | val = val[key]; 40 | if (val instanceof Array) suffix += '-'+key+'('+val.join(',')+')'; 41 | else suffix += '-'+key+'('+val+')'; 42 | break; // Only one key-val pair is allowed 43 | } 44 | } 45 | } 46 | } 47 | 48 | // Break the path apart and put back together again 49 | return src.replace(/^(.+)(\.[a-z]+)$/i, "$1"+suffix+"$2"); 50 | } 51 | 52 | // Expose public methods. 53 | return { 54 | url: url 55 | }; 56 | })); -------------------------------------------------------------------------------- /public/js/test/url.js: -------------------------------------------------------------------------------- 1 | // These tests were designed to be run via Mocha 2 | 3 | var assert = require("assert"), 4 | croppa = require('../croppa'); 5 | 6 | describe('Croppa', function() { 7 | describe('#url', function() { 8 | 9 | it('should append width and height when passed', function(){ 10 | assert.equal('/path/to/file-200x100.jpg', croppa.url('/path/to/file.jpg', 200, 100)); 11 | assert.equal('/path/to/file-200x_.jpg', croppa.url('/path/to/file.jpg', 200)); 12 | assert.equal('/path/to/file-_x100.jpg', croppa.url('/path/to/file.jpg', null, 100)); 13 | }); 14 | 15 | it('should allow width or height to be empty', function(){ 16 | assert.equal('/path/to/file-200x_.jpg', croppa.url('/path/to/file.jpg', 200)); 17 | assert.equal('/path/to/file-_x100.jpg', croppa.url('/path/to/file.jpg', null, 100)); 18 | }); 19 | 20 | it('should allow the setting of string options', function(){ 21 | assert.equal('/path/to/file-200x100-resize.jpg', croppa.url('/path/to/file.jpg', 200, 100, ['resize'])); 22 | }); 23 | 24 | it('should allow the setting of key value options', function(){ 25 | assert.equal('/path/to/file-200x100-quadrant(T).jpg', croppa.url('/path/to/file.jpg', 200, 100, [{quadrant: 'T'}])); 26 | assert.equal('/path/to/file-200x100-quadrant(T).jpg', croppa.url('/path/to/file.jpg', 200, 100, [{quadrant: ['T']}])); 27 | assert.equal('/path/to/file-200x100-coordinates(1,2,3,4).jpg', croppa.url('/path/to/file.jpg', 200, 100, [{coordinates: [1,2,3,4]}])); 28 | }); 29 | 30 | it('should allow the setting of multiple options', function(){ 31 | assert.equal('/path/to/file-200x100-resize-quadrant(T).jpg', croppa.url('/path/to/file.jpg', 200, 100, ['resize', {quadrant: 'T'}])); 32 | }); 33 | 34 | }); 35 | }); -------------------------------------------------------------------------------- /src/Commands/Purge.php: -------------------------------------------------------------------------------- 1 | storage = $storage; 40 | } 41 | 42 | /** 43 | * Execute the console command. 44 | */ 45 | public function handle() 46 | { 47 | $dry = $this->input->getOption('dry-run'); 48 | foreach ($this->storage->deleteAllCrops($this->input->getOption('filter'), $dry) as $path) { 49 | $this->info(sprintf('%s %s', $path, $dry ? 'not deleted' : 'deleted')); 50 | } 51 | } 52 | 53 | /** 54 | * Get the console command options. 55 | * 56 | * @return array; 57 | */ 58 | protected function getOptions() 59 | { 60 | return [ 61 | ['filter', null, InputOption::VALUE_REQUIRED, 'A regex pattern that whitelists matching crop paths', null], 62 | ['dry-run', null, InputOption::VALUE_NONE, 'Only return the crops that would be deleted'], 63 | ]; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/CroppaServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton(URL::class, function ($app) { 14 | return new URL($this->getConfig()); 15 | }); 16 | 17 | // Handle the request for an image, this coordinates the main logic. 18 | $this->app->singleton(Handler::class, function ($app) { 19 | return new Handler( 20 | $app[URL::class], 21 | $app[Storage::class], 22 | $app['request'], 23 | $this->getConfig() 24 | ); 25 | }); 26 | 27 | // Interact with the disk. 28 | $this->app->singleton(Storage::class, function () { 29 | return new Storage($this->getConfig()); 30 | }); 31 | 32 | // API for use in apps. 33 | $this->app->singleton(Helpers::class, function ($app) { 34 | return new Helpers($app[URL::class], $app[Storage::class], $app[Handler::class]); 35 | }); 36 | 37 | // Register command to delete all crops. 38 | $this->app->singleton(Purge::class, function ($app) { 39 | return new Purge($app[Storage::class]); 40 | }); 41 | 42 | $this->commands(Purge::class); 43 | } 44 | 45 | public function boot() 46 | { 47 | $this->mergeConfigFrom(__DIR__.'/../config/config.php', 'croppa'); 48 | $this->publishes([__DIR__.'/../config/config.php' => config_path('croppa.php')], 'croppa-config'); 49 | 50 | $this->app['router'] 51 | ->get('{path}', 'Bkwld\Croppa\Handler@handle') 52 | ->where('path', $this->app[URL::class]->routePattern()); 53 | } 54 | 55 | /** 56 | * Get the configuration. 57 | * 58 | * @return array 59 | */ 60 | public function getConfig() 61 | { 62 | $config = $this->app->make('config')->get('croppa'); 63 | 64 | // Use Laravel’s encryption key if instructed to. 65 | if (isset($config['signing_key']) && $config['signing_key'] === 'app.key') { 66 | $config['signing_key'] = $this->app->make('config')->get('app.key'); 67 | } 68 | 69 | return $config; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Exception.php: -------------------------------------------------------------------------------- 1 | greyscale(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Filters/Blur.php: -------------------------------------------------------------------------------- 1 | blur(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Filters/Darkgray.php: -------------------------------------------------------------------------------- 1 | greyscale()->colorize(-50, -50, -50); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Filters/FilterInterface.php: -------------------------------------------------------------------------------- 1 | invert(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Filters/OrangeWarhol.php: -------------------------------------------------------------------------------- 1 | greyscale()->brightness(50)->colorize(-10, -70, -100); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Filters/TurquoiseWarhol.php: -------------------------------------------------------------------------------- 1 | greyscale()->brightness(50)->colorize(-70, -10, -20); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Handler.php: -------------------------------------------------------------------------------- 1 | url = $url; 41 | $this->storage = $storage; 42 | $this->request = $request; 43 | $this->config = $config; 44 | } 45 | 46 | /** 47 | * Handles a Croppa style route. 48 | * 49 | * @throws Exception 50 | */ 51 | public function handle(string $requestPath): mixed 52 | { 53 | // Validate the signing token 54 | $token = $this->url->signingToken($requestPath); 55 | if ($token !== $this->request->input('token')) { 56 | throw new NotFoundHttpException('Token mismatch'); 57 | } 58 | 59 | // Create the image file 60 | $cropPath = $this->render($requestPath); 61 | 62 | // Redirect to remote crops ... 63 | if ($this->storage->cropsAreRemote()) { 64 | return redirect(app('filesystem')->disk($this->config['crops_disk'])->url($cropPath), 301); 65 | // ... or echo the image data to the browser 66 | } 67 | $absolutePath = $this->storage->getLocalCropPath($cropPath); 68 | 69 | return new BinaryFileResponse($absolutePath, 200, [ 70 | 'Content-Type' => $this->getContentType($absolutePath), 71 | ]); 72 | } 73 | 74 | /** 75 | * Render image. Return the path to the crop relative to the storage disk. 76 | */ 77 | public function render(string $requestPath): ?string 78 | { 79 | // Get crop path relative to it’s dir 80 | $cropPath = $this->url->relativePath($requestPath); 81 | 82 | // If the crops_disk is a remote disk and if the crop has already been 83 | // created. If it has, just return that path. 84 | if ($this->storage->cropsAreRemote() && $this->storage->cropExists($cropPath)) { 85 | return $cropPath; 86 | } 87 | 88 | // Parse the path. In the case there is an error (the pattern on the route 89 | // SHOULD have caught all errors with the pattern), return null. 90 | if (!$params = $this->url->parse($requestPath)) { 91 | return null; 92 | } 93 | list($path, $width, $height, $options) = $params; 94 | 95 | // Check if there are too many crops already 96 | if ($this->storage->tooManyCrops($path)) { 97 | throw new Exception('Croppa: Max crops'); 98 | } 99 | 100 | // Increase memory limit, cause some images require a lot to resize 101 | if ($this->config['memory_limit'] !== null) { 102 | ini_set('memory_limit', $this->config['memory_limit']); 103 | } 104 | 105 | // Build a new image using fetched image data 106 | $image = new Image( 107 | $this->storage->path($path), 108 | $this->url->config($options) 109 | ); 110 | 111 | // Process the image and write its data to disk 112 | $this->storage->writeCrop( 113 | $cropPath, 114 | $image->process($width, $height, $options)->get() 115 | ); 116 | 117 | // Return the path to the crop, relative to the storage disk 118 | return $cropPath; 119 | } 120 | 121 | /** 122 | * Determining MIME-type via the path name. 123 | */ 124 | public function getContentType(string $path): string 125 | { 126 | switch (pathinfo($path, PATHINFO_EXTENSION)) { 127 | case 'gif': 128 | return 'image/gif'; 129 | 130 | case 'png': 131 | return 'image/png'; 132 | 133 | case 'webp': 134 | return 'image/webp'; 135 | 136 | default: 137 | return 'image/jpeg'; 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Helpers.php: -------------------------------------------------------------------------------- 1 | url = $url; 32 | $this->storage = $storage; 33 | $this->handler = $handler; 34 | } 35 | 36 | /** 37 | * Delete source image and all of it's crops. 38 | * 39 | * @see Bkwld\Croppa\Storage::deleteSrc() 40 | * @see Bkwld\Croppa\Storage::deleteCrops() 41 | */ 42 | public function delete(string $url) 43 | { 44 | $path = $this->url->relativePath($url); 45 | $this->storage->deleteSrc($path); 46 | $this->storage->deleteCrops($path); 47 | } 48 | 49 | /** 50 | * Delete just the crops, leave the source image. 51 | * 52 | * @see Bkwld\Croppa\Storage::deleteCrops() 53 | */ 54 | public function reset(string $url) 55 | { 56 | $path = $this->url->relativePath($url); 57 | $this->storage->deleteCrops($path); 58 | } 59 | 60 | /** 61 | * Create an image tag rather than just the URL. Accepts the same params as url(). 62 | * 63 | * @see Bkwld\Croppa\URL::generate() 64 | */ 65 | public function tag(string $url, ?int $width = null, ?int $height = null, ?array $options = null): string 66 | { 67 | return ''; 68 | } 69 | 70 | /** 71 | * Pass through URL requests to URL->generate(). 72 | * 73 | * @see Bkwld\Croppa\URL::generate() 74 | */ 75 | public function url(string $url, ?int $width = null, ?int $height = null, ?array $options = null): string 76 | { 77 | return $this->url->generate($url, $width, $height, $options); 78 | } 79 | 80 | /** 81 | * Render image. 82 | * 83 | * @see Bkwld\Croppa\URL::generate() 84 | */ 85 | public function render(string $url): string 86 | { 87 | return $this->handler->render($url); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Image.php: -------------------------------------------------------------------------------- 1 | image = $manager->read($path); 45 | $this->interlace = $options['interlace']; 46 | $this->upsize = $options['upsize']; 47 | if (isset($options['quality']) && is_array($options['quality'])) { 48 | $this->quality = reset($options['quality']); 49 | } else { 50 | $this->quality = $options['quality']; 51 | } 52 | $this->format = $options['format'] ?? $this->getFormatFromPath($path); 53 | } 54 | 55 | /** 56 | * Take the input from the URL and apply transformations on the image. 57 | */ 58 | public function process(?int $width, ?int $height, array $options = []): self 59 | { 60 | $this->trim($options) 61 | ->resizeAndOrCrop($width, $height, $options) 62 | ->applyFilters($options); 63 | 64 | return $this; 65 | } 66 | 67 | /** 68 | * Determine which trim to apply. 69 | */ 70 | public function trim(array $options): self 71 | { 72 | if (isset($options['trim'])) { 73 | return $this->trimPixels($options['trim']); 74 | } 75 | if (isset($options['trim_perc'])) { 76 | return $this->trimPerc($options['trim_perc']); 77 | } 78 | 79 | return $this; 80 | } 81 | 82 | /** 83 | * Trim the source before applying the crop with as offset pixels. 84 | */ 85 | public function trimPixels(array $coords): self 86 | { 87 | list($x1, $y1, $x2, $y2) = $coords; 88 | $width = $x2 - $x1; 89 | $height = $y2 - $y1; 90 | $this->image->crop($width, $height, $x1, $y1); 91 | 92 | return $this; 93 | } 94 | 95 | /** 96 | * Trim the source before applying the crop with offset percentages. 97 | */ 98 | public function trimPerc(array $coords): self 99 | { 100 | list($x1, $y1, $x2, $y2) = $coords; 101 | $imgWidth = $this->image->width(); 102 | $imgHeight = $this->image->height(); 103 | $x = (int) round($x1 * $imgWidth); 104 | $y = (int) round($y1 * $imgHeight); 105 | $width = (int) round($x2 * $imgWidth - $x); 106 | $height = (int) round($y2 * $imgHeight - $y); 107 | $this->image->crop($width, $height, $x, $y); 108 | 109 | return $this; 110 | } 111 | 112 | /** 113 | * Determine which resize and crop to apply. 114 | */ 115 | public function resizeAndOrCrop(?int $width, ?int $height, array $options = []): self 116 | { 117 | if (!$width && !$height) { 118 | return $this; 119 | } 120 | if (isset($options['quadrant'])) { 121 | return $this->cropQuadrant($width, $height, $options); 122 | } 123 | if (isset($options['pad']) || in_array('pad', $options)) { 124 | $this->pad($width, $height, $options); 125 | } 126 | if (array_key_exists('resize', $options) || !$width || !$height) { 127 | return $this->resize($width, $height); 128 | } 129 | 130 | return $this->crop($width, $height); 131 | } 132 | 133 | /** 134 | * Do a quadrant adaptive resize. Supported quadrant values are: 135 | * +---+---+---+ 136 | * | | T | | 137 | * +---+---+---+ 138 | * | L | C | R | 139 | * +---+---+---+ 140 | * | | B | | 141 | * +---+---+---+. 142 | * 143 | * @throws Exception 144 | */ 145 | public function cropQuadrant(?int $width, ?int $height, array $options): self 146 | { 147 | if (!$height || !$width) { 148 | throw new Exception('Croppa: Qudrant option needs width and height'); 149 | } 150 | if (empty($options['quadrant'][0])) { 151 | throw new Exception('Croppa:: No quadrant specified'); 152 | } 153 | $quadrant = mb_strtoupper($options['quadrant'][0]); 154 | if (!in_array($quadrant, ['T', 'L', 'C', 'R', 'B'])) { 155 | throw new Exception('Croppa:: Invalid quadrant'); 156 | } 157 | $positions = [ 158 | 'T' => 'top', 159 | 'L' => 'left', 160 | 'C' => 'center', 161 | 'R' => 'right', 162 | 'B' => 'bottom', 163 | ]; 164 | if (!$this->upsize) { 165 | $this->image->coverDown($width, $height, $positions[$quadrant]); 166 | } else { 167 | $this->image->cover($width, $height, $positions[$quadrant]); 168 | } 169 | 170 | return $this; 171 | } 172 | 173 | /** 174 | * Resize with no cropping. 175 | */ 176 | public function resize(?int $width, ?int $height): self 177 | { 178 | if (!$this->upsize) { 179 | $this->image->scaleDown($width, $height); 180 | } else { 181 | $this->image->scale($width, $height); 182 | } 183 | 184 | return $this; 185 | } 186 | 187 | /** 188 | * Resize and crop. 189 | */ 190 | public function crop(?int $width, ?int $height): self 191 | { 192 | if (!$this->upsize) { 193 | $this->image->coverDown($width, $height); 194 | } else { 195 | $this->image->cover($width, $height); 196 | } 197 | 198 | return $this; 199 | } 200 | 201 | /** 202 | * Pad an image to desired dimensions. 203 | * Moves and resize the image into the center and fills the rest with given color. 204 | */ 205 | public function pad(?int $width, ?int $height, array $options): self 206 | { 207 | if (!$height || !$width) { 208 | throw new Exception('Croppa: Pad option needs width and height'); 209 | } 210 | 211 | $rgbArray = $options['pad'] ?? [255, 255, 255]; 212 | $color = sprintf("#%02x%02x%02x", $rgbArray[0], $rgbArray[1], $rgbArray[2]); 213 | 214 | if (!$this->upsize) { 215 | $this->image->scaleDown($width, $height); 216 | } else { 217 | $this->image->scale($width, $height); 218 | } 219 | 220 | $this->image->resizeCanvas($width, $height, $color, 'center'); 221 | 222 | return $this; 223 | } 224 | 225 | /** 226 | * Apply filters that have been defined in the config as seperate classes. 227 | */ 228 | public function applyFilters(array $options): self 229 | { 230 | if (isset($options['filters']) && is_array($options['filters'])) { 231 | array_map(function ($filter) { 232 | $this->image = $filter->applyFilter($this->image); 233 | }, $options['filters']); 234 | } 235 | 236 | return $this; 237 | } 238 | 239 | private function getFormatFromPath(string $path): string 240 | { 241 | switch (pathinfo($path, PATHINFO_EXTENSION)) { 242 | case 'gif': 243 | return 'gif'; 244 | 245 | case 'png': 246 | return 'png'; 247 | 248 | case 'webp': 249 | return 'webp'; 250 | 251 | default: 252 | return 'jpg'; 253 | } 254 | } 255 | 256 | /** 257 | * Get the image data. 258 | */ 259 | public function get(): string 260 | { 261 | return $this->image->encodeByExtension($this->format, progressive: $this->interlace, quality: $this->quality); 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /src/Storage.php: -------------------------------------------------------------------------------- 1 | config = $config; 47 | } 48 | 49 | /** 50 | * Factory function to create an instance and then "mount" disks. 51 | */ 52 | public static function make(array $config) 53 | { 54 | return with(new static($config))->mount(); 55 | } 56 | 57 | /** 58 | * Set the crops disk. 59 | */ 60 | public function setCropsDisk(FilesystemAdapter $disk): void 61 | { 62 | $this->cropsDisk = $disk; 63 | } 64 | 65 | /** 66 | * Get the crops disk or make via the config. 67 | */ 68 | public function getCropsDisk(): FilesystemAdapter 69 | { 70 | if (empty($this->cropsDisk)) { 71 | $this->setCropsDisk($this->makeDisk($this->config['crops_disk'])); 72 | } 73 | 74 | return $this->cropsDisk; 75 | } 76 | 77 | /** 78 | * Set the src disk. 79 | */ 80 | public function setSrcDisk(FilesystemAdapter $disk): void 81 | { 82 | $this->srcDisk = $disk; 83 | } 84 | 85 | /** 86 | * Get the src disk or make via the config. 87 | */ 88 | public function getSrcDisk(): FilesystemAdapter 89 | { 90 | if (empty($this->srcDisk)) { 91 | $this->setSrcDisk($this->makeDisk($this->config['src_disk'])); 92 | } 93 | 94 | return $this->srcDisk; 95 | } 96 | 97 | /** 98 | * Set the tmp disk. 99 | */ 100 | public function setTmpDisk(FilesystemAdapter $disk): void 101 | { 102 | $this->tmpDisk = $disk; 103 | } 104 | 105 | /** 106 | * Get the tmp disk or make via the config. 107 | */ 108 | public function getTmpDisk(): FilesystemAdapter 109 | { 110 | if (empty($this->tmpDisk)) { 111 | $this->setTmpDisk($this->makeDisk($this->config['tmp_disk'])); 112 | } 113 | 114 | return $this->tmpDisk; 115 | } 116 | 117 | /** 118 | * "Mount" disks given the config. 119 | */ 120 | public function mount(): self 121 | { 122 | $this->setSrcDisk($this->makeDisk($this->config['src_disk'])); 123 | $this->setCropsDisk($this->makeDisk($this->config['crops_disk'])); 124 | 125 | return $this; 126 | } 127 | 128 | /** 129 | * Use or instantiate a Flysystem disk. 130 | */ 131 | public function makeDisk(string $disk): FilesystemAdapter 132 | { 133 | return FacadesStorage::disk($disk); 134 | } 135 | 136 | /** 137 | * Return whether crops are stored remotely. 138 | */ 139 | public function cropsAreRemote(): bool 140 | { 141 | return !$this->getCropsDisk()->getAdapter() instanceof LocalFilesystemAdapter; 142 | } 143 | 144 | /** 145 | * Check if a remote crop exists. 146 | */ 147 | public function cropExists(string $path): bool 148 | { 149 | return $this->getCropsDisk()->fileExists($path); 150 | } 151 | 152 | /** 153 | * Get the src path or throw an exception. 154 | */ 155 | public function path(string $path): string 156 | { 157 | $srcDisk = $this->getSrcDisk(); 158 | if ($srcDisk->fileExists($path)) { 159 | if ($srcDisk->getAdapter() instanceof LocalFilesystemAdapter) { 160 | return $srcDisk->path($path); 161 | } 162 | 163 | // If a tmp_disk has been configured, copy file from remote srcDisk to tmpDisk 164 | if ($this->config['tmp_disk']) { 165 | $tmpDisk = $this->getTmpDisk(); 166 | $tmpDisk->writeStream($path, $srcDisk->readStream($path)); 167 | $this->tmpPath = $path; 168 | return $tmpDisk->path($path); 169 | } 170 | 171 | // With Intervention 3, this will lead to a DecoderException ("Unable to decode input") 172 | // We should probably throw an exception here to inform the developer that a tmp_disk is required. 173 | return $srcDisk->url($path); 174 | } 175 | 176 | throw new NotFoundHttpException('Croppa: Src image is missing'); 177 | } 178 | 179 | /** 180 | * Write the cropped image contents to disk. 181 | * 182 | * @throws Exception 183 | */ 184 | public function writeCrop(string $path, string $contents): void 185 | { 186 | try { 187 | $this->getCropsDisk()->write($path, $contents); 188 | } catch (FilesystemException $e) { 189 | // don't throw exception anymore as mentioned in PR #164 190 | } 191 | $this->cleanup(); 192 | } 193 | 194 | /** 195 | * Cleanup: delete tmp file if required. 196 | */ 197 | public function cleanup(): void 198 | { 199 | if ($this->tmpPath <> '') { 200 | $this->getTmpDisk()->delete($this->tmpPath); 201 | $this->tmpPath = ''; 202 | } 203 | } 204 | 205 | /** 206 | * Get a local crops disks absolute path. 207 | * 208 | * @param mixed $path 209 | */ 210 | public function getLocalCropPath($path): string 211 | { 212 | return $this->getCropsDisk()->path($path); 213 | } 214 | 215 | /** 216 | * Delete src image. 217 | */ 218 | public function deleteSrc(string $path) 219 | { 220 | $this->getSrcDisk()->delete($path); 221 | } 222 | 223 | /** 224 | * Delete crops. 225 | */ 226 | public function deleteCrops(string $path): array 227 | { 228 | $crops = $this->listCrops($path); 229 | $disk = $this->getCropsDisk(); 230 | foreach ($crops as $crop) { 231 | $disk->delete($crop); 232 | } 233 | 234 | return $crops; 235 | } 236 | 237 | /** 238 | * Delete ALL crops. 239 | */ 240 | public function deleteAllCrops(?string $filter = null, bool $dry_run = false): array 241 | { 242 | $crops = $this->listAllCrops($filter); 243 | $disk = $this->getCropsDisk(); 244 | if (!$dry_run) { 245 | foreach ($crops as $crop) { 246 | $disk->delete($crop); 247 | } 248 | } 249 | 250 | return $crops; 251 | } 252 | 253 | /** 254 | * Count up the number of crops that have already been created 255 | * and return true if they are at the max number. 256 | */ 257 | public function tooManyCrops(string $path): bool 258 | { 259 | if (empty($this->config['max_crops'])) { 260 | return false; 261 | } 262 | 263 | return count($this->listCrops($path)) >= $this->config['max_crops']; 264 | } 265 | 266 | /** 267 | * Find all the crops that have been generated for a src path. 268 | */ 269 | public function listCrops(string $path): array 270 | { 271 | // Get the filename and dir 272 | $filename = basename($path); 273 | $dir = dirname($path); 274 | if ($dir === '.') { 275 | $dir = ''; 276 | } // Flysystem doesn't like "." for the dir 277 | 278 | // Filter the files in the dir to just crops of the image path 279 | return $this->justPaths(array_filter( 280 | $this->getCropsDisk()->listContents($dir)->toArray(), 281 | function ($file) use ($filename) { 282 | // Don't return the source image, we're JUST getting crops 283 | return pathinfo($file['path'], PATHINFO_BASENAME) !== $filename 284 | // Test that the crop begins with the src's path, that the crop is FOR 285 | // the src 286 | && mb_strpos(pathinfo($file['path'], PATHINFO_FILENAME), pathinfo($filename, PATHINFO_FILENAME)) === 0 287 | 288 | // Make sure that the crop matches that Croppa file regex 289 | && preg_match('#'.URL::PATTERN.'#', $file['path']); 290 | } 291 | )); 292 | } 293 | 294 | /** 295 | * Find all the crops witin the crops dir, optionally applying a filtering 296 | * regex to them. 297 | */ 298 | public function listAllCrops(?string $filter = null): array 299 | { 300 | return $this->justPaths(array_filter( 301 | $this->getCropsDisk()->listContents('', true)->toArray(), 302 | function ($file) use ($filter) { 303 | // If there was a filter, force it to match 304 | if ($filter && !preg_match("#{$filter}#i", $file['path'])) { 305 | return; 306 | } 307 | 308 | // Check that the file matches the pattern and get at the parts to make to 309 | // make the path to the src 310 | if (!preg_match('#'.URL::PATTERN.'#', $file['path'], $matches)) { 311 | return false; 312 | } 313 | $src = $matches[1].'.'.$matches[5]; 314 | 315 | // Test that the src file exists 316 | return $this->getSrcDisk()->fileExists($src); 317 | } 318 | )); 319 | } 320 | 321 | /** 322 | * Take a an array of results from Flysystem's listContents and get a simpler 323 | * array of paths to the files, relative to the crops_disk. 324 | */ 325 | private function justPaths(array $files): array 326 | { 327 | // Reset the indexes to be 0 based, mostly for unit testing 328 | $files = array_values($files); 329 | 330 | // Get just the path key 331 | return array_map(function ($file) { 332 | return $file['path']; 333 | }, $files); 334 | } 335 | } 336 | -------------------------------------------------------------------------------- /src/URL.php: -------------------------------------------------------------------------------- 1 | config = $config; 31 | } 32 | 33 | /** 34 | * Insert Croppa parameter suffixes into a URL. 35 | * For use as a helper in views when rendering image src attributes. 36 | */ 37 | public function generate(string $url, ?int $width = null, ?int $height = null, ?array $options = null) 38 | { 39 | // Extract the path from a URL and remove it's leading slash 40 | $path = $this->toPath($url); 41 | 42 | // Skip croppa requests for images the ignore regexp 43 | if (isset($this->config['ignore']) 44 | && preg_match('#'.$this->config['ignore'].'#', $path)) { 45 | return '/'.$path; 46 | } 47 | 48 | // Defaults 49 | if (empty($path)) { 50 | return; 51 | } // Don't allow empty strings 52 | if (!$width && !$height) { 53 | return '/'.$path; 54 | } // Pass through if empty 55 | $width = $width ? round($width) : '_'; 56 | $height = $height ? round($height) : '_'; 57 | 58 | // Produce width, height, and options 59 | $suffix = '-'.$width.'x'.$height; 60 | if ($options && is_array($options)) { 61 | foreach ($options as $key => $val) { 62 | if (is_numeric($key)) { 63 | $suffix .= '-'.$val; 64 | } elseif (is_array($val)) { 65 | $suffix .= '-'.$key.'('.implode(',', $val).')'; 66 | } else { 67 | $suffix .= '-'.$key.'('.$val.')'; 68 | } 69 | } 70 | } 71 | 72 | // Assemble the new path 73 | $parts = pathinfo($path); 74 | $path = trim($parts['dirname'], '/').'/'.$parts['filename'].$suffix; 75 | if (isset($parts['extension'])) { 76 | $path .= '.'.$parts['extension']; 77 | } 78 | $url = '/'.$path; 79 | 80 | // Secure with hash token 81 | if ($token = $this->signingToken($url)) { 82 | $url .= '?token='.$token; 83 | } 84 | 85 | // Return the $url 86 | return $url; 87 | } 88 | 89 | /** 90 | * Extract the path from a URL and remove it's leading slash. 91 | */ 92 | public function toPath(string $url): string 93 | { 94 | return ltrim(parse_url($url, PHP_URL_PATH), '/'); 95 | } 96 | 97 | /** 98 | * Generate the signing token from a URL or path. 99 | * Or, if no key was defined, return nothing. 100 | */ 101 | public function signingToken(string $url): ?string 102 | { 103 | if (isset($this->config['signing_key']) 104 | && ($key = $this->config['signing_key'])) { 105 | return md5($key.basename($url)); 106 | } 107 | 108 | return null; 109 | } 110 | 111 | /** 112 | * Make the regex for the route definition. This works by wrapping both the 113 | * basic Croppa pattern and the `path` config in positive regex lookaheads so 114 | * they working like an AND condition. 115 | * https://regex101.com/r/kO6kL1/1. 116 | * 117 | * In the Laravel router, this gets wrapped with some extra regex before the 118 | * matching happnens and for the pattern to match correctly, the final .* needs 119 | * to exist. Otherwise, the lookaheads have no length and the regex fails 120 | * https://regex101.com/r/xS3nQ2/1 121 | */ 122 | public function routePattern(): string 123 | { 124 | return sprintf('(?=%s)(?=%s).+', $this->config['path'], self::PATTERN); 125 | } 126 | 127 | /** 128 | * Parse a request path into Croppa instructions. 129 | * 130 | * @return array|bool 131 | */ 132 | public function parse(string $request) 133 | { 134 | if (!preg_match('#'.self::PATTERN.'#', $request, $matches)) { 135 | return false; 136 | } 137 | 138 | return [ 139 | $this->relativePath($matches[1].'.'.$matches[5]), // Path 140 | $matches[2] == '_' ? null : (int) $matches[2], // Width 141 | $matches[3] == '_' ? null : (int) $matches[3], // Height 142 | $this->options($matches[4]), // Options 143 | ]; 144 | } 145 | 146 | /** 147 | * Take a URL or path to an image and get the path relative to the src and 148 | * crops dirs by using the `path` config regex. 149 | */ 150 | public function relativePath(string $url): string 151 | { 152 | $path = $this->toPath($url); 153 | if (!preg_match('#'.$this->config['path'].'#', $path, $matches)) { 154 | throw new Exception("{$url} doesn't match `{$this->config['path']}`"); 155 | } 156 | 157 | return $matches[1]; 158 | } 159 | 160 | /** 161 | * Create options array where each key is an option name 162 | * and the value is an array of the passed arguments. 163 | * 164 | * @param string $optionParams Options string in the Croppa URL style 165 | */ 166 | public function options(string $optionParams): array 167 | { 168 | $options = []; 169 | 170 | // These will look like: "-quadrant(T)-resize" 171 | $optionParams = explode('-', $optionParams); 172 | 173 | // Loop through the params and make the options key value pairs 174 | foreach ($optionParams as $option) { 175 | if (!preg_match('#(\w+)(?:\(([\w,.]+)\))?#i', $option, $matches)) { 176 | continue; 177 | } 178 | if (isset($matches[2])) { 179 | $options[$matches[1]] = explode(',', $matches[2]); 180 | } else { 181 | $options[$matches[1]] = null; 182 | } 183 | } 184 | 185 | // Map filter names to filter class instances or remove the config. 186 | $options['filters'] = $this->buildfilters($options); 187 | if (empty($options['filters'])) { 188 | unset($options['filters']); 189 | } 190 | 191 | // Return new options array 192 | return $options; 193 | } 194 | 195 | /** 196 | * Build filter class instancees. 197 | * 198 | * @return null|array Array of filter instances 199 | */ 200 | public function buildFilters(array $options) 201 | { 202 | if (empty($options['filters']) || !is_array($options['filters'])) { 203 | return []; 204 | } 205 | 206 | return array_filter(array_map(function ($filter) { 207 | if (empty($this->config['filters'][$filter])) { 208 | return; 209 | } 210 | 211 | return new $this->config['filters'][$filter](); 212 | }, $options['filters'])); 213 | } 214 | 215 | /** 216 | * Take options in the URL and options from the config file 217 | * and produce a config array. 218 | */ 219 | public function config(array $options): array 220 | { 221 | return array_merge($this->config, $options); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /tests/TestDelete.php: -------------------------------------------------------------------------------- 1 | shouldReceive('listContents') 23 | ->withAnyArgs() 24 | ->andReturn([ 25 | ['path' => 'me.jpg'], 26 | ['path' => 'me-200x100.jpg'], 27 | ['path' => 'me-200x200.jpg'], 28 | ['path' => 'me-200x300.jpg'], 29 | ['path' => 'unrelated.jpg'], 30 | ]) 31 | ->shouldReceive('delete') 32 | ->withAnyArgs() 33 | ->once() 34 | ->getMock(); 35 | 36 | $storage = new Storage(); 37 | $storage->setSrcDisk($disk); 38 | $this->assertNull($storage->deleteSrc('me.jpg')); 39 | } 40 | 41 | public function testDeleteCrops() 42 | { 43 | $disk = Mockery::mock(FilesystemAdapter::class) 44 | ->shouldReceive('listContents') 45 | ->withAnyArgs() 46 | ->andReturn(new DirectoryListing([ 47 | ['path' => 'me.jpg'], 48 | ['path' => 'me-200x100.jpg'], 49 | ['path' => 'me-200x200.jpg'], 50 | ['path' => 'me-200x300.jpg'], 51 | ['path' => 'unrelated.jpg'], 52 | ])) 53 | ->shouldReceive('delete') 54 | ->withAnyArgs() 55 | ->times(3) 56 | ->getMock(); 57 | 58 | $storage = new Storage(); 59 | $storage->setCropsDisk($disk); 60 | $this->assertEquals([ 61 | 'me-200x100.jpg', 62 | 'me-200x200.jpg', 63 | 'me-200x300.jpg', 64 | ], $storage->deleteCrops('me.jpg')); 65 | } 66 | 67 | // Instantiate a helpers instance using mocked disks so the whole delete 68 | // logic can be checked 69 | private function mockHelpersForDeleting() 70 | { 71 | // The path is to a sub dir 72 | $url = new URL([ 73 | 'path' => 'uploads/(?:thumbs/)?(.*)$', 74 | ]); 75 | 76 | $src = Mockery::mock(FilesystemAdapter::class) 77 | ->shouldReceive('listContents') 78 | ->withAnyArgs() 79 | ->andReturn(new DirectoryListing([ 80 | ['path' => 'me.jpg'], 81 | ['path' => 'unrelated.jpg'], 82 | ])) 83 | ->shouldReceive('delete') 84 | ->withAnyArgs() 85 | ->once() 86 | ->getMock(); 87 | 88 | $crops = Mockery::mock(FilesystemAdapter::class) 89 | ->shouldReceive('listContents') 90 | ->withAnyArgs() 91 | ->andReturn(new DirectoryListing([ 92 | ['path' => 'me-200x100.jpg'], 93 | ['path' => 'me-200x200.jpg'], 94 | ['path' => 'me-200x300.jpg'], 95 | ['path' => 'unrelated.jpg'], 96 | ])) 97 | ->shouldReceive('delete') 98 | ->withAnyArgs() 99 | ->times(3) 100 | ->getMock(); 101 | 102 | $storage = new Storage(); 103 | $storage->setSrcDisk($src); 104 | $storage->setCropsDisk($crops); 105 | 106 | $handler = Mockery::mock('Bkwld\Croppa\Handler'); 107 | 108 | return new Helpers($url, $storage, $handler); 109 | } 110 | 111 | public function testDeleteCropsInSubDir() 112 | { 113 | $helpers = $this->mockHelpersForDeleting(); 114 | $helpers->delete('/uploads/me.jpg'); 115 | } 116 | 117 | public function testDeleteCropsInSubDirWithFullURL() 118 | { 119 | $helpers = $this->mockHelpersForDeleting(); 120 | $helpers->delete('http://domain.com/uploads/me.jpg'); 121 | } 122 | 123 | public function tearDown(): void 124 | { 125 | Mockery::close(); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /tests/TestListAllCrops.php: -------------------------------------------------------------------------------- 1 | src_disk = Mockery::mock(FilesystemAdapter::class) 33 | ->shouldReceive('fileExists')->with('01/me.jpg')->andReturn(true) 34 | ->shouldReceive('fileExists')->with('02/another.jpg')->andReturn(true) 35 | ->shouldReceive('fileExists')->with('03/ignore.jpg')->andReturn(false) 36 | ->getMock(); 37 | 38 | // Mock crops dir 39 | $this->crops_disk = Mockery::mock(FilesystemAdapter::class) 40 | ->shouldReceive('listContents') 41 | ->withAnyArgs() 42 | ->andReturn(new DirectoryListing([ 43 | ['path' => '01/me.jpg'], 44 | ['path' => '01/me-too.jpg'], 45 | ['path' => '01/me-200x100.jpg'], 46 | ['path' => '01/me-200x200.jpg'], 47 | ['path' => '01/me-200x300.jpg'], 48 | 49 | // Stored in another src dir 50 | ['path' => '02/another.jpg'], 51 | ['path' => '02/another-200x300.jpg'], 52 | ['path' => '02/unrelated.jpg'], 53 | 54 | // Not a crop cause there is no corresponding source file 55 | ['path' => '03/ignore-200x200.jpg'], 56 | ])) 57 | ->getMock(); 58 | } 59 | 60 | public function testAll() 61 | { 62 | $storage = new Storage(); 63 | $storage->setSrcDisk($this->src_disk); 64 | $storage->setCropsDisk($this->crops_disk); 65 | $this->assertEquals([ 66 | '01/me-200x100.jpg', 67 | '01/me-200x200.jpg', 68 | '01/me-200x300.jpg', 69 | '02/another-200x300.jpg', 70 | ], $storage->listAllCrops()); 71 | } 72 | 73 | public function testFiltered() 74 | { 75 | $storage = new Storage(); 76 | $storage->setSrcDisk($this->src_disk); 77 | $storage->setCropsDisk($this->crops_disk); 78 | $this->assertEquals([ 79 | '02/another-200x300.jpg', 80 | ], $storage->listAllCrops('^02/')); 81 | } 82 | 83 | public function tearDown(): void 84 | { 85 | Mockery::close(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tests/TestResizing.php: -------------------------------------------------------------------------------- 1 | 75, 18 | 'interlace' => true, 19 | 'upsize' => false, 20 | ]; 21 | 22 | public function setUp(): void 23 | { 24 | parent::setUp(); 25 | 26 | // Make an image 27 | $gd = imagecreate(500, 400); 28 | ob_start(); 29 | imagejpeg($gd); 30 | $this->src = ob_get_clean(); 31 | } 32 | 33 | public function testPassthru() 34 | { 35 | $image = new Image($this->src, $this->options); 36 | $size = getimagesizefromstring($image->process(null, null)->get()); 37 | $this->assertEquals('500x400', $size[0].'x'.$size[1]); 38 | } 39 | 40 | public function testWidthConstraint() 41 | { 42 | $image = new Image($this->src, $this->options); 43 | $size = getimagesizefromstring($image->process(200, null)->get()); 44 | $this->assertEquals('200x160', $size[0].'x'.$size[1]); 45 | } 46 | 47 | public function testHeightConstraint() 48 | { 49 | $image = new Image($this->src, $this->options); 50 | $size = getimagesizefromstring($image->process(null, 200)->get()); 51 | $this->assertEquals('250x200', $size[0].'x'.$size[1]); 52 | } 53 | 54 | public function testWidthAndHeightConstraint() 55 | { 56 | $image = new Image($this->src, $this->options); 57 | $size = getimagesizefromstring($image->process(200, 100)->get()); 58 | $this->assertEquals('200x100', $size[0].'x'.$size[1]); 59 | } 60 | 61 | public function testTooSmall() 62 | { 63 | $image = new Image($this->src, $this->options); 64 | $size = getimagesizefromstring($image->process(600, 600)->get()); 65 | $this->assertEquals('400x400', $size[0].'x'.$size[1]); 66 | } 67 | 68 | public function testNotWideEnough() 69 | { 70 | $image = new Image($this->src, $this->options); 71 | $size = getimagesizefromstring($image->process(1000, 400)->get()); 72 | $this->assertEquals('500x200', $size[0].'x'.$size[1]); 73 | } 74 | 75 | public function testNotTallEnough() 76 | { 77 | $image = new Image($this->src, $this->options); 78 | $size = getimagesizefromstring($image->process(500, 500)->get()); 79 | $this->assertEquals('400x400', $size[0].'x'.$size[1]); 80 | } 81 | 82 | public function testWidthAndHeightWithUpscale() 83 | { 84 | $image = new Image($this->src, array_merge($this->options, ['upsize' => true])); 85 | $size = getimagesizefromstring($image->process(500, 500)->get()); 86 | $this->assertEquals('500x500', $size[0].'x'.$size[1]); 87 | } 88 | 89 | public function testWidthAndHeightResize() 90 | { 91 | $image = new Image($this->src, $this->options); 92 | $size = getimagesizefromstring($image->process(200, 200, ['resize' => null])->get()); 93 | $this->assertEquals('200x160', $size[0].'x'.$size[1]); 94 | } 95 | 96 | public function testWidthAndHeightPad() 97 | { 98 | $image = new Image($this->src, $this->options); 99 | $imageString = $image->process(200, 200, ['pad' => [100, 100, 100]])->get(); 100 | $size = getimagesizefromstring($imageString); 101 | $firstPixelColor = ImageManager::gd()->read($imageString)->pickColor(1, 1)->toHex(); 102 | 103 | $this->assertEquals('646464', $firstPixelColor); 104 | $this->assertEquals('200x200', $size[0].'x'.$size[1]); 105 | } 106 | 107 | public function testWidthAndHeightAndPadWithoutColor() 108 | { 109 | $image = new Image($this->src, $this->options); 110 | $imageString = $image->process(200, 200, ['pad'])->get(); 111 | $size = getimagesizefromstring($imageString); 112 | $firstPixelColor = ImageManager::gd()->read($imageString)->pickColor(1, 1)->toHex(); 113 | 114 | $this->assertEquals('ffffff', $firstPixelColor); 115 | $this->assertEquals('200x200', $size[0].'x'.$size[1]); 116 | } 117 | 118 | public function testWidthAndHeightTrim() 119 | { 120 | $image = new Image($this->src, $this->options); 121 | $size = getimagesizefromstring($image->process(200, 200, ['trim_perc' => [0.25, 0.25, 0.75, 0.75]])->get()); 122 | $this->assertEquals('200x200', $size[0].'x'.$size[1]); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /tests/TestTooManyCrops.php: -------------------------------------------------------------------------------- 1 | dir = Mockery::mock(FilesystemAdapter::class) 25 | ->shouldReceive('listContents') 26 | ->withAnyArgs() 27 | ->andReturn(new DirectoryListing([ 28 | ['path' => 'me.jpg'], 29 | ['path' => 'me-too.jpg'], 30 | ['path' => 'me-200x100.jpg'], 31 | ['path' => 'me-200x200.jpg'], 32 | ['path' => 'me-200x300.jpg'], 33 | ['path' => 'unrelated.jpg'], 34 | ])) 35 | ->getMock(); 36 | } 37 | 38 | public function testListCrops() 39 | { 40 | $storage = new Storage(); 41 | $storage->setCropsDisk($this->dir); 42 | $this->assertEquals([ 43 | 'me-200x100.jpg', 44 | 'me-200x200.jpg', 45 | 'me-200x300.jpg', 46 | ], $storage->listCrops('me.jpg')); 47 | } 48 | 49 | public function testAcceptableNumber() 50 | { 51 | $storage = new Storage(null, ['max_crops' => 4]); 52 | $storage->setCropsDisk($this->dir); 53 | $this->assertFalse($storage->tooManyCrops('me.jpg')); 54 | } 55 | 56 | public function testTooMany() 57 | { 58 | $storage = new Storage(['max_crops' => 3]); 59 | $storage->setCropsDisk($this->dir); 60 | $this->assertTrue($storage->tooManyCrops('me.jpg')); 61 | } 62 | 63 | public function tearDown(): void 64 | { 65 | Mockery::close(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/TestUrlGenerator.php: -------------------------------------------------------------------------------- 1 | assertEquals('/path/file-200x100.png', $url->generate('/path/file.png', 200, 100)); 18 | } 19 | 20 | public function testIgnore() 21 | { 22 | $url = new URL(['ignore' => '\.(?:gif|GIF)$']); 23 | $this->assertEquals('/path/file.gif', $url->generate('/path/file.gif', 200, 100)); 24 | $this->assertEquals('/path/file-200x100.png', $url->generate('/path/file.png', 200, 100)); 25 | } 26 | 27 | public function testNoWidthOrHeight() 28 | { 29 | $url = new URL(); 30 | $this->assertEquals('/path/file.png', $url->generate('/path/file.png')); 31 | } 32 | 33 | public function testNoWidth() 34 | { 35 | $url = new URL(); 36 | $this->assertEquals('/path/file-_x100.png', $url->generate('/path/file.png', null, 100)); 37 | } 38 | 39 | public function testNoHeight() 40 | { 41 | $url = new URL(); 42 | $this->assertEquals('/path/file-200x_.png', $url->generate('/path/file.png', 200)); 43 | } 44 | 45 | public function testResize() 46 | { 47 | $url = new URL(); 48 | $this->assertEquals('/path/file-200x100-resize.png', $url->generate('/path/file.png', 200, 100, ['resize'])); 49 | } 50 | 51 | public function testQuadrant() 52 | { 53 | $url = new URL(); 54 | $this->assertEquals('/path/file-200x100-quadrant(T).png', $url->generate('/path/file.png', 200, 100, ['quadrant' => 'T'])); 55 | } 56 | 57 | public function testHostInSrc() 58 | { 59 | $url = new URL(); 60 | $this->assertEquals('/path/file-200x_.png', $url->generate('http://domain.tld/path/file.png', 200)); 61 | $this->assertEquals('/path/file-200x_.png', $url->generate('https://domain.tld/path/file.png', 200)); 62 | } 63 | 64 | public function testSecure() 65 | { 66 | $url = new URL(['signing_key' => 'test']); 67 | $this->assertEquals('/path/file-200x100.png?token=dc0787d205f619a2b2df8554c960072e', $url->generate('/path/file.png', 200, 100)); 68 | 69 | $url = new URL(['signing_key' => 'test']); 70 | $this->assertNotEquals('/path/file-200x100.png?token=dc0787d205f619a2b2df8554c960072e', $url->generate('/path/file.png', 200, 200)); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/TestUrlMatching.php: -------------------------------------------------------------------------------- 1 | url = new URL([ 21 | 'path' => 'uploads/(.*)$', 22 | ]); 23 | } 24 | 25 | /** 26 | * This mimics the Illuminate\Routing\Matching\UriValidator compiled regex 27 | * https://regex101.com/r/xS3nQ2/1. 28 | * 29 | * @param mixed $path 30 | */ 31 | public function match($path) 32 | { 33 | // The compiled regex is wrapped like this 34 | $pattern = '#^\/(?P'.$this->url->routePattern().')$#s'; 35 | 36 | // UriValidator prepends a slash 37 | return preg_match($pattern, '/'.$path) > 0; 38 | } 39 | 40 | public function testNoParams() 41 | { 42 | $this->assertFalse($this->match('uploads/1/2/file.jpg')); 43 | } 44 | 45 | public function testOursideDir() 46 | { 47 | $this->assertFalse($this->match('assets/1/2/file.jpg')); 48 | $this->assertFalse($this->match('apple-touch-icon-152x152-precomposed.png')); 49 | } 50 | 51 | public function testWidth() 52 | { 53 | $this->assertTrue($this->match('uploads/1/2/file-200x_.jpg')); 54 | } 55 | 56 | public function testHeight() 57 | { 58 | $this->assertTrue($this->match('uploads/1/2/file-_x100.jpg')); 59 | } 60 | 61 | public function testWidthAndHeight() 62 | { 63 | $this->assertTrue($this->match('uploads/1/2/file-200x100.jpg')); 64 | } 65 | 66 | public function testWidthAndHeightAndOptions() 67 | { 68 | $this->assertTrue($this->match('uploads/1/2/file-200x100-quadrant(T).jpg')); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tests/TestUrlParsing.php: -------------------------------------------------------------------------------- 1 | url = new URL([ 27 | 'path' => 'uploads/(.*)$', 28 | 'filters' => [ 29 | 'gray' => BlackWhite::class, 30 | 'darkgray' => Darkgray::class, 31 | 'blur' => Blur::class, 32 | 'negative' => Negative::class, 33 | 'orange' => OrangeWarhol::class, 34 | 'turquoise' => TurquoiseWarhol::class, 35 | ], 36 | ]); 37 | } 38 | 39 | public function testNoParams() 40 | { 41 | $this->assertFalse($this->url->parse('uploads/1/2/file.jpg')); 42 | } 43 | 44 | public function testWidth() 45 | { 46 | $this->assertEquals([ 47 | '1/2/file.jpg', 200, null, [], 48 | ], $this->url->parse('uploads/1/2/file-200x_.jpg')); 49 | } 50 | 51 | public function testHeight() 52 | { 53 | $this->assertEquals([ 54 | '1/2/file.jpg', null, 100, [], 55 | ], $this->url->parse('uploads/1/2/file-_x100.jpg')); 56 | } 57 | 58 | public function testWidthAndHeight() 59 | { 60 | $this->assertEquals([ 61 | '1/2/file.jpg', 200, 100, [], 62 | ], $this->url->parse('uploads/1/2/file-200x100.jpg')); 63 | } 64 | 65 | public function testWidthAndHeightAndOptions() 66 | { 67 | $this->assertEquals([ 68 | '1/2/file.jpg', 200, 100, ['resize' => null], 69 | ], $this->url->parse('uploads/1/2/file-200x100-resize.jpg')); 70 | } 71 | 72 | public function testWidthAndHeightAndOptionsWithValue() 73 | { 74 | $this->assertEquals([ 75 | '1/2/file.jpg', 200, 100, ['quadrant' => ['T']], 76 | ], $this->url->parse('uploads/1/2/file-200x100-quadrant(T).jpg')); 77 | } 78 | 79 | public function testWidthAndHeightAndOptionsWithValueList() 80 | { 81 | $this->assertEquals([ 82 | '1/2/file.jpg', 200, 100, ['trim_perc' => [0.25, 0.25, 0.75, 0.75]], 83 | ], $this->url->parse('uploads/1/2/file-200x100-trim_perc(0.25,0.25,0.75,0.75).jpg')); 84 | } 85 | 86 | public function testFilters() 87 | { 88 | $this->assertEquals([ 89 | '1/2/file.jpg', 200, 100, ['filters' => [ 90 | new Blur(), 91 | new Negative(), 92 | ]], 93 | ], $this->url->parse('uploads/1/2/file-200x100-filters(blur,negative).jpg')); 94 | } 95 | 96 | public function testCropsInSubDirectory() 97 | { 98 | $url = new URL([ 99 | 'path' => 'images/(?:crops/)?(.*)$', 100 | ]); 101 | $this->assertEquals([ 102 | 'file.jpg', 200, 100, [], 103 | ], $url->parse('images/crops/file-200x100.jpg')); 104 | } 105 | } 106 | --------------------------------------------------------------------------------