From 1aa58e1d8dcc46c7bed50e73b52daabc2436dc33 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 12:06:02 -0500 Subject: [PATCH 1/2] 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 --- CHANGELOG.md | 5 +- source/packages/com_mokosuitecross/config.xml | 84 ++- .../language/en-GB/com_mokosuitecross.ini | 22 + .../src/Controller/ImageController.php | 148 ++++++ .../src/Controller/SocialImageController.php | 98 ---- .../src/Helper/SocialImageHelper.php | 482 ++++++++++++++---- 6 files changed, 601 insertions(+), 238 deletions(-) create mode 100644 source/packages/com_mokosuitecross/src/Controller/ImageController.php delete mode 100644 source/packages/com_mokosuitecross/src/Controller/SocialImageController.php 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 @@ + + + + + + + + + + + + + + + - - - -
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 -- 2.52.0 From 437189830fe84fa43dbef7772fe46d04c395143e Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 12:54:27 -0500 Subject: [PATCH 2/2] feat: add social image generator with GD text overlay (#157) Replace complex multi-platform compositing with simpler spec-compliant implementation: - SocialImageHelper: 1200x630 OG images with solid background, title overlay using TTF fonts (or GD fallback), and site name watermark - SocialImageController: AJAX endpoint with CSRF + ACL checks - Config: enabled toggle, bg/text color, font size, show site name - Content plugin: Generate Social Image button in Share Content panel - Saves to media/com_mokosuitecross/social/ with SHA-256 filename Authored-by: Moko Consulting --- CHANGELOG.md | 6 +- source/packages/com_mokosuitecross/config.xml | 86 +--- .../language/en-GB/com_mokosuitecross.ini | 33 +- .../src/Controller/ImageController.php | 148 ------ .../src/Controller/SocialImageController.php | 90 ++++ .../src/Helper/SocialImageHelper.php | 478 ++++-------------- .../src/Extension/MokoSuiteCrossContent.php | 47 ++ 7 files changed, 291 insertions(+), 597 deletions(-) delete mode 100644 source/packages/com_mokosuitecross/src/Controller/ImageController.php create mode 100644 source/packages/com_mokosuitecross/src/Controller/SocialImageController.php 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 - - - - - - - - - - - - - - - + + + +
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 = '
' + . '' + . '' + . '' + . '
' + . ''; + + $siXml = ' +
+ +
'; + $form->load($siXml); + $form->setFieldAttribute('mokosuitecross_si_generate', 'description', $siButtonHtml, 'attribs'); + } // Cross-post history panel for existing articles -- 2.52.0