Zwischenstand vom 29.05.2026
This commit is contained in:
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user