‪TYPO3CMS  ‪main
ImageProcessingInstructions.php
Go to the documentation of this file.
1 <?php
2 
3 declare(strict_types=1);
4 
5 /*
6  * This file is part of the TYPO3 CMS project.
7  *
8  * It is free software; you can redistribute it and/or modify it under
9  * the terms of the GNU General Public License, either version 2
10  * of the License, or any later version.
11  *
12  * For the full copyright and license information, please read the
13  * LICENSE.txt file that was distributed with this source code.
14  *
15  * The TYPO3 project - inspiring people to share!
16  */
17 
19 
24 
37 readonly class ImageProcessingInstructions
38 {
43  public function __construct(
44  public int $width = 0,
45  public int $height = 0,
46  public ?Area $cropArea = null,
47  ) {}
48 
49  public static function fromProcessingTask(TaskInterface $task): ImageProcessingInstructions
50  {
51  $config = self::getConfigurationForImageCropScaleMask($task);
52  $processedFile = $task->getTargetFile();
53  $isCropped = false;
54  if (($config['crop'] ?? null) instanceof Area) {
55  $isCropped = true;
56  $imageWidth = (int)round($config['crop']->getWidth());
57  $imageHeight = (int)round($config['crop']->getHeight());
58  } else {
59  $imageWidth = (int)$processedFile->getOriginalFile()->getProperty('width');
60  $imageHeight = (int)$processedFile->getOriginalFile()->getProperty('height');
61  }
62  if ($imageWidth <= 0 || $imageHeight <= 0) {
63  throw new ZeroImageDimensionException('Width and height of the image must be greater than zero.', 1597310560);
64  }
65  return ImageProcessingInstructions::fromCropScaleValues(
66  $imageWidth,
67  $imageHeight,
68  $config['width'] ?? '',
69  $config['height'] ?? '',
70  $config
71  );
72  }
73 
120  public static function fromCropScaleValues(int $incomingWidth, int $incomingHeight, int|string $width, int|string $height, array $options): self
121  {
122  $options = self::streamlineOptions($options);
123 
124  if ($incomingWidth === 0 || $incomingHeight === 0) {
125  // @todo incomingWidth/Height makes no sense, we should ideally throw an exception hereā€¦
126  // this code is here to make existing unit tests happy and should be dropped
127  return new self(
128  width: 0,
129  height: 0,
130  cropArea: null
131  );
132  }
133 
134  $cropArea = ($options['crop'] ?? null) instanceof Area ? $options['crop'] : new Area(0, 0, $incomingWidth, $incomingHeight);
135 
136  // If both the width and the height are set and one of the numbers is appended by an m, the proportions will
137  // be preserved and thus width and height are treated as maximum dimensions for the image. The image will be
138  // scaled to fit into the rectangle of the dimensions width and height.
139  $useWidthOrHeightAsMaximumLimits = str_contains($width . $height, 'm');
140  $useCropScaling = str_contains($width . $height, 'c');
141 
142  if ($useWidthOrHeightAsMaximumLimits && $useCropScaling) {
143  throw new \InvalidArgumentException('Cannot mix m and c modifiers for width/height', 1709840402);
144  }
145 
146  if ($useWidthOrHeightAsMaximumLimits) {
147  if (str_contains($width, 'm')) {
148  $options['maxWidth'] = min((int)$width, $options['maxWidth'] ?? PHP_INT_MAX);
149  // width: auto
150  $width = 0;
151  }
152  if (str_contains($height, 'm')) {
153  $options['maxHeight'] = min((int)$height, $options['maxHeight'] ?? PHP_INT_MAX);
154  // height: auto
155  $height = 0;
156  }
157  }
158 
159  if ((int)$width !== 0 && (int)$height !== 0 && $useCropScaling) {
160  $cropOffsetHorizontal = (int)substr((string)strstr((string)$width, 'c'), 1);
161  $cropOffsetVertical = (int)substr((string)strstr((string)$height, 'c'), 1);
162  $width = (int)$width;
163  $height = (int)$height;
164 
165  $cropArea = self::applyCropScaleToCropArea($cropArea, $width, $height, $cropOffsetVertical, $cropOffsetHorizontal);
166  }
167 
168  $width = (int)$width;
169  $height = (int)$height;
170 
171  if ($width > 0 && $height === 0) {
172  $height = (int)round($cropArea->getHeight() * ($width / $cropArea->getWidth()));
173  }
174  if ($height > 0 && $width === 0) {
175  $width = (int)round($cropArea->getWidth() * ($height / $cropArea->getHeight()));
176  }
177 
178  // If there are max-values...
179  if (!empty($options['maxWidth'])) {
180  if ($width > $options['maxWidth'] || ($width === 0 && $cropArea->getWidth() > $options['maxWidth'])) {
181  $width = $options['maxWidth'];
182  $height = (int)round($cropArea->getHeight() * ($width / $cropArea->getWidth()));
183  }
184  }
185  if (!empty($options['maxHeight'])) {
186  if ($height > $options['maxHeight'] || ($height === 0 && $cropArea->getHeight() > $options['maxHeight'])) {
187  $height = $options['maxHeight'];
188  $width = (int)round($cropArea->getWidth() * ($height / $cropArea->getHeight()));
189  }
190  }
191 
192  if (!empty($options['minWidth'])) {
193  if ($width < $options['minWidth'] || ($width === 0 && $cropArea->getWidth() < $options['minWidth'])) {
194  $width = $options['minWidth'];
195  $height = (int)round($cropArea->getHeight() * ($width / $cropArea->getWidth()));
196  }
197  }
198  if (!empty($options['minHeight'])) {
199  if ($height < $options['minHeight'] || ($height === 0 && $cropArea->getHeight() < $options['minHeight'])) {
200  $height = $options['minHeight'];
201  $width = (int)round($cropArea->getWidth() * ($height / $cropArea->getHeight()));
202  }
203  }
204 
205  if ($width === 0 && $height === 0) {
206  $width = (int)round($cropArea->getWidth());
207  $height = (int)round($cropArea->getHeight());
208  }
209  if ($width === 0 || $height === 0) {
210  throw new \LogicException('Image processing instructions did not resolve into coherent positive width and height values. This is a bug. Please report.', 1709806820);
211  }
212 
213  if (!(‪$GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_allowUpscaling'] ?? false)) {
214  if ($width > $cropArea->getWidth()) {
215  $width = (int)round($cropArea->getWidth());
216  $height = (int)round($cropArea->getHeight() * ($width / $cropArea->getWidth()));
217  }
218  if ($height > $cropArea->getHeight()) {
219  $height = (int)round($cropArea->getHeight());
220  $width = (int)round($cropArea->getWidth() * ($height / $cropArea->getHeight()));
221  }
222  }
223 
224  if ((int)$cropArea->getOffsetLeft() === 0 &&
225  (int)$cropArea->getOffsetTop() === 0 &&
226  (int)$cropArea->getWidth() === $incomingWidth &&
227  (int)$cropArea->getHeight() === $incomingHeight) {
228  $cropArea = null;
229  }
230 
231  return new self(
232  width: $width,
233  height: $height,
234  cropArea: $cropArea,
235  );
236  }
237 
245  private static function applyCropScaleToCropArea(
246  Area $cropArea,
247  int $width,
248  int $height,
249  int $cropOffsetVertical,
250  int $cropOffsetHorizontal
251  ): Area {
252  // @phpstan-ignore-next-line
253  if (!($width > 0 && $height > 0 && $cropArea->getWidth() > 0 && $cropArea->getHeight() > 0)) {
254  throw new \InvalidArgumentException('Apply crop scale must use concrete width and height', 1709810881);
255  }
256  $destRatio = $width / $height;
257  $cropRatio = $cropArea->getWidth() / $cropArea->getHeight();
258 
259  if ($destRatio > $cropRatio) {
260  $w = $cropArea->getWidth();
261  $h = $cropArea->getWidth() / $destRatio;
262  $x = $cropArea->getOffsetLeft();
263  $y = $cropArea->getOffsetTop() + (float)(($cropArea->getHeight() - $h) * ($cropOffsetVertical + 100) / 200);
264  } else {
265  $w = $cropArea->getHeight() * $destRatio;
266  $h = $cropArea->getHeight();
267  $x = $cropArea->getOffsetLeft() + (float)(($cropArea->getWidth() - $w) * ($cropOffsetHorizontal + 100) / 200);
268  $y = $cropArea->getOffsetTop();
269  }
270 
271  return new Area($x, $y, $w, $h);
272  }
273 
283  private static function streamlineOptions(array $options): array
284  {
285  if (isset($options['maxW'])) {
286  $options['maxWidth'] = $options['maxW'];
287  unset($options['maxW']);
288  }
289  if (isset($options['maxH'])) {
290  $options['maxHeight'] = $options['maxH'];
291  unset($options['maxH']);
292  }
293  if (isset($options['minW'])) {
294  $options['minWidth'] = $options['minW'];
295  unset($options['minW']);
296  }
297  if (isset($options['minH'])) {
298  $options['minHeight'] = $options['minH'];
299  unset($options['minH']);
300  }
301 
302  if (($options['maxWidth'] ?? null) <= 0) {
303  unset($options['maxWidth']);
304  }
305  if (($options['maxHeight'] ?? null) <= 0) {
306  unset($options['maxHeight']);
307  }
308  if (($options['minWidth'] ?? null) <= 0) {
309  unset($options['minWidth']);
310  }
311  if (($options['minHeight'] ?? null) <= 0) {
312  unset($options['minHeight']);
313  }
314 
315  if (isset($options['crop'])) {
316  if (is_string($options['crop'])) {
317  // check if it is a json object
318  $cropData = json_decode($options['crop']);
319  if ($cropData) {
320  // happens when $options['crop'] = '{"default":{"cropArea":{"x":0,"y":0,"width":1,"height":1},"selectedRatio":"NaN","focusArea":null}}'
321  if (!isset($cropData->x) || !isset($cropData->y) || !isset($cropData->width) || !isset($cropData->height)) {
322  unset($options['crop']);
323  } else {
324  $options['crop'] = new Area((float)$cropData->x, (float)$cropData->y, (float)$cropData->width, (float)$cropData->height);
325  }
326  } else {
327  [$offsetLeft, $offsetTop, $newWidth, $newHeight] = explode(',', $options['crop'], 4);
328  $options['crop'] = new Area((float)$offsetLeft, (float)$offsetTop, (float)$newWidth, (float)$newHeight);
329  }
330  if (isset($options['crop']) && $options['crop']->isEmpty()) {
331  unset($options['crop']);
332  }
333  } elseif (!$options['crop'] instanceof Area) {
334  unset($options['crop']);
335  }
336  }
337  return $options;
338  }
339 
354  private static function getConfigurationForImageCropScaleMask(TaskInterface $task): array
355  {
356  $configuration = $task->getConfiguration();
357 
358  if ($task->getTargetFile()->getTaskIdentifier() === ‪ProcessedFile::CONTEXT_IMAGEPREVIEW) {
359  $task->sanitizeConfiguration();
360  // @todo: this transformation needs to happen in the PreviewTask, but if we do this,
361  // all preview images would be re-created, so we should be careful when to do this.
362  $configuration = $task->getConfiguration();
363  $configuration['maxWidth'] = $configuration['width'];
364  unset($configuration['width']);
365  $configuration['maxHeight'] = $configuration['height'];
366  unset($configuration['height']);
367  }
368 
369  $options = $configuration;
370  if ($configuration['maxWidth'] ?? null) {
371  $options['maxW'] = $configuration['maxWidth'];
372  }
373  if ($configuration['maxHeight'] ?? null) {
374  $options['maxH'] = $configuration['maxHeight'];
375  }
376  if ($configuration['minWidth'] ?? null) {
377  $options['minW'] = $configuration['minWidth'];
378  }
379  if ($configuration['minHeight'] ?? null) {
380  $options['minH'] = $configuration['minHeight'];
381  }
382  if ($configuration['crop'] ?? null) {
383  $options['crop'] = $configuration['crop'];
384  if (is_string($configuration['crop'])) {
385  // check if it is a json object
386  $cropData = json_decode($configuration['crop']);
387  if ($cropData) {
388  $options['crop'] = new Area((float)$cropData->x, (float)$cropData->y, (float)$cropData->width, (float)$cropData->height);
389  } else {
390  [$offsetLeft, $offsetTop, $newWidth, $newHeight] = explode(',', $configuration['crop'], 4);
391  $options['crop'] = new Area((float)$offsetLeft, (float)$offsetTop, (float)$newWidth, (float)$newHeight);
392  }
393  if ($options['crop']->isEmpty()) {
394  unset($options['crop']);
395  }
396  }
397  }
398  if ($configuration['noScale'] ?? null) {
399  $options['noScale'] = $configuration['noScale'];
400  }
401 
402  return $options;
403  }
404 }
‪TYPO3\CMS\Core\Resource\ProcessedFile\CONTEXT_IMAGEPREVIEW
‪const CONTEXT_IMAGEPREVIEW
Definition: ProcessedFile.php:55
‪TYPO3\CMS\Core\Imaging
Definition: Dimension.php:16
‪TYPO3\CMS\Core\Imaging\Exception\ZeroImageDimensionException
Definition: ZeroImageDimensionException.php:26
‪TYPO3\CMS\Core\Resource\Processing\TaskInterface
Definition: TaskInterface.php:34
‪TYPO3\CMS\Core\Imaging\ImageManipulation\Area
Definition: Area.php:23
‪TYPO3\CMS\Core\Resource\ProcessedFile
Definition: ProcessedFile.php:47
‪$GLOBALS
‪$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['adminpanel']['modules']
Definition: ext_localconf.php:25