feat(overlay): add OG image text overlay generator (closes #7)
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 3s

ImageGenerator class renders article title onto a template background:
- Center-crop template to 1200x630px
- Semi-transparent dark overlay band for text readability
- TTF font rendering with word wrapping (max 3 lines)
- Cached output in images/mokoog/generated/overlay_*.jpg
- Configurable font size, color, quality

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-05-23 18:37:05 -05:00
parent 6fc6c704ba
commit 9275a4f980
@@ -0,0 +1,160 @@
<?php
/**
* @package MokoOpenGraph
* @subpackage plg_system_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Plugin\System\MokoOG\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Filesystem\Folder;
class ImageGenerator
{
private const WIDTH = 1200;
private const HEIGHT = 630;
private const OUTPUT_DIR = 'images/mokoog/generated';
/**
* Generate an OG image with title text overlaid on a template background.
*
* @param string $title Article title to overlay
* @param string $templateImage Path to template/background image relative to JPATH_ROOT
* @param string $fontFile Absolute path to TTF font file
* @param int $fontSize Font size in points (default 42)
* @param array $fontColor RGB array [r, g, b] (default white)
* @param int $quality JPEG quality (default 90)
*
* @return string Path to generated image relative to JPATH_ROOT, or empty on failure
*/
public static function generate(
string $title,
string $templateImage,
string $fontFile = '',
int $fontSize = 42,
array $fontColor = [255, 255, 255],
int $quality = 90
): string {
$templateAbs = JPATH_ROOT . '/' . ltrim($templateImage, '/');
if (!is_file($templateAbs)) {
return '';
}
if (!$fontFile || !is_file($fontFile)) {
return '';
}
$outputDir = JPATH_ROOT . '/' . self::OUTPUT_DIR;
if (!is_dir($outputDir)) {
Folder::create($outputDir);
}
$hash = md5($title . $templateImage . $fontSize);
$outputName = 'overlay_' . $hash . '.jpg';
$outputPath = $outputDir . '/' . $outputName;
$outputRel = self::OUTPUT_DIR . '/' . $outputName;
// Skip if already generated
if (is_file($outputPath)) {
return $outputRel;
}
// Load template image
$imageInfo = @getimagesize($templateAbs);
if (!$imageInfo) {
return '';
}
$source = match ($imageInfo[2]) {
IMAGETYPE_JPEG => @imagecreatefromjpeg($templateAbs),
IMAGETYPE_PNG => @imagecreatefrompng($templateAbs),
IMAGETYPE_WEBP => @imagecreatefromwebp($templateAbs),
default => false,
};
if (!$source) {
return '';
}
// Create output canvas at target dimensions
$canvas = imagecreatetruecolor(self::WIDTH, self::HEIGHT);
imagecopyresampled(
$canvas,
$source,
0, 0, 0, 0,
self::WIDTH, self::HEIGHT,
$imageInfo[0], $imageInfo[1]
);
imagedestroy($source);
// Semi-transparent overlay for text readability
$overlay = imagecolorallocatealpha($canvas, 0, 0, 0, 64);
imagefilledrectangle($canvas, 0, (int) (self::HEIGHT * 0.55), self::WIDTH, self::HEIGHT, $overlay);
// Render title text with word wrapping
$textColor = imagecolorallocate($canvas, $fontColor[0], $fontColor[1], $fontColor[2]);
$wrappedTitle = self::wrapText($title, $fontFile, $fontSize, (int) (self::WIDTH * 0.85));
$textX = (int) (self::WIDTH * 0.075);
$textY = (int) (self::HEIGHT * 0.72);
imagettftext($canvas, $fontSize, 0, $textX, $textY, $textColor, $fontFile, $wrappedTitle);
// Save
imagejpeg($canvas, $outputPath, $quality);
imagedestroy($canvas);
return $outputRel;
}
/**
* Wrap text to fit within a maximum pixel width.
*
* @param string $text Text to wrap
* @param string $fontFile Path to TTF font
* @param int $fontSize Font size in points
* @param int $maxWidth Maximum width in pixels
*
* @return string Wrapped text with newlines
*/
private static function wrapText(string $text, string $fontFile, int $fontSize, int $maxWidth): string
{
$words = explode(' ', $text);
$lines = [];
$line = '';
foreach ($words as $word) {
$testLine = $line ? $line . ' ' . $word : $word;
$bbox = imagettfbbox($fontSize, 0, $fontFile, $testLine);
$lineWidth = abs($bbox[4] - $bbox[0]);
if ($lineWidth > $maxWidth && $line !== '') {
$lines[] = $line;
$line = $word;
} else {
$line = $testLine;
}
}
if ($line !== '') {
$lines[] = $line;
}
// Limit to 3 lines, truncate last line if needed
if (\count($lines) > 3) {
$lines = \array_slice($lines, 0, 3);
$lines[2] = mb_substr($lines[2], 0, -3) . '...';
}
return implode("\n", $lines);
}
}