168 lines
5.2 KiB
PHP
168 lines
5.2 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Domain\Media;
|
||
|
||
use App\Models\Article;
|
||
use App\Models\Attachment;
|
||
use App\Settings\GeneralSettings;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use Intervention\Image\Drivers\Gd\Driver;
|
||
use Intervention\Image\ImageManager;
|
||
use RuntimeException;
|
||
|
||
/**
|
||
* Template-rendered OG covers (1200×630) written to the attachments disk.
|
||
* Uses title + summary; does not call an external text-to-image API.
|
||
*/
|
||
class ArticleCoverGenerator
|
||
{
|
||
public function generate(Article $article): array
|
||
{
|
||
$disk = (string) config('larablog.attachments_disk', 'attachments');
|
||
$relative = sprintf(
|
||
'covers/%s/article-%d-%s.jpg',
|
||
now()->format('Y/m'),
|
||
$article->id,
|
||
substr(sha1($article->title.'|'.microtime(true)), 0, 10),
|
||
);
|
||
|
||
$binary = $this->renderJpeg($article);
|
||
Storage::disk($disk)->put($relative, $binary, ['visibility' => 'public']);
|
||
|
||
Attachment::query()->create([
|
||
'article_id' => $article->id,
|
||
'disk' => $disk,
|
||
'path' => $relative,
|
||
'filename' => basename($relative),
|
||
'mime' => 'image/jpeg',
|
||
'size' => strlen($binary),
|
||
'checksum' => hash('sha256', $binary),
|
||
'visibility' => Attachment::VISIBILITY_PUBLIC,
|
||
'synced_at' => now(),
|
||
]);
|
||
|
||
return [
|
||
'disk' => $disk,
|
||
'path' => $relative,
|
||
'source' => ArticleCoverService::SOURCE_GENERATED,
|
||
];
|
||
}
|
||
|
||
protected function renderJpeg(Article $article): string
|
||
{
|
||
$manager = new ImageManager(new Driver);
|
||
$image = $manager->create(1200, 630)->fill('#0f3d4c');
|
||
|
||
// Soft bands for atmosphere (avoid a single flat fill).
|
||
$image->drawRectangle(0, 0, function ($rect): void {
|
||
$rect->size(1200, 210)->background('#123f4f');
|
||
});
|
||
$image->drawRectangle(0, 420, function ($rect): void {
|
||
$rect->size(1200, 210)->background('#16384a');
|
||
});
|
||
$image->drawRectangle(0, 0, function ($rect): void {
|
||
$rect->size(1200, 12)->background('#2bb0a6');
|
||
});
|
||
|
||
$site = 'LaraBlog';
|
||
try {
|
||
$site = (string) (app(GeneralSettings::class)->site_name ?: $site);
|
||
} catch (\Throwable) {
|
||
//
|
||
}
|
||
|
||
$title = $this->wrapText((string) $article->title, 18, 3);
|
||
$summary = trim((string) ($article->ai_summary ?: $article->description ?: ''));
|
||
if ($summary !== '') {
|
||
$summary = $this->wrapText($summary, 32, 2);
|
||
}
|
||
|
||
$font = $this->fontFile();
|
||
|
||
if ($font !== null) {
|
||
$image->text($site, 72, 110, function ($f) use ($font): void {
|
||
$f->filename($font);
|
||
$f->size(28);
|
||
$f->color('#8fd9d2');
|
||
});
|
||
|
||
$image->text($title, 72, 220, function ($f) use ($font): void {
|
||
$f->filename($font);
|
||
$f->size(56);
|
||
$f->color('#f4f7f8');
|
||
$f->lineHeight(1.25);
|
||
});
|
||
|
||
if ($summary !== '') {
|
||
$image->text($summary, 72, 430, function ($f) use ($font): void {
|
||
$f->filename($font);
|
||
$f->size(26);
|
||
$f->color('#c5d4db');
|
||
$f->lineHeight(1.35);
|
||
});
|
||
}
|
||
}
|
||
|
||
$encoded = $image->toJpeg(85)->toString();
|
||
|
||
if (! is_string($encoded) || $encoded === '') {
|
||
throw new RuntimeException('Failed to encode generated cover JPEG.');
|
||
}
|
||
|
||
return $encoded;
|
||
}
|
||
|
||
protected function wrapText(string $text, int $maxChars, int $maxLines): string
|
||
{
|
||
$text = trim(preg_replace('/\s+/u', ' ', $text) ?? '');
|
||
|
||
if ($text === '') {
|
||
return '';
|
||
}
|
||
|
||
$lines = [];
|
||
while ($text !== '' && count($lines) < $maxLines) {
|
||
if (mb_strlen($text) <= $maxChars) {
|
||
$lines[] = $text;
|
||
break;
|
||
}
|
||
|
||
$chunk = mb_substr($text, 0, $maxChars);
|
||
$lines[] = $chunk;
|
||
$text = ltrim(mb_substr($text, $maxChars));
|
||
}
|
||
|
||
if ($text !== '' && $lines !== []) {
|
||
$last = $lines[array_key_last($lines)];
|
||
$lines[array_key_last($lines)] = mb_substr($last, 0, max(1, $maxChars - 1)).'…';
|
||
}
|
||
|
||
return implode("\n", $lines);
|
||
}
|
||
|
||
protected function fontFile(): ?string
|
||
{
|
||
$configured = config('larablog.cover_font');
|
||
$candidates = array_filter([
|
||
is_string($configured) && $configured !== '' ? $configured : null,
|
||
resource_path('fonts/Cover.ttf'),
|
||
resource_path('fonts/NotoSansSC-Regular.otf'),
|
||
'/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
|
||
'/Library/Fonts/Arial Unicode.ttf',
|
||
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
|
||
'/usr/share/fonts/truetype/wqy/wqy-microhei.ttc',
|
||
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
|
||
]);
|
||
|
||
foreach ($candidates as $path) {
|
||
if (is_string($path) && is_file($path)) {
|
||
return $path;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
}
|