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))); + } +}