420 lines
16 KiB
PHP
420 lines
16 KiB
PHP
<?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),
|
||
};
|
||
}
|
||
}
|