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); } }