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:
2026-06-28 12:06:02 -05:00
parent 4810371bc0
commit 1aa58e1d8d
6 changed files with 601 additions and 238 deletions
+3 -2
View File
@@ -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
+57 -27
View File
@@ -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)),
];
}
}
}