*/ 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], // OpenGraph-Vorschau (Decision-Update E7): bekommt in H.1 den // eingebrannten Nachweisbalken; bis dahin ein normaler Zuschnitt. 'og' => ['width' => 1200, 'height' => 630], ]; /** * Zuschnitt-Steuerung je Bild (Bildkennzeichnung H.3, § 9 Abs. 1 AGB): * Fokuspunkt in Prozent der Bildbreite/-höhe, Standard Bildmitte. * * @var array{x: int, y: int} */ public const DEFAULT_FOCUS = ['x' => 50, 'y' => 50]; 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 /** * Schrift für den eingebrannten Nachweisbalken der OG-Variante * (Bildkennzeichnung H.1, O4): Inter SemiBold, OFL 1.1, im Repository. */ public const OG_CAPTION_FONT = 'fonts/Inter_18pt-SemiBold.ttf'; 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(); // Disks laufen mit throw=false: Ein Fehlschlag kommt nur als false // zurück — ohne Prüfung bekäme der Aufrufer einen Pfad ohne Datei (F23). if (! $disk->put($relativePath, $upload->get(), 'public')) { throw new RuntimeException('Das Logo konnte nicht gespeichert werden.'); } $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 working copy in the derivative folder. The canonical * stored path points to the cover variant to keep storage usage * predictable. * * Das Beweisoriginal liegt NICHT hier: Der Uploader (Bildkennzeichnung * A.6) sichert es vorher auf den privaten Originalpfad. Scheitert die * Cover-Variante, wird abgebrochen — kein Rückfall auf die Quelldatei, * damit nie ein unbereinigtes Original öffentlich wird (Feedback F05). * * @param array{x: int, y: int}|null $focus Fokuspunkt in Prozent (H.3) * @param bool $noCrop Letterbox statt Zuschnitt (H.3) * @return array{ * disk: string, * path: string, * variants: array, * width: int|null, * height: int|null, * mime: string|null, * } */ public function storePressReleaseImage( UploadedFile $upload, int $pressReleaseId, string $diskName = 'local', ?array $focus = null, bool $noCrop = false, ?string $ogCaption = null, ): 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($diskName); if (! $disk->put($relativePath, $upload->get(), $diskName === 'public' ? 'public' : 'private')) { throw new RuntimeException('Das Bild konnte nicht gespeichert werden.'); } try { $variants = $this->generateVariants( $disk, $relativePath, $extension, self::PRESS_RELEASE_IMAGE_VARIANTS, cover: true, focus: $focus, noCrop: $noCrop, ogCaption: $ogCaption, ); } finally { // Die Arbeitskopie im Derivat-Ordner wird immer entfernt — auch // bei Fehlern, damit nichts Unbereinigtes liegen bleibt. if ($disk->exists($relativePath)) { $disk->delete($relativePath); } } if (! isset($variants['cover'])) { $this->deleteWithVariants(null, $variants, $diskName); throw new RuntimeException('Das Bild konnte nicht verarbeitet werden (Titelbild-Variante fehlt).'); } $coverPath = $variants['cover']; $coverAbsolute = $disk->path($coverPath); $coverSize = @getimagesize($coverAbsolute) ?: [null, null]; return [ 'disk' => $diskName, '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). `focus` verschiebt den Zuschnitt, * `noCrop` erzwingt Letterbox auch für Pressebilder (H.3). * * @param array $variantSpecs * @param array{x: int, y: int}|null $focus * @return array */ /** * OG-Variante neu erzeugen (Bildkennzeichnung H.1): aus der größten * vorhandenen Variante, mit eingebranntem Nachweisbalken. Nötig nach * einer Korrektur des Nachweises (E.6) und für den Bestand ohne * OG-Variante. Liefert den Variantenpfad oder null. * * @param array|null $variants * @param array{x: int, y: int}|null $focus */ public function regenerateOgVariant(string $diskName, ?array $variants, ?string $fallbackPath, ?array $focus, bool $noCrop, ?string $caption): ?string { $disk = $this->disk($diskName); $source = null; foreach (['large', 'cover', 'medium'] as $key) { if (is_string($variants[$key] ?? null) && $disk->exists($variants[$key])) { $source = $variants[$key]; break; } } $source ??= (is_string($fallbackPath) && $disk->exists($fallbackPath)) ? $fallbackPath : null; if ($source === null) { return null; } $extension = strtolower(pathinfo($source, PATHINFO_EXTENSION) ?: 'jpg'); $sourceImage = $this->createImageResource($disk->path($source), $extension); if (! $sourceImage) { return null; } try { $spec = self::PRESS_RELEASE_IMAGE_VARIANTS['og']; $ogPath = preg_replace('/-(large|cover|medium)\.([a-z0-9]+)$/i', '-og.$2', $source); if (! is_string($ogPath) || $ogPath === $source) { $ogPath = $this->variantPath($source, 'og'); } $disk->makeDirectory(dirname($ogPath)); $resized = $noCrop ? $this->resizeContained($sourceImage, $spec['width'], $spec['height'], opaque: $extension === 'jpg') : $this->resizeCover($sourceImage, $spec['width'], $spec['height'], $focus); if ($caption !== null && $caption !== '') { $this->burnCaption($resized, $caption); } $written = $this->writeImage($resized, $disk->path($ogPath), $extension); imagedestroy($resized); return $written ? $ogPath : null; } finally { imagedestroy($sourceImage); } } /** * Halbtransparenter Balken am unteren Rand mit dem Nachweis in Inter * SemiBold; der Text wird auf die Breite gekürzt. Ohne Schrift bleibt * das Bild unverändert (H.1, O4). */ private function burnCaption(\GdImage $image, string $caption): void { $font = resource_path(self::OG_CAPTION_FONT); if (! is_file($font) || ! function_exists('imagettftext')) { return; } $width = imagesx($image); $height = imagesy($image); $bar = max(44, (int) round($height * 0.105)); $size = max(14, (int) round($height * 0.034)); $padding = max(16, (int) round($width * 0.023)); imagealphablending($image, true); $shade = imagecolorallocatealpha($image, 0, 0, 0, 38); imagefilledrectangle($image, 0, $height - $bar, $width, $height, $shade); $text = $this->fitCaption($caption, $font, $size, $width - 2 * $padding); $box = imagettfbbox($size, 0, $font, $text); $textHeight = abs($box[7] - $box[1]); $baseline = $height - (int) round(($bar - $textHeight) / 2); $white = imagecolorallocate($image, 255, 255, 255); imagettftext($image, $size, 0, $padding, $baseline, $white, $font, $text); } private function fitCaption(string $caption, string $font, int $size, int $maxWidth): string { $text = trim(preg_replace('/\s+/u', ' ', $caption) ?? $caption); while ($text !== '') { $box = imagettfbbox($size, 0, $font, $text); if (abs($box[2] - $box[0]) <= $maxWidth) { return $text; } $text = rtrim(mb_substr($text, 0, max(0, mb_strlen($text) - 4))).'…'; if (mb_strlen($text) <= 4) { return $text; } } return $text; } private function generateVariants(Filesystem $disk, string $relativePath, string $extension, array $variantSpecs, bool $cover = false, ?array $focus = null, bool $noCrop = false, ?string $ogCaption = null): 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 && ! $noCrop ? $this->resizeCover($sourceImage, $targetWidth, $targetHeight, $focus) : $this->resizeContained($sourceImage, $targetWidth, $targetHeight, opaque: $cover && $extension === 'jpg'); // OpenGraph-Vorschau trägt den Nachweis eingebrannt (H.1, E7). if ($key === 'og' && $ogCaption !== null && $ogCaption !== '') { $this->burnCaption($resized, $ogCaption); } $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, bool $opaque = false): \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); // JPEG kennt keine Transparenz: neutraler heller Rand für die // Letterbox (no_crop, H.3); sonst transparent wie bei Logos. $background = $opaque ? imagecolorallocate($canvas, 244, 244, 245) : imagecolorallocatealpha($canvas, 0, 0, 0, 127); imagefilledrectangle($canvas, 0, 0, $targetWidth, $targetHeight, $background); 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 around the focus point (default: centered). Used for * press release imagery so listings always render rectangles without * empty bars. * * @param array{x: int, y: int}|null $focus Fokuspunkt in Prozent (H.3) */ private function resizeCover(\GdImage $source, int $targetWidth, int $targetHeight, ?array $focus = null): \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)); $focusX = max(0, min(100, (int) ($focus['x'] ?? self::DEFAULT_FOCUS['x']))) / 100; $focusY = max(0, min(100, (int) ($focus['y'] ?? self::DEFAULT_FOCUS['y']))) / 100; // Der Ausschnitt wird so gelegt, dass der Fokuspunkt möglichst an // derselben relativen Position bleibt; an den Rändern wird geklemmt. $sourceX = (int) round(max(0, min($sourceWidth - $sampledWidth, $sourceWidth * $focusX - $sampledWidth * $focusX))); $sourceY = (int) round(max(0, min($sourceHeight - $sampledHeight, $sourceHeight * $focusY - $sampledHeight * $focusY))); $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(?string $diskName = null): Filesystem { return Storage::disk($diskName ?? $this->disk); } }