Skip to content
Open
249 changes: 232 additions & 17 deletions modules/system/classes/ImageResizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,63 @@ public function getConfig(): array
return $config;
}

/**
* Read the source image dimensions from the given disk and path.
*
* Returns ['width' => 0, 'height' => 0] when the file is missing,
* unreadable, or the dimensions cannot be determined.
*
* For local disks the file is read directly to avoid unnecessary I/O.
* Remote disks (S3, FTP, etc.) are downloaded to a temporary file first
* because getimagesize() requires a local path.
*
* @param FilesystemAdapter|string $disk
* @param string $path Path to the image on the disk
* @return array
*/
protected static function readSourceDimensions(FilesystemAdapter|string $disk, string $path): array
{
if (is_string($disk)) {
$disk = Storage::disk($disk);
}

$origWidth = 0;
$origHeight = 0;

try {
if (!$disk->exists($path)) {
return ['width' => 0, 'height' => 0];
}

if (FileHelper::isLocalDisk($disk)) {
$localPath = $disk->getPathPrefix() . $path;
$size = @getimagesize($localPath);
if ($size !== false) {
return ['width' => $size[0], 'height' => $size[1]];
}
}

$tempDir = temp_path() . '/resizer';
$tempPath = $tempDir . '/' . uniqid() . '.' . FileHelper::extension($path);

if (!FileHelper::isDirectory($tempDir)) {
FileHelper::makeDirectory($tempDir, 0777, true, true);
}

FileHelper::put($tempPath, $disk->get($path));
$size = @getimagesize($tempPath);
if ($size !== false) {
$origWidth = $size[0];
$origHeight = $size[1];
}
@unlink($tempPath);
Comment thread
matteotrubini marked this conversation as resolved.
Outdated
} catch (\Exception $ex) {
// Ignore failures to read source dimensions
}

return ['width' => $origWidth, 'height' => $origHeight];
}

/**
* Process the resize request
*/
Expand Down Expand Up @@ -912,27 +969,185 @@ public static function filterGetUrl($image, $width = null, $height = null, $opti
*/
public static function filterGetDimensions($image): array
{
$resizer = new static($image);
try {
$resizer = new static($image);
} catch (\SystemException $ex) {
if (is_string($image) && str_starts_with($image, '/resizer/')) {
return static::getDimensionsFromResizerUrl($image);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return ['width' => 0, 'height' => 0];
}

return Cache::rememberForever(static::CACHE_PREFIX . 'dimensions.' . $resizer->getIdentifier(), function () use ($resizer) {
// Prepare the local file for assessment
$tempPath = $resizer->getLocalTempPath();
$dimensions = [];
$identifier = $resizer->getIdentifier();
$configCacheKey = static::CACHE_PREFIX . $identifier;

// Attempt to get the image size
try {
$size = getimagesize($tempPath);
$dimensions['width'] = $size[0];
$dimensions['height'] = $size[1];
} catch (\Exception $ex) {
@unlink($tempPath);
throw $ex;
}
if (!Cache::has($configCacheKey)) {
Cache::put($configCacheKey, $resizer->getConfig());
}

// Cleanup afterwards
@unlink($tempPath);
return static::computeCachedDimensions($identifier);
}

return $dimensions;
/**
* Extract dimensions from a /resizer/* URL by reading the cached
* resizer configuration and, if possible, the source file.
*
* @param string $url The /resizer/* URL
* @return array
*/
protected static function getDimensionsFromResizerUrl(string $url): array
{
$path = parse_url($url, PHP_URL_PATH);
$segments = explode('/', ltrim($path, '/'));

if (count($segments) < 3 || $segments[0] !== 'resizer') {
return ['width' => 0, 'height' => 0];
}

$identifier = $segments[1];

if (!static::isValidIdentifier($identifier)) {
return ['width' => 0, 'height' => 0];
}

return static::computeCachedDimensions($identifier);
}

/**
* Compute and cache the output dimensions for a resizer configuration
* identified by its cache key suffix.
*
* @param string $identifier The resizer identifier
* @return array
*/
protected static function computeCachedDimensions(string $identifier): array
{
$cacheKey = static::CACHE_PREFIX . $identifier . '.dimensions';

return Cache::rememberForever($cacheKey, function () use ($identifier) {
$config = Cache::get(static::CACHE_PREFIX . $identifier);

if (empty($config) || !isset($config['width'], $config['height'], $config['options']['mode'])) {
return ['width' => 0, 'height' => 0];
}

$sourceDimensions = static::readSourceDimensions(
$config['image']['disk'],
$config['image']['path']
);
$origWidth = $sourceDimensions['width'];
$origHeight = $sourceDimensions['height'];

return static::calculateResizedDimensions(
$origWidth,
$origHeight,
$config['width'],
$config['height'],
$config['options']['mode']
);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

/**
* Calculate the expected output dimensions for a resize operation.
*
* This method intentionally duplicates the aspect-ratio math from
* \Winter\Storm\Database\Attach\Resizer::getDimensions() rather than
* delegating to it. Reasons:
*
* - getDimensions() is protected in Storm; making it public would be a
* BC surface expansion that Storm maintainers may not accept.
* - Calling Resizer::open() allocates GD resources solely to read
* dimensions, which is wasteful when getimagesize() is sufficient.
* - The formulas are stable arithmetic that has not changed in years.
*
* If Storm's math ever diverges, a single integration test comparing
* both implementations will catch the drift.
*
* @param int $origWidth Original image width (0 if unknown)
* @param int $origHeight Original image height (0 if unknown)
* @param int $reqWidth Requested output width
* @param int $reqHeight Requested output height
* @param string $mode Resize mode: exact, portrait, landscape, auto, fit, crop
* @return array
*/
protected static function calculateResizedDimensions(
int $origWidth,
int $origHeight,
int $reqWidth,
int $reqHeight,
string $mode
): array {
if ($origWidth <= 0 || $origHeight <= 0) {
return ['width' => $reqWidth, 'height' => $reqHeight];
}

switch ($mode) {
case 'exact':
return ['width' => $reqWidth, 'height' => $reqHeight];
Comment on lines +1134 to +1136

case 'crop':
$heightRatio = $origHeight / $reqHeight;
$widthRatio = $origWidth / $reqWidth;
$optimalRatio = $heightRatio < $widthRatio ? $heightRatio : $widthRatio;

return [
'width' => (int) round($origWidth / $optimalRatio),
'height' => (int) round($origHeight / $optimalRatio),
];

case 'fit':
$ratioW = $reqWidth / $origWidth;
$ratioH = $reqHeight / $origHeight;
$effectiveRatio = min($ratioW, $ratioH);
return [
'width' => (int) round($origWidth * $effectiveRatio),
'height' => (int) round($origHeight * $effectiveRatio),
];

case 'portrait':
$ratio = $origWidth / $origHeight;
return [
'width' => (int) round($reqHeight * $ratio),
'height' => $reqHeight,
];

case 'landscape':
$ratio = $origHeight / $origWidth;
return [
'width' => $reqWidth,
'height' => (int) round($reqWidth * $ratio),
];

case 'auto':
default:
if ($reqWidth > 0 && $reqHeight > 0) {
if ($origHeight < $origWidth) {
$optimalHeight = (int) round($origHeight * ($reqWidth / $origWidth));
return ['width' => $reqWidth, 'height' => $optimalHeight];
} elseif ($origHeight > $origWidth) {
$optimalWidth = (int) round($origWidth * ($reqHeight / $origHeight));
return ['width' => $optimalWidth, 'height' => $reqHeight];
} else {
if ($reqHeight < $reqWidth) {
$optimalHeight = (int) round($origHeight * ($reqWidth / $origWidth));
return ['width' => $reqWidth, 'height' => $optimalHeight];
} elseif ($reqHeight > $reqWidth) {
$optimalWidth = (int) round($origWidth * ($reqHeight / $origHeight));
return ['width' => $optimalWidth, 'height' => $reqHeight];
} else {
return ['width' => $reqWidth, 'height' => $reqHeight];
}
}
} elseif ($reqWidth > 0) {
$optimalHeight = (int) round($origHeight * ($reqWidth / $origWidth));
return ['width' => $reqWidth, 'height' => $optimalHeight];
} elseif ($reqHeight > 0) {
$optimalWidth = (int) round($origWidth * ($reqHeight / $origHeight));
return ['width' => $optimalWidth, 'height' => $reqHeight];
} else {
return ['width' => $origWidth, 'height' => $origHeight];
}
}
}
}
90 changes: 90 additions & 0 deletions modules/system/tests/classes/ImageResizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Backend\Facades\Backend;
use Cms\Classes\Controller as CmsController;
use Cms\Classes\Theme;
use Cache;
use Config;
use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;
use Event;
Expand Down Expand Up @@ -41,6 +42,8 @@ public function tearDown(): void
Config::set('cms.themesPath', $this->originalThemesPath);

ImageResizer::flushAvailableSources();
Cache::flush();

parent::tearDown();
}

Expand Down Expand Up @@ -436,6 +439,93 @@ public function testResizerRedirect()
Storage::disk('test_local')->deleteDirectory('resized');
}

public function testCalculateResizedDimensionsMatchesDefaultResizer()
{
if (!in_array('Cms', Config::get('cms.loadModules', []))) {
$this->markTestSkipped('The CMS module is not active.');
}

$imagePath = base_path('modules/system/tests/fixtures/plugins/database/tester/assets/images/avatar.png');

$resizer = new \Winter\Storm\Database\Attach\Resizer($imagePath);
$modes = ['exact', 'portrait', 'landscape', 'auto', 'fit', 'crop'];
$reqWidth = 200;
$reqHeight = 150;

$stormGetDimensions = new \ReflectionMethod($resizer, 'getDimensions');
$stormGetDimensions->setAccessible(true);
$stormWidth = new \ReflectionProperty($resizer, 'width');
$stormWidth->setAccessible(true);
$stormHeight = new \ReflectionProperty($resizer, 'height');
$stormHeight->setAccessible(true);
$winterMethod = new \ReflectionMethod(ImageResizer::class, 'calculateResizedDimensions');
$winterMethod->setAccessible(true);

foreach ($modes as $mode) {
$resizer->setOptions(['mode' => $mode]);
$expected = $stormGetDimensions->invoke($resizer, $reqWidth, $reqHeight);
$expected = ['width' => (int) $expected[0], 'height' => (int) $expected[1]];

Comment on lines +464 to +472
$calculated = $winterMethod->invoke(
null,
$stormWidth->getValue($resizer),
$stormHeight->getValue($resizer),
$reqWidth,
$reqHeight,
$mode
);

$this->assertSame($expected, $calculated, "Mode $mode output should match DefaultResizer");
}
}

public function testFilterGetDimensionsReturnsFallbackForMissingImage()
{
$this->assertSame(['width' => 0, 'height' => 0], ImageResizer::filterGetDimensions(
'/plugins/database/tester/assets/images/MISSING.png'
));
}

public function testFilterGetDimensionsReturnsOriginalWhenNoResizeRequested()
{
if (!in_array('Cms', Config::get('cms.loadModules', []))) {
$this->markTestSkipped('The CMS module is not active.');
}

$this->setUpStorage();
$this->copyMedia();

$url = URL::to(MediaLibrary::url('winter.png'));
$dimensions = ImageResizer::filterGetDimensions($url);

$this->assertSame(310, $dimensions['width']);
$this->assertSame(310, $dimensions['height']);
}

public function testFilterGetDimensionsFromResizerUrl()
{
if (!in_array('Cms', Config::get('cms.loadModules', []))) {
$this->markTestSkipped('The CMS module is not active.');
}

$this->setUpStorage();
$this->copyMedia();

$imageResizer = new ImageResizer(
URL::to(MediaLibrary::url('winter.png')),
100,
100
);
$resizerUrl = $imageResizer->getResizerUrl();

$this->assertStringStartsWith('/resizer/', $resizerUrl);

$dimensions = ImageResizer::filterGetDimensions($resizerUrl);

$this->assertSame(100, $dimensions['width']);
$this->assertSame(100, $dimensions['height']);
}

protected function setUpStorage()
{
$this->app->useStoragePath(base_path('storage/temp'));
Expand Down
Loading