437189830f
Universal: PR Check / Branch Policy (pull_request) Successful in 3s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 3s
Universal: PR Check / Secret Scan (pull_request) Successful in 9s
Universal: Auto Version Bump / Version Bump (push) Successful in 18s
Generic: Project CI / Lint & Validate (pull_request) Successful in 27s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 43s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
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
208 lines
7.0 KiB
PHP
208 lines
7.0 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @package MokoSuiteCross
|
|
* @subpackage com_mokosuitecross
|
|
* @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
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*/
|
|
|
|
namespace Joomla\Component\MokoSuiteCross\Administrator\Helper;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
class SocialImageHelper
|
|
{
|
|
private const WIDTH = 1200;
|
|
private const HEIGHT = 630;
|
|
|
|
/**
|
|
* 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]
|
|
*/
|
|
public static function generate(string $title, string $siteName, array $config): array
|
|
{
|
|
if (!\function_exists('imagecreatetruecolor')) {
|
|
return ['success' => false, 'error' => 'PHP GD extension is not available'];
|
|
}
|
|
|
|
$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'];
|
|
}
|
|
|
|
$bgRgb = self::hexToRgb($bgColor);
|
|
$textRgb = self::hexToRgb($textColor);
|
|
|
|
$bg = imagecolorallocate($image, $bgRgb[0], $bgRgb[1], $bgRgb[2]);
|
|
$text = imagecolorallocate($image, $textRgb[0], $textRgb[1], $textRgb[2]);
|
|
|
|
imagefilledrectangle($image, 0, 0, self::WIDTH - 1, self::HEIGHT - 1, $bg);
|
|
|
|
$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::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);
|
|
}
|
|
}
|
|
|
|
$outputDir = JPATH_ROOT . '/media/com_mokosuitecross/social';
|
|
|
|
if (!is_dir($outputDir)) {
|
|
mkdir($outputDir, 0755, true);
|
|
}
|
|
|
|
$hash = hash('sha256', $title . $bgColor . $textColor . $fontSize);
|
|
$filename = $hash . '.png';
|
|
$filePath = $outputDir . '/' . $filename;
|
|
|
|
if (!imagepng($image, $filePath, 6)) {
|
|
imagedestroy($image);
|
|
|
|
return ['success' => false, 'error' => 'Failed to save image file'];
|
|
}
|
|
|
|
imagedestroy($image);
|
|
|
|
$imageUrl = 'media/com_mokosuitecross/social/' . $filename;
|
|
|
|
return ['success' => true, 'image_url' => $imageUrl];
|
|
}
|
|
|
|
private static function renderTtfText(\GdImage $image, string $title, int $color, int $fontSize, string $fontFile): void
|
|
{
|
|
$maxWidth = self::WIDTH - 120;
|
|
$lines = self::wordWrapTtf($title, $fontFile, $fontSize, $maxWidth);
|
|
$lineHeight = (int) round($fontSize * 1.4);
|
|
$totalHeight = \count($lines) * $lineHeight;
|
|
|
|
$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);
|
|
}
|
|
}
|
|
|
|
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 ($lineArray as $i => $line) {
|
|
$y = $startY + ($i * $lineHeight);
|
|
imagestring($image, $font, 60, $y, $line, $color);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Word-wrap text for TTF rendering at a given pixel width.
|
|
*
|
|
* @return string[]
|
|
*/
|
|
private static function wordWrapTtf(string $text, string $fontFile, int $fontSize, int $maxWidth): array
|
|
{
|
|
$words = explode(' ', $text);
|
|
$lines = [];
|
|
$currentLine = '';
|
|
|
|
foreach ($words as $word) {
|
|
$testLine = $currentLine === '' ? $word : $currentLine . ' ' . $word;
|
|
$box = imagettfbbox($fontSize, 0, $fontFile, $testLine);
|
|
$width = abs($box[2] - $box[0]);
|
|
|
|
if ($width > $maxWidth && $currentLine !== '') {
|
|
$lines[] = $currentLine;
|
|
$currentLine = $word;
|
|
} else {
|
|
$currentLine = $testLine;
|
|
}
|
|
}
|
|
|
|
if ($currentLine !== '') {
|
|
$lines[] = $currentLine;
|
|
}
|
|
|
|
return $lines ?: [$text];
|
|
}
|
|
|
|
/**
|
|
* Locate a usable TTF font file -- check common system locations.
|
|
*/
|
|
private static function findFont(): ?string
|
|
{
|
|
$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;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return int[] [r, g, b]
|
|
*/
|
|
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)),
|
|
];
|
|
}
|
|
}
|