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 (0–100); 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: 10–100 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)), ]; } }