diff --git a/README.md b/README.md index 1d38a1f..f46907c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A tightly-focused library for performing a very limited set of simple image tran ## Roadmap -This library is under active development, and until a 1.0 release is made you should expect it to potentially change its API and functionality. Possibly **drastically**. That said, I *do* have use of this thing for work, so I'm probably going to be working pretty dang hard on it, and hope to have a stable release by about November of 2020. +This library is under active development, and until a 1.0 release is made you should expect it to potentially be broken, and unexpectedly and dramatically change its API and functionality. That said, I *do* have use of this thing for work, so I'm probably going to be working pretty dang hard on it, and hope to have a stable release by about November of 2020. ### Drivers @@ -19,22 +19,40 @@ A 1.0 release will not be made until the following drivers are available and sol A 1.0 release will be made available once the following transforms are available and solidly tested across all drivers. These are basically what I see as the bare minimum for such a library to be useful. -* rotate (in 90 degree increments) -* mirror-h -* mirror-v -* max-width -* max-height -* fit (basically an alias for max-width and max-height) -* cover (scale to cover a box, then crop excess) -* crop (crop toward the center to a given size) -* cover-crop (convenience transform, combining cover and crop, useful for thumbnails) +* orientation + * rotate (in 90 degree increments) + * mirror-h + * mirror-v +* size + * fit (basically an alias for max-width and max-height) + * cover (scale to cover a box, then crop excess) + * crop (crop toward the center to a given size) + * cover-crop (convenience transform, combining cover and crop, useful for thumbnails) -The following transforms are also on my mind as possibilities, but may or may not make it into 1.0: +More complex, and also lesser used effects/stages that may or may not make it into 1.0 -* grayscale -* colorize (needs to function consistently though) -* overlay (i.e. for watermarking) -* blur -* hue -* saturation -* brightness +* color effects + * grayscale + * colorize +* content effects + * overlay + * blur + * hue + * saturation + * brightness + +#### Order of operations + +In the name of simplicity and ease of use, the effective order of operations will always be as reflected above: + +1. Orientation +2. Resizing and cropping +3. Color effects +4. Content-changing effects + +This is only the effective order of operations, because to improve performance the *actual* order of operations, if applicable, will be: + +1. Resizing and cropping +2. Orientation +3. Color effects +4. Content-changing effects diff --git a/composer.json b/composer.json index e244830..58ccc06 100644 --- a/composer.json +++ b/composer.json @@ -21,5 +21,14 @@ "require": { "php": ">=7.1", "ext-gd": "*" + }, + "require-dev": { + "atoum/atoum": "^3.4", + "atoum/stubs": "^2.6" + }, + "autoload-dev": { + "psr-4": { + "ByJoby\\ImageTransform\\tests\\": "tests/" + } } } diff --git a/composer.lock b/composer.lock index 1261ea0..f02ad11 100644 --- a/composer.lock +++ b/composer.lock @@ -4,17 +4,148 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6eb3038135c5de665d7b86ae7ecb3427", + "content-hash": "5972ee7f683ce7e0b61ef2140ae044e9", "packages": [], - "packages-dev": [], + "packages-dev": [ + { + "name": "atoum/atoum", + "version": "3.4.2", + "source": { + "type": "git", + "url": "https://github.com/atoum/atoum.git", + "reference": "e90606b89e62c5c18c5d02596078edf55f35b3c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/atoum/atoum/zipball/e90606b89e62c5c18c5d02596078edf55f35b3c3", + "reference": "e90606b89e62c5c18c5d02596078edf55f35b3c3", + "shasum": "" + }, + "require": { + "ext-hash": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^5.6.0 || ^7.0.0 <7.5.0" + }, + "replace": { + "mageekguy/atoum": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2" + }, + "suggest": { + "atoum/stubs": "Provides IDE support (like autocompletion) for atoum", + "ext-mbstring": "Provides support for UTF-8 strings", + "ext-xdebug": "Provides code coverage report (>= 2.3)" + }, + "bin": [ + "bin/atoum" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "classmap": [ + "classes/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Frédéric Hardy", + "email": "frederic.hardy@atoum.org", + "homepage": "http://blog.mageekbox.net" + }, + { + "name": "François Dussert", + "email": "francois.dussert@atoum.org" + }, + { + "name": "Gérald Croes", + "email": "gerald.croes@atoum.org" + }, + { + "name": "Julien Bianchi", + "email": "julien.bianchi@atoum.org" + }, + { + "name": "Ludovic Fleury", + "email": "ludovic.fleury@atoum.org" + } + ], + "description": "Simple modern and intuitive unit testing framework for PHP 5.3+", + "homepage": "http://www.atoum.org", + "keywords": [ + "TDD", + "atoum", + "test", + "unit testing" + ], + "time": "2020-03-04T10:29:09+00:00" + }, + { + "name": "atoum/stubs", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/atoum/stubs.git", + "reference": "df8b73b0358de7283ecba91d8f4a9683f583993d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/atoum/stubs/zipball/df8b73b0358de7283ecba91d8f4a9683f583993d", + "reference": "df8b73b0358de7283ecba91d8f4a9683f583993d", + "shasum": "" + }, + "suggest": { + "atoum/atoum": "Include atoum in your projet dependencies" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Julien Bianchi", + "email": "julien.bianchi@atoum.org" + }, + { + "name": "Ludovic Fleury", + "email": "ludovic.fleury@atoum.org" + } + ], + "description": "Stubs for atoum, the simple modern and intuitive unit testing framework for PHP 5.3+", + "homepage": "http://www.atoum.org", + "keywords": [ + "TDD", + "atoum", + "test", + "unit testing" + ], + "time": "2018-01-29T22:41:37+00:00" + } + ], "aliases": [], "minimum-stability": "stable", "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { + "php": ">=7.1", "ext-gd": "*" }, - "platform-dev": [], - "plugin-api-version": "1.1.0" + "platform-dev": [] } diff --git a/debug.log b/debug.log new file mode 100644 index 0000000..2226f75 --- /dev/null +++ b/debug.log @@ -0,0 +1,2 @@ +[1001/084735.845:ERROR:registration_protocol_win.cc(103)] CreateFile: The system cannot find the file specified. (0x2) +[1001/084736.163:ERROR:registration_protocol_win.cc(103)] CreateFile: The system cannot find the file specified. (0x2) diff --git a/examples/example-portrait.jpg b/examples/example-portrait.jpg new file mode 100644 index 0000000..1c6db2f Binary files /dev/null and b/examples/example-portrait.jpg differ diff --git a/examples/usage.php b/examples/usage.php new file mode 100644 index 0000000..2a9a839 --- /dev/null +++ b/examples/usage.php @@ -0,0 +1,30 @@ +image( + 'example-portrait.jpg', + $sizer +); +// $image->rotate(); + +var_dump( + 'crop: width: '.$image->sizer()->cropToWidth(), + 'crop: height: '.$image->sizer()->cropToHeight(), + 'resize: width: '.$image->sizer()->resizeToWidth(), + 'resize: height: '.$image->sizer()->resizeToHeight(), + 'final: width: '.$image->width(), + 'final: height: '.$image->height(), + $image +); diff --git a/src/DriverInterface.php b/src/DriverInterface.php index 0b0e91f..6317ad7 100644 --- a/src/DriverInterface.php +++ b/src/DriverInterface.php @@ -2,9 +2,9 @@ /* image-transform | https://github.com/jobyone/image-transform | MIT License */ namespace ByJoby\ImageTransform; +use ByJoby\ImageTransform\Sizers\AbstractSizer; + interface DriverInterface { - public function source(string $source); - public function originalWidth(): int; - public function originalHeight(): int; + public function image(string $src, AbstractSizer $sizer): Image; } diff --git a/src/Drivers/AbstractCLIDriver.php b/src/Drivers/AbstractCLIDriver.php new file mode 100644 index 0000000..a493134 --- /dev/null +++ b/src/Drivers/AbstractCLIDriver.php @@ -0,0 +1,21 @@ +executablePath.$name; + } +} diff --git a/src/Drivers/AbstractDriver.php b/src/Drivers/AbstractDriver.php index 9750c0d..c6640a9 100644 --- a/src/Drivers/AbstractDriver.php +++ b/src/Drivers/AbstractDriver.php @@ -3,23 +3,64 @@ namespace ByJoby\ImageTransform\Drivers; use ByJoby\ImageTransform\DriverInterface; +use ByJoby\ImageTransform\Image; +use ByJoby\ImageTransform\Sizers\AbstractSizer; abstract class AbstractDriver implements DriverInterface { - protected $source; + protected $tmpDir = null; + protected $chmod = 0775; - public function source(string $source) + public function __construct() { - // set source - $this->source = $source; - // validate file - if (!is_file($this->source)) { - throw new \Exception("Image file doesn't exist: " . htmlentities($this->source)); + $this->__clone(); + } + + public function __clone() + { + $this->setTempDir(sys_get_temp_dir() . '/byjoby_image-transform/' . uniqid("",true)); + } + + public function image(string $src, AbstractSizer $sizer): Image + { + return new Image($src, $this, $sizer); + } + + public function setTempDir(string $dir) + { + if (!$this->mkdir($dir)) { + throw new \Exception("Temp directory " . htmlentities($dir) . " doesn't exist or isn't writeable, and couldn't be created."); } - if (!exif_imagetype($this->source)) { - throw new \Exception("Invalid image file: " . htmlentities($this->source)); + $this->tmpDir = $dir; + } + + protected function mkdir(string $dir) + { + // return true if dir exists and is writeable + if (is_dir($dir) && is_writeable($dir)) { + return true; + } + // recursively ensure parent directory exists + $parent = dirname($dir); + $this->mkdir($parent); + // try to create this directory if it doesn't exist + if (is_dir($parent)) { + // check parent permissions + if (!is_writeable($parent)) { + chmod($parent, $this->chmod); + } + if (!is_writeable($parent)) { + return false; + } + // create this directory + if (!mkdir($dir)) { + return false; + } + chmod($dir, $this->chmod); + return is_writeable($dir); + } else { + // parent doesn't exist, so recursive call failed + return false; } - // get height/width - list($this->originalWidth, $this->originalHeight) = getimagesize($this->source); } } diff --git a/src/Drivers/ImagickCLIDriver.php b/src/Drivers/ImagickCLIDriver.php index dee889a..8175f95 100644 --- a/src/Drivers/ImagickCLIDriver.php +++ b/src/Drivers/ImagickCLIDriver.php @@ -9,5 +9,5 @@ namespace ByJoby\ImageTransform\Drivers; * require that you have CLI Imagick installed, and that your server allows * PHP's exec() function to use it. */ -class ImagickCLIDriver extends AbstractDriver +class ImagickCLIDriver extends AbstractCLIDriver {} diff --git a/src/Image.php b/src/Image.php index b0d2b38..e1596b5 100644 --- a/src/Image.php +++ b/src/Image.php @@ -2,30 +2,104 @@ /* image-transform | https://github.com/jobyone/image-transform | MIT License */ namespace ByJoby\ImageTransform; +use ByJoby\ImageTransform\Sizers\AbstractSizer; + class Image { - protected $src, $driver; - protected $transforms = []; + protected $source, $driver; + protected $originalWidth, $originalHeight; + protected $rotation = 0; + protected $flipH = false; + protected $flipV = false; + protected $sizer = null; - public function __construct(string $src, DriverInterface $driver) + public function __construct(string $source, DriverInterface $driver, AbstractSizer $sizer) { - $this->src = $src; + $this->source($source); + $this->sizer($sizer); $this->driver = clone $driver; - $this->driver->source($src); } - public function transform(TransformInterface $transform) + public function source(string $source) { - $this->transforms[] = $transform; + // set source + $this->source = realpath($source); + if (!$this->source) { + throw new \Exception("Source image not found: " . htmlentities($source)); + } + // validate file + if (!is_file($this->source)) { + throw new \Exception("Image file doesn't exist: " . htmlentities($this->source)); + } + if (!exif_imagetype($this->source)) { + throw new \Exception("Invalid image file: " . htmlentities($this->source)); + } + // get height/width + list($this->originalWidth, $this->originalHeight) = getimagesize($this->source); + } + + public function sizer(AbstractSizer $sizer = null): AbstractSizer + { + if ($sizer) { + $this->sizer = clone $sizer; + $this->sizer->image($this); + } + return $this->sizer; + } + + public function rotate(int $steps = 1) + { + $this->rotation = ($this->rotation + $steps) % 4; + } + + public function rotation(): int + { + return $this->rotation; + } + + public function flipH() + { + $this->flipH = !$this->flipH; + if ($this->flipH && $this->flipV) { + $this->flipH = $this->flipV = false; + } + } + + public function flipV() + { + $this->flipV = !$this->flipV; + if ($this->flipH && $this->flipV) { + $this->flipH = $this->flipV = false; + } + } + + public function width(): int + { + return $this->sizer->width(); + } + + public function height(): int + { + return $this->sizer->height(); + } + + public function ratio(): float + { + return $this->width() / $this->height(); + } + + public function originalRatio(): float + { + return $this->originalWidth() / $this->originalHeight(); } public function originalWidth(): int { - return $this->driver->originalWidth(); + return $this->originalWidth; } public function originalHeight(): int { - return $this->driver->originalHeight(); + return $this->originalHeight; } } diff --git a/src/Sizers/AbstractSizer.php b/src/Sizers/AbstractSizer.php new file mode 100644 index 0000000..08f8834 --- /dev/null +++ b/src/Sizers/AbstractSizer.php @@ -0,0 +1,53 @@ +width(); + } + + public function resizeToHeight(): ?int + { + return $this->height(); + } + + public function originalWidth(): int + { + if ($this->image->rotation() % 2) { + return $this->image->originalHeight(); + }else { + return $this->image->originalWidth(); + } + } + + public function originalHeight(): int + { + if ($this->image->rotation() % 2) { + return $this->image->originalWidth(); + }else { + return $this->image->originalHeight(); + } + } + + public function originalRatio(): float + { + return $this->originalWidth()/$this->originalHeight(); + } + + public function image(Image $image) + { + $this->image = $image; + } +} diff --git a/src/Sizers/Cover.php b/src/Sizers/Cover.php new file mode 100644 index 0000000..8eaa0ed --- /dev/null +++ b/src/Sizers/Cover.php @@ -0,0 +1,54 @@ +width = $width; + $this->height = $height; + } + + protected function targetRatio(): float + { + return $this->width / $this->height; + } + + protected function calculateSize(): array + { + if ($this->targetRatio() < $this->originalRatio()) { + $height = $this->height; + $width = round($height * $this->originalRatio()); + } else { + $width = $this->width; + $height = round($width / $this->originalRatio()); + } + return [ + 'height' => $height, + 'width' => $width, + ]; + } + + public function width(): int + { + return $this->calculateSize()['width']; + } + + public function height(): int + { + return $this->calculateSize()['height']; + } + + public function cropToWidth(): ?int + { + return $this->width; + } + + public function cropToHeight(): ?int + { + return $this->height; + } +} diff --git a/src/Sizers/Crop.php b/src/Sizers/Crop.php new file mode 100644 index 0000000..eaf38c6 --- /dev/null +++ b/src/Sizers/Crop.php @@ -0,0 +1,44 @@ +width = $width; + $this->height = $height; + } + + public function width(): int + { + return $this->width; + } + + public function height(): int + { + return $this->height; + } + + public function resizeToHeight(): ?int + { + return null; + } + + public function resizeToWidth(): ?int + { + return null; + } + + public function cropToWidth(): ?int + { + return $this->width; + } + + public function cropToHeight(): ?int + { + return $this->height; + } +} diff --git a/src/Sizers/Fit.php b/src/Sizers/Fit.php new file mode 100644 index 0000000..8c46839 --- /dev/null +++ b/src/Sizers/Fit.php @@ -0,0 +1,54 @@ +width = $width; + $this->height = $height; + } + + public function cropToWidth(): ?int + { + return null; + } + + public function cropToHeight(): ?int + { + return null; + } + + protected function targetRatio(): float + { + return $this->width / $this->height; + } + + protected function calculateSize(): array + { + if ($this->targetRatio() > $this->originalRatio()) { + $height = $this->height; + $width = round($height * $this->originalRatio()); + } else { + $width = $this->width; + $height = round($width / $this->originalRatio()); + } + return [ + 'height' => $height, + 'width' => $width, + ]; + } + + public function width(): int + { + return $this->calculateSize()['width']; + } + + public function height(): int + { + return $this->calculateSize()['height']; + } +} diff --git a/src/Sizers/Original.php b/src/Sizers/Original.php new file mode 100644 index 0000000..38e2e4e --- /dev/null +++ b/src/Sizers/Original.php @@ -0,0 +1,36 @@ +originalWidth(); + } + + public function height(): int + { + return $this->originalHeight(); + } +} diff --git a/src/TransformInterface.php b/src/TransformInterface.php deleted file mode 100644 index 7eeaf3d..0000000 --- a/src/TransformInterface.php +++ /dev/null @@ -1,52 +0,0 @@ -size = $size; - } - - public function willTransform(Image $image): bool - { - if ($image->originalWidth() <= $this->size) { - return false; - } else { - return true; - } - } -} diff --git a/tests/units/Sizers/Cover.php b/tests/units/Sizers/Cover.php new file mode 100644 index 0000000..ba9f46e --- /dev/null +++ b/tests/units/Sizers/Cover.php @@ -0,0 +1,10 @@ +