buildImage($opts); $saveDir = Environment::getPublicPath() . '/' . self::SAVE_DIR; GeneralUtility::mkdir_deep($saveDir); $hashSeed = json_encode($opts, JSON_THROW_ON_ERROR) . microtime(true) . random_int(1000, 9999); $fileName = 'og-' . substr(sha1($hashSeed), 0, 12) . '.' . ($opts['output_format'] ?? 'jpg'); $fullPath = $saveDir . $fileName; $this->saveImage($gd, $fullPath, $opts['output_format'] ?? 'jpg', (int)($opts['output_quality'] ?? 90)); imagedestroy($gd); $publicUrl = '/' . self::SAVE_DIR . $fileName; return [true, 'Image saved: ' . $publicUrl, $publicUrl]; } catch (\Throwable $e) { return [false, 'Error: ' . $e->getMessage(), '']; } } /** * Generate a base64 data-URI preview (no file write). * * @return array{0: bool, 1: string, 2: string} [success, message, dataUri] */ public function generatePreview(array $opts): array { try { $gd = $this->buildImage($opts); ob_start(); imagepng($gd); $raw = ob_get_clean(); imagedestroy($gd); return [true, 'OK', 'data:image/png;base64,' . base64_encode($raw)]; } catch (\Throwable $e) { return [false, $e->getMessage(), '']; } } /** * Return list of previously generated images in the save dir. */ public function getSavedImages(): array { $dir = Environment::getPublicPath() . '/' . self::SAVE_DIR; if (!is_dir($dir)) { return []; } $files = glob($dir . '*.{jpg,jpeg,png}', GLOB_BRACE) ?: []; usort($files, static fn($a, $b) => filemtime($b) <=> filemtime($a)); return array_map(static fn($f) => [ 'path' => '/' . self::SAVE_DIR . basename($f), 'name' => basename($f), 'created' => date('Y-m-d H:i', filemtime($f)), ], array_slice($files, 0, 20)); } /** * Return selectable background images from fileadmin/og-backgrounds/. */ public function getBackgroundImages(): array { $dir = Environment::getPublicPath() . '/fileadmin/og-backgrounds/'; if (!is_dir($dir)) { return []; } $files = glob($dir . '*.{jpg,jpeg,png,webp}', GLOB_BRACE) ?: []; return array_map(static fn($f) => [ 'path' => '/fileadmin/og-backgrounds/' . basename($f), 'label' => basename($f), ], $files); } // ────────────────────────────────────────────────────────────────── // Core image builder // ────────────────────────────────────────────────────────────────── /** @return \GdImage */ private function buildImage(array $opts): \GdImage { $canvas = imagecreatetruecolor(self::OG_WIDTH, self::OG_HEIGHT); if ($canvas === false) { throw new \RuntimeException('imagecreatetruecolor failed – GD not available.'); } // Enable alpha blending imagealphablending($canvas, true); imagesavealpha($canvas, true); // 1. Background $this->drawBackground($canvas, $opts); // 2. Optional dark overlay when bg_image is set if (!empty($opts['bg_image']) && ($opts['bg_type'] ?? '') === 'image') { $opacity = max(0, min(100, (int)($opts['overlay_opacity'] ?? 40))); $this->drawOverlay($canvas, $opacity); } // 3. Label / badge if (!empty(trim($opts['label_text'] ?? ''))) { $this->drawLabel($canvas, $opts); } // 4. Title if (!empty(trim($opts['title'] ?? ''))) { $this->drawTitle($canvas, $opts); } // 5. Subtitle if (!empty(trim($opts['subtitle'] ?? ''))) { $this->drawSubtitle($canvas, $opts); } // 6. Logo (optional) if (!empty($opts['logo_path'])) { $this->drawLogo($canvas, $opts['logo_path']); } return $canvas; } // ────────────────────────────────────────────────────────────────── // Drawing helpers // ────────────────────────────────────────────────────────────────── private function drawBackground(\GdImage $canvas, array $opts): void { $bgType = $opts['bg_type'] ?? 'color'; if ($bgType === 'image' && !empty($opts['bg_image'])) { $imagePath = $this->resolvePath($opts['bg_image']); $src = $this->loadImageFromPath($imagePath); if ($src !== null) { imagecopyresampled( $canvas, $src, 0, 0, 0, 0, self::OG_WIDTH, self::OG_HEIGHT, imagesx($src), imagesy($src) ); imagedestroy($src); return; } } // Solid colour fallback $hex = ltrim($opts['bg_color'] ?? '#1a1a2e', '#'); [$r, $g, $b] = $this->hexToRgb($hex); $bg = imagecolorallocate($canvas, $r, $g, $b); imagefilledrectangle($canvas, 0, 0, self::OG_WIDTH - 1, self::OG_HEIGHT - 1, $bg); } private function drawOverlay(\GdImage $canvas, int $opacityPercent): void { // opacity 0 = fully transparent overlay, 100 = solid black $alpha = (int)round(127 - ($opacityPercent / 100 * 127)); $color = imagecolorallocatealpha($canvas, 0, 0, 0, $alpha); imagefilledrectangle($canvas, 0, 0, self::OG_WIDTH - 1, self::OG_HEIGHT - 1, $color); } private function drawLabel(\GdImage $canvas, array $opts): void { $text = strtoupper(trim($opts['label_text'] ?? 'LABEL')); $fontSize = 22; $font = $this->fontPath(true); $padding = 14; $bbox = imagettfbbox($fontSize, 0, $font, $text); $textW = abs($bbox[4] - $bbox[0]); $textH = abs($bbox[5] - $bbox[1]); $boxW = $textW + $padding * 2; $boxH = $textH + $padding; $position = $opts['label_position'] ?? 'top-left'; [$bx, $by] = $this->labelCoords($position, $boxW, $boxH); [$br, $bg, $bb] = $this->hexToRgb(ltrim($opts['label_bg_color'] ?? '#e63946', '#')); $bgColor = imagecolorallocate($canvas, $br, $bg, $bb); imagefilledrectangle($canvas, $bx, $by, $bx + $boxW, $by + $boxH, $bgColor); [$tr, $tg, $tb] = $this->hexToRgb(ltrim($opts['label_text_color'] ?? '#ffffff', '#')); $textColor = imagecolorallocate($canvas, $tr, $tg, $tb); imagettftext($canvas, $fontSize, 0, $bx + $padding, $by + $textH + (int)($padding / 2), $textColor, $font, $text); } private function drawTitle(\GdImage $canvas, array $opts): void { $text = trim($opts['title'] ?? ''); $fontSize = max(20, min(120, (int)($opts['title_size'] ?? 64))); $font = $this->fontPath(true); $maxWidth = self::OG_WIDTH - self::PADDING * 2; [$r, $g, $b] = $this->hexToRgb(ltrim($opts['title_color'] ?? '#ffffff', '#')); $color = imagecolorallocate($canvas, $r, $g, $b); $lines = $this->wrapText($text, $fontSize, $font, $maxWidth); $lineH = (int)($fontSize * 1.3); $startY = $this->titleStartY($opts, count($lines), $lineH, $fontSize); foreach ($lines as $i => $line) { $y = $startY + $i * $lineH; imagettftext($canvas, $fontSize, 0, self::PADDING, $y, $color, $font, $line); } } private function drawSubtitle(\GdImage $canvas, array $opts): void { $text = trim($opts['subtitle'] ?? ''); $fontSize = max(14, min(80, (int)($opts['subtitle_size'] ?? 32))); $font = $this->fontPath(false); $maxWidth = self::OG_WIDTH - self::PADDING * 2; [$r, $g, $b] = $this->hexToRgb(ltrim($opts['subtitle_color'] ?? '#cccccc', '#')); $color = imagecolorallocate($canvas, $r, $g, $b); $lines = $this->wrapText($text, $fontSize, $font, $maxWidth); $lineH = (int)($fontSize * 1.4); $startY = $this->subtitleStartY($opts, $fontSize); foreach ($lines as $i => $line) { $y = $startY + $i * $lineH; imagettftext($canvas, $fontSize, 0, self::PADDING, $y, $color, $font, $line); } } private function drawLogo(\GdImage $canvas, string $logoPath): void { $path = $this->resolvePath($logoPath); $src = $this->loadImageFromPath($path); if ($src === null) { return; } $logoW = 200; $logoH = (int)(imagesy($src) * ($logoW / imagesx($src))); $x = self::OG_WIDTH - self::PADDING - $logoW; $y = self::OG_HEIGHT - self::PADDING - $logoH; imagecopyresampled($canvas, $src, $x, $y, 0, 0, $logoW, $logoH, imagesx($src), imagesy($src)); imagedestroy($src); } // ────────────────────────────────────────────────────────────────── // Layout helpers // ────────────────────────────────────────────────────────────────── private function titleStartY(array $opts, int $lineCount, int $lineH, int $fontSize): int { $totalH = $lineCount * $lineH; $subH = empty(trim($opts['subtitle'] ?? '')) ? 0 : (int)($opts['subtitle_size'] ?? 32) + 20; $block = $totalH + $subH; $center = (int)((self::OG_HEIGHT - $block) / 2); // nudge up slightly so text block feels centered return max(self::PADDING + $fontSize, $center); } private function subtitleStartY(array $opts, int $fontSize): int { $titleSize = max(20, min(120, (int)($opts['title_size'] ?? 64))); $titleText = trim($opts['title'] ?? ''); $font = $this->fontPath(true); $maxWidth = self::OG_WIDTH - self::PADDING * 2; $titleLines = $this->wrapText($titleText, $titleSize, $font, $maxWidth); $titleLineH = (int)($titleSize * 1.3); $titleBlock = count($titleLines) * $titleLineH; $subH = empty(trim($opts['subtitle'] ?? '')) ? 0 : $fontSize + 20; $block = $titleBlock + $subH; $center = (int)((self::OG_HEIGHT - $block) / 2); $topOfTitle = max(self::PADDING + $titleSize, $center); return $topOfTitle + $titleBlock + 20; } private function labelCoords(string $position, int $w, int $h): array { $pad = self::PADDING; return match ($position) { 'top-right' => [self::OG_WIDTH - $pad - $w, $pad], 'bottom-left' => [$pad, self::OG_HEIGHT - $pad - $h], 'bottom-right' => [self::OG_WIDTH - $pad - $w, self::OG_HEIGHT - $pad - $h], default => [$pad, $pad], // top-left }; } // ────────────────────────────────────────────────────────────────── // Utility helpers // ────────────────────────────────────────────────────────────────── /** Wrap text to fit within $maxWidth px */ private function wrapText(string $text, int $fontSize, string $font, int $maxWidth): array { $words = explode(' ', $text); $lines = []; $current = ''; foreach ($words as $word) { $test = $current !== '' ? "$current $word" : $word; $bbox = imagettfbbox($fontSize, 0, $font, $test); $w = abs($bbox[4] - $bbox[0]); if ($w > $maxWidth && $current !== '') { $lines[] = $current; $current = $word; } else { $current = $test; } } if ($current !== '') { $lines[] = $current; } return $lines ?: ['']; } private function fontPath(bool $bold): string { $path = $bold ? self::FONT_BOLD : self::FONT_REG; if (!file_exists($path)) { $path = self::FONT_REG; // fallback to regular } if (!file_exists($path)) { throw new \RuntimeException("TTF font not found at $path"); } return $path; } private function hexToRgb(string $hex): array { $hex = ltrim($hex, '#'); if (strlen($hex) === 3) { $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; } return [ hexdec(substr($hex, 0, 2)), hexdec(substr($hex, 2, 2)), hexdec(substr($hex, 4, 2)), ]; } private function resolvePath(string $path): string { if (str_starts_with($path, '/')) { return Environment::getPublicPath() . $path; } return Environment::getPublicPath() . '/' . ltrim($path, '/'); } /** @return \GdImage|null */ private function loadImageFromPath(string $path): ?\GdImage { if (!file_exists($path)) { return null; } $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); return match ($ext) { 'jpg', 'jpeg' => imagecreatefromjpeg($path) ?: null, 'png' => imagecreatefrompng($path) ?: null, 'gif' => imagecreatefromgif($path) ?: null, 'webp' => imagecreatefromwebp($path) ?: null, default => null, }; } private function saveImage(\GdImage $gd, string $path, string $format, int $quality): void { match (strtolower($format)) { 'png' => imagepng($gd, $path, max(0, min(9, (int)(9 - $quality / 11)))), default => imagejpeg($gd, $path, $quality), }; } }