diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3dc6d605..86a037a5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,9 +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**: 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)
+- **Social image generator**: Generate branded 1200x630 OG images with article title overlay using PHP GD (#157)
+- **Social image config**: Background color, text color, font size, and site name branding options (#157)
+- **Generate Social Image button**: One-click image generation in the Share Content panel (#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 29f0fa70..8385117d 100644
--- a/source/packages/com_mokosuitecross/config.xml
+++ b/source/packages/com_mokosuitecross/config.xml
@@ -266,7 +266,7 @@
-
+
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 f2531646..f4ce7f59 100644
--- a/source/packages/com_mokosuitecross/language/en-GB/com_mokosuitecross.ini
+++ b/source/packages/com_mokosuitecross/language/en-GB/com_mokosuitecross.ini
@@ -573,26 +573,21 @@ 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_ENABLED="Enable Social Images"
+COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_ENABLED_DESC="Generate branded OG images with article title overlay for social sharing."
+COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_BG_COLOR="Background Color"
+COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_BG_COLOR_DESC="Hex color for the image background (e.g. #1a1a2e)."
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."
+COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR_DESC="Hex color for the title text overlay."
+COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_FONT_SIZE="Font Size"
+COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_FONT_SIZE_DESC="Font size in pixels for the title text (24-96)."
+COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SHOW_SITE_NAME="Show Site Name"
+COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SHOW_SITE_NAME_DESC="Display the site name in the bottom-right corner of generated images."
+COM_MOKOSUITECROSS_SOCIAL_IMAGE_GENERATE="Generate Social Image"
+COM_MOKOSUITECROSS_SOCIAL_IMAGE_GENERATING="Generating image..."
+COM_MOKOSUITECROSS_SOCIAL_IMAGE_GENERATED="Social image generated."
+COM_MOKOSUITECROSS_SOCIAL_IMAGE_ERROR="Image generation failed: %s"
+COM_MOKOSUITECROSS_SOCIAL_IMAGE_NOT_CONFIGURED="Social image generator is not enabled. Go to Options to enable it."
; Analytics
COM_MOKOSUITECROSS_SUBMENU_ANALYTICS="Analytics"
diff --git a/source/packages/com_mokosuitecross/src/Controller/ImageController.php b/source/packages/com_mokosuitecross/src/Controller/ImageController.php
deleted file mode 100644
index 557242d2..00000000
--- a/source/packages/com_mokosuitecross/src/Controller/ImageController.php
+++ /dev/null
@@ -1,148 +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 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
new file mode 100644
index 00000000..c3010299
--- /dev/null
+++ b/source/packages/com_mokosuitecross/src/Controller/SocialImageController.php
@@ -0,0 +1,90 @@
+
+ * @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\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.edit', '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;
+ }
+
+ $params = ComponentHelper::getParams('com_mokosuitecross');
+
+ if (!(int) $params->get('social_image_enabled', 0)) {
+ echo json_encode(['success' => false, 'error' => 'Social image generator is not enabled']);
+ $this->app->close();
+
+ return;
+ }
+
+ $db = Factory::getDbo();
+ $query = $db->getQuery(true)
+ ->select($db->quoteName('title'))
+ ->from($db->quoteName('#__content'))
+ ->where($db->quoteName('id') . ' = ' . $articleId);
+ $db->setQuery($query);
+ $title = $db->loadResult();
+
+ if (!$title) {
+ echo json_encode(['success' => false, 'error' => 'Article not found']);
+ $this->app->close();
+
+ return;
+ }
+
+ $siteName = $this->app->get('sitename', '');
+
+ $config = [
+ 'bg_color' => $params->get('social_image_bg_color', '#1a1a2e'),
+ 'text_color' => $params->get('social_image_text_color', '#ffffff'),
+ 'font_size' => $params->get('social_image_font_size', 48),
+ 'show_site_name' => (bool) $params->get('social_image_show_site_name', 1),
+ ];
+
+ $result = SocialImageHelper::generate($title, $siteName, $config);
+
+ $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 5d06d6d3..701f0395 100644
--- a/source/packages/com_mokosuitecross/src/Helper/SocialImageHelper.php
+++ b/source/packages/com_mokosuitecross/src/Helper/SocialImageHelper.php
@@ -13,396 +13,142 @@ 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
{
- /**
- * Platform canvas dimensions (width x height).
- */
- private const PLATFORMS = [
- 'facebook' => [1200, 630],
- 'twitter' => [1200, 675],
- 'instagram' => [1080, 1080],
- 'stories' => [1080, 1920],
- ];
+ private const WIDTH = 1200;
+ private const HEIGHT = 630;
/**
- * Maximum logo width as a fraction of canvas width.
- */
- 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.
+ * Generate a branded social/OG image with text overlay.
*
- * @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.
+ * @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 string Relative path to the generated image (from site root).
- *
- * @throws \RuntimeException If GD is unavailable or generation fails.
+ * @return array ['success' => bool, 'image_url' => string, 'error' => string]
*/
- public static function generate(string $title, string $imagePath, string $platform, array $config): string
+ public static function generate(string $title, string $siteName, array $config): array
{
- if (!extension_loaded('gd')) {
- throw new \RuntimeException('PHP GD extension is required for social image generation.');
+ if (!\function_exists('imagecreatetruecolor')) {
+ return ['success' => false, 'error' => 'PHP GD extension is not available'];
}
- if (!isset(self::PLATFORMS[$platform])) {
- throw new \RuntimeException('Unsupported platform: ' . $platform);
+ $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'];
}
- [$canvasW, $canvasH] = self::PLATFORMS[$platform];
+ $bgRgb = self::hexToRgb($bgColor);
+ $textRgb = self::hexToRgb($textColor);
- $canvas = imagecreatetruecolor($canvasW, $canvasH);
+ $bg = imagecolorallocate($image, $bgRgb[0], $bgRgb[1], $bgRgb[2]);
+ $text = imagecolorallocate($image, $textRgb[0], $textRgb[1], $textRgb[2]);
- if ($canvas === false) {
- throw new \RuntimeException('Failed to create image canvas.');
- }
+ imagefilledrectangle($image, 0, 0, self::WIDTH - 1, self::HEIGHT - 1, $bg);
- // --- Background: source image or gradient fallback ---
- if ($imagePath !== '' && is_file($imagePath)) {
- self::drawSourceImage($canvas, $imagePath, $canvasW, $canvasH);
+ $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);
+ }
} else {
- self::drawGradient(
- $canvas,
- $canvasW,
- $canvasH,
- $config['gradient_start'] ?? '#1a1a2e',
- $config['gradient_end'] ?? '#16213e'
- );
+ 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);
+ }
}
- // --- 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';
+ $outputDir = JPATH_ROOT . '/media/com_mokosuitecross/social';
if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
}
- $filename = $articleId . '_' . $platform . '.jpg';
- $outputPath = $outputDir . '/' . $filename;
+ $hash = hash('sha256', $title . $bgColor . $textColor . $fontSize);
+ $filename = $hash . '.png';
+ $filePath = $outputDir . '/' . $filename;
- if (!imagejpeg($canvas, $outputPath, 90)) {
- imagedestroy($canvas);
+ if (!imagepng($image, $filePath, 6)) {
+ imagedestroy($image);
- throw new \RuntimeException('Failed to write image: ' . $outputPath);
+ return ['success' => false, 'error' => 'Failed to save image file'];
}
- imagedestroy($canvas);
+ imagedestroy($image);
- return 'images/mokosuitecross/' . $filename;
+ $imageUrl = 'media/com_mokosuitecross/social/' . $filename;
+
+ return ['success' => true, 'image_url' => $imageUrl];
}
- /**
- * Remove all cached social images for an article.
- *
- * @param int $articleId The article ID.
- *
- * @return void
- */
- public static function clearCache(int $articleId): void
+ private static function renderTtfText(\GdImage $image, string $title, int $color, int $fontSize, string $fontFile): void
{
- $dir = JPATH_ROOT . '/images/mokosuitecross';
+ $maxWidth = self::WIDTH - 120;
+ $lines = self::wordWrapTtf($title, $fontFile, $fontSize, $maxWidth);
+ $lineHeight = (int) round($fontSize * 1.4);
+ $totalHeight = \count($lines) * $lineHeight;
- if (!is_dir($dir)) {
- return;
+ $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);
}
+ }
- foreach (self::PLATFORMS as $platform => $dims) {
- $file = $dir . '/' . $articleId . '_' . $platform . '.jpg';
+ 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);
- if (is_file($file)) {
- @unlink($file);
- }
+ foreach ($lineArray as $i => $line) {
+ $y = $startY + ($i * $lineHeight);
+ imagestring($image, $font, 60, $y, $line, $color);
}
}
/**
- * Return the list of supported platform keys.
+ * Word-wrap text for TTF rendering at a given pixel width.
*
* @return string[]
*/
- public static function getSupportedPlatforms(): array
+ private static function wordWrapTtf(string $text, string $fontFile, int $fontSize, int $maxWidth): array
{
- 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 = [];
+ $words = explode(' ', $text);
+ $lines = [];
$currentLine = '';
foreach ($words as $word) {
- $testLine = $currentLine === '' ? $word : $currentLine . ' ' . $word;
- $box = imagettfbbox($fontSize, 0, $fontPath, $testLine);
- $lineWidth = abs($box[2] - $box[0]);
+ $testLine = $currentLine === '' ? $word : $currentLine . ' ' . $word;
+ $box = imagettfbbox($fontSize, 0, $fontFile, $testLine);
+ $width = abs($box[2] - $box[0]);
- if ($lineWidth > $maxWidth && $currentLine !== '') {
+ if ($width > $maxWidth && $currentLine !== '') {
$lines[] = $currentLine;
$currentLine = $word;
} else {
@@ -418,43 +164,37 @@ class SocialImageHelper
}
/**
- * Load an image file (JPEG, PNG, WebP, GIF) and return a GdImage resource.
+ * Locate a usable TTF font file -- check common system locations.
*/
- private static function loadImage(string $path): ?\GdImage
+ private static function findFont(): ?string
{
- if (!is_file($path)) {
- return null;
+ $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;
+ }
}
- $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 null;
}
/**
- * 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.
+ * @return int[] [r, g, b]
*/
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];
}
@@ -464,4 +204,4 @@ class SocialImageHelper
(int) hexdec(substr($hex, 4, 2)),
];
}
-}
\ No newline at end of file
+}
diff --git a/source/packages/plg_content_mokosuitecross/src/Extension/MokoSuiteCrossContent.php b/source/packages/plg_content_mokosuitecross/src/Extension/MokoSuiteCrossContent.php
index 3b3c7e28..99fe1bb1 100644
--- a/source/packages/plg_content_mokosuitecross/src/Extension/MokoSuiteCrossContent.php
+++ b/source/packages/plg_content_mokosuitecross/src/Extension/MokoSuiteCrossContent.php
@@ -257,6 +257,53 @@ XML;
$form->load($aiXml);
$form->setFieldAttribute('mokosuitecross_ai_generate', 'description', $aiButtonHtml, 'attribs');
}
+ // Social Image Generator button (#157)
+ $siParams = ComponentHelper::getParams('com_mokosuitecross');
+ $siEnabled = (bool) $siParams->get('social_image_enabled', 0);
+
+ if ($siEnabled && $articleId > 0) {
+ $siToken = Session::getFormToken();
+ $siUrl = Uri::base() . 'index.php?option=com_mokosuitecross&task=socialimage.generate&format=raw&article_id=' . $articleId . '&' . $siToken . '=1';
+
+ $siButtonHtml = ''
+ . '
'
+ . ' '
+ . \Joomla\CMS\Language\Text::_('COM_MOKOSUITECROSS_SOCIAL_IMAGE_GENERATE')
+ . ' '
+ . '
'
+ . '
'
+ . '
'
+ . '
'
+ . '
'
+ . '';
+
+ $siXml = '
+';
+ $form->load($siXml);
+ $form->setFieldAttribute('mokosuitecross_si_generate', 'description', $siButtonHtml, 'attribs');
+ }
// Cross-post history panel for existing articles