Files
projectsend/app/Modules/Platform/Branding/LogoCropper.php
T
ignacionelson 4485e36c5c Crop the logo on the server, from the kept upload, and restore it
Cropping is optional: an upload is used whole until somebody crops it.
A crop is a new file cut from the upload with SimpleImage, which the
watermark already uses; the upload is kept (logo_original_path) with
the box (logo_crop), so cropping again starts from the whole picture and
restoring points back at the upload. A box covering the whole image is
a restore. The box must lie inside the image, and an image over 25
million pixels is refused before GD decodes it.

A new upload, or removing the logo, deletes both files.
2026-10-03 12:02:41 -03:00

121 lines
4.0 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Platform\Branding;
use App\Modules\Platform\Branding\Models\BrandingSetting;
use claviska\SimpleImage;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/**
* Cutting the logo down to part of itself, and putting the whole of it back.
*
* The uploaded file is never changed. A crop is a new file written from it,
* so cropping again starts from everything that was uploaded rather than
* from the last crop, and restoring is pointing back at the upload.
*
* Coordinates are in the upload's stored pixels, which is what GD reads. The
* cropper shows the image the same way (`image-orientation: none`), because
* the images here have no exif extension to rotate a phone photo by its
* orientation tag: if the browser rotated it and GD did not, the box would
* land on the wrong part of the picture.
*/
class LogoCropper
{
/**
* GD holds four bytes a pixel, and a 2 MB file can still describe a very
* large image if it compresses well. 25 million pixels is about 100 MB of
* memory, and far beyond any logo.
*/
public const MAX_PIXELS = 25_000_000;
/**
* @param array{x: int, y: int, width: int, height: int} $box
*/
public function crop(BrandingSetting $setting, array $box): void
{
$source = $setting->logoSourcePath();
$disk = Storage::disk('public');
if ($source === null || ! $disk->exists($source)) {
throw ValidationException::withMessages(['logo' => __('Upload a logo before cropping it.')]);
}
$absolute = $disk->path($source);
$size = @getimagesize($absolute);
if ($size === false) {
throw ValidationException::withMessages(['logo' => __('This logo cannot be cropped. Upload it again.')]);
}
[$width, $height] = $size;
if ($width * $height > self::MAX_PIXELS) {
throw ValidationException::withMessages(['logo' => __('This image is too large to crop. Upload a smaller one.')]);
}
if ($box['x'] + $box['width'] > $width || $box['y'] + $box['height'] > $height) {
throw ValidationException::withMessages(['width' => __('The crop must stay inside the image.')]);
}
// The whole picture is the upload itself: no second copy of it.
if ($box['x'] === 0 && $box['y'] === 0 && $box['width'] === $width && $box['height'] === $height) {
$this->restore($setting);
return;
}
// Same extension as the upload, which took it from the content
// when it was stored (see BrandingController::storeImage).
$cropped = 'branding/'.Str::uuid().'.'.pathinfo($source, PATHINFO_EXTENSION);
(new SimpleImage($absolute))
->crop($box['x'], $box['y'], $box['x'] + $box['width'], $box['y'] + $box['height'])
->toFile($disk->path($cropped), $size['mime']);
$previous = $setting->logoIsCropped() ? $setting->logo_path : null;
$setting->update([
'logo_original_path' => $source,
'logo_path' => $cropped,
'logo_crop' => $box,
]);
if ($previous !== null) {
$disk->delete($previous);
}
}
public function restore(BrandingSetting $setting): void
{
if (! $setting->logoIsCropped()) {
return;
}
$cropped = $setting->logo_path;
$setting->update([
'logo_path' => $setting->logo_original_path,
'logo_original_path' => null,
'logo_crop' => null,
]);
if ($cropped !== null) {
Storage::disk('public')->delete($cropped);
}
}
/**
* Every file the logo occupies: the one shown, and the upload behind it.
*
* @return list<string>
*/
public function files(BrandingSetting $setting): array
{
return array_values(array_filter([$setting->logo_path, $setting->logo_original_path]));
}
}