[FEATURE] Add new imageManipulation supporting multiple crop variants 15/51515/24
authorHelmut Hummel <typo3@helhum.io>
Mon, 16 Jan 2017 19:11:23 +0000 (20:11 +0100)
committerAndreas Fernandez <typo3@scripting-base.de>
Tue, 7 Feb 2017 20:26:22 +0000 (21:26 +0100)
This feature extends the image cropping tool in the backend
so that editors can now not only select one crop area,
but multiple ones per image.

Within the crop are now also a focus are can be selected
and to preview areas that will be covered once the image
is rendered in the frontend one or more cover areas can be configured
to be shown inside the crop area.

This change also adds a format.json view helper and a view helper
to generate backend URIs that are used in the now fully Fluid rendered
imageManipulation element.

This is the TYPO3 integration part. TypeScript and CSS
will be done in a second commit.

Resolves: #75880
Releases: master
Change-Id: I646f0f0a149d05d1f3d8283bcc92ab09aede768e
Reviewed-on: https://review.typo3.org/51515
Tested-by: TYPO3com <no-reply@typo3.com>
Reviewed-by: Frans Saris <franssaris@gmail.com>
Tested-by: Frans Saris <franssaris@gmail.com>
Reviewed-by: Christian Kuhn <lolli@schwarzbu.ch>
Tested-by: Christian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Andreas Fernandez <typo3@scripting-base.de>
Tested-by: Andreas Fernandez <typo3@scripting-base.de>
22 files changed:
typo3/sysext/backend/Classes/Form/Element/ImageManipulationElement.php
typo3/sysext/backend/Classes/Form/Wizard/ImageManipulationWizard.php
typo3/sysext/backend/Resources/Private/Templates/ImageManipulation/ImageCropping.html [new file with mode: 0644]
typo3/sysext/backend/Resources/Private/Templates/Wizards/ImageManipulationWizard.html [deleted file]
typo3/sysext/core/Classes/Imaging/ImageManipulation/Area.php [new file with mode: 0644]
typo3/sysext/core/Classes/Imaging/ImageManipulation/CropVariant.php [new file with mode: 0644]
typo3/sysext/core/Classes/Imaging/ImageManipulation/CropVariantCollection.php [new file with mode: 0644]
typo3/sysext/core/Classes/Imaging/ImageManipulation/InvalidConfigurationException.php [new file with mode: 0644]
typo3/sysext/core/Classes/Imaging/ImageManipulation/Ratio.php [new file with mode: 0644]
typo3/sysext/core/Classes/Migrations/TcaMigration.php
typo3/sysext/core/Documentation/Changelog/master/Feature-75880-ImplementMultipleCroppingVariantsInImageManipulationTool.rst [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/Imaging/ImageManipulation/CropVariantCollectionTest.php [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/Imaging/ImageManipulation/CropVariantTest.php [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/Migrations/TcaMigrationTest.php
typo3/sysext/extbase/Classes/Service/ImageService.php
typo3/sysext/fluid/Classes/ViewHelpers/Format/JsonViewHelper.php [new file with mode: 0644]
typo3/sysext/fluid/Classes/ViewHelpers/ImageViewHelper.php
typo3/sysext/fluid/Classes/ViewHelpers/Uri/ImageViewHelper.php
typo3/sysext/fluid/Tests/Unit/ViewHelpers/ImageViewHelperTest.php
typo3/sysext/install/Classes/Updates/DatabaseRowsUpdateWizard.php
typo3/sysext/install/Classes/Updates/RowUpdater/ImageCropUpdater.php [new file with mode: 0644]
typo3/sysext/lang/Resources/Private/Language/locallang_wizards.xlf

index bcf866a..7be8917 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+declare(strict_types=1);
 namespace TYPO3\CMS\Backend\Form\Element;
 
 /*
@@ -14,13 +15,18 @@ namespace TYPO3\CMS\Backend\Form\Element;
  * The TYPO3 project - inspiring people to share!
  */
 
+use TYPO3\CMS\Backend\Form\NodeFactory;
 use TYPO3\CMS\Backend\Routing\UriBuilder;
+use TYPO3\CMS\Core\Imaging\ImageManipulation\Area;
+use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection;
+use TYPO3\CMS\Core\Imaging\ImageManipulation\InvalidConfigurationException;
 use TYPO3\CMS\Core\Resource\Exception\FileDoesNotExistException;
 use TYPO3\CMS\Core\Resource\File;
 use TYPO3\CMS\Core\Resource\ResourceFactory;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\CMS\Core\Utility\MathUtility;
 use TYPO3\CMS\Core\Utility\StringUtility;
+use TYPO3\CMS\Fluid\View\StandaloneView;
 
 /**
  * Generation of image manipulation FormEngine element.
@@ -29,6 +35,46 @@ use TYPO3\CMS\Core\Utility\StringUtility;
 class ImageManipulationElement extends AbstractFormElement
 {
     /**
+     * Default element configuration
+     *
+     * @var array
+     */
+    protected static $defaultConfig = [
+        'file_field' => 'uid_local',
+        'allowedExtensions' => null, // default: $GLOBALS['TYPO3_CONF_VARS']['GFX']['imagefile_ext']
+        'cropVariants' => [
+            'default' => [
+                'title' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.crop_variant.default',
+                'allowedAspectRatios' => [
+                    '16:9' => [
+                        'title' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.ratio.16_9',
+                        'value' => 16 / 9
+                    ],
+                    '4:3' => [
+                        'title' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.ratio.4_3',
+                        'value' => 4 / 3
+                    ],
+                    '1:1' => [
+                        'title' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.ratio.1_1',
+                        'value' => 1.0
+                    ],
+                    'NaN' => [
+                        'title' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.ratio.free',
+                        'value' => 0.0
+                    ],
+                ],
+                'selectedRatio' => 'NaN',
+                'cropArea' => [
+                    'x' => 0.0,
+                    'y' => 0.0,
+                    'width' => 1.0,
+                    'height' => 1.0,
+                ],
+            ],
+        ]
+    ];
+
+    /**
      * Default field wizards enabled for this element.
      *
      * @var array
@@ -52,163 +98,88 @@ class ImageManipulationElement extends AbstractFormElement
     ];
 
     /**
-     * Default element configuration
-     *
-     * @var array
+     * @var StandaloneView
      */
-    protected $defaultConfig = [
-        'file_field' => 'uid_local',
-        'enableZoom' => false,
-        'allowedExtensions' => null, // default: $GLOBALS['TYPO3_CONF_VARS']['GFX']['imagefile_ext']
-        'ratios' => [
-            '1.7777777777777777' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.ratio.16_9',
-            '1.3333333333333333' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.ratio.4_3',
-            '1' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.ratio.1_1',
-            'NaN' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.ratio.free',
-        ]
-    ];
+    protected $templateView;
+
+    /**
+     * @var UriBuilder
+     */
+    protected $uriBuilder;
+
+    /**
+     * @param NodeFactory $nodeFactory
+     * @param array $data
+     */
+    public function __construct(NodeFactory $nodeFactory, array $data)
+    {
+        parent::__construct($nodeFactory, $data);
+        // Would be great, if we could inject the view here, but since the constructor is in the interface, we can't
+        $this->templateView = GeneralUtility::makeInstance(StandaloneView::class);
+        $this->templateView->setTemplatePathAndFilename(GeneralUtility::getFileAbsFileName('EXT:backend/Resources/Private/Templates/ImageManipulation/ImageCropping.html'));
+        $this->uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
+    }
 
     /**
      * This will render an imageManipulation field
      *
      * @return array As defined in initializeResultArray() of AbstractNode
+     * @throws \TYPO3\CMS\Core\Imaging\ImageManipulation\InvalidConfigurationException
      */
     public function render()
     {
         $resultArray = $this->initializeResultArray();
-        $languageService = $this->getLanguageService();
-
-        $row = $this->data['databaseRow'];
         $parameterArray = $this->data['parameterArray'];
-
-        // If ratios are set do not add default options
-        if (isset($parameterArray['fieldConf']['config']['ratios'])) {
-            unset($this->defaultConfig['ratios']);
-        }
-        $config = array_replace_recursive($this->defaultConfig, $parameterArray['fieldConf']['config']);
-
-        // By default we allow all image extensions that can be handled by the GFX functionality
-        if ($config['allowedExtensions'] === null) {
-            $config['allowedExtensions'] = $GLOBALS['TYPO3_CONF_VARS']['GFX']['imagefile_ext'];
-        }
+        $config = $this->populateConfiguration($parameterArray['fieldConf']['config']);
 
         if ($config['readOnly']) {
-            $html = [];
-            $html[] = '<div class="t3js-formengine-field-item">';
-            $html[] =   '<div class="form-wizards-wrap">';
-            $html[] =       '<div class="form-wizards-element">';
-            $html[] =           htmlspecialchars($parameterArray['itemFormElValue']);
-            $html[] =       '</div>';
-            $html[] =   '</div>';
-            $html[] = '</div>';
-            $resultArray['html'] = implode(LF, $html);
-            return $resultArray;
+            $options = [];
+            $options['parameterArray'] = [
+                'fieldConf' => [
+                    'config' => $parameterArray['fieldConf']['config'],
+                ],
+                'itemFormElValue' => $parameterArray['itemFormElValue'],
+            ];
+            $options['renderType'] = 'none';
+
+            // Early return in case the field is set to read only
+            return $this->nodeFactory->create($options)->render();
         }
 
-        $file = $this->getFile($row, $config['file_field']);
+        $file = $this->getFile($this->data['databaseRow'], $config['file_field']);
         if (!$file) {
+            // Early return in case we do not find a file
             return $resultArray;
         }
 
-        $content = '';
-        $preview = '';
-        if (GeneralUtility::inList(strtolower($config['allowedExtensions']), strtolower($file->getExtension()))) {
-
-            // Get preview
-            $preview = $this->getPreview($file, $parameterArray['itemFormElValue']);
-
-            // Check if ratio labels hold translation strings
-            foreach ((array)$config['ratios'] as $ratio => $label) {
-                $config['ratios'][$ratio] = htmlspecialchars($languageService->sL($label));
-            }
-
-            $formFieldId = StringUtility::getUniqueId('formengine-image-manipulation-');
-            $wizardData = [
-                'zoom' => $config['enableZoom'] ? '1' : '0',
-                'ratios' => json_encode($config['ratios']),
-                'file' => $file->getUid(),
-            ];
-            $wizardData['token'] = GeneralUtility::hmac(implode('|', $wizardData), 'ImageManipulationWizard');
-
-            /** @var UriBuilder $uriBuilder */
-            $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
-            $buttonAttributes = [
-                'data-url' => $uriBuilder->buildUriFromRoute('ajax_wizard_image_manipulation', $wizardData),
-                'data-severity' => 'notice',
-                'data-image-name' => $file->getNameWithoutExtension(),
-                'data-image-uid' => $file->getUid(),
-                'data-file-field' => $config['file_field'],
-                'data-field' => $formFieldId,
-            ];
-
-            $button = '<button class="btn btn-default t3js-image-manipulation-trigger"';
-            $button .= GeneralUtility::implodeAttributes($buttonAttributes, true, true);
-            $button .= '><span class="t3-icon fa fa-crop"></span>';
-            $button .= htmlspecialchars($languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.open-editor'));
-            $button .= '</button>';
-
-            $attributes = [];
-            $attributes['type'] = 'hidden';
-            $attributes['id'] = $formFieldId;
-            $attributes['name'] = $parameterArray['itemFormElName'];
-            $attributes['value'] = $parameterArray['itemFormElValue'];
-
-            $evalList = GeneralUtility::trimExplode(',', $config['eval'], true);
-            if (in_array('required', $evalList, true)) {
-                $attributes['data-formengine-validation-rules'] = $this->getValidationDataAsJsonString(['required' => true]);
-            }
-
-            $inputField = '<input ' . GeneralUtility::implodeAttributes($attributes, true, true) . '" />';
-
-            $content .= $inputField . $button;
-
-            $content .= $this->getImageManipulationInfoTable($parameterArray['itemFormElValue']);
+        $config = $this->processConfiguration($config, $parameterArray['itemFormElValue'] ?? '{}');
+
+        $arguments = [
+            'isAllowedFileExtension' => in_array(strtolower($file->getExtension()), GeneralUtility::trimExplode(',', strtolower($config['allowedExtensions'])), true),
+            'image' => $file,
+            'formEngine' => [
+                'field' => [
+                    'value' => $parameterArray['itemFormElValue'],
+                    'name' => $parameterArray['itemFormElName']
+                ],
+                'validation' => '[]'
+            ],
+            'config' => $config,
+            'wizardUri' => $this->getWizardUri($config['cropVariants'], $file),
+            'previewUrl' => $this->getPreviewUrl($this->data['databaseRow'], $file),
+        ];
 
+        if ($arguments['isAllowedFileExtension']) {
             $resultArray['requireJsModules'][] = [
-                'TYPO3/CMS/Backend/ImageManipulation' => 'function(ImageManipulation){ImageManipulation.initializeTrigger()}'
+                'TYPO3/CMS/Backend/ImageManipulation' => 'function (ImageManipulation) {top.require(["cropper"], function() { ImageManipulation.initializeTrigger(); }); }'
             ];
+            $arguments['formEngine']['field']['id'] = StringUtility::getUniqueId('formengine-image-manipulation-');
+            if (GeneralUtility::inList($config['eval'], 'required')) {
+                $arguments['formEngine']['validation'] = $this->getValidationDataAsJsonString(['required' => true]);
+            }
         }
+        $resultArray['html'] = $this->templateView->renderSection('Element', $arguments);
 
-        $content .= '<p class="text-muted"><em>' . htmlspecialchars($languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.supported-types-message')) . '<br />';
-        $content .= strtoupper(implode(', ', GeneralUtility::trimExplode(',', $config['allowedExtensions'])));
-        $content .= '</em></p>';
-
-        $item = '<div class="media">';
-        $item .= $preview;
-        $item .= '<div class="media-body">' . $content . '</div>';
-        $item .= '</div>';
-
-        $fieldInformationResult = $this->renderFieldInformation();
-        $fieldInformationHtml = $fieldInformationResult['html'];
-        $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false);
-
-        $fieldControlResult = $this->renderFieldControl();
-        $fieldControlHtml = $fieldControlResult['html'];
-        $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldControlResult, false);
-
-        $fieldWizardResult = $this->renderFieldWizard();
-        $fieldWizardHtml = $fieldWizardResult['html'];
-        $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldWizardResult, false);
-
-        $html = [];
-        $html[] = '<div class="t3js-formengine-field-item">';
-        $html[] =   $fieldInformationHtml;
-        $html[] =   '<div class="form-wizards-wrap">';
-        $html[] =       '<div class="form-wizards-element">';
-        $html[] =           $item;
-        $html[] =       '</div>';
-        $html[] =      '<div class="form-wizards-items-aside">';
-        $html[] =          '<div class="btn-group">';
-        $html[] =              $fieldControlHtml;
-        $html[] =          '</div>';
-        $html[] =      '</div>';
-        $html[] =       '<div class="form-wizards-items-bottom">';
-        $html[] =           $fieldWizardHtml;
-        $html[] =       '</div>';
-        $html[] =   '</div>';
-        $html[] = '</div>';
-
-        $resultArray['html'] = $item;
         return $resultArray;
     }
 
@@ -217,7 +188,7 @@ class ImageManipulationElement extends AbstractFormElement
      *
      * @param array $row
      * @param string $fieldName
-     * @return NULL|\TYPO3\CMS\Core\Resource\File
+     * @return null|File
      */
     protected function getFile(array $row, $fieldName)
     {
@@ -237,79 +208,90 @@ class ImageManipulationElement extends AbstractFormElement
     }
 
     /**
-     * Get preview image if cropping is set
-     *
+     * @param array $databaseRow
      * @param File $file
-     * @param string $crop
      * @return string
      */
-    public function getPreview(File $file, $crop)
+    protected function getPreviewUrl(array $databaseRow, File $file): string
     {
-        $thumbnail = '';
-        $maxWidth = 150;
-        $maxHeight = 200;
-        if ($crop) {
-            $imageSetup = ['maxWidth' => $maxWidth, 'maxHeight' => $maxHeight, 'crop' => $crop];
-            $processedImage = $file->process(\TYPO3\CMS\Core\Resource\ProcessedFile::CONTEXT_IMAGECROPSCALEMASK, $imageSetup);
-            // Only use a thumbnail if the processing process was successful by checking if image width is set
-            if ($processedImage->getProperty('width')) {
-                $imageUrl = $processedImage->getPublicUrl(true);
-                $thumbnail = '<img src="' . $imageUrl . '" ' .
-                    'class="thumbnail thumbnail-status" ' .
-                    'width="' . $processedImage->getProperty('width') . '" ' .
-                    'height="' . $processedImage->getProperty('height') . '" >';
+        $previewUrl = '';
+        // Hook to generate a preview URL
+        if (isset($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['Backend/Form/Element/ImageManipulationElement']['previewUrl']) && is_array($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['Backend/Form/Element/ImageManipulationElement']['previewUrl'])) {
+            $hookParameters = [
+                'databaseRow' => $databaseRow,
+                'file' => $file,
+                'previewUrl' => $previewUrl,
+            ];
+            foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['Backend/Form/Element/ImageManipulationElement']['previewUrl'] as $listener) {
+                $previewUrl = GeneralUtility::callUserFunction($listener, $hookParameters, $this);
             }
         }
-
-        $preview = '<div class="media-left">';
-        $preview .= '<div class="t3js-image-manipulation-preview media-object' . ($thumbnail ? '' : ' hide') . '" ';
-        // Set preview width/height needed by cropper
-        $preview .= 'data-preview-width="' . $maxWidth . '" data-preview-height="' . $maxHeight . '">';
-        $preview .= $thumbnail;
-        $preview .= '</div></div>';
-
-        return $preview;
+        return $previewUrl;
     }
 
     /**
-     * Get image manipulation info table
-     *
-     * @param string $rawImageManipulationValue
-     * @return string
+     * @param array $baseConfiguration
+     * @return array
+     * @throws InvalidConfigurationException
      */
-    protected function getImageManipulationInfoTable($rawImageManipulationValue)
+    protected function populateConfiguration(array $baseConfiguration)
     {
-        $content = '';
-        $imageManipulation = null;
-        $x = $y = $width = $height = 0;
+        $defaultConfig = self::$defaultConfig;
+
+        // If ratios are set do not add default options
+        if (isset($baseConfiguration['cropVariants'])) {
+            unset($defaultConfig['cropVariants']);
+        }
+
+        $config = array_replace_recursive($defaultConfig, $baseConfiguration);
 
-        // Determine cropping values
-        if ($rawImageManipulationValue) {
-            $imageManipulation = json_decode($rawImageManipulationValue);
-            if (is_object($imageManipulation)) {
-                $x = (int)$imageManipulation->x;
-                $y = (int)$imageManipulation->y;
-                $width = (int)$imageManipulation->width;
-                $height = (int)$imageManipulation->height;
-            } else {
-                $imageManipulation = null;
+        if (!is_array($config['cropVariants'])) {
+            throw new InvalidConfigurationException('Crop variants configuration must be an array', 1485377267);
+        }
+
+        foreach ($config['cropVariants'] as &$cropVariant) {
+            // Enforce a crop area (default is full image)
+            if (empty($cropVariant['cropArea'])) {
+                $cropVariant['cropArea'] = Area::createEmpty()->asArray();
             }
         }
-        $languageService = $this->getLanguageService();
+        unset($cropVariant);
 
-        $content .= '<div class="table-fit-block table-spacer-wrap">';
-        $content .= '<table class="table table-no-borders t3js-image-manipulation-info' . ($imageManipulation === null ? ' hide' : '') . '">';
-        $content .= '<tr><td>' . htmlspecialchars($languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.crop-x')) . '</td>';
-        $content .= '<td class="t3js-image-manipulation-info-crop-x">' . $x . 'px</td></tr>';
-        $content .= '<tr><td>' . htmlspecialchars($languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.crop-y')) . '</td>';
-        $content .= '<td class="t3js-image-manipulation-info-crop-y">' . $y . 'px</td></tr>';
-        $content .= '<tr><td>' . htmlspecialchars($languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.crop-width')) . '</td>';
-        $content .= '<td class="t3js-image-manipulation-info-crop-width">' . $width . 'px</td></tr>';
-        $content .= '<tr><td>' . htmlspecialchars($languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.crop-height')) . '</td>';
-        $content .= '<td class="t3js-image-manipulation-info-crop-height">' . $height . 'px</td></tr>';
-        $content .= '</table>';
-        $content .= '</div>';
+        // By default we allow all image extensions that can be handled by the GFX functionality
+        if ($config['allowedExtensions'] === null) {
+            $config['allowedExtensions'] = $GLOBALS['TYPO3_CONF_VARS']['GFX']['imagefile_ext'];
+        }
+        return $config;
+    }
+
+    /**
+     * @param array $config
+     * @param string $elementValue
+     * @return array
+     * @throws \TYPO3\CMS\Core\Imaging\ImageManipulation\InvalidConfigurationException
+     */
+    protected function processConfiguration(array $config, string $elementValue)
+    {
+        $cropVariantCollection = CropVariantCollection::create($elementValue, $config['cropVariants']);
+        $config['cropVariants'] = $cropVariantCollection->asArray();
+        $config['allowedExtensions'] = implode(', ', GeneralUtility::trimExplode(',', $config['allowedExtensions'], true));
+        return $config;
+    }
 
-        return $content;
+    /**
+     * @param array $cropVariants
+     * @param File $image
+     * @return string
+     */
+    protected function getWizardUri(array $cropVariants, File $image): string
+    {
+        $routeName = 'ajax_wizard_image_manipulation';
+        $arguments = [
+            'cropVariants' => $cropVariants,
+            'image' => $image->getUid(),
+        ];
+        $uriArguments['arguments'] = json_encode($arguments);
+        $uriArguments['signature'] = GeneralUtility::hmac($uriArguments['arguments'], $routeName);
+        return (string)$this->uriBuilder->buildUriFromRoute($routeName, $uriArguments);
     }
 }
index 88a64b5..a176c5e 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+declare(strict_types=1);
 namespace TYPO3\CMS\Backend\Form\Wizard;
 
 /*
@@ -28,9 +29,21 @@ use TYPO3\CMS\Fluid\View\StandaloneView;
 class ImageManipulationWizard
 {
     /**
-     * @var string
+     * @var StandaloneView
      */
-    protected $templatePath = 'EXT:backend/Resources/Private/Templates/';
+    private $templateView;
+
+    /**
+     * @param StandaloneView $templateView
+     */
+    public function __construct(StandaloneView $templateView = null)
+    {
+        if (!$templateView) {
+            $templateView = GeneralUtility::makeInstance(StandaloneView::class);
+            $templateView->setTemplatePathAndFilename(GeneralUtility::getFileAbsFileName('EXT:backend/Resources/Private/Templates/ImageManipulation/ImageCropping.html'));
+        }
+        $this->templateView = $templateView;
+    }
 
     /**
      * Returns the HTML for the wizard inside the modal
@@ -41,9 +54,9 @@ class ImageManipulationWizard
      */
     public function getWizardAction(ServerRequestInterface $request, ResponseInterface $response)
     {
-        if ($this->isValidToken($request)) {
-            $queryParams = $request->getQueryParams();
-            $fileUid = isset($request->getParsedBody()['file']) ? $request->getParsedBody()['file'] : $queryParams['file'];
+        if ($this->isSignatureValid($request)) {
+            $queryParams = json_decode($request->getQueryParams()['arguments'], true);
+            $fileUid = $queryParams['image'];
             $image = null;
             if (MathUtility::canBeInterpretedAsInteger($fileUid)) {
                 try {
@@ -51,14 +64,13 @@ class ImageManipulationWizard
                 } catch (FileDoesNotExistException $e) {
                 }
             }
-
-            $view = $this->getFluidTemplateObject($this->templatePath . 'Wizards/ImageManipulationWizard.html');
-            $view->assign('image', $image);
-            $view->assign('zoom', (bool)$queryParams['zoom']);
-            $view->assign('ratios', $this->getAvailableRatios($request));
-            $content = $view->render();
-
+            $viewData = [
+                'image' => $image,
+                'cropVariants' => $queryParams['cropVariants']
+            ];
+            $content = $this->templateView->renderSection('Cropper', $viewData);
             $response->getBody()->write($content);
+
             return $response;
         } else {
             return $response->withStatus(403);
@@ -66,52 +78,14 @@ class ImageManipulationWizard
     }
 
     /**
-     * Check if hmac token is correct
+     * Check if hmac signature is correct
      *
      * @param ServerRequestInterface $request the request with the GET parameters
      * @return bool
      */
-    protected function isValidToken(ServerRequestInterface $request)
+    protected function isSignatureValid(ServerRequestInterface $request)
     {
-        $parameters = [
-            'zoom'   => $request->getQueryParams()['zoom'] ? '1' : '0',
-            'ratios' => $request->getQueryParams()['ratios'] ?: '',
-            'file'   => $request->getQueryParams()['file'] ?: '',
-        ];
-
-        $token = GeneralUtility::hmac(implode('|', $parameters), 'ImageManipulationWizard');
-        return $token === $request->getQueryParams()['token'];
-    }
-
-    /**
-     * Get available ratios
-     *
-     * @param ServerRequestInterface $request
-     * @return array
-     */
-    protected function getAvailableRatios(ServerRequestInterface $request)
-    {
-        $ratios = json_decode($request->getQueryParams()['ratios']);
-        // Json transforms an array with string keys to an array,
-        // we need to transform this to an array for the fluid ForViewHelper
-        if (is_object($ratios)) {
-            $ratios = get_object_vars($ratios);
-        }
-        return $ratios;
-    }
-
-    /**
-     * Returns a new standalone view, shorthand function
-     *
-     * @param string $templatePathAndFileName optional the path to set the template path and filename
-     * @return StandaloneView
-     */
-    protected function getFluidTemplateObject($templatePathAndFileName = null)
-    {
-        $view = GeneralUtility::makeInstance(StandaloneView::class);
-        if ($templatePathAndFileName) {
-            $view->setTemplatePathAndFilename(GeneralUtility::getFileAbsFileName($templatePathAndFileName));
-        }
-        return $view;
+        $token = GeneralUtility::hmac($request->getQueryParams()['arguments'], 'ajax_wizard_image_manipulation');
+        return $token === $request->getQueryParams()['signature'];
     }
 }
diff --git a/typo3/sysext/backend/Resources/Private/Templates/ImageManipulation/ImageCropping.html b/typo3/sysext/backend/Resources/Private/Templates/ImageManipulation/ImageCropping.html
new file mode 100644 (file)
index 0000000..7a2a03d
--- /dev/null
@@ -0,0 +1,183 @@
+<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
+       xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers">
+
+       <f:section name="Element">
+               <div class="media">
+                       <f:if condition="{isAllowedFileExtension}">
+                               <f:then>
+                                       <div class="media-left">
+                                               <f:for each="{config.cropVariants}" as="cropVariant">
+                                                       <div class="t3js-image-manipulation-preview media-object" data-preview-width="150" data-preview-height="200" data-crop-variant-id="{cropVariant.id}">
+                                                               <f:image image="{image}" crop="{formEngine.field.value}" cropVariant="{cropVariant.id}" maxWidth="150" maxHeight="200" class="thumbnail thumbnail-status" additionalAttributes="{data-crop-variant: '{cropVariant -> f:format.json()}', data-crop-variant-id: cropVariant.id}" />
+                                                       </div>
+                                               </f:for>
+                                       </div>
+                                       <div class="media-body">
+                                               <input type="hidden" id="{formEngine.field.id}" name="{formEngine.field.name}" value="{formEngine.field.value}" data-formengine-validation-rules="{formEngine.validation}" />
+                                               <button class="btn btn-default t3js-image-manipulation-trigger"
+                                                                               data-url="{wizardUri}"
+                                                                               data-preview-url="{previewUrl}"
+                                                                               data-severity="notice"
+                                                                               data-modal-title="{f:render(section: 'ModalTitle', arguments: _all)}"
+                                                                               data-image-uid="{image.uid}"
+                                                                               data-crop-variants="{config.cropVariants -> f:format.json()}"
+                                                                               data-file-field="{config.file_field}"
+                                                                               data-field="{formEngine.field.id}">
+                                                       <span class="t3-icon fa fa-crop"></span><f:translate id="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.open-editor" />
+                                               </button>
+                                               <f:if condition="{crop}" >
+                                                       <div class="table-fit-block table-spacer-wrap">
+                                                               <table class="table table-no-borders t3js-image-manipulation-info">
+                                                                       <tr>
+                                                                               <td><f:translate id="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.crop-width" /></td>
+                                                                               <td class="t3js-image-manipulation-info-crop-width">{crop.width}px</td>
+                                                                       </tr>
+                                                                       <tr>
+                                                                               <td><f:translate id="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.crop-height" /></td>
+                                                                               <td class="t3js-image-manipulation-info-crop-height">{crop.height}px</td>
+                                                                       </tr>
+                                                               </table>
+                                                       </div>
+                                               </f:if>
+                                       </div>
+                               </f:then>
+                               <f:else>
+                                       <div class="media-body">
+                                       <p><em>
+                                               <f:translate id="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.supported-types-message" />
+                                               <br/>
+                                               {config.allowedExtensions -> f:format.case(mode: 'upper')}
+                                       </em></p>
+                                       </div>
+                               </f:else>
+                       </f:if>
+               </div>
+       </f:section>
+       <f:section name="Cropper">
+               <f:if condition="{image.properties.width}">
+                       <f:then>
+                               <div class="modal-header">
+                                       <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span>
+                                       </button>
+                                       <h4 class="modal-title">
+                                               {f:render(section: 'ModalTitle', arguments: _all)}
+                                       </h4>
+                               </div>
+                               <div class="cropper modal-panel">
+                                       <div class="modal-panel-body">
+                                               <div class="cropper-image-container">
+                                                       <img id="t3js-crop-image" class="cropper-image-container-image"
+                                                                        src="{f:uri.image(image:image, maxWidth:'1000', maxHeight: '700')}"
+                                                                        data-original-width="{image.properties.width}" data-original-height="{image.properties.height}"/>
+                                               </div>
+                                       </div>
+                                       <div class="modal-panel-sidebar modal-panel-sidebar-right">
+                                               <div class="modal-body">
+                                                       <div class="panel-group" id="accordion-cropper-variants" role="tablist" aria-multiselectable="true">
+                                                               <f:for each="{cropVariants}" as="cropVariant" iteration="cropVariantIterator">
+                                                                       <div class="panel panel-default">
+                                                                               <div class="panel-heading" role="tab" id="cropper-accordion-heading-{cropVariantIterator.cycle}">
+                                                                                       <h4 class="panel-title">
+                                                                                               <a role="button" data-toggle="collapse" data-parent="#accordion-cropper-variants"
+                                                                                                        href="#cropper-collapse-{cropVariantIterator.cycle}"
+                                                                                                        aria-expanded="{f:if(condition:cropVariantIterator.isFirst, then:'true', else:'false')}"
+                                                                                                        aria-controls="cropper-collapse-{cropVariantIterator.cycle}"
+                                                                                                        class="t3js-crop-variant-trigger {f:if(condition:cropVariantIterator.isFirst, then:'is-active', else:'collapsed')}"
+                                                                                                        data-crop-variant-id="{cropVariant.id}"
+                                                                                                        data-crop-variant>
+                                                                                               <span><i class="fa fa-chevron-{f:if(condition:cropVariantIterator.isFirst, then:'up', else:'down')}"
+                                                                                                                                aria-hidden="true"></i> {cropVariant.title -> f:translate(id: cropVariant.title)}</span>
+                                                                                                       <div
+                                                                                                               class="cropper-preview-thumbnail {f:if(condition:'{image.properties.width}>{image.properties.height}', then:'wide', else: 'tall')}">
+                                                                                                               <img class="cropper-preview-thumbnail-image"
+                                                                                                                                src="{f:uri.image(image:image, maxWidth:'300', maxHeight: '300')}">
+                                                                                                               <div class="cropper-preview-thumbnail-crop-area t3js-cropper-preview-thumbnail-crop-area">
+                                                                                                                       <img src="{f:uri.image(image:image, maxWidth:'300', maxHeight: '300')}"
+                                                                                                                                        class="cropper-preview-thumbnail-crop-image t3js-cropper-preview-thumbnail-crop-image">
+                                                                                                                       <div class="cropper-preview-thumbnail-focus-area t3js-cropper-preview-thumbnail-focus-area"></div>
+                                                                                                               </div>
+                                                                                                       </div>
+                                                                                               </a>
+                                                                                       </h4>
+                                                                               </div>
+                                                                               <div id="cropper-collapse-{cropVariantIterator.cycle}"
+                                                                                                class="panel-collapse collapse {f:if(condition:cropVariantIterator.isFirst, then:'in')}"
+                                                                                                role="tabpanel"
+                                                                                                aria-labelledby="cropper-accordion-heading-{cropVariantIterator.cycle}">
+                                                                                       <div class="panel-body">
+                                                                                               <form class="form">
+                                                                                                       <div class="form-group">
+                                                                                                               <f:if condition="{cropVariant.allowedAspectRatios}">
+                                                                                                                       <label for="ratio-{cropVariantIterator.cycle}">
+                                                                                                                               <f:translate id="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.aspect-ratio"/>
+                                                                                                                       </label>
+                                                                                                                       <div id="ratio-{cropVariantIterator.cycle}" class="ratio-buttons t3js-ratio-buttons"
+                                                                                                                                        data-toggle="buttons">
+                                                                                                                               <f:for each="{cropVariant.allowedAspectRatios}" as="ratio" iteration="ratioIterator">
+                                                                                                                                       <label class="btn btn-secondary" data-method="setAspectRatio" data-option="{ratio.id}" title="{f:translate(id:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.set-aspect-ratio')}">
+                                                                                                                                               <input
+                                                                                                                                                       class="sr-only" id="aspectRatio-{cropVariantIterator.cycle}-{ratioIterator.cycle}"
+                                                                                                                                                       name="aspectRatio-{cropVariantIterator.cycle}-{ratioIterator.cycle}" value="{cropVariant.id}"
+                                                                                                                                                       type="radio">
+                                                                                                                                               <span>{ratio.title -> f:translate(id: ratio.title)}</span> <i class="fa fa-check"></i></label>
+                                                                                                                               </f:for>
+                                                                                                                       </div>
+                                                                                                               </f:if>
+                                                                                                               <label><f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.selection" /></label>
+                                                                                                               <div class="table-fit-block">
+                                                                                                                       <table class="table table-no-borders table-transparent">
+                                                                                                                               <tr>
+                                                                                                                                       <td class="t3js-cropper-info-crop"></td>
+                                                                                                                               </tr>
+                                                                                                                       </table>
+                                                                                                               </div>
+                                                                                                               <button class="btn btn-secondary" data-method="reset" data-crop-variant="{cropVariant -> f:format.json()}"
+                                                                                                                                               title="{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.reset')}">
+                                                                                                                       <i class="fa fa-refresh"></i>
+                                                                                                                       {f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.reset')}
+                                                                                                               </button>
+                                                                                                       </div>
+                                                                                               </form>
+                                                                                       </div>
+                                                                               </div>
+                                                                       </div>
+                                                               </f:for>
+                                                       </div>
+                                               </div>
+                                       </div>
+                               </div>
+                               <div class="modal-footer">
+                                       <button class="btn btn-default pull-left" data-method="preview" title="Preview"><i
+                                               class="fa fa-eye"></i>
+                                               Preview
+                                       </button>
+                                       <button class="btn btn-default" data-method="dismiss"
+                                                                       title="{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.cancel')}">
+                                               <i class="fa fa-remove"></i>
+                                               {f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.cancel')}
+                                       </button>
+                                       <button class="btn btn-primary" data-method="save"
+                                                                       title="{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.accept')}">
+                                               <i class="fa fa-check"></i>
+                                               {f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.accept')}
+                                       </button>
+                               </div>
+                       </f:then>
+                       <f:else>
+                               <div class="alert alert-danger">
+                                       <h4 class="alert-title">
+                                               <f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.no-image-found"/>
+                                       </h4>
+                                       <p class="alert-message">
+                                               <f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.no-image-found-message"/>
+                                       </p>
+                               </div>
+                       </f:else>
+               </f:if>
+       </f:section>
+       <f:section name="ModalTitle">
+               {f:translate(id: 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.image-manipulation')}
+               : {f:if(condition:image.properties.title, then:image.properties.title, else:image.name)}
+               ({image.properties.width} × {image.properties.height})
+       </f:section>
+</html>
diff --git a/typo3/sysext/backend/Resources/Private/Templates/Wizards/ImageManipulationWizard.html b/typo3/sysext/backend/Resources/Private/Templates/Wizards/ImageManipulationWizard.html
deleted file mode 100644 (file)
index cccf03a..0000000
+++ /dev/null
@@ -1,102 +0,0 @@
-
-<f:if condition="{image.properties.width}">
-       <f:then>
-               <div class="modal-panel">
-
-                       <div class="modal-panel-sidebar modal-panel-sidebar-right">
-                               <div class="modal-header">
-                                       <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
-                                       <h4 class="modal-title"><f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.image-manipulation" /></h4>
-                               </div>
-                               <div class="modal-body">
-                                       <form class="form">
-                                               <div class="form-group">
-                                                       <label><f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.image-title" />:</label>
-                                                       <p>{f:if(condition:image.properties.title, then:image.properties.title, else:image.name)}</p>
-                                               </div>
-                                               <div class="form-group">
-                                                       <label><f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.original-dimensions" />:</label>
-                                                       <p>{image.properties.width} × {image.properties.height}</p>
-                                               </div>
-
-                                               <f:if condition="{ratios}">
-                                               <div class="form-group">
-                                                       <label for="ratio"><f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.aspect-ratio" /></label>
-                                                       <div class="ratio-buttons t3js-ratio-buttons" data-toggle="buttons">
-                                                               <f:for each="{ratios}" as="ratio" key="key" iteration="iteration">
-                                                                       <label class="btn btn-default" data-method="setAspectRatio" data-option="{key}" title="{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.set-aspect-ratio')}"><input class="sr-only" id="aspectRatio{iteration.cycle}" name="aspectRatio" value="{key}" type="radio"> <span>{ratio}</span></label>
-                                                               </f:for>
-                                                       </div>
-                                               </div>
-                                               </f:if>
-
-                                               <f:if condition="{zoom}">
-                                               <div class="form-group t3js-setting-zoom">
-                                                       <label for="zoom"><f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.zoom" /></label>
-                                                       <div class="btn-group">
-                                                               <button class="btn btn-default" data-method="zoom" data-option="0.1" title="{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.zoom-in')}"><i class="fa fa-search-plus"></i></button>
-                                                               <button class="btn btn-default" data-method="zoom" data-option="-0.1" title="{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.zoom-out')}"><i class="fa fa-search-minus"></i></button>
-                                                       </div>
-                                               </div>
-                                               </f:if>
-
-                                               <div class="form-group">
-                                                       <label><f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.selection" /></label>
-                                                       <div class="table-fit-block">
-                                                               <table class="table table-no-borders table-transparent t3js-image-manipulation-info">
-                                                                       <tr>
-                                                                               <td>
-                                                                                       <f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.crop-x"/>
-                                                                               </td>
-                                                                               <td class="t3js-image-manipulation-info-crop-x"></td>
-                                                                       </tr>
-                                                                       <tr>
-                                                                               <td>
-                                                                                       <f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.crop-y"/>
-                                                                               </td>
-                                                                               <td class="t3js-image-manipulation-info-crop-y"></td>
-                                                                       </tr>
-                                                                       <tr>
-                                                                               <td>
-                                                                                       <f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.crop-width"/>
-                                                                               </td>
-                                                                               <td class="t3js-image-manipulation-info-crop-width"></td>
-                                                                       </tr>
-                                                                       <tr>
-                                                                               <td>
-                                                                                       <f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.crop-height"/>
-                                                                               </td>
-                                                                               <td class="t3js-image-manipulation-info-crop-height"></td>
-                                                                       </tr>
-                                                               </table>
-                                                       </div>
-                                                       <div class="form-group">
-                                                               <button class="btn btn-default" data-method="reset" title="{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.reset')}"><i class="fa fa-refresh"></i> {f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.reset')}</button>
-                                                       </div>
-                                               </div>
-
-                                       </form>
-                               </div>
-                               <div class="modal-footer">
-                                       <button class="btn btn-default" data-method="dismiss" title="{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.cancel')}"><i class="fa fa-remove"></i> {f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.cancel')}</button>
-                                       <button class="btn btn-default" data-method="save" title="{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.accept')}"><i class="fa fa-check"></i> {f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.accept')}</button>
-                               </div>
-                       </div>
-
-                       <div class="modal-panel-body">
-                               <div class="t3js-cropper-image-container">
-                                       <img src="{f:uri.image(image:image, maxWidth:'1000', maxHeight: '700')}"
-                                                data-original-width="{image.properties.width}" data-original-height="{image.properties.height}" />
-                               </div>
-                       </div>
-
-               </div>
-
-       </f:then>
-       <f:else>
-               <div class="alert alert-danger">
-                       <h4 class="alert-title"><f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.no-image-found" /></h4>
-                       <p class="alert-message"><f:translate key="LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.no-image-found-message" /></p>
-               </div>
-       </f:else>
-</f:if>
diff --git a/typo3/sysext/core/Classes/Imaging/ImageManipulation/Area.php b/typo3/sysext/core/Classes/Imaging/ImageManipulation/Area.php
new file mode 100644 (file)
index 0000000..e74af9b
--- /dev/null
@@ -0,0 +1,158 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Core\Imaging\ImageManipulation;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Resource\FileInterface;
+
+class Area
+{
+    /**
+     * @var float
+     */
+    protected $x;
+
+    /**
+     * @var float
+     */
+    protected $y;
+
+    /**
+     * @var float
+     */
+    protected $width;
+
+    /**
+     * @var float
+     */
+    protected $height;
+
+    /**
+     * @param float $x
+     * @param float $y
+     * @param float $width
+     * @param float $height
+     */
+    public function __construct(float $x, float $y, float $width, float $height)
+    {
+        $this->x = $x;
+        $this->y = $y;
+        $this->width = $width;
+        $this->height = $height;
+    }
+
+    /**
+     * @param array $config
+     * @return Area
+     * @throws InvalidConfigurationException
+     */
+    public static function createFromConfiguration(array $config): Area
+    {
+        try {
+            return new self(
+                $config['x'],
+                $config['y'],
+                $config['width'],
+                $config['height']
+            );
+        } catch (\Throwable $throwable) {
+            throw new InvalidConfigurationException(sprintf('Invalid type for area property given: %s', $throwable->getMessage()), 1485279226, $throwable);
+        }
+    }
+
+    /**
+     * @param array $config
+     * @return Area[]
+     * @throws InvalidConfigurationException
+     */
+    public static function createMultipleFromConfiguration(array $config): array
+    {
+        $areas = [];
+        foreach ($config as $areaConfig) {
+            $areas[] = self::createFromConfiguration($areaConfig);
+        }
+        return $areas;
+    }
+
+    /**
+     * @return Area
+     */
+    public static function createEmpty()
+    {
+        return new self(0.0, 0.0, 1.0, 1.0);
+    }
+
+    /**
+     * @return array
+     * @internal
+     */
+    public function asArray(): array
+    {
+        return [
+            'x' => $this->x,
+            'y' => $this->y,
+            'width' => $this->width,
+            'height' => $this->height,
+        ];
+    }
+
+    /**
+     * @param FileInterface $file
+     * @return Area
+     */
+    public function makeAbsoluteBasedOnFile(FileInterface $file)
+    {
+        return new self(
+            $this->x * $file->getProperty('width'),
+            $this->y * $file->getProperty('height'),
+            $this->width * $file->getProperty('width'),
+            $this->height * $file->getProperty('height')
+        );
+    }
+
+    /**
+     * @param FileInterface $file
+     * @return Area
+     */
+    public function makeRelativeBasedOnFile(FileInterface $file)
+    {
+        return new self(
+            $this->x / $file->getProperty('width'),
+            $this->y / $file->getProperty('height'),
+            $this->width / $file->getProperty('width'),
+            $this->height / $file->getProperty('height')
+        );
+    }
+
+    /**
+     * @return bool
+     */
+    public function isEmpty()
+    {
+        return $this->x === 0.0 && $this->y === 0.0 && $this->width === 1.0 && $this->height === 1.0;
+    }
+
+    /**
+     * @return string
+     */
+    public function __toString()
+    {
+        if ($this->isEmpty()) {
+            return '';
+        } else {
+            return json_encode($this->asArray());
+        }
+    }
+}
diff --git a/typo3/sysext/core/Classes/Imaging/ImageManipulation/CropVariant.php b/typo3/sysext/core/Classes/Imaging/ImageManipulation/CropVariant.php
new file mode 100644 (file)
index 0000000..f84ec4b
--- /dev/null
@@ -0,0 +1,205 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Core\Imaging\ImageManipulation;
+
+/*
+ * 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!
+ */
+
+class CropVariant
+{
+    /**
+     * @var string
+     */
+    protected $id;
+
+    /**
+     * @var string
+     */
+    protected $title;
+
+    /**
+     * @var Area
+     */
+    protected $cropArea;
+
+    /**
+     * @var Ratio[]
+     */
+    protected $allowedAspectRatios;
+
+    /**
+     * @var string
+     */
+    protected $selectedRatio;
+
+    /**
+     * @var Area|null
+     */
+    protected $focusArea;
+
+    /**
+     * @var Area[]|null
+     */
+    protected $coverAreas;
+
+    /**
+     * @param string $id
+     * @param string $title
+     * @param Area $cropArea
+     * @param Ratio[] $allowedAspectRatios
+     * @param string|null $selectedRatio
+     * @param Area|null $focusArea
+     * @param Area[]|null $coverAreas
+     * @throws InvalidConfigurationException
+     */
+    public function __construct(
+        string $id,
+        string $title,
+        Area $cropArea,
+        array $allowedAspectRatios = null,
+        string $selectedRatio = null,
+        Area $focusArea = null,
+        array $coverAreas = null
+    ) {
+        $this->id = $id;
+        $this->title = $title;
+        $this->cropArea = $cropArea;
+        if ($allowedAspectRatios) {
+            $this->setAllowedAspectRatios(...$allowedAspectRatios);
+        }
+        $this->selectedRatio = $selectedRatio;
+        $this->focusArea = $focusArea;
+        if ($coverAreas !== null) {
+            $this->setCoverAreas(...$coverAreas);
+        }
+    }
+
+    /**
+     * @param string $id
+     * @param array $config
+     * @return CropVariant
+     * @throws InvalidConfigurationException
+     */
+    public static function createFromConfiguration(string $id, array $config): CropVariant
+    {
+        try {
+            return new self(
+                $id,
+                $config['title'] ?? '',
+                Area::createFromConfiguration($config['cropArea']),
+                isset($config['allowedAspectRatios']) ? Ratio::createMultipleFromConfiguration($config['allowedAspectRatios']) : null,
+                $config['selectedRatio'] ?? null,
+                isset($config['focusArea']) ? Area::createFromConfiguration($config['focusArea']) : null,
+                isset($config['coverAreas']) ? Area::createMultipleFromConfiguration($config['coverAreas']) : null
+            );
+        } catch (\Throwable $throwable) {
+            throw new InvalidConfigurationException(sprintf('Invalid type in configuration for crop variant: %s', $throwable->getMessage()), 1485278693, $throwable);
+        }
+    }
+
+    /**
+     * @return array
+     * @internal
+     */
+    public function asArray(): array
+    {
+        $allowedAspectRatiosAsArray = [];
+        foreach ($this->allowedAspectRatios as $id => $allowedAspectRatio) {
+            $allowedAspectRatiosAsArray[$id] = $allowedAspectRatio->asArray();
+        }
+        if ($this->coverAreas !== null) {
+            $coverAreasAsArray = [];
+            foreach ($this->coverAreas as $coverArea) {
+                $coverAreasAsArray[] = $coverArea->asArray();
+            }
+        }
+        return [
+            'id' => $this->id,
+            'title' => $this->title,
+            'cropArea' => $this->cropArea->asArray(),
+            'allowedAspectRatios' => $allowedAspectRatiosAsArray,
+            'selectedRatio' => $this->selectedRatio,
+            'focusArea' => $this->focusArea ? $this->focusArea->asArray() : null,
+            'coverAreas' => $coverAreasAsArray ?? null,
+        ];
+    }
+
+    /**
+     * @return string
+     */
+    public function getId(): string
+    {
+        return $this->id;
+    }
+
+    /**
+     * @return Area
+     */
+    public function getCropArea(): Area
+    {
+        return $this->cropArea;
+    }
+
+    /**
+     * @return Area|null
+     */
+    public function getFocusArea()
+    {
+        return $this->focusArea;
+    }
+
+    /**
+     * @param Ratio[] $ratios
+     * @throws InvalidConfigurationException
+     */
+    protected function setAllowedAspectRatios(Ratio ...$ratios)
+    {
+        $this->allowedAspectRatios = [];
+        foreach ($ratios as $ratio) {
+            $this->addAllowedAspectRatio($ratio);
+        }
+    }
+
+    /**
+     * @param Ratio $ratio
+     * @throws InvalidConfigurationException
+     */
+    protected function addAllowedAspectRatio(Ratio $ratio)
+    {
+        if (isset($this->allowedAspectRatios[$ratio->getId()])) {
+            throw new InvalidConfigurationException(sprintf('Ratio with with duplicate ID (%s) is configured. Make sure all configured ratios have different ids.', $ratio->getId()), 1485274618);
+        }
+        $this->allowedAspectRatios[$ratio->getId()] = $ratio;
+    }
+
+    /**
+     * @param Area[] $areas
+     * @throws InvalidConfigurationException
+     */
+    protected function setCoverAreas(Area ...$areas)
+    {
+        $this->coverAreas = [];
+        foreach ($areas as $area) {
+            $this->addCoverArea($area);
+        }
+    }
+
+    /**
+     * @param Area $area
+     * @throws InvalidConfigurationException
+     */
+    protected function addCoverArea(Area $area)
+    {
+        $this->coverAreas[] = $area;
+    }
+}
diff --git a/typo3/sysext/core/Classes/Imaging/ImageManipulation/CropVariantCollection.php b/typo3/sysext/core/Classes/Imaging/ImageManipulation/CropVariantCollection.php
new file mode 100644 (file)
index 0000000..c60b868
--- /dev/null
@@ -0,0 +1,151 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Core\Imaging\ImageManipulation;
+
+/*
+ * 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!
+ */
+
+class CropVariantCollection
+{
+    /**
+     * @var CropVariant[]
+     */
+    protected $cropVariants;
+
+    /**
+     * @param CropVariant[] cropVariants
+     * @throws \TYPO3\CMS\Core\Imaging\ImageManipulation\InvalidConfigurationException
+     */
+    public function __construct(array $cropVariants)
+    {
+        $this->setCropVariants(...$cropVariants);
+    }
+
+    /**
+     * @param string $jsonString
+     * @param array $tcaConfig
+     * @return CropVariantCollection
+     */
+    public static function create(string $jsonString, array $tcaConfig = []): CropVariantCollection
+    {
+        if (empty($jsonString) && empty($tcaConfig)) {
+            return self::createEmpty();
+        }
+        $persistedCollectionConfig = json_decode($jsonString, true);
+        if (!is_array($persistedCollectionConfig)) {
+            $persistedCollectionConfig = [];
+        }
+        try {
+            if ($tcaConfig === []) {
+                $tcaConfig = $persistedCollectionConfig;
+            } else {
+                // Merge selected areas with crop tool configuration
+                reset($persistedCollectionConfig);
+                foreach ($tcaConfig as $id => &$cropVariantConfig) {
+                    if (!isset($persistedCollectionConfig[$id])) {
+                        $id = key($persistedCollectionConfig);
+                        next($persistedCollectionConfig);
+                    }
+                    if (isset($persistedCollectionConfig[$id]['cropArea'], $cropVariantConfig['cropArea'])) {
+                        $cropVariantConfig['cropArea'] = $persistedCollectionConfig[$id]['cropArea'];
+                    }
+                    if (isset($persistedCollectionConfig[$id]['focusArea'], $cropVariantConfig['focusArea'])) {
+                        $cropVariantConfig['focusArea'] = $persistedCollectionConfig[$id]['focusArea'];
+                    }
+                    if (isset($persistedCollectionConfig[$id]['selectedRatio'], $cropVariantConfig['selectedRatio'])) {
+                        $cropVariantConfig['selectedRatio'] = $persistedCollectionConfig[$id]['selectedRatio'];
+                    }
+                }
+                unset($cropVariantConfig);
+            }
+            $cropVariants = [];
+            foreach ($tcaConfig as $id => $cropVariantConfig) {
+                $cropVariants[] = CropVariant::createFromConfiguration($id, $cropVariantConfig);
+            }
+            return new self($cropVariants);
+        } catch (\Throwable $throwable) {
+            return self::createEmpty();
+        }
+    }
+
+    /**
+     * @return array
+     * @internal
+     */
+    public function asArray(): array
+    {
+        $cropVariantsAsArray = [];
+        foreach ($this->cropVariants as $id => $cropVariant) {
+            $cropVariantsAsArray[$id] = $cropVariant->asArray();
+        }
+        return $cropVariantsAsArray;
+    }
+
+    /**
+     * @param string $id
+     * @return Area
+     */
+    public function getCropArea(string $id = 'default'): Area
+    {
+        if (isset($this->cropVariants[$id])) {
+            return $this->cropVariants[$id]->getCropArea();
+        } else {
+            return Area::createEmpty();
+        }
+    }
+
+    /**
+     * @param string $id
+     * @return Area
+     */
+    public function getFocusArea(string $id = 'default'): Area
+    {
+        if (isset($this->cropVariants[$id]) && $this->cropVariants[$id]->getFocusArea() !== null) {
+            return $this->cropVariants[$id]->getFocusArea();
+        } else {
+            return Area::createEmpty();
+        }
+    }
+
+    /**
+     * @return CropVariantCollection
+     */
+    protected static function createEmpty(): CropVariantCollection
+    {
+        return new self([]);
+    }
+
+    /**
+     * @param CropVariant[] ...$cropVariants
+     * @throws \TYPO3\CMS\Core\Imaging\ImageManipulation\InvalidConfigurationException
+     */
+    protected function setCropVariants(CropVariant ...$cropVariants)
+    {
+        $this->cropVariants = [];
+        foreach ($cropVariants as $cropVariant) {
+            $this->addCropVariant($cropVariant);
+        }
+    }
+
+    /**
+     * @param CropVariant $cropVariant
+     * @throws InvalidConfigurationException
+     */
+    protected function addCropVariant(CropVariant $cropVariant)
+    {
+        if (isset($this->cropVariants[$cropVariant->getId()])) {
+            throw new InvalidConfigurationException(sprintf('Crop variant with with duplicate ID (%s) is configured. Make sure all configured cropVariants have different ids.', $cropVariant->getId()), 1485284352);
+        }
+        $this->cropVariants[$cropVariant->getId()] = $cropVariant;
+    }
+}
diff --git a/typo3/sysext/core/Classes/Imaging/ImageManipulation/InvalidConfigurationException.php b/typo3/sysext/core/Classes/Imaging/ImageManipulation/InvalidConfigurationException.php
new file mode 100644 (file)
index 0000000..ea38213
--- /dev/null
@@ -0,0 +1,23 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Core\Imaging\ImageManipulation;
+
+/*
+ * 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!
+ */
+
+/**
+ * Thrown when an invalid TCA configuration for the image manipulation is detected
+ */
+class InvalidConfigurationException extends \Exception
+{
+}
diff --git a/typo3/sysext/core/Classes/Imaging/ImageManipulation/Ratio.php b/typo3/sysext/core/Classes/Imaging/ImageManipulation/Ratio.php
new file mode 100644 (file)
index 0000000..e88f56a
--- /dev/null
@@ -0,0 +1,82 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Core\Imaging\ImageManipulation;
+
+/*
+ * 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!
+ */
+
+class Ratio
+{
+    /**
+     * @var string
+     */
+    protected $id;
+    /**
+     * @var string
+     */
+    protected $title;
+    /**
+     * @var float
+     */
+    protected $value;
+
+    public function __construct(string $id, string $title, float $value)
+    {
+        $this->id = $id;
+        $this->title = $title;
+        $this->value = $value;
+    }
+
+    /**
+     * @return string
+     */
+    public function getId(): string
+    {
+        return $this->id;
+    }
+
+    /**
+     * @param array $config
+     * @return Ratio[]
+     * @throws \TYPO3\CMS\Core\Imaging\ImageManipulation\InvalidConfigurationException
+     */
+    public static function createMultipleFromConfiguration(array $config): array
+    {
+        $areas = [];
+        try {
+            foreach ($config as $id => $ratioConfig) {
+                $areas[] = new self(
+                    $id,
+                    $ratioConfig['title'],
+                    $ratioConfig['value']
+                );
+            }
+        } catch (\Throwable $throwable) {
+            throw new InvalidConfigurationException(sprintf('Invalid type for ratio id given: %s', $throwable->getMessage()), 1486313971, $throwable);
+        }
+        return $areas;
+    }
+
+    /**
+     * @return array
+     * @internal
+     */
+    public function asArray(): array
+    {
+        return [
+            'id' => $this->id,
+            'title' => $this->title,
+            'value' => $this->value,
+        ];
+    }
+}
index 115f7c8..734301a 100644 (file)
@@ -82,6 +82,7 @@ class TcaMigration
         $tca = $this->migrateSuggestWizardTypeGroup($tca);
         $tca = $this->migrateOptionsOfTypeGroup($tca);
         $tca = $this->migrateSelectShowIconTable($tca);
+        $tca = $this->migrateImageManipulationConfig($tca);
         return $tca;
     }
 
@@ -2169,4 +2170,67 @@ class TcaMigration
 
         return $tca;
     }
+
+    /**
+     * Migrate imageManipulation "ratio" config to new "cropVraiant" config
+     *
+     * @param array $tca
+     * @return array
+     */
+    protected function migrateImageManipulationConfig(array $tca): array
+    {
+        foreach ($tca as $table => &$tableDefinition) {
+            if (isset($tableDefinition['columns']) && is_array($tableDefinition['columns'])) {
+                foreach ($tableDefinition['columns'] as $fieldName => &$fieldConfig) {
+                    if ($fieldConfig['config']['type'] === 'imageManipulation') {
+                        if (isset($fieldConfig['config']['enableZoom'])) {
+                            unset($fieldConfig['config']['enableZoom']);
+                            $this->messages[] = sprintf(
+                                'The config option "enableZoom" has been removed from TCA type "imageManipulation" in table "%s" and field "%s"',
+                                $table,
+                                $fieldName
+                            );
+                        }
+                        if (isset($fieldConfig['config']['ratios'])) {
+                            $legacyRatios = $fieldConfig['config']['ratios'];
+                            unset($fieldConfig['config']['ratios']);
+                            if (isset($fieldConfig['config']['cropVariants'])) {
+                                $this->messages[] = sprintf(
+                                    'The config option "ratios" has been deprecated and cannot be used together with the option "cropVariants" in table "%s" and field "%s"',
+                                    $table,
+                                    $fieldName
+                                );
+                                continue;
+                            }
+                            $fieldConfig['config']['cropVariants']['default'] = [
+                                'title' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.crop_variant.default',
+                                'allowedAspectRatios' => [],
+                                'cropArea' => [
+                                    'x' => 0.0,
+                                    'y' => 0.0,
+                                    'width' => 1.0,
+                                    'height' => 1.0,
+                                ],
+                            ];
+                            foreach ($legacyRatios as $ratio => $ratioLabel) {
+                                $ratio = (float)$ratio;
+                                $ratioId = number_format($ratio, 2);
+                                $fieldConfig['config']['cropVariants']['default']['allowedAspectRatios'][$ratioId] = [
+                                    'title' => $ratioLabel,
+                                    'value' => $ratio,
+                                ];
+                            }
+                            $this->messages[] = sprintf(
+                                'Migrated config option "ratios" of type "imageManipulation" to option "cropVariants" in table "%s" and field "%s"',
+                                $table,
+                                $fieldName
+                            );
+                        }
+                    }
+                }
+            }
+        }
+
+        return $tca;
+    }
 }
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-75880-ImplementMultipleCroppingVariantsInImageManipulationTool.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-75880-ImplementMultipleCroppingVariantsInImageManipulationTool.rst
new file mode 100644 (file)
index 0000000..81a429c
--- /dev/null
@@ -0,0 +1,144 @@
+.. include:: ../../Includes.txt
+
+=================================================================================
+Feature: #75880 - Implement multiple cropping variants in image manipulation tool
+=================================================================================
+
+See :issue:`75880`
+
+Description
+===========
+
+The imageManipulation TCA type is now capable to handle multiple crop variants if configured.
+
+The default configuration is to have only one variant with the same possible aspect ratios
+like in older TYPO3 versions.
+
+For that the TCA configuration has been extended.
+The following example configures two crop variants, one with the id "mobile",
+one with the id "desktop". The array key defines the crop variant id, which will be used
+when rendering an image with the image view helper.
+
+The allowed crop areas are now also configured differently.
+The array key is used as identifier for the ratio and the label is specified with the "title"
+and the actual (floating point) ratio with the "value" key.
+The value **must** be of PHP type float, not only a string.
+
+.. code-block:: php
+
+       'config' => [
+            'type' => 'imageManipulation',
+            'cropVariants' => [
+                'mobile' => [
+                    'title' => 'LLL:EXT:ext_key/Resources/Private/Language/locallang.xlf:imageManipulation.mobile',
+                    'allowedAspectRatios' => [
+                        '4:3' => [
+                            'title' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.ratio.4_3',
+                            'value' => 4 / 3
+                        ],
+                        'NaN' => [
+                            'title' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.ratio.free',
+                            'value' => 0.0
+                        ],
+                    ],
+                ],
+                'desktop' => [
+                    'title' => 'LLL:EXT:ext_key/Resources/Private/Language/locallang.xlf:imageManipulation.desktop',
+                    'allowedAspectRatios' => [
+                        '4:3' => [
+                            'title' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.ratio.4_3',
+                            'value' => 4 / 3
+                        ],
+                        'NaN' => [
+                            'title' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.ratio.free',
+                            'value' => 0.0
+                        ],
+                    ],
+                ],
+            ]
+        ]
+
+
+It is now also possible to define an initial crop area. If no initial crop area is defined, the default selected crop area will cover the complete image.
+Crop areas are defined relatively with floating point numbers. The x and y coordinates and width and height must be specified for that.
+The below example has an initial crop area in the size the previous image cropper provided by default.
+
+.. code-block:: php
+
+       'config' => [
+           'type' => 'imageManipulation',
+           'cropVariants' => [
+               'mobile' => [
+                   'title' => 'LLL:EXT:ext_key/Resources/Private/Language/locallang.xlf:imageManipulation.mobile',
+                   'cropArea' => [
+                       'x' => 0.1,
+                       'y' => 0.1,
+                       'width' => 0.8,
+                       'height' => 0.8,
+                   ],
+               ],
+           ],
+       ]
+
+Users can also select a focus area, when configured. The focus area is always **inside**
+the crop area and mark the area in the image which must be visible for the image to transport
+its meaning. The selected area is persisted to the database but will have no effect on image processing.
+The data points are however made available as data attribute when using the `<f:image />` view helper.
+
+The below example adds a focus area, which is initially one third of the size of the image
+and centered.
+
+.. code-block:: php
+
+       'config' => [
+           'type' => 'imageManipulation',
+           'cropVariants' => [
+               'mobile' => [
+                   'title' => 'LLL:EXT:ext_key/Resources/Private/Language/locallang.xlf:imageManipulation.mobile',
+                   'focusArea' => [
+                       'x' => 1 / 3,
+                       'y' => 1 / 3,
+                       'width' => 1 / 3,
+                       'height' => 1 / 3,
+                   ],
+               ],
+           ],
+       ]
+
+Very often images are used in a context, where there are overlayed with other DOM elements
+like a headline. To give editors a hint which area of the image is affected, when selecting a crop area,
+it is possible to define multiple so called cover areas. These areas are shown inside
+the crop area. The focus area cannot intersect with any of the cover areas.
+
+.. code-block:: php
+
+       'config' => [
+           'type' => 'imageManipulation',
+            'coverAreas' => [
+                [
+                    'x' => 0.05,
+                    'y' => 0.85,
+                    'width' => 0.9,
+                    'height' => 0.1,
+                ]
+            ],
+           ],
+       ]
+
+To render crop variants, the variants can be specified as argument to the image view helper:
+
+.. code-block:: html
+
+       <f:image image="{data.image}" cropVariant="mobile" width="800" />
+
+Impact
+======
+
+TCA configuration for field type "imageManipulation" has changed. Old configuration options
+still work but are deprecated and issue a warning when used.
+
+The TCA configuration option `enableZoom` has for now been removed. It wasn't really usable
+anyway and will need some proper UX design before re-implementation. Setting the option
+will have no effect.
+
+.. index:: Backend, TCA
diff --git a/typo3/sysext/core/Tests/Unit/Imaging/ImageManipulation/CropVariantCollectionTest.php b/typo3/sysext/core/Tests/Unit/Imaging/ImageManipulation/CropVariantCollectionTest.php
new file mode 100644 (file)
index 0000000..05a3f90
--- /dev/null
@@ -0,0 +1,114 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Core\Tests\Unit\Imaging\ImageManipulation;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Imaging\ImageManipulation\Area;
+use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariant;
+use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection;
+use TYPO3\CMS\Core\Imaging\ImageManipulation\InvalidConfigurationException;
+use TYPO3\Components\TestingFramework\Core\Unit\UnitTestCase;
+
+class CropVariantCollectionTest extends UnitTestCase
+{
+    /**
+     * @var array
+     */
+    private static $tca = [
+        'title' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.crop_variant.default',
+        'cropArea' => [
+            'x' => 0.0,
+            'y' => 0.0,
+            'width' => 1.0,
+            'height' => 1.0,
+        ],
+        'allowedAspectRatios' => [
+            '16:9' => [
+                'title' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.ratio.16_9',
+                'value' => 1.777777777777777
+            ],
+            '4:3' => [
+                'title' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.ratio.4_3',
+                'value' => 1.333333333333333
+            ],
+            '1:1' => [
+                'title' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.ratio.1_1',
+                'value' => 1.0
+            ],
+            'free' => [
+                'title' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.ratio.free',
+                'value' => 0.0
+            ],
+        ],
+        'selectedRatio' => '16:9',
+        'focusArea' => [
+            'x' => 0.4,
+            'y' => 0.4,
+            'width' => 0.6,
+            'height' => 0.6,
+        ],
+        'coverAreas' => [
+            [
+                'x' => 0.0,
+                'y' => 0.8,
+                'width' => 1.0,
+                'height' => 0.2,
+            ]
+        ],
+    ];
+
+    /**
+     * @test
+     */
+    public function createFromJsonWorks()
+    {
+        $cropVariant1 = self::$tca;
+        $cropVariant2 = self::$tca;
+        $cropVariantCollection = CropVariantCollection::create(json_encode(['default' => $cropVariant1, 'Second' => $cropVariant2]));
+        $this->assertInstanceOf(CropVariantCollection::class, $cropVariantCollection);
+
+        $assertSameValues = function ($expected, $actual) use (&$assertSameValues) {
+            if (is_array($expected)) {
+                foreach ($expected as $key => $value) {
+                    $this->assertArrayHasKey($key, $actual);
+                    $assertSameValues($expected[$key], $actual[$key]);
+                }
+            } else {
+                $this->assertSame($expected, $actual);
+            }
+        };
+        // assertSame does not work here, because the fuzz for float is not applied for array values
+        $assertSameValues(['default' => $cropVariant1, 'Second' => $cropVariant2], $cropVariantCollection->asArray());
+    }
+
+    /**
+     * @test
+     */
+    public function duplicateIdThrowsException()
+    {
+        $this->expectException(InvalidConfigurationException::class);
+        $cropVariant1 = new CropVariant('foo', 'title 1', new Area(0.0, 0.0, 1.0, 1.0));
+        $cropVariant2 = new CropVariant('foo', 'title 2', new Area(0.0, 0.0, 0.5, 0.5));
+        new CropVariantCollection([$cropVariant1, $cropVariant2]);
+    }
+
+    /**
+     * @test
+     */
+    public function createEmptyWorks()
+    {
+        $this->assertTrue(CropVariantCollection::create('')->getCropArea()->isEmpty());
+    }
+}
diff --git a/typo3/sysext/core/Tests/Unit/Imaging/ImageManipulation/CropVariantTest.php b/typo3/sysext/core/Tests/Unit/Imaging/ImageManipulation/CropVariantTest.php
new file mode 100644 (file)
index 0000000..6216f71
--- /dev/null
@@ -0,0 +1,111 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Core\Tests\Unit\Imaging\ImageManipulation;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariant;
+use TYPO3\CMS\Core\Imaging\ImageManipulation\InvalidConfigurationException;
+use TYPO3\Components\TestingFramework\Core\Unit\UnitTestCase;
+
+class CropVariantTest extends UnitTestCase
+{
+    /**
+     * @var array
+     */
+    private static $tca = [
+        'title' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.crop_variant.default',
+        'cropArea' => [
+            'x' => 0.0,
+            'y' => 0.0,
+            'width' => 1.0,
+            'height' => 1.0,
+        ],
+        'allowedAspectRatios' => [
+            '16:9' => [
+                'title' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.ratio.16_9',
+                'value' => 1.777777777777777
+            ],
+            '4:3' => [
+                'title' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.ratio.4_3',
+                'value' => 1.333333333333333
+            ],
+            '1:1' => [
+                'title' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.ratio.1_1',
+                'value' => 1.0
+            ],
+            'free' => [
+                'title' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.ratio.free',
+                'value' => 0.0
+            ],
+        ],
+        'selectedRatio' => '16:9',
+        'focusArea' => [
+            'x' => 0.4,
+            'y' => 0.4,
+            'width' => 0.6,
+            'height' => 0.6,
+        ],
+        'coverAreas' => [
+            [
+                'x' => 0.0,
+                'y' => 0.8,
+                'width' => 1.0,
+                'height' => 0.2,
+            ]
+        ],
+    ];
+
+    private static $expectedConfig = [];
+
+    public static function setUpBeforeClass()
+    {
+        parent::setUpBeforeClass();
+        self::$expectedConfig = array_merge(['id' => 'default'], self::$tca);
+        foreach (self::$expectedConfig['allowedAspectRatios'] as $id => &$allowedAspectRatio) {
+            $allowedAspectRatio = array_merge(['id' => $id], $allowedAspectRatio);
+        }
+    }
+
+    /**
+     * @test
+     */
+    public function createFromTcaWorks()
+    {
+        $cropVariant = CropVariant::createFromConfiguration(self::$expectedConfig['id'], self::$tca);
+        $this->assertInstanceOf(CropVariant::class, $cropVariant);
+        $this->assertSame(self::$expectedConfig, $cropVariant->asArray());
+    }
+
+    /**
+     * @test
+     */
+    public function selectedRatioCanBeNull()
+    {
+        $tca = self::$tca;
+        unset($tca['selectedRatio']);
+        $this->assertInstanceOf(CropVariant::class, CropVariant::createFromConfiguration(self::$expectedConfig['id'], $tca));
+    }
+
+    /**
+     * @test
+     */
+    public function throwsExceptionOnTypeMismatchInRatio()
+    {
+        $tca = self::$tca;
+        $this->expectException(InvalidConfigurationException::class);
+        $tca['allowedAspectRatios'][0]['value'] = '1.77777777';
+        CropVariant::createFromConfiguration(self::$expectedConfig['id'], $tca);
+    }
+}
index 5d58d72..3935d61 100644 (file)
@@ -5472,4 +5472,132 @@ class TcaMigrationTest extends \TYPO3\Components\TestingFramework\Core\Unit\Unit
     {
         $this->assertEquals($expected, (new TcaMigration())->migrate($input));
     }
+
+    /**
+     * @return array
+     */
+    public function migrateImageManipulationRatiosDataProvider()
+    {
+        return [
+            'enableZoom is removed' => [
+                [
+                    'aTable' => [
+                        'columns' => [
+                            'aField' => [
+                                'config' => [
+                                    'type' => 'imageManipulation',
+                                    'enableZoom' => true,
+                                ],
+                            ],
+                        ],
+                    ],
+                ],
+                [
+                    'aTable' => [
+                        'columns' => [
+                            'aField' => [
+                                'config' => [
+                                    'type' => 'imageManipulation',
+                                ],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+            'ratios migration ignored if cropVariants config is present' => [
+                [
+                    'aTable' => [
+                        'columns' => [
+                            'aField' => [
+                                'config' => [
+                                    'type' => 'imageManipulation',
+                                    'ratios' => [
+                                        4 / 3 => '4:3',
+                                    ],
+                                    'cropVariants' => [],
+                                ],
+                            ],
+                        ],
+                    ],
+                ],
+                [
+                    'aTable' => [
+                        'columns' => [
+                            'aField' => [
+                                'config' => [
+                                    'type' => 'imageManipulation',
+                                    'cropVariants' => [],
+                                ],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+            'ratios are migrated' => [
+                [
+                    'aTable' => [
+                        'columns' => [
+                            'aField' => [
+                                'config' => [
+                                    'type' => 'imageManipulation',
+                                    'ratios' => [
+                                        '1.3333333333333333' => '4:3',
+                                        '1.7777777777777777' => '16:9',
+                                        '1' => '1:1',
+                                    ],
+                                ],
+                            ],
+                        ],
+                    ],
+                ],
+                [
+                    'aTable' => [
+                        'columns' => [
+                            'aField' => [
+                                'config' => [
+                                    'type' => 'imageManipulation',
+                                    'cropVariants' => [
+                                        'default' => [
+                                            'title' => 'LLL:EXT:lang/Resources/Private/Language/locallang_wizards.xlf:imwizard.crop_variant.default',
+                                            'allowedAspectRatios' => [
+                                                '1.33' => [
+                                                    'title' => '4:3',
+                                                    'value' => 4 / 3,
+                                                ],
+                                                '1.78' => [
+                                                    'title' => '16:9',
+                                                    'value' => 16 / 9,
+                                                ],
+                                                '1.00' => [
+                                                    'title' => '1:1',
+                                                    'value' => 1.0,
+                                                ],
+                                            ],
+                                            'cropArea' => [
+                                                'x' => 0.0,
+                                                'y' => 0.0,
+                                                'width' => 1.0,
+                                                'height' => 1.0,
+                                            ],
+                                        ],
+                                    ],
+                                ],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * @param array $input
+     * @param array $expected
+     * @test
+     * @dataProvider migrateImageManipulationRatiosDataProvider
+     */
+    public function migrateImageManipulationRatios(array $input, array $expected)
+    {
+        $this->assertEquals($expected, (new TcaMigration())->migrate($input));
+    }
 }
index 9e5cfce..841f7ed 100644 (file)
@@ -55,7 +55,7 @@ class ImageService implements \TYPO3\CMS\Core\SingletonInterface
     /**
      * Create a processed file
      *
-     * @param File|FileReference $image
+     * @param FileInterface|FileReference $image
      * @param array $processingInstructions
      * @return ProcessedFile
      * @api
@@ -116,7 +116,7 @@ class ImageService implements \TYPO3\CMS\Core\SingletonInterface
      * @param string $src
      * @param mixed $image
      * @param bool $treatIdAsReference
-     * @return FileInterface
+     * @return FileInterface|FileReference
      * @throws \UnexpectedValueException
      * @internal
      */
diff --git a/typo3/sysext/fluid/Classes/ViewHelpers/Format/JsonViewHelper.php b/typo3/sysext/fluid/Classes/ViewHelpers/Format/JsonViewHelper.php
new file mode 100644 (file)
index 0000000..3b8d16e
--- /dev/null
@@ -0,0 +1,100 @@
+<?php
+namespace TYPO3\CMS\Fluid\ViewHelpers\Format;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It originated from the Neos.Form package (www.neos.io)
+ *
+ * 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!
+ */
+
+use TYPO3\CMS\Fluid\Core\ViewHelper\AbstractViewHelper;
+use TYPO3Fluid\Fluid\Core\Rendering\RenderingContextInterface;
+
+/**
+ * Wrapper for PHPs json_encode function.
+ *
+ * = Examples =
+ *
+ * <code title="encoding a view variable">
+ * {someArray -> f:format.json()}
+ * </code>
+ * <output>
+ * ["array","values"]
+ * // depending on the value of {someArray}
+ * </output>
+ *
+ * <code title="associative array">
+ * {f:format.json(value: {foo: 'bar', bar: 'baz'})}
+ * </code>
+ * <output>
+ * {"foo":"bar","bar":"baz"}
+ * </output>
+ *
+ * <code title="non-associative array with forced object">
+ * {f:format.json(value: {0: 'bar', 1: 'baz'}, forceObject: true)}
+ * </code>
+ * <output>
+ * {"0":"bar","1":"baz"}
+ * </output>
+ *
+ * @api
+ */
+class JsonViewHelper extends AbstractViewHelper
+{
+    /**
+     * @var bool
+     */
+    protected $escapeChildren = false;
+
+    public function initializeArguments()
+    {
+        $this->registerArgument('value', 'mixed', 'The incoming data to convert, or null if VH children should be used');
+        $this->registerArgument('forceObject', 'bool', 'Outputs an JSON object rather than an array', false, false);
+    }
+
+    /**
+     * Outputs content with its JSON representation. To prevent issues in HTML context, occurrences
+     * of greater-than or less-than characters are converted to their hexadecimal representations.
+     *
+     * If $forceObject is TRUE a JSON object is outputted even if the value is a non-associative array
+     * Example: array('foo', 'bar') as input will not be ["foo","bar"] but {"0":"foo","1":"bar"}
+     *
+     * @return string the JSON-encoded string.
+     * @see http://www.php.net/manual/en/function.json-encode.php
+     * @api
+     */
+    public function render()
+    {
+        return self::renderStatic($this->arguments, $this->buildRenderChildrenClosure(), $this->renderingContext);
+    }
+
+    /**
+     * Applies json_encode() on the specified value.
+     *
+     * @param array $arguments
+     * @param \Closure $renderChildrenClosure
+     * @param RenderingContextInterface $renderingContext
+     * @return string
+     */
+    public static function renderStatic(array $arguments, \Closure $renderChildrenClosure, RenderingContextInterface $renderingContext)
+    {
+        $value = $arguments['value'];
+        if ($value === null) {
+            $value = $renderChildrenClosure();
+        }
+        $options = JSON_HEX_TAG;
+        if ($arguments['forceObject'] !== false) {
+            $options = $options | JSON_FORCE_OBJECT;
+        }
+        return json_encode($value, $options);
+    }
+}
index ddac57c..e5d0681 100644 (file)
@@ -14,6 +14,7 @@ namespace TYPO3\CMS\Fluid\ViewHelpers;
  * Public License for more details.                                       *
  *                                                                        */
 
+use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection;
 use TYPO3\CMS\Core\Resource\Exception\ResourceDoesNotExistException;
 use TYPO3\CMS\Core\Resource\FileReference;
 
@@ -94,50 +95,59 @@ class ImageViewHelper extends \TYPO3\CMS\Fluid\Core\ViewHelper\AbstractTagBasedV
         $this->registerTagAttribute('ismap', 'string', 'Specifies an image as a server-side image-map. Rarely used. Look at usemap instead', false);
         $this->registerTagAttribute('longdesc', 'string', 'Specifies the URL to a document that contains a long description of an image', false);
         $this->registerTagAttribute('usemap', 'string', 'Specifies an image as a client-side image-map', false);
+
+        $this->registerArgument('src', 'string', 'a path to a file, a combined FAL identifier or an uid (int). If $treatIdAsReference is set, the integer is considered the uid of the sys_file_reference record. If you already got a FAL object, consider using the $image parameter instead');
+        $this->registerArgument('treatIdAsReference', 'bool', 'given src argument is a sys_file_reference record');
+        $this->registerArgument('image', 'object', 'a FAL object');
+        $this->registerArgument('crop', 'string|bool', 'overrule cropping of image (setting to FALSE disables the cropping set in FileReference)');
+        $this->registerArgument('cropVariant', 'string', 'select a cropping variant, in case multiple croppings have been specified or stored in FileReference', false, 'default');
+
+        $this->registerArgument('width', 'string', 'width of the image. This can be a numeric value representing the fixed width of the image in pixels. But you can also perform simple calculations by adding "m" or "c" to the value. See imgResource.width for possible options.');
+        $this->registerArgument('height', 'string', 'height of the image. This can be a numeric value representing the fixed height of the image in pixels. But you can also perform simple calculations by adding "m" or "c" to the value. See imgResource.width for possible options.');
+        $this->registerArgument('minWidth', 'int', 'minimum width of the image');
+        $this->registerArgument('minHeight', 'int', 'minimum width of the image');
+        $this->registerArgument('maxWidth', 'int', 'minimum width of the image');
+        $this->registerArgument('maxHeight', 'int', 'minimum width of the image');
+        $this->registerArgument('absolute', 'bool', 'Force absolute URL', false, false);
     }
 
     /**
      * Resizes a given image (if required) and renders the respective img tag
      *
      * @see https://docs.typo3.org/typo3cms/TyposcriptReference/ContentObjects/Image/
-     * @param string $src a path to a file, a combined FAL identifier or an uid (int). If $treatIdAsReference is set, the integer is considered the uid of the sys_file_reference record. If you already got a FAL object, consider using the $image parameter instead
-     * @param string $width width of the image. This can be a numeric value representing the fixed width of the image in pixels. But you can also perform simple calculations by adding "m" or "c" to the value. See imgResource.width for possible options.
-     * @param string $height height of the image. This can be a numeric value representing the fixed height of the image in pixels. But you can also perform simple calculations by adding "m" or "c" to the value. See imgResource.width for possible options.
-     * @param int $minWidth minimum width of the image
-     * @param int $minHeight minimum height of the image
-     * @param int $maxWidth maximum width of the image
-     * @param int $maxHeight maximum height of the image
-     * @param bool $treatIdAsReference given src argument is a sys_file_reference record
-     * @param object $image a FAL object
-     * @param string|bool $crop overrule cropping of image (setting to FALSE disables the cropping set in FileReference)
-     * @param bool $absolute Force absolute URL
      *
      * @throws \TYPO3\CMS\Fluid\Core\ViewHelper\Exception
      * @return string Rendered tag
      */
-    public function render($src = null, $width = null, $height = null, $minWidth = null, $minHeight = null, $maxWidth = null, $maxHeight = null, $treatIdAsReference = false, $image = null, $crop = null, $absolute = false)
+    public function render()
     {
-        if (is_null($src) && is_null($image) || !is_null($src) && !is_null($image)) {
+        if ((is_null($this->arguments['src']) && is_null($this->arguments['image'])) || (!is_null($this->arguments['src']) && !is_null($this->arguments['image']))) {
             throw new \TYPO3\CMS\Fluid\Core\ViewHelper\Exception('You must either specify a string src or a File object.', 1382284106);
         }
 
         try {
-            $image = $this->imageService->getImage($src, $image, $treatIdAsReference);
-            if ($crop === null) {
-                $crop = $image instanceof FileReference ? $image->getProperty('crop') : null;
+            $image = $this->imageService->getImage($this->arguments['src'], $this->arguments['image'], $this->arguments['treatIdAsReference']);
+            $cropString = $this->arguments['crop'];
+            if ($cropString === null && $image->hasProperty('crop') && $image->getProperty('crop')) {
+                $cropString = $image->getProperty('crop');
             }
+            $cropVariantCollection = CropVariantCollection::create((string)$cropString);
+            $cropVariant = $this->arguments['cropVariant'] ?: 'default';
             $processingInstructions = [
-                'width' => $width,
-                'height' => $height,
-                'minWidth' => $minWidth,
-                'minHeight' => $minHeight,
-                'maxWidth' => $maxWidth,
-                'maxHeight' => $maxHeight,
-                'crop' => $crop,
+                'width' => $this->arguments['width'],
+                'height' => $this->arguments['height'],
+                'minWidth' => $this->arguments['minWidth'],
+                'minHeight' => $this->arguments['minHeight'],
+                'maxWidth' => $this->arguments['maxWidth'],
+                'maxHeight' => $this->arguments['maxHeight'],
+                'crop' => $cropVariantCollection->getCropArea($cropVariant)->makeAbsoluteBasedOnFile($image),
             ];
             $processedImage = $this->imageService->applyProcessingInstructions($image, $processingInstructions);
-            $imageUri = $this->imageService->getImageUri($processedImage, $absolute);
+            $imageUri = $this->imageService->getImageUri($processedImage, $this->arguments['absolute']);
 
+            if (!$cropVariantCollection->getFocusArea($cropVariant)->isEmpty()) {
+                $this->tag->addAttribute('data-focus-area', $cropVariantCollection->getFocusArea($cropVariant)->makeAbsoluteBasedOnFile($image));
+            }
             $this->tag->addAttribute('src', $imageUri);
             $this->tag->addAttribute('width', $processedImage->getProperty('width'));
             $this->tag->addAttribute('height', $processedImage->getProperty('height'));
index 58e7fc0..b32c553 100644 (file)
@@ -14,6 +14,7 @@ namespace TYPO3\CMS\Fluid\ViewHelpers\Uri;
  * Public License for more details.                                       *
  *                                                                        */
 
+use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection;
 use TYPO3\CMS\Core\Resource\Exception\ResourceDoesNotExistException;
 use TYPO3\CMS\Core\Resource\FileReference;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -73,15 +74,17 @@ class ImageViewHelper extends AbstractViewHelper
     {
         parent::initializeArguments();
         $this->registerArgument('src', 'string', 'src');
+        $this->registerArgument('treatIdAsReference', 'bool', 'given src argument is a sys_file_reference record', false, false);
         $this->registerArgument('image', 'object', 'image');
+        $this->registerArgument('crop', 'string|bool', 'overrule cropping of image (setting to FALSE disables the cropping set in FileReference)');
+        $this->registerArgument('cropVariant', 'string', 'select a cropping variant, in case multiple croppings have been specified or stored in FileReference', false, 'default');
+
         $this->registerArgument('width', 'string', 'width of the image. This can be a numeric value representing the fixed width of the image in pixels. But you can also perform simple calculations by adding "m" or "c" to the value. See imgResource.width for possible options.');
         $this->registerArgument('height', 'string', 'height of the image. This can be a numeric value representing the fixed height of the image in pixels. But you can also perform simple calculations by adding "m" or "c" to the value. See imgResource.width for possible options.');
         $this->registerArgument('minWidth', 'int', 'minimum width of the image');
         $this->registerArgument('minHeight', 'int', 'minimum height of the image');
         $this->registerArgument('maxWidth', 'int', 'maximum width of the image');
         $this->registerArgument('maxHeight', 'int', 'maximum height of the image');
-        $this->registerArgument('treatIdAsReference', 'bool', 'given src argument is a sys_file_reference record', false, false);
-        $this->registerArgument('crop', 'string|bool', 'overrule cropping of image (setting to FALSE disables the cropping set in FileReference)');
         $this->registerArgument('absolute', 'bool', 'Force absolute URL', false, false);
     }
 
@@ -99,10 +102,10 @@ class ImageViewHelper extends AbstractViewHelper
         $src = $arguments['src'];
         $image = $arguments['image'];
         $treatIdAsReference = $arguments['treatIdAsReference'];
-        $crop = $arguments['crop'];
+        $cropString = $arguments['crop'];
         $absolute = $arguments['absolute'];
 
-        if (is_null($src) && is_null($image) || !is_null($src) && !is_null($image)) {
+        if ((is_null($src) && is_null($image)) || (!is_null($src) && !is_null($image))) {
             throw new Exception('You must either specify a string src or a File object.', 1460976233);
         }
 
@@ -110,10 +113,12 @@ class ImageViewHelper extends AbstractViewHelper
             $imageService = self::getImageService();
             $image = $imageService->getImage($src, $image, $treatIdAsReference);
 
-            if ($crop === null) {
-                $crop = $image instanceof FileReference ? $image->getProperty('crop') : null;
+            if ($cropString === null && $image->hasProperty('crop') && $image->getProperty('crop')) {
+                $cropString = $image->getProperty('crop');
             }
 
+            $cropVariantCollection = CropVariantCollection::create((string)$cropString);
+            $cropVariant = $arguments['cropVariant'] ?: 'default';
             $processingInstructions = [
                 'width' => $arguments['width'],
                 'height' => $arguments['height'],
@@ -121,8 +126,9 @@ class ImageViewHelper extends AbstractViewHelper
                 'minHeight' => $arguments['minHeight'],
                 'maxWidth' => $arguments['maxWidth'],
                 'maxHeight' => $arguments['maxHeight'],
-                'crop' => $crop,
+                'crop' => $cropVariantCollection->getCropArea($cropVariant)->makeAbsoluteBasedOnFile($image),
             ];
+
             $processedImage = $imageService->applyProcessingInstructions($image, $processingInstructions);
             return $imageService->getImageUri($processedImage, $absolute);
         } catch (ResourceDoesNotExistException $e) {
index 504509b..0ddb7a2 100644 (file)
@@ -14,60 +14,25 @@ namespace TYPO3\CMS\Fluid\Tests\Unit\ViewHelpers;
  * The TYPO3 project - inspiring people to share!
  */
 
+use TYPO3\CMS\Core\Resource\File;
 use TYPO3\CMS\Core\Resource\FileReference;
 use TYPO3\CMS\Extbase\Service\ImageService;
 use TYPO3\CMS\Fluid\ViewHelpers\ImageViewHelper;
-use TYPO3\Components\TestingFramework\Core\Unit\UnitTestCase;
+use TYPO3\Components\TestingFramework\Fluid\Unit\ViewHelpers\ViewHelperBaseTestcase;
 use TYPO3Fluid\Fluid\Core\ViewHelper\TagBuilder;
 
-/**
- * Test case
- */
-class ImageViewHelperTest extends UnitTestCase
+class ImageViewHelperTest extends ViewHelperBaseTestcase
 {
     /**
-     * @test
+     * @var ImageViewHelper
      */
-    public function registersExpectedArgumentsInInitializeArgumentsMethod()
-    {
-        $mock = $this->getMockBuilder(ImageViewHelper::class)
-            ->setMethods(['registerUniversalTagAttributes', 'registerTagAttribute'])
-            ->getMock();
-        $mock->expects($this->at(0))->method('registerUniversalTagAttributes');
-        $mock->expects($this->at(1))->method('registerTagAttribute')->with('alt', 'string', $this->anything(), false);
-        $mock->expects($this->at(2))->method('registerTagAttribute')->with('ismap', 'string', $this->anything(), false);
-        $mock->expects($this->at(3))->method('registerTagAttribute')->with('longdesc', 'string', $this->anything(), false);
-        $mock->expects($this->at(4))->method('registerTagAttribute')->with('usemap', 'string', $this->anything(), false);
-        $mock->initializeArguments();
-    }
+    protected $viewHelper;
 
-    /**
-     * @test
-     * @dataProvider getInvalidArguments
-     * @param array $arguments
-     */
-    public function renderMethodThrowsExceptionOnInvalidArguments(array $arguments)
+    protected function setUp()
     {
-        $mock = $this->getMockBuilder(ImageViewHelper::class)
-            ->setMethods(['dummy'])
-            ->getMock();
-        $mock->setArguments($arguments);
-
-        $this->expectException(\TYPO3\CMS\Fluid\Core\ViewHelper\Exception::class);
-        $this->expectExceptionCode(1382284106);
-
-        $mock->render(
-            isset($arguments['src']) ? $arguments['src'] : null,
-            isset($arguments['width']) ? $arguments['width'] : null,
-            isset($arguments['height']) ? $arguments['height'] : null,
-            isset($arguments['minWidth']) ? $arguments['minWidth'] : null,
-            isset($arguments['minHeight']) ? $arguments['minHeight'] : null,
-            isset($arguments['maxWidth']) ? $arguments['maxWidth'] : null,
-            isset($arguments['maxHeight']) ? $arguments['maxHeight'] : null,
-            isset($arguments['treatIdAsReference']) ? $arguments['treatIdAsReference'] : null,
-            isset($arguments['image']) ? $arguments['image'] : null,
-            isset($arguments['crop']) ? $arguments['crop'] : null
-        );
+        parent::setUp();
+        $this->viewHelper = new ImageViewHelper();
+        $this->injectDependenciesIntoViewHelper($this->viewHelper);
     }
 
     /**
@@ -84,53 +49,17 @@ class ImageViewHelperTest extends UnitTestCase
 
     /**
      * @test
-     * @dataProvider getRenderMethodTestValues
+     * @dataProvider getInvalidArguments
      * @param array $arguments
-     * @param array $expected
      */
-    public function renderMethodCreatesExpectedTag(array $arguments, array $expected)
+    public function renderMethodThrowsExceptionOnInvalidArguments(array $arguments)
     {
-        $image = $this->getMockBuilder(FileReference::class)
-            ->setMethods(['getProperty'])
-            ->disableOriginalConstructor()
-            ->getMock();
-        $image->expects($this->any())->method('getProperty')->willReturnMap([
-            ['width', $arguments['width']],
-            ['height', $arguments['height']],
-            ['alternative', 'alternative'],
-            ['title', 'title'],
-            ['crop', 'crop']
-        ]);
-        $imageService = $this->getMockBuilder(ImageService::class)
-            ->setMethods(['getImage', 'applyProcessingInstructions', 'getImageUri'])
-            ->getMock();
-        $imageService->expects($this->once())->method('getImage')->willReturn($image);
-        $imageService->expects($this->once())->method('applyProcessingInstructions')->with($image, $this->anything())->willReturn($image);
-        $imageService->expects($this->once())->method('getImageUri')->with($image)->willReturn('test.png');
-        $tagBuilder = $this->getMockBuilder(TagBuilder::class)
-            ->setMethods(['addAttribute', 'render'])
-            ->getMock();
-        $index = -1;
-        foreach ($expected as $expectedAttribute => $expectedValue) {
-            $tagBuilder->expects($this->at(++ $index))->method('addAttribute')->with($expectedAttribute, $expectedValue);
-        }
-        $tagBuilder->expects($this->once())->method('render');
-        $mock = $this->getAccessibleMock(ImageViewHelper::class, ['dummy'], [], '', false);
-        $mock->_set('imageService', $imageService);
-        $mock->_set('tag', $tagBuilder);
-        $mock->setArguments($arguments);
-        $mock->render(
-            isset($arguments['src']) ? $arguments['src'] : null,
-            isset($arguments['width']) ? $arguments['width'] : null,
-            isset($arguments['height']) ? $arguments['height'] : null,
-            isset($arguments['minWidth']) ? $arguments['minWidth'] : null,
-            isset($arguments['minHeight']) ? $arguments['minHeight'] : null,
-            isset($arguments['maxWidth']) ? $arguments['maxWidth'] : null,
-            isset($arguments['maxHeight']) ? $arguments['maxHeight'] : null,
-            isset($arguments['treatIdAsReference']) ? $arguments['treatIdAsReference'] : null,
-            isset($arguments['image']) ? $arguments['image'] : null,
-            isset($arguments['crop']) ? $arguments['crop'] : null
-        );
+        $this->setArgumentsUnderTest($this->viewHelper, $arguments);
+
+        $this->expectException(\TYPO3\CMS\Fluid\Core\ViewHelper\Exception::class);
+        $this->expectExceptionCode(1382284106);
+
+        $this->viewHelper->render();
     }
 
     /**
@@ -179,4 +108,53 @@ class ImageViewHelperTest extends UnitTestCase
             ],
         ];
     }
+
+    /**
+     * @test
+     * @dataProvider getRenderMethodTestValues
+     * @param array $arguments
+     * @param array $expected
+     */
+    public function renderMethodCreatesExpectedTag(array $arguments, array $expected)
+    {
+        $this->setArgumentsUnderTest($this->viewHelper, $arguments);
+
+        $image = $this->getMockBuilder(FileReference::class)
+            ->setMethods(['getProperty'])
+            ->disableOriginalConstructor()
+            ->getMock();
+        $image->expects($this->any())->method('getProperty')->willReturnMap([
+            ['width', $arguments['width']],
+            ['height', $arguments['height']],
+            ['alternative', 'alternative'],
+            ['title', 'title'],
+            ['crop', 'crop']
+        ]);
+        $originalFile = $this->getMockBuilder(File::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $originalFile->expects($this->any())->method('getProperties')->willReturn([]);
+        $this->inject($image, 'originalFile', $originalFile);
+        $this->inject($image, 'propertiesOfFileReference', []);
+        $imageService = $this->getMockBuilder(ImageService::class)
+            ->setMethods(['getImage', 'applyProcessingInstructions', 'getImageUri'])
+            ->getMock();
+        $imageService->expects($this->once())->method('getImage')->willReturn($image);
+        $imageService->expects($this->once())->method('applyProcessingInstructions')->with($image, $this->anything())->willReturn($image);
+        $imageService->expects($this->once())->method('getImageUri')->with($image)->willReturn('test.png');
+
+        $this->inject($this->viewHelper, 'imageService', $imageService);
+
+        $tagBuilder = $this->getMockBuilder(TagBuilder::class)
+            ->setMethods(['addAttribute', 'render'])
+            ->getMock();
+        $index = -1;
+        foreach ($expected as $expectedAttribute => $expectedValue) {
+            $tagBuilder->expects($this->at(++ $index))->method('addAttribute')->with($expectedAttribute, $expectedValue);
+        }
+        $tagBuilder->expects($this->once())->method('render');
+        $this->inject($this->viewHelper, 'tag', $tagBuilder);
+
+        $this->viewHelper->render();
+    }
 }
index eaa9da2..595f74f 100644 (file)
@@ -17,6 +17,7 @@ namespace TYPO3\CMS\Install\Updates;
 use TYPO3\CMS\Core\Database\ConnectionPool;
 use TYPO3\CMS\Core\Registry;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Install\Updates\RowUpdater\ImageCropUpdater;
 use TYPO3\CMS\Install\Updates\RowUpdater\L10nModeUpdater;
 use TYPO3\CMS\Install\Updates\RowUpdater\RowUpdaterInterface;
 
@@ -49,6 +50,7 @@ class DatabaseRowsUpdateWizard extends AbstractUpdate
      */
     protected $rowUpdater = [
 //        L10nModeUpdater::class,
+        ImageCropUpdater::class,
     ];
 
     /**
diff --git a/typo3/sysext/install/Classes/Updates/RowUpdater/ImageCropUpdater.php b/typo3/sysext/install/Classes/Updates/RowUpdater/ImageCropUpdater.php
new file mode 100644 (file)
index 0000000..624733c
--- /dev/null
@@ -0,0 +1,202 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Install\Updates\RowUpdater;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Imaging\ImageManipulation\Area;
+use TYPO3\CMS\Core\Database\ConnectionPool;
+use TYPO3\CMS\Core\Resource\Exception\FileDoesNotExistException;
+use TYPO3\CMS\Core\Resource\ResourceFactory;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Core\Utility\MathUtility;
+use TYPO3\CMS\Install\Service\LoadTcaService;
+
+/**
+ * Migrate values for database records having columns
+ * using "l10n_mode" set to "mergeIfNotBlank".
+ */
+class ImageCropUpdater implements RowUpdaterInterface
+{
+    /**
+     * List of tables with information about to migrate fields.
+     * Created during hasPotentialUpdateForTable(), used in updateTableRow()
+     *
+     * @var array
+     */
+    protected $payload = [];
+
+    /**
+     * Get title
+     *
+     * @return string
+     */
+    public function getTitle(): string
+    {
+        return 'Migrate values in sys_file_reference crop field';
+    }
+
+    /**
+     * Return true if a table needs modifications.
+     *
+     * @param string $tableName Table name to check
+     * @return bool True if this table has fields to migrate
+     */
+    public function hasPotentialUpdateForTable(string $tableName): bool
+    {
+        $result = false;
+        $payload = $this->getPayloadForTable($tableName);
+        if (count($payload) !== 0) {
+            $this->payload[$tableName] = $payload;
+            $result = true;
+        }
+        return $result;
+    }
+
+    /**
+     * Update single row if needed
+     *
+     * @param string $tableName
+     * @param array $inputRow Given row data
+     * @return array Modified row data
+     */
+    public function updateTableRow(string $tableName, array $inputRow): array
+    {
+        $tablePayload = $this->payload[$tableName];
+
+        foreach ($tablePayload['fields'] as $field) {
+            if (strpos($inputRow[$field], '{"x":') === 0) {
+                $file = $this->getFile($inputRow, $tablePayload['fileReferenceField'] ?: 'uid_local');
+                $cropArea = Area::createFromConfiguration(json_decode($inputRow[$field], true));
+                $cropVariantCollectionConfig = [
+                    'default' => [
+                        'cropArea' => $cropArea->makeRelativeBasedOnFile($file)->asArray(),
+                    ]
+                ];
+                $inputRow[$field] = json_encode($cropVariantCollectionConfig);
+            }
+        }
+
+        return $inputRow;
+    }
+
+    /**
+     * Retrieves field names grouped per table name having "l10n_mode" set
+     * to a relevant value that shall be migrated in database records.
+     *
+     * Resulting array is structured like this:
+     * + fields: [field a, field b, ...]
+     * + sources
+     *   + source uid: [localization uid, localization uid, ...]
+     *
+     * @param string $tableName Table name
+     * @return array Payload information for this table
+     * @throws \RuntimeException
+     */
+    protected function getPayloadForTable(string $tableName): array
+    {
+        $loadTcaService = GeneralUtility::makeInstance(LoadTcaService::class);
+        $loadTcaService->loadExtensionTablesWithoutMigration();
+        if (!is_array($GLOBALS['TCA'][$tableName])) {
+            throw new \RuntimeException(
+                'Globals TCA of given table name must exist',
+                1485386982
+            );
+        }
+        $tableDefinition = $GLOBALS['TCA'][$tableName];
+
+        if (
+            empty($tableDefinition['columns'])
+            || !is_array($tableDefinition['columns'])
+        ) {
+            return [];
+        }
+
+        $fields = [];
+        $fileReferenceField = null;
+        foreach ($tableDefinition['columns'] as $fieldName => $fieldConfiguration) {
+            if (
+                !empty($fieldConfiguration['config']['type'])
+                && $fieldConfiguration['config']['type'] === 'group'
+                && !empty($fieldConfiguration['config']['internal_type'])
+                && $fieldConfiguration['config']['internal_type'] === 'db'
+                && !empty($fieldConfiguration['config']['allowed'])
+                && $fieldConfiguration['config']['allowed'] === 'sys_file'
+            ) {
+                $fileReferenceField = $fieldName;
+            }
+            if (
+                !empty($fieldConfiguration['config']['type'])
+                && $fieldConfiguration['config']['type'] === 'imageManipulation'
+            ) {
+                $fields[] = $fieldName;
+            }
+        }
+
+        if (empty($fields)) {
+            return [];
+        }
+
+        $payload = [];
+        $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
+
+        foreach ($fields as $fieldName) {
+            $queryBuilder = $connectionPool->getQueryBuilderForTable($tableName);
+            $queryBuilder->getRestrictions()->removeAll();
+
+            $query = $queryBuilder
+                ->from($tableName)
+                ->count($fieldName)
+                ->where(
+                    $queryBuilder->expr()->like(
+                        $fieldName,
+                        $queryBuilder->createNamedParameter('{"x":%', \PDO::PARAM_STR)
+                    )
+                );
+            if ((int)$query->execute()->fetchColumn(0) > 0) {
+                $payload['fields'][] = $fieldName;
+                if (isset($fileReferenceField)) {
+                    $payload['fileReferenceField'] = $fileReferenceField;
+                } else {
+                    $payload['fileReferenceField'] = null;
+                }
+            }
+        }
+        return $payload;
+    }
+
+    /**
+     * Get file object
+     *
+     * @param array $row
+     * @param string $fieldName
+     * @return null|\TYPO3\CMS\Core\Resource\File
+     */
+    private function getFile(array $row, $fieldName)
+    {
+        $file = null;
+        $fileUid = !empty($row[$fieldName]) ? $row[$fieldName] : null;
+        if (is_array($fileUid) && isset($fileUid[0]['uid'])) {
+            $fileUid = $fileUid[0]['uid'];
+        }
+        if (MathUtility::canBeInterpretedAsInteger($fileUid)) {
+            try {
+                $file = ResourceFactory::getInstance()->getFileObject($fileUid);
+            } catch (FileDoesNotExistException $e) {
+            } catch (\InvalidArgumentException $e) {
+            }
+        }
+        return $file;
+    }
+}
index d4a6b3e..b153e0c 100644 (file)
                        <trans-unit id="imwizard.ratio.free">
                                <source>Free</source>
                        </trans-unit>
-                       <trans-unit id="imwizard.zoom">
-                               <source>Zoom</source>
-                       </trans-unit>
-                       <trans-unit id="imwizard.zoom-in">
-                               <source>Zoom in</source>
-                       </trans-unit>
-                       <trans-unit id="imwizard.zoom-out">
-                               <source>Zoom out</source>
+                       <trans-unit id="imwizard.crop_variant.default">
+                               <source>Default</source>
                        </trans-unit>
                        <trans-unit id="imwizard.selection">
                                <source>Selected Size</source>
                        </trans-unit>
-                       <trans-unit id="imwizard.crop-x">
-                               <source>x:</source>
-                       </trans-unit>
-                       <trans-unit id="imwizard.crop-y">
-                               <source>y:</source>
-                       </trans-unit>
                        <trans-unit id="imwizard.crop-width">
                                <source>width:</source>
                        </trans-unit>