presseportale/app/Services/Image/ImageService.php
Kevin Adametz 075d0d931f legal/web: Zustimmungsgate je Dokumentversion (aus bis AGB 0.7), OG-Variante mit eingebranntem Bildnachweis, Nachweis in Teasern (Bildkennzeichnung Blöcke G und H)
- legal_document_acceptances (insert-only), LegalAcceptanceGate, Pflicht-Schalter im Einreichungs-Modal über den gemeinsamen Trait; API: GET/POST /api/v1/legal/acceptances, submit antwortet 428 mit den geforderten Fassungen; LEGAL_ACCEPTANCE_GATE
- ImageService: og-Variante (1200×630) mit halbtransparentem Balken und Inter SemiBold, regenerateOgVariant() für Korrekturen und Bestand; Fokuspunkt/Letterbox getestet
- Uploader brennt den Nachweis beim Upload ein, ImageCorrectionService baut die OG-Variante nach einer Korrektur neu; Artikelseite nutzt og für og:image
- Teaser: Nachweis in alt/title (Feed, Hero, Kategorie, Newsroom), Unterschrift am Hero-Bild; PressRelease::cardImageCredit()

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 10:12:17 +00:00

629 lines
22 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],
// 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<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();
// 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<string, string>,
* 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<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). `focus` verschiebt den Zuschnitt,
* `noCrop` erzwingt Letterbox auch für Pressebilder (H.3).
*
* @param array<string, array{width: int, height: int}> $variantSpecs
* @param array{x: int, y: int}|null $focus
* @return array<string, string>
*/
/**
* 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<string, string>|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);
}
}