feat: add social image generator with GD-based OG image compositing (#157)
Replace basic single-size OG image generation with full-featured
multi-platform social image compositing:
- Platform-specific canvas sizes: Facebook 1200x630, Twitter 1200x675,
Instagram 1080x1080, Stories 1080x1920
- Vertical linear gradient fallback when no source image available
- Semi-transparent overlay with configurable color and opacity (0-100%)
- Logo placement in top-right corner, auto-scaled to 15% of canvas width
- TTF text rendering with word wrap and text shadow for readability
- GD bitmap font fallback when no TTF fonts are available
- Configurable text position: top, center, or bottom
- Output to images/mokosuitecross/{articleId}_{platform}.jpg
- Cache clearing per article via clearCache() method
- ImageController AJAX endpoint with platform parameter validation
- Full config fieldset: enabled toggle, overlay color/opacity,
text color/position, gradient start/end, logo upload
Authored-by: Moko Consulting
This commit is contained in:
+3
-2
@@ -12,8 +12,9 @@
|
||||
- **Analytics service filter**: Filter heatmap and stats by service type with configurable date range
|
||||
- **Analytics service breakdown**: Per-service success rate, failure count, and average posts per day
|
||||
- **Analytics AJAX endpoint**: JSON heatmap data for dynamic filtering without page reload
|
||||
- **Social image generator**: Generate Open Graph images with article title overlay using PHP GD library (#157)
|
||||
- **Social image config**: Background color, text color, overlay style, and site name override in component options (#157)
|
||||
- **Social image generator**: Auto-generate OG/share images with text overlay, logo, and gradient fallback (#157)
|
||||
- **Social image sizes**: Platform-specific dimensions for Facebook (1200x630), Twitter (1200x675), Instagram (1080x1080), and Stories (1080x1920)
|
||||
- **Social image config**: Overlay color/opacity, text color/position, gradient colors, logo upload in component options (#157)
|
||||
- **AI caption generation**: Generate platform-optimized cross-post captions from article content using Claude or OpenAI (#161)
|
||||
- **AI provider config**: New "AI Caption Generation" fieldset in component options with provider, API key, model, and tone settings
|
||||
- **AI Generate button**: One-click AI generation button in the Share Content panel that fills all caption fields
|
||||
|
||||
@@ -277,43 +277,73 @@
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field
|
||||
name="social_image_bg_color"
|
||||
type="text"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_BG_COLOR"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_BG_COLOR_DESC"
|
||||
name="social_image_overlay_color"
|
||||
type="color"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_COLOR"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_COLOR_DESC"
|
||||
default="#000000"
|
||||
showon="social_image_enabled:1"
|
||||
/>
|
||||
|
||||
<field
|
||||
name="social_image_overlay_opacity"
|
||||
type="number"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_OPACITY"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_OPACITY_DESC"
|
||||
default="60"
|
||||
min="0"
|
||||
max="100"
|
||||
showon="social_image_enabled:1"
|
||||
/>
|
||||
|
||||
<field
|
||||
name="social_image_text_color"
|
||||
type="color"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR_DESC"
|
||||
default="#FFFFFF"
|
||||
showon="social_image_enabled:1"
|
||||
/>
|
||||
|
||||
<field
|
||||
name="social_image_text_position"
|
||||
type="list"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_POSITION"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_POSITION_DESC"
|
||||
default="bottom"
|
||||
showon="social_image_enabled:1">
|
||||
<option value="bottom">COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_POSITION_BOTTOM</option>
|
||||
<option value="center">COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_POSITION_CENTER</option>
|
||||
<option value="top">COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_POSITION_TOP</option>
|
||||
</field>
|
||||
|
||||
<field
|
||||
name="social_image_gradient_start"
|
||||
type="color"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_GRADIENT_START"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_GRADIENT_START_DESC"
|
||||
default="#1a1a2e"
|
||||
showon="social_image_enabled:1"
|
||||
/>
|
||||
|
||||
<field
|
||||
name="social_image_text_color"
|
||||
type="text"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR_DESC"
|
||||
default="#ffffff"
|
||||
name="social_image_gradient_end"
|
||||
type="color"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_GRADIENT_END"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_GRADIENT_END_DESC"
|
||||
default="#16213e"
|
||||
showon="social_image_enabled:1"
|
||||
/>
|
||||
|
||||
<field
|
||||
name="social_image_font_size"
|
||||
type="number"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_FONT_SIZE"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_FONT_SIZE_DESC"
|
||||
default="48"
|
||||
min="24"
|
||||
max="96"
|
||||
name="social_image_logo"
|
||||
type="media"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_LOGO"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_LOGO_DESC"
|
||||
showon="social_image_enabled:1"
|
||||
/>
|
||||
<field
|
||||
name="social_image_show_site_name"
|
||||
type="radio"
|
||||
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SHOW_SITE_NAME"
|
||||
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SHOW_SITE_NAME_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
showon="social_image_enabled:1">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="category_rules" label="COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES">
|
||||
|
||||
@@ -570,6 +570,28 @@ COM_MOKOSUITECROSS_AI_GENERATE_DESC="Generate platform-optimized captions from t
|
||||
COM_MOKOSUITECROSS_AI_GENERATING="Generating captions..."
|
||||
COM_MOKOSUITECROSS_AI_GENERATED="AI captions generated successfully."
|
||||
COM_MOKOSUITECROSS_AI_ERROR="AI generation failed: %s"
|
||||
|
||||
; Social Image Generator
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE="Social Image Generator"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_ENABLED="Enable Social Image Generator"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_ENABLED_DESC="Automatically generate OG/share images with text overlay for cross-posted articles. Requires the PHP GD extension."
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_COLOR="Overlay Color"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_COLOR_DESC="Color of the semi-transparent overlay drawn on top of the background image or gradient."
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_OPACITY="Overlay Opacity"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_OPACITY_DESC="Opacity of the overlay from 0 (fully transparent) to 100 (fully opaque). Default is 60."
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR="Text Color"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR_DESC="Color of the article title text rendered on the social image."
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_POSITION="Text Position"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_POSITION_DESC="Vertical position of the title text on the generated image."
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_POSITION_BOTTOM="Bottom"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_POSITION_CENTER="Center"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_POSITION_TOP="Top"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_GRADIENT_START="Gradient Start Color"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_GRADIENT_START_DESC="Starting color (top) for the gradient background used when no article image is available."
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_GRADIENT_END="Gradient End Color"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_GRADIENT_END_DESC="Ending color (bottom) for the gradient background used when no article image is available."
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_LOGO="Logo"
|
||||
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_LOGO_DESC="Logo image placed in the top-right corner of generated social images. Scaled to 15%% of canvas width."
|
||||
COM_MOKOSUITECROSS_AI_NOT_CONFIGURED="AI is not configured. Go to Options to set up a provider and API key."
|
||||
|
||||
; Analytics
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Helper\SocialImageHelper;
|
||||
|
||||
class ImageController extends BaseController
|
||||
{
|
||||
/**
|
||||
* AJAX endpoint to generate a social image for an article and platform.
|
||||
*
|
||||
* Expected GET parameters:
|
||||
* - article_id (int) The Joomla article ID.
|
||||
* - platform (string) Platform key (facebook, twitter, instagram, stories).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function generate(): void
|
||||
{
|
||||
if (!Session::checkToken('get')) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid token']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->app->getIdentity();
|
||||
|
||||
if (!$user->authorise('core.edit', 'com_mokosuitecross')) {
|
||||
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$articleId = $this->input->getInt('article_id', 0);
|
||||
$platform = $this->input->getCmd('platform', 'facebook');
|
||||
|
||||
if ($articleId < 1) {
|
||||
echo json_encode(['success' => false, 'error' => 'Missing article ID']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!in_array($platform, SocialImageHelper::getSupportedPlatforms(), true)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Unsupported platform: ' . $platform]);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Load article
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName(['id', 'title', 'images']))
|
||||
->from($db->quoteName('#__content'))
|
||||
->where($db->quoteName('id') . ' = ' . $articleId);
|
||||
$db->setQuery($query);
|
||||
$article = $db->loadObject();
|
||||
|
||||
if (!$article) {
|
||||
echo json_encode(['success' => false, 'error' => 'Article not found']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract intro image path from the article images JSON field
|
||||
$imagePath = '';
|
||||
$images = json_decode($article->images ?? '{}', true);
|
||||
|
||||
if (!empty($images['image_intro'])) {
|
||||
$candidate = JPATH_ROOT . '/' . ltrim($images['image_intro'], '/');
|
||||
|
||||
if (is_file($candidate)) {
|
||||
$imagePath = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if ($imagePath === '' && !empty($images['image_fulltext'])) {
|
||||
$candidate = JPATH_ROOT . '/' . ltrim($images['image_fulltext'], '/');
|
||||
|
||||
if (is_file($candidate)) {
|
||||
$imagePath = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Build config from component params
|
||||
$params = ComponentHelper::getParams('com_mokosuitecross');
|
||||
|
||||
$logoRelative = $params->get('social_image_logo', '');
|
||||
$logoPath = '';
|
||||
|
||||
if ($logoRelative !== '') {
|
||||
$candidate = JPATH_ROOT . '/' . ltrim($logoRelative, '/');
|
||||
|
||||
if (is_file($candidate)) {
|
||||
$logoPath = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
$config = [
|
||||
'article_id' => $articleId,
|
||||
'overlay_color' => $params->get('social_image_overlay_color', '#000000'),
|
||||
'overlay_opacity' => (int) $params->get('social_image_overlay_opacity', 60),
|
||||
'text_color' => $params->get('social_image_text_color', '#FFFFFF'),
|
||||
'text_position' => $params->get('social_image_text_position', 'bottom'),
|
||||
'gradient_start' => $params->get('social_image_gradient_start', '#1a1a2e'),
|
||||
'gradient_end' => $params->get('social_image_gradient_end', '#16213e'),
|
||||
'logo_path' => $logoPath,
|
||||
'font_path' => '',
|
||||
];
|
||||
|
||||
try {
|
||||
$relativePath = SocialImageHelper::generate($article->title, $imagePath, $platform, $config);
|
||||
$url = Uri::root() . $relativePath;
|
||||
|
||||
$this->app->setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'path' => $relativePath,
|
||||
'url' => $url,
|
||||
]);
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->app->setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
$this->app->close();
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteCross
|
||||
* @subpackage com_mokosuitecross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteCross\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\Component\MokoSuiteCross\Administrator\Helper\SocialImageHelper;
|
||||
|
||||
class SocialImageController extends BaseController
|
||||
{
|
||||
public function generate(): void
|
||||
{
|
||||
if (!Session::checkToken('get')) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid token']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->app->getIdentity();
|
||||
|
||||
if (!$user->authorise('core.manage', 'com_mokosuitecross')) {
|
||||
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$articleId = $this->input->getInt('article_id', 0);
|
||||
|
||||
if ($articleId < 1) {
|
||||
echo json_encode(['success' => false, 'error' => 'Missing article ID']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName(['id', 'title', 'images']))
|
||||
->from($db->quoteName('#__content'))
|
||||
->where($db->quoteName('id') . ' = ' . $articleId);
|
||||
$db->setQuery($query);
|
||||
$article = $db->loadObject();
|
||||
|
||||
if (!$article) {
|
||||
echo json_encode(['success' => false, 'error' => 'Article not found']);
|
||||
$this->app->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$params = ComponentHelper::getParams('com_mokosuitecross');
|
||||
$siteName = $params->get('social_image_site_name', '') ?: Factory::getApplication()->get('sitename', '');
|
||||
|
||||
$options = [
|
||||
'bg_color' => $params->get('social_image_bg_color', '#1a1a2e'),
|
||||
'text_color' => $params->get('social_image_text_color', '#ffffff'),
|
||||
'overlay' => $params->get('social_image_overlay', 'dark'),
|
||||
];
|
||||
|
||||
$backgroundPath = null;
|
||||
$images = json_decode($article->images ?? '{}', true);
|
||||
|
||||
if (!empty($images['image_intro'])) {
|
||||
$backgroundPath = JPATH_ROOT . '/' . ltrim($images['image_intro'], '/');
|
||||
} elseif (!empty($images['image_fulltext'])) {
|
||||
$backgroundPath = JPATH_ROOT . '/' . ltrim($images['image_fulltext'], '/');
|
||||
}
|
||||
|
||||
try {
|
||||
$imagePath = SocialImageHelper::generate($article->title, $siteName, $backgroundPath, $options);
|
||||
$imageUrl = str_replace(JPATH_ROOT, Uri::root(true), str_replace('\\', '/', $imagePath));
|
||||
|
||||
$result = ['success' => true, 'image_url' => $imageUrl, 'image_path' => $imagePath];
|
||||
} catch (\Throwable $e) {
|
||||
$result = ['success' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
$this->app->setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
echo json_encode($result);
|
||||
$this->app->close();
|
||||
}
|
||||
}
|
||||
@@ -13,142 +13,396 @@ namespace Joomla\Component\MokoSuiteCross\Administrator\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
/**
|
||||
* Social image generator using PHP GD.
|
||||
*
|
||||
* Produces platform-sized OG/share images with text overlay, logo, and gradient fallback.
|
||||
*/
|
||||
class SocialImageHelper
|
||||
{
|
||||
private const WIDTH = 1200;
|
||||
private const HEIGHT = 630;
|
||||
/**
|
||||
* Platform canvas dimensions (width x height).
|
||||
*/
|
||||
private const PLATFORMS = [
|
||||
'facebook' => [1200, 630],
|
||||
'twitter' => [1200, 675],
|
||||
'instagram' => [1080, 1080],
|
||||
'stories' => [1080, 1920],
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate a branded social/OG image with text overlay.
|
||||
*
|
||||
* @param string $title Article title to render on the image
|
||||
* @param string $siteName Site name for branding watermark
|
||||
* @param array $config Rendering config: bg_color, text_color, font_size, show_site_name
|
||||
*
|
||||
* @return array ['success' => bool, 'image_url' => string, 'error' => string]
|
||||
* Maximum logo width as a fraction of canvas width.
|
||||
*/
|
||||
public static function generate(string $title, string $siteName, array $config): array
|
||||
private const LOGO_MAX_WIDTH_RATIO = 0.15;
|
||||
|
||||
/**
|
||||
* Logo inset from the top-right corner in pixels.
|
||||
*/
|
||||
private const LOGO_INSET = 20;
|
||||
|
||||
/**
|
||||
* Generate a social image for the given platform.
|
||||
*
|
||||
* @param string $title Article title for text overlay.
|
||||
* @param string $imagePath Absolute path to the source image (may be empty).
|
||||
* @param string $platform Platform key (facebook, twitter, instagram, stories).
|
||||
* @param array $config Configuration array:
|
||||
* - article_id (int) Required for output filename.
|
||||
* - overlay_color (string) Hex colour, default #000000.
|
||||
* - overlay_opacity (int) 0-100, default 60.
|
||||
* - text_color (string) Hex colour, default #FFFFFF.
|
||||
* - text_position (string) top|center|bottom, default bottom.
|
||||
* - gradient_start (string) Hex colour, default #1a1a2e.
|
||||
* - gradient_end (string) Hex colour, default #16213e.
|
||||
* - logo_path (string) Absolute path to logo image.
|
||||
* - font_path (string) Absolute path to TTF font.
|
||||
*
|
||||
* @return string Relative path to the generated image (from site root).
|
||||
*
|
||||
* @throws \RuntimeException If GD is unavailable or generation fails.
|
||||
*/
|
||||
public static function generate(string $title, string $imagePath, string $platform, array $config): string
|
||||
{
|
||||
if (!\function_exists('imagecreatetruecolor')) {
|
||||
return ['success' => false, 'error' => 'PHP GD extension is not available'];
|
||||
if (!extension_loaded('gd')) {
|
||||
throw new \RuntimeException('PHP GD extension is required for social image generation.');
|
||||
}
|
||||
|
||||
$bgColor = $config['bg_color'] ?? '#1a1a2e';
|
||||
$textColor = $config['text_color'] ?? '#ffffff';
|
||||
$fontSize = (int) ($config['font_size'] ?? 48);
|
||||
$showSiteName = (bool) ($config['show_site_name'] ?? true);
|
||||
|
||||
$fontSize = max(24, min(96, $fontSize));
|
||||
|
||||
$image = imagecreatetruecolor(self::WIDTH, self::HEIGHT);
|
||||
|
||||
if ($image === false) {
|
||||
return ['success' => false, 'error' => 'Failed to create image canvas'];
|
||||
if (!isset(self::PLATFORMS[$platform])) {
|
||||
throw new \RuntimeException('Unsupported platform: ' . $platform);
|
||||
}
|
||||
|
||||
$bgRgb = self::hexToRgb($bgColor);
|
||||
$textRgb = self::hexToRgb($textColor);
|
||||
[$canvasW, $canvasH] = self::PLATFORMS[$platform];
|
||||
|
||||
$bg = imagecolorallocate($image, $bgRgb[0], $bgRgb[1], $bgRgb[2]);
|
||||
$text = imagecolorallocate($image, $textRgb[0], $textRgb[1], $textRgb[2]);
|
||||
$canvas = imagecreatetruecolor($canvasW, $canvasH);
|
||||
|
||||
imagefilledrectangle($image, 0, 0, self::WIDTH - 1, self::HEIGHT - 1, $bg);
|
||||
if ($canvas === false) {
|
||||
throw new \RuntimeException('Failed to create image canvas.');
|
||||
}
|
||||
|
||||
$fontFile = self::findFont();
|
||||
|
||||
if ($fontFile !== null) {
|
||||
self::renderTtfText($image, $title, $text, $fontSize, $fontFile);
|
||||
|
||||
if ($showSiteName && $siteName !== '') {
|
||||
$siteSize = (int) round($fontSize * 0.45);
|
||||
$siteBox = imagettfbbox($siteSize, 0, $fontFile, $siteName);
|
||||
$siteX = self::WIDTH - ($siteBox[2] - $siteBox[0]) - 40;
|
||||
$siteY = self::HEIGHT - 30;
|
||||
imagettftext($image, $siteSize, 0, $siteX, $siteY, $text, $fontFile, $siteName);
|
||||
}
|
||||
// --- Background: source image or gradient fallback ---
|
||||
if ($imagePath !== '' && is_file($imagePath)) {
|
||||
self::drawSourceImage($canvas, $imagePath, $canvasW, $canvasH);
|
||||
} else {
|
||||
self::renderFallbackText($image, $title, $text);
|
||||
|
||||
if ($showSiteName && $siteName !== '') {
|
||||
$siteX = self::WIDTH - (\strlen($siteName) * imagefontwidth(3)) - 40;
|
||||
$siteY = self::HEIGHT - 30;
|
||||
imagestring($image, 3, $siteX, $siteY, $siteName, $text);
|
||||
}
|
||||
self::drawGradient(
|
||||
$canvas,
|
||||
$canvasW,
|
||||
$canvasH,
|
||||
$config['gradient_start'] ?? '#1a1a2e',
|
||||
$config['gradient_end'] ?? '#16213e'
|
||||
);
|
||||
}
|
||||
|
||||
$outputDir = JPATH_ROOT . '/media/com_mokosuitecross/social';
|
||||
// --- Semi-transparent overlay ---
|
||||
self::drawOverlay(
|
||||
$canvas,
|
||||
$canvasW,
|
||||
$canvasH,
|
||||
$config['overlay_color'] ?? '#000000',
|
||||
(int) ($config['overlay_opacity'] ?? 60)
|
||||
);
|
||||
|
||||
// --- Logo (top-right) ---
|
||||
if (!empty($config['logo_path']) && is_file($config['logo_path'])) {
|
||||
self::drawLogo($canvas, $config['logo_path'], $canvasW);
|
||||
}
|
||||
|
||||
// --- Title text ---
|
||||
self::drawText(
|
||||
$canvas,
|
||||
$title,
|
||||
$canvasW,
|
||||
$canvasH,
|
||||
$config['text_color'] ?? '#FFFFFF',
|
||||
$config['text_position'] ?? 'bottom',
|
||||
$config['font_path'] ?? ''
|
||||
);
|
||||
|
||||
// --- Write output ---
|
||||
$articleId = (int) ($config['article_id'] ?? 0);
|
||||
$outputDir = JPATH_ROOT . '/images/mokosuitecross';
|
||||
|
||||
if (!is_dir($outputDir)) {
|
||||
mkdir($outputDir, 0755, true);
|
||||
}
|
||||
|
||||
$hash = hash('sha256', $title . $bgColor . $textColor . $fontSize);
|
||||
$filename = $hash . '.png';
|
||||
$filePath = $outputDir . '/' . $filename;
|
||||
$filename = $articleId . '_' . $platform . '.jpg';
|
||||
$outputPath = $outputDir . '/' . $filename;
|
||||
|
||||
if (!imagepng($image, $filePath, 6)) {
|
||||
imagedestroy($image);
|
||||
if (!imagejpeg($canvas, $outputPath, 90)) {
|
||||
imagedestroy($canvas);
|
||||
|
||||
return ['success' => false, 'error' => 'Failed to save image file'];
|
||||
throw new \RuntimeException('Failed to write image: ' . $outputPath);
|
||||
}
|
||||
|
||||
imagedestroy($image);
|
||||
imagedestroy($canvas);
|
||||
|
||||
$imageUrl = 'media/com_mokosuitecross/social/' . $filename;
|
||||
|
||||
return ['success' => true, 'image_url' => $imageUrl];
|
||||
return 'images/mokosuitecross/' . $filename;
|
||||
}
|
||||
|
||||
private static function renderTtfText(\GdImage $image, string $title, int $color, int $fontSize, string $fontFile): void
|
||||
/**
|
||||
* Remove all cached social images for an article.
|
||||
*
|
||||
* @param int $articleId The article ID.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function clearCache(int $articleId): void
|
||||
{
|
||||
$maxWidth = self::WIDTH - 120;
|
||||
$lines = self::wordWrapTtf($title, $fontFile, $fontSize, $maxWidth);
|
||||
$lineHeight = (int) round($fontSize * 1.4);
|
||||
$totalHeight = \count($lines) * $lineHeight;
|
||||
$dir = JPATH_ROOT . '/images/mokosuitecross';
|
||||
|
||||
$startY = (int) round((self::HEIGHT - $totalHeight) / 2) + $fontSize;
|
||||
|
||||
foreach ($lines as $i => $line) {
|
||||
$y = $startY + ($i * $lineHeight);
|
||||
imagettftext($image, $fontSize, 0, 60, $y, $color, $fontFile, $line);
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static function renderFallbackText(\GdImage $image, string $title, int $color): void
|
||||
{
|
||||
$font = 5;
|
||||
$charWidth = imagefontwidth($font);
|
||||
$charHeight = imagefontheight($font);
|
||||
$maxChars = (int) floor((self::WIDTH - 120) / $charWidth);
|
||||
$lines = wordwrap($title, $maxChars, "\n", true);
|
||||
$lineArray = explode("\n", $lines);
|
||||
$lineHeight = $charHeight + 8;
|
||||
$totalHeight = \count($lineArray) * $lineHeight;
|
||||
$startY = (int) round((self::HEIGHT - $totalHeight) / 2);
|
||||
foreach (self::PLATFORMS as $platform => $dims) {
|
||||
$file = $dir . '/' . $articleId . '_' . $platform . '.jpg';
|
||||
|
||||
foreach ($lineArray as $i => $line) {
|
||||
$y = $startY + ($i * $lineHeight);
|
||||
imagestring($image, $font, 60, $y, $line, $color);
|
||||
if (is_file($file)) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Word-wrap text for TTF rendering at a given pixel width.
|
||||
* Return the list of supported platform keys.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private static function wordWrapTtf(string $text, string $fontFile, int $fontSize, int $maxWidth): array
|
||||
public static function getSupportedPlatforms(): array
|
||||
{
|
||||
$words = explode(' ', $text);
|
||||
$lines = [];
|
||||
return array_keys(self::PLATFORMS);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Private drawing helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load a source image and resize/crop to fill the canvas.
|
||||
*/
|
||||
private static function drawSourceImage(\GdImage $canvas, string $path, int $canvasW, int $canvasH): void
|
||||
{
|
||||
$source = self::loadImage($path);
|
||||
|
||||
if ($source === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$srcW = imagesx($source);
|
||||
$srcH = imagesy($source);
|
||||
|
||||
// Centre-crop resize (cover)
|
||||
$scale = max($canvasW / $srcW, $canvasH / $srcH);
|
||||
$newW = (int) round($srcW * $scale);
|
||||
$newH = (int) round($srcH * $scale);
|
||||
$offX = (int) round(($canvasW - $newW) / 2);
|
||||
$offY = (int) round(($canvasH - $newH) / 2);
|
||||
|
||||
imagecopyresampled($canvas, $source, $offX, $offY, 0, 0, $newW, $newH, $srcW, $srcH);
|
||||
imagedestroy($source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a vertical linear gradient as a background.
|
||||
*/
|
||||
private static function drawGradient(\GdImage $canvas, int $w, int $h, string $startHex, string $endHex): void
|
||||
{
|
||||
[$r1, $g1, $b1] = self::hexToRgb($startHex);
|
||||
[$r2, $g2, $b2] = self::hexToRgb($endHex);
|
||||
|
||||
for ($y = 0; $y < $h; $y++) {
|
||||
$ratio = $y / max($h - 1, 1);
|
||||
$r = (int) round($r1 + ($r2 - $r1) * $ratio);
|
||||
$g = (int) round($g1 + ($g2 - $g1) * $ratio);
|
||||
$b = (int) round($b1 + ($b2 - $b1) * $ratio);
|
||||
$color = imagecolorallocate($canvas, $r, $g, $b);
|
||||
imageline($canvas, 0, $y, $w - 1, $y, $color);
|
||||
imagecolordeallocate($canvas, $color);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a semi-transparent overlay rectangle.
|
||||
*/
|
||||
private static function drawOverlay(\GdImage $canvas, int $w, int $h, string $hex, int $opacity): void
|
||||
{
|
||||
if ($opacity < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$opacity = min($opacity, 100);
|
||||
[$r, $g, $b] = self::hexToRgb($hex);
|
||||
|
||||
// GD alpha: 0 = opaque, 127 = fully transparent
|
||||
$alpha = (int) round(127 - ($opacity / 100 * 127));
|
||||
$color = imagecolorallocatealpha($canvas, $r, $g, $b, $alpha);
|
||||
imagefilledrectangle($canvas, 0, 0, $w - 1, $h - 1, $color);
|
||||
imagecolordeallocate($canvas, $color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a logo in the top-right corner, scaled to at most 15% of canvas width.
|
||||
*/
|
||||
private static function drawLogo(\GdImage $canvas, string $logoPath, int $canvasW): void
|
||||
{
|
||||
$logo = self::loadImage($logoPath);
|
||||
|
||||
if ($logo === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$logoW = imagesx($logo);
|
||||
$logoH = imagesy($logo);
|
||||
$maxW = (int) round($canvasW * self::LOGO_MAX_WIDTH_RATIO);
|
||||
|
||||
if ($logoW > $maxW) {
|
||||
$scale = $maxW / $logoW;
|
||||
$newW = $maxW;
|
||||
$newH = (int) round($logoH * $scale);
|
||||
$scaled = imagecreatetruecolor($newW, $newH);
|
||||
|
||||
imagesavealpha($scaled, true);
|
||||
$transparent = imagecolorallocatealpha($scaled, 0, 0, 0, 127);
|
||||
imagefill($scaled, 0, 0, $transparent);
|
||||
imagecopyresampled($scaled, $logo, 0, 0, 0, 0, $newW, $newH, $logoW, $logoH);
|
||||
imagedestroy($logo);
|
||||
$logo = $scaled;
|
||||
$logoW = $newW;
|
||||
$logoH = $newH;
|
||||
}
|
||||
|
||||
$x = $canvasW - $logoW - self::LOGO_INSET;
|
||||
$y = self::LOGO_INSET;
|
||||
|
||||
imagecopy($canvas, $logo, $x, $y, 0, 0, $logoW, $logoH);
|
||||
imagedestroy($logo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw word-wrapped title text onto the canvas.
|
||||
*/
|
||||
private static function drawText(
|
||||
\GdImage $canvas,
|
||||
string $title,
|
||||
int $canvasW,
|
||||
int $canvasH,
|
||||
string $colorHex,
|
||||
string $position,
|
||||
string $fontPath
|
||||
): void {
|
||||
if ($title === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
[$r, $g, $b] = self::hexToRgb($colorHex);
|
||||
$color = imagecolorallocate($canvas, $r, $g, $b);
|
||||
$padding = (int) round($canvasW * 0.06);
|
||||
|
||||
$useTtf = ($fontPath !== '' && is_file($fontPath) && function_exists('imagettftext'));
|
||||
|
||||
if ($useTtf) {
|
||||
self::drawTtfText($canvas, $title, $fontPath, $color, $canvasW, $canvasH, $padding, $position);
|
||||
} else {
|
||||
self::drawGdText($canvas, $title, $color, $canvasW, $canvasH, $padding, $position);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render text using a TrueType font with automatic word wrapping.
|
||||
*/
|
||||
private static function drawTtfText(
|
||||
\GdImage $canvas,
|
||||
string $title,
|
||||
string $fontPath,
|
||||
int $color,
|
||||
int $canvasW,
|
||||
int $canvasH,
|
||||
int $padding,
|
||||
string $position
|
||||
): void {
|
||||
$maxTextW = $canvasW - ($padding * 2);
|
||||
$fontSize = (int) round($canvasH * 0.055);
|
||||
$fontSize = max(16, min($fontSize, 64));
|
||||
|
||||
$lines = self::wordWrapTtf($title, $fontPath, $fontSize, $maxTextW);
|
||||
$lineHeight = (int) round($fontSize * 1.35);
|
||||
$totalH = count($lines) * $lineHeight;
|
||||
|
||||
$y = match ($position) {
|
||||
'top' => $padding + $fontSize,
|
||||
'center' => (int) round(($canvasH - $totalH) / 2) + $fontSize,
|
||||
default => $canvasH - $padding - $totalH + $fontSize,
|
||||
};
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$box = imagettfbbox($fontSize, 0, $fontPath, $line);
|
||||
$textW = abs($box[2] - $box[0]);
|
||||
$x = (int) round(($canvasW - $textW) / 2);
|
||||
|
||||
// Draw text shadow for readability
|
||||
$shadow = imagecolorallocatealpha($canvas, 0, 0, 0, 60);
|
||||
imagettftext($canvas, $fontSize, 0, $x + 2, $y + 2, $shadow, $fontPath, $line);
|
||||
imagecolordeallocate($canvas, $shadow);
|
||||
|
||||
imagettftext($canvas, $fontSize, 0, $x, $y, $color, $fontPath, $line);
|
||||
$y += $lineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render text using the built-in GD bitmap font (fallback when no TTF is available).
|
||||
*/
|
||||
private static function drawGdText(
|
||||
\GdImage $canvas,
|
||||
string $title,
|
||||
int $color,
|
||||
int $canvasW,
|
||||
int $canvasH,
|
||||
int $padding,
|
||||
string $position
|
||||
): void {
|
||||
$font = 5; // largest built-in GD font
|
||||
$charW = imagefontwidth($font);
|
||||
$charH = imagefontheight($font);
|
||||
$maxChars = (int) floor(($canvasW - $padding * 2) / $charW);
|
||||
$maxChars = max($maxChars, 10);
|
||||
|
||||
$wrapped = wordwrap($title, $maxChars, "\n", true);
|
||||
$lines = explode("\n", $wrapped);
|
||||
$lineHeight = $charH + 4;
|
||||
$totalH = count($lines) * $lineHeight;
|
||||
|
||||
$y = match ($position) {
|
||||
'top' => $padding,
|
||||
'center' => (int) round(($canvasH - $totalH) / 2),
|
||||
default => $canvasH - $padding - $totalH,
|
||||
};
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$textW = mb_strlen($line) * $charW;
|
||||
$x = (int) round(($canvasW - $textW) / 2);
|
||||
imagestring($canvas, $font, $x, $y, $line, $color);
|
||||
$y += $lineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Word-wrap a string to fit within a pixel width using TTF metrics.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private static function wordWrapTtf(string $text, string $fontPath, int $fontSize, int $maxWidth): array
|
||||
{
|
||||
$words = preg_split('/\s+/', $text);
|
||||
$lines = [];
|
||||
$currentLine = '';
|
||||
|
||||
foreach ($words as $word) {
|
||||
$testLine = $currentLine === '' ? $word : $currentLine . ' ' . $word;
|
||||
$box = imagettfbbox($fontSize, 0, $fontFile, $testLine);
|
||||
$width = abs($box[2] - $box[0]);
|
||||
$testLine = $currentLine === '' ? $word : $currentLine . ' ' . $word;
|
||||
$box = imagettfbbox($fontSize, 0, $fontPath, $testLine);
|
||||
$lineWidth = abs($box[2] - $box[0]);
|
||||
|
||||
if ($width > $maxWidth && $currentLine !== '') {
|
||||
if ($lineWidth > $maxWidth && $currentLine !== '') {
|
||||
$lines[] = $currentLine;
|
||||
$currentLine = $word;
|
||||
} else {
|
||||
@@ -164,37 +418,43 @@ class SocialImageHelper
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate a usable TTF font file -- check common system locations.
|
||||
* Load an image file (JPEG, PNG, WebP, GIF) and return a GdImage resource.
|
||||
*/
|
||||
private static function findFont(): ?string
|
||||
private static function loadImage(string $path): ?\GdImage
|
||||
{
|
||||
$candidates = [
|
||||
JPATH_ROOT . '/media/com_mokosuitecross/fonts/OpenSans-Bold.ttf',
|
||||
JPATH_ROOT . '/media/com_mokosuitecross/fonts/Roboto-Bold.ttf',
|
||||
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
|
||||
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
|
||||
'/usr/share/fonts/TTF/DejaVuSans-Bold.ttf',
|
||||
'C:/Windows/Fonts/arial.ttf',
|
||||
'C:/Windows/Fonts/segoeui.ttf',
|
||||
];
|
||||
|
||||
foreach ($candidates as $path) {
|
||||
if (is_file($path)) {
|
||||
return $path;
|
||||
}
|
||||
if (!is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
$info = @getimagesize($path);
|
||||
|
||||
if ($info === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$image = match ($info[2]) {
|
||||
IMAGETYPE_JPEG => @imagecreatefromjpeg($path),
|
||||
IMAGETYPE_PNG => @imagecreatefrompng($path),
|
||||
IMAGETYPE_GIF => @imagecreatefromgif($path),
|
||||
IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : false,
|
||||
default => false,
|
||||
};
|
||||
|
||||
return ($image instanceof \GdImage) ? $image : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[] [r, g, b]
|
||||
* Convert a hex colour string to an [R, G, B] array.
|
||||
*
|
||||
* @param string $hex Hex colour (e.g. #FF00AA or FF00AA).
|
||||
*
|
||||
* @return int[] [red, green, blue] each 0-255.
|
||||
*/
|
||||
private static function hexToRgb(string $hex): array
|
||||
{
|
||||
$hex = ltrim($hex, '#');
|
||||
|
||||
if (\strlen($hex) === 3) {
|
||||
if (strlen($hex) === 3) {
|
||||
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||
}
|
||||
|
||||
@@ -204,4 +464,4 @@ class SocialImageHelper
|
||||
(int) hexdec(substr($hex, 4, 2)),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user