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) <noreply@anthropic.com>
This commit is contained in:
@@ -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/."
|
||||
|
||||
@@ -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/."
|
||||
|
||||
@@ -107,6 +107,17 @@
|
||||
min="50"
|
||||
max="300"
|
||||
/>
|
||||
<field
|
||||
name="auto_resize"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
|
||||
@@ -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, '/');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
<?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\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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user