From 5fc0fbfc07f67cde62aae3ac7082e7f336625694 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 23 May 2026 17:57:07 -0500 Subject: [PATCH] feat(images): auto-resize OG images to 1200x630px (closes #2) - New ImageHelper class with resize(), validate(), cleanup() methods - Center-crop algorithm maintains aspect ratio to target dimensions - GD-based processing, supports JPEG/PNG/GIF/WebP input, outputs JPEG - Generated images cached in images/mokoog/generated/ with hash naming - Skips resize if image already at or below target dimensions - Skips regeneration if cached version is newer than source - validate() checks minimum 200x200px (Facebook/WhatsApp requirement) - cleanup() removes generated images when OG records are deleted - Auto-resize toggle in system plugin advanced settings (default: on) - Integrated into resolveImageUrl() in the system plugin Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- .../language/en-GB/plg_system_mokoog.ini | 2 + .../language/en-US/plg_system_mokoog.ini | 2 + src/packages/plg_system_mokoog/mokoog.xml | 11 + .../src/Extension/MokoOG.php | 8 +- .../src/Helper/ImageHelper.php | 222 ++++++++++++++++++ 5 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 src/packages/plg_system_mokoog/src/Helper/ImageHelper.php diff --git a/src/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini b/src/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini index 4983711..a0902bd 100644 --- a/src/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini +++ b/src/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini @@ -23,3 +23,5 @@ PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML="Strip HTML from Description" PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML_DESC="Remove HTML tags from the auto-generated description." PLG_SYSTEM_MOKOOG_FIELD_DESC_LENGTH="Description Length" PLG_SYSTEM_MOKOOG_FIELD_DESC_LENGTH_DESC="Maximum character length for the auto-generated og:description." +PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE="Auto-resize Images" +PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE_DESC="Automatically resize images to 1200x630px (Facebook recommended) using center crop. Generated images are saved to images/mokoog/generated/." diff --git a/src/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini b/src/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini index 4983711..a0902bd 100644 --- a/src/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini +++ b/src/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini @@ -23,3 +23,5 @@ PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML="Strip HTML from Description" PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML_DESC="Remove HTML tags from the auto-generated description." PLG_SYSTEM_MOKOOG_FIELD_DESC_LENGTH="Description Length" PLG_SYSTEM_MOKOOG_FIELD_DESC_LENGTH_DESC="Maximum character length for the auto-generated og:description." +PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE="Auto-resize Images" +PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE_DESC="Automatically resize images to 1200x630px (Facebook recommended) using center crop. Generated images are saved to images/mokoog/generated/." diff --git a/src/packages/plg_system_mokoog/mokoog.xml b/src/packages/plg_system_mokoog/mokoog.xml index 50a2120..2517076 100644 --- a/src/packages/plg_system_mokoog/mokoog.xml +++ b/src/packages/plg_system_mokoog/mokoog.xml @@ -107,6 +107,17 @@ min="50" max="300" /> + + + + diff --git a/src/packages/plg_system_mokoog/src/Extension/MokoOG.php b/src/packages/plg_system_mokoog/src/Extension/MokoOG.php index c245c42..e571cc4 100644 --- a/src/packages/plg_system_mokoog/src/Extension/MokoOG.php +++ b/src/packages/plg_system_mokoog/src/Extension/MokoOG.php @@ -17,6 +17,7 @@ use Joomla\CMS\Plugin\CMSPlugin; use Joomla\CMS\Uri\Uri; use Joomla\Event\Event; use Joomla\Event\SubscriberInterface; +use Joomla\Plugin\System\MokoOG\Helper\ImageHelper; final class MokoOG extends CMSPlugin implements SubscriberInterface { @@ -288,7 +289,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface } /** - * Resolve a relative image path to a full URL. + * Resolve a relative image path to a full URL, resizing for OG if needed. * * @param string $image Image path * @@ -300,6 +301,11 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface return $image; } + // Auto-resize to OG recommended dimensions if enabled + if ($this->params->get('auto_resize', 1)) { + $image = ImageHelper::resize($image); + } + return rtrim(Uri::root(), '/') . '/' . ltrim($image, '/'); } } diff --git a/src/packages/plg_system_mokoog/src/Helper/ImageHelper.php b/src/packages/plg_system_mokoog/src/Helper/ImageHelper.php new file mode 100644 index 0000000..8364f18 --- /dev/null +++ b/src/packages/plg_system_mokoog/src/Helper/ImageHelper.php @@ -0,0 +1,222 @@ + + * @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\File; +use Joomla\CMS\Filesystem\Folder; + +class ImageHelper +{ + /** + * Target width for OG images (Facebook recommended). + */ + private const TARGET_WIDTH = 1200; + + /** + * Target height for OG images (Facebook recommended). + */ + private const TARGET_HEIGHT = 630; + + /** + * JPEG quality for generated images. + */ + private const JPEG_QUALITY = 85; + + /** + * Output directory relative to JPATH_ROOT. + */ + private const OUTPUT_DIR = 'images/mokoog/generated'; + + /** + * Resize an image to OG-optimized dimensions if needed. + * + * Returns the path to the resized image relative to JPATH_ROOT, + * or the original path if no resize was needed or possible. + * + * @param string $imagePath Image path relative to JPATH_ROOT + * @param int $targetWidth Target width (default 1200) + * @param int $targetHeight Target height (default 630) + * @param int $quality JPEG quality 1-100 (default 85) + * + * @return string Path to the output image (relative to JPATH_ROOT) + */ + public static function resize( + string $imagePath, + int $targetWidth = self::TARGET_WIDTH, + int $targetHeight = self::TARGET_HEIGHT, + int $quality = self::JPEG_QUALITY + ): string { + // Resolve absolute path + $absPath = JPATH_ROOT . '/' . ltrim($imagePath, '/'); + + if (!is_file($absPath)) { + return $imagePath; + } + + $imageInfo = @getimagesize($absPath); + + if (!$imageInfo) { + return $imagePath; + } + + [$origWidth, $origHeight, $type] = $imageInfo; + + // Skip if already at or below target size + if ($origWidth <= $targetWidth && $origHeight <= $targetHeight) { + return $imagePath; + } + + // Ensure output directory exists + $outputDir = JPATH_ROOT . '/' . self::OUTPUT_DIR; + + if (!is_dir($outputDir)) { + Folder::create($outputDir); + } + + // Generate output filename based on source hash + dimensions + $hash = md5($imagePath . $targetWidth . $targetHeight); + $outputName = $hash . '.jpg'; + $outputPath = $outputDir . '/' . $outputName; + $outputRel = self::OUTPUT_DIR . '/' . $outputName; + + // Skip if already generated + if (is_file($outputPath) && filemtime($outputPath) >= filemtime($absPath)) { + return $outputRel; + } + + // Load source image + $source = self::loadImage($absPath, $type); + + if (!$source) { + return $imagePath; + } + + // Calculate crop dimensions (center crop to target aspect ratio) + $targetRatio = $targetWidth / $targetHeight; + $sourceRatio = $origWidth / $origHeight; + + if ($sourceRatio > $targetRatio) { + // Source is wider — crop sides + $cropHeight = $origHeight; + $cropWidth = (int) round($origHeight * $targetRatio); + $cropX = (int) round(($origWidth - $cropWidth) / 2); + $cropY = 0; + } else { + // Source is taller — crop top/bottom + $cropWidth = $origWidth; + $cropHeight = (int) round($origWidth / $targetRatio); + $cropX = 0; + $cropY = (int) round(($origHeight - $cropHeight) / 2); + } + + // Create output canvas and resample + $output = imagecreatetruecolor($targetWidth, $targetHeight); + + imagecopyresampled( + $output, + $source, + 0, + 0, + $cropX, + $cropY, + $targetWidth, + $targetHeight, + $cropWidth, + $cropHeight + ); + + // Save as JPEG + imagejpeg($output, $outputPath, $quality); + + imagedestroy($source); + imagedestroy($output); + + return $outputRel; + } + + /** + * Remove a generated image file. + * + * @param string $generatedPath Path relative to JPATH_ROOT + * + * @return void + */ + public static function cleanup(string $generatedPath): void + { + if (empty($generatedPath) || !str_starts_with($generatedPath, self::OUTPUT_DIR)) { + return; + } + + $absPath = JPATH_ROOT . '/' . $generatedPath; + + if (is_file($absPath)) { + File::delete($absPath); + } + } + + /** + * Check if an image meets minimum OG size requirements. + * + * @param string $imagePath Image path relative to JPATH_ROOT + * + * @return array{valid: bool, width: int, height: int, message: string} + */ + public static function validate(string $imagePath): array + { + $absPath = JPATH_ROOT . '/' . ltrim($imagePath, '/'); + + if (!is_file($absPath)) { + return ['valid' => false, 'width' => 0, 'height' => 0, 'message' => 'File not found']; + } + + $imageInfo = @getimagesize($absPath); + + if (!$imageInfo) { + return ['valid' => false, 'width' => 0, 'height' => 0, 'message' => 'Not a valid image']; + } + + [$width, $height] = $imageInfo; + + // Facebook minimum: 200x200, recommended: 1200x630 + // WhatsApp minimum: 300x200 + if ($width < 200 || $height < 200) { + return [ + 'valid' => false, + 'width' => $width, + 'height' => $height, + 'message' => "Image too small ({$width}x{$height}). Minimum: 200x200px.", + ]; + } + + return ['valid' => true, 'width' => $width, 'height' => $height, 'message' => 'OK']; + } + + /** + * Load an image resource from a file. + * + * @param string $path Absolute file path + * @param int $type IMAGETYPE_* constant + * + * @return \GdImage|false + */ + private static function loadImage(string $path, int $type) + { + return match ($type) { + IMAGETYPE_JPEG => @imagecreatefromjpeg($path), + IMAGETYPE_PNG => @imagecreatefrompng($path), + IMAGETYPE_GIF => @imagecreatefromgif($path), + IMAGETYPE_WEBP => @imagecreatefromwebp($path), + default => false, + }; + } +}