*/ 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 */ public const PRESS_RELEASE_IMAGE_VARIANTS = [ 'thumb' => ['width' => 320, 'height' => 240], 'medium' => ['width' => 800, 'height' => 600], 'large' => ['width' => 1600, 'height' => 1200], // Titelbild (Hero) der Detailansicht: harte Obergrenze 1280x580 px. 'cover' => ['width' => 1280, 'height' => 580], ]; 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 = 16 * 1024 * 1024; // 16 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} */ 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, generates all variants * and discards the original upload. The canonical stored path points to * the cover variant to keep storage usage predictable. * * @return array{ * path: string, * variants: array, * 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'); $variants = $this->generateVariants( $disk, $relativePath, $extension, self::PRESS_RELEASE_IMAGE_VARIANTS, cover: true, ); $coverPath = $variants['cover'] ?? $relativePath; $coverAbsolute = $disk->path($coverPath); $coverSize = @getimagesize($coverAbsolute) ?: [null, null]; if ($coverPath !== $relativePath && $disk->exists($relativePath)) { $disk->delete($relativePath); } return [ 'path' => $coverPath, 'variants' => $variants, 'width' => is_int($coverSize[0] ?? null) ? $coverSize[0] : null, 'height' => is_int($coverSize[1] ?? null) ? $coverSize[1] : null, 'mime' => $upload->getMimeType(), ]; } /** * Deletes a press release image and all variants on the given disk. * * @param array|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 */ 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, ); } /** * Generates and persists missing variants for an existing company logo. * * @return array */ public function generateMissingCompanyLogoVariants(string $relativePath): array { $disk = $this->disk(); if (! $disk->exists($relativePath)) { return []; } $extension = strtolower(pathinfo($relativePath, PATHINFO_EXTENSION) ?: 'jpg'); return $this->generateLogoVariants($disk, $relativePath, $extension); } 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 */ 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 $variantSpecs * @return array */ 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); } }