Zwischenstand vom 29.05.2026

This commit is contained in:
khaccount committed 2026-05-29 11:14:02 +02:00
1 parent b6d2142214
commit dba154e572
36 files changed
+5113 -649

No files matched your search

@@ -0,0 +1,419 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Generates 1200×630 OG images using the PHP GD library.
*/
final class OgImageGeneratorService
{
private const OG_WIDTH = 1200;
private const OG_HEIGHT = 630;
/** Where generated images are stored relative to the web root */
private const SAVE_DIR = 'fileadmin/og-images/';
/** Font paths (ordered by preference) */
private const FONT_BOLD = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
private const FONT_REG = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf';
private const PADDING = 80;
// ──────────────────────────────────────────────────────────────────
// Public API
// ──────────────────────────────────────────────────────────────────
/**
* Generate and save an OG image.
*
* @return array{0: bool, 1: string, 2: string} [success, message, publicPath]
*/
public function generate(array $opts): array
{
try {
$gd = $this->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),
};
}
}