543 lines
24 KiB
PHP
543 lines
24 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Services\NovaCards;
|
||
|
||
use App\Models\NovaCard;
|
||
use Illuminate\Support\Arr;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use RuntimeException;
|
||
|
||
class NovaCardRenderService
|
||
{
|
||
public function render(NovaCard $card): array
|
||
{
|
||
if (! function_exists('imagecreatetruecolor')) {
|
||
throw new RuntimeException('Nova card rendering requires the GD extension.');
|
||
}
|
||
|
||
$format = Arr::get(config('nova_cards.formats'), $card->format, config('nova_cards.formats.square'));
|
||
$width = (int) Arr::get($format, 'width', 1080);
|
||
$height = (int) Arr::get($format, 'height', 1080);
|
||
$project = is_array($card->project_json) ? $card->project_json : [];
|
||
|
||
$image = imagecreatetruecolor($width, $height);
|
||
imagealphablending($image, true);
|
||
imagesavealpha($image, true);
|
||
|
||
$this->paintBackground($image, $card, $width, $height, $project);
|
||
$this->paintOverlay($image, $project, $width, $height);
|
||
$this->paintText($image, $card, $project, $width, $height);
|
||
$this->paintDecorations($image, $project, $width, $height);
|
||
$this->paintAssets($image, $project, $width, $height);
|
||
|
||
$disk = Storage::disk((string) config('nova_cards.storage.public_disk', 'public'));
|
||
$basePath = trim((string) config('nova_cards.storage.preview_prefix', 'cards/previews'), '/') . '/' . $card->user_id;
|
||
$previewPath = $basePath . '/' . $card->uuid . '.webp';
|
||
$ogPath = $basePath . '/' . $card->uuid . '-og.jpg';
|
||
|
||
ob_start();
|
||
imagewebp($image, null, (int) config('nova_cards.render.preview_quality', 86));
|
||
$webpBinary = (string) ob_get_clean();
|
||
|
||
ob_start();
|
||
imagejpeg($image, null, (int) config('nova_cards.render.og_quality', 88));
|
||
$jpgBinary = (string) ob_get_clean();
|
||
|
||
imagedestroy($image);
|
||
|
||
// Store publicly so CDN / direct S3 URLs are accessible without signing.
|
||
$disk->put($previewPath, $webpBinary, 'public');
|
||
$disk->put($ogPath, $jpgBinary, 'public');
|
||
|
||
$card->forceFill([
|
||
'preview_path' => $previewPath,
|
||
'preview_width' => $width,
|
||
'preview_height' => $height,
|
||
'last_rendered_at' => now(),
|
||
])->save();
|
||
|
||
return [
|
||
'preview_path' => $previewPath,
|
||
'og_path' => $ogPath,
|
||
'width' => $width,
|
||
'height' => $height,
|
||
];
|
||
}
|
||
|
||
// ─── Background ──────────────────────────────────────────────────────────
|
||
|
||
private function paintBackground($image, NovaCard $card, int $width, int $height, array $project): void
|
||
{
|
||
$background = Arr::get($project, 'background', []);
|
||
$type = (string) Arr::get($background, 'type', $card->background_type ?: 'gradient');
|
||
|
||
if ($type === 'solid') {
|
||
$color = $this->allocateHex($image, (string) Arr::get($background, 'solid_color', '#111827'));
|
||
imagefilledrectangle($image, 0, 0, $width, $height, $color);
|
||
|
||
return;
|
||
}
|
||
|
||
if ($type === 'upload' && $card->backgroundImage?->processed_path) {
|
||
$focalPosition = (string) Arr::get($project, 'background.focal_position', 'center');
|
||
$blurLevel = (int) Arr::get($project, 'background.blur_level', 0);
|
||
$this->paintImageBackground($image, $card->backgroundImage->processed_path, $width, $height, $focalPosition, $blurLevel);
|
||
} else {
|
||
$colors = Arr::wrap(Arr::get($background, 'gradient_colors', ['#0f172a', '#1d4ed8']));
|
||
$from = (string) Arr::get($colors, 0, '#0f172a');
|
||
$to = (string) Arr::get($colors, 1, '#1d4ed8');
|
||
$this->paintVerticalGradient($image, $width, $height, $from, $to);
|
||
}
|
||
}
|
||
|
||
private function paintImageBackground($image, string $path, int $width, int $height, string $focalPosition = 'center', int $blurLevel = 0): void
|
||
{
|
||
$disk = Storage::disk((string) config('nova_cards.storage.public_disk', 'public'));
|
||
if (! $disk->exists($path)) {
|
||
$this->paintVerticalGradient($image, $width, $height, '#0f172a', '#1d4ed8');
|
||
|
||
return;
|
||
}
|
||
|
||
$blob = $disk->get($path);
|
||
if (! $blob) {
|
||
$this->paintVerticalGradient($image, $width, $height, '#0f172a', '#1d4ed8');
|
||
|
||
return;
|
||
}
|
||
|
||
$background = @imagecreatefromstring($blob);
|
||
if ($background === false) {
|
||
$this->paintVerticalGradient($image, $width, $height, '#0f172a', '#1d4ed8');
|
||
|
||
return;
|
||
}
|
||
|
||
$srcW = imagesx($background);
|
||
$srcH = imagesy($background);
|
||
|
||
// Implement CSS background-size: cover / object-fit: cover:
|
||
// scale the source so both dimensions FILL the destination, then crop to focal point.
|
||
$scaleX = $width / max(1, $srcW);
|
||
$scaleY = $height / max(1, $srcH);
|
||
$scale = max($scaleX, $scaleY);
|
||
|
||
// How many source pixels map to the entire destination.
|
||
$sampleW = max(1, (int) round($width / $scale));
|
||
$sampleH = max(1, (int) round($height / $scale));
|
||
|
||
// Focal ratios (0 = left/top, 0.5 = centre, 1 = right/bottom).
|
||
[$focalX, $focalY] = $this->resolveFocalRatios($focalPosition);
|
||
$srcX = max(0, min($srcW - $sampleW, (int) round(($srcW - $sampleW) * $focalX)));
|
||
$srcY = max(0, min($srcH - $sampleH, (int) round(($srcH - $sampleH) * $focalY)));
|
||
|
||
imagecopyresampled($image, $background, 0, 0, $srcX, $srcY, $width, $height, $sampleW, $sampleH);
|
||
|
||
for ($index = 0; $index < (int) floor($blurLevel / 4); $index++) {
|
||
imagefilter($image, IMG_FILTER_GAUSSIAN_BLUR);
|
||
}
|
||
|
||
imagedestroy($background);
|
||
}
|
||
|
||
private function paintOverlay($image, array $project, int $width, int $height): void
|
||
{
|
||
$style = (string) Arr::get($project, 'background.overlay_style', 'dark-soft');
|
||
$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)),
|
||
];
|
||
}
|
||
}
|