Skip to content

Commit

Permalink
Merge pull request #12214 from craftcms/feature/dev-239-fill-image-tr…
Browse files Browse the repository at this point in the history
…ansform-setting

Fill image transform setting
  • Loading branch information
brandonkelly authored Feb 4, 2023
2 parents 87c8d23 + d81e156 commit bf93085
Show file tree
Hide file tree
Showing 26 changed files with 661 additions and 83 deletions.
2 changes: 1 addition & 1 deletion src/config/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/controllers/ImageTransformsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion src/elements/Asset.php
Original file line number Diff line number Diff line change
Expand Up @@ -2999,7 +2999,8 @@ private function _dimensions(mixed $transform = null): array
$this->_height,
$transform->width,
$transform->height,
$transform->mode
$transform->mode,
$transform->upscale
);
}

Expand Down
19 changes: 10 additions & 9 deletions src/helpers/Image.php
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand All @@ -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') {
Expand All @@ -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));
Expand Down
65 changes: 53 additions & 12 deletions src/helpers/ImageTransforms.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -33,7 +34,7 @@ class ImageTransforms
/**
* @var string The pattern to use for matching against a transform string.
*/
public const TRANSFORM_STRING_PATTERN = '/_(?P<width>\d+|AUTO)x(?P<height>\d+|AUTO)_(?P<mode>[a-z]+)(?:_(?P<position>[a-z\-]+))?(?:_(?P<quality>\d+))?(?:_(?P<interlace>[a-z]+))?/i';
public const TRANSFORM_STRING_PATTERN = '/_(?P<width>\d+|AUTO)x(?P<height>\d+|AUTO)_(?P<mode>[a-z]+)(?:_(?P<position>[a-z\-]+))?(?:_(?P<quality>\d+))?(?:_(?P<interlace>[a-z]+))?(?:_(?P<fill>[0-9a-f]{6}|transparent))?(?:_(?P<upscale>ns))?/i';

/**
* Create an AssetImageTransform model from a string.
Expand All @@ -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,
Expand All @@ -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,
]);
}
Expand Down Expand Up @@ -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');
}

/**
Expand All @@ -247,17 +256,20 @@ public static function getTransformString(ImageTransform $transform, bool $ignor
*/
public static function parseTransformString(string $str): array
{
if (!preg_match('/^_?(?P<width>\d+|AUTO)x(?P<height>\d+|AUTO)_(?P<mode>[a-z]+)_(?P<position>[a-z\-]+)(?:_(?P<quality>\d+))?_(?P<interlace>[a-z]+)$/', $str, $match)) {
if (!preg_match('/^_?(?P<width>\d+|AUTO)x(?P<height>\d+|AUTO)_(?P<mode>[a-z]+)_(?P<position>[a-z\-]+)(?:_(?P<quality>\d+))?_(?P<interlace>[a-z]+)(?:_(?P<fill>transparent|[0-9a-f]{3}|[0-9a-f]{6}))?(?:_(?P<upscale>ns))?$/', $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,
'mode' => $match['mode'],
'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',
];
}

Expand Down Expand Up @@ -290,6 +302,8 @@ public static function normalizeTransform(mixed $transform): ?ImageTransform
'parameterChangeTime',
'mode',
'position',
'fill',
'upscale',
'quality',
'interlace',
]);
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
91 changes: 87 additions & 4 deletions src/image/Raster.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -78,6 +79,11 @@ class Raster extends Image
*/
private ?Font $_font = null;

/**
* @var ColorInterface|null
*/
private ?ColorInterface $_fill = null;

/**
* @inheritdoc
*/
Expand Down Expand Up @@ -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);
Expand All @@ -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
*/
Expand Down Expand Up @@ -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
*/
Expand Down
4 changes: 3 additions & 1 deletion src/migrations/Install.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading

0 comments on commit bf93085

Please sign in to comment.