diff --git a/typo3/sysext/core/Classes/Imaging/SvgManipulation.php b/typo3/sysext/core/Classes/Imaging/SvgManipulation.php new file mode 100644 index 0000000000000000000000000000000000000000..6d002d306246b7b2b9fd84d7b178d58d436be4bd --- /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 a04b59390220143a26254ac0074155c0bfa56d80..c3d15a0dbc0c316191daabd3d99b3965baa70a69 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 1e2d92858dbfc4fe056d96061d12963c60d63ba0..59e87cf6401ddb3f19c5ea70aa290485c3e4909b 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 614133b8078b1dc32a1aad2e57525d60f73ceb1c..8b8cb4a719d8c73e6927b494d7466236ff87135a 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 94db206282832137a2c5467ec71e5c3ae42b4db1..d03b4d4fc8b680e9a4f3c54bb6805ab3f6c6c22e 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 925a454419f74544ca9e76ee22f7c52b8f79aaf1..bc66c8b027fdc0f686448dbaeefd00f257ee5aad 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 0000000000000000000000000000000000000000..667c9cb2c12751968dc769b7decd85ab38d0c1f5 --- /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 0000000000000000000000000000000000000000..0724b4cedbf688ca572c6e049dc96ca151b69012 --- /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 0000000000000000000000000000000000000000..28e9bd0f3cfc2cffd3e3ff3b41127dfcfea5f650 --- /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 0000000000000000000000000000000000000000..d768def3922eecf0be501f708e574d6e65728865 --- /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 0000000000000000000000000000000000000000..f227bede140c5cf4585ab7c7dfc4ee1eee59aa5c --- /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 0000000000000000000000000000000000000000..bd5186f83cf6e22449aa5aca6bac4a78f3923fec --- /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 0000000000000000000000000000000000000000..620ec8e6452a9bb575555dd070e1b77ba19fcfae --- /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 0000000000000000000000000000000000000000..db6c0536e7c48c3c868670ebeeecc1658b4353bc --- /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 0000000000000000000000000000000000000000..6654132e3eb8f5086735ac1bf489859b95185062 --- /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 0000000000000000000000000000000000000000..403f5c4af6c9c7253ff1d2ffe37aed26a4b366f2 --- /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); + } +}