12-05-2026 Frontend dev
This commit is contained in:
parent
405df0a122
commit
5b8bdf4182
779 changed files with 480564 additions and 6241 deletions
416
app/Services/Image/ImageService.php
Normal file
416
app/Services/Image/ImageService.php
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Image;
|
||||
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Centralised image processing for company logos and press release images.
|
||||
*
|
||||
* Uses GD natively to keep the dependency footprint small. Should
|
||||
* `intervention/image` be added later, only the private rendering
|
||||
* helpers below need to be replaced.
|
||||
*
|
||||
* Generates the logo variant set defined by the CRM data model:
|
||||
* - sq: 1:1 contained, 256x256
|
||||
* - wide: 2:1 contained, 640x320
|
||||
*
|
||||
* Each variant is written next to the original and registered in
|
||||
* `companies.logo_variants` (JSON).
|
||||
*/
|
||||
class ImageService
|
||||
{
|
||||
/**
|
||||
* @var array<string, array{width: int, height: int}>
|
||||
*/
|
||||
public const LOGO_VARIANTS = [
|
||||
'sq' => ['width' => 256, 'height' => 256],
|
||||
'wide' => ['width' => 640, 'height' => 320],
|
||||
];
|
||||
|
||||
/**
|
||||
* Press release image variants. The `large` variant is the canonical
|
||||
* full-size representation we serve in the new portal; `thumb` and
|
||||
* `medium` are derived for listings and previews.
|
||||
*
|
||||
* @var array<string, array{width: int, height: int, fit?: string}>
|
||||
*/
|
||||
public const PRESS_RELEASE_IMAGE_VARIANTS = [
|
||||
'thumb' => ['width' => 320, 'height' => 240],
|
||||
'medium' => ['width' => 800, 'height' => 600],
|
||||
'large' => ['width' => 1600, 'height' => 1200],
|
||||
];
|
||||
|
||||
public const ALLOWED_LOGO_MIME_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
];
|
||||
|
||||
public const ALLOWED_PRESS_RELEASE_IMAGE_MIME_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
];
|
||||
|
||||
public const MAX_LOGO_BYTES = 4 * 1024 * 1024; // 4 MB
|
||||
|
||||
public const MAX_PRESS_RELEASE_IMAGE_BYTES = 8 * 1024 * 1024; // 8 MB
|
||||
|
||||
public function __construct(private readonly string $disk = 'public') {}
|
||||
|
||||
/**
|
||||
* Persists a freshly uploaded company logo and generates all variants.
|
||||
*
|
||||
* @return array{path: string, variants: array<string, string>}
|
||||
*/
|
||||
public function storeCompanyLogo(UploadedFile $upload, string $portal, int $companyId): array
|
||||
{
|
||||
$this->assertValidLogoUpload($upload);
|
||||
|
||||
$directory = sprintf('company-logos/%s/%d', $portal, $companyId);
|
||||
$extension = $this->normalizedExtension($upload->getMimeType());
|
||||
$filename = Str::uuid()->toString().'.'.$extension;
|
||||
|
||||
$relativePath = $directory.'/'.$filename;
|
||||
|
||||
$disk = $this->disk();
|
||||
$disk->put($relativePath, $upload->get(), 'public');
|
||||
|
||||
$variants = $this->generateLogoVariants($disk, $relativePath, $extension);
|
||||
|
||||
return [
|
||||
'path' => $relativePath,
|
||||
'variants' => $variants,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a logo and all variants below the given relative path.
|
||||
*/
|
||||
public function deleteCompanyLogo(?string $relativePath, ?array $variants): void
|
||||
{
|
||||
$this->deleteWithVariants($relativePath, $variants);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a freshly uploaded press release image and generates all
|
||||
* variants. Original is stored under `press-releases/{id}/images`.
|
||||
*
|
||||
* @return array{
|
||||
* path: string,
|
||||
* variants: array<string, string>,
|
||||
* width: int|null,
|
||||
* height: int|null,
|
||||
* mime: string|null,
|
||||
* }
|
||||
*/
|
||||
public function storePressReleaseImage(UploadedFile $upload, int $pressReleaseId): array
|
||||
{
|
||||
$this->assertValidPressReleaseImageUpload($upload);
|
||||
|
||||
$directory = sprintf('press-releases/%d/images', $pressReleaseId);
|
||||
$extension = $this->normalizedExtension($upload->getMimeType());
|
||||
$filename = Str::uuid()->toString().'.'.$extension;
|
||||
$relativePath = $directory.'/'.$filename;
|
||||
|
||||
$disk = $this->disk();
|
||||
$disk->put($relativePath, $upload->get(), 'public');
|
||||
|
||||
$absolute = $disk->path($relativePath);
|
||||
$size = @getimagesize($absolute) ?: [null, null];
|
||||
|
||||
$variants = $this->generateVariants(
|
||||
$disk,
|
||||
$relativePath,
|
||||
$extension,
|
||||
self::PRESS_RELEASE_IMAGE_VARIANTS,
|
||||
cover: true,
|
||||
);
|
||||
|
||||
return [
|
||||
'path' => $relativePath,
|
||||
'variants' => $variants,
|
||||
'width' => is_int($size[0] ?? null) ? $size[0] : null,
|
||||
'height' => is_int($size[1] ?? null) ? $size[1] : null,
|
||||
'mime' => $upload->getMimeType(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a press release image and all variants on the given disk.
|
||||
*
|
||||
* @param array<string, string>|null $variants
|
||||
*/
|
||||
public function deletePressReleaseImage(string $disk, ?string $relativePath, ?array $variants): void
|
||||
{
|
||||
$this->deleteWithVariants($relativePath, $variants, $disk);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates and persists missing variants for an existing image (for
|
||||
* legacy images that already live on disk). Returns the variant map.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function generateMissingPressReleaseVariants(string $relativePath): array
|
||||
{
|
||||
$disk = $this->disk();
|
||||
|
||||
if (! $disk->exists($relativePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$extension = strtolower(pathinfo($relativePath, PATHINFO_EXTENSION) ?: 'jpg');
|
||||
|
||||
return $this->generateVariants(
|
||||
$disk,
|
||||
$relativePath,
|
||||
$extension,
|
||||
self::PRESS_RELEASE_IMAGE_VARIANTS,
|
||||
cover: true,
|
||||
);
|
||||
}
|
||||
|
||||
private function deleteWithVariants(?string $relativePath, ?array $variants, ?string $diskName = null): void
|
||||
{
|
||||
$disk = $diskName ? Storage::disk($diskName) : $this->disk();
|
||||
|
||||
if (filled($relativePath) && $disk->exists($relativePath)) {
|
||||
$disk->delete($relativePath);
|
||||
}
|
||||
|
||||
if (is_array($variants)) {
|
||||
foreach ($variants as $variantPath) {
|
||||
if (is_string($variantPath) && $disk->exists($variantPath)) {
|
||||
$disk->delete($variantPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function generateLogoVariants(Filesystem $disk, string $relativePath, string $extension): array
|
||||
{
|
||||
return $this->generateVariants($disk, $relativePath, $extension, self::LOGO_VARIANTS, cover: false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic variant generator. `cover` switches between contained
|
||||
* (transparent letterbox, used for logos) and cover (cropped to fill,
|
||||
* used for press release imagery).
|
||||
*
|
||||
* @param array<string, array{width: int, height: int}> $variantSpecs
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function generateVariants(Filesystem $disk, string $relativePath, string $extension, array $variantSpecs, bool $cover = false): array
|
||||
{
|
||||
$absolute = $disk->path($relativePath);
|
||||
$sourceImage = $this->createImageResource($absolute, $extension);
|
||||
|
||||
if (! $sourceImage) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$variants = [];
|
||||
$sourceWidth = imagesx($sourceImage);
|
||||
$sourceHeight = imagesy($sourceImage);
|
||||
|
||||
foreach ($variantSpecs as $key => $size) {
|
||||
$variantPath = $this->variantPath($relativePath, $key);
|
||||
$variantAbsolute = $disk->path($variantPath);
|
||||
|
||||
$disk->makeDirectory(dirname($variantPath));
|
||||
|
||||
$targetWidth = $size['width'];
|
||||
$targetHeight = $size['height'];
|
||||
|
||||
$upscale = $sourceWidth < $targetWidth && $sourceHeight < $targetHeight;
|
||||
if ($upscale) {
|
||||
$scale = min($sourceWidth / $targetWidth, $sourceHeight / $targetHeight);
|
||||
$targetWidth = max(1, (int) round($targetWidth * $scale));
|
||||
$targetHeight = max(1, (int) round($targetHeight * $scale));
|
||||
}
|
||||
|
||||
$resized = $cover
|
||||
? $this->resizeCover($sourceImage, $targetWidth, $targetHeight)
|
||||
: $this->resizeContained($sourceImage, $targetWidth, $targetHeight);
|
||||
|
||||
$written = $this->writeImage($resized, $variantAbsolute, $extension);
|
||||
|
||||
imagedestroy($resized);
|
||||
|
||||
if ($written) {
|
||||
$variants[$key] = $variantPath;
|
||||
}
|
||||
}
|
||||
|
||||
return $variants;
|
||||
} finally {
|
||||
imagedestroy($sourceImage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \GdImage|false
|
||||
*/
|
||||
private function createImageResource(string $absolutePath, string $extension)
|
||||
{
|
||||
return match ($extension) {
|
||||
'jpg' => @imagecreatefromjpeg($absolutePath),
|
||||
'png' => @imagecreatefrompng($absolutePath),
|
||||
'webp' => @imagecreatefromwebp($absolutePath),
|
||||
'gif' => @imagecreatefromgif($absolutePath),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
private function writeImage(\GdImage $image, string $absolutePath, string $extension): bool
|
||||
{
|
||||
return match ($extension) {
|
||||
'jpg' => imagejpeg($image, $absolutePath, 88),
|
||||
'png' => imagepng($image, $absolutePath, 6),
|
||||
'webp' => imagewebp($image, $absolutePath, 88),
|
||||
'gif' => imagegif($image, $absolutePath),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes preserving aspect ratio with transparent letterbox/pillarbox
|
||||
* so logos always render fully on a square or 2:1 canvas.
|
||||
*/
|
||||
private function resizeContained(\GdImage $source, int $targetWidth, int $targetHeight): \GdImage
|
||||
{
|
||||
$sourceWidth = imagesx($source);
|
||||
$sourceHeight = imagesy($source);
|
||||
|
||||
$scale = min($targetWidth / $sourceWidth, $targetHeight / $sourceHeight);
|
||||
$resizedWidth = max(1, (int) round($sourceWidth * $scale));
|
||||
$resizedHeight = max(1, (int) round($sourceHeight * $scale));
|
||||
|
||||
$offsetX = (int) round(($targetWidth - $resizedWidth) / 2);
|
||||
$offsetY = (int) round(($targetHeight - $resizedHeight) / 2);
|
||||
|
||||
$canvas = imagecreatetruecolor($targetWidth, $targetHeight);
|
||||
|
||||
imagealphablending($canvas, false);
|
||||
imagesavealpha($canvas, true);
|
||||
|
||||
$transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127);
|
||||
imagefilledrectangle($canvas, 0, 0, $targetWidth, $targetHeight, $transparent);
|
||||
|
||||
imagealphablending($canvas, true);
|
||||
|
||||
imagecopyresampled(
|
||||
$canvas,
|
||||
$source,
|
||||
$offsetX,
|
||||
$offsetY,
|
||||
0,
|
||||
0,
|
||||
$resizedWidth,
|
||||
$resizedHeight,
|
||||
$sourceWidth,
|
||||
$sourceHeight,
|
||||
);
|
||||
|
||||
return $canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cover-resize: scale source so that the target box is fully filled, then
|
||||
* crop the overflow centered. Used for press release imagery so listings
|
||||
* always render rectangles without empty bars.
|
||||
*/
|
||||
private function resizeCover(\GdImage $source, int $targetWidth, int $targetHeight): \GdImage
|
||||
{
|
||||
$sourceWidth = imagesx($source);
|
||||
$sourceHeight = imagesy($source);
|
||||
|
||||
$scale = max($targetWidth / $sourceWidth, $targetHeight / $sourceHeight);
|
||||
$sampledWidth = max(1, (int) round($targetWidth / $scale));
|
||||
$sampledHeight = max(1, (int) round($targetHeight / $scale));
|
||||
|
||||
$sourceX = (int) round(($sourceWidth - $sampledWidth) / 2);
|
||||
$sourceY = (int) round(($sourceHeight - $sampledHeight) / 2);
|
||||
|
||||
$canvas = imagecreatetruecolor($targetWidth, $targetHeight);
|
||||
|
||||
imagealphablending($canvas, false);
|
||||
imagesavealpha($canvas, true);
|
||||
$transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127);
|
||||
imagefilledrectangle($canvas, 0, 0, $targetWidth, $targetHeight, $transparent);
|
||||
imagealphablending($canvas, true);
|
||||
|
||||
imagecopyresampled(
|
||||
$canvas,
|
||||
$source,
|
||||
0,
|
||||
0,
|
||||
$sourceX,
|
||||
$sourceY,
|
||||
$targetWidth,
|
||||
$targetHeight,
|
||||
$sampledWidth,
|
||||
$sampledHeight,
|
||||
);
|
||||
|
||||
return $canvas;
|
||||
}
|
||||
|
||||
private function variantPath(string $originalRelativePath, string $variantKey): string
|
||||
{
|
||||
$info = pathinfo($originalRelativePath);
|
||||
$filename = $info['filename'] ?? Str::uuid()->toString();
|
||||
$extension = $info['extension'] ?? 'jpg';
|
||||
|
||||
return ($info['dirname'] ?? '').'/variants/'.$filename.'-'.$variantKey.'.'.$extension;
|
||||
}
|
||||
|
||||
private function normalizedExtension(?string $mimeType): string
|
||||
{
|
||||
return match ($mimeType) {
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
'image/gif' => 'gif',
|
||||
'image/jpeg', 'image/jpg' => 'jpg',
|
||||
default => 'jpg',
|
||||
};
|
||||
}
|
||||
|
||||
private function assertValidLogoUpload(UploadedFile $upload): void
|
||||
{
|
||||
if (! in_array($upload->getMimeType(), self::ALLOWED_LOGO_MIME_TYPES, true)) {
|
||||
throw new RuntimeException('Unsupported logo mime type: '.($upload->getMimeType() ?? 'unknown'));
|
||||
}
|
||||
|
||||
if ($upload->getSize() > self::MAX_LOGO_BYTES) {
|
||||
throw new RuntimeException('Logo exceeds maximum allowed size of '.self::MAX_LOGO_BYTES.' bytes.');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertValidPressReleaseImageUpload(UploadedFile $upload): void
|
||||
{
|
||||
if (! in_array($upload->getMimeType(), self::ALLOWED_PRESS_RELEASE_IMAGE_MIME_TYPES, true)) {
|
||||
throw new RuntimeException('Unsupported press release image mime type: '.($upload->getMimeType() ?? 'unknown'));
|
||||
}
|
||||
|
||||
if ($upload->getSize() > self::MAX_PRESS_RELEASE_IMAGE_BYTES) {
|
||||
throw new RuntimeException('Press release image exceeds maximum allowed size of '.self::MAX_PRESS_RELEASE_IMAGE_BYTES.' bytes.');
|
||||
}
|
||||
}
|
||||
|
||||
private function disk(): Filesystem
|
||||
{
|
||||
return Storage::disk($this->disk);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue