From 437189830fe84fa43dbef7772fe46d04c395143e Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 12:54:27 -0500 Subject: [PATCH] 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