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