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

567 lines
26 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');
if ($style === 'none') {
return;
}
// Respect the opacity slider (0100 %) that the CSS preview applies to the overlay div.
$opacityPct = max(0, min(100, (int) Arr::get($project, 'background.opacity', 50)));
$scale = $opacityPct / 100.0;
// Top/bottom gradient stop opacities — matches overlayStyle() in NovaCardCanvasPreview.jsx:
// dark-soft: linear-gradient(180deg, rgba(2,6,23,0.18), rgba(2,6,23,0.48))
// dark-strong: linear-gradient(180deg, rgba(2,6,23,0.38), rgba(2,6,23,0.68))
// light-soft: linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.22))
[$topA, $botA] = match ($style) {
'dark-strong' => [0.38, 0.68],
'light-soft' => [0.08, 0.22],
default => [0.18, 0.48], // dark-soft
};
$rgb = $style === 'light-soft' ? [255, 255, 255] : [0, 0, 0];
// Draw a scanline gradient to match the CSS linear-gradient overlay.
for ($y = 0; $y < $height; $y++) {
$alpha = ($topA + ($botA - $topA) * ($y / $height)) * $scale;
$gdAlpha = max(0, min(127, (int) round((1.0 - $alpha) * 127)));
$color = imagecolorallocatealpha($image, $rgb[0], $rgb[1], $rgb[2], $gdAlpha);
imageline($image, 0, $y, $width - 1, $y, $color);
}
}
// ─── 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);
// Apply text_opacity (10100 %) to both text and accent colours, matching CSS blockStyle().
$textOpacityPct = max(10, min(100, (int) Arr::get($project, 'typography.text_opacity', 100)));
$textAlpha = (int) round((1.0 - $textOpacityPct / 100.0) * 127);
[$tr, $tg, $tb] = $this->hexToRgb((string) Arr::get($project, 'typography.text_color', '#ffffff'));
[$ar, $ag, $ab] = $this->hexToRgb((string) Arr::get($project, 'typography.accent_color', Arr::get($project, 'typography.text_color', '#ffffff')));
$textColor = imagecolorallocatealpha($image, $tr, $tg, $tb, $textAlpha);
$accentColor = imagecolorallocatealpha($image, $ar, $ag, $ab, $textAlpha);
$alignment = (string) Arr::get($project, 'layout.alignment', 'center');
$lhMulti = (float) Arr::get($project, 'typography.line_height', 1.2);
$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); when absent, fall back to
// `placement` field — mirroring placementStyles in NovaCardCanvasPreview.jsx.
$xPct = Arr::get($decoration, 'pos_x');
$yPct = Arr::get($decoration, 'pos_y');
if ($xPct !== null && $yPct !== null) {
$x = (int) round((float) $xPct / 100 * $width);
$y = (int) round((float) $yPct / 100 * $height);
} else {
$placement = (string) Arr::get($decoration, 'placement', 'top-right');
$x = str_contains($placement, 'left') ? (int) round(0.12 * $width)
: (str_contains($placement, 'right') ? (int) round(0.88 * $width)
: (int) round(0.50 * $width));
$y = str_contains($placement, 'top') ? (int) round(0.12 * $height)
: (str_contains($placement, 'bottom') ? (int) round(0.88 * $height)
: (int) round(0.50 * $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)),
];
}
}