Files
SkinbaseNova/app/Services/NovaCards/NovaCardRenderService.php

543 lines
24 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 App\Services\NovaCards;
use App\Models\NovaCard;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Storage;
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);
// Store publicly so CDN / direct S3 URLs are accessible without signing.
$disk->put($previewPath, $webpBinary, 'public');
$disk->put($ogPath, $jpgBinary, 'public');
$card->forceFill([
'preview_path' => $previewPath,
'preview_width' => $width,
'preview_height' => $height,
'last_rendered_at' => now(),
])->save();
return [
'preview_path' => $previewPath,
'og_path' => $ogPath,
'width' => $width,
'height' => $height,
];
}
// ─── Background ──────────────────────────────────────────────────────────
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) {
$focalPosition = (string) Arr::get($project, 'background.focal_position', 'center');
$blurLevel = (int) Arr::get($project, 'background.blur_level', 0);
$this->paintImageBackground($image, $card->backgroundImage->processed_path, $width, $height, $focalPosition, $blurLevel);
} 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, string $focalPosition = 'center', int $blurLevel = 0): 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);
if (! $blob) {
$this->paintVerticalGradient($image, $width, $height, '#0f172a', '#1d4ed8');
return;
}
$background = @imagecreatefromstring($blob);
if ($background === false) {
$this->paintVerticalGradient($image, $width, $height, '#0f172a', '#1d4ed8');
return;
}
$srcW = imagesx($background);
$srcH = imagesy($background);
// Implement CSS background-size: cover / object-fit: cover:
// scale the source so both dimensions FILL the destination, then crop to focal point.
$scaleX = $width / max(1, $srcW);
$scaleY = $height / max(1, $srcH);
$scale = max($scaleX, $scaleY);
// How many source pixels map to the entire destination.
$sampleW = max(1, (int) round($width / $scale));
$sampleH = max(1, (int) round($height / $scale));
// Focal ratios (0 = left/top, 0.5 = centre, 1 = right/bottom).
[$focalX, $focalY] = $this->resolveFocalRatios($focalPosition);
$srcX = max(0, min($srcW - $sampleW, (int) round(($srcW - $sampleW) * $focalX)));
$srcY = max(0, min($srcH - $sampleH, (int) round(($srcH - $sampleH) * $focalY)));
imagecopyresampled($image, $background, 0, 0, $srcX, $srcY, $width, $height, $sampleW, $sampleH);
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);
}
// ─── Text rendering (FreeType / GD fallback) ─────────────────────────────
private function paintText($image, NovaCard $card, array $project, int $width, int $height): void
{
$fontPreset = (string) Arr::get($project, 'typography.font_preset', 'modern-sans');
$fontFile = $this->resolveFont($fontPreset);
$textColor = $this->allocateHex($image, (string) Arr::get($project, 'typography.text_color', '#ffffff'));
$accentColor = $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');
$lhMulti = (float) Arr::get($project, 'typography.line_height', 1.35);
$shadow = (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);
// Respect the quote_width typography slider first; fall back to the layout max_width preset.
$quoteWidthPct = (float) Arr::get($project, 'typography.quote_width', 0);
$maxLineWidth = ($quoteWidthPct >= 30 && $quoteWidthPct <= 100)
? (int) round($width * $quoteWidthPct / 100)
: match ((string) Arr::get($project, 'layout.max_width', 'balanced')) {
'compact' => (int) round($width * 0.52),
'wide' => (int) round($width * 0.80),
default => (int) round($width * 0.66),
};
// Font sizes come from the editor sliders (stored in project_json.typography).
// Slider values are normalised to a 1080-px-wide canvas; scale up for wider formats
// (e.g. landscape 1920 px) so proportions match the CSS preview at any container size.
$fontScale = $width / 1080.0;
$quoteSize = (float) max(10, (float) Arr::get($project, 'typography.quote_size', 72)) * $fontScale;
$authorSize = (float) max(14, (float) Arr::get($project, 'typography.author_size', 28)) * $fontScale;
$sizeMap = [
'title' => max(16, $quoteSize * 0.48),
'quote' => $quoteSize,
'author' => $authorSize,
'source' => max(12, $authorSize * 0.82),
'body' => max(16, $quoteSize * 0.54),
'caption' => max(12, $authorSize * 0.74),
];
$allBlocks = $this->resolveTextBlocks($card, $project);
// Blocks with both pos_x and pos_y set were dragged to a free position; others flow normally.
$flowBlocks = array_values(array_filter($allBlocks, fn ($b) => Arr::get($b, 'pos_x') === null || Arr::get($b, 'pos_y') === null));
$freeBlocks = array_values(array_filter($allBlocks, fn ($b) => Arr::get($b, 'pos_x') !== null && Arr::get($b, 'pos_y') !== null));
// ── Flow blocks: vertically stacked, centred by the layout position ───
$blockData = [];
$totalHeight = 0;
foreach ($flowBlocks as $block) {
$type = (string) ($block['type'] ?? 'quote');
$size = (float) ($sizeMap[$type] ?? $quoteSize);
$prefix = $type === 'author' ? '— ' : '';
$raw = $type === 'title' ? strtoupper($prefix . (string) ($block['text'] ?? '')) : $prefix . (string) ($block['text'] ?? '');
$lines = $this->wrapLines($raw, $size, $fontFile, $maxLineWidth);
$lineH = $this->lineHeight($size, $fontFile, $lhMulti);
$gap = (int) round($size * 0.55);
$totalHeight += count($lines) * $lineH + $gap;
$blockData[] = compact('type', 'size', 'lines', 'lineH', 'gap');
}
$position = (string) Arr::get($project, 'layout.position', 'center');
$startY = match ($position) {
'top' => (int) round($height * 0.13),
'upper-middle' => (int) round($height * 0.27),
'lower-middle' => (int) round($height * 0.55),
'bottom' => max($xPadding, $height - $totalHeight - (int) round($height * 0.10)),
default => (int) round(($height - $totalHeight) / 2),
};
foreach ($blockData as $bdata) {
$color = in_array($bdata['type'], ['author', 'source', 'title'], true) ? $accentColor : $textColor;
foreach ($bdata['lines'] as $line) {
$lw = $this->measureLine($line, $bdata['size'], $fontFile);
$x = $this->resolveAlignedX($alignment, $width, $xPadding, $lw);
$this->drawLine($image, $bdata['size'], $x, $startY, $line, $color, $fontFile, $shadow);
$startY += $bdata['lineH'];
}
$startY += $bdata['gap'];
}
// ── Free-positioned blocks: absolute x/y percentages from canvas origin ─
foreach ($freeBlocks as $block) {
$type = (string) ($block['type'] ?? 'quote');
$size = (float) ($sizeMap[$type] ?? $quoteSize);
$prefix = $type === 'author' ? '— ' : '';
$raw = $type === 'title' ? strtoupper($prefix . (string) ($block['text'] ?? '')) : $prefix . (string) ($block['text'] ?? '');
$blockMaxW = Arr::get($block, 'pos_width') !== null
? max(20, (int) round((float) $block['pos_width'] / 100 * $width))
: $maxLineWidth;
$lines = $this->wrapLines($raw, $size, $fontFile, $blockMaxW);
$lineH = $this->lineHeight($size, $fontFile, $lhMulti);
$x = (int) round((float) Arr::get($block, 'pos_x', 0) / 100 * $width);
$y = (int) round((float) Arr::get($block, 'pos_y', 0) / 100 * $height);
$color = in_array($type, ['author', 'source', 'title'], true) ? $accentColor : $textColor;
foreach ($lines as $line) {
$this->drawLine($image, $size, $x, $y, $line, $color, $fontFile, $shadow);
$y += $lineH;
}
}
}
/** Resolve the TTF font file path for a given preset key. */
private function resolveFont(string $preset): string
{
$dir = rtrim((string) config('nova_cards.render.fonts_dir', storage_path('app/fonts')), '/\\');
$custom = $dir . DIRECTORY_SEPARATOR . $preset . '.ttf';
if (file_exists($custom) && function_exists('imagettftext')) {
return $custom;
}
$default = $dir . DIRECTORY_SEPARATOR . 'default.ttf';
if (file_exists($default) && function_exists('imagettftext')) {
return $default;
}
return ''; // falls back to GD built-in fonts
}
/**
* Resolve the best TTF font for Unicode decoration glyphs (✦ ♥ ☾ …).
* Looks for symbols.ttf first (place a NotoSansSymbols or similar file there),
* then falls back to the preset font or default.ttf.
*/
private function resolveSymbolFont(string $preset): string
{
$dir = rtrim((string) config('nova_cards.render.fonts_dir', storage_path('app/fonts')), '/\\');
foreach (['symbols.ttf', $preset . '.ttf', 'default.ttf'] as $candidate) {
$path = $dir . DIRECTORY_SEPARATOR . $candidate;
if (file_exists($path) && function_exists('imagettftext')) {
return $path;
}
}
return '';
}
/** Wrap text into lines that fit $maxWidth pixels. */
private function wrapLines(string $text, float $size, string $fontFile, int $maxWidth): array
{
if ($text === '') {
return [];
}
// Split on explicit newlines first, then wrap each segment.
$paragraphs = preg_split('/\r\n|\r|\n/', $text) ?: [$text];
$lines = [];
foreach ($paragraphs as $para) {
$words = preg_split('/\s+/u', trim($para)) ?: [trim($para)];
$current = '';
foreach ($words as $word) {
$candidate = $current === '' ? $word : $current . ' ' . $word;
if ($this->measureLine($candidate, $size, $fontFile) <= $maxWidth) {
$current = $candidate;
} else {
if ($current !== '') {
$lines[] = $current;
}
$current = $word;
}
}
if ($current !== '') {
$lines[] = $current;
}
}
return $lines ?: [$text];
}
/** Measure the pixel width of a single line of text. */
private function measureLine(string $text, float $size, string $fontFile): int
{
if ($fontFile !== '' && function_exists('imagettfbbox')) {
$bbox = @imagettfbbox($size, 0, $fontFile, $text);
if ($bbox !== false) {
return abs($bbox[4] - $bbox[0]);
}
}
// GD built-in fallback: font 5 is ~9px wide per character.
return strlen($text) * imagefontwidth(5);
}
/** Calculate the line height (pixels) for a given font size. */
private function lineHeight(float $size, string $fontFile, float $multiplier): int
{
if ($fontFile !== '' && function_exists('imagettfbbox')) {
$bbox = @imagettfbbox($size, 0, $fontFile, 'Ágjy');
if ($bbox !== false) {
return (int) round(abs($bbox[1] - $bbox[7]) * $multiplier);
}
}
return (int) round($size * $multiplier);
}
/** Draw a single text line with optional shadow. Baseline is $y. */
private function drawLine($image, float $size, int $x, int $y, string $text, int $color, string $fontFile, string $shadowPreset): void
{
if ($fontFile !== '' && function_exists('imagettftext')) {
// FreeType: $y is the baseline.
$baseline = $y + (int) round($size);
if ($shadowPreset !== 'none') {
$offset = $shadowPreset === 'strong' ? 4 : 2;
$shadowAlpha = $shadowPreset === 'strong' ? 46 : 80;
$shadowColor = imagecolorallocatealpha($image, 0, 0, 0, $shadowAlpha);
imagettftext($image, $size, 0, $x + $offset, $baseline + $offset, $shadowColor, $fontFile, $text);
}
imagettftext($image, $size, 0, $x, $baseline, $color, $fontFile, $text);
return;
}
// GD built-in fallback (bitmap fonts, $y is the top of the glyph).
if ($shadowPreset !== 'none') {
$offset = $shadowPreset === 'strong' ? 3 : 1;
$shadowColor = imagecolorallocatealpha($image, 0, 0, 0, $shadowPreset === 'strong' ? 46 : 80);
imagestring($image, 5, $x + $offset, $y + $offset, $text, $shadowColor);
}
imagestring($image, 5, $x, $y, $text, $color);
}
// ─── Decorations & assets ────────────────────────────────────────────────
private function paintDecorations($image, array $project, int $width, int $height): void
{
$decorations = Arr::wrap(Arr::get($project, 'decorations', []));
$fontScale = $width / 1080.0;
$symbolFont = $this->resolveSymbolFont((string) Arr::get($project, 'typography.font_preset', 'modern-sans'));
[$accentR, $accentG, $accentB] = $this->hexToRgb((string) Arr::get($project, 'typography.accent_color', '#ffffff'));
foreach (array_slice($decorations, 0, (int) config('nova_cards.validation.max_decorations', 6)) as $index => $decoration) {
$glyph = (string) Arr::get($decoration, 'glyph', '•');
// pos_x / pos_y are stored as percentages (0100); fall back to sensible defaults.
$xPct = Arr::get($decoration, 'pos_x');
$yPct = Arr::get($decoration, 'pos_y');
$x = $xPct !== null
? (int) round((float) $xPct / 100 * $width)
: (int) round(($index % 2 === 0 ? 0.12 : 0.82) * $width);
$y = $yPct !== null
? (int) round((float) $yPct / 100 * $height)
: (int) round((0.14 + ($index * 0.1)) * $height);
// Canvas clamp: max(18, min(size, 64)) matching NovaCardCanvasPreview.
$rawSize = max(18, min((int) Arr::get($decoration, 'size', 28), 64));
$size = (float) ($rawSize * $fontScale);
// Opacity: 10100 integer percent → GD alpha 0 (opaque)127 (transparent).
$opacityPct = max(10, min(100, (int) Arr::get($decoration, 'opacity', 85)));
$alpha = (int) round((1 - $opacityPct / 100) * 127);
$color = imagecolorallocatealpha($image, $accentR, $accentG, $accentB, $alpha);
if ($symbolFont !== '' && function_exists('imagettftext')) {
// Render the Unicode glyph; baseline = y + size (same as drawLine).
imagettftext($image, $size, 0, $x, (int) ($y + $size), $color, $symbolFont, $glyph);
} else {
// No TTF font available — draw a small filled ellipse as a generic marker.
$d = max(4, (int) round($size * 0.6));
imagefilledellipse($image, $x, (int) ($y + $size / 2), $d, $d, $color);
}
}
}
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') {
$lineY = $index % 2 === 0 ? (int) round($height * 0.08) : (int) round($height * 0.92);
imageline($image, (int) round($width * 0.12), $lineY, (int) round($width * 0.88), $lineY, $accent);
}
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────
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();
}
// Fallback: use top-level card fields.
return array_values(array_filter([
trim((string) $card->title) !== '' ? ['type' => 'title', 'text' => trim((string) $card->title)] : null,
trim((string) $card->quote_text) !== '' ? ['type' => 'quote', 'text' => trim((string) $card->quote_text)] : null,
trim((string) $card->quote_author) !== '' ? ['type' => 'author', 'text' => trim((string) $card->quote_author)] : null,
trim((string) $card->quote_source) !== '' ? ['type' => 'source', 'text' => trim((string) $card->quote_source)] : null,
]));
}
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(0, (int) round(($width - $lineWidth) / 2)),
};
}
private function resolveFocalRatios(string $focalPosition): array
{
$x = match ($focalPosition) {
'left', 'top-left', 'bottom-left' => 0.0,
'right', 'top-right', 'bottom-right' => 1.0,
default => 0.5,
};
$y = match ($focalPosition) {
'top', 'top-left', 'top-right' => 0.0,
'bottom', 'bottom-left', 'bottom-right' => 1.0,
default => 0.5,
};
return [$x, $y];
}
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): int
{
[$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 [
(int) hexdec(substr($normalized, 0, 2)),
(int) hexdec(substr($normalized, 2, 2)),
(int) hexdec(substr($normalized, 4, 2)),
];
}
}