Files
VITEC-website/packages/vitec/Classes/Service/OgImageGeneratorService.php
2026-05-29 11:14:02 +02:00

420 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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),
};
}
}