419 lines
19 KiB
PHP
419 lines
19 KiB
PHP
<?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;
|
|
}
|
|
}
|