feat(attachment): add cropping functionality for images and update metadata storage
This commit is contained in:
@@ -6,6 +6,7 @@ use App\Domains\Attachable\Enums\AttachmentType;
|
||||
use App\Domains\Attachable\Exceptions\AttachmentStorageException;
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Mime\MimeTypes;
|
||||
@@ -13,6 +14,56 @@ use Throwable;
|
||||
|
||||
class AttachmentService
|
||||
{
|
||||
public function storeCroppedImage(
|
||||
UploadedFile|string $image,
|
||||
string $path,
|
||||
float $horizontalCropPercentage,
|
||||
float $verticalCropPercentage,
|
||||
): Attachment {
|
||||
$this->validateCropPercentage($horizontalCropPercentage, 'horizontal');
|
||||
$this->validateCropPercentage($verticalCropPercentage, 'vertical');
|
||||
|
||||
$storedPaths = [];
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use (
|
||||
$image,
|
||||
$path,
|
||||
$horizontalCropPercentage,
|
||||
$verticalCropPercentage,
|
||||
&$storedPaths,
|
||||
): Attachment {
|
||||
$original = $this->store($image, $path);
|
||||
$storedPaths[] = $original->path;
|
||||
|
||||
$croppedContents = $this->cropImage(
|
||||
$this->imageContents($image),
|
||||
$horizontalCropPercentage,
|
||||
$verticalCropPercentage,
|
||||
);
|
||||
$cropped = $this->store(
|
||||
'data:'.$croppedContents['mime_type'].';base64,'.base64_encode($croppedContents['contents']),
|
||||
$path,
|
||||
);
|
||||
$storedPaths[] = $cropped->path;
|
||||
|
||||
$original->update([
|
||||
'crop_horizontal_start_percent' => $horizontalCropPercentage,
|
||||
'crop_vertical_start_percent' => $verticalCropPercentage,
|
||||
'cropped_attachment_id' => $cropped->id,
|
||||
]);
|
||||
|
||||
return $original->load('croppedAttachment');
|
||||
});
|
||||
} catch (Throwable $throwable) {
|
||||
if ($storedPaths !== []) {
|
||||
Storage::disk('s3')->delete(array_values(array_unique($storedPaths)));
|
||||
}
|
||||
|
||||
throw $throwable;
|
||||
}
|
||||
}
|
||||
|
||||
public function store(
|
||||
UploadedFile|string $file,
|
||||
string $path,
|
||||
@@ -107,6 +158,98 @@ class AttachmentService
|
||||
return trim($path, '/');
|
||||
}
|
||||
|
||||
protected function validateCropPercentage(float $percentage, string $axis): void
|
||||
{
|
||||
if (! is_finite($percentage) || $percentage < 0 || $percentage >= 100) {
|
||||
throw new AttachmentStorageException(
|
||||
"The {$axis} crop percentage must be greater than or equal to 0 and less than 100."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected function imageContents(UploadedFile|string $image): string
|
||||
{
|
||||
if (is_string($image)) {
|
||||
return $this->decodeBase64File($image)['data'];
|
||||
}
|
||||
|
||||
$contents = file_get_contents($image->getRealPath());
|
||||
|
||||
if (! is_string($contents) || $contents === '') {
|
||||
throw new AttachmentStorageException('The image content cannot be empty.');
|
||||
}
|
||||
|
||||
return $contents;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{contents: string, mime_type: string}
|
||||
*/
|
||||
protected function cropImage(
|
||||
string $contents,
|
||||
float $horizontalCropPercentage,
|
||||
float $verticalCropPercentage,
|
||||
): array {
|
||||
$source = @imagecreatefromstring($contents);
|
||||
|
||||
if ($source === false) {
|
||||
throw new AttachmentStorageException('The attachment must contain a valid image.');
|
||||
}
|
||||
|
||||
$width = imagesx($source);
|
||||
$height = imagesy($source);
|
||||
$x = min($width - 1, (int) floor($width * $horizontalCropPercentage / 100));
|
||||
$y = min($height - 1, (int) floor($height * $verticalCropPercentage / 100));
|
||||
$cropped = imagecrop($source, [
|
||||
'x' => $x,
|
||||
'y' => $y,
|
||||
'width' => $width - $x,
|
||||
'height' => $height - $y,
|
||||
]);
|
||||
|
||||
if ($cropped === false) {
|
||||
throw new AttachmentStorageException('The image could not be cropped.');
|
||||
}
|
||||
|
||||
return $this->encodeImage($cropped, $contents);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{contents: string, mime_type: string}
|
||||
*/
|
||||
protected function encodeImage(\GdImage $image, string $originalContents): array
|
||||
{
|
||||
$mimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($originalContents);
|
||||
$mimeType = is_string($mimeType) ? strtolower($mimeType) : '';
|
||||
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
$encoded = match ($mimeType) {
|
||||
'image/jpeg' => imagejpeg($image, null, 90),
|
||||
'image/gif' => imagegif($image),
|
||||
'image/webp' => imagewebp($image, null, 90),
|
||||
'image/avif' => function_exists('imageavif') && imageavif($image, null, 90),
|
||||
default => imagepng($image),
|
||||
};
|
||||
$output = ob_get_contents();
|
||||
} finally {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
if (! $encoded || ! is_string($output) || $output === '') {
|
||||
throw new AttachmentStorageException('The cropped image could not be encoded.');
|
||||
}
|
||||
|
||||
return [
|
||||
'contents' => $output,
|
||||
'mime_type' => match ($mimeType) {
|
||||
'image/jpeg', 'image/gif', 'image/webp', 'image/avif' => $mimeType,
|
||||
default => 'image/png',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
protected function resolveFilename(UploadedFile|string $file, string $key): string
|
||||
{
|
||||
if ($file instanceof UploadedFile) {
|
||||
|
||||
Reference in New Issue
Block a user