- 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>
629 lines
22 KiB
PHP
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);
|
|
}
|
|
}
|