From 374f6b1c4d03aaaede239916142f0831fd0f711c Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Sun, 6 Sep 2026 15:53:04 +0000 Subject: [PATCH] feat: support focal point cropping --- src/Image/Image.php | 101 +++++++++++++++++++++++--------------- tests/Image/ImageTest.php | 38 ++++++++++++++ 2 files changed, 100 insertions(+), 39 deletions(-) diff --git a/src/Image/Image.php b/src/Image/Image.php index ff086b8..a7986a8 100644 --- a/src/Image/Image.php +++ b/src/Image/Image.php @@ -99,10 +99,28 @@ public static function getGravityTypes(): array /** * @throws \Throwable */ - public function crop(int $width, int $height, string $gravity = Image::GRAVITY_CENTER): self + public function crop( + int $width, + int $height, + string $gravity = Image::GRAVITY_CENTER, + ?float $x = null, + ?float $y = null + ): self { + if (($x === null) !== ($y === null)) { + throw new \InvalidArgumentException('Both focal point coordinates are required'); + } + + if ($x !== null && ($x < 0 || $x > 1 || $y < 0 || $y > 1)) { + throw new \InvalidArgumentException('Focal point coordinates must be between 0 and 1'); + } + + $hasFocalPoint = $x !== null; + $focalX = $x; + $focalY = $y; + // if no changes to Gravity, Width or Height, don't process image - if ($gravity === Image::GRAVITY_CENTER + if ($gravity === Image::GRAVITY_CENTER && !$hasFocalPoint && ( ($width !== 0 && $height !== 0) && ($width === $this->width && $height === $this->height) @@ -127,7 +145,7 @@ public function crop(int $width, int $height, string $gravity = Image::GRAVITY_C $resizeWidth = $this->width; $resizeHeight = $this->height; - if ($gravity !== Image::GRAVITY_CENTER) { + if ($gravity !== Image::GRAVITY_CENTER || $hasFocalPoint) { $targetAspect = $width / $height; if ($targetAspect > $originalAspect) { $resizeWidth = $width; @@ -139,40 +157,45 @@ public function crop(int $width, int $height, string $gravity = Image::GRAVITY_C } $x = $y = 0; - switch ($gravity) { - case self::GRAVITY_TOP_LEFT: - $x = 0; - $y = 0; - break; - case self::GRAVITY_TOP: - $x = ($resizeWidth / 2) - ($width / 2); - break; - case self::GRAVITY_TOP_RIGHT: - $x = $resizeWidth - $width; - break; - case self::GRAVITY_LEFT: - $y = ($resizeHeight / 2) - ($height / 2); - break; - case self::GRAVITY_RIGHT: - $x = $resizeWidth - $width; - $y = ($resizeHeight / 2) - ($height / 2); - break; - case self::GRAVITY_BOTTOM_LEFT: - $x = 0; - $y = $resizeHeight - $height; - break; - case self::GRAVITY_BOTTOM: - $x = ($resizeWidth / 2) - ($width / 2); - $y = $resizeHeight - $height; - break; - case self::GRAVITY_BOTTOM_RIGHT: - $x = $resizeWidth - $width; - $y = $resizeHeight - $height; - break; - default: - $x = ($resizeWidth / 2) - ($width / 2); - $y = ($resizeHeight / 2) - ($height / 2); - break; + if ($hasFocalPoint) { + $x = \max(0, \min($resizeWidth - $width, $focalX * $resizeWidth - $width / 2)); + $y = \max(0, \min($resizeHeight - $height, $focalY * $resizeHeight - $height / 2)); + } else { + switch ($gravity) { + case self::GRAVITY_TOP_LEFT: + $x = 0; + $y = 0; + break; + case self::GRAVITY_TOP: + $x = ($resizeWidth / 2) - ($width / 2); + break; + case self::GRAVITY_TOP_RIGHT: + $x = $resizeWidth - $width; + break; + case self::GRAVITY_LEFT: + $y = ($resizeHeight / 2) - ($height / 2); + break; + case self::GRAVITY_RIGHT: + $x = $resizeWidth - $width; + $y = ($resizeHeight / 2) - ($height / 2); + break; + case self::GRAVITY_BOTTOM_LEFT: + $x = 0; + $y = $resizeHeight - $height; + break; + case self::GRAVITY_BOTTOM: + $x = ($resizeWidth / 2) - ($width / 2); + $y = $resizeHeight - $height; + break; + case self::GRAVITY_BOTTOM_RIGHT: + $x = $resizeWidth - $width; + $y = $resizeHeight - $height; + break; + default: + $x = ($resizeWidth / 2) - ($width / 2); + $y = ($resizeHeight / 2) - ($height / 2); + break; + } } $x = \intval($x); $y = \intval($y); @@ -181,7 +204,7 @@ public function crop(int $width, int $height, string $gravity = Image::GRAVITY_C $this->image = $this->image->coalesceImages(); foreach ($this->image as $frame) { - if ($gravity === self::GRAVITY_CENTER) { + if ($gravity === self::GRAVITY_CENTER && !$hasFocalPoint) { $frame->cropThumbnailImage($width, $height); } else { $frame->scaleImage($resizeWidth, $resizeHeight, false); @@ -191,7 +214,7 @@ public function crop(int $width, int $height, string $gravity = Image::GRAVITY_C $frame->setImagePage($width, $height, 0, 0); } - } elseif ($gravity === self::GRAVITY_CENTER) { + } elseif ($gravity === self::GRAVITY_CENTER && !$hasFocalPoint) { $this->image->cropThumbnailImage($width, $height); } else { $this->image->scaleImage($resizeWidth, $resizeHeight, false); diff --git a/tests/Image/ImageTest.php b/tests/Image/ImageTest.php index ea99663..2e8e12a 100644 --- a/tests/Image/ImageTest.php +++ b/tests/Image/ImageTest.php @@ -111,6 +111,44 @@ public function testCrop100x100(): void unlink($target); } + public function testCropFocalUsesNormalizedCoordinates(): void + { + $source = new \Imagick(); + $source->newImage(6, 2, 'red', 'png'); + $draw = new \ImagickDraw(); + $draw->setFillColor('green'); + $draw->rectangle(2, 0, 3, 1); + $draw->setFillColor('blue'); + $draw->rectangle(4, 0, 5, 1); + $source->drawImage($draw); + + $image = new Image($source->getImageBlob()); + $image->crop(2, 2, x: 0.75, y: 0.5); + + $result = new \Imagick(); + $result->readImageBlob($image->output('png', 100) ?: ''); + $color = $result->getImagePixelColor(1, 1)->getColor(); + + $this->assertGreaterThan($color['r'], $color['b']); + $this->assertGreaterThan($color['g'], $color['b']); + } + + public function testCropFocalRejectsCoordinatesOutsideTheImage(): void + { + $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); + + $this->expectException(\InvalidArgumentException::class); + $image->crop(100, 100, x: 1.1, y: 0.5); + } + + public function testCropFocalRequiresBothCoordinates(): void + { + $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); + + $this->expectException(\InvalidArgumentException::class); + $image->crop(100, 100, x: 0.5); + } + public function testCropGravityNw(): void { $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: '');