TYPO3 v15 dev-main snapshot ()
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Imaging;
|
||||
|
||||
use TYPO3\CMS\Core\Imaging\Exception\ZeroImageDimensionException;
|
||||
use TYPO3\CMS\Core\Imaging\ImageManipulation\Area;
|
||||
use TYPO3\CMS\Core\Resource\ProcessedFile;
|
||||
use TYPO3\CMS\Core\Resource\Processing\TaskInterface;
|
||||
|
||||
/**
|
||||
* A DTO representing all information needed to process an image,
|
||||
* mainly the target dimensions.
|
||||
*
|
||||
* With this information an image can be processed by ImageMagick/GraphicsMagick.
|
||||
*
|
||||
* "cropScaling" refers to the logic where the image is cropped and scaled at the same time, which was
|
||||
* used back in TYPO3 v3/v4 but the "LocalCropScaleMaskHelper" is actually doing this in subsequent steps,
|
||||
* but should be merged together again once there is a load of more tests.
|
||||
*
|
||||
* @internal This object is still internal as long as cropping isn't migrated yet to the Crop API.
|
||||
*/
|
||||
readonly class ImageProcessingInstructions
|
||||
{
|
||||
/**
|
||||
* @param int<0, max> $width
|
||||
* @param int<0, max> $height
|
||||
*/
|
||||
public function __construct(
|
||||
public int $width = 0,
|
||||
public int $height = 0,
|
||||
public ?Area $cropArea = null,
|
||||
) {}
|
||||
|
||||
public static function fromProcessingTask(TaskInterface $task): ImageProcessingInstructions
|
||||
{
|
||||
$config = self::getConfigurationForImageCropScaleMask($task);
|
||||
$processedFile = $task->getTargetFile();
|
||||
$isCropped = false;
|
||||
if (($config['crop'] ?? null) instanceof Area) {
|
||||
$isCropped = true;
|
||||
$imageWidth = (int)round($config['crop']->getWidth());
|
||||
$imageHeight = (int)round($config['crop']->getHeight());
|
||||
} else {
|
||||
$imageWidth = (int)$processedFile->getOriginalFile()->getProperty('width');
|
||||
$imageHeight = (int)$processedFile->getOriginalFile()->getProperty('height');
|
||||
}
|
||||
if ($imageWidth <= 0 || $imageHeight <= 0) {
|
||||
throw new ZeroImageDimensionException('Width and height of the image must be greater than zero.', 1597310560);
|
||||
}
|
||||
return ImageProcessingInstructions::fromCropScaleValues(
|
||||
$imageWidth,
|
||||
$imageHeight,
|
||||
$config['width'] ?? '',
|
||||
$config['height'] ?? '',
|
||||
$config
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get numbers for scaling the image based on input.
|
||||
*
|
||||
* Notes by Benni in 2023 in order to understand this magic:
|
||||
* ----------------------------
|
||||
* Relevant if an image should be
|
||||
* - scaled
|
||||
* - cropped
|
||||
* - keep the aspect ratio while scaling?
|
||||
* - use a target width or height
|
||||
* - or rather have a minimum or maximum width and/or height
|
||||
*
|
||||
* This method does a lot of magic:
|
||||
* - $incomingWidth/$incomingHeight contains the size of an original image for example.
|
||||
* - $w and $h are the width and height that are originally required the image to be like
|
||||
* when scaled. They could contain a "c" for cropping information or "m" for "Ensure that even though $w and $h are given, one containing an $m that we keep the aspect ratio."
|
||||
* "m" really allows to say $w="50c" that this might in a result with [0]=100 because $w would follow $h in order to keep aspect ratio.
|
||||
* Obviously this only works properly if both m and c are working
|
||||
* - $options contain "maxW" (never go beyond this width, even if scaling larger as this), same with "maxH" and "minW" and "minH" (note these get streamlined to maxWidth, maxHeight, minWidth, minHeight)
|
||||
*
|
||||
* The return values are a bit tricky to understand, so I added a few tests:
|
||||
* - AFAICS "0" and "1" are always used as "these are the target width / height" which my image
|
||||
* should be scaled to, or cropped down to.
|
||||
* Notes: If you hand in $info[0] and $incomingHeight a "0", you will get "0" as return value back!
|
||||
* but
|
||||
* - "crs" if the image should be cropped (which is indicated by one of $w or $h contain the "c" at the end)
|
||||
* - "cropH" and "cropV" is also set when one of the incoming $w or $h contains a "c".
|
||||
* Notes: "cropH" and "cropV" are rather cryptic, and can't really be used outside of this context.
|
||||
* They are then "magically calculated" outside of this function
|
||||
* $offsetX = (int)(($data[0] - $data['origW']) * ($data['cropH'] + 100) / 200);
|
||||
* $offsetY = (int)(($data[1] - $data['origH']) * ($data['cropV'] + 100) / 200);
|
||||
*
|
||||
* - "origW" / "origH" seems to be the values that were handed in as $w and $h, but they might be altered
|
||||
* f.e. "origH" is set when $w is given and $options["maxH"]
|
||||
* - When such a rearranging calculation was made ("maxH" reduces the original $w due to constraints),
|
||||
* then the return value "max" is set.
|
||||
* - When using the "c" argument, origH and origW seem to contain the values that you would expect when NOT doing a crop scenario
|
||||
* whereas $incomingWidth and $incomingHeight contain the target width and height that could be larger than originally requested.
|
||||
*
|
||||
* ----------------------------
|
||||
* @param int<0, max> $incomingWidth the width of an original image for example, can be "0" if there is no original image
|
||||
* @param int<0, max> $incomingHeight the height of an original image for example, can be "0" if there is no original image
|
||||
* @param int<0, max>|string $width "required" width that is requested, can be "" or "0" or a number of a magic "m" or "c" appended
|
||||
* @param int<0, max>|string $height "required" height that is requested, can be "" or "0" or a number of a magic "m" or "c" appended
|
||||
* @param array $options Options: Keys are like "maxW", "maxH", "minW", "minH" (streamlined to "maxWidth", "maxHeight", "minWidth", "minHeight")
|
||||
*/
|
||||
public static function fromCropScaleValues(int $incomingWidth, int $incomingHeight, int|string $width, int|string $height, array $options): self
|
||||
{
|
||||
$options = self::streamlineOptions($options);
|
||||
|
||||
if ($incomingWidth === 0 || $incomingHeight === 0) {
|
||||
// @todo incomingWidth/Height makes no sense, we should ideally throw an exception here…
|
||||
// this code is here to make existing unit tests happy and should be dropped
|
||||
return new self(
|
||||
width: 0,
|
||||
height: 0,
|
||||
cropArea: null
|
||||
);
|
||||
}
|
||||
|
||||
$cropArea = ($options['crop'] ?? null) instanceof Area ? $options['crop'] : new Area(0, 0, $incomingWidth, $incomingHeight);
|
||||
|
||||
// If both the width and the height are set and one of the numbers is appended by an m, the proportions will
|
||||
// be preserved and thus width and height are treated as maximum dimensions for the image. The image will be
|
||||
// scaled to fit into the rectangle of the dimensions width and height.
|
||||
$useWidthOrHeightAsMaximumLimits = str_contains($width . $height, 'm');
|
||||
$useCropScaling = str_contains($width . $height, 'c');
|
||||
|
||||
if ($useWidthOrHeightAsMaximumLimits && $useCropScaling) {
|
||||
throw new \InvalidArgumentException('Cannot mix m and c modifiers for width/height', 1709840402);
|
||||
}
|
||||
|
||||
if ($useWidthOrHeightAsMaximumLimits) {
|
||||
if (str_contains((string)$width, 'm')) {
|
||||
$options['maxWidth'] = min((int)$width, (int)($options['maxWidth'] ?? PHP_INT_MAX));
|
||||
// width: auto
|
||||
$width = 0;
|
||||
}
|
||||
if (str_contains((string)$height, 'm')) {
|
||||
$options['maxHeight'] = min((int)$height, (int)($options['maxHeight'] ?? PHP_INT_MAX));
|
||||
// height: auto
|
||||
$height = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if ((int)$width !== 0 && (int)$height !== 0 && $useCropScaling) {
|
||||
$cropOffsetHorizontal = (int)substr((string)strstr((string)$width, 'c'), 1);
|
||||
$cropOffsetVertical = (int)substr((string)strstr((string)$height, 'c'), 1);
|
||||
$width = (int)$width;
|
||||
$height = (int)$height;
|
||||
|
||||
$cropArea = self::applyCropScaleToCropArea($cropArea, $width, $height, $cropOffsetVertical, $cropOffsetHorizontal);
|
||||
}
|
||||
|
||||
$width = (int)$width;
|
||||
$height = (int)$height;
|
||||
|
||||
// Rounding in extreme formats like 1920x10 to 64x??? can yield a 0 height/width, which should be at least 1 pixel.
|
||||
// Because of this, the following checks use a max(1, $maybeZero) assignment.
|
||||
if ($width > 0 && $height === 0) {
|
||||
$height = max(1, (int)round($cropArea->getHeight() * ($width / $cropArea->getWidth())));
|
||||
}
|
||||
if ($height > 0 && $width === 0) {
|
||||
$width = max(1, (int)round($cropArea->getWidth() * ($height / $cropArea->getHeight())));
|
||||
}
|
||||
|
||||
// If there are max/min-values...
|
||||
if (!empty($options['maxWidth'])) {
|
||||
if ($width > $options['maxWidth'] || ($width === 0 && $cropArea->getWidth() > $options['maxWidth'])) {
|
||||
$width = (int)$options['maxWidth'];
|
||||
$height = max(1, (int)round($cropArea->getHeight() * ($width / $cropArea->getWidth())));
|
||||
}
|
||||
}
|
||||
if (!empty($options['maxHeight'])) {
|
||||
if ($height > $options['maxHeight'] || ($height === 0 && $cropArea->getHeight() > $options['maxHeight'])) {
|
||||
$height = (int)$options['maxHeight'];
|
||||
$width = max(1, (int)round($cropArea->getWidth() * ($height / $cropArea->getHeight())));
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($options['minWidth'])) {
|
||||
if ($width < $options['minWidth'] || ($width === 0 && $cropArea->getWidth() < $options['minWidth'])) {
|
||||
$width = (int)$options['minWidth'];
|
||||
$height = max(1, (int)round($cropArea->getHeight() * ($width / $cropArea->getWidth())));
|
||||
}
|
||||
}
|
||||
if (!empty($options['minHeight'])) {
|
||||
if ($height < $options['minHeight'] || ($height === 0 && $cropArea->getHeight() < $options['minHeight'])) {
|
||||
$height = (int)$options['minHeight'];
|
||||
$width = max(1, (int)round($cropArea->getWidth() * ($height / $cropArea->getHeight())));
|
||||
}
|
||||
}
|
||||
|
||||
if ($width === 0 && $height === 0) {
|
||||
$width = (int)round($cropArea->getWidth());
|
||||
$height = (int)round($cropArea->getHeight());
|
||||
// This here may return "0", which should continue to throw a LogicException. Probably.
|
||||
}
|
||||
if ($width === 0 || $height === 0) {
|
||||
$extraDetails = [];
|
||||
$extraDetails[] = 'incomingWidth: ' . $incomingWidth;
|
||||
$extraDetails[] = 'incomingHeight: ' . $incomingHeight;
|
||||
$extraDetails[] = 'width: ' . $width;
|
||||
$extraDetails[] = 'height: ' . $height;
|
||||
$extraDetails[] = 'options: ' . json_encode($options, JSON_PRETTY_PRINT);
|
||||
$extraDetails[] = 'cropArea: ' . json_encode($cropArea->asArray(), JSON_PRETTY_PRINT);
|
||||
// Exceptions have no HTML/Text formatting.
|
||||
throw new \LogicException('Image processing instructions did not resolve into coherent positive width and height values. This is a bug. Please report. Extra details: ' . implode(', ', $extraDetails), 1709806820);
|
||||
}
|
||||
|
||||
if (!($GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_allowUpscaling'] ?? false)) {
|
||||
if ($width > $cropArea->getWidth()) {
|
||||
$width = (int)round($cropArea->getWidth());
|
||||
$height = (int)round($cropArea->getHeight() * ($width / $cropArea->getWidth()));
|
||||
}
|
||||
if ($height > $cropArea->getHeight()) {
|
||||
$height = (int)round($cropArea->getHeight());
|
||||
$width = (int)round($cropArea->getWidth() * ($height / $cropArea->getHeight()));
|
||||
}
|
||||
}
|
||||
|
||||
if ((int)$cropArea->getOffsetLeft() === 0
|
||||
&& (int)$cropArea->getOffsetTop() === 0
|
||||
&& (int)$cropArea->getWidth() === $incomingWidth
|
||||
&& (int)$cropArea->getHeight() === $incomingHeight) {
|
||||
$cropArea = null;
|
||||
}
|
||||
|
||||
return new self(
|
||||
width: $width,
|
||||
height: $height,
|
||||
cropArea: $cropArea,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Area $cropArea with absolute crop data (not relative!)
|
||||
* @param positive-int $width
|
||||
* @param positive-int $height
|
||||
* @param int<-100,100> $cropOffsetVertical
|
||||
* @param int<-100,100> $cropOffsetHorizontal
|
||||
*/
|
||||
private static function applyCropScaleToCropArea(
|
||||
Area $cropArea,
|
||||
int $width,
|
||||
int $height,
|
||||
int $cropOffsetVertical,
|
||||
int $cropOffsetHorizontal
|
||||
): Area {
|
||||
if (!($width > 0 && $height > 0 && $cropArea->getWidth() > 0 && $cropArea->getHeight() > 0)) {
|
||||
throw new \InvalidArgumentException('Apply crop scale must use concrete width and height', 1709810881);
|
||||
}
|
||||
$destRatio = $width / $height;
|
||||
$cropRatio = $cropArea->getWidth() / $cropArea->getHeight();
|
||||
|
||||
if ($destRatio > $cropRatio) {
|
||||
$w = $cropArea->getWidth();
|
||||
$h = $cropArea->getWidth() / $destRatio;
|
||||
$x = $cropArea->getOffsetLeft();
|
||||
$y = $cropArea->getOffsetTop() + (float)(($cropArea->getHeight() - $h) * ($cropOffsetVertical + 100) / 200);
|
||||
} else {
|
||||
$w = $cropArea->getHeight() * $destRatio;
|
||||
$h = $cropArea->getHeight();
|
||||
$x = $cropArea->getOffsetLeft() + (float)(($cropArea->getWidth() - $w) * ($cropOffsetHorizontal + 100) / 200);
|
||||
$y = $cropArea->getOffsetTop();
|
||||
}
|
||||
|
||||
return new Area($x, $y, $w, $h);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* maxWidth?: int,
|
||||
* maxHeight?: int,
|
||||
* minWidth?: int,
|
||||
* minHeight?: int,
|
||||
* crop?: Area,
|
||||
* }
|
||||
*/
|
||||
private static function streamlineOptions(array $options): array
|
||||
{
|
||||
if (isset($options['maxW'])) {
|
||||
$options['maxWidth'] = $options['maxW'];
|
||||
unset($options['maxW']);
|
||||
}
|
||||
if (isset($options['maxH'])) {
|
||||
$options['maxHeight'] = $options['maxH'];
|
||||
unset($options['maxH']);
|
||||
}
|
||||
if (isset($options['minW'])) {
|
||||
$options['minWidth'] = $options['minW'];
|
||||
unset($options['minW']);
|
||||
}
|
||||
if (isset($options['minH'])) {
|
||||
$options['minHeight'] = $options['minH'];
|
||||
unset($options['minH']);
|
||||
}
|
||||
|
||||
if (($options['maxWidth'] ?? null) <= 0) {
|
||||
unset($options['maxWidth']);
|
||||
}
|
||||
if (($options['maxHeight'] ?? null) <= 0) {
|
||||
unset($options['maxHeight']);
|
||||
}
|
||||
if (($options['minWidth'] ?? null) <= 0) {
|
||||
unset($options['minWidth']);
|
||||
}
|
||||
if (($options['minHeight'] ?? null) <= 0) {
|
||||
unset($options['minHeight']);
|
||||
}
|
||||
|
||||
if (isset($options['crop'])) {
|
||||
if ($options['crop'] === '') {
|
||||
unset($options['crop']);
|
||||
} elseif (is_string($options['crop'])) {
|
||||
// check if it is a json object
|
||||
$cropData = json_decode($options['crop']);
|
||||
if ($cropData) {
|
||||
// happens when $options['crop'] = '{"default":{"cropArea":{"x":0,"y":0,"width":1,"height":1},"selectedRatio":"NaN","focusArea":null}}'
|
||||
if (!isset($cropData->x) || !isset($cropData->y) || !isset($cropData->width) || !isset($cropData->height)) {
|
||||
unset($options['crop']);
|
||||
} else {
|
||||
$options['crop'] = new Area((float)$cropData->x, (float)$cropData->y, (float)$cropData->width, (float)$cropData->height);
|
||||
}
|
||||
} elseif (substr_count($options['crop'], ',') === 3) {
|
||||
[$offsetLeft, $offsetTop, $newWidth, $newHeight] = explode(',', $options['crop'], 4);
|
||||
$options['crop'] = new Area((float)$offsetLeft, (float)$offsetTop, (float)$newWidth, (float)$newHeight);
|
||||
} else {
|
||||
unset($options['crop']);
|
||||
}
|
||||
if (isset($options['crop']) && $options['crop']->isEmpty()) {
|
||||
unset($options['crop']);
|
||||
}
|
||||
} elseif (!$options['crop'] instanceof Area) {
|
||||
unset($options['crop']);
|
||||
}
|
||||
}
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* width?: int<0, max>|string,
|
||||
* height?: int<0, max>|string,
|
||||
* maxWidth?: int<0, max>,
|
||||
* maxHeight?: int<0, max>,
|
||||
* maxW?: int<0, max>,
|
||||
* maxH?: int<0, max>,
|
||||
* minW?: int<0, max>,
|
||||
* minH?: int<0, max>,
|
||||
* crop?: Area,
|
||||
* noScale?: bool
|
||||
* }
|
||||
*/
|
||||
private static function getConfigurationForImageCropScaleMask(TaskInterface $task): array
|
||||
{
|
||||
$configuration = $task->getConfiguration();
|
||||
|
||||
if ($task->getTargetFile()->getTaskIdentifier() === ProcessedFile::CONTEXT_IMAGEPREVIEW) {
|
||||
$task->sanitizeConfiguration();
|
||||
// @todo: this transformation needs to happen in the PreviewTask, but if we do this,
|
||||
// all preview images would be re-created, so we should be careful when to do this.
|
||||
$configuration = $task->getConfiguration();
|
||||
$configuration['maxWidth'] = $configuration['width'];
|
||||
unset($configuration['width']);
|
||||
$configuration['maxHeight'] = $configuration['height'];
|
||||
unset($configuration['height']);
|
||||
}
|
||||
|
||||
$options = $configuration;
|
||||
if ($configuration['maxWidth'] ?? null) {
|
||||
$options['maxW'] = $configuration['maxWidth'];
|
||||
}
|
||||
if ($configuration['maxHeight'] ?? null) {
|
||||
$options['maxH'] = $configuration['maxHeight'];
|
||||
}
|
||||
if ($configuration['minWidth'] ?? null) {
|
||||
$options['minW'] = $configuration['minWidth'];
|
||||
}
|
||||
if ($configuration['minHeight'] ?? null) {
|
||||
$options['minH'] = $configuration['minHeight'];
|
||||
}
|
||||
if ($configuration['crop'] ?? null) {
|
||||
$options['crop'] = $configuration['crop'];
|
||||
if (is_string($configuration['crop'])) {
|
||||
// check if it is a json object
|
||||
$cropData = json_decode($configuration['crop']);
|
||||
if ($cropData) {
|
||||
$options['crop'] = new Area((float)$cropData->x, (float)$cropData->y, (float)$cropData->width, (float)$cropData->height);
|
||||
} else {
|
||||
[$offsetLeft, $offsetTop, $newWidth, $newHeight] = explode(',', $configuration['crop'], 4);
|
||||
$options['crop'] = new Area((float)$offsetLeft, (float)$offsetTop, (float)$newWidth, (float)$newHeight);
|
||||
}
|
||||
if ($options['crop']->isEmpty()) {
|
||||
unset($options['crop']);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($configuration['noScale'] ?? null) {
|
||||
$options['noScale'] = $configuration['noScale'];
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user