├── .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 | [](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 |
--------------------------------------------------------------------------------