diff --git a/composer.json b/composer.json index 511690104..2f7aa1f95 100644 --- a/composer.json +++ b/composer.json @@ -83,7 +83,8 @@ "ext-memcache": "Needed to support Memcache servers", "ext-memcached": "Needed to support Memcached servers", "ext-redis": "Needed to support Redis servers", - "ext-exif": "Needed to use exif data from images." + "ext-exif": "Needed to use exif data from images.", + "ext-gd": "Needed to resize images." }, "config": { "apcu-autoloader": true, diff --git a/system/src/Grav/Framework/Contracts/Image/ImageAdapterInterface.php b/system/src/Grav/Framework/Contracts/Image/ImageAdapterInterface.php new file mode 100644 index 000000000..236451a58 --- /dev/null +++ b/system/src/Grav/Framework/Contracts/Image/ImageAdapterInterface.php @@ -0,0 +1,297 @@ +applyExifOrientation($this->orientation); + } + + /** + * {@inheritdoc} + */ + public function applyExifOrientation(int $exif_orientation) + { + switch ($exif_orientation) { + case 1: // do nothing + break; + + case 2: // horizontal flip + $this->flip(false, true); + break; + + case 3: // 180 rotate left + $this->rotate(180.0); + break; + + case 4: // vertical flip + $this->flip(true, false); + break; + + case 5: // vertical flip + 90 rotate right + $this->flip(true, false); + $this->rotate(-90.0); + break; + + case 6: // 90 rotate right + $this->rotate(-90.0); + break; + + case 7: // horizontal flip + 90 rotate right + $this->flip(false, true); + $this->rotate(-90.0); + break; + + case 8: // 90 rotate left + $this->rotate(90.0); + break; + } + + return $this; + } +} diff --git a/system/src/Grav/Framework/Image/Adapter/GdAdapter.php b/system/src/Grav/Framework/Image/Adapter/GdAdapter.php new file mode 100644 index 000000000..6714eaef5 --- /dev/null +++ b/system/src/Grav/Framework/Image/Adapter/GdAdapter.php @@ -0,0 +1,816 @@ + */ + public static $types = [ + 'jpeg' => \IMG_JPG, + 'jpg' => \IMG_JPG, + 'gif' => \IMG_GIF, + 'png' => \IMG_PNG, + 'webp' => \IMG_WEBP + ]; + + /** @var \GdImage|resource */ + protected $resource; + + /** + * {@inheritdoc} + */ + public static function isEnabled(): bool + { + return extension_loaded('gd') && function_exists('gd_info'); + } + + /** + * {@inheritdoc} + */ + public static function isSupported(string $type): bool + { + $test = self::$types[$type] ?? 0; + + return (bool)(imagetypes() & $test); + } + + /** + * @param \GdImage|resource $resource + */ + public function __construct($resource) + { + if (PHP_VERSION_ID > 80000) { + if (!$resource instanceof \GdImage) { + throw new InvalidArgumentException('Resource has to be GD Image'); + } + } elseif (!is_resource($resource)) { + throw new InvalidArgumentException('Resource has to be GD Image'); + } + + $this->resource = $resource; + } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'GD'; + } + + /** + * @return \GdImage|resource + */ + public function getResource() + { + return $this->resource; + } + + /** + * {@inheritdoc} + */ + public function fillBackground(?int $background = 0xffffff) + { + $w = $this->width(); + $h = $this->height(); + $n = imagecreatetruecolor($w, $h); + if (!$n) { + throw new RuntimeException('Image background color fill failed'); + } + imagefill($n, 0, 0, $this->allocateColor($background)); + imagecopyresampled($n, $this->resource, 0, 0, 0, 0, $w, $h, $w, $h); + imagedestroy($this->resource); + $this->resource = $n; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function resize(?int $background, int $target_width, int $target_height, int $new_width, int $new_height) + { + $width = $this->width(); + $height = $this->height(); + $n = imagecreatetruecolor($target_width, $target_height); + if (!$n) { + throw new RuntimeException('Failed to resize image: image creation failed'); + } + + if ($background !== null) { + imagefill($n, 0, 0, $this->allocateColor($background)); + } else { + imagealphablending($n, false); + $color = $this->allocateColor(null); + + imagefill($n, 0, 0, $color); + imagesavealpha($n, true); + } + + imagecopyresampled($n, $this->resource, ($target_width - $new_width) / 2, ($target_height - $new_height) / 2, 0, 0, $new_width, $new_height, $width, $height); + imagedestroy($this->resource); + + $this->resource = $n; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function crop(int $x, int $y, int $width, int $height) + { + $destination = imagecreatetruecolor($width, $height); + if (!$destination) { + throw new RuntimeException('Image crop failed'); + } + + imagealphablending($destination, false); + imagesavealpha($destination, true); + imagecopy($destination, $this->resource, 0, 0, $x, $y, $this->width(), $this->height()); + imagedestroy($this->resource); + + $this->resource = $destination; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function negate() + { + imagefilter($this->resource, IMG_FILTER_NEGATE); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function brightness($brightness) + { + imagefilter($this->resource, IMG_FILTER_BRIGHTNESS, $brightness); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function contrast($contrast) + { + imagefilter($this->resource, IMG_FILTER_CONTRAST, $contrast); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function grayscale() + { + imagefilter($this->resource, IMG_FILTER_GRAYSCALE); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function emboss() + { + imagefilter($this->resource, IMG_FILTER_EMBOSS); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function smooth(int $p) + { + imagefilter($this->resource, IMG_FILTER_SMOOTH, $p); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function sharp() + { + imagefilter($this->resource, IMG_FILTER_MEAN_REMOVAL); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function edge() + { + imagefilter($this->resource, IMG_FILTER_EDGEDETECT); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function colorize(int $red, int $green, int $blue) + { + imagefilter($this->resource, IMG_FILTER_COLORIZE, $red, $green, $blue); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function sepia() + { + imagefilter($this->resource, IMG_FILTER_GRAYSCALE); + imagefilter($this->resource, IMG_FILTER_COLORIZE, 100, 50, 0); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function gaussianBlur(int $blurFactor = 1) + { + if ($blurFactor < 1) { + return $this; + } + + $originalWidth = $this->width(); + $originalHeight = $this->height(); + + $smallestWidth = (int)ceil($originalWidth * (0.5 ** $blurFactor)); + $smallestHeight = (int)ceil($originalHeight * (0.5 ** $blurFactor)); + + // for the first run, the previous image is the original input + $prevImage = $this->resource; + $prevWidth = $originalWidth; + $prevHeight = $originalHeight; + + // scale way down and gradually scale back up, blurring all the way + for ($i = 0; $i < $blurFactor; ++$i) { + // determine dimensions of next image + $nextWidth = (int)($smallestWidth * (2 ** $i)); + $nextHeight = (int)($smallestHeight * (2 ** $i)); + + // resize previous image to next size + $nextImage = imagecreatetruecolor($nextWidth, $nextHeight); + if (!$nextImage) { + throw new RuntimeException('Image gaussian blur failed'); + } + imagecopyresized($nextImage, $prevImage, 0, 0, 0, 0, + $nextWidth, $nextHeight, $prevWidth, $prevHeight); + + // apply blur filter + imagefilter($nextImage, IMG_FILTER_GAUSSIAN_BLUR); + + // now the new image becomes the previous image for the next step + $prevImage = $nextImage; + $prevWidth = $nextWidth; + $prevHeight = $nextHeight; + } + + // scale back to original size and blur one more time + imagecopyresized($this->resource, $nextImage, + 0, 0, 0, 0, $originalWidth, $originalHeight, $nextWidth, $nextHeight); + imagefilter($this->resource, IMG_FILTER_GAUSSIAN_BLUR); + + // clean up + imagedestroy($prevImage); + + return $this; + } + + /** + * {@inheritdoc} + * + * @param GdAdapter $other + */ + public function merge(ImageAdapterInterface $other, int $x = 0, int $y = 0, int $width = null, int $height = null) + { + if (!$other instanceof self) { + throw new InvalidArgumentException('Image to be merged needs to be instance of GdAdapter'); + } + + imagealphablending($this->resource, true); + + if (null === $width) { + $width = $other->width(); + } + + if (null === $height) { + $height = $other->height(); + } + + imagecopyresampled($this->resource, $other->getResource(), $x, $y, 0, 0, $width, $height, $width, $height); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function rotate(float $angle, ?int $background = 0xffffff) + { + $resource = imagerotate($this->resource, $angle, $this->allocateColor($background)); + if (!$resource) { + throw new RuntimeException('Image rotate failed'); + } + + $this->resource = $resource; + imagealphablending($this->resource, true); + imagesavealpha($this->resource, true); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function fill(int $color = 0xffffff, int $x = 0, int $y = 0) + { + imagealphablending($this->resource, false); + imagefill($this->resource, $x, $y, $this->allocateColor($color)); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function write(string $font, string $text, int $x = 0, int $y = 0, float $size = 12.0, float $angle = 0.0, int $color = 0x000000, string $align = 'left') + { + imagealphablending($this->resource, true); + + if ($align !== 'left') { + $sim_size = $this->getTTFBox($font, $text, $size, $angle); + + if ($align === 'center') { + $x -= $sim_size['width'] / 2; + } + + if ($align === 'right') { + $x -= $sim_size['width']; + } + } + + imagettftext($this->resource, $size, $angle, $x, $y, $this->allocateColor($color), $font, $text); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function rectangle(int $x1, int $y1, int $x2, int $y2, int $color, bool $filled = false) + { + $c = $this->allocateColor($color); + if ($filled) { + imagefilledrectangle($this->resource, $x1, $y1, $x2, $y2, $c); + } else { + imagerectangle($this->resource, $x1, $y1, $x2, $y2, $c); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function roundedRectangle(int $x1, int $y1, int $x2, int $y2, int $radius, int $color, bool $filled = false) + { + $c = $this->allocateColor($color); + + if ($filled) { + imagefilledrectangle($this->resource, $x1 + $radius, $y1, $x2 - $radius, $y2, $c); + imagefilledrectangle($this->resource, $x1, $y1 + $radius, $x1 + $radius - 1, $y2 - $radius, $c); + imagefilledrectangle($this->resource, $x2 - $radius + 1, $y1 + $radius, $x2, $y2 - $radius, $c); + + imagefilledarc($this->resource, $x1 + $radius, $y1 + $radius, $radius * 2, $radius * 2, 180, 270, $c, IMG_ARC_PIE); + imagefilledarc($this->resource, $x2 - $radius, $y1 + $radius, $radius * 2, $radius * 2, 270, 360, $c, IMG_ARC_PIE); + imagefilledarc($this->resource, $x1 + $radius, $y2 - $radius, $radius * 2, $radius * 2, 90, 180, $c, IMG_ARC_PIE); + imagefilledarc($this->resource, $x2 - $radius, $y2 - $radius, $radius * 2, $radius * 2, 360, 90, $c, IMG_ARC_PIE); + } else { + imageline($this->resource, $x1 + $radius, $y1, $x2 - $radius, $y1, $c); + imageline($this->resource, $x1 + $radius, $y2, $x2 - $radius, $y2, $c); + imageline($this->resource, $x1, $y1 + $radius, $x1, $y2 - $radius, $c); + imageline($this->resource, $x2, $y1 + $radius, $x2, $y2 - $radius, $c); + + imagearc($this->resource, $x1 + $radius, $y1 + $radius, $radius * 2, $radius * 2, 180, 270, $c); + imagearc($this->resource, $x2 - $radius, $y1 + $radius, $radius * 2, $radius * 2, 270, 360, $c); + imagearc($this->resource, $x1 + $radius, $y2 - $radius, $radius * 2, $radius * 2, 90, 180, $c); + imagearc($this->resource, $x2 - $radius, $y2 - $radius, $radius * 2, $radius * 2, 360, 90, $c); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function line(int $x1, int $y1, int $x2, int $y2, $color = 0x000000) + { + imageline($this->resource, $x1, $y1, $x2, $y2, $this->allocateColor($color)); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function ellipse(int $cx, int $cy, int $width, int $height, $color = 0x000000, bool $filled = false) + { + $c = $this->allocateColor($color); + if ($filled) { + imagefilledellipse($this->resource, $cx, $cy, $width, $height, $c); + } else { + imageellipse($this->resource, $cx, $cy, $width, $height, $c); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function circle(int $cx, int $cy, int $r, $color = 0x000000, bool $filled = false) + { + return $this->ellipse($cx, $cy, $r, $r, $this->allocateColor($color), $filled); + } + + /** + * {@inheritdoc} + */ + public function polygon(array $points, $color, bool $filled = false) + { + $num = (int)(count($points) / 2); + $c = $this->allocateColor($color); + + if ($filled) { + imagefilledpolygon($this->resource, $points, $num, $c); + } else { + imagepolygon($this->resource, $points, $num, $c); + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function flip(bool $flipVertical, bool $flipHorizontal) + { + if (!$flipVertical && !$flipHorizontal) { + return $this; + } + + if (function_exists('imageflip')) { + if ($flipVertical && $flipHorizontal) { + $flipMode = \IMG_FLIP_BOTH; + } elseif ($flipVertical && !$flipHorizontal) { + $flipMode = \IMG_FLIP_VERTICAL; + } elseif (!$flipVertical && $flipHorizontal) { + $flipMode = \IMG_FLIP_HORIZONTAL; + } + + if (isset($flipMode)) { + imageflip($this->resource, $flipMode); + } + } else { + $width = $this->width(); + $height = $this->height(); + + $src_x = 0; + $src_y = 0; + $src_width = $width; + $src_height = $height; + + if ($flipVertical) { + $src_y = $height - 1; + $src_height = -$height; + } + + if ($flipHorizontal) { + $src_x = $width - 1; + $src_width = -$width; + } + + $imgdest = imagecreatetruecolor($width, $height); + if (!$imgdest) { + throw new RuntimeException('Image flip failed'); + } + + imagealphablending($imgdest, false); + imagesavealpha($imgdest, true); + + if (imagecopyresampled($imgdest, $this->resource, 0, 0, $src_x, $src_y, $width, $height, $src_width, $src_height)) { + imagedestroy($this->resource); + $this->resource = $imgdest; + } + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function width(): int + { + return imagesx($this->resource); + } + + /** + * {@inheritdoc} + */ + public function height(): int + { + return imagesy($this->resource); + } + + /** + * {@inheritdoc} + */ + public function saveGif(string $filepath) + { + $transColor = imagecolorallocatealpha($this->resource, 255, 255, 255, 127); + if (!$transColor) { + throw new RuntimeException('Image save failed'); + } + + imagecolortransparent($this->resource, $transColor); + imagegif($this->resource, $filepath); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function savePng(string $filepath) + { + imagepng($this->resource, $filepath); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function saveWebp(string $filepath, int $quality) + { + imagewebp($this->resource, $filepath, $quality); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function saveJpeg(string $filepath, int $quality) + { + imagejpeg($this->resource, $filepath, $quality); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function enableProgressive() + { + imageinterlace($this->resource, true); + + return $this; + } + + /** + * Create empty image. + * + * @param int $width + * @param int $height + * @return void + */ + protected function createImage(int $width, int $height): void + { + $this->resource = imagecreatetruecolor($width, $height) ?: null; + } + + /** + * Create image from a string. + * + * @param string $data + * @return void + */ + protected function createImageFromString(string $data): void + { + $this->resource = @imagecreatefromstring($data) ?: null; + } + + /** + * Converts the image to true color. + * + * @return void + */ + protected function convertToTrueColor(): void + { + if (!imageistruecolor($this->resource)) { + imagepalettetotruecolor($this->resource); + } + + imagesavealpha($this->resource, true); + } + + /** + * Try to open the file using jpeg. + * + * @param string $filepath + * @return void + */ + protected function openJpeg(string $filepath): void + { + if (file_exists($filepath) && filesize($filepath)) { + $this->resource = @imagecreatefromjpeg($filepath) ?: null; + } else { + $this->resource = null; + } + } + + /** + * Try to open the file using gif. + * + * @param string $filepath + * @return void + */ + protected function openGif(string $filepath): void + { + if (file_exists($filepath) && filesize($filepath)) { + $this->resource = @imagecreatefromgif($filepath) ?: null; + } else { + $this->resource = null; + } + } + + /** + * Try to open the file using PNG. + * + * @param string $filepath + * @return void + */ + protected function openPng(string $filepath): void + { + if (file_exists($filepath) && filesize($filepath)) { + $this->resource = @imagecreatefrompng($filepath) ?: null; + } else { + $this->resource = null; + } + } + + /** + * Try to open the file using WEBP. + * + * @param string $filepath + * @return void + */ + protected function openWebp(string $filepath): void + { + if (file_exists($filepath) && filesize($filepath)) { + $this->resource = @imagecreatefromwebp($filepath) ?: null; + } else { + $this->resource = null; + } + } + + /** + * Get color in x, y. + * + * @param int $x + * @param int $y + * @return int|false + */ + protected function getColor(int $x, int $y) + { + return imagecolorat($this->resource, $x, $y); + } + + /** + * Load image resource. + * + * @param \GdImage|resource $resource + */ + protected function loadResource($resource): void + { + $this->resource = $resource; + + imagesavealpha($this->resource, true); + } + + /** + * Load file. + * + * @param string $filepath + * @param string $type + * @return void + * @throws UnexpectedValueException + */ + protected function loadFile(string $filepath, string $type): void + { + if (!static::isSupported($type)) { + throw new UnexpectedValueException('Type ' . $type . ' is not supported by GD'); + } + + switch ($type) { + case 'jpeg': + $this->openJpeg($filepath); + break; + case 'gif': + $this->openGif($filepath); + break; + case 'png': + $this->openPng($filepath); + break; + case 'webp': + $this->openWebp($filepath); + break; + default: + throw new UnexpectedValueException('Unable to open file (' . $filepath . ')'); + } + + if (null === $this->getResource()) { + throw new UnexpectedValueException('Unable to open file (' . $filepath . ')'); + } + + $this->convertToTrueColor(); + } + + /** + * Give the bounding box of a text using TrueType fonts. + * + * @param string $font + * @param string $text + * @param float $size + * @param float $angle + * @return array + */ + protected function getTTFBox(string $font, string $text, float $size, float $angle = 0): array + { + $box = imagettfbbox($size, $angle, $font, $text); + if (false === $box) { + throw new RuntimeException('Failed to allocate room for text'); + } + + return [ + 'width' => abs($box[2] - $box[0]), + 'height' => abs($box[3] - $box[5]), + ]; + } + + + /** + * Allocate color for the image. + * + * @param int|null $color + * @return int + */ + protected function allocateColor(?int $color): int + { + $colorRGBA = $color ?? 0x7fffffff; + + $b = ($colorRGBA) & 0xff; + $colorRGBA >>= 8; + $g = ($colorRGBA) & 0xff; + $colorRGBA >>= 8; + $r = ($colorRGBA) & 0xff; + $colorRGBA >>= 8; + $a = ($colorRGBA) & 0xff; + + $c = imagecolorallocatealpha($this->resource, $r, $g, $b, $a); + + if (false !== $c && $color === null) { + imagecolortransparent($this->resource, $c); + } + + if (false === $c) { + throw new RuntimeException('Failed to allocate color'); + } + + return $c; + } +} diff --git a/system/src/Grav/Framework/Image/Image.php b/system/src/Grav/Framework/Image/Image.php new file mode 100644 index 000000000..75ef4025a --- /dev/null +++ b/system/src/Grav/Framework/Image/Image.php @@ -0,0 +1,79 @@ +filepath = $filepath; + $this->width = $info['width'] ?? 0; + $this->height = $info['height'] ?? 0; + $this->orientation = isset($info['exif']['Orientation']) ? (int)$info['exif']['Orientation'] : null; + } + + /** + * @return array + */ + public function __serialize(): array + { + return [ + 'image' => 1, + 'filepath' => $this->filepath, + 'info' => $this->info, + 'width' => $this->width, + 'height' => $this->height, + 'orientation' => $this->orientation, + 'operations' => $this->operations, + ]; + } + + /** + * @param array $data + * @return void + */ + public function __unserialize(array $data): void + { + $image = $data['image'] ?? null; + if ($image !== 1) { + throw new RuntimeException('Cannot unserialize image: Version mismatch'); + } + + $this->filepath = $data['filepath']; + $this->info = $data['info']; + $this->width = $data['width']; + $this->height = $data['height']; + $this->orientation = $data['orientation']; + $this->operations = $data['operations']; + } + + /** + * Generates the hash for the image. + * + * @return string + */ + public function generateHash(): string + { + return sha1(serialize($this)); + } +} diff --git a/system/src/Grav/Framework/Image/ImageColor.php b/system/src/Grav/Framework/Image/ImageColor.php new file mode 100644 index 000000000..b4553dcc7 --- /dev/null +++ b/system/src/Grav/Framework/Image/ImageColor.php @@ -0,0 +1,225 @@ + 0xf0f8ff, + 'antiquewhite' => 0xfaebd7, + 'aqua' => 0x00ffff, + 'aquamarine' => 0x7fffd4, + 'azure' => 0xf0ffff, + 'beige' => 0xf5f5dc, + 'bisque' => 0xffe4c4, + 'black' => 0x000000, + 'blanchedalmond' => 0xffebcd, + 'blue' => 0x0000ff, + 'blueviolet' => 0x8a2be2, + 'brown' => 0xa52a2a, + 'burlywood' => 0xdeb887, + 'cadetblue' => 0x5f9ea0, + 'chartreuse' => 0x7fff00, + 'chocolate' => 0xd2691e, + 'coral' => 0xff7f50, + 'cornflowerblue' => 0x6495ed, + 'cornsilk' => 0xfff8dc, + 'crimson' => 0xdc143c, + 'cyan' => 0x00ffff, + 'darkblue' => 0x00008b, + 'darkcyan' => 0x008b8b, + 'darkgoldenrod' => 0xb8860b, + 'darkgray' => 0xa9a9a9, + 'darkgreen' => 0x006400, + 'darkgrey' => 0xa9a9a9, + 'darkkhaki' => 0xbdb76b, + 'darkmagenta' => 0x8b008b, + 'darkolivegreen' => 0x556b2f, + 'darkorange' => 0xff8c00, + 'darkorchid' => 0x9932cc, + 'darkred' => 0x8b0000, + 'darksalmon' => 0xe9967a, + 'darkseagreen' => 0x8fbc8f, + 'darkslateblue' => 0x483d8b, + 'darkslategray' => 0x2f4f4f, + 'darkslategrey' => 0x2f4f4f, + 'darkturquoise' => 0x00ced1, + 'darkviolet' => 0x9400d3, + 'deeppink' => 0xff1493, + 'deepskyblue' => 0x00bfff, + 'dimgray' => 0x696969, + 'dimgrey' => 0x696969, + 'dodgerblue' => 0x1e90ff, + 'firebrick' => 0xb22222, + 'floralwhite' => 0xfffaf0, + 'forestgreen' => 0x228b22, + 'fuchsia' => 0xff00ff, + 'gainsboro' => 0xdcdcdc, + 'ghostwhite' => 0xf8f8ff, + 'goldenrod' => 0xdaa520, + 'gold' => 0xffd700, + 'gray' => 0x808080, + 'green' => 0x008000, + 'greenyellow' => 0xadff2f, + 'grey' => 0x808080, + 'honeydew' => 0xf0fff0, + 'hotpink' => 0xff69b4, + 'indianred' => 0xcd5c5c, + 'indigo' => 0x4b0082, + 'ivory' => 0xfffff0, + 'khaki' => 0xf0e68c, + 'lavenderblush' => 0xfff0f5, + 'lavender' => 0xe6e6fa, + 'lawngreen' => 0x7cfc00, + 'lemonchiffon' => 0xfffacd, + 'lightblue' => 0xadd8e6, + 'lightcoral' => 0xf08080, + 'lightcyan' => 0xe0ffff, + 'lightgoldenrodyellow' => 0xfafad2, + 'lightgray' => 0xd3d3d3, + 'lightgreen' => 0x90ee90, + 'lightgrey' => 0xd3d3d3, + 'lightpink' => 0xffb6c1, + 'lightsalmon' => 0xffa07a, + 'lightseagreen' => 0x20b2aa, + 'lightskyblue' => 0x87cefa, + 'lightslategray' => 0x778899, + 'lightslategrey' => 0x778899, + 'lightsteelblue' => 0xb0c4de, + 'lightyellow' => 0xffffe0, + 'lime' => 0x00ff00, + 'limegreen' => 0x32cd32, + 'linen' => 0xfaf0e6, + 'magenta' => 0xff00ff, + 'maroon' => 0x800000, + 'mediumaquamarine' => 0x66cdaa, + 'mediumblue' => 0x0000cd, + 'mediumorchid' => 0xba55d3, + 'mediumpurple' => 0x9370db, + 'mediumseagreen' => 0x3cb371, + 'mediumslateblue' => 0x7b68ee, + 'mediumspringgreen' => 0x00fa9a, + 'mediumturquoise' => 0x48d1cc, + 'mediumvioletred' => 0xc71585, + 'midnightblue' => 0x191970, + 'mintcream' => 0xf5fffa, + 'mistyrose' => 0xffe4e1, + 'moccasin' => 0xffe4b5, + 'navajowhite' => 0xffdead, + 'navy' => 0x000080, + 'oldlace' => 0xfdf5e6, + 'olive' => 0x808000, + 'olivedrab' => 0x6b8e23, + 'orange' => 0xffa500, + 'orangered' => 0xff4500, + 'orchid' => 0xda70d6, + 'palegoldenrod' => 0xeee8aa, + 'palegreen' => 0x98fb98, + 'paleturquoise' => 0xafeeee, + 'palevioletred' => 0xdb7093, + 'papayawhip' => 0xffefd5, + 'peachpuff' => 0xffdab9, + 'peru' => 0xcd853f, + 'pink' => 0xffc0cb, + 'plum' => 0xdda0dd, + 'powderblue' => 0xb0e0e6, + 'purple' => 0x800080, + 'rebeccapurple' => 0x663399, + 'red' => 0xff0000, + 'rosybrown' => 0xbc8f8f, + 'royalblue' => 0x4169e1, + 'saddlebrown' => 0x8b4513, + 'salmon' => 0xfa8072, + 'sandybrown' => 0xf4a460, + 'seagreen' => 0x2e8b57, + 'seashell' => 0xfff5ee, + 'sienna' => 0xa0522d, + 'silver' => 0xc0c0c0, + 'skyblue' => 0x87ceeb, + 'slateblue' => 0x6a5acd, + 'slategray' => 0x708090, + 'slategrey' => 0x708090, + 'snow' => 0xfffafa, + 'springgreen' => 0x00ff7f, + 'steelblue' => 0x4682b4, + 'tan' => 0xd2b48c, + 'teal' => 0x008080, + 'thistle' => 0xd8bfd8, + 'tomato' => 0xff6347, + 'turquoise' => 0x40e0d0, + 'violet' => 0xee82ee, + 'wheat' => 0xf5deb3, + 'white' => 0xffffff, + 'whitesmoke' => 0xf5f5f5, + 'yellow' => 0xffff00, + 'yellowgreen' => 0x9acd32 + ]; + + /** + * Parse color and return integer value of it. Transparent will be converted to null. + * + * @param string|int|null $color + * @return int|null + * @throws InvalidArgumentException + */ + public static function parse($color): ?int + { + // Direct color representation (0xff0000 or null). + if (null === $color || is_int($color)) { + return $color; + } + + if (is_string($color)) { + $color = strtolower($color); + + // Transparent becomes null. + if ($color === 'transparent') { + return null; + } + + // Normalize string. + $color = str_replace(' ', '', $color); + + // Lookup named colors. + if (isset(self::$colors[$color])) { + return self::$colors[$color]; + } + + // Color string ('ff0000', '#ff0000' or '0xfff'). + if (preg_match('/^(#|0x|)([0-9a-f]{3,6})/u', $color, $matches)) { + $col = $matches[2]; + + if (strlen($col) === 3) { + $r = ''; + for ($i = 0; $i < 3; ++$i) { + $r .= $col[$i] . $col[$i]; + } + + $col = $r; + } + + return (int)hexdec($col); + } + + // Colors like 'rgb(255, 0, 0)' + if (preg_match('/^rgb\((\d+),(\d+),(\d+)\)/', $color, $matches)) { + [,$r,$g,$b] = $matches; + if ($r >= 0 && $r <= 0xff && $g >= 0 && $g <= 0xff && $b >= 0 && $b <= 0xff) { + return ($r << 16) | ($g << 8) | $b; + } + } + } + + throw new InvalidArgumentException('Invalid color: ' . $color); + } +} diff --git a/system/src/Grav/Framework/Image/Traits/ImageOperationsTrait.php b/system/src/Grav/Framework/Image/Traits/ImageOperationsTrait.php new file mode 100644 index 000000000..3ec54f3cd --- /dev/null +++ b/system/src/Grav/Framework/Image/Traits/ImageOperationsTrait.php @@ -0,0 +1,676 @@ +width; + } + + /** + * Image height. + * + * @return int + */ + public function height(): int + { + return $this->height; + } + + /** + * Works as resize() excepts that the layout will be cropped. + * + * @param string|int|null $width the width + * @param string|int|null $height he height + * @param string|int $background the background + * @return $this + */ + public function cropResize($width = null, $height = null, $background = 0xffffff) + { + return $this->resize($width, $height, $background, false, false, true); + } + + /** + * Resize the image preserving scale. Can enlarge it. + * + * @param string|int|null $width the width + * @param string|int|null $height the height + * @param string|int $background the background + * @param bool $crop + * @return $this + */ + public function scaleResize($width = null, $height = null, $background = 0xffffff, bool $crop = false) + { + return $this->resize($width, $height, $background, false, true, $crop); + } + + /** + * Resizes the image forcing the destination to have exactly the given width and the height. + * + * @param string|int|null $width the width + * @param string|int|null $height the height + * @param string|int $background the background + * @return $this + */ + public function forceResize($width = null, $height = null, $background = 0xffffff) + { + return $this->resize($width, $height, $background, true); + } + + /** + * Resizes the image. It will never be enlarged. + * + * @param string|int|null $width the width + * @param string|int|null $height the height + * @param string|int $background the background + * @param bool $force + * @param bool $rescale + * @param bool $crop + * @return $this + */ + public function resize($width = null, $height = null, $background = 0xffffff, bool $force = false, bool $rescale = false, bool $crop = false) + { + [$width, $height] = $this->getSize($width, $height); + if ($width < 0 || $height < 0) { + return $this; + } + + $bg = ImageColor::parse($background); + + $current_width = $this->width(); + $current_height = $this->height(); + $new_width = 0; + $new_height = 0; + $scale = 1.0; + + if (!$rescale && (!$force || $crop)) { + if ($width !== 0 && $current_width > $width) { + $scale = $current_width / $width; + } + + if ($height !== 0 && $current_height > $height && $current_height / $height > $scale) { + $scale = $current_height / $height; + } + } else { + if ($width !== 0) { + $scale = $current_width / $width; + $new_width = $width; + } + + if ($height !== 0) { + if ($width !== 0 && $rescale) { + $scale = max($scale, $current_height / $height); + } else { + $scale = $current_height / $height; + } + $new_height = $height; + } + } + + if (!$force || $width === 0 || $rescale) { + $new_width = (int)round($current_width / $scale); + } + + if (!$force || $height === 0 || $rescale) { + $new_height = (int)round($current_height / $scale); + } + + if ($width === 0 || $crop) { + $width = $new_width; + } + + if ($height === 0 || $crop) { + $height = $new_height; + } + + $this->operations[] = ['resize', [$bg, $width, $height, $new_width, $new_height]]; + + // Update image size. + $this->width = $width; + $this->height = $height; + + return $this; + } + + /** + * Perform a zoom crop of the image to desired width and height. + * + * @param string|int|null $width Desired width + * @param string|int|null $height Desired height + * @param string|int $background + * @param string|int $xPosLetter + * @param string|int $yPosLetter + * @return $this + */ + public function zoomCrop($width, $height, $background = 0xffffff, $xPosLetter = 'center', $yPosLetter = 'center') + { + [$width, $height] = $this->getSize($width, $height); + if ($width <= 0 || $height <= 0) { + return $this; + } + + $bg = ImageColor::parse($background); + + $originalWidth = $this->width(); + $originalHeight = $this->height(); + + // Calculate the different ratios + $originalRatio = $originalWidth / $originalHeight; + $newRatio = $width / $height; + + // Compare ratios + if ($originalRatio > $newRatio) { + // Original image is wider + $newHeight = $height; + $newWidth = (int)($height * $originalRatio); + } else { + // Equal width or smaller + $newHeight = (int)($width / $originalRatio); + $newWidth = $width; + } + + // Perform resize + $this->resize($newWidth, $newHeight, $bg, true); + + // Define x position + switch ($xPosLetter) { + case 'L': + case 'left': + $xPos = 0; + break; + case 'R': + case 'right': + $xPos = $newWidth - $width; + break; + case 'C': + case 'center': + $xPos = (int)(($newWidth - $width) / 2); + break; + default: + $factorW = $newWidth / $originalWidth; + $xPos = (int)((int)$xPosLetter * $factorW); + + // If the desired cropping position goes beyond the width then + // set the crop to be within the correct bounds. + if ($xPos + $width > $newWidth) { + $xPos = $newWidth - $width; + } + } + + // Define y position + switch ($yPosLetter) { + case 'T': + case 'top': + $yPos = 0; + break; + case 'B': + case 'bottom': + $yPos = $newHeight - $height; + break; + case 'C': + case 'center': + $yPos = (int)(($newHeight - $height) / 2); + break; + default: + $factorH = $newHeight / $originalHeight; + $yPos = (int)((int)$yPosLetter * $factorH); + + // If the desired cropping position goes beyond the height then + // set the crop to be within the correct bounds. + if ($yPos + $height > $newHeight) { + $yPos = $newHeight - $height; + } + } + + // Crop image to reach desired size + $this->crop($xPos, $yPos, $width, $height); + + return $this; + } + + /** + * Crops the image. + * + * @param int $x the top-left x position of the crop box + * @param int $y the top-left y position of the crop box + * @param int $width the width of the crop box + * @param int $height the height of the crop box + * @return $this + */ + public function crop(int $x, int $y, int $width, int $height) + { + $this->operations[] = ['crop', [$x, $y, $width, $height]]; + + // Update image size. + $this->width = $width; + $this->height = $height; + + return $this; + } + + /** + * Read exif rotation from file and apply it. + * + * @return $this + */ + public function fixOrientation() + { + if (null !== $this->orientation) { + return $this->applyExifOrientation($this->orientation); + } + + $this->operations[] = ['fixOrientation', []]; + + return $this; + } + + /** + * Apply orientation using Exif orientation value. + * + * @param int $exif_orientation + * @return $this + */ + public function applyExifOrientation(int $exif_orientation) + { + $this->operations[] = ['applyExifOrientation', [$exif_orientation]]; + + return $this; + } + + /** + * enable progressive image loading. + * + * @return $this + */ + public function enableProgressive() + { + $this->operations[] = ['enableProgressive', []]; + + return $this; + } + + /** + * Fills the image background to $bg if the image is transparent. + * + * @param string|int $background background color + * @return $this + */ + public function fillBackground($background = 0xffffff) + { + $bg = ImageColor::parse($background); + + $this->operations[] = ['fillBackground', [$bg]]; + + return $this; + } + + /** + * Negates the image. + * + * @return $this + */ + public function negate() + { + $this->operations[] = ['negate', []]; + + return $this; + } + + /** + * Changes the brightness of the image. + * + * @param int $brightness the brightness + * @return $this + */ + public function brightness(int $brightness) + { + $this->operations[] = ['brightness', [$brightness]]; + + return $this; + } + + /** + * Contrasts the image. + * + * @param int $contrast the contrast [-100, 100] + * @return $this + */ + public function contrast(int $contrast) + { + $this->operations[] = ['contrast', [$contrast]]; + + return $this; + } + + /** + * Apply a grayscale level effect on the image. + * + * @return $this + */ + public function grayscale() + { + $this->operations[] = ['grayscale', []]; + + return $this; + } + + /** + * Emboss the image. + * + * @return $this + */ + public function emboss() + { + $this->operations[] = ['emboss', []]; + + return $this; + } + + /** + * Smooth the image. + * + * @param int $p value between [-10,10] + * + * @return $this + */ + public function smooth(int $p) + { + $this->operations[] = ['smooth', [$p]]; + + return $this; + } + + /** + * Sharpens the image. + * + * @return $this + */ + public function sharp() + { + $this->operations[] = ['sharp', []]; + + return $this; + } + + /** + * Edges the image. + * + * @return $this + */ + public function edge() + { + $this->operations[] = ['edge', []]; + + return $this; + } + + /** + * Colorize the image. + * + * @param int $red value in range [-255, 255] + * @param int $green value in range [-255, 255] + * @param int $blue value in range [-255, 255] + * @return $this + */ + public function colorize(int $red, int $green, int $blue) + { + $this->operations[] = ['colorize', [$red, $green, $blue]]; + + return $this; + } + + /** + * apply sepia to the image. + * + * @return $this + */ + public function sepia() + { + $this->operations[] = ['sepia', []]; + + return $this; + } + + /** + * Merge with another image. + * + * @param Image $other + * @param int $x + * @param int $y + * @param int $width + * @param int $height + * @return $this + */ + public function merge(Image $other, int $x = 0, int $y = 0, int $width = 0, int $height = 0) + { + $serialized = $other->__serialize(); + + $this->operations[] = ['merge', [$serialized, $x, $y, $width, $height]]; + + return $this; + } + + /** + * Rotate the image. + * + * @param float $angle + * @param string|int $background + * @return $this + */ + public function rotate(float $angle, $background = 0xffffff) + { + $bg = ImageColor::parse($background); + + $this->operations[] = ['rotate', [$angle, $bg]]; + + // FIXME: Image size may change? + + return $this; + } + + /** + * Fills the image. + * + * @param string|int $color + * @param int $x + * @param int $y + * @return $this + */ + public function fill($color = 0xffffff, int $x = 0, int $y = 0) + { + $c = (int)ImageColor::parse($color); + + $this->operations[] = ['fill', [$c, $x, $y]]; + + return $this; + } + + /** + * write text to the image. + * + * @param string $font + * @param string $text + * @param int $x + * @param int $y + * @param float $size + * @param float $angle + * @param string|int $color + * @param string $align + * @return $this + */ + public function write(string $font, string $text, int $x = 0, int $y = 0, float $size = 12.0, float $angle = 0.0, $color = 0x000000, string $align = 'left') + { + $c = (int)ImageColor::parse($color); + + $this->operations[] = ['write', [$font, $text, $x, $y, $size, $angle, $c, $align]]; + + return $this; + } + + /** + * Draws a rectangle. + * + * @param int $x1 + * @param int $y1 + * @param int $x2 + * @param int $y2 + * @param string|int $color + * @param bool $filled + * @return $this + */ + public function rectangle(int $x1, int $y1, int $x2, int $y2, $color, bool $filled = false) + { + $c = (int)ImageColor::parse($color); + + $this->operations[] = ['rectangle', [$x1, $y1, $x2, $y2, $c, $filled]]; + + return $this; + } + + /** + * Draws a rounded rectangle. + * + * @param int $x1 + * @param int $y1 + * @param int $x2 + * @param int $y2 + * @param int $radius + * @param string|int $color + * @param bool $filled + * @return $this + */ + public function roundedRectangle(int $x1, int $y1, int $x2, int $y2, int $radius, $color, bool $filled = false) + { + $c = (int)ImageColor::parse($color); + + $this->operations[] = ['roundedRectangle', [$x1, $y1, $x2, $y2, $radius, $c, $filled]]; + + return $this; + } + + /** + * Draws a line. + * + * @param int $x1 + * @param int $y1 + * @param int $x2 + * @param int $y2 + * @param string|int $color + * @return $this + */ + public function line(int $x1, int $y1, int $x2, int $y2, $color = 0x000000) + { + $c = (int)ImageColor::parse($color); + + $this->operations[] = ['line', [$x1, $y1, $x2, $y2, $c]]; + + return $this; + } + + /** + * Draws an ellipse. + * + * @param int $cx + * @param int $cy + * @param int $width + * @param int $height + * @param string|int $color + * @param bool $filled + * @return $this + */ + public function ellipse(int $cx, int $cy, int $width, int $height, $color = 0x000000, bool $filled = false) + { + $c = (int)ImageColor::parse($color); + + $this->operations[] = ['ellipse', [$cx, $cy, $width, $height, $c, $filled]]; + + return $this; + } + + /** + * Draws a circle. + * + * @param int $cx + * @param int $cy + * @param int $r + * @param string|int $color + * @param bool $filled + * @return $this + */ + public function circle(int $cx, int $cy, int $r, $color = 0x000000, bool $filled = false) + { + $c = (int)ImageColor::parse($color); + + $this->operations[] = ['circle', [$cx, $cy, $r, $c, $filled]]; + + return $this; + } + + /** + * Draws a polygon. + * + * @param array $points + * @param string|int $color + * @param bool $filled + * @return $this + */ + public function polygon(array $points, $color, bool $filled = false) + { + $c = (int)ImageColor::parse($color); + + $this->operations[] = ['polygon', [$points, $c, $filled]]; + + return $this; + } + + /** + * Flips the image. + * + * @param bool $flipVertical + * @param bool $flipHorizontal + * @return $this + */ + public function flip(bool $flipVertical, bool $flipHorizontal) + { + $this->operations[] = ['flip', [$flipVertical, $flipHorizontal]]; + + return $this; + } + + /** + * @param string|float|int|null $width + * @param string|float|int|null $height + * @return int[] + */ + protected function getSize($width, $height): array + { + if ($height === null && is_string($width) && preg_match('#^(.+)%$#mUs', $width, $matches)) { + $width = round($this->width() * ((float)$matches[1] / 100.0)); + $height = round($this->height() * ((float)$matches[1] / 100.0)); + } + + return [(int)$width, (int)$height]; + } +}