diff --git a/apps/qubit/modules/repository/actions/editThemeAction.class.php b/apps/qubit/modules/repository/actions/editThemeAction.class.php
index dbff364f96..6c4b9e038f 100644
--- a/apps/qubit/modules/repository/actions/editThemeAction.class.php
+++ b/apps/qubit/modules/repository/actions/editThemeAction.class.php
@@ -118,7 +118,7 @@ protected function addField($name)
sfContext::getInstance()->getConfiguration()->loadHelpers('Url');
$this->form->setValidator($name, new sfValidatorFile([
- 'max_size' => '262144', // 256K
+ 'max_size' => '512000', // 500K
'mime_types' => ['image/png'],
// Crop image, it is synchronous but it should be fast
'validated_file_class' => 'arRepositoryThemeCropValidatedFile',
@@ -129,7 +129,7 @@ protected function addField($name)
$this->form->setWidget($name, new arB5WidgetFormInputFileEditable([
'label' => $this->context->i18n->__('Banner'),
'help' => $this->context->i18n->__(
- 'Requirements: PNG format, 256K max. size.
Recommended dimensions of %1%x%2%px, it will be cropped if ImageMagick is installed.',
+ 'Requirements: PNG format, 500K max. size.
Recommended dimensions of %1%x%2%px, it will be cropped if ImageMagick is installed.',
[
'%1%' => arRepositoryThemeCropValidatedFile::BANNER_MAX_WIDTH,
'%2%' => arRepositoryThemeCropValidatedFile::BANNER_MAX_HEIGHT,
@@ -153,7 +153,7 @@ protected function addField($name)
sfContext::getInstance()->getConfiguration()->loadHelpers('Url');
$this->form->setValidator($name, new sfValidatorFile([
- 'max_size' => '262144', // 256K
+ 'max_size' => '512000', // 500K
'mime_types' => ['image/png'],
// Crop image, it is synchronous but it should be fast
'validated_file_class' => 'arRepositoryThemeCropValidatedFile',
@@ -164,7 +164,7 @@ protected function addField($name)
$this->form->setWidget($name, new arB5WidgetFormInputFileEditable([
'label' => $this->context->i18n->__('Logo'),
'help' => $this->context->i18n->__(
- 'Requirements: PNG format, 256K max. size.
Recommended dimensions of %1%x%2%px, it will be cropped if ImageMagick is installed.',
+ 'Requirements: PNG format, 500K max. size.
Recommended dimensions of %1%x%2%px, it will be cropped if ImageMagick is installed.',
[
'%1%' => arRepositoryThemeCropValidatedFile::LOGO_MAX_WIDTH,
'%2%' => arRepositoryThemeCropValidatedFile::LOGO_MAX_HEIGHT,
diff --git a/lib/form/arRepositoryThemeCropValidatedFile.class.php b/lib/form/arRepositoryThemeCropValidatedFile.class.php
index acc6782ea3..1a357d3fb5 100644
--- a/lib/form/arRepositoryThemeCropValidatedFile.class.php
+++ b/lib/form/arRepositoryThemeCropValidatedFile.class.php
@@ -29,48 +29,113 @@ class arRepositoryThemeCropValidatedFile extends sfValidatedFile
public const LOGO_MAX_HEIGHT = 270;
public const BANNER_MAX_WIDTH = 800;
public const BANNER_MAX_HEIGHT = 300;
+ public const IMAGICK_MEMORY_LIMIT_MB = 64;
+ public const IMAGICK_MAP_LIMIT_MB = 64;
+ public const IMAGICK_AREA_LIMIT_PIXELS = 16000000;
public function save($file = null, $fileMode = 0666, $create = true, $dirMode = 0777)
{
$file = parent::save($file, $fileMode, $create, $dirMode);
- // Check if mogrify is available in the system
- exec('which mogrify', $output, $status);
- if (0 < $status) {
+ if (!$this->shouldCropImages()) {
return $file;
}
- // Figure out necessary dimensions from the filename
- $pathInfo = pathinfo($this->savedName);
+ if (null === $dimensions = self::getTargetDimensionsFromPath($this->savedName)) {
+ return $file;
+ }
+
+ $this->resizeAndCropImage($dimensions['width'], $dimensions['height']);
+
+ return $file;
+ }
+
+ public static function getTargetDimensionsFromPath($path)
+ {
+ $pathInfo = pathinfo($path);
switch ($pathInfo['filename']) {
case 'logo':
- $width = self::LOGO_MAX_WIDTH;
- $height = self::LOGO_MAX_HEIGHT;
-
- break;
+ return [
+ 'width' => self::LOGO_MAX_WIDTH,
+ 'height' => self::LOGO_MAX_HEIGHT,
+ ];
case 'banner':
- $width = self::BANNER_MAX_WIDTH;
- $height = self::BANNER_MAX_HEIGHT;
-
- break;
+ return [
+ 'width' => self::BANNER_MAX_WIDTH,
+ 'height' => self::BANNER_MAX_HEIGHT,
+ ];
}
+ }
- // Stop execution if dimensions were not set
- if (!isset($width, $height)) {
- return $file;
+ public static function getResizeGeometry($sourceWidth, $sourceHeight, $targetWidth, $targetHeight)
+ {
+ if (
+ 0 >= $sourceWidth
+ || 0 >= $sourceHeight
+ || 0 >= $targetWidth
+ || 0 >= $targetHeight
+ ) {
+ return null;
}
- // mogrify overwrites the original image file
- $command = sprintf(
- 'mogrify -crop %sx%s+0+0 %s',
- $width,
- $height,
- $this->savedName
- );
- exec($command, $output, $status);
+ // Scale to fully cover the target box while preserving aspect ratio.
+ $scale = max($targetWidth / $sourceWidth, $targetHeight / $sourceHeight);
+ $resizeWidth = (int) ceil($sourceWidth * $scale);
+ $resizeHeight = (int) ceil($sourceHeight * $scale);
- return $file;
+ return [
+ // Crop back to the exact target size from the centered overflow.
+ 'resizeWidth' => $resizeWidth,
+ 'resizeHeight' => $resizeHeight,
+ 'cropX' => max(0, (int) floor(($resizeWidth - $targetWidth) / 2)),
+ 'cropY' => max(0, (int) floor(($resizeHeight - $targetHeight) / 2)),
+ ];
+ }
+
+ protected function shouldCropImages()
+ {
+ return extension_loaded('imagick');
+ }
+
+ protected function resizeAndCropImage($width, $height)
+ {
+ try {
+ $image = new Imagick();
+ $image->setResourceLimit(Imagick::RESOURCETYPE_MEMORY, self::IMAGICK_MEMORY_LIMIT_MB);
+ $image->setResourceLimit(Imagick::RESOURCETYPE_MAP, self::IMAGICK_MAP_LIMIT_MB);
+ $image->setResourceLimit(Imagick::RESOURCETYPE_AREA, self::IMAGICK_AREA_LIMIT_PIXELS);
+ $image->readImage($this->savedName);
+ $geometry = self::getResizeGeometry(
+ $image->getImageWidth(),
+ $image->getImageHeight(),
+ $width,
+ $height
+ );
+
+ if (null === $geometry) {
+ $image->clear();
+ $image->destroy();
+
+ return;
+ }
+
+ $image->resizeImage(
+ $geometry['resizeWidth'],
+ $geometry['resizeHeight'],
+ Imagick::FILTER_LANCZOS,
+ 1
+ );
+ $image->cropImage($width, $height, $geometry['cropX'], $geometry['cropY']);
+ $image->setImagePage(0, 0, 0, 0);
+ $image->stripImage();
+ $image->setImageCompressionQuality(90);
+ $image->writeImage($this->savedName);
+ $image->clear();
+ $image->destroy();
+ } catch (Exception $e) {
+ // Leave the uploaded file untouched if Imagick cannot process it.
+ }
}
}
diff --git a/test/phpunit/lib/form/arRepositoryThemeCropValidatedFileTest.php b/test/phpunit/lib/form/arRepositoryThemeCropValidatedFileTest.php
new file mode 100644
index 0000000000..55ffe45968
--- /dev/null
+++ b/test/phpunit/lib/form/arRepositoryThemeCropValidatedFileTest.php
@@ -0,0 +1,99 @@
+assertSame(
+ [
+ 'width' => arRepositoryThemeCropValidatedFile::LOGO_MAX_WIDTH,
+ 'height' => arRepositoryThemeCropValidatedFile::LOGO_MAX_HEIGHT,
+ ],
+ arRepositoryThemeCropValidatedFile::getTargetDimensionsFromPath('/tmp/logo.png')
+ );
+ }
+
+ public function testBannerUploadsUseExpectedTargetDimensions()
+ {
+ $this->assertSame(
+ [
+ 'width' => arRepositoryThemeCropValidatedFile::BANNER_MAX_WIDTH,
+ 'height' => arRepositoryThemeCropValidatedFile::BANNER_MAX_HEIGHT,
+ ],
+ arRepositoryThemeCropValidatedFile::getTargetDimensionsFromPath('/tmp/banner.png')
+ );
+ }
+
+ public function testUnknownUploadNamesAreNotCropped()
+ {
+ $this->assertNull(arRepositoryThemeCropValidatedFile::getTargetDimensionsFromPath('/tmp/other.png'));
+ }
+
+ public function testResizeGeometryForLandscapeLogoCentersHorizontalCrop()
+ {
+ $this->assertSame(
+ [
+ 'resizeWidth' => 540,
+ 'resizeHeight' => 270,
+ 'cropX' => 135,
+ 'cropY' => 0,
+ ],
+ arRepositoryThemeCropValidatedFile::getResizeGeometry(1200, 600, 270, 270)
+ );
+ }
+
+ public function testResizeGeometryForPortraitLogoCentersVerticalCrop()
+ {
+ $this->assertSame(
+ [
+ 'resizeWidth' => 270,
+ 'resizeHeight' => 540,
+ 'cropX' => 0,
+ 'cropY' => 135,
+ ],
+ arRepositoryThemeCropValidatedFile::getResizeGeometry(600, 1200, 270, 270)
+ );
+ }
+
+ public function testResizeGeometryForBannerCentersVerticalCrop()
+ {
+ $this->assertSame(
+ [
+ 'resizeWidth' => 800,
+ 'resizeHeight' => 600,
+ 'cropX' => 0,
+ 'cropY' => 150,
+ ],
+ arRepositoryThemeCropValidatedFile::getResizeGeometry(1600, 1200, 800, 300)
+ );
+ }
+
+ public function testResizeGeometryRejectsInvalidDimensions()
+ {
+ $this->assertNull(arRepositoryThemeCropValidatedFile::getResizeGeometry(0, 1200, 270, 270));
+ }
+
+ public function testCroppingRequiresImagickExtension()
+ {
+ $file = new TestRepositoryThemeCropValidatedFile('logo.png', 'image/png', '/tmp/logo.png', 0);
+
+ $this->assertSame(extension_loaded('imagick'), $file->shouldCropImagesForTest());
+ }
+}
+
+class TestRepositoryThemeCropValidatedFile extends arRepositoryThemeCropValidatedFile
+{
+ public function shouldCropImagesForTest()
+ {
+ return $this->shouldCropImages();
+ }
+}