From 747b9bb113af2a087edd6615596ebdd224058d21 Mon Sep 17 00:00:00 2001 From: Garvin Hicking <gh@faktor-e.de> Date: Wed, 23 Aug 2023 19:28:01 +0200 Subject: [PATCH] [FEATURE] Crop SVG images natively This change adds support for SVGs to be processed. They can be scaled and cropped, without them being rasterized (converted to pixel formats like PNG). This way, SVGs are now natively used for cropping and scaling without quality loss. All cropping operations on SVGs will create a processedFile variant, just like their pixel counterparts. The processed files will contain SVG wrappers to reset coordinate systems and apply the cropped viewBox. This way, responsive styling can be applied to generated images, and processed SVGs can also be used as background images via <f:uri.image>. Tests are added for the fluid ViewHelpers as well as TypoScript- based rendering of assets. Note that SVG cropping is solely based on the "crop" argument, either passed to a viewHelper or inferred via the sys_file_reference entry, that the image editor creates. Other arguments like `width="100c-10"` which are used for pixel cropping are not evaluated (because they rely on pixel operations). maxWidth and maxHeight attributes (like used in the backend) will accordingly force a specific size for the <img> tag and the inner SVG width/height specification. Browsers can still override this via CSS to enforce lossless resizing. This change preserves the possibility to rasterize a SVG to PNG by setting a file extension explicitly. (Remember, not all image processors support converting SVG to pixel formats; GraphicsMagick should work, ImageMagick needs additional helpers.) Example to keep PNG output format via TypoScript: ``` 10 = IMAGE 10.file = 2:/myfile.svg 10.file.crop = 20,20,500,500 10.file.ext = png ``` or via Fluid: ``` <f:image image="{image}" fileExtension="png" /> ``` Resolves: #93942 Releases: main Change-Id: Ibac79b14738294252b527e66eefa91cccac4e5b8 Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/80617 Tested-by: Benni Mack <benni@typo3.org> Tested-by: Benjamin Franzke <ben@bnf.dev> Reviewed-by: Benjamin Franzke <ben@bnf.dev> Reviewed-by: Benni Mack <benni@typo3.org> Tested-by: core-ci <typo3@b13.com> --- .../core/Classes/Imaging/SvgManipulation.php | 196 +++++++ .../Processing/ImageCropScaleMaskTask.php | 4 +- .../Resource/Processing/ImagePreviewTask.php | 4 +- .../Processing/LocalImageProcessor.php | 8 +- .../Resource/Processing/SvgImageProcessor.php | 132 ++++- .../Configuration/DefaultConfiguration.php | 1 + .../Features-93942-CropSVGImagesNatively.rst | 52 ++ .../ViewHelpers/ImageViewHelperTest1.svg | 7 + .../ViewHelpers/ImageViewHelperTest2.svg | 13 + .../ViewHelpers/ImageViewHelperTest3.svg | 7 + .../ViewHelpers/ImageViewHelperTest4.svg | 7 + .../ViewHelpers/ImageViewHelperTest5.svg | 7 + .../fluid/Tests/Functional/Fixtures/crops.csv | 29 + .../ViewHelpers/SvgImageViewHelperTest.php | 540 ++++++++++++++++++ .../Fixtures/SvgImageRenderingTest.typoscript | 175 ++++++ .../Rendering/SvgImageRenderingTest.php | 182 ++++++ 16 files changed, 1345 insertions(+), 19 deletions(-) create mode 100644 typo3/sysext/core/Classes/Imaging/SvgManipulation.php create mode 100644 typo3/sysext/core/Documentation/Changelog/13.1/Features-93942-CropSVGImagesNatively.rst create mode 100644 typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest1.svg create mode 100644 typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest2.svg create mode 100644 typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest3.svg create mode 100644 typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest4.svg create mode 100644 typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest5.svg create mode 100644 typo3/sysext/fluid/Tests/Functional/Fixtures/crops.csv create mode 100644 typo3/sysext/fluid/Tests/Functional/ViewHelpers/SvgImageViewHelperTest.php create mode 100644 typo3/sysext/frontend/Tests/Functional/Rendering/Fixtures/SvgImageRenderingTest.typoscript create mode 100644 typo3/sysext/frontend/Tests/Functional/Rendering/SvgImageRenderingTest.php diff --git a/typo3/sysext/core/Classes/Imaging/SvgManipulation.php b/typo3/sysext/core/Classes/Imaging/SvgManipulation.php new file mode 100644 index 000000000000..6d002d306246 --- /dev/null +++ b/typo3/sysext/core/Classes/Imaging/SvgManipulation.php @@ -0,0 +1,196 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Imaging; + +use TYPO3\CMS\Core\Imaging\ImageManipulation\Area; + +/** + * Performs SVG cropping by applying a wrapper SVG as view + * + * A simple SVG with an input like this: + * + * <svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0" y="0" + * viewBox="0 168.4 940.7 724" width="941" height="724"> + * <path id="path" d="M490.1 655.5c-9.4 1.2-16.9 + * </svg> + * + * is wrapped with crop dimensions (i.e. "50 50 640 480") to something like this: + * + * <svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="50 50 640 480" width="640" height="480"> + * <svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0" y="0" + * viewBox="0 168.4 940.7 724" width="941" height="724"> + * <path id="path" d="M490.1 655.5c-9.4 1.2-16.9 + * </svg> + * </svg> + * + * @internal not part of TYPO3 Core API. + */ +class SvgManipulation +{ + private int $defaultSvgDimension = 64; + + /** + * @throws \DOMException + */ + public function cropScaleSvgString(string $svgString, Area $cropArea, ImageDimension $imageDimension): \DOMDocument + { + $offsetLeft = (int)$cropArea->getOffsetLeft(); + $offsetTop = (int)$cropArea->getOffsetTop(); + // Rounding is applied to preserve the same width/height that imageDimension calculates + $newWidth = (int)round($cropArea->getWidth()); + $newHeight = (int)round($cropArea->getHeight()); + + // Load original SVG + $originalSvg = new \DOMDocument(); + $originalSvg->loadXML($svgString); + + // Create a fresh wrapping <svg> tag + $processedSvg = new \DOMDocument('1.0'); + $processedSvg->preserveWhiteSpace = true; + $processedSvg->formatOutput = true; + $outerSvgElement = $processedSvg->createElement('svg'); + $outerSvgElement->setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + + // Determine the SVG dimensions of the source SVG file contents + $dimensions = $this->determineSvgDimensions($originalSvg); + + // Adjust the width/height attributes of the outer SVG proxy element, if they were empty before. + $this->adjustSvgDimensions($originalSvg, $dimensions); + + // Set several attributes on the outer SVG proxy element (the "wrapper" of the real SVG) + $outerSvgElement->setAttribute('viewBox', $offsetLeft . ' ' . $offsetTop . ' ' . $newWidth . ' ' . $newHeight); + $outerSvgElement->setAttribute('width', (string)$imageDimension->getWidth()); + $outerSvgElement->setAttribute('height', (string)$imageDimension->getHeight()); + + // Possibly prevent some attributes on the "inner svg" (original input) and transport them + // to the new root (outerSvgElement). Currently only 'preserveAspectRatio'. + if ($originalSvg->documentElement->getAttribute('preserveAspectRatio') != '') { + $outerSvgElement->setAttribute('preserveAspectRatio', $originalSvg->documentElement->getAttribute('preserveAspectRatio')); + } + + // To enable some debugging for embeddding the original determined dimensions into the SVG, use: + // $outerSvgElement->setAttribute('data-inherit-width', (string)$dimensions['determined']['width']); + // $outerSvgElement->setAttribute('data-inherit-height', (string)$dimensions['determined']['height']); + + // Attach the main source SVG element into our proxy SVG element. + $innerSvgElement = $processedSvg->importNode($originalSvg->documentElement, true); + + // Stitch together the wrapper plus the old root element plus children, + // so that $processedSvg contains the full XML tree + $outerSvgElement->appendChild($innerSvgElement); + $processedSvg->appendChild($outerSvgElement); + + return $processedSvg; + } + + /** + * Ensure that the determined width and height settings are attributes on the original <svg>. + * If those were missing, cropping could not successfully be applied when getting + * embedded and adjusted within a <img> element. + * + * Returns true, if the determined width/height has been injected into the main <svg> + */ + protected function adjustSvgDimensions(\DOMDocument $originalSvg, array $determinedDimensions): bool + { + $isAltered = false; + + if ($determinedDimensions['original']['width'] === '') { + $originalSvg->documentElement->setAttribute('width', $determinedDimensions['determined']['width']); + $originalSvg->documentElement->setAttribute('data-manipulated-width', 'true'); + $isAltered = true; + } + + if ($determinedDimensions['original']['height'] === '') { + $originalSvg->documentElement->setAttribute('height', $determinedDimensions['determined']['height']); + $originalSvg->documentElement->setAttribute('data-manipulated-height', 'true'); + $isAltered = true; + } + + return $isAltered; + } + + /** + * Check an input SVG element for its dimensions through + * width/height/viewBox attributes. + * + * Returns an array with the determined width/height. + */ + protected function determineSvgDimensions(\DOMDocument $originalSvg): array + { + // A default used when SVG neither uses width, height nor viewBox + // Files falling back to this are probably broken. + $width = $height = null; + + $originalSvgViewBox = $originalSvg->documentElement->getAttribute('viewBox'); + $originalSvgWidth = $originalSvg->documentElement->getAttribute('width'); + $originalSvgHeight = $originalSvg->documentElement->getAttribute('height'); + + // width/height can easily be used if they are numeric. Else, viewBox attribute dimensions + // are evaluated. These are used as better fallback here, overridden if width/height exist. + if ($originalSvgViewBox !== '') { + $viewBoxParts = explode(' ', $originalSvgViewBox); + if (isset($viewBoxParts[2]) && is_numeric($viewBoxParts[2])) { + $width = $viewBoxParts[2]; + } + + if (isset($viewBoxParts[3]) && is_numeric($viewBoxParts[3])) { + $height = $viewBoxParts[3]; + } + } + + // width/height may contain percentages or units like "mm", "cm" + // When non-numeric, we only use the width/height when no viewBox + // exists (because the size of a viewBox would be preferred + // to a non-numeric value), and then unify the unit as "1". + if ($originalSvgWidth !== '') { + if (is_numeric($originalSvgWidth)) { + $width = $originalSvgWidth; + } elseif ($width === null) { + // contains a unit like "cm", "mm", "%", ... + // Currently just stripped because without knowing the output + // device, no pixel size can be calculated (missing dpi). + // So we regard the unit to be "1" - this is how TYPO3 + // already did it when SVG file metadata was evaluated (before + // cropping). + $width = (int)$originalSvgWidth; + } + } + + if ($originalSvgHeight !== '') { + if (is_numeric($originalSvgHeight)) { + $height = $originalSvgHeight; + } elseif ($height === null) { + $height = (int)$originalSvgHeight; + } + } + + return [ + // The "proper" image dimensions (with viewBox preference) + 'determined' => [ + 'width' => $width ?? $this->defaultSvgDimension, + 'height' => $height ?? $this->defaultSvgDimension, + ], + + // Possible original "width/height" attributes (may not correlate with the viewBox, could be empty) + 'original' => [ + 'width' => $originalSvgWidth, + 'height' => $originalSvgHeight, + ], + ]; + } +} diff --git a/typo3/sysext/core/Classes/Resource/Processing/ImageCropScaleMaskTask.php b/typo3/sysext/core/Classes/Resource/Processing/ImageCropScaleMaskTask.php index a04b59390220..c3d15a0dbc0c 100644 --- a/typo3/sysext/core/Classes/Resource/Processing/ImageCropScaleMaskTask.php +++ b/typo3/sysext/core/Classes/Resource/Processing/ImageCropScaleMaskTask.php @@ -57,12 +57,10 @@ class ImageCropScaleMaskTask extends AbstractTask { if (!empty($this->configuration['fileExtension'])) { $targetFileExtension = $this->configuration['fileExtension']; - } elseif (in_array($this->getSourceFile()->getExtension(), ['jpg', 'jpeg', 'png', 'gif'], true)) { + } elseif (in_array($this->getSourceFile()->getExtension(), ['jpg', 'jpeg', 'png', 'gif', 'svg'], true)) { $targetFileExtension = $this->getSourceFile()->getExtension(); } elseif ($this->getSourceFile()->getExtension() === 'webp' && GeneralUtility::inList($GLOBALS['TYPO3_CONF_VARS']['GFX']['imagefile_ext'] ?? '', 'webp')) { $targetFileExtension = $this->getSourceFile()->getExtension(); - } elseif (empty($this->configuration['crop']) && $this->getSourceFile()->getExtension() === 'svg') { - $targetFileExtension = 'svg'; } else { // Thumbnails from non-processable files will be converted to 'png' $targetFileExtension = 'png'; diff --git a/typo3/sysext/core/Classes/Resource/Processing/ImagePreviewTask.php b/typo3/sysext/core/Classes/Resource/Processing/ImagePreviewTask.php index 1e2d92858dbf..59e87cf6401d 100644 --- a/typo3/sysext/core/Classes/Resource/Processing/ImagePreviewTask.php +++ b/typo3/sysext/core/Classes/Resource/Processing/ImagePreviewTask.php @@ -70,12 +70,10 @@ class ImagePreviewTask extends AbstractTask { if (!empty($this->configuration['fileExtension'])) { $targetFileExtension = $this->configuration['fileExtension']; - } elseif (in_array($this->getSourceFile()->getExtension(), ['jpg', 'jpeg', 'png', 'gif'], true)) { + } elseif (in_array($this->getSourceFile()->getExtension(), ['jpg', 'jpeg', 'png', 'gif', 'svg'], true)) { $targetFileExtension = $this->getSourceFile()->getExtension(); } elseif ($this->getSourceFile()->getExtension() === 'webp' && GeneralUtility::inList($GLOBALS['TYPO3_CONF_VARS']['GFX']['imagefile_ext'] ?? '', 'webp')) { $targetFileExtension = $this->getSourceFile()->getExtension(); - } elseif (empty($this->configuration['crop']) && $this->getSourceFile()->getExtension() === 'svg') { - $targetFileExtension = 'svg'; } else { // Thumbnails from non-processable files will be converted to 'png' $targetFileExtension = 'png'; diff --git a/typo3/sysext/core/Classes/Resource/Processing/LocalImageProcessor.php b/typo3/sysext/core/Classes/Resource/Processing/LocalImageProcessor.php index 614133b8078b..8b8cb4a719d8 100644 --- a/typo3/sysext/core/Classes/Resource/Processing/LocalImageProcessor.php +++ b/typo3/sysext/core/Classes/Resource/Processing/LocalImageProcessor.php @@ -91,12 +91,10 @@ class LocalImageProcessor implements ProcessorInterface, LoggerAwareInterface } /** - * Check if the to be processed target file already exists - * if exist take info from that file and mark task as done - * - * @return bool + * Check if the target file that is to be processed already exists. + * If it exists, use the metadata from that file and mark task as done. */ - protected function checkForExistingTargetFile(TaskInterface $task) + protected function checkForExistingTargetFile(TaskInterface $task): bool { // the storage of the processed file, not of the original file! $storage = $task->getTargetFile()->getStorage(); diff --git a/typo3/sysext/core/Classes/Resource/Processing/SvgImageProcessor.php b/typo3/sysext/core/Classes/Resource/Processing/SvgImageProcessor.php index 94db20628283..d03b4d4fc8b6 100644 --- a/typo3/sysext/core/Classes/Resource/Processing/SvgImageProcessor.php +++ b/typo3/sysext/core/Classes/Resource/Processing/SvgImageProcessor.php @@ -19,35 +19,51 @@ namespace TYPO3\CMS\Core\Resource\Processing; use TYPO3\CMS\Core\Imaging\Exception\ZeroImageDimensionException; use TYPO3\CMS\Core\Imaging\ImageDimension; +use TYPO3\CMS\Core\Imaging\ImageManipulation\Area; +use TYPO3\CMS\Core\Imaging\ImageProcessingInstructions; +use TYPO3\CMS\Core\Imaging\SvgManipulation; +use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderReadPermissionsException; +use TYPO3\CMS\Core\Type\File\ImageInfo; +use TYPO3\CMS\Core\Utility\GeneralUtility; /** - * Processes (scales) SVG Images files, when no cropping is defined + * Processes (scales) SVG Images files or crops them via \DOMDocument + * and creates a new locally created processed file which is then pushed + * into FAL again. */ class SvgImageProcessor implements ProcessorInterface { + private int $defaultSvgDimension = 64; + public function canProcessTask(TaskInterface $task): bool { return $task->getType() === 'Image' && in_array($task->getName(), ['Preview', 'CropScaleMask'], true) - && empty($task->getConfiguration()['crop']) && $task->getTargetFileExtension() === 'svg'; } /** * Processes the given task. * - * @throws \InvalidArgumentException + * @throws \InvalidArgumentException|InsufficientFolderReadPermissionsException */ public function processTask(TaskInterface $task): void { - $task->setExecuted(true); - $task->getTargetFile()->setUsesOriginalFile(); try { - $imageDimension = ImageDimension::fromProcessingTask($task); - } catch (ZeroImageDimensionException $e) { + $processingInstructions = ImageProcessingInstructions::fromProcessingTask($task); + $imageDimension = new ImageDimension($processingInstructions->width, $processingInstructions->height); + } catch (ZeroImageDimensionException) { + $processingInstructions = new ImageProcessingInstructions( + width: $this->defaultSvgDimension, + height: $this->defaultSvgDimension, + ); // To not fail image processing, we just assume an SVG image dimension here - $imageDimension = new ImageDimension(64, 64); + $imageDimension = new ImageDimension( + width: $this->defaultSvgDimension, + height: $this->defaultSvgDimension + ); } + $task->getTargetFile()->updateProperties( [ 'width' => $imageDimension->getWidth(), @@ -56,5 +72,105 @@ class SvgImageProcessor implements ProcessorInterface 'checksum' => $task->getConfigurationChecksum(), ] ); + + if ($this->checkForExistingTargetFile($task)) { + return; + } + + $cropArea = $processingInstructions->cropArea; + if ($cropArea === null || $cropArea->makeRelativeBasedOnFile($task->getSourceFile())->isEmpty()) { + $task->setExecuted(true); + $task->getTargetFile()->setUsesOriginalFile(); + return; + } + + $this->applyCropping($task, $cropArea, $imageDimension); + } + + /** + * Create standalone wrapper files for SVGs. + * Cropped responsive images delivered via an <img> tag or + * as a URI for a background image, need to be self-contained. + * Therefore we wrap a <svg> container around the original SVG + * content. + * A viewBox() crop is then applied to that container. + * The processed file will contain all the viewBox cropping information + * and thus transports intrinsic sizes for all variants of CSS + * processing (max/min width/height). + */ + protected function applyCropping(TaskInterface $task, Area $cropArea, ImageDimension $imageDimension): void + { + $processedSvg = GeneralUtility::makeInstance(SvgManipulation::class)->cropScaleSvgString( + $task->getSourceFile()->getContents(), + $cropArea, + $imageDimension + ); + // Save the output as a new processed file. + $temporaryFilename = $this->getFilenameForSvgCropScaleMask($task); + GeneralUtility::writeFile($temporaryFilename, $processedSvg->saveXML()); + + $task->setExecuted(true); + $imageInformation = GeneralUtility::makeInstance(ImageInfo::class, $temporaryFilename); + + $task->getTargetFile()->setName($task->getTargetFileName()); + + $task->getTargetFile()->updateProperties([ + // @todo: Use round() instead of int-cast to avoid an implicit floor()? + 'width' => (string)$imageDimension->getWidth(), + 'height' => (string)$imageDimension->getHeight(), + 'size' => $imageInformation->getSize(), + 'checksum' => $task->getConfigurationChecksum(), + ]); + $task->getTargetFile()->updateWithLocalFile($temporaryFilename); + // The temporary file is removed again + GeneralUtility::unlink_tempfile($temporaryFilename); } + + /** + * Check if the target file that is to be processed already exists. + * If it exists, use the metadata from that file and mark task as done. + * + * @throws InsufficientFolderReadPermissionsException + * @todo - Refactor this 80% duplicate code of LocalImageProcessor::checkForExistingTargetFile + */ + protected function checkForExistingTargetFile(TaskInterface $task): bool + { + // the storage of the processed file, not of the original file! + $storage = $task->getTargetFile()->getStorage(); + $processingFolder = $storage->getProcessingFolder($task->getSourceFile()); + + // explicitly check for the raw filename here, as we check for files that existed before we even started + // processing, i.e. that were processed earlier + if ($processingFolder->hasFile($task->getTargetFileName())) { + // When the processed file already exists set it as processed file + $task->getTargetFile()->setName($task->getTargetFileName()); + + // If the processed file is stored on a remote server, we must fetch a local copy of the file, as we + // have no API for fetching file metadata from a remote file. + $localProcessedFile = $storage->getFileForLocalProcessing($task->getTargetFile(), false); + $task->setExecuted(true); + $imageInformation = GeneralUtility::makeInstance(ImageInfo::class, $localProcessedFile); + $properties = [ + 'width' => $imageInformation->getWidth(), + 'height' => $imageInformation->getHeight(), + 'size' => $imageInformation->getSize(), + 'checksum' => $task->getConfigurationChecksum(), + ]; + $task->getTargetFile()->updateProperties($properties); + + return true; + } + return false; + } + + /** + * Returns the filename for a cropped/scaled/masked file which will be put + * in typo3temp for the time being. + */ + protected function getFilenameForSvgCropScaleMask(TaskInterface $task): string + { + $targetFileExtension = $task->getTargetFileExtension(); + return GeneralUtility::tempnam($task->getTargetFile()->generateProcessedFileNameWithoutExtension(), '.' . ltrim(trim($targetFileExtension))); + } + } diff --git a/typo3/sysext/core/Configuration/DefaultConfiguration.php b/typo3/sysext/core/Configuration/DefaultConfiguration.php index 925a454419f7..bc66c8b027fd 100644 --- a/typo3/sysext/core/Configuration/DefaultConfiguration.php +++ b/typo3/sysext/core/Configuration/DefaultConfiguration.php @@ -283,6 +283,7 @@ return [ 'className' => \TYPO3\CMS\Core\Resource\Processing\SvgImageProcessor::class, 'before' => [ 'LocalImageProcessor', + 'DeferredBackendImageProcessor', ], ], 'DeferredBackendImageProcessor' => [ diff --git a/typo3/sysext/core/Documentation/Changelog/13.1/Features-93942-CropSVGImagesNatively.rst b/typo3/sysext/core/Documentation/Changelog/13.1/Features-93942-CropSVGImagesNatively.rst new file mode 100644 index 000000000000..667c9cb2c127 --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/13.1/Features-93942-CropSVGImagesNatively.rst @@ -0,0 +1,52 @@ +.. include:: /Includes.rst.txt + +.. _feature-93942-1709722341: + +========================================== +Feature: #93942 - Crop SVG images natively +========================================== + +See :issue:`93942` + +Description +=========== + +Cropping SVG images via backend image editing or specific Fluid ViewHelper via +:html:`<f:image>` or :html:`<f:uri.image>` (via :html:`crop` attribute) now +outputs native SVG files by default - which are processed but again stored +as SVG, instead of rasterized PNG/JPG images like before. + + +Impact +====== + +Editors and integrators can now crop SVG assets without an impact to their +output quality. + +Forced rasterization of cropped SVG assets can still be performed by setting the +:html:`fileExtension="png"` Fluid ViewHelper attribute or the TypoScript +:typoscript:`file.ext = png` property. + +:html:`<f:image>` ViewHelper example: +------------------------------------- + +.. code-block:: html + + <f:image image="{image}" fileExtension="png" /> + +This keeps forcing images to be generated as PNG image. + +`file.ext = png` TypoScript example: +------------------------------------ + +.. code-block:: typoscript + + page.10 = IMAGE + page.10.file = 2:/myfile.svg + page.10.file.crop = 20,20,500,500 + page.10.file.ext = png + +If no special hard-coded option for the file extension is set, SVGs are now +processed and stored as SVGs again. + +.. index:: Backend, FAL, Fluid, Frontend, TypoScript, ext:fluid diff --git a/typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest1.svg b/typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest1.svg new file mode 100644 index 000000000000..0724b4cedbf6 --- /dev/null +++ b/typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest1.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1680 1050"> + <rect width="100%" height="100%" fill="pink" stroke-width="2" stroke="black" /> + <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="30 0 64 68"> + <path id="a" d="M14 27v-20c0-3.7-3.3-7-7-7s-7 3.3-7 7v41c0 8.2 9.2 17 20 17s20-9.2 20-20c0-13.3-13.4-21.8-26-18zm6 25c-4 0-7-3-7-7s3-7 7-7 7 3 7 7-3 7-7 7z"></path> + </svg> +</svg> diff --git a/typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest2.svg b/typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest2.svg new file mode 100644 index 000000000000..28e9bd0f3cfc --- /dev/null +++ b/typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest2.svg @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="1958.3 -758.3 283.5 283.5" xml:space="preserve"> +<rect x="1958.3" y="-758.3" fill="blue" width="283.5" height="283.5"></rect> +<path fill="#999999" d="M1976-740.5v248h248v-248H1976L1976-740.5z M1993.7-722.8h212.6v212.6h-212.6V-722.8z"></path> +<g> + <path fill="#5599FF" d="M2174.9-641.5h-149.8v-50h129.4c11.3,0,20.5,9.2,20.5,20.5V-641.5z"></path> + <rect x="2025.1" y="-641.5" fill="#FFC857" width="149.8" height="50"></rect> + <path fill="#666666" d="M2145.2-653.7l18.1-15.8c0.5-0.4,0.2-1.2-0.4-1.2l-35.6-0.3c-0.6,0-0.9,0.8-0.5,1.2l17.5,16.1 C2144.5-653.5,2144.9-653.5,2145.2-653.7z"></path> + <path fill="#FF8700" d="M2157.3-541.5h-132.2v-50h149.8v32.4C2174.9-549.4,2167-541.5,2157.3-541.5z"></path> + <path fill="#666666" d="M2145.2-603.7l18.1-15.8c0.5-0.4,0.2-1.2-0.4-1.2l-35.6-0.3c-0.6,0-0.9,0.8-0.5,1.2l17.5,16.1 C2144.5-603.5,2144.9-603.5,2145.2-603.7z"></path> + <path fill="#666666" d="M2145.2-553.7l18.1-15.8c0.5-0.4,0.2-1.2-0.4-1.2l-35.6-0.3c-0.6,0-0.9,0.8-0.5,1.2l17.5,16.1 C2144.5-553.4,2144.9-553.4,2145.2-553.7z"></path> +</g> +</svg> diff --git a/typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest3.svg b/typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest3.svg new file mode 100644 index 000000000000..d768def3922e --- /dev/null +++ b/typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest3.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0" y="0" viewBox="0 12 940.7 724" width="941" height="724"> + <rect width="100%" height="100%" fill="yellow" stroke-width="2" stroke="black" /> + <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="15 0 64 68"> + <path id="a" d="M14 27v-20c0-3.7-3.3-7-7-7s-7 3.3-7 7v41c0 8.2 9.2 17 20 17s20-9.2 20-20c0-13.3-13.4-21.8-26-18zm6 25c-4 0-7-3-7-7s3-7 7-7 7 3 7 7-3 7-7 7z"></path> + </svg> +</svg> diff --git a/typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest4.svg b/typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest4.svg new file mode 100644 index 000000000000..f227bede140c --- /dev/null +++ b/typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest4.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0" y="0" width="1680" height="1050"> + <rect width="100%" height="100%" fill="green" stroke-width="2" stroke="black" /> + <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="30 -1 64 68"> + <path id="a" d="M14 27v-20c0-3.7-3.3-7-7-7s-7 3.3-7 7v41c0 8.2 9.2 17 20 17s20-9.2 20-20c0-13.3-13.4-21.8-26-18zm6 25c-4 0-7-3-7-7s3-7 7-7 7 3 7 7-3 7-7 7z"></path> + </svg> +</svg> diff --git a/typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest5.svg b/typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest5.svg new file mode 100644 index 000000000000..bd5186f83cf6 --- /dev/null +++ b/typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest5.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0" y="0"> + <rect width="100%" height="100%" fill="red" stroke-width="2" stroke="black" /> + <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="5 -20 64 68"> + <path id="a" d="M14 27v-20c0-3.7-3.3-7-7-7s-7 3.3-7 7v41c0 8.2 9.2 17 20 17s20-9.2 20-20c0-13.3-13.4-21.8-26-18zm6 25c-4 0-7-3-7-7s3-7 7-7 7 3 7 7-3 7-7 7z"></path> + </svg> +</svg> diff --git a/typo3/sysext/fluid/Tests/Functional/Fixtures/crops.csv b/typo3/sysext/fluid/Tests/Functional/Fixtures/crops.csv new file mode 100644 index 000000000000..620ec8e6452a --- /dev/null +++ b/typo3/sysext/fluid/Tests/Functional/Fixtures/crops.csv @@ -0,0 +1,29 @@ +sys_file_storage,,,,,,,,,,,,,, +,uid,pid,name,driver,configuration,is_browsable,is_public,is_writable,is_online,,,,, +,1,0,fileadmin,Local,"<?xml version=""1.0"" encoding=""utf-8"" standalone=""yes"" ?><T3FlexForms><data><sheet index=""sDEF""><language index=""lDEF""><field index=""basePath""><value index=""vDEF"">fileadmin/</value></field><field index=""pathType""><value index=""vDEF"">relative</value></field><field index=""caseSensitive""><value index=""vDEF"">1</value></field></language></sheet></data></T3FlexForms>",1,1,1,1,,,,, +sys_file,,,,,,,,,,,,,, +,uid,pid,storage,type,identifier,identifier_hash,folder_hash,extension,mime_type,name,sha1,size,creation_date,modification_date +,1,0,1,2,/user_upload/FALImageViewHelperTest1.svg,cd3af075d80618729520920802d0fd25b535b74c,19669f1e02c2f16705ec7587044c66443be70725,svg,image/svg+xml,FALImageViewHelperTest1.svg,b6bcefe7804cb2f0de8d5d2834c7c546c3766dd7,64735,1389878273,1389878273 +,2,0,1,2,/user_upload/FALImageViewHelperTest2.svg,a5d36e35adfe231b5c01778462ebabbf12defd0f,19669f1e02c2f16705ec7587044c66443be70725,svg,image/svg+xml,FALImageViewHelperTest2.svg,575d40f604e1b46792dd6566bf6ff52ffae5d6a8,1211,1389878273,1389878273 +,3,0,1,2,/user_upload/FALImageViewHelperTest3.svg,5755173832a12c5fc4855b891b550544c075a5bd,19669f1e02c2f16705ec7587044c66443be70725,svg,image/svg+xml,FALImageViewHelperTest3.svg,7f9d82e8dfd85f85debc7a07080b1e7fb8c72358,17468,1389878273,1389878273 +,4,0,1,2,/user_upload/FALImageViewHelperTest4.svg,c39b710aded127bf0e37430983cabc2b48db1f4a,19669f1e02c2f16705ec7587044c66443be70725,svg,image/svg+xml,FALImageViewHelperTest4.svg,0930d53140d0c229baaccb8aa35177861996225e,64738,1389878273,1389878273 +,5,0,1,2,/user_upload/FALImageViewHelperTest5.svg,241648cd6f15a2ed19c7affe44a91adf717494a2,19669f1e02c2f16705ec7587044c66443be70725,svg,image/svg+xml,FALImageViewHelperTest5.svg,f10070e21cb7c53bcbca8549423c691e37738bf9,64711,1389878273,1389878273 +sys_file_reference,,,,,,,,,,,,,, +,uid,pid,uid_local,uid_foreign,tablenames,fieldname,crop,,,,,,, +,1,1,1,1,pages,media,"{""default"":{""cropArea"":{""height"":0.22666666666666666,""width"":0.1375,""x"":0.04583333333333333,""y"":0.5533333333333333},""selectedRatio"":""NaN"",""focusArea"":null}}",,,,,,, +,2,1,2,1,pages,media,"{""default"":{""cropArea"":{""height"":0.21333333333333335,""width"":0.8533333333333334,""x"":0.06666666666666667,""y"":0.22},""selectedRatio"":""NaN"",""focusArea"":null}}",,,,,,, +,3,1,3,1,pages,media,"{""default"":{""cropArea"":{""x"":0.24973432518597238,""y"":0.4185082872928177,""width"":0.18703506907545164,""height"":0.4419889502762431},""selectedRatio"":""NaN"",""focusArea"":null}}",,,,,,, +,4,1,4,1,pages,media,"{""default"":{""cropArea"":{""height"":0.12476190476190477,""width"":0.06785714285714285,""x"":0.03511904761904762,""y"":0.32857142857142857},""selectedRatio"":""NaN"",""focusArea"":null}}",,,,,,, +,5,1,5,1,pages,media,"{""default"":{""cropArea"":{""height"":1,""width"":0.18,""x"":0.2,""y"":0},""selectedRatio"":""NaN"",""focusArea"":null}}",,,,,,, +,6,1,1,1,pages,media,,,,,,,, +,7,1,2,1,pages,media,,,,,,,, +,8,1,3,1,pages,media,,,,,,,, +,9,1,4,1,pages,media,,,,,,,, +,10,1,5,1,pages,media,,,,,,,, +sys_file_metadata,,,,,,,,,,,,,, +,uid,pid,file,width,height,,,,,,,,, +,1,0,1,1680,1050,,,,,,,,, +,2,0,2,283,283,,,,,,,,, +,3,0,3,941,724,,,,,,,,, +,4,0,4,1680,1050,,,,,,,,, +,5,0,5,64,64,,,,,,,,, diff --git a/typo3/sysext/fluid/Tests/Functional/ViewHelpers/SvgImageViewHelperTest.php b/typo3/sysext/fluid/Tests/Functional/ViewHelpers/SvgImageViewHelperTest.php new file mode 100644 index 000000000000..db6c0536e7c4 --- /dev/null +++ b/typo3/sysext/fluid/Tests/Functional/ViewHelpers/SvgImageViewHelperTest.php @@ -0,0 +1,540 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Fluid\Tests\Functional\ViewHelpers; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Fluid\Core\Rendering\RenderingContextFactory; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; +use TYPO3Fluid\Fluid\View\TemplateView; + +final class SvgImageViewHelperTest extends FunctionalTestCase +{ + protected array $coreExtensionsToLoad = ['filemetadata']; + + protected array $pathsToProvideInTestInstance = [ + 'typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest1.svg' => 'fileadmin/user_upload/FALImageViewHelperTest1.svg', + 'typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest2.svg' => 'fileadmin/user_upload/FALImageViewHelperTest2.svg', + 'typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest3.svg' => 'fileadmin/user_upload/FALImageViewHelperTest3.svg', + 'typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest4.svg' => 'fileadmin/user_upload/FALImageViewHelperTest4.svg', + 'typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest5.svg' => 'fileadmin/user_upload/FALImageViewHelperTest5.svg', + ]; + + protected array $additionalFoldersToCreate = [ + '/fileadmin/user_upload', + ]; + + public function setUp(): void + { + parent::setUp(); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/be_users.csv'); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/crops.csv'); + $this->setUpBackendUser(1); + } + + public static function renderReturnsExpectedMarkupDataProvider(): array + { + /** Used files: + * =========== + * + * ImageViewHelperTest1.svg: with viewBox, no width, no height, 0x0 origin + * ImageViewHelperTest2.svg: with viewBox, no width, no height, shifted origin + * ImageViewHelperTest3.svg: with viewBox, with width and height + * ImageViewHelperTest4.svg: no viewBox, with height and width + **/ + + // width, height, [scalingFactorBasedOnWidth (60px/80%)], [scalingFactorBasedOnOffset (15px/10%)], [pixelBasedOnOffset] + $dimensionMap = [ + 'ImageViewHelperTest1.svg' => [ + 'input' => [1680, 1050], + 'fixedCrop60px' => [60, 38, 0.03571428572, 0.008928571429, 15, 9], + 'heightAtMaxWidth60px' => 62, + 'relativeCrop80Percent' => [1344, 840, 0.8, 0.1, 168, 105], + 'falUidCropped' => 1, + 'falUidUncropped' => 6, + 'falCropString' => '77 581 231 238', + 'falCropDim' => [231, 238], + ], + 'ImageViewHelperTest2.svg' => [ + 'input' => [283.5, 283.5], + 'fixedCrop60px' => [60, 60, 0.2116402117, 0.05291005291, 14, 14], + 'heightAtMaxWidth60px' => 15, + 'relativeCrop80Percent' => [226, 226, 0.8, 0.1, 28, 28], + 'falUidCropped' => 2, + 'falUidUncropped' => 7, + 'falCropString' => '18 62 241 60', + 'falCropDim' => [241, 60], + ], + 'ImageViewHelperTest3.svg' => [ + 'input' => [940.7, 724], + 'fixedCrop60px' => [60, 46, 0.06378228979, 0.01594557245, 15, 11], + 'heightAtMaxWidth60px' => 109, + 'relativeCrop80Percent' => [753, 579, 0.8, 0.1, 94, 72], + 'falUidCropped' => 3, + 'falUidUncropped' => 8, + 'falCropString' => '235 303 176 320', + 'falCropDim' => [176, 320], + ], + 'ImageViewHelperTest4.svg' => [ + 'input' => [1680, 1050], + 'fixedCrop60px' => [60, 38, 0.03571428572, 0.008928571429, 15, 9], + 'heightAtMaxWidth60px' => 69, + 'relativeCrop80Percent' => [1344, 840, 0.8, 0.1, 168, 105], + 'falUidCropped' => 4, + 'falUidUncropped' => 9, + 'falCropString' => '59 345 114 131', + 'falCropDim' => [113, 131], + ], + ]; + + $expected = []; + + $maximum = count($dimensionMap); + + $storageDirOriginal = 'typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers'; + $storageDirTemp = 'typo3temp/assets/_processed_/[0-9a-f]/[0-9a-f]'; + $storageDirFal = 'fileadmin/user_upload'; + $storageDirFalTemp = 'fileadmin/_processed_/[0-9a-f]/[0-9a-f]'; + + // To prevent excess copy and paste labor, this is done programmatically: + for ($i = 1; $i <= $maximum; $i++) { + $fn = 'ImageViewHelperTest' . $i . '.svg'; + $fUid = $dimensionMap[$fn]['falUidCropped']; + $fUidUncropped = $dimensionMap[$fn]['falUidUncropped']; + + $width = round($dimensionMap[$fn]['input'][0]); + $height = round($dimensionMap[$fn]['input'][1]); + + // Note: Uncropped SVGs are returned from their original location. No conversion/tampering is done. + + //# SECTION 1: Referenced via EXT: ### + $expected[sprintf('no crop (%s)', $fn)] = [ + sprintf( + '<f:image src="EXT:fluid/Tests/Functional/Fixtures/ViewHelpers/%s" width="%d" height="%d" />', + $fn, + $width, + $height, + ), + sprintf( + '@^<img src="(%s/%s)" width="%d" height="%d" alt="" />$@', + $storageDirOriginal, + $fn, + $width, + $height, + ), + null, + false, + ]; + + $expected[sprintf('empty crop (%s)', $fn)] = [ + sprintf( + '<f:image src="EXT:fluid/Tests/Functional/Fixtures/ViewHelpers/%s" width="%d" height="%d" crop="null" />', + $fn, + $width, + $height, + ), + sprintf( + '@^<img src="(%s/%s)" width="%d" height="%d" alt="" />$@', + $storageDirOriginal, + $fn, + $width, + $height, + ), + null, + false, + ]; + + $expected[sprintf('crop as array - forced 60px (%s)', $fn)] = [ + sprintf( + '<f:image src="EXT:fluid/Tests/Functional/Fixtures/ViewHelpers/%1$s" width="%2$d" height="%3$d" crop="{\'default\':{\'cropArea\':{\'width\':%4$s,\'height\':%4$s,\'x\':%5$s,\'y\':%5$s},\'selectedRatio\':\'1:1\',\'focusArea\':null}}" />', + $fn, + $dimensionMap[$fn]['fixedCrop60px'][0], // width + $dimensionMap[$fn]['fixedCrop60px'][1], // height + $dimensionMap[$fn]['fixedCrop60px'][2], // crop-string width/height + $dimensionMap[$fn]['fixedCrop60px'][3] // crop-string offset left/top + ), + sprintf( + '@^<img src="(%s/csm_ImageViewHelperTest%d_.*\.svg)" width="%d" height="%d" alt="" />$@', + $storageDirTemp, + $i, + $dimensionMap[$fn]['fixedCrop60px'][0], + $dimensionMap[$fn]['fixedCrop60px'][1], + ), + $dimensionMap[$fn]['fixedCrop60px'][4] . ' ' . $dimensionMap[$fn]['fixedCrop60px'][5] . ' ' . $dimensionMap[$fn]['fixedCrop60px'][0] . ' ' . $dimensionMap[$fn]['fixedCrop60px'][1], + ]; + + $expected[sprintf('crop as array - no width/height (%s)', $fn)] = [ + sprintf( + '<f:image src="EXT:fluid/Tests/Functional/Fixtures/ViewHelpers/%1$s" crop="{\'default\':{\'cropArea\':{\'width\':%2$s,\'height\':%2$s,\'x\':%3$s,\'y\':%3$s},\'selectedRatio\':\'1:1\',\'focusArea\':null}}" />', + $fn, + $dimensionMap[$fn]['relativeCrop80Percent'][2], // crop-string width/height + $dimensionMap[$fn]['relativeCrop80Percent'][3] // crop-string offset left/top + ), + sprintf( + '@^<img src="(%s/csm_ImageViewHelperTest%d_.*\.svg)" width="%d" height="%d" alt="" />$@', + $storageDirTemp, + $i, + $dimensionMap[$fn]['relativeCrop80Percent'][0], + $dimensionMap[$fn]['relativeCrop80Percent'][1], + ), + $dimensionMap[$fn]['relativeCrop80Percent'][4] . ' ' . $dimensionMap[$fn]['relativeCrop80Percent'][5] . ' ' . $dimensionMap[$fn]['relativeCrop80Percent'][0] . ' ' . $dimensionMap[$fn]['relativeCrop80Percent'][1], + ]; + + $expected[sprintf('force pixel-conversion, no crop (%s)', $fn)] = [ + sprintf( + '<f:image src="EXT:fluid/Tests/Functional/Fixtures/ViewHelpers/%s" width="%d" height="%d" fileExtension="png" />', + $fn, + $width, + $height, + ), + sprintf( + '@^<img src="(%s/csm_ImageViewHelperTest%d_.*\.png)" width="%d" height="%d" alt="" />$@', + $storageDirTemp, + $i, + $width, + $height, + ), + null, + ]; + + $expected[sprintf('force pixel-conversion, with crop (%s)', $fn)] = [ + sprintf( + '<f:image src="EXT:fluid/Tests/Functional/Fixtures/ViewHelpers/%1$s" fileExtension="png" width="%2$d" height="%3$d" crop="{\'default\':{\'cropArea\':{\'width\':%4$s,\'height\':%4$s,\'x\':%5$s,\'y\':%5$s},\'selectedRatio\':\'1:1\',\'focusArea\':null}}" />', + $fn, + $dimensionMap[$fn]['relativeCrop80Percent'][0], // width + $dimensionMap[$fn]['relativeCrop80Percent'][1], // height + $dimensionMap[$fn]['relativeCrop80Percent'][2], // crop-string width/height + $dimensionMap[$fn]['relativeCrop80Percent'][3] // crop-string offset left/top + ), + sprintf( + '@^<img src="(%s/csm_ImageViewHelperTest%d_.*\.png)" width="%d" height="%d" alt="" />$@', + $storageDirTemp, + $i, + $dimensionMap[$fn]['relativeCrop80Percent'][0], + $dimensionMap[$fn]['relativeCrop80Percent'][1], + ), + $dimensionMap[$fn]['relativeCrop80Percent'][4] . ' ' . $dimensionMap[$fn]['relativeCrop80Percent'][5] . $dimensionMap[$fn]['relativeCrop80Percent'][0] . ' ' . $dimensionMap[$fn]['relativeCrop80Percent'][1], + ]; + //############################################################################ + + //# SECTION 2: Referenced via UID, cropped via sys_file_reference (with overrides) ### + // width/height is using the original dimensions, contained crop will be rendered within + $expected[sprintf('using sys_file_reference crop (UID %d)', $fUid)] = [ + sprintf( + '<f:image src="%1$d" treatIdAsReference="true" width="%2$d" height="%3$d" />', + $fUid, + $width, + $height, + ), + sprintf( + '@^<img src="(%s/csm_FALImageViewHelperTest%d_.*\.svg)" width="%d" height="%d" alt="" />$@', + $storageDirFalTemp, + $i, + $width, + $height, + ), + $dimensionMap[$fn]['falCropString'], // Stored in sys_file_reference + true, + ]; + + $expected[sprintf('using sys_file_reference crop, using maxWidth (60px, UID %d)', $fUid)] = [ + sprintf( + '<f:image src="%1$d" treatIdAsReference="true" maxWidth="60" />', + $fUid, + ), + sprintf( + '@^<img src="(%s/csm_FALImageViewHelperTest%d_.*\.svg)" width="60" height="%d" alt="" />$@', + $storageDirFalTemp, + $i, + $dimensionMap[$fn]['heightAtMaxWidth60px'] + ), + $dimensionMap[$fn]['falCropString'], // Stored in sys_file_reference + true, + ]; + + $expected[sprintf('empty crop (UID %d)', $fUid)] = [ + sprintf( + '<f:image src="%1$d" treatIdAsReference="true" width="%2$d" height="%3$d" crop="null" />', + $fUid, + $width, + $height, + ), + sprintf( + '@^<img src="(%s/%s)" width="%d" height="%d" alt="" />$@', + $storageDirFal, + 'FAL' . $fn, + $width, + $height, + ), + null, + false, + ]; + + $expected[sprintf('crop as array - forced 60px (UID %d)', $fUid)] = [ + sprintf( + '<f:image src="%1$d" treatIdAsReference="true" width="%2$d" height="%3$d" crop="{\'default\':{\'cropArea\':{\'width\':%4$s,\'height\':%4$s,\'x\':%5$s,\'y\':%5$s},\'selectedRatio\':\'1:1\',\'focusArea\':null}}" />', + $fUid, + $dimensionMap[$fn]['fixedCrop60px'][0], // width + $dimensionMap[$fn]['fixedCrop60px'][1], // height + $dimensionMap[$fn]['fixedCrop60px'][2], // crop-string width/height + $dimensionMap[$fn]['fixedCrop60px'][3] // crop-string offset left/top + ), + sprintf( + '@^<img src="(%s/csm_FALImageViewHelperTest%d_.*\.svg)" width="%d" height="%d" alt="" />$@', + $storageDirFalTemp, + $i, + $dimensionMap[$fn]['fixedCrop60px'][0], + $dimensionMap[$fn]['fixedCrop60px'][1], + ), + $dimensionMap[$fn]['fixedCrop60px'][4] . ' ' . $dimensionMap[$fn]['fixedCrop60px'][5] . ' ' . $dimensionMap[$fn]['fixedCrop60px'][0] . ' ' . $dimensionMap[$fn]['fixedCrop60px'][1], + ]; + + $expected[sprintf('crop as array - no width/height (UID %d)', $fUid)] = [ + sprintf( + '<f:image src="%1$d" treatIdAsReference="true" crop="{\'default\':{\'cropArea\':{\'width\':%2$s,\'height\':%2$s,\'x\':%3$s,\'y\':%3$s},\'selectedRatio\':\'1:1\',\'focusArea\':null}}" />', + $fUid, + $dimensionMap[$fn]['relativeCrop80Percent'][2], // crop-string width/height + $dimensionMap[$fn]['relativeCrop80Percent'][3] // crop-string offset left/top + ), + sprintf( + '@^<img src="(%s/csm_FALImageViewHelperTest%d_.*\.svg)" width="%d" height="%d" alt="" />$@', + $storageDirFalTemp, + $i, + $dimensionMap[$fn]['relativeCrop80Percent'][0], + $dimensionMap[$fn]['relativeCrop80Percent'][1], + ), + $dimensionMap[$fn]['relativeCrop80Percent'][4] . ' ' . $dimensionMap[$fn]['relativeCrop80Percent'][5] . ' ' . $dimensionMap[$fn]['relativeCrop80Percent'][0] . ' ' . $dimensionMap[$fn]['relativeCrop80Percent'][1], + ]; + + $expected[sprintf('force pixel-conversion, sys_file_reference crop (UID %d)', $fUid)] = [ + sprintf( + '<f:image src="%1$d" treatIdAsReference="true" width="%2$d" height="%3$d" fileExtension="png" />', + $fUid, + $width, + $height, + ), + sprintf( + '@^<img src="(%s/csm_FALImageViewHelperTest%d_.*\.png)" width="%d" height="%d" alt="" />$@', + $storageDirFalTemp, + $i, + $width, + $height, + ), + null, + ]; + + $expected[sprintf('force pixel-conversion, with crop (UID %d)', $fUid)] = [ + sprintf( + '<f:image src="%1$d" treatIdAsReference="true" fileExtension="png" width="%2$d" height="%3$d" crop="{\'default\':{\'cropArea\':{\'width\':%4$s,\'height\':%4$s,\'x\':%5$s,\'y\':%5$s},\'selectedRatio\':\'1:1\',\'focusArea\':null}}" />', + $fUid, + $dimensionMap[$fn]['relativeCrop80Percent'][0], // width + $dimensionMap[$fn]['relativeCrop80Percent'][1], // height + $dimensionMap[$fn]['relativeCrop80Percent'][2], // crop-string width/height + $dimensionMap[$fn]['relativeCrop80Percent'][3] // crop-string offset left/top + ), + sprintf( + '@^<img src="(%s/csm_FALImageViewHelperTest%d_.*\.png)" width="%d" height="%d" alt="" />$@', + $storageDirFalTemp, + $i, + $dimensionMap[$fn]['relativeCrop80Percent'][0], + $dimensionMap[$fn]['relativeCrop80Percent'][1], + ), + $dimensionMap[$fn]['relativeCrop80Percent'][4] . ' ' . $dimensionMap[$fn]['relativeCrop80Percent'][5] . $dimensionMap[$fn]['relativeCrop80Percent'][0] . ' ' . $dimensionMap[$fn]['relativeCrop80Percent'][1], + ]; + //############################################################################ + + //# SECTION 3: Referenced via UID, uncropped in sys_file_reference ### + $expected[sprintf('no crop (uncrop-UID %d)', $fUidUncropped)] = [ + sprintf( + '<f:image src="%1$d" treatIdAsReference="true" width="%2$d" height="%3$d" />', + $fUidUncropped, + $width, + $height, + ), + sprintf( + '@^<img src="(%s/%s)" width="%d" height="%d" alt="" />$@', + $storageDirFal, + 'FAL' . $fn, + $width, + $height, + ), + null, + false, + ]; + + $expected[sprintf('empty crop (uncrop-UID %d)', $fUidUncropped)] = [ + sprintf( + '<f:image src="%1$d" treatIdAsReference="true" width="%2$d" height="%3$d" crop="null" />', + $fUidUncropped, + $width, + $height, + ), + sprintf( + '@^<img src="(%s/%s)" width="%d" height="%d" alt="" />$@', + $storageDirFal, + 'FAL' . $fn, + $width, + $height, + ), + null, + false, + ]; + + $expected[sprintf('crop as array - forced 60px (uncrop-UID %d)', $fUidUncropped)] = [ + sprintf( + '<f:image src="%1$d" treatIdAsReference="true" width="%2$d" height="%3$d" crop="{\'default\':{\'cropArea\':{\'width\':%4$s,\'height\':%4$s,\'x\':%5$s,\'y\':%5$s},\'selectedRatio\':\'1:1\',\'focusArea\':null}}" />', + $fUidUncropped, + $dimensionMap[$fn]['fixedCrop60px'][0], // width + $dimensionMap[$fn]['fixedCrop60px'][1], // height + $dimensionMap[$fn]['fixedCrop60px'][2], // crop-string width/height + $dimensionMap[$fn]['fixedCrop60px'][3] // crop-string offset left/top + ), + sprintf( + '@^<img src="(%s/csm_FALImageViewHelperTest%d_.*\.svg)" width="%d" height="%d" alt="" />$@', + $storageDirFalTemp, + $i, + $dimensionMap[$fn]['fixedCrop60px'][0], + $dimensionMap[$fn]['fixedCrop60px'][1], + ), + $dimensionMap[$fn]['fixedCrop60px'][4] . ' ' . $dimensionMap[$fn]['fixedCrop60px'][5] . ' ' . $dimensionMap[$fn]['fixedCrop60px'][0] . ' ' . $dimensionMap[$fn]['fixedCrop60px'][1], + ]; + + $expected[sprintf('crop as array - no width/height (uncrop-UID %d)', $fUidUncropped)] = [ + sprintf( + '<f:image src="%1$d" treatIdAsReference="true" crop="{\'default\':{\'cropArea\':{\'width\':%2$s,\'height\':%2$s,\'x\':%3$s,\'y\':%3$s},\'selectedRatio\':\'1:1\',\'focusArea\':null}}" />', + $fUidUncropped, + $dimensionMap[$fn]['relativeCrop80Percent'][2], // crop-string width/height + $dimensionMap[$fn]['relativeCrop80Percent'][3] // crop-string offset left/top + ), + sprintf( + '@^<img src="(%s/csm_FALImageViewHelperTest%d_.*\.svg)" width="%d" height="%d" alt="" />$@', + $storageDirFalTemp, + $i, + $dimensionMap[$fn]['relativeCrop80Percent'][0], + $dimensionMap[$fn]['relativeCrop80Percent'][1], + ), + $dimensionMap[$fn]['relativeCrop80Percent'][4] . ' ' . $dimensionMap[$fn]['relativeCrop80Percent'][5] . ' ' . $dimensionMap[$fn]['relativeCrop80Percent'][0] . ' ' . $dimensionMap[$fn]['relativeCrop80Percent'][1], + ]; + + $expected[sprintf('force pixel-conversion, no crop (uncrop-UID %d)', $fUidUncropped)] = [ + sprintf( + '<f:image src="%1$d" treatIdAsReference="true" width="%2$d" height="%3$d" fileExtension="png" />', + $fUidUncropped, + $width, + $height, + ), + sprintf( + '@^<img src="(%s/csm_FALImageViewHelperTest%d_.*\.png)" width="%d" height="%d" alt="" />$@', + $storageDirFalTemp, + $i, + $width, + $height, + ), + null, + ]; + + $expected[sprintf('force pixel-conversion, with crop (uncrop-UID %d)', $fUidUncropped)] = [ + sprintf( + '<f:image src="%1$d" treatIdAsReference="true" fileExtension="png" width="%2$d" height="%3$d" crop="{\'default\':{\'cropArea\':{\'width\':%4$s,\'height\':%4$s,\'x\':%5$s,\'y\':%5$s},\'selectedRatio\':\'1:1\',\'focusArea\':null}}" />', + $fUidUncropped, + $dimensionMap[$fn]['relativeCrop80Percent'][0], // width + $dimensionMap[$fn]['relativeCrop80Percent'][1], // height + $dimensionMap[$fn]['relativeCrop80Percent'][2], // crop-string width/height + $dimensionMap[$fn]['relativeCrop80Percent'][3] // crop-string offset left/top + ), + sprintf( + '@^<img src="(%s/csm_FALImageViewHelperTest%d_.*\.png)" width="%d" height="%d" alt="" />$@', + $storageDirFalTemp, + $i, + $dimensionMap[$fn]['relativeCrop80Percent'][0], + $dimensionMap[$fn]['relativeCrop80Percent'][1], + ), + $dimensionMap[$fn]['relativeCrop80Percent'][4] . ' ' . $dimensionMap[$fn]['relativeCrop80Percent'][5] . $dimensionMap[$fn]['relativeCrop80Percent'][0] . ' ' . $dimensionMap[$fn]['relativeCrop80Percent'][1], + ]; + //############################################################################ + } + + // Iterate the whole array, utilize and test f:uri.image with the same inputs. + // This is done if in the future the two viewHelpers may diverge from each other, + // to still perform all tests properly. + $uriImageCopy = $expected; + foreach ($uriImageCopy as $expectedKey => $imageViewHelperGreatExpectations) { + // Switch and bait execution string + $imageViewHelperGreatExpectations[0] = str_replace('<f:image', '<f:uri.image', $imageViewHelperGreatExpectations[0]); + // ... and expectation string + $imageViewHelperGreatExpectations[1] = '@^' . preg_replace('@^.+src="(.+)".+$@imsU', '\1', $imageViewHelperGreatExpectations[1]) . '$@'; + + // ... and append to the main data provider + $expected[$expectedKey . ' (f:uri.image)'] = $imageViewHelperGreatExpectations; + } + + return $expected; + } + + #[DataProvider('renderReturnsExpectedMarkupDataProvider')] + #[Test] + public function renderReturnsExpectedMarkup(string $template, string $expected, string|null $cropResult, bool $expectProcessedFile = true): void + { + $context = $this->get(RenderingContextFactory::class)->create(); + $context->getTemplatePaths()->setTemplateSource($template); + $actual = (new TemplateView($context))->render(); + self::assertMatchesRegularExpression($expected, $actual); + + $dumpTables = [ + 'sys_file_processedfile' => 1, + ]; + + foreach ($dumpTables as $dumpTable => $expectedRecords) { + $queryBuilder = $this->getConnectionPool()->getQueryBuilderForTable($dumpTable); + $rows = + $queryBuilder + ->select('*') + ->from($dumpTable) + ->executeQuery() + ->fetchAllAssociative(); + + self::assertEquals(count($rows), $expectedRecords, sprintf('Expected post-conversion database records in %s do not match.', $dumpTable)); + + if ($dumpTable === 'sys_file_processedfile' && $expectProcessedFile) { + // Only SVGs count + if (str_ends_with($rows[0]['identifier'], '.svg')) { + $this->verifySvg($rows[0], $cropResult); + } + } + } + } + + protected function verifySvg(array $file, string|null $cropResult) + { + if ($file['storage'] == 1) { + $dir = Environment::getPublicPath() . '/fileadmin'; + } else { + $dir = Environment::getPublicPath(); + } + + $svg = new \DOMDocument(); + $svg->load($dir . $file['identifier']); + + self::assertEquals($file['width'], $svg->documentElement->getAttribute('width'), 'SVG "width" mismatch.'); + self::assertEquals($file['height'], $svg->documentElement->getAttribute('height'), 'SVG "height" mismatch.'); + self::assertEquals($cropResult, $svg->documentElement->getAttribute('viewBox'), 'SVG "viewBox" (crop) mismatch.'); + unlink($dir . $file['identifier']); + } + +} diff --git a/typo3/sysext/frontend/Tests/Functional/Rendering/Fixtures/SvgImageRenderingTest.typoscript b/typo3/sysext/frontend/Tests/Functional/Rendering/Fixtures/SvgImageRenderingTest.typoscript new file mode 100644 index 000000000000..6654132e3eb8 --- /dev/null +++ b/typo3/sysext/frontend/Tests/Functional/Rendering/Fixtures/SvgImageRenderingTest.typoscript @@ -0,0 +1,175 @@ +page = PAGE +page { + # SECTION1: Specific files, forced crop + 20 = IMAGE + 20 { + file = {$localImage1} + file.crop = 10,10,500,500 + } + + 30 = IMAGE + 30 { + file = {$localImage2} + file.crop = 10,10,500,500 + } + + 40 = IMAGE + 40 { + file = {$localImage3} + file.crop = 10,10,500,500 + } + + 50 = IMAGE + 50 { + file = {$localImage4} + file.crop = 10,10,500,500 + } + + # SECTION2: Specific files, forced crop, force pixel image + 120 = IMAGE + 120 { + file = {$localImage1} + file.crop = 10,10,500,500 + file.ext = png + } + + 130 = IMAGE + 130 { + file = {$localImage2} + file.crop = 10,10,500,500 + file.ext = png + } + + 140 = IMAGE + 140 { + file = {$localImage3} + file.crop = 10,10,500,500 + file.ext = png + } + + 150 = IMAGE + 150 { + file = {$localImage4} + file.crop = 10,10,500,500 + file.ext = png + } + + + # SECTION3: FAL Items, sys_file_reference crop + 220 = IMAGE + 220 { + file = {$localImage1Uid} + file.treatIdAsReference = true + } + + 230 = IMAGE + 230 { + file = {$localImage2Uid} + file.treatIdAsReference = true + } + + 240 = IMAGE + 240 { + file = {$localImage3Uid} + file.treatIdAsReference = true + } + + 250 = IMAGE + 250 { + file = {$localImage4Uid} + file.treatIdAsReference = true + } + + # SECTION4: FAL Items, sys_file_reference crop, force pixel image + 320 = IMAGE + 320 { + file = {$localImage1Uid} + file.ext = png + file.treatIdAsReference = true + } + + 330 = IMAGE + 330 { + file = {$localImage2Uid} + file.ext = png + file.treatIdAsReference = true + + } + + 340 = IMAGE + 340 { + file = {$localImage3Uid} + file.ext = png + file.treatIdAsReference = true + } + + 350 = IMAGE + 350 { + file = {$localImage4Uid} + file.ext = png + file.treatIdAsReference = true + } + + # SECTION5: FAL Items, override crop + 420 = IMAGE + 420 { + file = {$localImage1UidUncrop} + file.crop = 10,10,500,500 + file.treatIdAsReference = true + } + + 430 = IMAGE + 430 { + file = {$localImage2UidUncrop} + file.crop = 10,10,500,500 + file.treatIdAsReference = true + } + + 440 = IMAGE + 440 { + file = {$localImage3UidUncrop} + file.crop = 10,10,500,500 + file.treatIdAsReference = true + } + + 450 = IMAGE + 450 { + file = {$localImage4UidUncrop} + file.crop = 10,10,500,500 + file.treatIdAsReference = true + } + + # SECTION6: FAL Items, override crop, force pixel image + 520 = IMAGE + 520 { + file = {$localImage1UidUncrop} + file.crop = 10,10,500,500 + file.ext = png + file.treatIdAsReference = true + } + + 530 = IMAGE + 530 { + file = {$localImage2UidUncrop} + file.crop = 10,10,500,500 + file.ext = png + file.treatIdAsReference = true + } + + 540 = IMAGE + 540 { + file = {$localImage3UidUncrop} + file.crop = 10,10,500,500 + file.ext = png + file.treatIdAsReference = true + } + + 550 = IMAGE + 550 { + file = {$localImage4UidUncrop} + file.crop = 10,10,500,500 + file.ext = png + file.treatIdAsReference = true + } + +} diff --git a/typo3/sysext/frontend/Tests/Functional/Rendering/SvgImageRenderingTest.php b/typo3/sysext/frontend/Tests/Functional/Rendering/SvgImageRenderingTest.php new file mode 100644 index 000000000000..403f5c4af6c9 --- /dev/null +++ b/typo3/sysext/frontend/Tests/Functional/Rendering/SvgImageRenderingTest.php @@ -0,0 +1,182 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Frontend\Tests\Functional\Rendering; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Tests\Functional\SiteHandling\SiteBasedTestTrait; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; + +final class SvgImageRenderingTest extends FunctionalTestCase +{ + use SiteBasedTestTrait; + + /** + * @var string[] + */ + private array $definedResources = [ + 'localImage1' => 'typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest1.svg', + 'localImage2' => 'typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest2.svg', + 'localImage3' => 'typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest3.svg', + 'localImage4' => 'typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest4.svg', + + 'localImage1Uid' => '1', + 'localImage2Uid' => '2', + 'localImage3Uid' => '3', + 'localImage4Uid' => '4', + + 'localImage1UidUncrop' => '6', + 'localImage2UidUncrop' => '7', + 'localImage3UidUncrop' => '8', + 'localImage4UidUncrop' => '9', + ]; + + protected array $pathsToProvideInTestInstance = [ + 'typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest1.svg' => 'fileadmin/user_upload/FALImageViewHelperTest1.svg', + 'typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest2.svg' => 'fileadmin/user_upload/FALImageViewHelperTest2.svg', + 'typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest3.svg' => 'fileadmin/user_upload/FALImageViewHelperTest3.svg', + 'typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest4.svg' => 'fileadmin/user_upload/FALImageViewHelperTest4.svg', + 'typo3/sysext/fluid/Tests/Functional/Fixtures/ViewHelpers/ImageViewHelperTest5.svg' => 'fileadmin/user_upload/FALImageViewHelperTest5.svg', + ]; + + protected array $additionalFoldersToCreate = [ + '/fileadmin/user_upload', + ]; + + protected array $coreExtensionsToLoad = ['rte_ckeditor']; + + protected const LANGUAGE_PRESETS = [ + 'EN' => ['id' => 0, 'title' => 'English', 'locale' => 'en_US.UTF8', 'iso' => 'en'], + ]; + + protected function setUp(): void + { + parent::setUp(); + $this->importCsvDataSet(__DIR__ . '/../../../../core/Tests/Functional/Fixtures/pages.csv'); + $this->importCSVDataSet(__DIR__ . '/../../../../fluid/Tests/Functional/Fixtures/crops.csv'); + + $this->writeSiteConfiguration( + 'test', + $this->buildSiteConfiguration(1, '/'), + [ + $this->buildDefaultLanguageConfiguration('EN', '/en/'), + ], + $this->buildErrorHandlingConfiguration('Fluid', [404]), + ); + $this->setUpFrontendRootPage( + 1, + ['EXT:frontend/Tests/Functional/Rendering/Fixtures/SvgImageRenderingTest.typoscript'] + ); + $this->setTypoScriptConstantsToTemplateRecord( + 1, + $this->compileTypoScriptConstants($this->definedResources) + ); + } + + public static function svgsAreRenderedWithTyposcriptDataProvider(): array + { + return [ + 'rendered svg assets contains' => [ + [ + '@<img src="/typo3temp/assets/_processed_/[0-9a-f]/[0-9a-f]/csm_ImageViewHelperTest1_.*\.svg" width="500" height="500"\s+alt=""\s+/?>@U', + '@<img src="/typo3temp/assets/_processed_/[0-9a-f]/[0-9a-f]/csm_ImageViewHelperTest2_.*\.svg" width="500" height="500"\s+alt=""\s+/?>@U', + '@<img src="/typo3temp/assets/_processed_/[0-9a-f]/[0-9a-f]/csm_ImageViewHelperTest3_.*\.svg" width="500" height="500"\s+alt=""\s+/?>@U', + '@<img src="/typo3temp/assets/_processed_/[0-9a-f]/[0-9a-f]/csm_ImageViewHelperTest4_.*\.svg" width="500" height="500"\s+alt=""\s+/?>@U', + + '@<img src="/typo3temp/assets/_processed_/[0-9a-f]/[0-9a-f]/csm_ImageViewHelperTest1_.*\.png" width="500" height="500"\s+alt=""\s+/?>@U', + // @todo should be 273x273 (or 274x274) + '@<img src="/typo3temp/assets/_processed_/[0-9a-f]/[0-9a-f]/csm_ImageViewHelperTest2_.*\.png" width="500" height="500"\s+alt=""\s+/?>@U', + '@<img src="/typo3temp/assets/_processed_/[0-9a-f]/[0-9a-f]/csm_ImageViewHelperTest3_.*\.png" width="500" height="500"\s+alt=""\s+/?>@U', + '@<img src="/typo3temp/assets/_processed_/[0-9a-f]/[0-9a-f]/csm_ImageViewHelperTest4_.*\.png" width="500" height="500"\s+alt=""\s+/?>@U', + + '@<img src="/fileadmin/_processed_/[0-9a-f]/[0-9a-f]/csm_FALImageViewHelperTest1_.*\.svg" width="231" height="238"\s+alt=""\s+/?>@U', + '@<img src="/fileadmin/_processed_/[0-9a-f]/[0-9a-f]/csm_FALImageViewHelperTest2_.*\.svg" width="241" height="60"\s+alt=""\s+/?>@U', + '@<img src="/fileadmin/_processed_/[0-9a-f]/[0-9a-f]/csm_FALImageViewHelperTest3_.*\.svg" width="176" height="320"\s+alt=""\s+/?>@U', + '@<img src="/fileadmin/_processed_/[0-9a-f]/[0-9a-f]/csm_FALImageViewHelperTest4_.*\.svg" width="114" height="131"\s+alt=""\s+/?>@U', + + '@<img src="/fileadmin/_processed_/[0-9a-f]/[0-9a-f]/csm_FALImageViewHelperTest1_.*\.png" width="231" height="238"\s+alt=""\s+/?>@U', + '@<img src="/fileadmin/_processed_/[0-9a-f]/[0-9a-f]/csm_FALImageViewHelperTest2_.*\.png" width="241" height="60"\s+alt=""\s+/?>@U', + '@<img src="/fileadmin/_processed_/[0-9a-f]/[0-9a-f]/csm_FALImageViewHelperTest3_.*\.png" width="176" height="320"\s+alt=""\s+/?>@U', + '@<img src="/fileadmin/_processed_/[0-9a-f]/[0-9a-f]/csm_FALImageViewHelperTest4_.*\.png" width="114" height="131"\s+alt=""\s+/?>@U', + + '@<img src="/fileadmin/_processed_/[0-9a-f]/[0-9a-f]/csm_FALImageViewHelperTest1_.*\.svg" width="500" height="500"\s+alt=""\s+/?>@U', + '@<img src="/fileadmin/_processed_/[0-9a-f]/[0-9a-f]/csm_FALImageViewHelperTest2_.*\.svg" width="500" height="500"\s+alt=""\s+/?>@U', + '@<img src="/fileadmin/_processed_/[0-9a-f]/[0-9a-f]/csm_FALImageViewHelperTest3_.*\.svg" width="500" height="500"\s+alt=""\s+/?>@U', + '@<img src="/fileadmin/_processed_/[0-9a-f]/[0-9a-f]/csm_FALImageViewHelperTest4_.*\.svg" width="500" height="500"\s+alt=""\s+/?>@U', + + '@<img src="/fileadmin/_processed_/[0-9a-f]/[0-9a-f]/csm_FALImageViewHelperTest1_.*\.png" width="500" height="500"\s+alt=""\s+/?>@U', + // @todo should be 273x273 (or 274x274) + '@<img src="/fileadmin/_processed_/[0-9a-f]/[0-9a-f]/csm_FALImageViewHelperTest2_.*\.png" width="500" height="500"\s+alt=""\s+/?>@U', + '@<img src="/fileadmin/_processed_/[0-9a-f]/[0-9a-f]/csm_FALImageViewHelperTest3_.*\.png" width="500" height="500"\s+alt=""\s+/?>@U', + '@<img src="/fileadmin/_processed_/[0-9a-f]/[0-9a-f]/csm_FALImageViewHelperTest4_.*\.png" width="500" height="500"\s+alt=""\s+/?>@U', + ], + ], + ]; + } + + #[DataProvider('svgsAreRenderedWithTyposcriptDataProvider')] + #[Test] + public function svgsAreRenderedWithTyposcript(array $expectedAssets): void + { + $response = $this->executeFrontendSubRequest( + (new InternalRequest())->withQueryParameters([ + 'id' => 1, + ]) + ); + $content = (string)$response->getBody(); + + preg_match('@<body>(.+)</body>@imsU', $content, $bodyContent); + self::assertIsArray($bodyContent); + + foreach ($expectedAssets as $expectedAsset) { + self::assertMatchesRegularExpression($expectedAsset, $bodyContent[1]); + } + } + + /** + * Adds TypoScript constants snippet to the existing template record + */ + protected function setTypoScriptConstantsToTemplateRecord(int $pageId, string $constants, bool $append = false): void + { + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_template'); + + $template = $connection->select(['uid', 'constants'], 'sys_template', ['pid' => $pageId, 'root' => 1])->fetchAssociative(); + if (empty($template)) { + self::fail('Cannot find root template on page with id: "' . $pageId . '"'); + } + $updateFields = []; + $updateFields['constants'] = ($append ? $template['constants'] . LF : '') . $constants; + $connection->update( + 'sys_template', + $updateFields, + ['uid' => $template['uid']] + ); + } + + protected function compileTypoScriptConstants(array $constants): string + { + $lines = []; + foreach ($constants as $constantName => $constantValue) { + $lines[] = $constantName . ' = ' . $constantValue; + } + return implode(PHP_EOL, $lines); + } +} -- GitLab