Implement creator studio and upload updates
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
127
app/Services/NovaCards/NovaCardPlaywrightRenderService.php
Normal file
127
app/Services/NovaCards/NovaCardPlaywrightRenderService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user