Phase 8 (Rest) + Umbauten vom 10./11.06.: - Ein Titelbild pro PM (Cover 1280x580), SVG-Platzhalter-Set + Picker, PressReleaseCoverImage-Resolver - Lizenz-/Rechteformular nach "Lizenztyp Bildupload" (7 Lizenztypen, Personen-/Sachrechte-Status, bedingte Pflichtfelder, Risikohinweise) - Veroeffentlichungs-Box vereinfacht (Embargo aus der Form-UI entfernt), geplante Termine in Europe/Berlin (Speicherung UTC, DISPLAY_TIMEZONE) - Quota-Stub (users.press_release_quota) + monatlicher Reset-Command - Einreichungs-Modal einheitlich in Show/Create/Edit; Ghost-Buttons auf filled; PM-Editor-Layout responsive entkoppelt (.pr-editor-layout) KI-Pruef-Pipeline (Phasen 1-5 des Entwicklungsplans): - API-Haertung: status nicht mehr per API setzbar, eigene Submit-Route durch denselben Funnel (Blacklist, Quota, Status-Log) - Klassifikation Rot/Gelb/Gruen asynchron (Queue classification, OpenAI-Treiber + deterministischer Fallback), ki_audits-Audit-Log - Routing: Rot -> rejected + Mail, Gelb -> Review-Queue, Gruen -> Auto-Publish; Scheduler publiziert nur gruene faellige PMs - Content-Score 0-100 -> Stufe (Standard/Geprueft/Hochwertig) inkl. Editor-Panel und Badges; Re-Klassifikation/-Score bei Aenderung - Admin: KI-Badge + Filter, On-Demand-Pruefung mit Anbieter-Override Suite: 442 passed, 4 skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
442 lines
15 KiB
PHP
442 lines
15 KiB
PHP
<?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],
|
|
// 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<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, 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<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');
|
|
|
|
$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<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,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Generates and persists missing variants for an existing company logo.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
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<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);
|
|
}
|
|
}
|