Implement creator studio and upload updates

This commit is contained in:
2026-04-04 10:12:02 +02:00
parent 1da7d3bf88
commit 0b216b7ecd
15107 changed files with 31206 additions and 626514 deletions

View File

@@ -32,6 +32,9 @@ class NovaCardBackgroundService
throw new RuntimeException('Nova card background processing requires Intervention Image.');
}
$sourcePath = $this->resolveUploadPath($file);
$binary = $this->readUploadedBinary($sourcePath);
$uuid = (string) Str::uuid();
$extension = strtolower($file->getClientOriginalExtension() ?: 'jpg');
$originalDisk = Storage::disk((string) config('nova_cards.storage.private_disk', 'local'));
@@ -43,9 +46,9 @@ class NovaCardBackgroundService
$processedPath = trim((string) config('nova_cards.storage.background_processed_prefix', 'cards/backgrounds/processed'), '/')
. '/' . $user->id . '/' . $uuid . '.webp';
$originalDisk->put($originalPath, file_get_contents($file->getRealPath()) ?: '');
$originalDisk->put($originalPath, $binary);
$image = $this->manager->read($file->getRealPath())->scaleDown(width: 2200, height: 2200);
$image = $this->manager->read($binary)->scaleDown(width: 2200, height: 2200);
$encoded = (string) $image->encode(new WebpEncoder(88));
$processedDisk->put($processedPath, $encoded);
@@ -57,8 +60,30 @@ class NovaCardBackgroundService
'height' => $image->height(),
'mime_type' => (string) ($file->getMimeType() ?: 'image/jpeg'),
'file_size' => (int) $file->getSize(),
'sha256' => hash_file('sha256', $file->getRealPath()) ?: null,
'sha256' => hash('sha256', $binary),
'visibility' => 'card-only',
]);
}
private function resolveUploadPath(UploadedFile $file): string
{
$path = $file->getRealPath() ?: $file->getPathname();
if (! is_string($path) || trim($path) === '' || ! is_readable($path)) {
throw new RuntimeException('Unable to resolve uploaded background path.');
}
return $path;
}
private function readUploadedBinary(string $path): string
{
$binary = @file_get_contents($path);
if ($binary === false || $binary === '') {
throw new RuntimeException('Unable to read uploaded background image.');
}
return $binary;
}
}

View File

@@ -60,6 +60,7 @@ class NovaCardDraftService
'palette_family' => Arr::get($attributes, 'palette_family'),
'original_card_id' => $originalCardId,
'root_card_id' => $rootCardId,
'scheduling_timezone' => Arr::get($attributes, 'scheduling_timezone'),
]);
$this->tagService->syncTags($card, Arr::wrap(Arr::get($attributes, 'tags', [])));
@@ -137,6 +138,7 @@ class NovaCardDraftService
'style_family' => Arr::get($payload, 'style_family', $card->style_family),
'palette_family' => Arr::get($payload, 'palette_family', $card->palette_family),
'editor_mode_last_used' => Arr::get($payload, 'editor_mode_last_used', $card->editor_mode_last_used),
'scheduling_timezone' => Arr::get($payload, 'scheduling_timezone', $card->scheduling_timezone),
]);
if ($card->isDirty('title')) {

View File

@@ -0,0 +1,127 @@
<?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\Facades\URL;
use RuntimeException;
use Symfony\Component\Process\Process;
/**
* Renders a Nova Card by loading the editor's React canvas preview inside a
* headless Chromium (via Playwright) and screenshotting it.
*
* This produces a pixel-perfect image that matches what users see in the editor,
* including web fonts, CSS container queries, and CSS gradients/blend modes.
*
* Prerequisites:
* - Node.js must be in PATH.
* - Playwright browsers must be installed (`npx playwright install chromium`).
* - NOVA_CARDS_PLAYWRIGHT_RENDER=true in .env (default: false).
* - APP_URL must be reachable from the queue worker (used for the signed URL).
*/
class NovaCardPlaywrightRenderService
{
public function isAvailable(): bool
{
if (! config('nova_cards.playwright_render', false)) {
return false;
}
// Quick check: verify `node` is executable.
$probe = new Process(['node', '--version']);
$probe->run();
return $probe->isSuccessful();
}
public function render(NovaCard $card): array
{
if (! $this->isAvailable()) {
throw new RuntimeException('Playwright rendering is disabled or Node.js is not available.');
}
$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);
// Signed URL is valid for 10 minutes — enough for the queue job.
$signedUrl = URL::temporarySignedRoute(
'nova-cards.render-frame',
now()->addMinutes(10),
['uuid' => $card->uuid],
);
$tmpDir = rtrim(sys_get_temp_dir(), '/\\') . DIRECTORY_SEPARATOR . 'nova-render-' . $card->uuid;
$pngPath = $tmpDir . DIRECTORY_SEPARATOR . 'canvas.png';
if (! is_dir($tmpDir)) {
mkdir($tmpDir, 0755, true);
}
$script = base_path('scripts/render-nova-card.cjs');
$process = new Process(
['node', $script, "--url={$signedUrl}", "--out={$pngPath}", "--width={$width}", "--height={$height}"],
null,
null,
null,
60, // seconds
);
$process->run();
if (! $process->isSuccessful()) {
throw new RuntimeException('Playwright render failed: ' . trim($process->getErrorOutput()));
}
if (! file_exists($pngPath) || filesize($pngPath) < 100) {
throw new RuntimeException('Playwright render produced no output or an empty file.');
}
$pngBlob = (string) file_get_contents($pngPath);
@unlink($pngPath);
@rmdir($tmpDir);
// Convert the PNG to WebP + JPEG using GD (already a project dependency).
$img = @imagecreatefromstring($pngBlob);
if ($img === false) {
throw new RuntimeException('Could not decode Playwright PNG output with GD.');
}
ob_start();
imagewebp($img, null, (int) config('nova_cards.render.preview_quality', 86));
$webpBinary = (string) ob_get_clean();
ob_start();
imagejpeg($img, null, (int) config('nova_cards.render.og_quality', 88));
$jpgBinary = (string) ob_get_clean();
imagedestroy($img);
$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';
$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,
];
}
}

View File

@@ -222,6 +222,8 @@ class NovaCardPresenter
'og_preview_url' => $card->ogPreviewUrl(),
'public_url' => $card->publicUrl(),
'published_at' => optional($card->published_at)?->toISOString(),
'scheduled_for' => optional($card->scheduled_for)?->toISOString(),
'scheduling_timezone' => $card->scheduling_timezone,
'render_version' => (int) $card->render_version,
'schema_version' => (int) $card->schema_version,
'views_count' => (int) $card->views_count,

View File

@@ -8,6 +8,7 @@ use App\Jobs\NovaCards\RenderNovaCardPreviewJob;
use App\Models\NovaCard;
use App\Services\NovaCards\NovaCardPublishModerationService;
use Illuminate\Support\Carbon;
use InvalidArgumentException;
class NovaCardPublishService
{
@@ -24,6 +25,8 @@ class NovaCardPublishService
'status' => NovaCard::STATUS_PROCESSING,
'moderation_status' => NovaCard::MOD_PENDING,
'published_at' => $card->published_at ?? Carbon::now(),
'scheduled_for' => null,
'scheduling_timezone' => null,
'render_version' => (int) $card->render_version + 1,
])->save();
@@ -39,6 +42,8 @@ class NovaCardPublishService
'status' => NovaCard::STATUS_PROCESSING,
'moderation_status' => NovaCard::MOD_PENDING,
'published_at' => $card->published_at ?? Carbon::now(),
'scheduled_for' => null,
'scheduling_timezone' => null,
'render_version' => (int) $card->render_version + 1,
])->save();
@@ -52,4 +57,33 @@ class NovaCardPublishService
return $card->refresh()->load(['category', 'template', 'tags', 'backgroundImage']);
}
public function schedule(NovaCard $card, Carbon $scheduledFor, ?string $timezone = null): NovaCard
{
$scheduledFor = $scheduledFor->copy()->utc();
if ($scheduledFor->lte(now()->addMinute())) {
throw new InvalidArgumentException('Scheduled publish time must be at least 1 minute in the future.');
}
$card->forceFill([
'status' => NovaCard::STATUS_SCHEDULED,
'scheduled_for' => $scheduledFor,
'published_at' => $scheduledFor,
'scheduling_timezone' => $timezone,
])->save();
return $card->refresh()->load(['category', 'template', 'tags', 'backgroundImage']);
}
public function clearSchedule(NovaCard $card): NovaCard
{
$card->forceFill([
'status' => NovaCard::STATUS_DRAFT,
'scheduled_for' => null,
'published_at' => null,
])->save();
return $card->refresh()->load(['category', 'template', 'tags', 'backgroundImage']);
}
}

View File

@@ -7,7 +7,6 @@ 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
@@ -48,23 +47,27 @@ class NovaCardRenderService
imagedestroy($image);
$disk->put($previewPath, $webpBinary);
$disk->put($ogPath, $jpgBinary);
// 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,
'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,
'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', []);
@@ -78,7 +81,9 @@ class NovaCardRenderService
}
if ($type === 'upload' && $card->backgroundImage?->processed_path) {
$this->paintImageBackground($image, $card->backgroundImage->processed_path, $width, $height);
$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');
@@ -87,7 +92,7 @@ class NovaCardRenderService
}
}
private function paintImageBackground($image, string $path, int $width, int $height): void
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)) {
@@ -97,6 +102,12 @@ class NovaCardRenderService
}
$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');
@@ -104,23 +115,26 @@ class NovaCardRenderService
return;
}
$focalPosition = (string) Arr::get($card->project_json, 'background.focal_position', 'center');
[$srcX, $srcY] = $this->resolveFocalSourceOrigin($focalPosition, imagesx($background), imagesy($background));
$srcW = imagesx($background);
$srcH = imagesy($background);
imagecopyresampled(
$image,
$background,
0,
0,
$srcX,
$srcY,
$width,
$height,
max(1, imagesx($background) - $srcX),
max(1, imagesy($background) - $srcY)
);
// 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);
$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);
}
@@ -133,9 +147,9 @@ class NovaCardRenderService
$style = (string) Arr::get($project, 'background.overlay_style', 'dark-soft');
$alpha = match ($style) {
'dark-strong' => 72,
'dark-soft' => 92,
'light-soft' => 108,
default => null,
'dark-soft' => 92,
'light-soft' => 108,
default => null,
};
if ($alpha === null) {
@@ -147,75 +161,272 @@ class NovaCardRenderService
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
{
$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');
$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,
'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;
// 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.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),
'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 ($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;
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'];
}
$startY += 18;
// ── 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', []));
$accent = $this->allocateHex($image, (string) Arr::get($project, 'typography.accent_color', '#ffffff'));
$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) {
$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);
$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);
}
}
}
@@ -237,12 +448,14 @@ class NovaCardRenderService
}
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);
$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', [])))
@@ -253,24 +466,40 @@ class NovaCardRenderService
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)],
];
// 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 fontForBlockType(string $type): int
private function resolveAlignedX(string $alignment, int $width, int $padding, int $lineWidth): int
{
return match ($type) {
'title', 'source' => 3,
'author', 'body' => 4,
'caption' => 2,
default => 5,
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);
@@ -278,15 +507,15 @@ class NovaCardRenderService
for ($y = 0; $y < $height; $y++) {
$ratio = $height > 1 ? $y / ($height - 1) : 0;
$red = (int) round($r1 + (($r2 - $r1) * $ratio));
$red = (int) round($r1 + (($r2 - $r1) * $ratio));
$green = (int) round($g1 + (($g2 - $g1) * $ratio));
$blue = (int) round($b1 + (($b2 - $b1) * $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)
private function allocateHex($image, string $hex): int
{
[$r, $g, $b] = $this->hexToRgb($hex);
@@ -305,46 +534,9 @@ class NovaCardRenderService
}
return [
hexdec(substr($normalized, 0, 2)),
hexdec(substr($normalized, 2, 2)),
hexdec(substr($normalized, 4, 2)),
(int) hexdec(substr($normalized, 0, 2)),
(int) hexdec(substr($normalized, 2, 2)),
(int) 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);
}
}