diff --git a/CHANGELOG.md b/CHANGELOG.md
index 51ed2a44..3dc6d605 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/source/packages/com_mokosuitecross/config.xml b/source/packages/com_mokosuitecross/config.xml
index c93837fd..29f0fa70 100644
--- a/source/packages/com_mokosuitecross/config.xml
+++ b/source/packages/com_mokosuitecross/config.xml
@@ -277,43 +277,73 @@
JYES
JNO
+
+
+
+
+
+
+
+ COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_POSITION_BOTTOM
+ COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_POSITION_CENTER
+ COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_POSITION_TOP
+
+
+
+
+
-
- JYES
- JNO
-
diff --git a/source/packages/com_mokosuitecross/language/en-GB/com_mokosuitecross.ini b/source/packages/com_mokosuitecross/language/en-GB/com_mokosuitecross.ini
index e0f2b18c..f2531646 100644
--- a/source/packages/com_mokosuitecross/language/en-GB/com_mokosuitecross.ini
+++ b/source/packages/com_mokosuitecross/language/en-GB/com_mokosuitecross.ini
@@ -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
diff --git a/source/packages/com_mokosuitecross/src/Controller/ImageController.php b/source/packages/com_mokosuitecross/src/Controller/ImageController.php
new file mode 100644
index 00000000..557242d2
--- /dev/null
+++ b/source/packages/com_mokosuitecross/src/Controller/ImageController.php
@@ -0,0 +1,148 @@
+
+ * @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();
+ }
+}
\ No newline at end of file
diff --git a/source/packages/com_mokosuitecross/src/Controller/SocialImageController.php b/source/packages/com_mokosuitecross/src/Controller/SocialImageController.php
deleted file mode 100644
index 36462869..00000000
--- a/source/packages/com_mokosuitecross/src/Controller/SocialImageController.php
+++ /dev/null
@@ -1,98 +0,0 @@
-
- * @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();
- }
-}
diff --git a/source/packages/com_mokosuitecross/src/Helper/SocialImageHelper.php b/source/packages/com_mokosuitecross/src/Helper/SocialImageHelper.php
index 701f0395..5d06d6d3 100644
--- a/source/packages/com_mokosuitecross/src/Helper/SocialImageHelper.php
+++ b/source/packages/com_mokosuitecross/src/Helper/SocialImageHelper.php
@@ -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)),
];
}
-}
+}
\ No newline at end of file