Files
SkinbaseNova/app/Services/NovaCards/NovaCardRenderService.php
2026-03-28 19:15:39 +01:00

351 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\NovaCards;
use App\Models\NovaCard;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
class NovaCardRenderService
{
public function render(NovaCard $card): array
{
if (! function_exists('imagecreatetruecolor')) {
throw new RuntimeException('Nova card rendering requires the GD extension.');
}
$format = Arr::get(config('nova_cards.formats'), $card->format, config('nova_cards.formats.square'));
$width = (int) Arr::get($format, 'width', 1080);
$height = (int) Arr::get($format, 'height', 1080);
$project = is_array($card->project_json) ? $card->project_json : [];
$image = imagecreatetruecolor($width, $height);
imagealphablending($image, true);
imagesavealpha($image, true);
$this->paintBackground($image, $card, $width, $height, $project);
$this->paintOverlay($image, $project, $width, $height);
$this->paintText($image, $card, $project, $width, $height);
$this->paintDecorations($image, $project, $width, $height);
$this->paintAssets($image, $project, $width, $height);
$disk = Storage::disk((string) config('nova_cards.storage.public_disk', 'public'));
$basePath = trim((string) config('nova_cards.storage.preview_prefix', 'cards/previews'), '/') . '/' . $card->user_id;
$previewPath = $basePath . '/' . $card->uuid . '.webp';
$ogPath = $basePath . '/' . $card->uuid . '-og.jpg';
ob_start();
imagewebp($image, null, (int) config('nova_cards.render.preview_quality', 86));
$webpBinary = (string) ob_get_clean();
ob_start();
imagejpeg($image, null, (int) config('nova_cards.render.og_quality', 88));
$jpgBinary = (string) ob_get_clean();
imagedestroy($image);
$disk->put($previewPath, $webpBinary);
$disk->put($ogPath, $jpgBinary);
$card->forceFill([
'preview_path' => $previewPath,
'preview_width' => $width,
'preview_height' => $height,
])->save();
return [
'preview_path' => $previewPath,
'og_path' => $ogPath,
'width' => $width,
'height' => $height,
];
}
private function paintBackground($image, NovaCard $card, int $width, int $height, array $project): void
{
$background = Arr::get($project, 'background', []);
$type = (string) Arr::get($background, 'type', $card->background_type ?: 'gradient');
if ($type === 'solid') {
$color = $this->allocateHex($image, (string) Arr::get($background, 'solid_color', '#111827'));
imagefilledrectangle($image, 0, 0, $width, $height, $color);
return;
}
if ($type === 'upload' && $card->backgroundImage?->processed_path) {
$this->paintImageBackground($image, $card->backgroundImage->processed_path, $width, $height);
} else {
$colors = Arr::wrap(Arr::get($background, 'gradient_colors', ['#0f172a', '#1d4ed8']));
$from = (string) Arr::get($colors, 0, '#0f172a');
$to = (string) Arr::get($colors, 1, '#1d4ed8');
$this->paintVerticalGradient($image, $width, $height, $from, $to);
}
}
private function paintImageBackground($image, string $path, int $width, int $height): void
{
$disk = Storage::disk((string) config('nova_cards.storage.public_disk', 'public'));
if (! $disk->exists($path)) {
$this->paintVerticalGradient($image, $width, $height, '#0f172a', '#1d4ed8');
return;
}
$blob = $disk->get($path);
$background = @imagecreatefromstring($blob);
if ($background === false) {
$this->paintVerticalGradient($image, $width, $height, '#0f172a', '#1d4ed8');
return;
}
$focalPosition = (string) Arr::get($card->project_json, 'background.focal_position', 'center');
[$srcX, $srcY] = $this->resolveFocalSourceOrigin($focalPosition, imagesx($background), imagesy($background));
imagecopyresampled(
$image,
$background,
0,
0,
$srcX,
$srcY,
$width,
$height,
max(1, imagesx($background) - $srcX),
max(1, imagesy($background) - $srcY)
);
$blurLevel = (int) Arr::get($card->project_json, 'background.blur_level', 0);
for ($index = 0; $index < (int) floor($blurLevel / 4); $index++) {
imagefilter($image, IMG_FILTER_GAUSSIAN_BLUR);
}
imagedestroy($background);
}
private function paintOverlay($image, array $project, int $width, int $height): void
{
$style = (string) Arr::get($project, 'background.overlay_style', 'dark-soft');
$alpha = match ($style) {
'dark-strong' => 72,
'dark-soft' => 92,
'light-soft' => 108,
default => null,
};
if ($alpha === null) {
return;
}
$rgb = $style === 'light-soft' ? [255, 255, 255] : [0, 0, 0];
$overlay = imagecolorallocatealpha($image, $rgb[0], $rgb[1], $rgb[2], $alpha);
imagefilledrectangle($image, 0, 0, $width, $height, $overlay);
}
private function paintText($image, NovaCard $card, array $project, int $width, int $height): void
{
$textColor = $this->allocateHex($image, (string) Arr::get($project, 'typography.text_color', '#ffffff'));
$authorColor = $this->allocateHex($image, (string) Arr::get($project, 'typography.accent_color', Arr::get($project, 'typography.text_color', '#ffffff')));
$alignment = (string) Arr::get($project, 'layout.alignment', 'center');
$lineHeightMultiplier = (float) Arr::get($project, 'typography.line_height', 1.2);
$shadowPreset = (string) Arr::get($project, 'typography.shadow_preset', 'soft');
$paddingRatio = match ((string) Arr::get($project, 'layout.padding', 'comfortable')) {
'tight' => 0.08,
'airy' => 0.15,
default => 0.11,
};
$xPadding = (int) round($width * $paddingRatio);
$maxLineWidth = match ((string) Arr::get($project, 'layout.max_width', 'balanced')) {
'compact' => (int) round($width * 0.5),
'wide' => (int) round($width * 0.78),
default => (int) round($width * 0.64),
};
$textBlocks = $this->resolveTextBlocks($card, $project);
$charWidth = imagefontwidth(5);
$lineHeight = max(imagefontheight(5) + 4, (int) round((imagefontheight(5) + 2) * $lineHeightMultiplier));
$charsPerLine = max(14, (int) floor($maxLineWidth / max(1, $charWidth)));
$textBlockHeight = 0;
foreach ($textBlocks as $block) {
$font = $this->fontForBlockType((string) ($block['type'] ?? 'body'));
$wrapped = preg_split('/\r\n|\r|\n/', wordwrap((string) ($block['text'] ?? ''), max(10, $charsPerLine - ($font === 3 ? 4 : 0)), "\n", true)) ?: [(string) ($block['text'] ?? '')];
$textBlockHeight += count($wrapped) * max(imagefontheight($font) + 4, (int) round((imagefontheight($font) + 2) * $lineHeightMultiplier));
$textBlockHeight += 18;
}
$position = (string) Arr::get($project, 'layout.position', 'center');
$startY = match ($position) {
'top' => (int) round($height * 0.14),
'upper-middle' => (int) round($height * 0.26),
'lower-middle' => (int) round($height * 0.58),
'bottom' => max($xPadding, $height - $textBlockHeight - (int) round($height * 0.12)),
default => (int) round(($height - $textBlockHeight) / 2),
};
foreach ($textBlocks as $block) {
$type = (string) ($block['type'] ?? 'body');
$font = $this->fontForBlockType($type);
$color = in_array($type, ['author', 'source', 'title'], true) ? $authorColor : $textColor;
$prefix = $type === 'author' ? '— ' : '';
$value = $prefix . (string) ($block['text'] ?? '');
$wrapped = preg_split('/\r\n|\r|\n/', wordwrap($type === 'title' ? strtoupper($value) : $value, max(10, $charsPerLine - ($font === 3 ? 4 : 0)), "\n", true)) ?: [$value];
$blockLineHeight = max(imagefontheight($font) + 4, (int) round((imagefontheight($font) + 2) * $lineHeightMultiplier));
foreach ($wrapped as $line) {
$lineWidth = imagefontwidth($font) * strlen($line);
$x = $this->resolveAlignedX($alignment, $width, $xPadding, $lineWidth);
$this->drawText($image, $font, $x, $startY, $line, $color, $shadowPreset);
$startY += $blockLineHeight;
}
$startY += 18;
}
}
private function paintDecorations($image, array $project, int $width, int $height): void
{
$decorations = Arr::wrap(Arr::get($project, 'decorations', []));
$accent = $this->allocateHex($image, (string) Arr::get($project, 'typography.accent_color', '#ffffff'));
foreach (array_slice($decorations, 0, (int) config('nova_cards.validation.max_decorations', 6)) as $index => $decoration) {
$x = (int) Arr::get($decoration, 'x', ($index % 2 === 0 ? 0.12 : 0.82) * $width);
$y = (int) Arr::get($decoration, 'y', (0.14 + ($index * 0.1)) * $height);
$size = max(2, (int) Arr::get($decoration, 'size', 6));
imagefilledellipse($image, $x, $y, $size, $size, $accent);
}
}
private function paintAssets($image, array $project, int $width, int $height): void
{
$items = Arr::wrap(Arr::get($project, 'assets.items', []));
if ($items === []) {
return;
}
$accent = $this->allocateHex($image, (string) Arr::get($project, 'typography.accent_color', '#ffffff'));
foreach (array_slice($items, 0, 6) as $index => $item) {
$type = (string) Arr::get($item, 'type', 'glyph');
if ($type === 'glyph') {
$glyph = (string) Arr::get($item, 'glyph', Arr::get($item, 'label', '✦'));
imagestring($image, 5, (int) round($width * (0.08 + (($index % 3) * 0.28))), (int) round($height * (0.08 + (intdiv($index, 3) * 0.74))), $glyph, $accent);
continue;
}
if ($type === 'frame') {
$y = $index % 2 === 0 ? (int) round($height * 0.08) : (int) round($height * 0.92);
imageline($image, (int) round($width * 0.12), $y, (int) round($width * 0.88), $y, $accent);
}
}
}
private function resolveTextBlocks(NovaCard $card, array $project): array
{
$blocks = collect(Arr::wrap(Arr::get($project, 'text_blocks', [])))
->filter(fn ($block): bool => is_array($block) && (bool) Arr::get($block, 'enabled', true) && trim((string) Arr::get($block, 'text', '')) !== '')
->values();
if ($blocks->isNotEmpty()) {
return $blocks->all();
}
return [
['type' => 'title', 'text' => trim((string) $card->title)],
['type' => 'quote', 'text' => trim((string) $card->quote_text)],
['type' => 'author', 'text' => trim((string) $card->quote_author)],
['type' => 'source', 'text' => trim((string) $card->quote_source)],
];
}
private function fontForBlockType(string $type): int
{
return match ($type) {
'title', 'source' => 3,
'author', 'body' => 4,
'caption' => 2,
default => 5,
};
}
private function paintVerticalGradient($image, int $width, int $height, string $fromHex, string $toHex): void
{
[$r1, $g1, $b1] = $this->hexToRgb($fromHex);
[$r2, $g2, $b2] = $this->hexToRgb($toHex);
for ($y = 0; $y < $height; $y++) {
$ratio = $height > 1 ? $y / ($height - 1) : 0;
$red = (int) round($r1 + (($r2 - $r1) * $ratio));
$green = (int) round($g1 + (($g2 - $g1) * $ratio));
$blue = (int) round($b1 + (($b2 - $b1) * $ratio));
$color = imagecolorallocate($image, $red, $green, $blue);
imageline($image, 0, $y, $width, $y, $color);
}
}
private function allocateHex($image, string $hex)
{
[$r, $g, $b] = $this->hexToRgb($hex);
return imagecolorallocate($image, $r, $g, $b);
}
private function hexToRgb(string $hex): array
{
$normalized = ltrim($hex, '#');
if (strlen($normalized) === 3) {
$normalized = preg_replace('/(.)/', '$1$1', $normalized) ?: 'ffffff';
}
if (strlen($normalized) !== 6) {
$normalized = 'ffffff';
}
return [
hexdec(substr($normalized, 0, 2)),
hexdec(substr($normalized, 2, 2)),
hexdec(substr($normalized, 4, 2)),
];
}
private function resolveAlignedX(string $alignment, int $width, int $padding, int $lineWidth): int
{
return match ($alignment) {
'left' => $padding,
'right' => max($padding, $width - $padding - $lineWidth),
default => max($padding, (int) round(($width - $lineWidth) / 2)),
};
}
private function resolveFocalSourceOrigin(string $focalPosition, int $sourceWidth, int $sourceHeight): array
{
$x = match ($focalPosition) {
'left', 'top-left', 'bottom-left' => 0,
'right', 'top-right', 'bottom-right' => max(0, (int) round($sourceWidth * 0.18)),
default => max(0, (int) round($sourceWidth * 0.09)),
};
$y = match ($focalPosition) {
'top', 'top-left', 'top-right' => 0,
'bottom', 'bottom-left', 'bottom-right' => max(0, (int) round($sourceHeight * 0.18)),
default => max(0, (int) round($sourceHeight * 0.09)),
};
return [$x, $y];
}
private function drawText($image, int $font, int $x, int $y, string $text, int $color, string $shadowPreset): void
{
if ($shadowPreset !== 'none') {
$offset = $shadowPreset === 'strong' ? 3 : 1;
$shadow = imagecolorallocatealpha($image, 2, 6, 23, $shadowPreset === 'strong' ? 46 : 78);
imagestring($image, $font, $x + $offset, $y + $offset, $text, $shadow);
}
imagestring($image, $font, $x, $y, $text, $color);
}
}