From aac4b55077c14cddf4654e985fe69c01ef9a3920 Mon Sep 17 00:00:00 2001 From: J0WI Date: Sun, 1 Aug 2021 20:38:04 +0200 Subject: [PATCH 1/3] Add image backends for GD, Imagick and Gmagick Signed-off-by: J0WI --- lib/private/Image/Common.php | 507 ++++++++++++++++++++++++ lib/private/Image/Gd.php | 667 ++++++++++++++++++++++++++++++++ lib/private/Image/Gmagick.php | 474 +++++++++++++++++++++++ lib/private/Image/Imagick.php | 474 +++++++++++++++++++++++ lib/private/legacy/OC_Image.php | 4 +- lib/public/IImage.php | 2 +- tests/lib/Image/Gd.php | 367 ++++++++++++++++++ tests/lib/Image/Gmagick.php | 375 ++++++++++++++++++ tests/lib/Image/Imagick.php | 375 ++++++++++++++++++ 9 files changed, 3242 insertions(+), 3 deletions(-) create mode 100644 lib/private/Image/Common.php create mode 100644 lib/private/Image/Gd.php create mode 100644 lib/private/Image/Gmagick.php create mode 100644 lib/private/Image/Imagick.php create mode 100644 tests/lib/Image/Gd.php create mode 100644 tests/lib/Image/Gmagick.php create mode 100644 tests/lib/Image/Imagick.php diff --git a/lib/private/Image/Common.php b/lib/private/Image/Common.php new file mode 100644 index 0000000000000..cc9790cf954d9 --- /dev/null +++ b/lib/private/Image/Common.php @@ -0,0 +1,507 @@ + + * @author Bart Visscher + * @author Björn Schießle + * @author Byron Marohn + * @author Christopher Schäpers + * @author Christoph Wurst + * @author Georg Ehrke + * @author J0WI + * @author j-ed + * @author Joas Schilling + * @author Johannes Willnecker + * @author Jörn Friedrich Dreyer + * @author Julius Härtl + * @author Lukas Reschke + * @author Morris Jobke + * @author Olivier Paroz + * @author Robin Appelman + * @author Roeland Jago Douma + * @author Samuel CHEMLA + * @author Thomas Müller + * @author Thomas Tanghus + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ +namespace OC\Image; + +use OCP\IImage; + +/** + * Class for basic image manipulation + */ +abstract class Common implements IImage { + /** @var bool|object|resource */ + protected $resource = false; // tmp resource. + + /** @var int */ + protected $imageType = IMAGETYPE_PNG; // Default to png if file type isn't evident. + + /** @var null|string */ + protected $mimeType = 'image/png'; // Default to png + + /** @var null|string */ + protected $filePath = null; + + /** @var \finfo */ + protected $fileInfo; + + /** @var \OCP\ILogger */ + protected $logger; + + /** @var \OCP\IConfig */ + protected $config; + + /** @var ?array [header => value] */ + protected $exif; + + /** + * @inheritDoc + */ + public function __construct(\OCP\ILogger $logger = null, \OCP\IConfig $config = null) { + $this->logger = $logger; + if ($logger === null) { + $this->logger = \OC::$server->getLogger(); + } + + $this->config = $config; + if ($config === null) { + $this->config = \OC::$server->getConfig(); + } + + if (\OC_Util::fileInfoLoaded()) { + $this->fileInfo = new \finfo(FILEINFO_MIME_TYPE); + } + } + + /** + * @inheritDoc + */ + abstract public function valid(): bool; + + /** + * @inheritDoc + */ + public function mimeType(): ?string { + return $this->valid() ? $this->mimeType : null; + } + + /** + * @inheritDoc + */ + abstract public function width(): int; + + /** + * @inheritDoc + */ + abstract public function height(): int; + + /** + * @inheritDoc + */ + public function widthTopLeft(): int { + $o = $this->getOrientation(); + $this->logger->debug(__METHOD__ . '() Orientation: ' . $o, ['app' => 'core']); + switch ($o) { + case -1: + case 1: + case 2: // Not tested + case 3: + case 4: // Not tested + return $this->width(); + case 5: // Not tested + case 6: + case 7: // Not tested + case 8: + return $this->height(); + } + return $this->width(); + } + + /** + * @inheritDoc + */ + public function heightTopLeft(): int { + $o = $this->getOrientation(); + $this->logger->debug(__METHOD__ . '() Orientation: ' . $o, ['app' => 'core']); + switch ($o) { + case -1: + case 1: + case 2: // Not tested + case 3: + case 4: // Not tested + return $this->height(); + case 5: // Not tested + case 6: + case 7: // Not tested + case 8: + return $this->width(); + } + return $this->height(); + } + + /** + * @inheritDoc + */ + public function show(?string $mimeType = null): bool { + if ($mimeType === null) { + $mimeType = $this->mimeType(); + } + header('Content-Type: ' . $mimeType); + return $this->_output(null, $mimeType); + } + + /** + * @inheritDoc + */ + public function save(?string $filePath = null, ?string $mimeType = null): bool { + if ($mimeType === null) { + $mimeType = $this->mimeType(); + } + if ($filePath === null) { + if ($this->filePath === null) { + $this->logger->error(__METHOD__ . '(): called with no path.', ['app' => 'core']); + return false; + } else { + $filePath = $this->filePath; + } + } + return $this->_output($filePath, $mimeType); + } + + /** + * @inheritDoc + */ + public function __invoke() { + return $this->show(); + } + + /** + * @param resource Returns the image resource in any. + * @throws \InvalidArgumentException in case the supplied resource does not have the type "gd" + */ + abstract public function setResource($resource): void; + + /** + * @inheritDoc + */ + public function resource() { + return $this->resource; + } + + /** + * @inheritDoc + */ + public function dataMimeType(): ?string { + if (!$this->valid()) { + return null; + } + + switch ($this->mimeType) { + case 'image/png': + case 'image/jpeg': + case 'image/gif': + return $this->mimeType; + default: + return 'image/png'; + } + } + + /** + * Get JPEG quality setting. + * + * @return int between 10 and 100, defaults to 90 + */ + protected function getJpegQuality(): int { + $quality = $this->config->getAppValue('preview', 'jpeg_quality', '90'); + assert($quality !== null); // TODO: remove when getAppValue is type safe + return min(100, max(10, (int) $quality)); + } + + /** + * @return string - base64 encoded, which is suitable for embedding in a VCard. + */ + public function __toString(): string { + return base64_encode($this->data()); + } + + public function __destruct() { + $this->destroy(); + } + + /** + * Write/saves the image and handles output file paths. + * + * @param string $filePath + * @param string $mimeType + * @return bool + * @throws \Exception + */ + protected function _output($filePath = null, $mimeType = null): bool { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + if ($filePath) { + if (!file_exists(dirname($filePath))) { + mkdir(dirname($filePath), 0777, true); + } + $isWritable = is_writable(dirname($filePath)); + if (!$isWritable) { + $this->logger->error(__METHOD__ . '(): Directory \'' . dirname($filePath) . '\' is not writable.', ['app' => 'core']); + return false; + } elseif (file_exists($filePath) && !is_writable($filePath)) { + $this->logger->error(__METHOD__ . '(): File \'' . $filePath . '\' is not writable.', ['app' => 'core']); + return false; + } + } + + return $this->_write($filePath = null, $mimeType = null); + } + + /** + * Write/saves the image. + * + * @param string $filePath + * @param string $mimeType + * @return bool + * @throws \Exception + */ + abstract protected function _write($filePath = null, $mimeType = null): bool; + + /** + * @inheritDoc + */ + abstract public function data(): ?string; + + /** + * @inheritDoc + */ + public function getOrientation(): int { + if ($this->exif !== null) { + return $this->exif['Orientation']; + } + + if ($this->imageType !== IMAGETYPE_JPEG) { + $this->logger->debug(__METHOD__ . '(): Image is not a JPEG.', ['app' => 'core']); + return -1; + } + if (!is_callable('exif_read_data')) { + $this->logger->debug(__METHOD__ . '(): Exif module not enabled.', ['app' => 'core']); + return -1; + } + if (!$this->valid()) { + $this->logger->debug(__METHOD__ . '(): No image loaded.', ['app' => 'core']); + return -1; + } + if (is_null($this->filePath) || !is_readable($this->filePath)) { + $this->logger->debug(__METHOD__ . '(): No readable file path set.', ['app' => 'core']); + return -1; + } + $exif = @exif_read_data($this->filePath, 'IFD0'); + if (!$exif) { + return -1; + } + if (!isset($exif['Orientation'])) { + return -1; + } + $this->exif = $exif; + return $exif['Orientation']; + } + + /** + * Reads the EXIF headers from an image data stream + * + * @param $data image data + */ + public function readExif($data): void { + if (!is_callable('exif_read_data')) { + $this->logger->debug(__METHOD__ . '(): Exif module not enabled.', ['app' => 'core']); + return; + } + if (!$this->valid()) { + $this->logger->debug(__METHOD__ . '(): No image loaded.', ['app' => 'core']); + return; + } + + $exif = @exif_read_data('data://image/jpeg;base64,' . base64_encode($data)); + if (!$exif) { + return; + } + if (!isset($exif['Orientation'])) { + return; + } + $this->exif = $exif; + } + + /** + * @inheritDoc + */ + abstract public function fixOrientation(): bool; + + /** + * Loads an image from a local file. + * + * @param bool|string $imagePath The path to a local file. + * @return bool|object|resource An image resource or false on error + */ + abstract public function loadFromFile($imagePath = false); + + /** + * Loads an image from an open file handle. + * It is the responsibility of the caller to position the pointer at the correct place and to close the handle again. + * + * @param resource $handle + * @return bool|object|resource A raw image resource or false on error + */ + abstract public function loadFromFileHandle($handle); + + /** + * Loads an image from a string of data. + * + * @param string $str A string of image data as read from a file. + * @return bool|object|resource A raw image resource or false on error + */ + abstract public function loadFromData(string $str); + + /** + * Loads an image from a base64 encoded string. + * + * @param string $str A string base64 encoded string of image data. + * @return bool|object|resource A raw image resource or false on error + */ + public function loadFromBase64(string $str) { + return $this->loadFromData(base64_decode($str)); + } + + /** + * @inheritDoc + */ + abstract public function resize(int $maxSize): bool; + + /** + * @param $maxSize + * @return resource | bool + */ + abstract public function resizeNew(int $maxSize); + + /** + * @inheritDoc + */ + abstract public function preciseResize(int $width, int $height): bool; + + /** + * @param int $width + * @param int $height + * @return resource | bool + */ + abstract public function preciseResizeNew(int $width, int $height); + + /** + * @inheritDoc + */ + abstract public function centerCrop(int $size = 0): bool; + + /** + * @inheritDoc + */ + abstract public function crop(int $x, int $y, int $w, int $h): bool; + + /** + * Crops the image from point $x$y with dimension $wx$h. + * + * @param int $x Horizontal position + * @param int $y Vertical position + * @param int $w Width + * @param int $h Height + * @return resource | bool + */ + abstract public function cropNew(int $x, int $y, int $w, int $h); + + /** + * @inheritDoc + */ + abstract public function fitIn(int $maxWidth, int $maxHeight): bool; + + /** + * @inheritDoc + */ + public function scaleDownToFit(int $maxWidth, int $maxHeight): bool { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + $widthOrig = $this->width(); + $heightOrig = $this->height(); + + if ($widthOrig > $maxWidth || $heightOrig > $maxHeight) { + return $this->fitIn($maxWidth, $maxHeight); + } + + return false; + } + + /** + * @inheritDoc + */ + public function copy(): IImage { + return clone $this->resource; + } + + /** + * @inheritDoc + */ + abstract public function cropCopy(int $x, int $y, int $w, int $h): IImage; + + /** + * @inheritDoc + */ + abstract public function preciseResizeCopy(int $width, int $height): IImage; + + /** + * @inheritDoc + */ + abstract public function resizeCopy(int $maxSize): IImage; + + /** + * Destroys the current image and resets the object + */ + abstract public function destroy(): void; +} + +if (!function_exists('exif_imagetype')) { + /** + * Workaround if exif_imagetype does not exist + * + * @link https://www.php.net/manual/en/function.exif-imagetype.php#80383 + * @param string $fileName + * @return string|boolean + */ + function exif_imagetype(string $fileName) { + if (($info = getimagesize($fileName)) !== false) { + return $info[2]; + } + return false; + } +} diff --git a/lib/private/Image/Gd.php b/lib/private/Image/Gd.php new file mode 100644 index 0000000000000..faf66cb729854 --- /dev/null +++ b/lib/private/Image/Gd.php @@ -0,0 +1,667 @@ + + * @author Bart Visscher + * @author Björn Schießle + * @author Byron Marohn + * @author Christopher Schäpers + * @author Christoph Wurst + * @author Georg Ehrke + * @author J0WI + * @author j-ed + * @author Joas Schilling + * @author Johannes Willnecker + * @author Jörn Friedrich Dreyer + * @author Julius Härtl + * @author Lukas Reschke + * @author Morris Jobke + * @author Olivier Paroz + * @author Robin Appelman + * @author Roeland Jago Douma + * @author Samuel CHEMLA + * @author Thomas Müller + * @author Thomas Tanghus + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ +namespace OC\Image; + +use OCP\IImage; + +/** + * Class for basic image manipulation using GD + */ +class Gd extends Common { + + /** + * @inheritDoc + */ + public function valid(): bool { // apparently you can't name a method 'empty'... + if (is_resource($this->resource)) { + return true; + } + if (is_object($this->resource) && $this->$resource instanceof \GdImage) { + return true; + } + + return false; + } + + /** + * @inheritDoc + */ + public function width(): int { + if ($this->valid()) { + $width = imagesx($this->resource); + if ($width !== false) { + return $width; + } + } + return -1; + } + + /** + * @inheritDoc + */ + public function height(): int { + if ($this->valid()) { + $height = imagesy($this->resource); + if ($height !== false) { + return $height; + } + } + return -1; + } + + /** + * @inheritDoc + */ + protected function _write($filePath = null, $mimeType = null): bool { + $imageType = $this->imageType; + if ($mimeType !== null) { + switch ($mimeType) { + case 'image/gif': + $imageType = IMAGETYPE_GIF; + break; + case 'image/jpeg': + $imageType = IMAGETYPE_JPEG; + break; + case 'image/png': + $imageType = IMAGETYPE_PNG; + break; + case 'image/x-xbitmap': + $imageType = IMAGETYPE_XBM; + break; + case 'image/bmp': + case 'image/x-ms-bmp': + $imageType = IMAGETYPE_BMP; + break; + default: + throw new \Exception(__METHOD__ . '(): "' . $mimeType . '" is not supported when forcing a specific output format'); + } + } + + switch ($imageType) { + case IMAGETYPE_GIF: + $retVal = imagegif($this->resource, $filePath); + break; + case IMAGETYPE_JPEG: + $retVal = imagejpeg($this->resource, $filePath, $this->getJpegQuality()); + break; + case IMAGETYPE_PNG: + $retVal = imagepng($this->resource, $filePath); + break; + case IMAGETYPE_XBM: + if (function_exists('imagexbm')) { + $retVal = imagexbm($this->resource, $filePath); + } else { + throw new \Exception(__METHOD__ . '(): imagexbm() is not supported.'); + } + + break; + case IMAGETYPE_WBMP: + $retVal = imagewbmp($this->resource, $filePath); + break; + case IMAGETYPE_BMP: + $retVal = imagebmp($this->resource, $filePath); + break; + default: + $retVal = imagepng($this->resource, $filePath); + } + return $retVal; + } + + /** + * @inheritDoc + */ + public function setResource($resource): void { + // For PHP<8 + if (is_resource($resource) && get_resource_type($resource) === 'gd') { + $this->resource = $resource; + return; + } + // PHP 8 has real objects for GD stuff + if (is_object($resource) && $resource instanceof \GdImage) { + $this->resource = $resource; + return; + } + throw new \InvalidArgumentException('Supplied resource is not of type "gd".'); + } + + /** + * @inheritDoc + */ + public function data(): ?string { + if (!$this->valid()) { + return null; + } + ob_start(); + switch ($this->mimeType) { + case "image/png": + $res = imagepng($this->resource); + break; + case "image/jpeg": + $quality = $this->getJpegQuality(); + if ($quality !== null) { + $res = imagejpeg($this->resource, null, $quality); + } else { + $res = imagejpeg($this->resource); + } + break; + case "image/gif": + $res = imagegif($this->resource); + break; + default: + $res = imagepng($this->resource); + $this->logger->info(__METHOD__ . '(): Could not guess mime-type, defaulting to png', ['app' => 'core']); + break; + } + if (!$res) { + $this->logger->error(__METHOD__ . '(): Error getting image data.', ['app' => 'core']); + } + return ob_get_clean(); + } + + /** + * @inheritDoc + */ + public function fixOrientation(): bool { + $o = $this->getOrientation(); + $this->logger->debug(__METHOD__ . '() Orientation: ' . $o, ['app' => 'core']); + $rotate = 0; + $flip = false; + switch ($o) { + case -1: + return false; //Nothing to fix + case 1: + $rotate = 0; + break; + case 2: + $rotate = 0; + $flip = true; + break; + case 3: + $rotate = 180; + break; + case 4: + $rotate = 180; + $flip = true; + break; + case 5: + $rotate = 90; + $flip = true; + break; + case 6: + $rotate = 270; + break; + case 7: + $rotate = 270; + $flip = true; + break; + case 8: + $rotate = 90; + break; + } + if ($flip && function_exists('imageflip')) { + imageflip($this->resource, IMG_FLIP_HORIZONTAL); + } + if ($rotate) { + $res = imagerotate($this->resource, $rotate, 0); + if ($res) { + if (imagealphablending($res, true)) { + if (imagesavealpha($res, true)) { + imagedestroy($this->resource); + $this->resource = $res; + return true; + } else { + $this->logger->debug(__METHOD__ . '(): Error during alpha-saving', ['app' => 'core']); + return false; + } + } else { + $this->logger->debug(__METHOD__ . '(): Error during alpha-blending', ['app' => 'core']); + return false; + } + } else { + $this->logger->debug(__METHOD__ . '(): Error during orientation fixing', ['app' => 'core']); + return false; + } + } + return false; + } + + /** + * @inheritDoc + */ + public function loadFromFile($imagePath = false) { + // exif_imagetype throws "read error!" if file is less than 12 byte + if (is_bool($imagePath) || !@is_file($imagePath) || !file_exists($imagePath) || filesize($imagePath) < 12 || !is_readable($imagePath)) { + return false; + } + $iType = exif_imagetype($imagePath); + switch ($iType) { + case IMAGETYPE_GIF: + if (imagetypes() & IMG_GIF) { + $this->resource = imagecreatefromgif($imagePath); + // Preserve transparency + imagealphablending($this->resource, true); + imagesavealpha($this->resource, true); + } else { + $this->logger->debug(__METHOD__ . '(): GIF images not supported: ' . $imagePath, ['app' => 'core']); + } + break; + case IMAGETYPE_JPEG: + if (imagetypes() & IMG_JPG) { + if (getimagesize($imagePath) !== false) { + $this->resource = @imagecreatefromjpeg($imagePath); + } else { + $this->logger->debug(__METHOD__ . '(): JPG image not valid: ' . $imagePath, ['app' => 'core']); + } + } else { + $this->logger->debug(__METHOD__ . '(): JPG images not supported: ' . $imagePath, ['app' => 'core']); + } + break; + case IMAGETYPE_PNG: + if (imagetypes() & IMG_PNG) { + $this->resource = @imagecreatefrompng($imagePath); + // Preserve transparency + imagealphablending($this->resource, true); + imagesavealpha($this->resource, true); + } else { + $this->logger->debug(__METHOD__ . '(): PNG images not supported: ' . $imagePath, ['app' => 'core']); + } + break; + case IMAGETYPE_XBM: + if (imagetypes() & IMG_XPM) { + $this->resource = @imagecreatefromxbm($imagePath); + } else { + $this->logger->debug(__METHOD__ . '(): XBM/XPM images not supported: ' . $imagePath, ['app' => 'core']); + } + break; + case IMAGETYPE_WBMP: + if (imagetypes() & IMG_WBMP) { + $this->resource = @imagecreatefromwbmp($imagePath); + } else { + $this->logger->debug(__METHOD__ . '(): WBMP images not supported: ' . $imagePath, ['app' => 'core']); + } + break; + case IMAGETYPE_BMP: + if (imagetypes() & IMG_BMP) { + $this->resource = @imagecreatefrombmp($imagePath); + } else { + $this->logger->debug(__METHOD__ . '(): BMP images not supported: ' . $imagePath, ['app' => 'core']); + } + break; + case IMAGETYPE_WEBP: + if (imagetypes() & IMG_WEBP) { + $this->resource = @imagecreatefromwebp($imagePath); + } else { + $this->logger->debug(__METHOD__ . '(): WEBP images not supported: ' . $imagePath, ['app' => 'core']); + } + break; + /* + case IMAGETYPE_TIFF_II: // (intel byte order) + break; + case IMAGETYPE_TIFF_MM: // (motorola byte order) + break; + case IMAGETYPE_JPC: + break; + case IMAGETYPE_JP2: + break; + case IMAGETYPE_JPX: + break; + case IMAGETYPE_JB2: + break; + case IMAGETYPE_SWC: + break; + case IMAGETYPE_IFF: + break; + case IMAGETYPE_ICO: + break; + case IMAGETYPE_SWF: + break; + case IMAGETYPE_PSD: + break; + */ + default: + + // this is mostly file created from encrypted file + $this->resource = imagecreatefromstring(\OC\Files\Filesystem::file_get_contents(\OC\Files\Filesystem::getLocalPath($imagePath))); + $iType = IMAGETYPE_PNG; + $this->logger->debug(__METHOD__ . '(): Default', ['app' => 'core']); + break; + } + if ($this->valid()) { + $this->imageType = $iType; + $this->mimeType = image_type_to_mime_type($iType); + $this->filePath = $imagePath; + } + return $this->resource; + } + + /** + * @inheritDoc + */ + public function loadFromFileHandle($handle) { + $contents = stream_get_contents($handle); + if ($this->loadFromData($contents)) { + return $this->resource; + } + return false; + } + + /** + * @inheritDoc + */ + public function loadFromData(string $str) { + $this->resource = @imagecreatefromstring($str); + if ($this->fileInfo) { + $this->mimeType = $this->fileInfo->buffer($str); + } + if (is_resource($this->resource)) { + imagealphablending($this->resource, false); + imagesavealpha($this->resource, true); + } + + if (!$this->resource) { + $this->logger->debug(__METHOD__ . '(): Could not load', ['app' => 'core']); + return false; + } + return $this->resource; + } + + /** + * @inheritDoc + */ + public function loadFromBase64(string $str) { + $data = base64_decode($str); + if ($data) { // try to load from string data + $this->resource = @imagecreatefromstring($data); + if ($this->fileInfo) { + $this->mimeType = $this->fileInfo->buffer($data); + } + if (!$this->resource) { + $this->logger->debug(__METHOD__ . '(): Could not load', ['app' => 'core']); + return false; + } + return $this->resource; + } else { + return false; + } + } + + /** + * @inheritDoc + */ + public function resize(int $maxSize): bool { + $result = $this->resizeNew($maxSize); + imagedestroy($this->resource); + $this->resource = $result; + return is_resource($result); + } + + /** + * @inheritDoc + */ + public function resizeNew(int $maxSize) { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + $widthOrig = $this->width(); + $heightOrig = $this->height(); + $ratioOrig = $widthOrig / $heightOrig; + + if ($ratioOrig > 1) { + $newHeight = round($maxSize / $ratioOrig); + $newWidth = $maxSize; + } else { + $newWidth = round($maxSize * $ratioOrig); + $newHeight = $maxSize; + } + + return $this->preciseResizeNew((int)round($newWidth), (int)round($newHeight)); + } + + /** + * @inheritDoc + */ + public function preciseResize(int $width, int $height): bool { + $result = $this->preciseResizeNew($width, $height); + imagedestroy($this->resource); + $this->resource = $result; + return is_resource($result); + } + + /** + * @inheritDoc + */ + public function preciseResizeNew(int $width, int $height) { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + $widthOrig = $this->width(); + $heightOrig = $this->height(); + $process = imagecreatetruecolor($width, $height); + if ($process === false) { + $this->logger->error(__METHOD__ . '(): Error creating true color image', ['app' => 'core']); + return false; + } + + // preserve transparency + if ($this->imageType === IMAGETYPE_GIF or $this->imageType === IMAGETYPE_PNG) { + imagecolortransparent($process, imagecolorallocatealpha($process, 0, 0, 0, 127)); + imagealphablending($process, false); + imagesavealpha($process, true); + } + + $res = imagecopyresampled($process, $this->resource, 0, 0, 0, 0, $width, $height, $widthOrig, $heightOrig); + if ($res === false) { + $this->logger->error(__METHOD__ . '(): Error re-sampling process image', ['app' => 'core']); + imagedestroy($process); + return false; + } + return $process; + } + + /** + * @inheritDoc + */ + public function centerCrop(int $size = 0): bool { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + $widthOrig = $this->width(); + $heightOrig = $this->height(); + if ($widthOrig === $heightOrig and $size === 0) { + return true; + } + $ratioOrig = $widthOrig / $heightOrig; + $width = $height = min($widthOrig, $heightOrig); + + if ($ratioOrig > 1) { + $x = (int) (($widthOrig / 2) - ($width / 2)); + $y = 0; + } else { + $y = (int) (($heightOrig / 2) - ($height / 2)); + $x = 0; + } + if ($size > 0) { + $targetWidth = $size; + $targetHeight = $size; + } else { + $targetWidth = $width; + $targetHeight = $height; + } + $process = imagecreatetruecolor($targetWidth, $targetHeight); + if ($process === false) { + $this->logger->error(__METHOD__ . '(): Error creating true color image', ['app' => 'core']); + imagedestroy($process); + return false; + } + + // preserve transparency + if ($this->imageType === IMAGETYPE_GIF or $this->imageType === IMAGETYPE_PNG) { + imagecolortransparent($process, imagecolorallocatealpha($process, 0, 0, 0, 127)); + imagealphablending($process, false); + imagesavealpha($process, true); + } + + $res = imagecopyresampled($process, $this->resource, 0, 0, $x, $y, $targetWidth, $targetHeight, $width, $height); + if ($res === false) { + $this->logger->error(__METHOD__ . '(): Error re-sampling process image ' . $width . 'x' . $height, ['app' => 'core']); + imagedestroy($process); + return false; + } + imagedestroy($this->resource); + $this->resource = $process; + return true; + } + + /** + * @inheritDoc + */ + public function crop(int $x, int $y, int $w, int $h): bool { + $result = $this->cropNew($x, $y, $w, $h); + imagedestroy($this->resource); + $this->resource = $result; + return is_resource($result); + } + + /** + * @inheritDoc + */ + public function cropNew(int $x, int $y, int $w, int $h) { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + $process = imagecreatetruecolor($w, $h); + if ($process === false) { + $this->logger->error(__METHOD__ . '(): Error creating true color image', ['app' => 'core']); + return false; + } + + // preserve transparency + if ($this->imageType === IMAGETYPE_GIF or $this->imageType === IMAGETYPE_PNG) { + imagecolortransparent($process, imagecolorallocatealpha($process, 0, 0, 0, 127)); + imagealphablending($process, false); + imagesavealpha($process, true); + } + + $res = imagecopyresampled($process, $this->resource, 0, 0, $x, $y, $w, $h, $w, $h); + if ($res === false) { + $this->logger->error(__METHOD__ . '(): Error re-sampling process image ' . $w . 'x' . $h, ['app' => 'core']); + return false; + } + return $process; + } + + /** + * @inheritDoc + */ + public function fitIn(int $maxWidth, int $maxHeight): bool { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + $widthOrig = $this->width(); + $heightOrig = $this->height(); + $ratio = $widthOrig / $heightOrig; + + $newWidth = min($maxWidth, $ratio * $maxHeight); + $newHeight = min($maxHeight, $maxWidth / $ratio); + + $this->preciseResize((int)round($newWidth), (int)round($newHeight)); + return true; + } + + /** + * @inheritDoc + */ + public function cropCopy(int $x, int $y, int $w, int $h): IImage { + $image = new \OCP\Image($this->logger, $this->config); + $image->imageType = $this->imageType; + $image->mimeType = $this->mimeType; + $image->resource = $this->cropNew($x, $y, $w, $h); + + return $image; + } + + /** + * @inheritDoc + */ + public function preciseResizeCopy(int $width, int $height): IImage { + $image = new \OCP\Image($this->logger, $this->config); + $image->imageType = $this->imageType; + $image->mimeType = $this->mimeType; + $image->resource = $this->preciseResizeNew($width, $height); + + return $image; + } + + /** + * @inheritDoc + */ + public function resizeCopy(int $maxSize): IImage { + $image = new \OCP\Image($this->logger, $this->config); + $image->imageType = $this->imageType; + $image->mimeType = $this->mimeType; + $image->resource = $this->resizeNew($maxSize); + + return $image; + } + + /** + * @inheritDoc + */ + public function destroy(): void { + if ($this->valid()) { + imagedestroy($this->resource); + } + unset($this->resource); + unset($this->mimeType); + unset($this->filePath); + unset($this->fileInfo); + unset($this->exif); + } +} diff --git a/lib/private/Image/Gmagick.php b/lib/private/Image/Gmagick.php new file mode 100644 index 0000000000000..cc0323da741bb --- /dev/null +++ b/lib/private/Image/Gmagick.php @@ -0,0 +1,474 @@ + + * + */ +namespace OC\Image; + +use OCP\IImage; + +/** + * Class for basic image manipulation using Gmagick + */ +class Gmagick extends Common { + + /** + * Get the corresponding imageType + * see \Imagick::queryFormats() + */ + private function formatToImageType(string $loader): int { + switch ($loader) { + case 'GIF': + case 'GIF87': + return IMAGETYPE_GIF; + break; + case 'PNG': + case 'PNG00': + case 'PNG24': + case 'PNG32': + case 'PNG48': + case 'PNG64': + case 'PNG8': + return IMAGETYPE_PNG; + break; + case 'JPG': + case 'JPEG': + return IMAGETYPE_JPEG; + break; + case 'WEBP': + return IMAGETYPE_WEBP; + break; + default: + throw new \Exception(__METHOD__ . '(): "' . $loader . '" is not supported.'); + } + } + + /** + * @inheritDoc + */ + public function valid(): bool { + if (is_object($this->resource) && $this->resource instanceof \Gmagick) { + return true; + } + + return false; + } + + /** + * @inheritDoc + */ + public function width(): int { + return $this->resource->getImageWidth(); + } + + /** + * @inheritDoc + */ + public function height(): int { + return $this->resource->getImageHeight(); + } + + /** + * @inheritDoc + */ + protected function _write($filePath = null, $mimeType = null): bool { + try { + if ($mimeType !== null) { + $compression = \Gmagick::COMPRESSION_UNDEFINED; + switch ($mimeType) { + case 'image/gif': + $imageType = $this->resource->setImageFormat('GIF'); + break; + case 'image/jpeg': + $imageType = $this->resource->setImageFormat('JPEG'); + break; + case 'image/png': + $imageType = $this->resource->setImageFormat('PNG'); + break; + case 'image/x-xbitmap': + $imageType = $this->resource->setImageFormat('XBM'); + break; + case 'image/bmp': + case 'image/x-ms-bmp': + $imageType = $this->resource->setImageFormat('BMP'); + break; + default: + throw new \Exception(__METHOD__ . '(): "' . $mimeType . '" is not supported when forcing a specific output format'); + } + } + + $imageType = $this->formatToImageType($this->resource->getImageFormat()); + switch ($imageType) { + case IMAGETYPE_GIF: + $compression = \Gmagick::COMPRESSION_LZW; + break; + case IMAGETYPE_JPEG: + $compression = \Gmagick::COMPRESSION_JPEG; + $this->resource->setImageCompressionQuality($this->getJpegQuality()); + break; + case IMAGETYPE_PNG: + $compression = \Gmagick::COMPRESSION_ZIP; + break; + } + $this->resource->setImageCompression($compression_type); + return $this->resource->writeImage($filePath); + } catch (\GmagickException $e) { + $this->logger->error(__METHOD__ . '(): Error wrtinig image.', ['app' => 'core']); + return false; + } + } + + /** + * @inheritDoc + */ + public function setResource($resource): void { + if (is_object($resource) && $resource instanceof \Gmagick) { + $this->resource = $resource; + return; + } + throw new \InvalidArgumentException('Supplied resource is not of type "Gmagick".'); + } + + /** + * @inheritDoc + */ + public function data(): ?string { + if (!$this->valid()) { + return null; + } + + try { + $compression = \Gmagick::COMPRESSION_UNDEFINED; + switch ($this->mimeType) { + case "image/png": + $compression = \Gmagick::COMPRESSION_ZIP; + break; + case "image/jpeg": + $compression = \Gmagick::COMPRESSION_JPEG; + $this->resource->setImageCompressionQuality($this->getJpegQuality()); + break; + case "image/gif": + $compression = \Gmagick::COMPRESSION_LZW; + break; + default: + $this->logger->info(__METHOD__ . '(): Could not guess mime-type', ['app' => 'core']); + break; + } + $this->resource->setImageCompression($compression_type); + return $this->resource->getImageBlob(); + } catch (\GmagickException $e) { + $this->logger->error(__METHOD__ . '(): Error getting image data.', ['app' => 'core']); + return null; + } + } + + /** + * @inheritDoc + */ + public function getOrientation(): int { + return $this->resource->getImageOrientation(); + } + + /** + * @inheritDoc + */ + public function fixOrientation(): bool { + $o = $this->getOrientation(); + $this->logger->debug(__METHOD__ . '() Orientation: ' . $o, ['app' => 'core']); + try { + $filler = new \GmagickPixel('none'); + switch ($o) { + case \Gmagick::ORIENTATION_TOPLEFT: + break; + case \Gmagick::ORIENTATION_TOPRIGHT: + $this->resource->flopImage(); + break; + case \Gmagick::ORIENTATION_BOTTOMRIGHT: + $this->resource->rotateImage($filler, 180); + break; + case \Gmagick::ORIENTATION_BOTTOMLEFT: + $this->resource->flopImage(); + $this->resource->rotateImage($filler, 180); + break; + case \Gmagick::ORIENTATION_LEFTTOP: + $this->resource->flopImage(); + $this->resource->rotateImage($filler, -90); + break; + case \Gmagick::ORIENTATION_RIGHTTOP: + $this->resource->rotateImage($filler, 90); + break; + case \Gmagick::ORIENTATION_RIGHTBOTTOM: + $this->resource->flopImage(); + $this->resource->rotateImage($filler, 90); + break; + case \Gmagick::ORIENTATION_LEFTBOTTOM: + $this->resource->rotateImage($filler, -90); + break; + default: // Invalid orientation + break; + } + } catch (\GmagickException $e) { + $this->logger->warning(__METHOD__ . '(): ' . $e); + return false; + } + return true; + } + + /** + * @inheritDoc + */ + public function loadFromFile($imagePath = false) { + // exif_imagetype throws "read error!" if file is less than 12 byte + if (is_bool($imagePath) || !@is_file($imagePath) || !file_exists($imagePath) || filesize($imagePath) < 12 || !is_readable($imagePath)) { + return false; + } + + try { + $this->resource = new \Gmagick($imagePath); + if ($this->valid()) { + $this->imageType = $this->formatToImageType($this->resource->getImageFormat()); + $this->mimeType = $this->resource->getImageMimeType(); + $this->filePath = $imagePath; + } + return $this->resource; + } catch (\GmagickException $e) { + $this->logger->error(__METHOD__ . '(): Error getting image data.', ['app' => 'core']); + return false; + } + } + + /** + * @inheritDoc + */ + public function loadFromFileHandle($handle) { + try { + $this->resource = new \Gmagick(); + $this->resource->readImageFile($handle); + if ($this->valid()) { + $this->imageType = $this->formatToImageType($this->resource->getImageFormat()); + $this->mimeType = $this->resource->getImageMimeType(); + } + return $this->resource; + } catch (\GmagickException $e) { + $this->logger->error(__METHOD__ . '(): Error getting image data.', ['app' => 'core']); + return false; + } + } + + /** + * @inheritDoc + */ + public function loadFromData(string $str) { + try { + $this->resource = new \Gmagick(); + $this->resource->readImageBlob($str); + if ($this->valid()) { + $this->imageType = $this->formatToImageType($this->resource->getImageFormat()); + $this->mimeType = $this->resource->getImageMimeType(); + } + return $this->resource; + } catch (\GmagickException $e) { + $this->logger->error(__METHOD__ . '(): Error getting image data.', ['app' => 'core']); + return false; + } + } + + /** + * @inheritDoc + */ + public function resize(int $maxSize): bool { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + return $this->fitIn($maxSize, $maxSize); + } + + /** + * @inheritDoc + */ + public function resizeNew(int $maxSize) { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + try { + $image = $this->resource->copy(); + $image->resize($maxSize); + return $image; + } catch (\GmagickException $e) { + $this->logger->warning(__METHOD__ . '(): ' . $e); + return false; + } + } + + /** + * @inheritDoc + */ + public function preciseResize(int $width, int $height): bool { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + try { + $this->resource->thumbnailimage($maxSize, $maxSize); + } catch (\GmagickException $e) { + $this->logger->warning(__METHOD__ . '(): ' . $e); + return false; + } + return true; + } + + /** + * @inheritDoc + */ + public function preciseResizeNew(int $width, int $height) { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + try { + $image = $this->resource->copy(); + $image->preciseResize($maxSize); + return $image; + } catch (\GmagickException $e) { + $this->logger->warning(__METHOD__ . '(): ' . $e); + return false; + } + } + + /** + * @inheritDoc + */ + public function centerCrop(int $size = 0): bool { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + $widthOrig = $this->width(); + $heightOrig = $this->height(); + if ($widthOrig === $heightOrig and $size === 0) { + return true; + } + + try { + $this->resource->cropThumbnailImage($size, $size); + } catch (\GmagickException $e) { + $this->logger->warning(__METHOD__ . '(): ' . $e); + return false; + } + return true; + } + + /** + * @inheritDoc + */ + public function crop(int $x, int $y, int $w, int $h): bool { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + try { + $this->resource->cropImage($w, $h, $x, $y); + } catch (\GmagickException $e) { + $this->logger->warning(__METHOD__ . '(): ' . $e); + return false; + } + return true; + } + + /** + * @inheritDoc + */ + public function cropNew(int $x, int $y, int $w, int $h) { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + try { + $image = $this->resource->copy(); + $image->crop($x, $y, $w, $h); + return $image; + } catch (\GmagickException $e) { + $this->logger->warning(__METHOD__ . '(): ' . $e); + return false; + } + } + + /** + * @inheritDoc + */ + public function fitIn(int $maxWidth, int $maxHeight): bool { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + try { + $this->resource->thumbnailimage($maxWidth, $maxHeight, true); + } catch (\GmagickException $e) { + $this->logger->warning(__METHOD__ . '(): ' . $e); + return false; + } + return true; + } + + /** + * @inheritDoc + */ + public function cropCopy(int $x, int $y, int $w, int $h) { + return $this->cropNew($x, $y, $w, $h); + } + + /** + * @inheritDoc + */ + public function preciseResizeCopy(int $width, int $height) { + return $this->preciseResizeNew($width, $height); + } + + /** + * @inheritDoc + */ + public function resizeCopy(int $maxSize) { + return $this->resizeNew($maxSize, $maxSize); + } + + /** + * @inheritDoc + */ + public function destroy(): void { + if ($this->valid()) { + $this->resource->clear(); + } + unset($this->resource); + unset($this->mimeType); + unset($this->filePath); + unset($this->fileInfo); + unset($this->exif); + } +} diff --git a/lib/private/Image/Imagick.php b/lib/private/Image/Imagick.php new file mode 100644 index 0000000000000..f999553d3fa8b --- /dev/null +++ b/lib/private/Image/Imagick.php @@ -0,0 +1,474 @@ + + * + */ +namespace OC\Image; + +use OCP\IImage; + +/** + * Class for basic image manipulation using Imagick + */ +class Imagick extends Common { + + /** + * Get the corresponding imageType + * see \Imagick::queryFormats() + */ + private function formatToImageType(string $loader): int { + switch ($loader) { + case 'GIF': + case 'GIF87': + return IMAGETYPE_GIF; + break; + case 'PNG': + case 'PNG00': + case 'PNG24': + case 'PNG32': + case 'PNG48': + case 'PNG64': + case 'PNG8': + return IMAGETYPE_PNG; + break; + case 'JPG': + case 'JPEG': + return IMAGETYPE_JPEG; + break; + case 'WEBP': + return IMAGETYPE_WEBP; + break; + default: + throw new \Exception(__METHOD__ . '(): "' . $loader . '" is not supported.'); + } + } + + /** + * @inheritDoc + */ + public function valid(): bool { + if (is_object($this->resource) && $this->resource instanceof \Imagick) { + return true; + } + + return false; + } + + /** + * @inheritDoc + */ + public function width(): int { + return $this->resource->getImageWidth(); + } + + /** + * @inheritDoc + */ + public function height(): int { + return $this->resource->getImageHeight(); + } + + /** + * @inheritDoc + */ + protected function _write($filePath = null, $mimeType = null): bool { + try { + if ($mimeType !== null) { + $compression = \Imagick::COMPRESSION_UNDEFINED; + switch ($mimeType) { + case 'image/gif': + $imageType = $this->resource->setImageFormat('GIF'); + break; + case 'image/jpeg': + $imageType = $this->resource->setImageFormat('JPEG'); + break; + case 'image/png': + $imageType = $this->resource->setImageFormat('PNG'); + break; + case 'image/x-xbitmap': + $imageType = $this->resource->setImageFormat('XBM'); + break; + case 'image/bmp': + case 'image/x-ms-bmp': + $imageType = $this->resource->setImageFormat('BMP'); + break; + default: + throw new \Exception(__METHOD__ . '(): "' . $mimeType . '" is not supported when forcing a specific output format'); + } + } + + $imageType = $this->formatToImageType($this->resource->getImageFormat()); + switch ($imageType) { + case IMAGETYPE_GIF: + $compression = \Imagick::COMPRESSION_LZW; + break; + case IMAGETYPE_JPEG: + $compression = \Imagick::COMPRESSION_JPEG; + $this->resource->setImageCompressionQuality($this->getJpegQuality()); + break; + case IMAGETYPE_PNG: + $compression = \Imagick::COMPRESSION_ZIP; + break; + } + $this->resource->setImageCompression($compression_type); + return $this->resource->writeImage($filePath); + } catch (\ImagickException $e) { + $this->logger->error(__METHOD__ . '(): Error wrtinig image.', ['app' => 'core']); + return false; + } + } + + /** + * @inheritDoc + */ + public function setResource($resource): void { + if (is_object($resource) && $resource instanceof \Imagick) { + $this->resource = $resource; + return; + } + throw new \InvalidArgumentException('Supplied resource is not of type "Imagick".'); + } + + /** + * @inheritDoc + */ + public function data(): ?string { + if (!$this->valid()) { + return null; + } + + try { + $compression = \Imagick::COMPRESSION_UNDEFINED; + switch ($this->mimeType) { + case "image/png": + $compression = \Imagick::COMPRESSION_ZIP; + break; + case "image/jpeg": + $compression = \Imagick::COMPRESSION_JPEG; + $this->resource->setImageCompressionQuality($this->getJpegQuality()); + break; + case "image/gif": + $compression = \Imagick::COMPRESSION_LZW; + break; + default: + $this->logger->info(__METHOD__ . '(): Could not guess mime-type', ['app' => 'core']); + break; + } + $this->resource->setImageCompression($compression_type); + return $this->resource->getImageBlob(); + } catch (\ImagickException $e) { + $this->logger->error(__METHOD__ . '(): Error getting image data.', ['app' => 'core']); + return null; + } + } + + /** + * @inheritDoc + */ + public function getOrientation(): int { + return $this->resource->getImageOrientation(); + } + + /** + * @inheritDoc + */ + public function fixOrientation(): bool { + $o = $this->getOrientation(); + $this->logger->debug(__METHOD__ . '() Orientation: ' . $o, ['app' => 'core']); + try { + $filler = new \ImagickPixel('none'); + switch ($o) { + case \Imagick::ORIENTATION_TOPLEFT: + break; + case \Imagick::ORIENTATION_TOPRIGHT: + $this->resource->flopImage(); + break; + case \Imagick::ORIENTATION_BOTTOMRIGHT: + $this->resource->rotateImage($filler, 180); + break; + case \Imagick::ORIENTATION_BOTTOMLEFT: + $this->resource->flopImage(); + $this->resource->rotateImage($filler, 180); + break; + case \Imagick::ORIENTATION_LEFTTOP: + $this->resource->flopImage(); + $this->resource->rotateImage($filler, -90); + break; + case \Imagick::ORIENTATION_RIGHTTOP: + $this->resource->rotateImage($filler, 90); + break; + case \Imagick::ORIENTATION_RIGHTBOTTOM: + $this->resource->flopImage(); + $this->resource->rotateImage($filler, 90); + break; + case \Imagick::ORIENTATION_LEFTBOTTOM: + $this->resource->rotateImage($filler, -90); + break; + default: // Invalid orientation + break; + } + } catch (\ImagickException $e) { + $this->logger->warning(__METHOD__ . '(): ' . $e); + return false; + } + return true; + } + + /** + * @inheritDoc + */ + public function loadFromFile($imagePath = false) { + // exif_imagetype throws "read error!" if file is less than 12 byte + if (is_bool($imagePath) || !@is_file($imagePath) || !file_exists($imagePath) || filesize($imagePath) < 12 || !is_readable($imagePath)) { + return false; + } + + try { + $this->resource = new \Imagick($imagePath); + if ($this->valid()) { + $this->imageType = $this->formatToImageType($this->resource->getImageFormat()); + $this->mimeType = $this->resource->getImageMimeType(); + $this->filePath = $imagePath; + } + return $this->resource; + } catch (\ImagickException $e) { + $this->logger->error(__METHOD__ . '(): Error getting image data.', ['app' => 'core']); + return false; + } + } + + /** + * @inheritDoc + */ + public function loadFromFileHandle($handle) { + try { + $this->resource = new \Imagick(); + $this->resource->readImageFile($handle); + if ($this->valid()) { + $this->imageType = $this->formatToImageType($this->resource->getImageFormat()); + $this->mimeType = $this->resource->getImageMimeType(); + } + return $this->resource; + } catch (\ImagickException $e) { + $this->logger->error(__METHOD__ . '(): Error getting image data.', ['app' => 'core']); + return false; + } + } + + /** + * @inheritDoc + */ + public function loadFromData(string $str) { + try { + $this->resource = new \Imagick(); + $this->resource->readImageBlob($str); + if ($this->valid()) { + $this->imageType = $this->formatToImageType($this->resource->getImageFormat()); + $this->mimeType = $this->resource->getImageMimeType(); + } + return $this->resource; + } catch (\ImagickException $e) { + $this->logger->error(__METHOD__ . '(): Error getting image data.', ['app' => 'core']); + return false; + } + } + + /** + * @inheritDoc + */ + public function resize(int $maxSize): bool { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + return $this->fitIn($maxSize, $maxSize); + } + + /** + * @inheritDoc + */ + public function resizeNew(int $maxSize) { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + try { + $image = $this->resource->copy(); + $image->resize($maxSize); + return $image; + } catch (\ImagickException $e) { + $this->logger->warning(__METHOD__ . '(): ' . $e); + return false; + } + } + + /** + * @inheritDoc + */ + public function preciseResize(int $width, int $height): bool { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + try { + $this->resource->thumbnailimage($maxSize, $maxSize); + } catch (\ImagickException $e) { + $this->logger->warning(__METHOD__ . '(): ' . $e); + return false; + } + return true; + } + + /** + * @inheritDoc + */ + public function preciseResizeNew(int $width, int $height) { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + try { + $image = $this->resource->copy(); + $image->preciseResize($maxSize); + return $image; + } catch (\ImagickException $e) { + $this->logger->warning(__METHOD__ . '(): ' . $e); + return false; + } + } + + /** + * @inheritDoc + */ + public function centerCrop(int $size = 0): bool { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + $widthOrig = $this->width(); + $heightOrig = $this->height(); + if ($widthOrig === $heightOrig and $size === 0) { + return true; + } + + try { + $this->resource->cropThumbnailImage($size, $size); + } catch (\ImagickException $e) { + $this->logger->warning(__METHOD__ . '(): ' . $e); + return false; + } + return true; + } + + /** + * @inheritDoc + */ + public function crop(int $x, int $y, int $w, int $h): bool { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + try { + $this->resource->cropImage($w, $h, $x, $y); + } catch (\ImagickException $e) { + $this->logger->warning(__METHOD__ . '(): ' . $e); + return false; + } + return true; + } + + /** + * @inheritDoc + */ + public function cropNew(int $x, int $y, int $w, int $h) { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + try { + $image = $this->resource->copy(); + $image->crop($x, $y, $w, $h); + return $image; + } catch (\ImagickException $e) { + $this->logger->warning(__METHOD__ . '(): ' . $e); + return false; + } + } + + /** + * @inheritDoc + */ + public function fitIn(int $maxWidth, int $maxHeight): bool { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + try { + $this->resource->thumbnailimage($maxWidth, $maxHeight, true); + } catch (\ImagickException $e) { + $this->logger->warning(__METHOD__ . '(): ' . $e); + return false; + } + return true; + } + + /** + * @inheritDoc + */ + public function cropCopy(int $x, int $y, int $w, int $h) { + return $this->cropNew($x, $y, $w, $h); + } + + /** + * @inheritDoc + */ + public function preciseResizeCopy(int $width, int $height) { + return $this->preciseResizeNew($width, $height); + } + + /** + * @inheritDoc + */ + public function resizeCopy(int $maxSize) { + return $this->resizeNew($maxSize, $maxSize); + } + + /** + * @inheritDoc + */ + public function destroy(): void { + if ($this->valid()) { + $this->resource->clear(); + } + unset($this->resource); + unset($this->mimeType); + unset($this->filePath); + unset($this->fileInfo); + unset($this->exif); + } +} diff --git a/lib/private/legacy/OC_Image.php b/lib/private/legacy/OC_Image.php index 9ccc6409ba030..7881bec8ee00c 100644 --- a/lib/private/legacy/OC_Image.php +++ b/lib/private/legacy/OC_Image.php @@ -118,7 +118,7 @@ public function valid(): bool { /** * Returns the MIME type of the image or null if no image is loaded. * - * @return string + * @return ?string */ public function mimeType(): ?string { return $this->valid() ? $this->mimeType : null; @@ -716,7 +716,7 @@ public function loadFromFile($imagePath = false) { } break; case IMAGETYPE_BMP: - $this->resource = imagecreatefrombmp($imagePath); + $this->resource = @imagecreatefrombmp($imagePath); break; case IMAGETYPE_WEBP: if (imagetypes() & IMG_WEBP) { diff --git a/lib/public/IImage.php b/lib/public/IImage.php index f1ac3bf1a5011..47a6eaa4ae737 100644 --- a/lib/public/IImage.php +++ b/lib/public/IImage.php @@ -92,7 +92,7 @@ public function show(?string $mimeType = null): bool; public function save(?string $filePath = null, ?string $mimeType = null): bool; /** - * @return false|resource|\GdImage Returns the image resource if any + * @return bool|object|resource Returns the raw image resource if any, false otherwise * @since 8.1.0 */ public function resource(); diff --git a/tests/lib/Image/Gd.php b/tests/lib/Image/Gd.php new file mode 100644 index 0000000000000..ba7b2b1e2b546 --- /dev/null +++ b/tests/lib/Image/Gd.php @@ -0,0 +1,367 @@ + + * This file is licensed under the Affero General Public License version 3 or + * later. + * See the COPYING-README file. + */ + +namespace Test; + +use OC; +use OCP\IConfig; +use OCP\Image\Gd; + +class ImageGdTest extends \Test\TestCase { + public static function tearDownAfterClass(): void { + @unlink(OC::$SERVERROOT.'/tests/data/testimage2.png'); + @unlink(OC::$SERVERROOT.'/tests/data/testimage2.jpg'); + + parent::tearDownAfterClass(); + } + + public function testConstructDestruct() { + $img = new Gd(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertInstanceOf('Gd', $img); + $this->assertInstanceOf('\OCP\IImage', $img); + unset($img); + + $imgcreate = imagecreatefromjpeg(OC::$SERVERROOT.'/tests/data/testimage.jpg'); + $img = new Gd(); + $img->setResource($imgcreate); + $this->assertInstanceOf('Gd', $img); + $this->assertInstanceOf('\OCP\IImage', $img); + unset($img); + + $base64 = base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif')); + $img = new Gd(); + $img->loadFromBase64($base64); + $this->assertInstanceOf('Gd', $img); + $this->assertInstanceOf('\OCP\IImage', $img); + unset($img); + + $img = new Gd(); + $this->assertInstanceOf('Gd', $img); + $this->assertInstanceOf('\OCP\IImage', $img); + unset($img); + } + + public function testValid() { + $img = new Gd(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertTrue($img->valid()); + + $text = base64_encode("Lorem ipsum dolor sir amet …"); + $img = new Gd(); + $img->loadFromBase64($text); + $this->assertFalse($img->valid()); + + $img = new Gd(); + $this->assertFalse($img->valid()); + } + + public function testMimeType() { + $img = new Gd(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertEquals('image/png', $img->mimeType()); + + $img = new Gd(); + $this->assertEquals('', $img->mimeType()); + + $img = new Gd(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertEquals('image/jpeg', $img->mimeType()); + + $img = new Gd(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertEquals('image/gif', $img->mimeType()); + } + + public function testWidth() { + $img = new Gd(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertEquals(128, $img->width()); + + $img = new Gd(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertEquals(1680, $img->width()); + + $img = new Gd(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertEquals(64, $img->width()); + + $img = new Gd(); + $this->assertEquals(-1, $img->width()); + } + + public function testHeight() { + $img = new Gd(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertEquals(128, $img->height()); + + $img = new Gd(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertEquals(1050, $img->height()); + + $img = new Gd(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertEquals(64, $img->height()); + + $img = new Gd(); + $this->assertEquals(-1, $img->height()); + } + + public function testSave() { + $img = new Gd(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $img->resize(16); + $img->save(OC::$SERVERROOT.'/tests/data/testimage2.png'); + $this->assertEquals(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage2.png'), $img->data()); + + $img = new Gd(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.jpg'); + $img->resize(128); + $img->save(OC::$SERVERROOT.'/tests/data/testimage2.jpg'); + $this->assertEquals(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage2.jpg'), $img->data()); + } + + public function testData() { + $img = new Gd(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $raw = imagecreatefromstring(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.png')); + // Preserve transparency + imagealphablending($raw, true); + imagesavealpha($raw, true); + ob_start(); + imagepng($raw); + $expected = ob_get_clean(); + $this->assertEquals($expected, $img->data()); + + $config = $this->createMock(IConfig::class); + $config->expects($this->once()) + ->method('getAppValue') + ->with('preview', 'jpeg_quality', 90) + ->willReturn(null); + $img = new Gd(null, null, $config); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.jpg'); + $raw = imagecreatefromstring(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + ob_start(); + imagejpeg($raw); + $expected = ob_get_clean(); + $this->assertEquals($expected, $img->data()); + + $img = new Gd(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.gif'); + $raw = imagecreatefromstring(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif')); + ob_start(); + imagegif($raw); + $expected = ob_get_clean(); + $this->assertEquals($expected, $img->data()); + } + + public function testDataNoResource() { + $img = new Gd(); + $this->assertNull($img->data()); + } + + /** + * @depends testData + */ + public function testToString() { + $img = new Gd(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $expected = base64_encode($img->data()); + $this->assertEquals($expected, (string)$img); + + $img = new Gd(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $expected = base64_encode($img->data()); + $this->assertEquals($expected, (string)$img); + + $img = new Gd(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.gif'); + $expected = base64_encode($img->data()); + $this->assertEquals($expected, (string)$img); + } + + public function testResize() { + $img = new Gd(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertTrue($img->resize(32)); + $this->assertEquals(32, $img->width()); + $this->assertEquals(32, $img->height()); + + $img = new Gd(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertTrue($img->resize(840)); + $this->assertEquals(840, $img->width()); + $this->assertEquals(525, $img->height()); + + $img = new Gd(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertTrue($img->resize(100)); + $this->assertEquals(100, $img->width()); + $this->assertEquals(100, $img->height()); + } + + public function testPreciseResize() { + $img = new Gd(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertTrue($img->preciseResize(128, 512)); + $this->assertEquals(128, $img->width()); + $this->assertEquals(512, $img->height()); + + $img = new Gd(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertTrue($img->preciseResize(64, 840)); + $this->assertEquals(64, $img->width()); + $this->assertEquals(840, $img->height()); + + $img = new Gd(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertTrue($img->preciseResize(1000, 1337)); + $this->assertEquals(1000, $img->width()); + $this->assertEquals(1337, $img->height()); + } + + public function testCenterCrop() { + $img = new Gd(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $img->centerCrop(); + $this->assertEquals(128, $img->width()); + $this->assertEquals(128, $img->height()); + + $img = new Gd(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $img->centerCrop(); + $this->assertEquals(1050, $img->width()); + $this->assertEquals(1050, $img->height()); + + $img = new Gd(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $img->centerCrop(512); + $this->assertEquals(512, $img->width()); + $this->assertEquals(512, $img->height()); + } + + public function testCrop() { + $img = new Gd(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertTrue($img->crop(0, 0, 50, 20)); + $this->assertEquals(50, $img->width()); + $this->assertEquals(20, $img->height()); + + $img = new Gd(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertTrue($img->crop(500, 700, 550, 300)); + $this->assertEquals(550, $img->width()); + $this->assertEquals(300, $img->height()); + + $img = new Gd(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertTrue($img->crop(10, 10, 15, 15)); + $this->assertEquals(15, $img->width()); + $this->assertEquals(15, $img->height()); + } + + public static function sampleProvider() { + return [ + ['testimage.png', [200, 100], [100, 100]], + ['testimage.jpg', [840, 840], [840, 525]], + ['testimage.gif', [200, 250], [200, 200]] + ]; + } + + /** + * @dataProvider sampleProvider + * + * @param string $filename + * @param int[] $asked + * @param int[] $expected + */ + public function testFitIn($filename, $asked, $expected) { + $img = new Gd(); + $img->loadFromFile(OC::$SERVERROOT . '/tests/data/' . $filename); + $this->assertTrue($img->fitIn($asked[0], $asked[1])); + $this->assertEquals($expected[0], $img->width()); + $this->assertEquals($expected[1], $img->height()); + } + + public static function sampleFilenamesProvider() { + return [ + ['testimage.png'], + ['testimage.jpg'], + ['testimage.gif'] + ]; + } + + /** + * Image should not be resized if it's already smaller than what is required + * + * @dataProvider sampleFilenamesProvider + * + * @param string $filename + */ + public function testScaleDownToFitWhenSmallerAlready($filename) { + $img = new Gd(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/' . $filename); + $currentWidth = $img->width(); + $currentHeight = $img->height(); + // We pick something larger than the image we want to scale down + $this->assertFalse($img->scaleDownToFit(4000, 4000)); + // The dimensions of the image should not have changed since it's smaller already + $resizedWidth = $img->width(); + $resizedHeight = $img->height(); + $this->assertEquals( + $currentWidth, $img->width(), "currentWidth $currentWidth resizedWidth $resizedWidth \n" + ); + $this->assertEquals( + $currentHeight, $img->height(), + "currentHeight $currentHeight resizedHeight $resizedHeight \n" + ); + } + + public static function largeSampleProvider() { + return [ + ['testimage.png', [200, 100], [100, 100]], + ['testimage.jpg', [840, 840], [840, 525]], + ]; + } + + /** + * @dataProvider largeSampleProvider + * + * @param string $filename + * @param int[] $asked + * @param int[] $expected + */ + public function testScaleDownWhenBigger($filename, $asked, $expected) { + $img = new Gd(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/' . $filename); + //$this->assertTrue($img->scaleDownToFit($asked[0], $asked[1])); + $img->scaleDownToFit($asked[0], $asked[1]); + $this->assertEquals($expected[0], $img->width()); + $this->assertEquals($expected[1], $img->height()); + } + + public function convertDataProvider() { + return [ + [ 'image/gif'], + [ 'image/jpeg'], + [ 'image/png'], + ]; + } + + /** + * @dataProvider convertDataProvider + */ + public function testConvert($mimeType) { + $img = new Gd(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $tempFile = tempnam(sys_get_temp_dir(), 'img-test'); + + $img->save($tempFile, $mimeType); + $this->assertEquals($mimeType, image_type_to_mime_type(exif_imagetype($tempFile))); + } +} diff --git a/tests/lib/Image/Gmagick.php b/tests/lib/Image/Gmagick.php new file mode 100644 index 0000000000000..5f64d2ed1ef98 --- /dev/null +++ b/tests/lib/Image/Gmagick.php @@ -0,0 +1,375 @@ + + * This file is licensed under the Affero General Public License version 3 or + * later. + * See the COPYING-README file. + */ + +namespace Test; + +use OC; +use OCP\IConfig; +use OCP\Image\Gmagick; + +class ImageGmagickTest extends \Test\TestCase { + protected function setUp(): void { + if (!extension_loaded('gmagick')) { + $this->markTestSkipped('Gmagick module not available. Skipping tests'); + } else { + parent::setUp(); + } + } + + public static function tearDownAfterClass(): void { + @unlink(OC::$SERVERROOT.'/tests/data/testimage2.png'); + @unlink(OC::$SERVERROOT.'/tests/data/testimage2.jpg'); + + parent::tearDownAfterClass(); + } + + public function testConstructDestruct() { + $img = new Gmagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertInstanceOf('Gmagick', $img); + $this->assertInstanceOf('\OCP\IImage', $img); + unset($img); + + $imgcreate = imagecreatefromjpeg(OC::$SERVERROOT.'/tests/data/testimage.jpg'); + $img = new Gmagick(); + $img->setResource($imgcreate); + $this->assertInstanceOf('Gmagick', $img); + $this->assertInstanceOf('\OCP\IImage', $img); + unset($img); + + $base64 = base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif')); + $img = new Gmagick(); + $img->loadFromBase64($base64); + $this->assertInstanceOf('Gmagick', $img); + $this->assertInstanceOf('\OCP\IImage', $img); + unset($img); + + $img = new Gmagick(); + $this->assertInstanceOf('Gmagick', $img); + $this->assertInstanceOf('\OCP\IImage', $img); + unset($img); + } + + public function testValid() { + $img = new Gmagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertTrue($img->valid()); + + $text = base64_encode("Lorem ipsum dolor sir amet …"); + $img = new Gmagick(); + $img->loadFromBase64($text); + $this->assertFalse($img->valid()); + + $img = new Gmagick(); + $this->assertFalse($img->valid()); + } + + public function testMimeType() { + $img = new Gmagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertEquals('image/png', $img->mimeType()); + + $img = new Gmagick(); + $this->assertEquals('', $img->mimeType()); + + $img = new Gmagick(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertEquals('image/jpeg', $img->mimeType()); + + $img = new Gmagick(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertEquals('image/gif', $img->mimeType()); + } + + public function testWidth() { + $img = new Gmagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertEquals(128, $img->width()); + + $img = new Gmagick(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertEquals(1680, $img->width()); + + $img = new Gmagick(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertEquals(64, $img->width()); + + $img = new Gmagick(); + $this->assertEquals(-1, $img->width()); + } + + public function testHeight() { + $img = new Gmagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertEquals(128, $img->height()); + + $img = new Gmagick(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertEquals(1050, $img->height()); + + $img = new Gmagick(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertEquals(64, $img->height()); + + $img = new Gmagick(); + $this->assertEquals(-1, $img->height()); + } + + public function testSave() { + $img = new Gmagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $img->resize(16); + $img->save(OC::$SERVERROOT.'/tests/data/testimage2.png'); + $this->assertEquals(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage2.png'), $img->data()); + + $img = new Gmagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.jpg'); + $img->resize(128); + $img->save(OC::$SERVERROOT.'/tests/data/testimage2.jpg'); + $this->assertEquals(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage2.jpg'), $img->data()); + } + + public function testData() { + $img = new Gmagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $raw = imagecreatefromstring(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.png')); + // Preserve transparency + imagealphablending($raw, true); + imagesavealpha($raw, true); + ob_start(); + imagepng($raw); + $expected = ob_get_clean(); + $this->assertEquals($expected, $img->data()); + + $config = $this->createMock(IConfig::class); + $config->expects($this->once()) + ->method('getAppValue') + ->with('preview', 'jpeg_quality', 90) + ->willReturn(null); + $img = new Gmagick(null, null, $config); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.jpg'); + $raw = imagecreatefromstring(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + ob_start(); + imagejpeg($raw); + $expected = ob_get_clean(); + $this->assertEquals($expected, $img->data()); + + $img = new Gmagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.gif'); + $raw = imagecreatefromstring(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif')); + ob_start(); + imagegif($raw); + $expected = ob_get_clean(); + $this->assertEquals($expected, $img->data()); + } + + public function testDataNoResource() { + $img = new Gmagick(); + $this->assertNull($img->data()); + } + + /** + * @depends testData + */ + public function testToString() { + $img = new Gmagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $expected = base64_encode($img->data()); + $this->assertEquals($expected, (string)$img); + + $img = new Gmagick(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $expected = base64_encode($img->data()); + $this->assertEquals($expected, (string)$img); + + $img = new Gmagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.gif'); + $expected = base64_encode($img->data()); + $this->assertEquals($expected, (string)$img); + } + + public function testResize() { + $img = new Gmagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertTrue($img->resize(32)); + $this->assertEquals(32, $img->width()); + $this->assertEquals(32, $img->height()); + + $img = new Gmagick(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertTrue($img->resize(840)); + $this->assertEquals(840, $img->width()); + $this->assertEquals(525, $img->height()); + + $img = new Gmagick(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertTrue($img->resize(100)); + $this->assertEquals(100, $img->width()); + $this->assertEquals(100, $img->height()); + } + + public function testPreciseResize() { + $img = new Gmagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertTrue($img->preciseResize(128, 512)); + $this->assertEquals(128, $img->width()); + $this->assertEquals(512, $img->height()); + + $img = new Gmagick(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertTrue($img->preciseResize(64, 840)); + $this->assertEquals(64, $img->width()); + $this->assertEquals(840, $img->height()); + + $img = new Gmagick(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertTrue($img->preciseResize(1000, 1337)); + $this->assertEquals(1000, $img->width()); + $this->assertEquals(1337, $img->height()); + } + + public function testCenterCrop() { + $img = new Gmagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $img->centerCrop(); + $this->assertEquals(128, $img->width()); + $this->assertEquals(128, $img->height()); + + $img = new Gmagick(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $img->centerCrop(); + $this->assertEquals(1050, $img->width()); + $this->assertEquals(1050, $img->height()); + + $img = new Gmagick(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $img->centerCrop(512); + $this->assertEquals(512, $img->width()); + $this->assertEquals(512, $img->height()); + } + + public function testCrop() { + $img = new Gmagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertTrue($img->crop(0, 0, 50, 20)); + $this->assertEquals(50, $img->width()); + $this->assertEquals(20, $img->height()); + + $img = new Gmagick(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertTrue($img->crop(500, 700, 550, 300)); + $this->assertEquals(550, $img->width()); + $this->assertEquals(300, $img->height()); + + $img = new Gmagick(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertTrue($img->crop(10, 10, 15, 15)); + $this->assertEquals(15, $img->width()); + $this->assertEquals(15, $img->height()); + } + + public static function sampleProvider() { + return [ + ['testimage.png', [200, 100], [100, 100]], + ['testimage.jpg', [840, 840], [840, 525]], + ['testimage.gif', [200, 250], [200, 200]] + ]; + } + + /** + * @dataProvider sampleProvider + * + * @param string $filename + * @param int[] $asked + * @param int[] $expected + */ + public function testFitIn($filename, $asked, $expected) { + $img = new Gmagick(); + $img->loadFromFile(OC::$SERVERROOT . '/tests/data/' . $filename); + $this->assertTrue($img->fitIn($asked[0], $asked[1])); + $this->assertEquals($expected[0], $img->width()); + $this->assertEquals($expected[1], $img->height()); + } + + public static function sampleFilenamesProvider() { + return [ + ['testimage.png'], + ['testimage.jpg'], + ['testimage.gif'] + ]; + } + + /** + * Image should not be resized if it's already smaller than what is required + * + * @dataProvider sampleFilenamesProvider + * + * @param string $filename + */ + public function testScaleDownToFitWhenSmallerAlready($filename) { + $img = new Gmagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/' . $filename); + $currentWidth = $img->width(); + $currentHeight = $img->height(); + // We pick something larger than the image we want to scale down + $this->assertFalse($img->scaleDownToFit(4000, 4000)); + // The dimensions of the image should not have changed since it's smaller already + $resizedWidth = $img->width(); + $resizedHeight = $img->height(); + $this->assertEquals( + $currentWidth, $img->width(), "currentWidth $currentWidth resizedWidth $resizedWidth \n" + ); + $this->assertEquals( + $currentHeight, $img->height(), + "currentHeight $currentHeight resizedHeight $resizedHeight \n" + ); + } + + public static function largeSampleProvider() { + return [ + ['testimage.png', [200, 100], [100, 100]], + ['testimage.jpg', [840, 840], [840, 525]], + ]; + } + + /** + * @dataProvider largeSampleProvider + * + * @param string $filename + * @param int[] $asked + * @param int[] $expected + */ + public function testScaleDownWhenBigger($filename, $asked, $expected) { + $img = new Gmagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/' . $filename); + //$this->assertTrue($img->scaleDownToFit($asked[0], $asked[1])); + $img->scaleDownToFit($asked[0], $asked[1]); + $this->assertEquals($expected[0], $img->width()); + $this->assertEquals($expected[1], $img->height()); + } + + public function convertDataProvider() { + return [ + [ 'image/gif'], + [ 'image/jpeg'], + [ 'image/png'], + ]; + } + + /** + * @dataProvider convertDataProvider + */ + public function testConvert($mimeType) { + $img = new Gmagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $tempFile = tempnam(sys_get_temp_dir(), 'img-test'); + + $img->save($tempFile, $mimeType); + $this->assertEquals($mimeType, image_type_to_mime_type(exif_imagetype($tempFile))); + } +} diff --git a/tests/lib/Image/Imagick.php b/tests/lib/Image/Imagick.php new file mode 100644 index 0000000000000..79cbae20cfe4a --- /dev/null +++ b/tests/lib/Image/Imagick.php @@ -0,0 +1,375 @@ + + * This file is licensed under the Affero General Public License version 3 or + * later. + * See the COPYING-README file. + */ + +namespace Test; + +use OC; +use OCP\IConfig; +use OCP\Image\Imagick; + +class ImageImagickTest extends \Test\TestCase { + protected function setUp(): void { + if (!extension_loaded('imagick')) { + $this->markTestSkipped('Imagick module not available. Skipping tests'); + } else { + parent::setUp(); + } + } + + public static function tearDownAfterClass(): void { + @unlink(OC::$SERVERROOT.'/tests/data/testimage2.png'); + @unlink(OC::$SERVERROOT.'/tests/data/testimage2.jpg'); + + parent::tearDownAfterClass(); + } + + public function testConstructDestruct() { + $img = new Imagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertInstanceOf('Imagick', $img); + $this->assertInstanceOf('\OCP\IImage', $img); + unset($img); + + $imgcreate = imagecreatefromjpeg(OC::$SERVERROOT.'/tests/data/testimage.jpg'); + $img = new Imagick(); + $img->setResource($imgcreate); + $this->assertInstanceOf('Imagick', $img); + $this->assertInstanceOf('\OCP\IImage', $img); + unset($img); + + $base64 = base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif')); + $img = new Imagick(); + $img->loadFromBase64($base64); + $this->assertInstanceOf('Imagick', $img); + $this->assertInstanceOf('\OCP\IImage', $img); + unset($img); + + $img = new Imagick(); + $this->assertInstanceOf('Imagick', $img); + $this->assertInstanceOf('\OCP\IImage', $img); + unset($img); + } + + public function testValid() { + $img = new Imagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertTrue($img->valid()); + + $text = base64_encode("Lorem ipsum dolor sir amet …"); + $img = new Imagick(); + $img->loadFromBase64($text); + $this->assertFalse($img->valid()); + + $img = new Imagick(); + $this->assertFalse($img->valid()); + } + + public function testMimeType() { + $img = new Imagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertEquals('image/png', $img->mimeType()); + + $img = new Imagick(); + $this->assertEquals('', $img->mimeType()); + + $img = new Imagick(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertEquals('image/jpeg', $img->mimeType()); + + $img = new Imagick(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertEquals('image/gif', $img->mimeType()); + } + + public function testWidth() { + $img = new Imagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertEquals(128, $img->width()); + + $img = new Imagick(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertEquals(1680, $img->width()); + + $img = new Imagick(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertEquals(64, $img->width()); + + $img = new Imagick(); + $this->assertEquals(-1, $img->width()); + } + + public function testHeight() { + $img = new Imagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertEquals(128, $img->height()); + + $img = new Imagick(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertEquals(1050, $img->height()); + + $img = new Imagick(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertEquals(64, $img->height()); + + $img = new Imagick(); + $this->assertEquals(-1, $img->height()); + } + + public function testSave() { + $img = new Imagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $img->resize(16); + $img->save(OC::$SERVERROOT.'/tests/data/testimage2.png'); + $this->assertEquals(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage2.png'), $img->data()); + + $img = new Imagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.jpg'); + $img->resize(128); + $img->save(OC::$SERVERROOT.'/tests/data/testimage2.jpg'); + $this->assertEquals(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage2.jpg'), $img->data()); + } + + public function testData() { + $img = new Imagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $raw = imagecreatefromstring(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.png')); + // Preserve transparency + imagealphablending($raw, true); + imagesavealpha($raw, true); + ob_start(); + imagepng($raw); + $expected = ob_get_clean(); + $this->assertEquals($expected, $img->data()); + + $config = $this->createMock(IConfig::class); + $config->expects($this->once()) + ->method('getAppValue') + ->with('preview', 'jpeg_quality', 90) + ->willReturn(null); + $img = new Imagick(null, null, $config); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.jpg'); + $raw = imagecreatefromstring(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + ob_start(); + imagejpeg($raw); + $expected = ob_get_clean(); + $this->assertEquals($expected, $img->data()); + + $img = new Imagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.gif'); + $raw = imagecreatefromstring(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif')); + ob_start(); + imagegif($raw); + $expected = ob_get_clean(); + $this->assertEquals($expected, $img->data()); + } + + public function testDataNoResource() { + $img = new Imagick(); + $this->assertNull($img->data()); + } + + /** + * @depends testData + */ + public function testToString() { + $img = new Imagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $expected = base64_encode($img->data()); + $this->assertEquals($expected, (string)$img); + + $img = new Imagick(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $expected = base64_encode($img->data()); + $this->assertEquals($expected, (string)$img); + + $img = new Imagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.gif'); + $expected = base64_encode($img->data()); + $this->assertEquals($expected, (string)$img); + } + + public function testResize() { + $img = new Imagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertTrue($img->resize(32)); + $this->assertEquals(32, $img->width()); + $this->assertEquals(32, $img->height()); + + $img = new Imagick(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertTrue($img->resize(840)); + $this->assertEquals(840, $img->width()); + $this->assertEquals(525, $img->height()); + + $img = new Imagick(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertTrue($img->resize(100)); + $this->assertEquals(100, $img->width()); + $this->assertEquals(100, $img->height()); + } + + public function testPreciseResize() { + $img = new Imagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertTrue($img->preciseResize(128, 512)); + $this->assertEquals(128, $img->width()); + $this->assertEquals(512, $img->height()); + + $img = new Imagick(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertTrue($img->preciseResize(64, 840)); + $this->assertEquals(64, $img->width()); + $this->assertEquals(840, $img->height()); + + $img = new Imagick(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertTrue($img->preciseResize(1000, 1337)); + $this->assertEquals(1000, $img->width()); + $this->assertEquals(1337, $img->height()); + } + + public function testCenterCrop() { + $img = new Imagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $img->centerCrop(); + $this->assertEquals(128, $img->width()); + $this->assertEquals(128, $img->height()); + + $img = new Imagick(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $img->centerCrop(); + $this->assertEquals(1050, $img->width()); + $this->assertEquals(1050, $img->height()); + + $img = new Imagick(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $img->centerCrop(512); + $this->assertEquals(512, $img->width()); + $this->assertEquals(512, $img->height()); + } + + public function testCrop() { + $img = new Imagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertTrue($img->crop(0, 0, 50, 20)); + $this->assertEquals(50, $img->width()); + $this->assertEquals(20, $img->height()); + + $img = new Imagick(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertTrue($img->crop(500, 700, 550, 300)); + $this->assertEquals(550, $img->width()); + $this->assertEquals(300, $img->height()); + + $img = new Imagick(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertTrue($img->crop(10, 10, 15, 15)); + $this->assertEquals(15, $img->width()); + $this->assertEquals(15, $img->height()); + } + + public static function sampleProvider() { + return [ + ['testimage.png', [200, 100], [100, 100]], + ['testimage.jpg', [840, 840], [840, 525]], + ['testimage.gif', [200, 250], [200, 200]] + ]; + } + + /** + * @dataProvider sampleProvider + * + * @param string $filename + * @param int[] $asked + * @param int[] $expected + */ + public function testFitIn($filename, $asked, $expected) { + $img = new Imagick(); + $img->loadFromFile(OC::$SERVERROOT . '/tests/data/' . $filename); + $this->assertTrue($img->fitIn($asked[0], $asked[1])); + $this->assertEquals($expected[0], $img->width()); + $this->assertEquals($expected[1], $img->height()); + } + + public static function sampleFilenamesProvider() { + return [ + ['testimage.png'], + ['testimage.jpg'], + ['testimage.gif'] + ]; + } + + /** + * Image should not be resized if it's already smaller than what is required + * + * @dataProvider sampleFilenamesProvider + * + * @param string $filename + */ + public function testScaleDownToFitWhenSmallerAlready($filename) { + $img = new Imagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/' . $filename); + $currentWidth = $img->width(); + $currentHeight = $img->height(); + // We pick something larger than the image we want to scale down + $this->assertFalse($img->scaleDownToFit(4000, 4000)); + // The dimensions of the image should not have changed since it's smaller already + $resizedWidth = $img->width(); + $resizedHeight = $img->height(); + $this->assertEquals( + $currentWidth, $img->width(), "currentWidth $currentWidth resizedWidth $resizedWidth \n" + ); + $this->assertEquals( + $currentHeight, $img->height(), + "currentHeight $currentHeight resizedHeight $resizedHeight \n" + ); + } + + public static function largeSampleProvider() { + return [ + ['testimage.png', [200, 100], [100, 100]], + ['testimage.jpg', [840, 840], [840, 525]], + ]; + } + + /** + * @dataProvider largeSampleProvider + * + * @param string $filename + * @param int[] $asked + * @param int[] $expected + */ + public function testScaleDownWhenBigger($filename, $asked, $expected) { + $img = new Imagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/' . $filename); + //$this->assertTrue($img->scaleDownToFit($asked[0], $asked[1])); + $img->scaleDownToFit($asked[0], $asked[1]); + $this->assertEquals($expected[0], $img->width()); + $this->assertEquals($expected[1], $img->height()); + } + + public function convertDataProvider() { + return [ + [ 'image/gif'], + [ 'image/jpeg'], + [ 'image/png'], + ]; + } + + /** + * @dataProvider convertDataProvider + */ + public function testConvert($mimeType) { + $img = new Imagick(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $tempFile = tempnam(sys_get_temp_dir(), 'img-test'); + + $img->save($tempFile, $mimeType); + $this->assertEquals($mimeType, image_type_to_mime_type(exif_imagetype($tempFile))); + } +} From 461286c5978687b9003393af2296d0a669851928 Mon Sep 17 00:00:00 2001 From: J0WI Date: Fri, 2 Sep 2022 16:30:08 +0200 Subject: [PATCH 2/3] Use _write() in data() Signed-off-by: J0WI --- lib/private/Image/Gd.php | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/lib/private/Image/Gd.php b/lib/private/Image/Gd.php index faf66cb729854..229189a7200ef 100644 --- a/lib/private/Image/Gd.php +++ b/lib/private/Image/Gd.php @@ -144,6 +144,7 @@ protected function _write($filePath = null, $mimeType = null): bool { $retVal = imagebmp($this->resource, $filePath); break; default: + $this->logger->info(__METHOD__ . '(): Could not guess mime-type, defaulting to png', ['app' => 'core']); $retVal = imagepng($this->resource, $filePath); } return $retVal; @@ -174,26 +175,7 @@ public function data(): ?string { return null; } ob_start(); - switch ($this->mimeType) { - case "image/png": - $res = imagepng($this->resource); - break; - case "image/jpeg": - $quality = $this->getJpegQuality(); - if ($quality !== null) { - $res = imagejpeg($this->resource, null, $quality); - } else { - $res = imagejpeg($this->resource); - } - break; - case "image/gif": - $res = imagegif($this->resource); - break; - default: - $res = imagepng($this->resource); - $this->logger->info(__METHOD__ . '(): Could not guess mime-type, defaulting to png', ['app' => 'core']); - break; - } + $res = $this->_write(null, $this->mimeType); if (!$res) { $this->logger->error(__METHOD__ . '(): Error getting image data.', ['app' => 'core']); } From 6f7c1bb6432816c1923f9df69e0fb1c9d707db94 Mon Sep 17 00:00:00 2001 From: J0WI Date: Sun, 1 Aug 2021 23:20:53 +0200 Subject: [PATCH 3/3] Add image backend for Vips Signed-off-by: J0WI --- lib/private/Image/Vips.php | 447 +++++++++++++++++++++++++++++++++++++ tests/lib/Image/Vips.php | 375 +++++++++++++++++++++++++++++++ 2 files changed, 822 insertions(+) create mode 100644 lib/private/Image/Vips.php create mode 100644 tests/lib/Image/Vips.php diff --git a/lib/private/Image/Vips.php b/lib/private/Image/Vips.php new file mode 100644 index 0000000000000..f75360484d8db --- /dev/null +++ b/lib/private/Image/Vips.php @@ -0,0 +1,447 @@ + + * + */ +namespace OC\Image; + +use OCP\IImage; +use Jcupitt\Vips\Image as VipsImage; +use Jcupitt\Vips\Direction; +use Jcupitt\Vips\Exception as VipsException; + +/** + * Class for basic image manipulation using libvips + */ +class Vips extends Common { + + /** + * Get the corresponding imageType + * https://github.com/libvips/php-vips/blob/v1.0.8/src/Image.php#L512 + */ + private function loaderToImageType(string $loader): int { + switch ($loader) { + case 'VipsForeignLoadGifFile': + case 'VipsForeignLoadGifBuffer': + return IMAGETYPE_GIF; + break; + case 'VipsForeignLoadPng': + case 'VipsForeignLoadPngBuffer': + return IMAGETYPE_PNG; + break; + case 'VipsForeignLoadJpegFile': + case 'VipsForeignLoadJpegBuffer': + return IMAGETYPE_JPEG; + break; + case 'VipsForeignLoadWebpFile': + case 'VipsForeignLoadWebpBuffer': + return IMAGETYPE_WEBP; + break; + default: + throw new \Exception(__METHOD__ . '(): "' . $loader . '" is not supported.'); + } + } + + /** + * @inheritDoc + */ + public function valid(): bool { + if (is_object($this->resource) && $this->resource instanceof VipsImage) { + return true; + } + + return false; + } + + /** + * @inheritDoc + */ + public function width(): int { + return $this->resource->width; + } + + /** + * @inheritDoc + */ + public function height(): int { + return $this->resource->height; + } + + /** + * @inheritDoc + */ + protected function _write($filePath = null, $mimeType = null): bool { + try { + $imageType = $this->imageType; + if ($mimeType !== null) { + switch ($mimeType) { + case 'image/gif': + $imageType = IMAGETYPE_GIF; + break; + case 'image/jpeg': + $imageType = IMAGETYPE_JPEG; + break; + case 'image/png': + $imageType = IMAGETYPE_PNG; + break; + case 'image/x-xbitmap': + $imageType = IMAGETYPE_XBM; + break; + case 'image/bmp': + case 'image/x-ms-bmp': + $imageType = IMAGETYPE_BMP; + break; + default: + throw new \Exception(__METHOD__ . '(): "' . $mimeType . '" is not supported when forcing a specific output format'); + } + } + + $options = []; + switch ($imageType) { + case IMAGETYPE_JPEG: + $options = ['strip' => true, 'Q' => $this->getJpegQuality(), 'interlace' => true]; + break; + case IMAGETYPE_PNG: + $options = ['strip' => true, 'compression' => 7]; + break; + default: + break; + } + $this->resource->writeToFile($filePath, $options); + return true; + } catch (VipsException $e) { + $this->logger->error(__METHOD__ . '(): Error wrtinig image.', ['app' => 'core']); + return false; + } + } + + /** + * @inheritDoc + */ + public function setResource($resource): void { + if (is_object($resource) && $resource instanceof VipsImage) { + $this->resource = $resource; + return; + } + throw new \InvalidArgumentException('Supplied resource is not of type "Vips".'); + } + + /** + * @inheritDoc + */ + public function data(): ?string { + if (!$this->valid()) { + return null; + } + + try { + $extension = '.png'; + $options = []; + switch ($this->mimeType) { + case "image/gif": + $extension = '.gif'; + break; + case 'image/jpeg': + $extension = '.jpg'; + $options = ['strip' => true, 'Q' => $this->getJpegQuality(), 'interlace' => true]; + break; + case 'image/png': + $extension = '.png'; + $options = ['strip' => true, 'compression' => 7]; + break; + default: + $this->logger->info(__METHOD__ . '(): Could not guess mime-type', ['app' => 'core']); + break; + } + return $this->resource->writeToBuffer($extension, $options); + } catch (VipsException $e) { + $this->logger->error(__METHOD__ . '(): Error getting image data.', ['app' => 'core']); + return null; + } + } + + /** + * @inheritDoc + */ + public function fixOrientation(): bool { + $o = $this->getOrientation(); + $this->logger->debug(__METHOD__ . '() Orientation: ' . $o, ['app' => 'core']); + try { + $rotate = 0; + $flip = false; + switch ($o) { + case -1: + return false; //Nothing to fix + case 1: + $rotate = 0; + break; + case 2: + $rotate = 0; + $flip = true; + break; + case 3: + $rotate = 180; + break; + case 4: + $rotate = 180; + $flip = true; + break; + case 5: + $rotate = 90; + $flip = true; + break; + case 6: + $rotate = 270; + break; + case 7: + $rotate = 270; + $flip = true; + break; + case 8: + $rotate = 90; + break; + } + + if ($flip) { + $this->resource = $this->resource->flip(Direction::HORIZONTAL); + } + if ($rotate) { // case 0 + switch ($rotate) { + case 90: + $this->resource = $this->resource->rot90(); + break; + case 180: + $this->resource = $this->resource->rot180(); + break; + case 270: + $this->resource = $this->resource->rot270(); + break; + default: + assert(false); + } + } + } catch (VipsException $e) { + $this->logger->warning(__METHOD__ . '(): ' . $e); + return false; + } + return true; + } + + /** + * @inheritDoc + */ + public function loadFromFile($imagePath = false) { + // exif_imagetype throws "read error!" if file is less than 12 byte + if (is_bool($imagePath) || !@is_file($imagePath) || !file_exists($imagePath) || filesize($imagePath) < 12 || !is_readable($imagePath)) { + return false; + } + + try { + $loader = VipsImage::findLoad($imagePath); + $this->resource = VipsImage::newFromFile($imagePath); + if ($this->valid()) { + $this->imageType = $this->loaderToImageType($loader); + // TODO: still depends on GD + $this->mimeType = image_type_to_mime_type($this->imageType); + $this->filePath = $imagePath; + } + return $this->resource; + } catch (VipsException $e) { + $this->logger->error(__METHOD__ . '(): Error getting image data.', ['app' => 'core']); + return false; + } + } + + /** + * @inheritDoc + */ + public function loadFromFileHandle($handle) { + $contents = stream_get_contents($handle); + if ($this->loadFromData($contents)) { + return $this->resource; + } + return false; + } + + /** + * @inheritDoc + */ + public function loadFromData(string $str) { + try { + $loader = VipsImage::findLoadBuffer($str); + $this->resource = VipsImage::newFromBuffer($str); + if ($this->valid()) { + $this->imageType = $this->loaderToImageType($loader); + // TODO: still depends on GD + $this->mimeType = image_type_to_mime_type($this->imageType); + } + return $this->resource; + } catch (VipsException $e) { + $this->logger->error(__METHOD__ . '(): Error getting image data.', ['app' => 'core']); + return false; + } + } + + /** + * @inheritDoc + */ + public function resize(int $maxSize): bool { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + return $this->fitIn($maxSize, $maxSize); + } + + /** + * @inheritDoc + */ + public function resizeNew(int $maxSize): IImage { + return $this->resource->thumbnail_image($maxSize, ['height' => $maxSize]); + } + + /** + * @inheritDoc + */ + public function preciseResize(int $width, int $height): bool { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + try { + $this->resource = $this->preciseResizeNew($width, $height); + } catch (VipsException $e) { + $this->logger->warning(__METHOD__ . '(): ' . $e); + return false; + } + return true; + } + + /** + * @inheritDoc + */ + public function preciseResizeNew(int $width, int $height): IImage { + return $this->resource->resize($width / $this->width(), ['vscale' => $height / $this->height()]); + } + + /** + * @inheritDoc + */ + public function centerCrop(int $size = 0): bool { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + $widthOrig = $this->width(); + $heightOrig = $this->height(); + if ($widthOrig === $heightOrig and $size === 0) { + return true; + } + + try { + $this->resource->cropThumbnailImage($size, $size); + } catch (VipsException $e) { + $this->logger->warning(__METHOD__ . '(): ' . $e); + return false; + } + return true; + } + + /** + * @inheritDoc + */ + public function crop(int $x, int $y, int $w, int $h): bool { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + try { + $this->resource = $this->cropNew($x, $y, $w, $h); + } catch (VipsException $e) { + $this->logger->warning(__METHOD__ . '(): ' . $e); + return false; + } + return true; + } + + /** + * @inheritDoc + */ + public function cropNew(int $x, int $y, int $w, int $h): IImage { + return $this->resource->crop($x, $y, $w, $h); + } + + /** + * @inheritDoc + */ + public function fitIn(int $maxWidth, int $maxHeight): bool { + if (!$this->valid()) { + $this->logger->error(__METHOD__ . '(): No image loaded', ['app' => 'core']); + return false; + } + + try { + $this->resource = $this->resource->thumbnail_image($maxWidth, ['height' => $maxHeight]); + } catch (VipsException $e) { + $this->logger->warning(__METHOD__ . '(): ' . $e); + return false; + } + return true; + } + + /** + * @inheritDoc + */ + public function cropCopy(int $x, int $y, int $w, int $h): IImage { + return $this->cropNew($x, $y, $w, $h); + } + + /** + * @inheritDoc + */ + public function preciseResizeCopy(int $width, int $height): IImage { + return $this->preciseResizeNew($width, $height); + } + + /** + * @inheritDoc + */ + public function resizeCopy(int $maxSize): IImage { + return $this->resizeNew($width, $height); + } + + /** + * @inheritDoc + */ + public function destroy(): void { + if ($this->valid()) { + $this->resource->clear(); + } + unset($this->resource); + unset($this->mimeType); + unset($this->filePath); + unset($this->fileInfo); + unset($this->exif); + } +} diff --git a/tests/lib/Image/Vips.php b/tests/lib/Image/Vips.php new file mode 100644 index 0000000000000..608bb63cc728e --- /dev/null +++ b/tests/lib/Image/Vips.php @@ -0,0 +1,375 @@ + + * This file is licensed under the Affero General Public License version 3 or + * later. + * See the COPYING-README file. + */ + +namespace Test; + +use OC; +use OCP\IConfig; +use OCP\Image\Vips; + +class ImageVipsTest extends \Test\TestCase { + protected function setUp(): void { + if (!extension_loaded('vips')) { + $this->markTestSkipped('Vips module not available. Skipping tests'); + } else { + parent::setUp(); + } + } + + public static function tearDownAfterClass(): void { + @unlink(OC::$SERVERROOT.'/tests/data/testimage2.png'); + @unlink(OC::$SERVERROOT.'/tests/data/testimage2.jpg'); + + parent::tearDownAfterClass(); + } + + public function testConstructDestruct() { + $img = new Vips(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertInstanceOf('Vips', $img); + $this->assertInstanceOf('\OCP\IImage', $img); + unset($img); + + $imgcreate = imagecreatefromjpeg(OC::$SERVERROOT.'/tests/data/testimage.jpg'); + $img = new Vips(); + $img->setResource($imgcreate); + $this->assertInstanceOf('Vips', $img); + $this->assertInstanceOf('\OCP\IImage', $img); + unset($img); + + $base64 = base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif')); + $img = new Vips(); + $img->loadFromBase64($base64); + $this->assertInstanceOf('Vips', $img); + $this->assertInstanceOf('\OCP\IImage', $img); + unset($img); + + $img = new Vips(); + $this->assertInstanceOf('Vips', $img); + $this->assertInstanceOf('\OCP\IImage', $img); + unset($img); + } + + public function testValid() { + $img = new Vips(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertTrue($img->valid()); + + $text = base64_encode("Lorem ipsum dolor sir amet …"); + $img = new Vips(); + $img->loadFromBase64($text); + $this->assertFalse($img->valid()); + + $img = new Vips(); + $this->assertFalse($img->valid()); + } + + public function testMimeType() { + $img = new Vips(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertEquals('image/png', $img->mimeType()); + + $img = new Vips(); + $this->assertEquals('', $img->mimeType()); + + $img = new Vips(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertEquals('image/jpeg', $img->mimeType()); + + $img = new Vips(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertEquals('image/gif', $img->mimeType()); + } + + public function testWidth() { + $img = new Vips(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertEquals(128, $img->width()); + + $img = new Vips(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertEquals(1680, $img->width()); + + $img = new Vips(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertEquals(64, $img->width()); + + $img = new Vips(); + $this->assertEquals(-1, $img->width()); + } + + public function testHeight() { + $img = new Vips(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertEquals(128, $img->height()); + + $img = new Vips(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertEquals(1050, $img->height()); + + $img = new Vips(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertEquals(64, $img->height()); + + $img = new Vips(); + $this->assertEquals(-1, $img->height()); + } + + public function testSave() { + $img = new Vips(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $img->resize(16); + $img->save(OC::$SERVERROOT.'/tests/data/testimage2.png'); + $this->assertEquals(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage2.png'), $img->data()); + + $img = new Vips(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.jpg'); + $img->resize(128); + $img->save(OC::$SERVERROOT.'/tests/data/testimage2.jpg'); + $this->assertEquals(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage2.jpg'), $img->data()); + } + + public function testData() { + $img = new Vips(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $raw = imagecreatefromstring(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.png')); + // Preserve transparency + imagealphablending($raw, true); + imagesavealpha($raw, true); + ob_start(); + imagepng($raw); + $expected = ob_get_clean(); + $this->assertEquals($expected, $img->data()); + + $config = $this->createMock(IConfig::class); + $config->expects($this->once()) + ->method('getAppValue') + ->with('preview', 'jpeg_quality', 90) + ->willReturn(null); + $img = new Vips(null, null, $config); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.jpg'); + $raw = imagecreatefromstring(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + ob_start(); + imagejpeg($raw); + $expected = ob_get_clean(); + $this->assertEquals($expected, $img->data()); + + $img = new Vips(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.gif'); + $raw = imagecreatefromstring(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif')); + ob_start(); + imagegif($raw); + $expected = ob_get_clean(); + $this->assertEquals($expected, $img->data()); + } + + public function testDataNoResource() { + $img = new Vips(); + $this->assertNull($img->data()); + } + + /** + * @depends testData + */ + public function testToString() { + $img = new Vips(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $expected = base64_encode($img->data()); + $this->assertEquals($expected, (string)$img); + + $img = new Vips(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $expected = base64_encode($img->data()); + $this->assertEquals($expected, (string)$img); + + $img = new Vips(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.gif'); + $expected = base64_encode($img->data()); + $this->assertEquals($expected, (string)$img); + } + + public function testResize() { + $img = new Vips(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertTrue($img->resize(32)); + $this->assertEquals(32, $img->width()); + $this->assertEquals(32, $img->height()); + + $img = new Vips(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertTrue($img->resize(840)); + $this->assertEquals(840, $img->width()); + $this->assertEquals(525, $img->height()); + + $img = new Vips(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertTrue($img->resize(100)); + $this->assertEquals(100, $img->width()); + $this->assertEquals(100, $img->height()); + } + + public function testPreciseResize() { + $img = new Vips(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertTrue($img->preciseResize(128, 512)); + $this->assertEquals(128, $img->width()); + $this->assertEquals(512, $img->height()); + + $img = new Vips(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertTrue($img->preciseResize(64, 840)); + $this->assertEquals(64, $img->width()); + $this->assertEquals(840, $img->height()); + + $img = new Vips(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertTrue($img->preciseResize(1000, 1337)); + $this->assertEquals(1000, $img->width()); + $this->assertEquals(1337, $img->height()); + } + + public function testCenterCrop() { + $img = new Vips(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $img->centerCrop(); + $this->assertEquals(128, $img->width()); + $this->assertEquals(128, $img->height()); + + $img = new Vips(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $img->centerCrop(); + $this->assertEquals(1050, $img->width()); + $this->assertEquals(1050, $img->height()); + + $img = new Vips(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $img->centerCrop(512); + $this->assertEquals(512, $img->width()); + $this->assertEquals(512, $img->height()); + } + + public function testCrop() { + $img = new Vips(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $this->assertTrue($img->crop(0, 0, 50, 20)); + $this->assertEquals(50, $img->width()); + $this->assertEquals(20, $img->height()); + + $img = new Vips(); + $img->loadFromData(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.jpg')); + $this->assertTrue($img->crop(500, 700, 550, 300)); + $this->assertEquals(550, $img->width()); + $this->assertEquals(300, $img->height()); + + $img = new Vips(); + $img->loadFromBase64(base64_encode(file_get_contents(OC::$SERVERROOT.'/tests/data/testimage.gif'))); + $this->assertTrue($img->crop(10, 10, 15, 15)); + $this->assertEquals(15, $img->width()); + $this->assertEquals(15, $img->height()); + } + + public static function sampleProvider() { + return [ + ['testimage.png', [200, 100], [100, 100]], + ['testimage.jpg', [840, 840], [840, 525]], + ['testimage.gif', [200, 250], [200, 200]] + ]; + } + + /** + * @dataProvider sampleProvider + * + * @param string $filename + * @param int[] $asked + * @param int[] $expected + */ + public function testFitIn($filename, $asked, $expected) { + $img = new Vips(); + $img->loadFromFile(OC::$SERVERROOT . '/tests/data/' . $filename); + $this->assertTrue($img->fitIn($asked[0], $asked[1])); + $this->assertEquals($expected[0], $img->width()); + $this->assertEquals($expected[1], $img->height()); + } + + public static function sampleFilenamesProvider() { + return [ + ['testimage.png'], + ['testimage.jpg'], + ['testimage.gif'] + ]; + } + + /** + * Image should not be resized if it's already smaller than what is required + * + * @dataProvider sampleFilenamesProvider + * + * @param string $filename + */ + public function testScaleDownToFitWhenSmallerAlready($filename) { + $img = new Vips(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/' . $filename); + $currentWidth = $img->width(); + $currentHeight = $img->height(); + // We pick something larger than the image we want to scale down + $this->assertFalse($img->scaleDownToFit(4000, 4000)); + // The dimensions of the image should not have changed since it's smaller already + $resizedWidth = $img->width(); + $resizedHeight = $img->height(); + $this->assertEquals( + $currentWidth, $img->width(), "currentWidth $currentWidth resizedWidth $resizedWidth \n" + ); + $this->assertEquals( + $currentHeight, $img->height(), + "currentHeight $currentHeight resizedHeight $resizedHeight \n" + ); + } + + public static function largeSampleProvider() { + return [ + ['testimage.png', [200, 100], [100, 100]], + ['testimage.jpg', [840, 840], [840, 525]], + ]; + } + + /** + * @dataProvider largeSampleProvider + * + * @param string $filename + * @param int[] $asked + * @param int[] $expected + */ + public function testScaleDownWhenBigger($filename, $asked, $expected) { + $img = new Vips(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/' . $filename); + //$this->assertTrue($img->scaleDownToFit($asked[0], $asked[1])); + $img->scaleDownToFit($asked[0], $asked[1]); + $this->assertEquals($expected[0], $img->width()); + $this->assertEquals($expected[1], $img->height()); + } + + public function convertDataProvider() { + return [ + [ 'image/gif'], + [ 'image/jpeg'], + [ 'image/png'], + ]; + } + + /** + * @dataProvider convertDataProvider + */ + public function testConvert($mimeType) { + $img = new Vips(); + $img->loadFromFile(OC::$SERVERROOT.'/tests/data/testimage.png'); + $tempFile = tempnam(sys_get_temp_dir(), 'img-test'); + + $img->save($tempFile, $mimeType); + $this->assertEquals($mimeType, image_type_to_mime_type(exif_imagetype($tempFile))); + } +}