<?php
/**
- * @file src/Object/Image.php
- * @brief This file contains the Image class for image processing
+ * @copyright Copyright (C) 2010-2023, the Friendica project
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * 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
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
*/
+
namespace Friendica\Object;
use Exception;
-use Friendica\Core\Cache;
-use Friendica\Core\Config;
-use Friendica\Core\Logger;
-use Friendica\Core\System;
-use Friendica\Util\Network;
+use Friendica\DI;
+use Friendica\Util\Images;
use Imagick;
+use ImagickDraw;
use ImagickPixel;
+use GDImage;
+use kornrunner\Blurhash\Blurhash;
/**
* Class to handle images
*/
class Image
{
- /** @var Imagick|resource */
+ /** @var GDImage|Imagick|resource */
private $image;
/*
private $types;
/**
- * @brief supported mimetypes and corresponding file extensions
- * @return array
- */
- public static function supportedTypes()
- {
- if (class_exists('Imagick')) {
- // Imagick::queryFormats won't help us a lot there...
- // At least, not yet, other parts of friendica uses this array
- $t = [
- 'image/jpeg' => 'jpg',
- 'image/png' => 'png',
- 'image/gif' => 'gif'
- ];
- } else {
- $t = [];
- $t['image/jpeg'] ='jpg';
- if (imagetypes() & IMG_PNG) {
- $t['image/png'] = 'png';
- }
- }
-
- return $t;
- }
-
- /**
- * @brief Constructor
- * @param string $data
- * @param boolean $type optional, default null
+ * Constructor
+ *
+ * @param string $data Image data
+ * @param string $type optional, default null
* @throws \Friendica\Network\HTTPException\InternalServerErrorException
* @throws \ImagickException
*/
- public function __construct($data, $type = null)
+ public function __construct(string $data, string $type = null)
{
$this->imagick = class_exists('Imagick');
- $this->types = static::supportedTypes();
+ $this->types = Images::supportedTypes();
if (!array_key_exists($type, $this->types)) {
- $type='image/jpeg';
+ $type = 'image/jpeg';
}
$this->type = $type;
- if ($this->isImagick() && $this->loadData($data)) {
- return true;
+ if ($this->isImagick() && (empty($data) || $this->loadData($data))) {
+ $this->valid = !empty($data);
+ return;
} else {
// Failed to load with Imagick, fallback
$this->imagick = false;
}
- return $this->loadData($data);
+ $this->loadData($data);
}
/**
- * @brief Destructor
+ * Destructor
+ *
* @return void
*/
public function __destruct()
}
/**
- * @brief Maps Mime types to Imagick formats
- * @return array With with image formats (mime type as key)
- */
- public static function getFormatsMap()
- {
- $m = [
- 'image/jpeg' => 'JPG',
- 'image/png' => 'PNG',
- 'image/gif' => 'GIF'
- ];
- return $m;
- }
-
- /**
- * @param string $data data
- * @return boolean
+ * Loads image data into handler class
+ *
+ * @param string $data Image data
+ * @return boolean Success
* @throws \Friendica\Network\HTTPException\InternalServerErrorException
* @throws \ImagickException
*/
- private function loadData($data)
+ private function loadData(string $data): bool
{
if ($this->isImagick()) {
$this->image = new Imagick();
/*
* Setup the image to the format it will be saved to
*/
- $map = self::getFormatsMap();
+ $map = Images::getFormatsMap();
$format = $map[$this->type];
$this->image->setFormat($format);
// Always coalesce, if it is not a multi-frame image it won't hurt anyway
- $this->image = $this->image->coalesceImages();
+ try {
+ $this->image = $this->image->coalesceImages();
+ } catch (Exception $e) {
+ return false;
+ }
/*
* setup the compression here, so we'll do it only once
*/
switch ($this->getType()) {
- case "image/png":
- $quality = Config::get('system', 'png_quality');
- if ((! $quality) || ($quality > 9)) {
- $quality = PNG_QUALITY;
- }
+ case 'image/png':
+ $quality = DI::config()->get('system', 'png_quality');
/*
* From http://www.imagemagick.org/script/command-line-options.php#quality:
*
$quality = $quality * 10;
$this->image->setCompressionQuality($quality);
break;
- case "image/jpeg":
- $quality = Config::get('system', 'jpeg_quality');
- if ((! $quality) || ($quality > 100)) {
- $quality = JPEG_QUALITY;
- }
+
+ case 'image/jpg':
+ case 'image/jpeg':
+ $quality = DI::config()->get('system', 'jpeg_quality');
$this->image->setCompressionQuality($quality);
}
- // The 'width' and 'height' properties are only used by non-Imagick routines.
$this->width = $this->image->getImageWidth();
$this->height = $this->image->getImageHeight();
- $this->valid = true;
+ $this->valid = !empty($this->image);
- return true;
+ return $this->valid;
}
$this->valid = false;
- $this->image = @imagecreatefromstring($data);
- if ($this->image !== false) {
- $this->width = imagesx($this->image);
- $this->height = imagesy($this->image);
- $this->valid = true;
- imagealphablending($this->image, false);
- imagesavealpha($this->image, true);
-
- return true;
+ try {
+ $this->image = @imagecreatefromstring($data);
+ if ($this->image !== false) {
+ $this->width = imagesx($this->image);
+ $this->height = imagesy($this->image);
+ $this->valid = true;
+ imagealphablending($this->image, false);
+ imagesavealpha($this->image, true);
+
+ return true;
+ }
+ } catch (\Throwable $error) {
+ /** @see https://github.com/php/doc-en/commit/d09a881a8e9059d11e756ee59d75bf404d6941ed */
+ if (strstr($error->getMessage(), "gd-webp cannot allocate temporary buffer")) {
+ DI::logger()->notice('Image is probably animated and therefore unsupported', ['error' => $error]);
+ } else {
+ DI::logger()->warning('Unexpected throwable.', ['error' => $error]);
+ }
}
return false;
/**
* @return boolean
*/
- public function isValid()
+ public function isValid(): bool
{
if ($this->isImagick()) {
- return ($this->image !== false);
+ return !empty($this->image);
}
return $this->valid;
}
return false;
}
- if ($this->isImagick()) {
- return $this->image->getImageWidth();
- }
return $this->width;
}
return false;
}
- if ($this->isImagick()) {
- return $this->image->getImageHeight();
- }
return $this->height;
}
}
if ($this->isImagick()) {
- /* Clean it */
- $this->image = $this->image->deconstructImages();
- return $this->image;
+ try {
+ /* Clean it */
+ $this->image = $this->image->deconstructImages();
+ return $this->image;
+ } catch (Exception $e) {
+ return false;
+ }
}
return $this->image;
}
}
/**
+ * Scales image down
+ *
* @param integer $max max dimension
* @return mixed
*/
- public function scaleDown($max)
+ public function scaleDown(int $max)
{
if (!$this->isValid()) {
return false;
$width = $this->getWidth();
$height = $this->getHeight();
- if ((! $width)|| (! $height)) {
- return false;
- }
-
- if ($width > $max && $height > $max) {
- // very tall image (greater than 16:9)
- // constrain the width - let the height float.
-
- if ((($height * 9) / 16) > $width) {
- $dest_width = $max;
- $dest_height = intval(($height * $max) / $width);
- } elseif ($width > $height) {
- // else constrain both dimensions
- $dest_width = $max;
- $dest_height = intval(($height * $max) / $width);
- } else {
- $dest_width = intval(($width * $max) / $height);
- $dest_height = $max;
- }
+ $scale = Images::getScalingDimensions($width, $height, $max);
+ if ($scale) {
+ return $this->scale($scale['width'], $scale['height']);
} else {
- if ($width > $max) {
- $dest_width = $max;
- $dest_height = intval(($height * $max) / $width);
- } else {
- if ($height > $max) {
- // very tall image (greater than 16:9)
- // but width is OK - don't do anything
-
- if ((($height * 9) / 16) > $width) {
- $dest_width = $width;
- $dest_height = $height;
- } else {
- $dest_width = intval(($width * $max) / $height);
- $dest_height = $max;
- }
- } else {
- $dest_width = $width;
- $dest_height = $height;
- }
- }
+ return false;
}
- return $this->scale($dest_width, $dest_height);
}
/**
+ * Rotates image
+ *
* @param integer $degrees degrees to rotate image
* @return mixed
*/
- public function rotate($degrees)
+ public function rotate(int $degrees)
{
if (!$this->isValid()) {
return false;
do {
$this->image->rotateImage(new ImagickPixel(), -$degrees); // ImageMagick rotates in the opposite direction of imagerotate()
} while ($this->image->nextImage());
+
+ $this->width = $this->image->getImageWidth();
+ $this->height = $this->image->getImageHeight();
return;
}
}
/**
+ * Flips image
+ *
* @param boolean $horiz optional, default true
* @param boolean $vert optional, default false
* @return mixed
*/
- public function flip($horiz = true, $vert = false)
+ public function flip(bool $horiz = true, bool $vert = false)
{
if (!$this->isValid()) {
return false;
}
/**
- * @param string $filename filename
+ * Fixes orientation and maybe returns EXIF data (?)
+ *
+ * @param string $filename Filename
* @return mixed
*/
- public function orient($filename)
+ public function orient(string $filename)
{
if ($this->isImagick()) {
// based off comment on http://php.net/manual/en/imagick.getimageorientation.php
$orientation = $this->image->getImageOrientation();
switch ($orientation) {
case Imagick::ORIENTATION_BOTTOMRIGHT:
- $this->image->rotateimage("#000", 180);
+ $this->rotate(180);
break;
case Imagick::ORIENTATION_RIGHTTOP:
- $this->image->rotateimage("#000", 90);
+ $this->rotate(-90);
break;
case Imagick::ORIENTATION_LEFTBOTTOM:
- $this->image->rotateimage("#000", -90);
+ $this->rotate(90);
break;
}
break;
}
- // Logger::log('exif: ' . print_r($exif,true));
return $exif;
}
/**
- * @param integer $min minimum dimension
+ * Rescales image to minimum size
+ *
+ * @param integer $min Minimum dimension
* @return mixed
*/
- public function scaleUp($min)
+ public function scaleUp(int $min)
{
if (!$this->isValid()) {
return false;
}
/**
- * @param integer $dim dimension
+ * Scales image to square
+ *
+ * @param integer $dim Dimension
* @return mixed
*/
- public function scaleToSquare($dim)
+ public function scaleToSquare(int $dim)
{
if (!$this->isValid()) {
return false;
}
/**
- * @brief Scale image to target dimensions
+ * Scale image to target dimensions
*
- * @param int $dest_width
- * @param int $dest_height
- * @return boolean
+ * @param int $dest_width Destination width
+ * @param int $dest_height Destination height
+ * @return boolean Success
*/
- private function scale($dest_width, $dest_height)
+ private function scale(int $dest_width, int $dest_height): bool
{
if (!$this->isValid()) {
return false;
do {
// FIXME - implement horizontal bias for scaling as in following GD functions
// to allow very tall images to be constrained only horizontally.
- $this->image->scaleImage($dest_width, $dest_height);
+ try {
+ $this->image->scaleImage($dest_width, $dest_height);
+ } catch (Exception $e) {
+ // Imagick couldn't use the data
+ return false;
+ }
} while ($this->image->nextImage());
- // These may not be necessary anymore
$this->width = $this->image->getImageWidth();
$this->height = $this->image->getImageHeight();
} else {
}
/**
+ * Convert a GIF to a PNG to make it static
+ *
+ * @return void
+ */
+ public function toStatic()
+ {
+ if ($this->type != 'image/gif') {
+ return;
+ }
+
+ if ($this->isImagick()) {
+ $this->type == 'image/png';
+ $this->image->setFormat('png');
+ }
+ }
+
+ /**
+ * Crops image
+ *
* @param integer $max maximum
* @param integer $x x coordinate
* @param integer $y y coordinate
* @param integer $h height
* @return mixed
*/
- public function crop($max, $x, $y, $w, $h)
+ public function crop(int $max, int $x, int $y, int $w, int $h)
{
if (!$this->isValid()) {
return false;
if ($this->image) {
imagedestroy($this->image);
}
- $this->image = $dest;
+ $this->image = $dest;
$this->width = imagesx($this->image);
$this->height = imagesy($this->image);
- }
-
- /**
- * @param string $path file path
- * @return mixed
- * @throws \Friendica\Network\HTTPException\InternalServerErrorException
- */
- public function saveToFilePath($path)
- {
- if (!$this->isValid()) {
- return false;
- }
- $string = $this->asString();
-
- $a = \get_app();
-
- $stamp1 = microtime(true);
- file_put_contents($path, $string);
- $a->getProfiler()->saveTimestamp($stamp1, "file", System::callstack());
+ // All successful
+ return true;
}
/**
- * @brief Magic method allowing string casting of an Image object
+ * Magic method allowing string casting of an Image object
*
* Ex: $data = $Image->asString();
* can be replaced by
* @return string
* @throws \Friendica\Network\HTTPException\InternalServerErrorException
*/
- public function __toString() {
- return $this->asString();
+ public function __toString(): string
+ {
+ return (string) $this->asString();
}
/**
+ * Returns image as string or false on failure
+ *
* @return mixed
* @throws \Friendica\Network\HTTPException\InternalServerErrorException
*/
}
if ($this->isImagick()) {
- /* Clean it */
- $this->image = $this->image->deconstructImages();
- $string = $this->image->getImagesBlob();
- return $string;
+ try {
+ /* Clean it */
+ $this->image = $this->image->deconstructImages();
+ return $this->image->getImagesBlob();
+ } catch (Exception $e) {
+ return false;
+ }
}
- ob_start();
+ $stream = fopen('php://memory','r+');
// Enable interlacing
imageinterlace($this->image, true);
switch ($this->getType()) {
- case "image/png":
- $quality = Config::get('system', 'png_quality');
- if ((!$quality) || ($quality > 9)) {
- $quality = PNG_QUALITY;
- }
- imagepng($this->image, null, $quality);
+ case 'image/png':
+ $quality = DI::config()->get('system', 'png_quality');
+ imagepng($this->image, $stream, $quality);
break;
- case "image/jpeg":
- $quality = Config::get('system', 'jpeg_quality');
- if ((!$quality) || ($quality > 100)) {
- $quality = JPEG_QUALITY;
- }
- imagejpeg($this->image, null, $quality);
- }
- $string = ob_get_contents();
- ob_end_clean();
- return $string;
+ case 'image/jpeg':
+ case 'image/jpg':
+ $quality = DI::config()->get('system', 'jpeg_quality');
+ imagejpeg($this->image, $stream, $quality);
+ break;
+ }
+ rewind($stream);
+ return stream_get_contents($stream);
}
/**
- * Guess image mimetype from filename or from Content-Type header
- *
- * @param string $filename Image filename
- * @param boolean $fromcurl Check Content-Type header from curl request
- * @param string $header passed headers to take into account
+ * Create a blurhash out of a given image string
*
- * @return object
- * @throws \ImagickException
+ * @param string $img_str
+ * @return string
*/
- public static function guessType($filename, $fromcurl = false, $header = '')
+ public function getBlurHash(): string
{
- Logger::log('Image: guessType: '.$filename . ($fromcurl?' from curl headers':''), Logger::DEBUG);
- $type = null;
- if ($fromcurl) {
- $headers=[];
- $h = explode("\n", $header);
- foreach ($h as $l) {
- $data = array_map("trim", explode(":", trim($l), 2));
- if (count($data) > 1) {
- list($k,$v) = $data;
- $headers[$k] = $v;
- }
- }
- if (array_key_exists('Content-Type', $headers))
- $type = $headers['Content-Type'];
- }
- if (is_null($type)) {
- // Guessing from extension? Isn't that... dangerous?
- if (class_exists('Imagick') && file_exists($filename) && is_readable($filename)) {
- /**
- * Well, this not much better,
- * but at least it comes from the data inside the image,
- * we won't be tricked by a manipulated extension
- */
- $image = new Imagick($filename);
- $type = $image->getImageMimeType();
- $image->setInterlaceScheme(Imagick::INTERLACE_PLANE);
- } else {
- $ext = pathinfo($filename, PATHINFO_EXTENSION);
- $types = self::supportedTypes();
- $type = "image/jpeg";
- foreach ($types as $m => $e) {
- if ($ext == $e) {
- $type = $m;
- }
- }
- }
+ $image = New Image($this->asString());
+ if (empty($image) || !$this->isValid()) {
+ return '';
}
- Logger::log('Image: guessType: type='.$type, Logger::DEBUG);
- return $type;
- }
- /**
- * @param string $url url
- * @return object
- * @throws \Friendica\Network\HTTPException\InternalServerErrorException
- */
- public static function getInfoFromURL($url)
- {
- $data = [];
+ $width = $image->getWidth();
+ $height = $image->getHeight();
- if (empty($url)) {
- return $data;
+ if (max($width, $height) > 90) {
+ $image->scaleDown(90);
+ $width = $image->getWidth();
+ $height = $image->getHeight();
}
- $data = Cache::get($url);
-
- if (is_null($data) || !$data || !is_array($data)) {
- $img_str = Network::fetchUrl($url, true, 4);
-
- if (!$img_str) {
- return false;
- }
-
- $filesize = strlen($img_str);
+ if (empty($width) || empty($height)) {
+ return '';
+ }
- try {
- if (function_exists("getimagesizefromstring")) {
- $data = @getimagesizefromstring($img_str);
+ $pixels = [];
+ for ($y = 0; $y < $height; ++$y) {
+ $row = [];
+ for ($x = 0; $x < $width; ++$x) {
+ if ($image->isImagick()) {
+ try {
+ $colors = $image->image->getImagePixelColor($x, $y)->getColor();
+ } catch (\Throwable $th) {
+ return '';
+ }
+ $row[] = [$colors['r'], $colors['g'], $colors['b']];
} else {
- $tempfile = tempnam(get_temppath(), "cache");
-
- $a = \get_app();
- $stamp1 = microtime(true);
- file_put_contents($tempfile, $img_str);
- $a->getProfiler()->saveTimestamp($stamp1, "file", System::callstack());
-
- $data = getimagesize($tempfile);
- unlink($tempfile);
+ $index = imagecolorat($image->image, $x, $y);
+ $colors = @imagecolorsforindex($image->image, $index);
+ $row[] = [$colors['red'], $colors['green'], $colors['blue']];
}
- } catch (Exception $e) {
- return false;
- }
-
- if ($data) {
- $data["size"] = $filesize;
}
-
- Cache::set($url, $data);
+ $pixels[] = $row;
}
- return $data;
+ // The components define the amount of details (1 to 9).
+ $components_x = 9;
+ $components_y = 9;
+
+ return Blurhash::encode($pixels, $components_x, $components_y);
}
/**
- * @param integer $width width
- * @param integer $height height
- * @param integer $max max
- * @return array
+ * Create an image out of a blurhash
+ *
+ * @param string $blurhash
+ * @param integer $width
+ * @param integer $height
+ * @return void
*/
- public static function getScalingDimensions($width, $height, $max)
+ public function getFromBlurHash(string $blurhash, int $width, int $height)
{
- if ((!$width) || (!$height)) {
- return false;
- }
-
- if ($width > $max && $height > $max) {
- // very tall image (greater than 16:9)
- // constrain the width - let the height float.
+ $scaled = Images::getScalingDimensions($width, $height, 90);
+ $pixels = Blurhash::decode($blurhash, $scaled['width'], $scaled['height']);
- if ((($height * 9) / 16) > $width) {
- $dest_width = $max;
- $dest_height = intval(($height * $max) / $width);
- } elseif ($width > $height) {
- // else constrain both dimensions
- $dest_width = $max;
- $dest_height = intval(($height * $max) / $width);
- } else {
- $dest_width = intval(($width * $max) / $height);
- $dest_height = $max;
- }
+ if ($this->isImagick()) {
+ $this->image = new Imagick();
+ $draw = new ImagickDraw();
+ $this->image->newImage($scaled['width'], $scaled['height'], '', 'png');
} else {
- if ($width > $max) {
- $dest_width = $max;
- $dest_height = intval(($height * $max) / $width);
- } else {
- if ($height > $max) {
- // very tall image (greater than 16:9)
- // but width is OK - don't do anything
-
- if ((($height * 9) / 16) > $width) {
- $dest_width = $width;
- $dest_height = $height;
- } else {
- $dest_width = intval(($width * $max) / $height);
- $dest_height = $max;
- }
+ $this->image = imagecreatetruecolor($scaled['width'], $scaled['height']);
+ }
+
+ for ($y = 0; $y < $scaled['height']; ++$y) {
+ for ($x = 0; $x < $scaled['width']; ++$x) {
+ [$r, $g, $b] = $pixels[$y][$x];
+ if ($this->isImagick()) {
+ $draw->setFillColor("rgb($r, $g, $b)");
+ $draw->point($x, $y);
} else {
- $dest_width = $width;
- $dest_height = $height;
+ imagesetpixel($this->image, $x, $y, imagecolorallocate($this->image, $r, $g, $b));
}
}
}
- return ["width" => $dest_width, "height" => $dest_height];
+
+ if ($this->isImagick()) {
+ $this->image->drawImage($draw);
+ $this->width = $this->image->getImageWidth();
+ $this->height = $this->image->getImageHeight();
+ } else {
+ $this->width = imagesx($this->image);
+ $this->height = imagesy($this->image);
+ }
+
+ $this->valid = !empty($this->image);
+
+ $this->scaleUp(min($width, $height));
}
}