diff --git a/src/config/app.php b/src/config/app.php index df6aee26082..68c69ff438c 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -4,7 +4,7 @@ 'id' => 'CraftCMS', 'name' => 'Craft CMS', 'version' => '4.3.6.1', - 'schemaVersion' => '4.4.0.2', + 'schemaVersion' => '4.4.0.3', 'minVersionRequired' => '3.7.11', 'basePath' => dirname(__DIR__), // Defines the @app alias 'runtimePath' => '@storage/runtime', // Defines the @runtime alias diff --git a/src/controllers/ImageTransformsController.php b/src/controllers/ImageTransformsController.php index 80208d979a6..629cb8bb10b 100644 --- a/src/controllers/ImageTransformsController.php +++ b/src/controllers/ImageTransformsController.php @@ -10,6 +10,7 @@ use Craft; use craft\helpers\Image; use craft\models\ImageTransform; +use craft\validators\ColorValidator; use craft\web\assets\edittransform\EditTransformAsset; use craft\web\Controller; use yii\web\NotFoundHttpException; @@ -133,6 +134,8 @@ public function actionSave(): ?Response $transform->quality = $this->request->getBodyParam('quality') ?: null; $transform->interlace = $this->request->getBodyParam('interlace'); $transform->format = $this->request->getBodyParam('format'); + $transform->fill = $this->request->getBodyParam('fill') ?: null; + $transform->upscale = $this->request->getBodyParam('upscale', $transform->upscale); if (empty($transform->format)) { $transform->format = null; @@ -156,6 +159,10 @@ public function actionSave(): ?Response $errors = true; } + if ($transform->mode === 'letterbox') { + $transform->fill = $transform->fill ? ColorValidator::normalizeColor($transform->fill) : 'transparent'; + } + if (!$errors) { $success = Craft::$app->getImageTransforms()->saveTransform($transform); } else { diff --git a/src/elements/Asset.php b/src/elements/Asset.php index 743d3ed75de..b8b7e7b0d4c 100644 --- a/src/elements/Asset.php +++ b/src/elements/Asset.php @@ -2999,7 +2999,8 @@ private function _dimensions(mixed $transform = null): array $this->_height, $transform->width, $transform->height, - $transform->mode + $transform->mode, + $transform->upscale ); } diff --git a/src/helpers/Image.php b/src/helpers/Image.php index 666be2e247a..bb5ca78ab5d 100644 --- a/src/helpers/Image.php +++ b/src/helpers/Image.php @@ -69,7 +69,7 @@ public static function calculateMissingDimension(float|int|null $targetWidth, fl * @param int $sourceHeight * @param int|null $transformWidth * @param int|null $transformHeight - * @param string $mode The transform mode (`crop`, `fit`, or `stretch`) + * @param string $mode The transform mode (`crop`, `fit`, `letterbox` or `stretch`) * @param bool|null $upscale Whether to upscale the image to fill the transform dimensions. * Defaults to the `upscaleImages` config setting. * @return int[] @@ -87,6 +87,14 @@ public static function targetDimensions( [$width, $height] = static::calculateMissingDimension($transformWidth, $transformHeight, $sourceWidth, $sourceHeight); $factor = max($sourceWidth / $width, $sourceHeight / $height); + $imageRatio = $sourceWidth / $sourceHeight; + $transformRatio = $width / $height; + + // When mode is `letterbox` always use the transform size + if ($mode === 'letterbox') { + return [$width, $height]; + } + if ($upscale ?? Craft::$app->getConfig()->getGeneral()->upscaleImages) { // Special case for 'fit' since that's the only one whose dimensions vary from the transform dimensions if ($mode === 'fit') { @@ -97,14 +105,7 @@ public static function targetDimensions( return [$width, $height]; } - if ($transformWidth === null || $transformHeight === null) { - $transformRatio = $sourceWidth / $sourceHeight; - } else { - $transformRatio = $transformWidth / $transformHeight; - } - - $imageRatio = $sourceWidth / $sourceHeight; - + // When mode is `fit` or the source is the same ratio as the transform if ($mode === 'fit' || $imageRatio === $transformRatio) { $targetWidth = min($sourceWidth, $width, (int)round($sourceWidth / $factor)); $targetHeight = min($sourceHeight, $height, (int)round($sourceHeight / $factor)); diff --git a/src/helpers/ImageTransforms.php b/src/helpers/ImageTransforms.php index de01f5f29cc..20a51b7057f 100644 --- a/src/helpers/ImageTransforms.php +++ b/src/helpers/ImageTransforms.php @@ -19,6 +19,7 @@ use craft\errors\ImageTransformException; use craft\image\Raster; use craft\models\ImageTransform; +use craft\validators\ColorValidator; use Imagine\Image\Format; use yii\base\InvalidArgumentException; @@ -33,7 +34,7 @@ class ImageTransforms /** * @var string The pattern to use for matching against a transform string. */ - public const TRANSFORM_STRING_PATTERN = '/_(?P\d+|AUTO)x(?P\d+|AUTO)_(?P[a-z]+)(?:_(?P[a-z\-]+))?(?:_(?P\d+))?(?:_(?P[a-z]+))?/i'; + public const TRANSFORM_STRING_PATTERN = '/_(?P\d+|AUTO)x(?P\d+|AUTO)_(?P[a-z]+)(?:_(?P[a-z\-]+))?(?:_(?P\d+))?(?:_(?P[a-z]+))?(?:_(?P[0-9a-f]{6}|transparent))?(?:_(?Pns))?/i'; /** * Create an AssetImageTransform model from a string. @@ -58,6 +59,10 @@ public static function createTransformFromString(string $transformString): Image unset($matches['quality']); } + if (!empty($matches['fill'])) { + $fill = ColorValidator::normalizeColor($matches['fill']); + } + return Craft::createObject([ 'class' => ImageTransform::class, 'width' => $matches['width'] ?? null, @@ -66,6 +71,8 @@ public static function createTransformFromString(string $transformString): Image 'position' => $matches['position'], 'quality' => $matches['quality'] ?? null, 'interlace' => $matches['interlace'] ?? 'none', + 'fill' => $fill ?? null, + 'upscale' => ($matches['upscale'] ?? null) !== 'ns', 'transformer' => ImageTransform::DEFAULT_TRANSFORMER, ]); } @@ -235,7 +242,9 @@ public static function getTransformString(ImageTransform $transform, bool $ignor '_' . $transform->mode . '_' . $transform->position . ($transform->quality ? '_' . $transform->quality : '') . - '_' . $transform->interlace; + '_' . $transform->interlace . + ($transform->fill ? '_' . ltrim($transform->fill, '#') : '') . + ($transform->upscale ? '' : '_ns'); } /** @@ -247,10 +256,11 @@ public static function getTransformString(ImageTransform $transform, bool $ignor */ public static function parseTransformString(string $str): array { - if (!preg_match('/^_?(?P\d+|AUTO)x(?P\d+|AUTO)_(?P[a-z]+)_(?P[a-z\-]+)(?:_(?P\d+))?_(?P[a-z]+)$/', $str, $match)) { + if (!preg_match('/^_?(?P\d+|AUTO)x(?P\d+|AUTO)_(?P[a-z]+)_(?P[a-z\-]+)(?:_(?P\d+))?_(?P[a-z]+)(?:_(?Ptransparent|[0-9a-f]{3}|[0-9a-f]{6}))?(?:_(?Pns))?$/', $str, $match)) { throw new InvalidArgumentException("Invalid transform string: $str"); } + $upscale = $match['upscale'] ?? null; return [ 'width' => $match['width'] !== 'AUTO' ? (int)$match['width'] : null, 'height' => $match['height'] !== 'AUTO' ? (int)$match['height'] : null, @@ -258,6 +268,8 @@ public static function parseTransformString(string $str): array 'position' => $match['position'], 'quality' => $match['quality'] ? (int)$match['quality'] : null, 'interlace' => $match['interlace'], + 'fill' => ($match['fill'] ?? null) ? sprintf('%s%s', $match['fill'] !== 'transparent' ? '#' : '', $match['fill']) : null, + 'upscale' => $upscale !== 'ns', ]; } @@ -290,6 +302,8 @@ public static function normalizeTransform(mixed $transform): ?ImageTransform 'parameterChangeTime', 'mode', 'position', + 'fill', + 'upscale', 'quality', 'interlace', ]); @@ -306,6 +320,16 @@ public static function normalizeTransform(mixed $transform): ?ImageTransform $transform['height'] = null; } + if (!empty($transform['fill'])) { + $normalizedValue = ColorValidator::normalizeColor($transform['fill']); + if ((new ColorValidator())->validate($normalizedValue)) { + $transform['fill'] = $normalizedValue; + } else { + Craft::warning("Invalid transform fill: {$transform['fill']}", __METHOD__); + $transform['fill'] = null; + } + } + if (array_key_exists('transform', $transform)) { $baseTransform = self::normalizeTransform(ArrayHelper::remove($transform, 'transform')); return self::extendTransform($baseTransform, $transform); @@ -411,22 +435,39 @@ public static function generateTransform( $image->setHeartbeatCallback($heartbeat); } + if ($asset->getHasFocalPoint() && $transform->mode === 'crop') { + $position = $asset->getFocalPoint(); + } elseif (!preg_match('/^(top|center|bottom)-(left|center|right)$/', $transform->position)) { + $position = 'center-center'; + } else { + $position = $transform->position; + } + + $scaleIfSmaller = $transform->upscale ?? Craft::$app->getConfig()->getGeneral()->upscaleImages; + switch ($transform->mode) { + case 'letterbox': + if ($image instanceof Raster) { + $image->scaleToFitAndFill( + $transform->width, + $transform->height, + $transform->fill, + $position, + $scaleIfSmaller + ); + } else { + Craft::warning("Cannot add fill to non-raster images"); + $image->scaleToFit($transform->width, $transform->height, $scaleIfSmaller); + } + break; case 'fit': - $image->scaleToFit($transform->width, $transform->height); + $image->scaleToFit($transform->width, $transform->height, $scaleIfSmaller); break; case 'stretch': $image->resize($transform->width, $transform->height); break; default: - if ($asset->getHasFocalPoint()) { - $position = $asset->getFocalPoint(); - } elseif (!preg_match('/(top|center|bottom)-(left|center|right)/', $transform->position)) { - $position = 'center-center'; - } else { - $position = $transform->position; - } - $image->scaleAndCrop($transform->width, $transform->height, $generalConfig->upscaleImages, $position); + $image->scaleAndCrop($transform->width, $transform->height, $scaleIfSmaller, $position); } if ($image instanceof Raster) { diff --git a/src/image/Raster.php b/src/image/Raster.php index 225a21b04b9..8e22a3801b0 100644 --- a/src/image/Raster.php +++ b/src/image/Raster.php @@ -24,6 +24,7 @@ use Imagine\Image\BoxInterface; use Imagine\Image\ImageInterface; use Imagine\Image\Metadata\ExifMetadataReader; +use Imagine\Image\Palette\Color\ColorInterface; use Imagine\Image\Palette\RGB; use Imagine\Image\Point; use Imagine\Imagick\Imagine as ImagickImagine; @@ -59,9 +60,9 @@ class Raster extends Image private int $_quality = 0; /** - * @var AbstractImage|null + * @var ImageInterface|null */ - private ?AbstractImage $_image = null; + private ?ImageInterface $_image = null; /** * @var AbstractImagine|null @@ -78,6 +79,11 @@ class Raster extends Image */ private ?Font $_font = null; + /** + * @var ColorInterface|null + */ + private ?ColorInterface $_fill = null; + /** * @inheritdoc */ @@ -234,11 +240,11 @@ public function crop(int $x1, int $x2, int $y1, int $y2): self /** * @inheritdoc */ - public function scaleToFit(?int $targetWidth, ?int $targetHeight, bool $scaleIfSmaller = true): self + public function scaleToFit(?int $targetWidth, ?int $targetHeight, bool $scaleIfSmaller = null): self { $this->normalizeDimensions($targetWidth, $targetHeight); - $scaleIfSmaller = $scaleIfSmaller && Craft::$app->getConfig()->getGeneral()->upscaleImages; + $scaleIfSmaller = $scaleIfSmaller ?? Craft::$app->getConfig()->getGeneral()->upscaleImages; if ($scaleIfSmaller || $this->getWidth() > $targetWidth || $this->getHeight() > $targetHeight) { $factor = max($this->getWidth() / $targetWidth, $this->getHeight() / $targetHeight); @@ -248,6 +254,64 @@ public function scaleToFit(?int $targetWidth, ?int $targetHeight, bool $scaleIfS return $this; } + /** + * Scales an image to the target size and fills empty pixels with color. + * + * @param int|null $targetWidth + * @param int|null $targetHeight + * @param string|null $fill + * @param string|array $position + * @param bool|null $upscale + * @return Raster + * @since 4.4.0 + */ + public function scaleToFitAndFill(?int $targetWidth, ?int $targetHeight, string $fill = null, string|array $position = 'center-center', bool $upscale = null): static + { + $upscale = $upscale ?? Craft::$app->getConfig()->getGeneral()->upscaleImages; + + $this->scaleToFit($targetWidth, $targetHeight, $upscale); + $this->setFill($fill); + + $box = new Box($targetWidth, $targetHeight); + $canvas = $this->_instance->create($box, $this->_fill); + + [$verticalPosition, $horizontalPosition] = explode('-', $position); + + $y = match ($verticalPosition) { + 'top' => 0, + 'bottom' => ($box->getHeight() - $this->getHeight()), + default => ($box->getHeight() - $this->getHeight()) / 2, + }; + + $x = match ($horizontalPosition) { + 'left' => 0, + 'right' => ($box->getWidth() - $this->getWidth()), + default => ($box->getWidth() - $this->getWidth()) / 2, + }; + + $point = new Point($x, $y); + + if ($this->_isAnimated) { + $canvas->layers()->remove(0); + $this->_image->layers()->coalesce(); + + foreach ($this->_image->layers() as $layer) { + $newLayer = $this->_instance->create($box, $this->_fill); + $newLayer->paste($layer, $point); + $canvas->layers()->add($newLayer); + + // Hopefully this doesn't take _too_ long, but it might + $this->heartbeat(); + } + } else { + $canvas->paste($this->_image, $point); + } + + $this->_image = $canvas; + + return $this; + } + /** * @inheritdoc */ @@ -458,6 +522,25 @@ public function setInterlace(string $interlace): self return $this; } + /** + * Sets the fill color based on the image's palette. + * + * @param string $fill Hex color of the fill. + * @return $this Self reference + * @since 4.4.0 + */ + public function setFill(string $fill = null): self + { + $fill = $fill ?? 'transparent'; + if ($fill === 'transparent') { + $this->_fill = $this->_image->palette()->color('#ffffff', 0); + } else { + $this->_fill = $this->_image->palette()->color($fill); + } + + return $this; + } + /** * @inheritdoc */ diff --git a/src/migrations/Install.php b/src/migrations/Install.php index 669aae17234..90d70dcd8d8 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -192,13 +192,15 @@ public function createTables(): void 'id' => $this->primaryKey(), 'name' => $this->string()->notNull(), 'handle' => $this->string()->notNull(), - 'mode' => $this->enum('mode', ['stretch', 'fit', 'crop'])->notNull()->defaultValue('crop'), + 'mode' => $this->enum('mode', ['stretch', 'fit', 'crop', 'letterbox'])->notNull()->defaultValue('crop'), 'position' => $this->enum('position', ['top-left', 'top-center', 'top-right', 'center-left', 'center-center', 'center-right', 'bottom-left', 'bottom-center', 'bottom-right'])->notNull()->defaultValue('center-center'), 'width' => $this->integer()->unsigned(), 'height' => $this->integer()->unsigned(), 'format' => $this->string(), 'quality' => $this->integer(), 'interlace' => $this->enum('interlace', ['none', 'line', 'plane', 'partition'])->notNull()->defaultValue('none'), + 'fill' => $this->string(11)->null(), + 'upscale' => $this->boolean()->notNull()->defaultValue(true), 'parameterChangeTime' => $this->dateTime(), 'dateCreated' => $this->dateTime()->notNull(), 'dateUpdated' => $this->dateTime()->notNull(), diff --git a/src/migrations/m221027_160703_add_image_transform_fill.php b/src/migrations/m221027_160703_add_image_transform_fill.php new file mode 100644 index 00000000000..7a14136ddb7 --- /dev/null +++ b/src/migrations/m221027_160703_add_image_transform_fill.php @@ -0,0 +1,65 @@ +addColumn(Table::IMAGETRANSFORMS, 'fill', $this->string(11)->null()->after('interlace')); + $this->addColumn(Table::IMAGETRANSFORMS, 'upscale', $this->boolean()->notNull()->defaultValue(true)->after('fill')); + + $modeOptions = ['stretch', 'fit', 'crop', 'letterbox']; + if ($this->db->getIsPgsql()) { + // Manually construct the SQL for Postgres + $check = '[[mode]] in ('; + foreach ($modeOptions as $i => $value) { + if ($i !== 0) { + $check .= ','; + } + $check .= $this->db->quoteValue($value); + } + $check .= ')'; + $this->execute("alter table {{%imagetransforms}} drop constraint {{%imagetransforms_mode_check}}, add check ({$check})"); + } else { + $this->alterColumn(Table::IMAGETRANSFORMS, 'mode', $this->enum('mode', $modeOptions)->notNull()->defaultValue('crop')); + } + + $projectConfig = Craft::$app->getProjectConfig(); + $schemaVersion = $projectConfig->get('system.schemaVersion', true); + + if (version_compare($schemaVersion, '4.4.0.3', '<')) { + // Hard-code the existing transforms with the current upscaleImages config value + $generalConfig = Craft::$app->getConfig()->getGeneral(); + $transforms = $projectConfig->get(ProjectConfig::PATH_IMAGE_TRANSFORMS) ?? []; + foreach ($transforms as $uid => $config) { + $config['upscale'] = $generalConfig->upscaleImages; + $path = sprintf('%s.%s', ProjectConfig::PATH_IMAGE_TRANSFORMS, $uid); + $projectConfig->set($path, $config); + } + } + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m221027_160703_add_image_transform_fill cannot be reverted.\n"; + return false; + } +} diff --git a/src/models/ImageTransform.php b/src/models/ImageTransform.php index e03c100ba0e..b4efa465965 100644 --- a/src/models/ImageTransform.php +++ b/src/models/ImageTransform.php @@ -12,6 +12,7 @@ use craft\base\Model; use craft\imagetransforms\ImageTransformer; use craft\records\ImageTransform as ImageTransformRecord; +use craft\validators\ColorValidator; use craft\validators\DateTimeValidator; use craft\validators\HandleValidator; use craft\validators\UniqueValidator; @@ -67,7 +68,7 @@ class ImageTransform extends Model public ?DateTime $parameterChangeTime = null; /** - * @var 'crop'|'fit'|'stretch' Mode + * @var string 'crop'|'fit'|'stretch'|'letterbox' Mode */ public string $mode = 'crop'; @@ -91,12 +92,36 @@ class ImageTransform extends Model */ public ?string $uid = null; + /** + * @var string|null Fill color + * @since 4.4.0 + */ + public ?string $fill = null; + + /** + * @var bool|null Allow upscaling + * @since 4.4.0 + */ + public ?bool $upscale = null; + /** * @var string The image transformer to use. * @phpstan-var class-string */ protected string $transformer = self::DEFAULT_TRANSFORMER; + /** + * @inheritdoc + */ + public function init(): void + { + parent::init(); + + if (!isset($this->upscale)) { + $this->upscale = Craft::$app->getConfig()->getGeneral()->upscaleImages; + } + } + /** * @inheritdoc */ @@ -110,6 +135,8 @@ public function attributeLabels(): array 'position' => Craft::t('app', 'Position'), 'quality' => Craft::t('app', 'Quality'), 'width' => Craft::t('app', 'Width'), + 'fill' => Craft::t('app', 'Fill Color'), + 'upscale' => Craft::t('app', 'Allow Upscaling'), 'transformer' => Craft::t('app', 'Image transformer'), ]; } @@ -125,6 +152,8 @@ protected function defineRules(): array $rules[] = [['handle'], 'string', 'max' => 255]; $rules[] = [['name', 'handle', 'mode', 'position'], 'required']; $rules[] = [['handle'], 'string', 'max' => 255]; + $rules[] = [['fill'], ColorValidator::class]; + $rules[] = [['upscale'], 'boolean']; $rules[] = [ ['mode'], 'in', @@ -132,6 +161,7 @@ protected function defineRules(): array 'stretch', 'fit', 'crop', + 'letterbox', ], ]; $rules[] = [ @@ -209,6 +239,7 @@ public static function modes(): array 'crop' => Craft::t('app', 'Scale and crop'), 'fit' => Craft::t('app', 'Scale to fit'), 'stretch' => Craft::t('app', 'Stretch to fit'), + 'letterbox' => Craft::t('app', 'Letterbox'), ]; } diff --git a/src/records/ImageTransform.php b/src/records/ImageTransform.php index db600c432b7..e8d911ce6ca 100644 --- a/src/records/ImageTransform.php +++ b/src/records/ImageTransform.php @@ -22,7 +22,9 @@ * @property int $width Width * @property string $format Format * @property string $interlace Interlace + * @property string $fill Fill Color * @property int $quality Quality + * @property bool $upscale Allow Upscaling * @property string|null $parameterChangeTime Critical parameter change time * @author Pixel & Tonic, Inc. * @since 4.0.0 diff --git a/src/services/ImageTransforms.php b/src/services/ImageTransforms.php index 8491feb69f1..ec12b08bb92 100644 --- a/src/services/ImageTransforms.php +++ b/src/services/ImageTransforms.php @@ -222,6 +222,8 @@ public function saveTransform(ImageTransform $transform, bool $runValidation = t 'position' => $transform->position, 'quality' => (int)$transform->quality ?: null, 'width' => (int)$transform->width ?: null, + 'fill' => $transform->fill, + 'upscale' => $transform->upscale, ]; $configPath = ProjectConfig::PATH_IMAGE_TRANSFORMS . '.' . $transform->uid; @@ -258,8 +260,10 @@ public function handleChangedTransform(ConfigEvent $event): void $modeChanged = $transformRecord->mode !== $data['mode'] || $transformRecord->position !== $data['position']; $qualityChanged = $transformRecord->quality !== $data['quality']; $interlaceChanged = $transformRecord->interlace !== $data['interlace']; + $fillChanged = $transformRecord->fill !== ($data['fill'] ?? null); + $upscaleChanged = $transformRecord->upscale !== ($data['upscale'] ?? null); - if ($heightChanged || $modeChanged || $qualityChanged || $interlaceChanged) { + if ($heightChanged || $modeChanged || $qualityChanged || $interlaceChanged || $fillChanged || $upscaleChanged) { $transformRecord->parameterChangeTime = Db::prepareDateForDb(new DateTime()); $deleteTransformIndexes = true; } @@ -271,6 +275,8 @@ public function handleChangedTransform(ConfigEvent $event): void $transformRecord->quality = $data['quality']; $transformRecord->interlace = $data['interlace']; $transformRecord->format = $data['format']; + $transformRecord->fill = $data['fill'] ?? null; + $transformRecord->upscale = $data['upscale'] ?? null; $transformRecord->uid = $transformUid; $transformRecord->save(false); @@ -600,6 +606,8 @@ private function _createTransformQuery(): Query 'format', 'quality', 'interlace', + 'fill', + 'upscale', 'parameterChangeTime', 'uid', ]) diff --git a/src/templates/settings/assets/transforms/_settings.twig b/src/templates/settings/assets/transforms/_settings.twig index d3d6935d8cb..813ecf8d53e 100644 --- a/src/templates/settings/assets/transforms/_settings.twig +++ b/src/templates/settings/assets/transforms/_settings.twig @@ -52,6 +52,11 @@ {{ "Fit"|t('app') }} + +