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 @@
+
+
+
+
+ COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_NONE
+ COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_LIGHT
+ COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_DARK
+
+
+
+
+ * @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)),
+ ];
+ }
+}