* @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; use Joomla\CMS\Log\Log; 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) { Log::add('MokoOG ImageHelper: Cannot read image dimensions: ' . basename($absPath), Log::WARNING, 'mokoog'); 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)) { Log::add('MokoOG ImageHelper: Cannot create output directory: ' . self::OUTPUT_DIR, Log::WARNING, 'mokoog'); return $imagePath; } // 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) { $image = match ($type) { IMAGETYPE_JPEG => imagecreatefromjpeg($path), IMAGETYPE_PNG => imagecreatefrompng($path), IMAGETYPE_GIF => imagecreatefromgif($path), IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? imagecreatefromwebp($path) : false, default => false, }; if (!$image) { Log::add('MokoOG ImageHelper: Failed to load image: ' . basename($path), Log::WARNING, 'mokoog'); } return $image; } }