From d951d86b3aa5e433399dd400ba8d3f1db99eecd2 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 11:43:09 -0500 Subject: [PATCH] feat: add social image generator with PHP GD for OG images (#157) - SocialImageHelper: generates 1200x630 OG images with title overlay - SocialImageController: AJAX endpoint to generate from article data - Config fields: bg color, text color, overlay style, site name override - Supports background image scaling, dark/light overlay, TTF fonts Closes #157 Authored-by: Moko Consulting --- CHANGELOG.md | 2 + source/packages/com_mokosuitecross/config.xml | 34 +++ .../language/en-GB/com_mokosuitecross.ini | 14 ++ .../src/Controller/SocialImageController.php | 98 ++++++++ .../src/Helper/SocialImageHelper.php | 230 ++++++++++++++++++ 5 files changed, 378 insertions(+) create mode 100644 source/packages/com_mokosuitecross/src/Controller/SocialImageController.php create mode 100644 source/packages/com_mokosuitecross/src/Helper/SocialImageHelper.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c2e982e7..29119572 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] ### Added +- **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) - **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 2df31bfe..75415a0d 100644 --- a/source/packages/com_mokosuitecross/config.xml +++ b/source/packages/com_mokosuitecross/config.xml @@ -266,6 +266,40 @@ +
+ + + + + + + + +
+
+ * @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 new file mode 100644 index 00000000..0f7e4831 --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Helper/SocialImageHelper.php @@ -0,0 +1,230 @@ + + * @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\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; + +class SocialImageHelper +{ + private const WIDTH = 1200; + private const HEIGHT = 630; + private const PADDING = 60; + private const MAX_TITLE_CHARS_PER_LINE = 30; + + public static function generate(string $title, string $siteName, ?string $backgroundPath = null, array $options = []): string + { + if (!\function_exists('imagecreatetruecolor')) { + throw new \RuntimeException('PHP GD extension is required for social image generation.'); + } + + $bgColor = $options['bg_color'] ?? '#1a1a2e'; + $textColor = $options['text_color'] ?? '#ffffff'; + $overlayType = $options['overlay'] ?? 'dark'; + $fontSize = (int) ($options['font_size'] ?? 36); + + $image = imagecreatetruecolor(self::WIDTH, self::HEIGHT); + + if ($image === false) { + throw new \RuntimeException('Failed to create image canvas.'); + } + + $bgRgb = self::hexToRgb($bgColor); + $bg = imagecolorallocate($image, $bgRgb[0], $bgRgb[1], $bgRgb[2]); + imagefill($image, 0, 0, $bg); + + if ($backgroundPath !== null && is_file($backgroundPath)) { + self::drawBackground($image, $backgroundPath); + } + + if ($overlayType !== 'none') { + self::drawOverlay($image, $overlayType); + } + + $textRgb = self::hexToRgb($textColor); + $textCol = imagecolorallocate($image, $textRgb[0], $textRgb[1], $textRgb[2]); + + $fontPath = self::findFont(); + + if ($fontPath !== null) { + self::drawTextTtf($image, $title, $siteName, $fontPath, $fontSize, $textCol); + } else { + self::drawTextFallback($image, $title, $siteName, $textCol); + } + + $tmpDir = Factory::getApplication()->get('tmp_path', JPATH_ROOT . '/tmp'); + $outPath = $tmpDir . '/social_' . md5($title . $siteName . microtime()) . '.png'; + + imagepng($image, $outPath, 6); + imagedestroy($image); + + return $outPath; + } + + private static function drawBackground(\GdImage $image, string $path): void + { + $info = @getimagesize($path); + + if ($info === false) { + return; + } + + $source = match ($info[2]) { + IMAGETYPE_JPEG => @imagecreatefromjpeg($path), + IMAGETYPE_PNG => @imagecreatefrompng($path), + IMAGETYPE_WEBP => \function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : false, + default => false, + }; + + if ($source === false) { + return; + } + + $srcW = imagesx($source); + $srcH = imagesy($source); + + $scale = max(self::WIDTH / $srcW, self::HEIGHT / $srcH); + $newW = (int) ($srcW * $scale); + $newH = (int) ($srcH * $scale); + $dstX = (int) ((self::WIDTH - $newW) / 2); + $dstY = (int) ((self::HEIGHT - $newH) / 2); + + imagecopyresampled($image, $source, $dstX, $dstY, 0, 0, $newW, $newH, $srcW, $srcH); + imagedestroy($source); + } + + private static function drawOverlay(\GdImage $image, string $type): void + { + $opacity = ($type === 'light') ? 80 : 160; + $color = imagecolorallocatealpha($image, 0, 0, 0, 127 - (int) ($opacity * 127 / 255)); + + if ($color === false) { + return; + } + + imagesavealpha($image, true); + imagefilledrectangle($image, 0, 0, self::WIDTH - 1, self::HEIGHT - 1, $color); + } + + private static function drawTextTtf(\GdImage $image, string $title, string $siteName, string $fontPath, int $fontSize, int $color): void + { + $lines = self::wordWrap($title, $fontPath, $fontSize, self::WIDTH - self::PADDING * 2); + $lineH = (int) ($fontSize * 1.4); + $totalH = \count($lines) * $lineH; + $startY = (int) ((self::HEIGHT - $totalH) / 2) + $fontSize; + + foreach ($lines as $i => $line) { + $box = imagettfbbox($fontSize, 0, $fontPath, $line); + $lineW = abs($box[2] - $box[0]); + $x = (int) ((self::WIDTH - $lineW) / 2); + imagettftext($image, $fontSize, 0, $x, $startY + $i * $lineH, $color, $fontPath, $line); + } + + $siteSize = (int) ($fontSize * 0.5); + $box = imagettfbbox($siteSize, 0, $fontPath, $siteName); + $siteW = abs($box[2] - $box[0]); + $siteX = (int) ((self::WIDTH - $siteW) / 2); + $siteY = self::HEIGHT - self::PADDING; + + $siteCol = imagecolorallocatealpha($image, 255, 255, 255, 40); + + if ($siteCol !== false) { + imagettftext($image, $siteSize, 0, $siteX, $siteY, $siteCol, $fontPath, $siteName); + } + } + + private static function drawTextFallback(\GdImage $image, string $title, string $siteName, int $color): void + { + $maxChars = (int) (self::WIDTH / 10); + $wrapped = wordwrap($title, $maxChars, "\n", true); + $lines = explode("\n", $wrapped); + $lineH = 20; + $totalH = \count($lines) * $lineH; + $startY = (int) ((self::HEIGHT - $totalH) / 2); + + foreach ($lines as $i => $line) { + $lineW = \strlen($line) * 9; + $x = max(self::PADDING, (int) ((self::WIDTH - $lineW) / 2)); + imagestring($image, 5, $x, $startY + $i * $lineH, $line, $color); + } + + $siteW = \strlen($siteName) * 7; + $siteX = max(self::PADDING, (int) ((self::WIDTH - $siteW) / 2)); + $siteY = self::HEIGHT - 40; + + $gray = imagecolorallocate($image, 180, 180, 180); + + if ($gray !== false) { + imagestring($image, 3, $siteX, $siteY, $siteName, $gray); + } + } + + private static function wordWrap(string $text, string $fontPath, int $fontSize, int $maxWidth): array + { + $words = explode(' ', $text); + $lines = []; + $line = ''; + + foreach ($words as $word) { + $test = ($line === '') ? $word : $line . ' ' . $word; + $box = imagettfbbox($fontSize, 0, $fontPath, $test); + $testW = abs($box[2] - $box[0]); + + if ($testW > $maxWidth && $line !== '') { + $lines[] = $line; + $line = $word; + } else { + $line = $test; + } + } + + if ($line !== '') { + $lines[] = $line; + } + + return $lines ?: [$text]; + } + + private static function findFont(): ?string + { + $candidates = [ + JPATH_ROOT . '/media/com_mokosuitecross/fonts/OpenSans-Bold.ttf', + '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', + '/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf', + 'C:/Windows/Fonts/arialbd.ttf', + ]; + + foreach ($candidates as $path) { + if (is_file($path)) { + return $path; + } + } + + return null; + } + + private static function hexToRgb(string $hex): array + { + $hex = ltrim($hex, '#'); + + if (\strlen($hex) === 3) { + $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; + } + + return [ + (int) hexdec(substr($hex, 0, 2)), + (int) hexdec(substr($hex, 2, 2)), + (int) hexdec(substr($hex, 4, 2)), + ]; + } +}