From 51d44e93ba875ccb208a2eeafe0ab2497ebc5804 Mon Sep 17 00:00:00 2001 From: Mickael GOETZ Date: Wed, 1 Aug 2018 23:09:58 +0200 Subject: [PATCH] add Mesavolt\Service\ImagingService --- .coveralls.yml | 6 + .gitignore | 8 ++ .travis.yml | 10 ++ LICENSE.md | 21 ++++ README.md | 86 +++++++++++++ Tests/.gitkeep | 0 Tests/Resources/images/gif.gif | Bin 0 -> 43 bytes Tests/Resources/images/jpg.jpg | Bin 0 -> 285 bytes Tests/Resources/images/png.png | Bin 0 -> 173 bytes Tests/Resources/images/tall.png | Bin 0 -> 429 bytes Tests/Resources/images/tiff.tiff | Bin 0 -> 303 bytes Tests/Resources/images/wide.png | Bin 0 -> 428 bytes Tests/Service/ImageServiceTest.php | 134 +++++++++++++++++++++ composer.json | 39 ++++++ phpunit.xml | 28 +++++ src/DependencyInjection/Configuration.php | 32 +++++ src/DependencyInjection/ImageExtension.php | 31 +++++ src/ImagingBundle.php | 11 ++ src/Resources/config/services.yaml | 8 ++ src/Service/ImagingService.php | 119 ++++++++++++++++++ 20 files changed, 533 insertions(+) create mode 100644 .coveralls.yml create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 Tests/.gitkeep create mode 100644 Tests/Resources/images/gif.gif create mode 100644 Tests/Resources/images/jpg.jpg create mode 100644 Tests/Resources/images/png.png create mode 100644 Tests/Resources/images/tall.png create mode 100644 Tests/Resources/images/tiff.tiff create mode 100644 Tests/Resources/images/wide.png create mode 100644 Tests/Service/ImageServiceTest.php create mode 100644 composer.json create mode 100644 phpunit.xml create mode 100644 src/DependencyInjection/Configuration.php create mode 100644 src/DependencyInjection/ImageExtension.php create mode 100644 src/ImagingBundle.php create mode 100644 src/Resources/config/services.yaml create mode 100644 src/Service/ImagingService.php diff --git a/.coveralls.yml b/.coveralls.yml new file mode 100644 index 0000000..c207f54 --- /dev/null +++ b/.coveralls.yml @@ -0,0 +1,6 @@ +# thanks to +# https://www.tomasvotruba.cz/blog/2017/06/12/how-to-require-minimal-code-coverage-for-github-pull-requests-with-coveralls/ + +service_name: travis-ci +coverage_clover: coverage.xml # file generated by phpunit +json_path: coverage.json # file generated by php-coveralls diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e8ac342 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +vendor/ +composer.lock +.DS_Store +.idea +/tests/cache-dir + +/coverage.xml +/coverage diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..f93fa28 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,10 @@ +language: php +php: + - '7.1' + +install: composer install +script: vendor/bin/phpunit --coverage-clover coverage.xml + +after_script: + # upload coverage.xml file to Coveralls to analyze it + - travis_retry vendor/bin/php-coveralls --verbose diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..de3338b --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 MesaVolt, SARL + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4fc4018 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# Mesavolt/ImagingBundle + + +## Installation + +### Applications that use Symfony Flex + +Open a command console, enter your project directory and execute: + +```console +composer require mesavolt/imaging-bundle +``` + +That's it. Flex automagically enables the bundle for you. Go to the **Configuration** +section of this README to see how you can customize the bundle's behavior. + +### Applications that don't use Symfony Flex + +#### Step 1: Download the Bundle + +Open a command console, enter your project directory and execute the +following command to download the latest stable version of this bundle: + +```console +composer require mesavolt/imaging-bundle +``` + +This command requires you to have Composer installed globally, as explained +in the [installation chapter](https://getcomposer.org/doc/00-intro.md) +of the Composer documentation. + +#### Step 2: Enable the Bundle + +Then, enable the bundle by adding it to the list of registered bundles +in the `app/AppKernel.php` file of your project: + +```php +getParameter('kernel.project_dir').$relative; + $imagingService->shrink('/tmp/image.jpg', $path); + + return $this->render('home/index.html.twig', [ + 'shrunk' => $relative + ]); + } +} + +``` diff --git a/Tests/.gitkeep b/Tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Tests/Resources/images/gif.gif b/Tests/Resources/images/gif.gif new file mode 100644 index 0000000000000000000000000000000000000000..7448e444c0009f3e72c125d8a0e4323db2eadcd6 GIT binary patch literal 43 rcmZ?wbhEHbWMW`qXkcUjg8%>jEB<5wG8q|kKzxu40~1qAAcHjk!Z8Pr literal 0 HcmV?d00001 diff --git a/Tests/Resources/images/jpg.jpg b/Tests/Resources/images/jpg.jpg new file mode 100644 index 0000000000000000000000000000000000000000..421d5ecba983390ee49b27305c95e53a2d81aedb GIT binary patch literal 285 zcmex=kgYHu;{Ff#&eVHRYtXPEzk;r~qlm;xEB literal 0 HcmV?d00001 diff --git a/Tests/Resources/images/png.png b/Tests/Resources/images/png.png new file mode 100644 index 0000000000000000000000000000000000000000..7c566a8377c7966e9f31c7a7cc3c8df1325e98a0 GIT binary patch literal 173 zcmeAS@N?(olHy`uVBq!ia0vp^Od!m`#=yYHy3uwMki(Yb?e4l*!&%@FSbCYGe8D3oWGWGJ|M`UZqI@`(ct=Js@P45_&F_8=oKP|KkO-~Ds>%ohNe cBM$82z0u3S$a!J)YmnDHUHx3vIVCg!08Q5}Q~&?~ literal 0 HcmV?d00001 diff --git a/Tests/Resources/images/tiff.tiff b/Tests/Resources/images/tiff.tiff new file mode 100644 index 0000000000000000000000000000000000000000..af83de16b92832a9073206703df5215380d6a733 GIT binary patch literal 303 zcmebD)M5}}Km~#f{}@;p7-52p%wRSXkk5$3W&*OAfnwi)m>DV#(!++NhL@3vK>;ZC z2Z;HAY+)cR15_l)2r-`n$QMG=D+*zi7CbUC7EeydLV+qH?=r1 TEI+42p{O(`wMx-YQIP=v>}?tr literal 0 HcmV?d00001 diff --git a/Tests/Resources/images/wide.png b/Tests/Resources/images/wide.png new file mode 100644 index 0000000000000000000000000000000000000000..26565b6f3db6aaeccd64cdd51aa4e5282b90c9a8 GIT binary patch literal 428 zcmeAS@N?(olHy`uVBq!ia0y~yV86h?!2E)d87Q)6!uOXziaEe1#1%*{ud=-bq!^RD z-CYEZzkxv|GFqfx`V@SoVw+9({fm#kN`0k&}&n^OF djxcawJL8q542-X~h}(mF?&<31vd$@?2>?yFFVFx0 literal 0 HcmV?d00001 diff --git a/Tests/Service/ImageServiceTest.php b/Tests/Service/ImageServiceTest.php new file mode 100644 index 0000000..148a2f0 --- /dev/null +++ b/Tests/Service/ImageServiceTest.php @@ -0,0 +1,134 @@ +service = new ImagingService(); + $this->tempFiles = []; + + $this->tall = __DIR__.'/../Resources/images/tall.png'; + $this->wide = __DIR__.'/../Resources/images/wide.png'; + } + + protected function tearDown()/* The :void return type declaration that should be here would cause a BC issue */ + { + $this->service = null; + + foreach($this->tempFiles as $file) { + @unlink($file); + } + } + + protected function createTempFile(): string + { + $file = tempnam(sys_get_temp_dir(), null); + $this->tempFiles[] = $file; + + return $file; + } + + public function testShrink() + { + // handles JPG, PNG and GIF + foreach(['jpg', 'gif', 'png'] as $ext) { + $this->assertTrue($this->service->shrink(__DIR__."/../Resources/images/$ext.$ext", $this->createTempFile(), 1)); + } + + // doesn't handle tiff + $this->expectException(\InvalidArgumentException::class); + $this->assertTrue($this->service->shrink(__DIR__."/../Resources/images/tiff.tiff", $this->createTempFile(), 1)); + + // shrinks horizontal image to match width + $tallShrunkW = $this->createTempFile(); + $this->service->shrink($this->tall, $tallShrunkW, 100, null); + [$w, $h] = getimagesize($tallShrunkW); + $this->assertEquals(100, $w, 'Width should be 100px'); + $this->assertEquals(200, $h, 'Height should be 200px'); + + // shrinks horizontal image to match height + $tallShrunkH = $this->createTempFile(); + $this->service->shrink($this->tall, $tallShrunkH, null, 1000); + [$w, $h] = getimagesize($tallShrunkH); + $this->assertEquals(500, $w, 'Width should be 500px'); + $this->assertEquals(1000, $h, 'Height should be 1000px'); + + // shrinks vertical image to match width + $wideShrunkW = $this->createTempFile(); + $this->service->shrink($this->wide, $wideShrunkW, 1000, null); + [$w, $h] = getimagesize($wideShrunkW); + $this->assertEquals(1000, $w, 'Width should be 1000px'); + $this->assertEquals(500, $h, 'Height should be 500px'); + + // shrinks vertical image to match height + $wideShrunkH = $this->createTempFile(); + $this->service->shrink($this->wide, $wideShrunkH, null, 100); + [$w, $h] = getimagesize($wideShrunkH); + $this->assertEquals(200, $w, 'Width should be 200px'); + $this->assertEquals(100, $h, 'Height should be 100px'); + + // asking for 3000*3000 won't resize the image because it's already smaller + $wideUntouched = $this->createTempFile(); + $this->service->shrink($this->wide, $wideUntouched, 3000, 3000); + [$w, $h] = getimagesize($wideUntouched); + $this->assertEquals(2000, $w, 'Width should be 2000px'); + $this->assertEquals(1000, $h, 'Height should be 1000px'); + + // asking for 300*100 will result in 200*100 to keep tall proportions + $tallShrunk300x100 = $this->createTempFile(); + $this->service->shrink($this->tall, $tallShrunk300x100, 300, 100); + [$w, $h] = getimagesize($tallShrunk300x100); + $this->assertEquals(50, $w, 'Width should be 50px'); + $this->assertEquals(100, $h, 'Height should be 100px'); + + // asking for 100*300 will result in 100*50 to keep tall proportions + $tallShrunk100x300 = $this->createTempFile(); + $this->service->shrink($this->tall, $tallShrunk100x300, 100, 300); + [$w, $h] = getimagesize($tallShrunk100x300); + $this->assertEquals(100, $w, 'Width should be 100px'); + $this->assertEquals(200, $h, 'Height should be 200px'); + + // asking for 300*100 will result in 200*100 to keep wide proportions + $wideShrunk300x100 = $this->createTempFile(); + $this->service->shrink($this->wide, $wideShrunk300x100, 300, 100); + [$w, $h] = getimagesize($wideShrunk300x100); + $this->assertEquals(200, $w, 'Width should be 200px'); + $this->assertEquals(100, $h, 'Height should be 100px'); + + // asking for 100*300 will result in 100*50 to keep wide proportions + $wideShrunk100x300 = $this->createTempFile(); + $this->service->shrink($this->wide, $wideShrunk100x300, 100, 300); + [$w, $h] = getimagesize($wideShrunk100x300); + $this->assertEquals(100, $w, 'Width should be 100px'); + $this->assertEquals(50, $h, 'Height should be 50px'); + + // __FILE__ is not an image, shrinking fails and returns false + $this->assertFalse($this->service->shrink(__FILE__, '/dev/null')); + } + + public function testGenerateWebp() + { + $webp = $this->createTempFile(); + $this->service->generateWebp($this->wide, $webp); + [$w, $h] = getimagesize($webp); + // dimensions shouldn't have changed + $this->assertEquals(2000, $w, 'Width should be 2000px'); + $this->assertEquals(1000, $h, 'Height should be 1000px'); + // generated image must be webp + $this->assertEquals(IMAGETYPE_WEBP, exif_imagetype($webp)); + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..7007910 --- /dev/null +++ b/composer.json @@ -0,0 +1,39 @@ +{ + "name": "mesavolt/imaging-bundle", + "description": "Symfony bundle to facilitate image manipulation in PHP", + "keywords": ["image", "gd", "webp", "bundle"], + "license": "MIT", + "authors": [ + { + "name": "MesaVolt", + "email": "contact@mesavolt.com" + } + ], + "type": "symfony-bundle", + "config": { + "sort-packages": true + }, + "require": { + "php": ">=7.1", + "ext-gd": "*", + "ext-exif": "*", + "rosell-dk/webp-convert": "^1.1", + "symfony/config": "^3.3||^4.1", + "symfony/dependency-injection": "^3.3||^4.1", + "symfony/http-kernel": "^3.3||^4.1" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^7.2" + }, + "autoload": { + "psr-4": { + "Mesavolt\\ImagingBundle\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Mesavolt\\Tests\\": "Tests/" + } + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..814305c --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,28 @@ + + + + + + + Tests/ + + + + + + ./src/ + + + diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php new file mode 100644 index 0000000..8a4382c --- /dev/null +++ b/src/DependencyInjection/Configuration.php @@ -0,0 +1,32 @@ +root('mesavolt_imaging'); + + // Here you should define the parameters that are allowed to + // configure your bundle. See the documentation linked above for + // more information on that topic. + + return $treeBuilder; + + } +} diff --git a/src/DependencyInjection/ImageExtension.php b/src/DependencyInjection/ImageExtension.php new file mode 100644 index 0000000..a28b3dc --- /dev/null +++ b/src/DependencyInjection/ImageExtension.php @@ -0,0 +1,31 @@ +load('services.yaml'); + + } +} diff --git a/src/ImagingBundle.php b/src/ImagingBundle.php new file mode 100644 index 0000000..9e1899b --- /dev/null +++ b/src/ImagingBundle.php @@ -0,0 +1,11 @@ +resize($source, $width, $height); + + if ($shrunk === null) { + return false; + } + + $success = imagejpeg($shrunk, $destination, $quality); + + imagedestroy($shrunk); // don't hog the RAM, we're not Slack or Chrome. + + return $success; + } + + /** + * Generates a webp version of the specified image, and outputs it in the specified destination. + */ + public function generateWebp(string $source, string $destination, int $quality = self::DEFAULT_WEBP_QUALITY): bool + { + try { + return WebPConvert::convert($source, $destination, [ + 'quality' => $quality, + ]); + } catch (InvalidFileExtensionException $ex) { + // Throws when trying to convert gifs + return false; + } + } + + protected function resize(string $source, ?int $maxWidth = null, ?int $maxHeight = null) + { + [$originalWidth, $originalHeight] = getimagesize($source); + + if ($originalWidth === null || $originalHeight === null) { + return null; + } + + $height = $originalHeight; + $width = $originalWidth; + + if ($maxWidth !== null) { + if ($width > $maxWidth) { + // both images need to have the same ratio: i.e. we want newHeight/newWidth = height/width + // but newWidth must be maxWidth at most + $height = round($maxWidth * $height / $width, 0, PHP_ROUND_HALF_UP); + $width = $maxWidth; + } + } + + if ($maxHeight !== null) { + if ($height > $maxHeight) { + $width = round($maxHeight * $width / $height, 0, PHP_ROUND_HALF_UP); + $height = $maxHeight; + } + } + + $src = $this->createGdImage($source); + + // resize only if necessary + if ($width === $originalWidth && $height === $originalHeight) { + $dst = $src; + } else { + $dst = imagecreatetruecolor($width, $height); + $resized = imagecopyresampled($dst, $src, 0, 0, 0, 0, $width, $height, $originalWidth, $originalHeight); + + imagedestroy($src); // don't hog the RAM, we're not Slack or Chrome. + + if (!$resized) { + $filename = basename($source); + throw new \RuntimeException("Couldn't resize $filename to [w:$maxWidth ; h:$maxHeight]"); + } + } + + return $dst; + } + + protected function createGdImage(string $source) + { + switch ($imageType = exif_imagetype($source)) { + case IMAGETYPE_GIF: + return imagecreatefromgif($source); + break; + case IMAGETYPE_JPEG: + return imagecreatefromjpeg($source); + break; + case IMAGETYPE_PNG: + return imagecreatefrompng($source); + break; + case IMAGETYPE_BMP: + return imagecreatefrombmp($source); + break; + case IMAGETYPE_WEBP: + return imagecreatefromwebp($source); + break; + default: + // Unsupported image + throw new \InvalidArgumentException(sprintf('Provided image "%s" has an invalid type %s', $source, $imageType)); + } + } +}