TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:09 +02:00
commit af8cc155b5
6818 changed files with 642608 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
<?php
/*
* 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;
/**
* Dimension class holds width and height for an icon
*/
class Dimension
{
/**
* @var int
*/
protected int $width;
/**
* @var int
*/
protected int $height;
/**
* @throws \InvalidArgumentException
*/
public function __construct(IconSize $size = IconSize::MEDIUM)
{
$dimensions = $size->getDimensions();
$this->width = $dimensions[0];
$this->height = $dimensions[1];
}
public function getWidth(): int
{
return $this->width;
}
public function getHeight(): int
{
return $this->height;
}
}
@@ -0,0 +1,71 @@
<?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\Event;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Resource\ResourceInterface;
/**
* This is an Event every time an icon for a resource (file or folder) is fetched, allowing
* to modify the icon or overlay in an event listener.
*/
final class ModifyIconForResourcePropertiesEvent
{
public function __construct(
private readonly ResourceInterface $resource,
private readonly IconSize $size,
private readonly array $options,
private ?string $iconIdentifier,
private ?string $overlayIdentifier
) {}
public function getResource(): ResourceInterface
{
return $this->resource;
}
public function getIconSize(): IconSize
{
return $this->size;
}
public function getOptions(): array
{
return $this->options;
}
public function getIconIdentifier(): ?string
{
return $this->iconIdentifier;
}
public function setIconIdentifier(?string $iconIdentifier): void
{
$this->iconIdentifier = $iconIdentifier;
}
public function getOverlayIdentifier(): ?string
{
return $this->overlayIdentifier;
}
public function setOverlayIdentifier(?string $overlayIdentifier): void
{
$this->overlayIdentifier = $overlayIdentifier;
}
}
@@ -0,0 +1,56 @@
<?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\Event;
/**
* Listeners to this event are able to modify the overlay icon identifier of any record icon
*/
final class ModifyRecordOverlayIconIdentifierEvent
{
public function __construct(
private string $overlayIconIdentifier,
private readonly string $table,
private readonly array $row,
private readonly array $status,
) {}
public function setOverlayIconIdentifier(string $overlayIconIdentifier): void
{
$this->overlayIconIdentifier = $overlayIconIdentifier;
}
public function getOverlayIconIdentifier(): string
{
return $this->overlayIconIdentifier;
}
public function getTable(): string
{
return $this->table;
}
public function getRow(): array
{
return $this->row;
}
public function getStatus(): array
{
return $this->status;
}
}
@@ -0,0 +1,25 @@
<?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\Exception;
use TYPO3\CMS\Core\Exception;
/**
* Thrown when an SVG document cannot be loaded or parsed.
*/
class InvalidSvgException extends Exception {}
@@ -0,0 +1,25 @@
<?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\Exception;
use TYPO3\CMS\Core\Exception;
/**
* Thrown when a file type is not supported.
*/
class UnsupportedFileException extends Exception {}
@@ -0,0 +1,26 @@
<?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\Exception;
use TYPO3\CMS\Core\Exception;
/**
* This exception is thrown when an image is tasked to be processed with
* dimensions of zero.
*/
class ZeroImageDimensionException extends Exception {}
+840
View File
@@ -0,0 +1,840 @@
<?php
/*
* 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\Core\Environment;
use TYPO3\CMS\Core\Type\File\ImageInfo;
use TYPO3\CMS\Core\Utility\CommandUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
use TYPO3\CMS\Core\Utility\StringUtility;
/**
* Standard graphical functions
*
* Class contains a bunch of cool functions for manipulating graphics with GDlib/Freetype and ImageMagick.
* VERY OFTEN used with gifbuilder that uses this class and provides a TypoScript API to using these functions
*/
class GraphicalFunctions
{
/**
* If set, the frame pointer is appended to the filenames.
*
* @var bool
*/
public $addFrameSelection = true;
/**
* defines the RGB colorspace to use
*
* @var non-empty-string
*/
protected string $colorspace = 'RGB';
/**
* colorspace names allowed
*
* @var list<non-empty-string>
*/
protected array $allowedColorSpaceNames = [
'CMY',
'CMYK',
'Gray',
'HCL',
'HSB',
'HSL',
'HWB',
'Lab',
'LCH',
'LMS',
'Log',
'Luv',
'OHTA',
'Rec601Luma',
'Rec601YCbCr',
'Rec709Luma',
'Rec709YCbCr',
'RGB',
'sRGB',
'Transparent',
'XYZ',
'YCbCr',
'YCC',
'YIQ',
'YCbCr',
'YUV',
];
/**
* Allowed file extensions perceived as images by TYPO3.
* List should be set to `gif,png,jpeg,jpg` if IM is not available.
* Due to 'avif' still missing support with GraphicsMagick (https://sourceforge.net/p/graphicsmagick/feature-requests/64/),
* this is not enabled by default. But if availability is detected, it is automatically appended to $webImageExt.
* Also, system maintainers can add this format to $GLOBALS['TYPO3_CONF_VARS']['GFX']['imagefile_ext'].
* Please note, this array is populated in the constructor.
*
* @var list<non-empty-string>
*/
protected array $imageFileExt = [];
/**
* Will hold the lookup map of "originalFileExtension" -> "processedFileExtension" according
* to the parsed interpretation of $GLOBALS['TYPO3_CONF_VARS']['GFX']['imageFileConversionFormats']
* within the constructor.
* @var array<string, string> $defaultImagePreview
*/
protected array $defaultImagePreview = [];
/**
* Last resort fallback when the $defaultImagePreview array does not match an entry, or when
* $GLOBALS['TYPO3_CONF_VARS']['GFX']['imageFileConversionFormats'] specifies a fallback (via constructor).
*/
protected string $defaultImagePreviewFallback = 'png';
/**
* Web image extensions (can be shown by a webbrowser)
* Note that 'avif' support is checked on an individual condition, see method resize().
*
* @var list<non-empty-string>
*/
protected array $webImageExt = ['gif', 'jpg', 'jpeg', 'png', 'webp'];
/**
* @var array{jpg: string, jpeg: string, gif: string, png: string, webp: string, avif: string}
*/
public array $cmds = [
'jpg' => '',
'jpeg' => '',
'gif' => '',
'png' => '',
'webp' => '',
'avif' => '',
];
/**
* Whether ImageMagick/GraphicsMagick is enabled or not
*/
protected bool $processorEnabled;
protected bool $mayScaleUp = true;
/**
* Filename prefix for images scaled in imageMagickConvert()
*
* @var string
*/
public $filenamePrefix = '';
/**
* Forcing the output filename of imageMagickConvert() to this value. However after calling imageMagickConvert() it will be set blank again.
*
* @var string
*/
public $imageMagickConvert_forceFileNameBody = '';
/**
* This flag should always be FALSE. If set TRUE, imageMagickConvert will always write a new file to the tempdir! Used for debugging.
*
* @var bool
*/
public $dontCheckForExistingTempFile = false;
/**
* For debugging only.
* Filenames will not be based on mtime and only filename (not path) will be used.
* This key is also included in the hash of the filename...
*
* @var string
*/
public $alternativeOutputKey = '';
/**
* All ImageMagick commands executed is stored in this array for tracking. Used by the Install Tools Image section
*
* @var list<array{0: string, 1: string}>
*/
public $IM_commands = [];
/**
* ImageMagick scaling command; "-auto-orient -geometry" or "-auto-orient -sample". Used in makeText() and imageMagickConvert()
*
* @var non-empty-string
*/
public $scalecmd = '-auto-orient -geometry';
/**
* Used by v5_blur() to simulate 10 continuous steps of blurring
*
* @var non-empty-string
*/
protected string $im5fx_blurSteps = '1x2,2x2,3x2,4x3,5x3,5x4,6x4,7x5,8x5,9x5';
/**
* Used by v5_sharpen() to simulate 10 continuous steps of sharpening.
*
* @var non-empty-string
*/
protected string $im5fx_sharpenSteps = '1x2,2x2,3x2,2x3,3x3,4x3,3x4,4x4,4x5,5x5';
/**
* @var int<1, 100>
*/
protected int $jpegQuality = 85;
/**
* @var int<1, 101>
*/
protected int $webpQuality = 85;
/**
* @var int<1, 100>
*/
protected int $avifQuality = 85;
/**
* Reads configuration information from $GLOBALS['TYPO3_CONF_VARS']['GFX']
* and sets some values in internal variables.
*/
public function __construct()
{
$gfxConf = $GLOBALS['TYPO3_CONF_VARS']['GFX'];
$this->colorspace = $this->getColorspaceFromConfiguration();
$this->processorEnabled = (bool)$gfxConf['processor_enabled'];
$this->jpegQuality = MathUtility::forceIntegerInRange($gfxConf['jpg_quality'], 1, 100, 85);
if (isset($gfxConf['webp_quality'])) {
if ($gfxConf['webp_quality'] === 'lossless') {
$this->webpQuality = 101;
} else {
$this->webpQuality = MathUtility::forceIntegerInRange($gfxConf['webp_quality'], 1, 101, $this->webpQuality);
}
}
if (isset($gfxConf['avif_quality'])) {
$this->avifQuality = MathUtility::forceIntegerInRange($gfxConf['avif_quality'], 1, 100, $this->avifQuality);
}
$this->addFrameSelection = (bool)$gfxConf['processor_allowFrameSelection'];
$this->imageFileExt = GeneralUtility::trimExplode(',', $gfxConf['imagefile_ext']);
// Processor Effects. This is necessary if using ImageMagick 5+.
// Effects in Imagemagick 5+ tends to render very slowly!
// Therefore, must be disabled in order not to perform sharpen, blurring and such.
// but if 'processor_effects' is set, enable effects
if ($gfxConf['processor_effects']) {
$this->cmds['jpg'] = $this->v5_sharpen(10);
$this->cmds['jpeg'] = $this->v5_sharpen(10);
$this->cmds['webp'] = $this->v5_sharpen(10);
$this->cmds['avif'] = $this->v5_sharpen(10);
}
// Secures that images are not scaled up.
$this->mayScaleUp = (bool)$gfxConf['processor_allowUpscaling'];
// Set up default image preview processing formats
$map = $GLOBALS['TYPO3_CONF_VARS']['GFX']['imageFileConversionFormats'] ?? [];
if (!is_array($map)) {
$map = [];
}
// For now only a single file extension is supported as a target format
// ([$originalFileExtension => $processedFileExtension]). Maybe in the future,
// multiple ones can be specified to indicate fallbacks when certain
// formats are not available, or allow to configure things like
// "if X amount of pixels, use format A, else format B".
// Filter the configuration array: Remove non-string entries, evaluate default
// $defaultImagePreviewFallback (if set), populate $defaultImagePreview.
array_walk($map, function ($mapProcessedFileExtension, $mapOriginalFileExtension) {
if (!is_string($mapProcessedFileExtension)) {
return;
}
$mapOriginalFileExtension = trim($mapOriginalFileExtension);
$mapProcessedFileExtension = trim($mapProcessedFileExtension);
if ($mapOriginalFileExtension === 'default') {
$this->defaultImagePreviewFallback = $mapProcessedFileExtension;
} else {
$this->defaultImagePreview[$mapOriginalFileExtension] = $mapProcessedFileExtension;
}
});
}
/**
* Returns the IM command for sharpening with ImageMagick 5
* Uses $this->im5fx_sharpenSteps for translation of the factor to an actual command.
*
* @param int $factor The sharpening factor, 0-100 (effectively in 10 steps)
* @return string The sharpening command, eg. " -sharpen 3x4"
* @see makeText()
* @see IMparams()
* @see v5_blur()
*/
public function v5_sharpen($factor)
{
$factor = MathUtility::forceIntegerInRange((int)ceil($factor / 10), 0, 10);
$sharpenArr = explode(',', ',' . $this->im5fx_sharpenSteps);
$sharpenF = trim($sharpenArr[$factor]);
if ($sharpenF) {
return ' -sharpen ' . $sharpenF;
}
return '';
}
/**
* Returns the IM command for blurring with ImageMagick 5.
* Uses $this->im5fx_blurSteps for translation of the factor to an actual command.
*
* @param int $factor The blurring factor, 0-100 (effectively in 10 steps)
* @return string The blurring command, e.g. " -blur 3x4"
* @see makeText()
* @see IMparams()
* @see v5_sharpen()
*/
public function v5_blur($factor)
{
$factor = MathUtility::forceIntegerInRange((int)ceil($factor / 10), 0, 10);
$blurArr = explode(',', ',' . $this->im5fx_blurSteps);
$blurF = trim($blurArr[$factor]);
if ($blurF) {
return ' -blur ' . $blurF;
}
return '';
}
/**
* Returns a random filename prefixed with "temp_" and then 32 char md5 hash (without extension).
* Used by functions in this class to create truly temporary files for the on-the-fly processing. These files will most likely be deleted right away.
*
* @return string
*/
public function randomName()
{
GeneralUtility::mkdir_deep(Environment::getVarPath() . '/transient/');
return Environment::getVarPath() . '/transient/' . md5(StringUtility::getUniqueId());
}
/***********************************
*
* Scaling, Dimensions of images
*
***********************************/
/**
* A simple call to migrate a file to a different web-based file format. Let's say you want to convert
* a PDF to a PNG, use this method.
* If you want to also resize it, try "resize" instead.
*
* @see resize()
*/
public function convert(string $sourceFile, string $targetFileExtension = 'web'): ?ImageProcessingResult
{
return $this->resize($sourceFile, $targetFileExtension);
}
/**
* Converts $sourceFile to another file in temp-dir of type $targetFileExtension.
*
* @param string $sourceFile The absolute image filepath
* @param string $targetFileExtension New extension, eg. "gif", "png", "jpg", "tif". If $targetFileExtension is NOT set, the new imagefile will be of the original format. If $targetFileExtension = 'WEB' then one of the web-formats is applied.
* @param int|string $width Width. $width / $height is optional. If only one is given the image is scaled proportionally. If an 'm' exists in the $width or $height and if both are present the $width and $height is regarded as the Maximum w/h and the proportions will be kept
* @param int|string $height Height. See $width
* @param string $additionalParameters Additional ImageMagick parameters.
* @param array $options An array with options passed to getImageScale (see this function).
* @param bool $forceCreation If set, then another image than the input imagefile MUST be returned. Otherwise you can risk that the input image is good enough regarding measures etc and is of course not rendered to a new, temporary file in typo3temp/. But this option will force it to.
* @see \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::getImgResource()
* @see maskImageOntoImage()
* @see copyImageOntoImage()
* @see \TYPO3\CMS\Frontend\Imaging\GifBuilder::scale()
* @internal until imageMagickConvert() is marked as deprecated.
*/
public function resize(string $sourceFile, string $targetFileExtension, int|string $width = '', int|string $height = '', string $additionalParameters = '', array $options = [], bool $forceCreation = false): ?ImageProcessingResult
{
if (!$this->processorEnabled) {
// Returning file info right away
return $this->getImageDimensions($sourceFile, true);
}
$info = $this->getImageDimensions($sourceFile, true);
if (!$info) {
return null;
}
$originalFileExtension = $info->getExtension();
// Determine the final target file extension
$targetFileExtension = strtolower(trim($targetFileExtension));
// If no extension is given the original extension is used
$targetFileExtension = $targetFileExtension ?: $originalFileExtension;
$useFallback = false;
if ($targetFileExtension === 'web') {
// This code path is not really triggered anymore. The targetFileExtension
// is already pre-calculated via:
// - TYPO3\CMS\Core\Resource\Processing\ImageCropScaleMaskTask->getTargetFileExtension()
// - TYPO3\CMS\Core\Resource\Processing\ImagePreviewTask->getTargetFileExtension()
// This place only acts as legacy for be:thumbnail helper and manual code calls to
// the the convert() method without an argument.
// This would be the "give me anything web-compatible" case. Ideally it will use the original format to do scaling operations.
// If it's not a web-format, fallback to JPG/PNG will be applied.
// Special case for AVIF format - only use this if supported (ImageMagick: YES, GraphicsMagick: NO)
if (in_array($originalFileExtension, $this->webImageExt, true)) {
$targetFileExtension = $originalFileExtension;
} elseif ($originalFileExtension === 'avif' && $this->avifSupportAvailable()) {
$targetFileExtension = $originalFileExtension;
} else {
$useFallback = true;
}
} elseif (
($targetFileExtension === 'avif' && !$this->avifSupportAvailable())
|| ($targetFileExtension === 'webp' && !$this->webpSupportAvailable())
) {
// Outside the "web-compatible" case above, we also need to check if a
// specific output format can be written.
// For now, only AVIF+WEBP has special support check handling.
$useFallback = true;
}
if ($useFallback) {
// Note that this may change the expected targetFileExtension from something like ".avif" to ".jpg".
// This is evaluated further on in LocalCropScaleMaskHelper->processWithLocalFile() and the
// processed filename will be altered accordingly.
$targetFileExtension = $this->determineDefaultProcessingFileExtension($originalFileExtension);
}
if (!in_array($targetFileExtension, $this->imageFileExt, true)) {
return null;
}
// Clean up additional $params
$additionalParameters = trim($additionalParameters);
// Refers to which frame-number to select in the image. null or 0 will select the first frame, 1 will select the next and so on...
$frame = $this->addFrameSelection && isset($options['frame']) ? (int)$options['frame'] : 0;
$processingInstructions = ImageProcessingInstructions::fromCropScaleValues($info->getWidth(), $info->getHeight(), $width, $height, $options);
$originalWidth = $info->getWidth() ?: $width;
$originalHeight = $info->getHeight() ?: $height;
// Check if conversion should be performed ($noScale - no processing needed).
// $noScale flag is TRUE if the width / height does NOT dictate the image to be scaled. That is if no
// width / height is given or if the destination w/h matches the original image dimensions, or if
// the option to not scale the image is set.
$noScale = !$originalWidth && !$originalHeight || $processingInstructions->width === $info->getWidth() && $processingInstructions->height === $info->getHeight() || !empty($options['noScale']);
if ($noScale && !$processingInstructions->cropArea && !$additionalParameters && !$frame && $targetFileExtension === $info->getExtension() && !$forceCreation) {
// Set the new width and height before returning,
// if the noScale option is set, otherwise the incoming
// values are calculated.
if (!empty($options['noScale'])) {
return new ImageProcessingResult(
$sourceFile,
$processingInstructions->width,
$processingInstructions->height
);
}
return $info;
}
$command = '';
if ($processingInstructions->cropArea) {
$cropArea = $processingInstructions->cropArea;
$command .= ' -crop ' . (int)round($cropArea->getWidth()) . 'x' . (int)round($cropArea->getHeight()) . '+' . (int)round($cropArea->getOffsetLeft()) . '+' . (int)round($cropArea->getOffsetTop()) . '! +repage ';
}
// Start with the default scale command
// check if we should use -sample or -geometry
if ($options['sample'] ?? false) {
$command .= '-auto-orient -sample';
} else {
$command .= $this->scalecmd;
}
// from the IM docs -- https://imagemagick.org/script/command-line-processing.php
// "We see that ImageMagick is very good about preserving aspect ratios of images, to prevent distortion
// of your favorite photos and images. But you might really want the dimensions to be 100x200, thereby
// stretching the image. In this case just tell ImageMagick you really mean it (!) by appending an exclamation
// operator to the geometry. This will force the image size to exactly what you specify.
// So, for example, if you specify 100x200! the dimensions will become exactly 100x200"
$command .= ' ' . $processingInstructions->width . 'x' . $processingInstructions->height . '!';
// Add params
$additionalParameters = $this->modifyImageMagickStripProfileParameters($additionalParameters, $options);
$command .= ($additionalParameters ? ' ' . $additionalParameters : $this->cmds[$targetFileExtension] ?? '');
// Add quality parameter for jpg, jpeg or webp if not already set
if (!str_contains($command, '-quality') && ($targetFileExtension === 'jpg' || $targetFileExtension === 'jpeg')) {
$command .= ' -quality ' . $this->jpegQuality;
}
// Add quality parameter for webp if not already set
if ($targetFileExtension === 'webp') {
if (!str_contains($command, '-quality') && !str_contains($command, 'webp:lossless')) {
if ($this->webpQuality === 101) {
$command .= ' -define webp:lossless=true';
} else {
$command .= ' -quality ' . $this->webpQuality;
}
}
}
if ($targetFileExtension === 'avif' && !str_contains($command, '-quality')) {
$command .= ' -quality ' . $this->avifQuality;
}
// re-apply colorspace-setting for the resulting image so colors don't appear to dark (sRGB instead of RGB)
if (!str_contains($command, '-colorspace')) {
$command .= ' -colorspace ' . CommandUtility::escapeShellArgument($this->colorspace);
}
if ($this->alternativeOutputKey) {
$theOutputName = md5($command . $processingInstructions->cropArea . PathUtility::basename($sourceFile) . $this->alternativeOutputKey . '[' . $frame . ']');
} else {
$theOutputName = md5($command . $processingInstructions->cropArea . $sourceFile . filemtime($sourceFile) . '[' . $frame . ']');
}
if ($this->imageMagickConvert_forceFileNameBody) {
$theOutputName = $this->imageMagickConvert_forceFileNameBody;
$this->imageMagickConvert_forceFileNameBody = '';
}
// Making the temporary filename
GeneralUtility::mkdir_deep(Environment::getPublicPath() . '/typo3temp/assets/images/');
$output = Environment::getPublicPath() . '/typo3temp/assets/images/' . $this->filenamePrefix . $theOutputName . '.' . $targetFileExtension;
if ($this->dontCheckForExistingTempFile || !file_exists($output)) {
$this->imageMagickExec($sourceFile, $output, $command, $frame);
}
if (file_exists($output)) {
// params might change some image data, so this should be calculated again
if ($additionalParameters) {
return $this->getImageDimensions($output, true);
}
return new ImageProcessingResult($output, $processingInstructions->width, $processingInstructions->height);
}
return null;
}
/**
* Converts $imagefile to another file in temp-dir of type $targetFileExtension.
*
* @param string $imagefile The absolute image filepath
* @param string $targetFileExtension New image file extension. If $targetFileExtension is NOT set, the new imagefile will be of the original format. If set to = 'WEB' then one of the web-formats is applied.
* @param string $w Width. $w / $h is optional. If only one is given the image is scaled proportionally. If an 'm' exists in the $w or $h and if both are present the $w and $h is regarded as the Maximum w/h and the proportions will be kept
* @param string $h Height. See $w
* @param string $params Additional ImageMagick parameters.
* @param string $frame Refers to which frame-number to select in the image. '' or 0 will select the first frame, 1 will select the next and so on...
* @param array $options An array with options passed to getImageScale (see this function).
* @param bool $mustCreate If set, then another image than the input imagefile MUST be returned. Otherwise, you can risk that the input image is good enough regarding measures etc and is of course not rendered to a new, temporary file in typo3temp/. But this option will force it to.
* @return array|null [0]/[1] is w/h, [2] is file extension and [3] is the filename.
* @see \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::getImgResource()
* @see \TYPO3\CMS\Frontend\Imaging\GifBuilder::maskImageOntoImage()
* @see \TYPO3\CMS\Frontend\Imaging\GifBuilder::copyImageOntoImage()
* @see \TYPO3\CMS\Frontend\Imaging\GifBuilder::scale()
*/
public function imageMagickConvert($imagefile, $targetFileExtension = '', $w = '', $h = '', $params = '', $frame = '', $options = [], $mustCreate = false)
{
if ($frame !== '') {
$options['frame'] = (int)$frame;
}
$result = $this->resize($imagefile, $targetFileExtension, $w, $h, $params, $options, $mustCreate);
return $result?->toLegacyArray();
}
/**
* This applies an image onto the $inputFile with an additional backgroundImage for the mask
* @internal until API is finalized
*/
public function mask(string $inputFile, string $outputFile, string $maskImage, string $maskBackgroundImage, string $params, array $options)
{
$params = $this->modifyImageMagickStripProfileParameters($params, $options);
$tmpStr = $this->randomName();
// m_mask
$intermediateMaskFile = $tmpStr . '_mask.png';
$this->imageMagickExec($maskImage, $intermediateMaskFile, $params);
// m_bgImg
$intermediateMaskBackgroundFile = $tmpStr . '_bgImg.miff';
$this->imageMagickExec($maskBackgroundImage, $intermediateMaskBackgroundFile, $params);
// The image onto the background
$this->combineExec($intermediateMaskBackgroundFile, $inputFile, $intermediateMaskFile, $outputFile);
// Unlink the temp-images...
@unlink($intermediateMaskFile);
@unlink($intermediateMaskBackgroundFile);
}
/**
* Gets the input image dimensions.
*
* @param string $imageFile The absolute image filepath
* @return ImageProcessingResult|array|null Returns an array where [0]/[1] is w/h, [2] is extension and [3] is the absolute filepath.
* @see imageMagickConvert()
* @see \TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::getImgResource()
*/
public function getImageDimensions(string $imageFile, bool $useResultObject = false): ImageProcessingResult|array|null
{
preg_match('/([^\\.]*)$/', $imageFile, $reg);
if (!file_exists($imageFile)) {
return null;
}
// @todo: check if we actually need this, as ImageInfo deals with this much more professionally
// @todo: "svg" is not part of imageFileExt, but getting image width/height from it is possible.
if (!in_array(strtolower($reg[0]), $this->imageFileExt, true) && strtolower($reg[0]) !== 'svg') {
return null;
}
$imageInfoObject = GeneralUtility::makeInstance(ImageInfo::class, $imageFile);
if ($imageInfoObject->isFile() && $imageInfoObject->getWidth()) {
$result = ImageProcessingResult::createFromImageInfo($imageInfoObject);
return $useResultObject ? $result : $result->toLegacyArray();
}
return null;
}
/***********************************
*
* ImageMagick API functions
*
***********************************/
/**
* Call the identify command
*
* @param string $imagefile The relative to public web path image filepath
* @return array|null Returns an array where [0]/[1] is w/h, [2] is extension, [3] is the filename and [4] the real image type identified by ImageMagick.
*/
public function imageMagickIdentify($imagefile)
{
if (!$this->processorEnabled) {
return null;
}
$result = $this->executeIdentifyCommandForImageFile($imagefile);
if ($result) {
[$width, $height, $fileExtension, $fileType] = explode(' ', $result);
if ((int)$width && (int)$height) {
return [$width, $height, strtolower($fileExtension), $imagefile, strtolower($fileType)];
}
}
return null;
}
/**
* Internal function to execute an IM command fetching information on an image
*
* @param string $imageFile the absolute path to the image
* @return string|null the raw result of the identify command.
*/
protected function executeIdentifyCommandForImageFile(string $imageFile): ?string
{
$frame = $this->addFrameSelection ? 0 : null;
$cmd = CommandUtility::imageMagickCommand(
'identify',
'-format "%w %h %e %m" ' . ImageMagickFile::fromFilePath($imageFile, $frame)
);
$returnVal = [];
CommandUtility::exec($cmd, $returnVal);
$result = array_pop($returnVal);
$this->IM_commands[] = ['identify', $cmd, $result];
return $result;
}
/**
* Executes an ImageMagick "convert" on two filenames, $input and $output using $params before them.
* Can be used for many things, mostly scaling and effects.
*
* @param string $input The relative to public web path image filepath, input file (read from)
* @param string $output The relative to public web path image filepath, output filename (written to)
* @param string $params ImageMagick parameters
* @param int $frame Optional, refers to which frame-number to select in the image. '' or 0
* @return string The result of a call to PHP function "exec()
*/
public function imageMagickExec($input, $output, $params, $frame = 0)
{
if (!$this->processorEnabled) {
return '';
}
// If addFrameSelection is set in the Install Tool, a frame number is added to
// select a specific page of the image (by default this will be the first page)
$frame = $this->addFrameSelection ? (int)$frame : null;
$cmd = CommandUtility::imageMagickCommand(
'convert',
$params
. ' ' . ImageMagickFile::fromFilePath($input, $frame)
. ' ' . CommandUtility::escapeShellArgument($output)
);
$this->IM_commands[] = [$output, $cmd];
$ret = CommandUtility::exec($cmd);
// Change the permissions of the file
GeneralUtility::fixPermissions($output);
return $ret;
}
/**
* Executes an ImageMagick "combine" (or composite in newer times) on four filenames - $input, $overlay and $mask as input files and $output as the output filename (written to)
* Can be used for many things, mostly scaling and effects.
*
* @param string $input The relative to public web path image filepath, bottom file
* @param string $overlay The relative to public web path image filepath, overlay file (top)
* @param string $mask The relative to public web path image filepath, the mask file (grayscale)
* @param string $output The relative to public web path image filepath, output filename (written to)
* @return string
*/
public function combineExec($input, $overlay, $mask, $output)
{
if (!$this->processorEnabled) {
return '';
}
$theMask = $this->randomName() . '.png';
// +matte / -alpha off = no alpha layer in output
$noAlpha = $GLOBALS['TYPO3_CONF_VARS']['GFX']['processor'] === 'ImageMagick' ? ' -alpha off ' : ' +matte ';
$this->imageMagickExec($mask, $theMask, '-colorspace GRAY' . $noAlpha);
$parameters = '-compose over'
. ' -quality ' . $this->jpegQuality
. $noAlpha
. ImageMagickFile::fromFilePath($input) . ' '
. ImageMagickFile::fromFilePath($overlay) . ' '
. ImageMagickFile::fromFilePath($theMask) . ' '
. CommandUtility::escapeShellArgument($output);
$cmd = CommandUtility::imageMagickCommand('combine', $parameters);
$this->IM_commands[] = [$output, $cmd];
$ret = CommandUtility::exec($cmd);
// Change the permissions of the file
GeneralUtility::fixPermissions($output);
if (is_file($theMask)) {
@unlink($theMask);
}
return $ret;
}
/**
* Modifies the parameters for ImageMagick for stripping of profile information.
* Strips profile information of image to save some space ideally
*
* @param string $parameters The parameters to be modified (if required)
*/
protected function modifyImageMagickStripProfileParameters(string $parameters, array $options): string
{
if (!isset($options['stripProfile'])) {
return $parameters;
}
$gfxConf = $GLOBALS['TYPO3_CONF_VARS']['GFX'] ?? [];
// Use legacy processor_stripColorProfileCommand setting if defined, otherwise
// use the preferred configuration option processor_stripColorProfileParameters
$stripColorProfileCommand = $gfxConf['processor_stripColorProfileCommand']
?? implode(' ', array_map(CommandUtility::escapeShellArgument(...), $gfxConf['processor_stripColorProfileParameters'] ?? []));
if ($options['stripProfile'] && $stripColorProfileCommand !== '') {
return $stripColorProfileCommand . ' ' . $parameters;
}
return $parameters . '###SkipStripProfile###';
}
/***********************************
*
* Various IO functions
*
***********************************/
/**
* Helper method available to all GraphicalFunctions/AbstractTask implementations, looks up the definition
* in $GLOBALS['TYPO3_CONF_VARS']['GFX']['imageFileConversionFormats'] to see
* which processing output file format (file extension) should be used, based on
* Used in both GraphicalFunctions and ImageCropScaleMaskTask / ImagePreviewTask
* the file extension of the original file.
* @internal - Will get moved into its own service where it can be API (@todo)
*/
public function determineDefaultProcessingFileExtension(string $originalFileExtension = ''): string
{
$map = $GLOBALS['TYPO3_CONF_VARS']['GFX']['imageFileConversionFormats'] ?? [];
if (!is_array($map) || $map === [] || $originalFileExtension === '') {
// Should never be disabled. Last line of defense.
return $this->defaultImagePreviewFallback;
}
$originalFileExtension = strtolower($originalFileExtension);
// When a wanted file extension is not part of the format list, it needs to be converted to
// the "default" fallback format.
return $this->defaultImagePreview[$originalFileExtension] ?? $this->defaultImagePreviewFallback;
}
/**
* @internal
*/
public function isProcessingEnabled(): bool
{
return $this->processorEnabled;
}
/**
* Check if a specific format is writable with image/graphicsmagick
*
* @internal
*/
public function webpSupportAvailable(): bool
{
return $this->isConvertSupportAvailableForFormat('WEBP');
}
/**
* Check if a specific format is writable with image/graphicsmagick
*
* @internal
*/
public function avifSupportAvailable(): bool
{
return $this->isConvertSupportAvailableForFormat('AVIF');
}
/**
* convert -list format returns all formats, ideally with a line like this:
* "WEBP P rw- WebP Image Format (libwepb v1.3.2, ENCODER ABI 0x020F)"
* "AVIF* HEIC rw+ AV1 Image File Format (1.15.1)"
* only if we have "rw" included, TYPO3 can fully support to read and write webp images.
*
* @internal
*/
public function isConvertSupportAvailableForFormat(string $fileFormat): bool
{
$cmd = CommandUtility::imageMagickCommand('convert', '-list format');
CommandUtility::exec($cmd, $output);
$this->IM_commands[] = ['', $cmd];
foreach ($output as $outputLine) {
$outputLine = trim($outputLine);
if (str_starts_with($outputLine, $fileFormat) && str_contains($outputLine, ' rw')) {
return true;
}
}
return false;
}
/**
* @internal Only used for ext:install, not part of TYPO3 Core API.
*/
public function setImageFileExt(array $imageFileExt): void
{
$this->imageFileExt = $imageFileExt;
}
/**
* @internal Not part of TYPO3 Core API.
*/
public function getImageFileExt(): array
{
return $this->imageFileExt;
}
/**
* Returns the recommended colorspace for a processor or the one set
* in the configuration
*/
protected function getColorspaceFromConfiguration(): string
{
$gfxConf = $GLOBALS['TYPO3_CONF_VARS']['GFX'];
if ($gfxConf['processor'] === 'ImageMagick' && $gfxConf['processor_colorspace'] === '') {
return 'sRGB';
}
if ($gfxConf['processor'] === 'GraphicsMagick' && $gfxConf['processor_colorspace'] === '') {
return 'RGB';
}
return in_array($gfxConf['processor_colorspace'], $this->allowedColorSpaceNames, true) ? $gfxConf['processor_colorspace'] : $this->colorspace;
}
}
+266
View File
@@ -0,0 +1,266 @@
<?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\Utility\GeneralUtility;
/**
* Icon object, holds all information for one icon, identified by the "identifier" property.
* Is available to render itself as string.
*/
class Icon
{
/**
* The identifier which the PHP code that calls the IconFactory hands over
*/
protected string $identifier;
/**
* The title rendered to the icon
*/
protected ?string $title = null;
/**
* The identifier for a possible overlay icon
*/
protected ?Icon $overlayIcon = null;
/**
* Contains the size string ("large", "small" or "default")
*/
protected IconSize $size;
/**
* Flag to indicate if the icon has a spinning animation
*/
protected bool $spinning = false;
/**
* Flag to indicate if the icon should be mirrored in RTL mode
*/
protected bool $bidi = false;
/**
* Contains the state information
*
* @var IconState|null
*/
protected $state;
/**
* @var Dimension
*/
protected $dimension;
/**
* @var string
*/
protected $markup;
/**
* @var array
*/
protected $alternativeMarkups = [];
/**
* @internal this method is used for internal processing, to get the prepared and final markup use render()
*/
public function getMarkup(?string $alternativeMarkupIdentifier = null): string
{
if ($alternativeMarkupIdentifier !== null && isset($this->alternativeMarkups[$alternativeMarkupIdentifier])) {
return $this->alternativeMarkups[$alternativeMarkupIdentifier];
}
return $this->markup;
}
/**
* @return $this
*/
public function setMarkup(string $markup): self
{
$this->markup = $markup;
return $this;
}
public function getAlternativeMarkup(string $markupIdentifier): string
{
return $this->alternativeMarkups[$markupIdentifier] ?: '';
}
/**
* @return $this
*/
public function setAlternativeMarkup(string $markupIdentifier, string $markup): self
{
$this->alternativeMarkups[$markupIdentifier] = $markup;
return $this;
}
public function getIdentifier(): string
{
return $this->identifier;
}
/**
* @return $this
*/
public function setIdentifier(string $identifier): self
{
$this->identifier = $identifier;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
/**
* @return $this
*/
public function setTitle(?string $title): self
{
$this->title = $title;
return $this;
}
public function getOverlayIcon(): ?Icon
{
return $this->overlayIcon;
}
/**
* @return $this
*/
public function setOverlayIcon(?Icon $overlayIcon): self
{
$this->overlayIcon = $overlayIcon;
return $this;
}
public function getSize(): string
{
return $this->size->value;
}
/**
* Sets the size and creates the new dimension
*/
public function setSize(IconSize $size): self
{
$this->size = $size;
$this->dimension = GeneralUtility::makeInstance(Dimension::class, $size);
return $this;
}
public function isSpinning(): bool
{
return $this->spinning;
}
/**
* @return $this
*/
public function setSpinning(bool $spinning): self
{
$this->spinning = $spinning;
return $this;
}
public function isBidi(): bool
{
return $this->bidi;
}
/**
* @return $this
*/
public function setBidi(bool $bidi): self
{
$this->bidi = $bidi;
return $this;
}
public function getState(): IconState
{
return $this->state;
}
/**
* @return $this
*/
public function setState(IconState $state): self
{
$this->state = $state;
return $this;
}
public function getDimension(): Dimension
{
return $this->dimension;
}
public function render(?string $alternativeMarkupIdentifier = null): string
{
$overlayIconMarkup = '';
if ($this->overlayIcon !== null) {
$overlayIconMarkup = '<span class="icon-overlay icon-' . htmlspecialchars($this->overlayIcon->getIdentifier()) . '">' . $this->overlayIcon->getMarkup() . '</span>';
}
return str_replace('{overlayMarkup}', $overlayIconMarkup, $this->wrappedIcon($alternativeMarkupIdentifier));
}
public function __toString(): string
{
return $this->render();
}
/**
* Wrap icon markup in unified HTML code
*/
protected function wrappedIcon(?string $alternativeMarkupIdentifier = null): string
{
$classes = [];
$classes[] = 't3js-icon';
$classes[] = 'icon';
$classes[] = 'icon-size-' . $this->getSize();
$classes[] = 'icon-state-' . htmlspecialchars($this->state instanceof IconState ? $this->state->value : IconState::STATE_DEFAULT->value);
$classes[] = 'icon-' . $this->getIdentifier();
if ($this->isSpinning()) {
$classes[] = 'icon-spin';
}
if ($this->isBidi()) {
$classes[] = 'icon-bidi';
}
$attributes = [];
$attributes['title'] = $this->getTitle();
$attributes['class'] = implode(' ', $classes);
$attributes['data-identifier'] = $this->getIdentifier();
$attributes['aria-hidden'] = 'true';
$markup = [];
$markup[] = '<span ' . GeneralUtility::implodeAttributes($attributes, true) . '>';
$markup[] = ' <span class="icon-markup">';
$markup[] = $this->getMarkup($alternativeMarkupIdentifier);
$markup[] = ' </span>';
$markup[] = ' {overlayMarkup}';
$markup[] = '</span>';
return implode(LF, $markup);
}
}
+465
View File
@@ -0,0 +1,465 @@
<?php
/*
* 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 Psr\Container\ContainerInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Imaging\Event\ModifyIconForResourcePropertiesEvent;
use TYPO3\CMS\Core\Imaging\Event\ModifyRecordOverlayIconIdentifierEvent;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\FolderInterface;
use TYPO3\CMS\Core\Resource\InaccessibleFolder;
use TYPO3\CMS\Core\Resource\ResourceInterface;
use TYPO3\CMS\Core\Schema\TcaSchema;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Versioning\VersionState;
/**
* The main factory class, which acts as the entrypoint for generating an Icon object which
* is responsible for rendering an icon. Checks for the correct icon provider through the IconRegistry.
*/
readonly class IconFactory
{
public function __construct(
private EventDispatcherInterface $eventDispatcher,
private IconRegistry $iconRegistry,
private ContainerInterface $container,
private FrontendInterface $runtimeCache,
) {}
public function getIcon(
string $identifier,
IconSize $size = IconSize::MEDIUM,
?string $overlayIdentifier = null,
?IconState $state = null
): Icon {
$cacheIdentifier = 'icon-factory-' . hash('xxh3', $identifier . $size->value . $overlayIdentifier . ($state->value ?? ''));
$icon = $this->runtimeCache->get($cacheIdentifier);
if ($icon instanceof Icon) {
return $icon;
}
if (!$this->iconRegistry->isDeprecated($identifier) && !$this->iconRegistry->isRegistered($identifier)) {
// If icon identifier is neither deprecated nor registered
$identifier = $this->iconRegistry->getDefaultIconIdentifier();
}
$iconConfiguration = $this->iconRegistry->getIconConfigurationByIdentifier($identifier);
$iconConfiguration['state'] = $state;
$icon = $this->createIcon($identifier, $size, $overlayIdentifier, $iconConfiguration);
/** @var IconProviderInterface $iconProvider */
$iconProvider = $this->container->has($iconConfiguration['provider'])
? $this->container->get($iconConfiguration['provider'])
: GeneralUtility::makeInstance($iconConfiguration['provider']);
$iconProvider->prepareIconMarkup($icon, $iconConfiguration['options']);
$this->runtimeCache->set($cacheIdentifier, $icon);
return $icon;
}
/**
* This method is used throughout the TYPO3 Backend to show icons for a DB record
*/
public function getIconForRecord(string $table, array $row, IconSize $size = IconSize::MEDIUM, ?TcaSchema $schema = null): Icon
{
if ($schema === null) {
$tcaSchemaFactory = GeneralUtility::makeInstance(TcaSchemaFactory::class);
$schema = $tcaSchemaFactory->get($table);
}
$iconIdentifier = $this->mapRecordTypeToIconIdentifier($table, $row, $schema);
$overlayIdentifier = $this->mapRecordTypeToOverlayIdentifier($table, $row, $schema);
return $this->getIcon($iconIdentifier, $size, $overlayIdentifier);
}
/**
* This helper functions looks up the column that is used for the type of the chosen TCA table and then fetches the
* corresponding iconName based on the chosen icon class in this TCA.
* The TCA looks up
* - [ctrl][typeicon_column]
* -
* This method solely takes care of the type of this record, not any statuses used for overlays.
*
* see EXT:core/Configuration/TCA/pages.php for an example with the TCA table "pages"
*
* @param string $table The TCA table
* @param array $row The selected record
* @internal
* @todo: Protect method when FormEngine doesn't need it anymore.
* @return string The icon identifier string for the icon of that DB record
*/
public function mapRecordTypeToIconIdentifier(string $table, array $row, TcaSchema $schema): string
{
$recordType = [];
$ref = null;
if (isset($schema->getRawConfiguration()['typeicon_column'])) {
$column = $schema->getRawConfiguration()['typeicon_column'];
if (isset($row[$column])) {
// even if not properly documented the value of the typeicon_column in a record could be
// an array (multiselect) in typeicon_classes a key could consist of a comma-separated string "foo,bar"
// but mostly it should be only one entry in that array
if (is_array($row[$column])) {
$recordType[1] = implode(',', $row[$column]);
} else {
$recordType[1] = $row[$column];
}
} else {
$recordType[1] = 'default';
}
// Workaround to give nav_hide pages a complete different icon
// Although it's not a separate doctype
// and to give root-pages an own icon
if ($table === 'pages') {
if (($row['nav_hide'] ?? 0) > 0) {
$recordType[2] = $this->getRecordTypeForPageType(
$recordType[1],
'hideinmenu',
$schema
);
}
if (($row['is_siteroot'] ?? 0) > 0) {
$recordType[3] = $this->getRecordTypeForPageType(
$recordType[1],
'root',
$schema
);
}
if (!empty($row['module'])) {
if (is_array($row['module'])) {
// field 'module' is configured as type 'select' in the TCA,
// so the value may have already been converted to an array
$moduleSuffix = reset($row['module']);
} else {
$moduleSuffix = $row['module'];
}
$recordType[4] = 'contains-' . $moduleSuffix;
}
$contentFromPid = is_array($row['content_from_pid'] ?? 0) ? ($row['content_from_pid'][0]['uid'] ?? 0) : $row['content_from_pid'] ?? 0;
if ($contentFromPid > 0) {
if ($row['is_siteroot'] ?? false) {
$recordType[4] = $this->getRecordTypeForPageType(
$recordType[1],
'contentFromPid-root',
$schema
);
} else {
$suffix = (int)$row['nav_hide'] === 0 ? 'contentFromPid' : 'contentFromPid-hideinmenu';
$recordType[4] = $this->getRecordTypeForPageType($recordType[1], $suffix, $schema, 'page');
}
}
}
if (isset($schema->getRawConfiguration()['typeicon_classes'])
&& is_array($schema->getRawConfiguration()['typeicon_classes'])
) {
foreach ($recordType as $key => $type) {
if (isset($schema->getRawConfiguration()['typeicon_classes'][$type])) {
$recordType[$key] = $schema->getRawConfiguration()['typeicon_classes'][$type];
} else {
unset($recordType[$key]);
}
}
$recordType[0] = $schema->getRawConfiguration()['typeicon_classes']['default'] ?? '';
if (isset($schema->getRawConfiguration()['typeicon_classes']['mask'])
&& isset($row[$column]) && is_string($row[$column])
) {
$recordType[5] = str_replace(
'###TYPE###',
$row[$column] ?? '',
$schema->getRawConfiguration()['typeicon_classes']['mask']
);
}
if (isset($schema->getRawConfiguration()['typeicon_classes']['userFunc'])) {
$parameters = ['row' => $row];
$recordType[6] = GeneralUtility::callUserFunction(
$schema->getRawConfiguration()['typeicon_classes']['userFunc'],
$parameters,
$ref
);
}
} else {
foreach ($recordType as &$type) {
$type = 'tcarecords-' . $table . '-' . $type;
}
unset($type);
$recordType[0] = 'tcarecords-' . $table . '-default';
}
} elseif (isset($schema->getRawConfiguration()['typeicon_classes'])
&& is_array($schema->getRawConfiguration()['typeicon_classes'])
) {
$recordType[0] = $schema->getRawConfiguration()['typeicon_classes']['default'];
} else {
$recordType[0] = 'tcarecords-' . $table . '-default';
}
$recordType = array_filter($recordType);
krsort($recordType);
foreach ($recordType as $iconName) {
if ($this->iconRegistry->isRegistered($iconName)) {
return $iconName;
}
}
return $this->iconRegistry->getDefaultIconIdentifier();
}
/**
* Returns recordType for icon based on a typeName and a suffix.
* Fallback to page as typeName if resulting type is not configured.
*/
protected function getRecordTypeForPageType(string $typeName, string $suffix, TcaSchema $schema, string $fallbackTypeName = '1'): string
{
$recordType = $typeName . '-' . $suffix;
// Check if typeicon class exists. If not fallback to page as typeName
if (!isset($schema->getRawConfiguration()['typeicon_classes'][$recordType])) {
$recordType = $fallbackTypeName . '-' . $suffix;
}
return $recordType;
}
/**
* This helper function checks if the DB record ($row) has any special status based on the TCA settings
* like hidden, starttime etc, and then returns a specific icon overlay identifier for the overlay of this DB record
* This method solely takes care of the overlay of this record, not any type
*
* @param string $table The TCA table
* @param array $row The selected record
* @return string The status with the highest priority
*/
protected function mapRecordTypeToOverlayIdentifier(string $table, array $row, TcaSchema $schema): string
{
$tcaCtrl = $schema->getRawConfiguration();
// Calculate for a given record the actual visibility at the moment
$status = [
'hidden' => false,
'starttime' => false,
'endtime' => false,
'futureendtime' => false,
'fe_group' => false,
'deleted' => false,
'protectedSection' => false,
'nav_hide' => !empty($row['nav_hide']),
];
// Icon state based on "enableFields":
if (isset($tcaCtrl['enablecolumns']) && is_array($tcaCtrl['enablecolumns'])) {
$enableColumns = $tcaCtrl['enablecolumns'];
// If "hidden" is enabled:
if (isset($enableColumns['disabled']) && !empty($row[$enableColumns['disabled']])) {
$status['hidden'] = true;
}
if (isset($enableColumns['starttime'])) {
$starttime = $row[$enableColumns['starttime']] ?? null;
if ($starttime !== null) {
if ($starttime instanceof \DateTimeInterface) {
$starttime = $starttime->getTimestamp();
}
// If a "starttime" is set and higher than current time
if ($starttime > $GLOBALS['EXEC_TIME']) {
$status['starttime'] = true;
}
}
}
if (isset($enableColumns['endtime'])) {
$endtime = $row[$enableColumns['endtime']] ?? null;
if ($endtime !== null && $endtime !== 0) {
if ($endtime instanceof \DateTimeInterface) {
$endtime = $endtime->getTimestamp();
}
if ($endtime < $GLOBALS['EXEC_TIME']) {
// End-timing applies at this point.
$status['endtime'] = true;
} else {
// End-timing WILL apply in the future for this element.
$status['futureendtime'] = true;
}
}
}
// If a user-group field is set
if (!empty($enableColumns['fe_group']) && !empty($row[$enableColumns['fe_group']])) {
$status['fe_group'] = true;
}
}
// If "deleted" flag is set (only when listing records which are also deleted!)
if (isset($tcaCtrl['delete']) && !empty($row[$tcaCtrl['delete']])) {
$status['deleted'] = true;
}
// Detecting extendToSubpages (for pages only)
if ($table === 'pages' && (int)($row['extendToSubpages'] ?? 0) > 0) {
$status['protectedSection'] = true;
}
if (VersionState::tryFrom($row['t3ver_state'] ?? 0) === VersionState::DELETE_PLACEHOLDER) {
$status['deleted'] = true;
}
// Now only show the status with the highest priority
$iconName = '';
foreach ($GLOBALS['TYPO3_CONF_VARS']['SYS']['IconFactory']['overlayPriorities'] ?? [] as $priority) {
if ($status[$priority]) {
if (!$GLOBALS['TYPO3_CONF_VARS']['SYS']['IconFactory']['recordStatusMapping'][$priority]) {
throw new \LogicException('Priority ' . $priority . ' is not configured', 1719756056);
}
$iconName = $GLOBALS['TYPO3_CONF_VARS']['SYS']['IconFactory']['recordStatusMapping'][$priority];
break;
}
}
return $this->eventDispatcher->dispatch(
new ModifyRecordOverlayIconIdentifierEvent($iconName, $table, $row, $status)
)->getOverlayIconIdentifier();
}
/**
* Get Icon for a file by its extension
*/
public function getIconForFileExtension(string $fileExtension, IconSize $size = IconSize::MEDIUM, ?string $overlayIdentifier = null): Icon
{
$iconName = $this->iconRegistry->getIconIdentifierForFileExtension($fileExtension);
return $this->getIcon($iconName, $size, $overlayIdentifier);
}
/**
* This method is used throughout the TYPO3 Backend to show icons for files and folders
*
* The method takes care of the translation of file extension to proper icon and for folders
* it will return the icon depending on the role of the folder.
*
* If the given resource is a folder there are some additional options that can be used:
* - mount-root => TRUE (to indicate this is the root of a mount)
* - folder-open => TRUE (to indicate that the folder is opened in the file tree)
*
* There is a hook in place to manipulate the icon name and overlays.
*
* @param array $options An associative array with additional options.
*/
public function getIconForResource(
ResourceInterface $resource,
IconSize $size = IconSize::MEDIUM,
?string $overlayIdentifier = null,
array $options = []
): Icon {
$iconIdentifier = null;
// Folder
if ($resource instanceof FolderInterface) {
// non-browsable storage
if ($resource->getStorage()->isBrowsable() === false && !empty($options['mount-root'])) {
$iconIdentifier = 'apps-filetree-folder-locked';
} else {
// storage root
if ($resource->getStorage()->getRootLevelFolder()->getIdentifier() === $resource->getIdentifier()) {
$iconIdentifier = 'apps-filetree-root';
}
// user/group mount root
if (!empty($options['mount-root'])) {
$iconIdentifier = 'apps-filetree-mount';
if ($resource->getRole() === FolderInterface::ROLE_READONLY_MOUNT) {
$overlayIdentifier = 'overlay-locked';
} elseif ($resource->getRole() === FolderInterface::ROLE_USER_MOUNT) {
$overlayIdentifier = 'overlay-restricted';
}
}
if ($iconIdentifier === null) {
// in folder tree view $options['folder-open'] can define an open folder icon
if (!empty($options['folder-open'])) {
$iconIdentifier = 'apps-filetree-folder-opened';
} else {
$iconIdentifier = 'apps-filetree-folder-default';
}
if ($resource->getRole() === FolderInterface::ROLE_TEMPORARY) {
$iconIdentifier = 'apps-filetree-folder-temp';
} elseif ($resource->getRole() === FolderInterface::ROLE_RECYCLER) {
$iconIdentifier = 'apps-filetree-folder-recycler';
}
}
// if locked add overlay
if ($resource instanceof InaccessibleFolder
|| !$resource->getStorage()->isBrowsable()
|| !$resource->getStorage()->checkFolderActionPermission('add', $resource)
) {
$overlayIdentifier = 'overlay-locked';
}
}
} elseif ($resource instanceof File) {
$mimeTypeIcon = $this->iconRegistry->getIconIdentifierForMimeType($resource->getMimeType());
// Check if we find an exact matching mime type
if ($mimeTypeIcon !== null) {
$iconIdentifier = $mimeTypeIcon;
} else {
$fileExtensionIcon = $this->iconRegistry->getIconIdentifierForFileExtension($resource->getExtension());
if ($fileExtensionIcon !== 'mimetypes-other-other') {
// Fallback 1: icon by file extension
$iconIdentifier = $fileExtensionIcon;
} else {
// Fallback 2: icon by mime type with subtype replaced by *
$mimeTypeParts = explode('/', $resource->getMimeType());
$mimeTypeIcon = $this->iconRegistry->getIconIdentifierForMimeType($mimeTypeParts[0] . '/*');
if ($mimeTypeIcon !== null) {
$iconIdentifier = $mimeTypeIcon;
} else {
// Fallback 3: use 'mimetypes-other-other'
$iconIdentifier = $fileExtensionIcon;
}
}
}
if ($resource->isMissing()) {
$overlayIdentifier = 'overlay-missing';
}
}
$event = $this->eventDispatcher->dispatch(
new ModifyIconForResourcePropertiesEvent(
$resource,
$size,
$options,
$iconIdentifier,
$overlayIdentifier
)
);
return $this->getIcon($event->getIconIdentifier(), $size, $event->getOverlayIdentifier());
}
/**
* Creates an icon object
*
* @param array $iconConfiguration the icon configuration array
*/
protected function createIcon(string $identifier, IconSize $size, ?string $overlayIdentifier = null, array $iconConfiguration = []): Icon
{
$icon = GeneralUtility::makeInstance(Icon::class);
$icon->setIdentifier($identifier);
$icon->setSize($size);
$icon->setState($iconConfiguration['state'] ?? IconState::STATE_DEFAULT);
if (!empty($overlayIdentifier)) {
$icon->setOverlayIcon($this->getIcon($overlayIdentifier, IconSize::OVERLAY));
}
if (!empty($iconConfiguration['options']['spinning'])) {
$icon->setSpinning(true);
}
if (!empty($iconConfiguration['options']['bidi'])) {
$icon->setBidi(true);
}
return $icon;
}
}
@@ -0,0 +1,107 @@
<?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\IconProvider;
use TYPO3\CMS\Core\Imaging\Exception\InvalidSvgException;
use TYPO3\CMS\Core\Imaging\Icon;
use TYPO3\CMS\Core\Imaging\IconProviderInterface;
use TYPO3\CMS\Core\Imaging\Svg\SvgDocumentFactory;
use TYPO3\CMS\Core\Imaging\Svg\SvgDocumentService;
use TYPO3\CMS\Core\SystemResource\Exception\SystemResourceDoesNotExistException;
use TYPO3\CMS\Core\SystemResource\Exception\SystemResourceException;
use TYPO3\CMS\Core\SystemResource\SystemResourceFactory;
use TYPO3\CMS\Core\SystemResource\Type\SystemResourceInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
/**
* Abstract class for all SVG-based icon providers
*
* @internal
*/
abstract class AbstractSvgIconProvider implements IconProviderInterface
{
public const MARKUP_IDENTIFIER_INLINE = 'inline';
protected SvgDocumentFactory $svgDocumentFactory;
protected SvgDocumentService $svgDocumentService;
abstract protected function generateMarkup(Icon $icon, array $options): string;
abstract protected function generateInlineMarkup(array $options): string;
// inject* setters keep the constructor of inheriting providers clean.
public function injectSvgDocumentFactory(SvgDocumentFactory $svgDocumentFactory): void
{
$this->svgDocumentFactory = $svgDocumentFactory;
}
public function injectSvgDocumentService(SvgDocumentService $svgDocumentService): void
{
$this->svgDocumentService = $svgDocumentService;
}
public function prepareIconMarkup(Icon $icon, array $options = []): void
{
$icon->setMarkup($this->generateMarkup($icon, $options));
$icon->setAlternativeMarkup(self::MARKUP_IDENTIFIER_INLINE, $this->generateInlineMarkup($options));
}
/**
* Calculate public path of SVG file
*/
protected function getPublicPath(string $source): string
{
return (string)PathUtility::getSystemResourceUri($source);
}
protected function getInlineSvg(string $source): string
{
$svgContent = $this->getInlineSvgContents($source);
if ($svgContent === null) {
return '';
}
try {
return $this->svgDocumentService->toInlineMarkup(
$this->svgDocumentFactory->fromStringAndSanitize($svgContent)
);
} catch (InvalidSvgException) {
return '';
}
}
protected function getInlineSvgContents(string $source): ?string
{
try {
$resourceFactory = GeneralUtility::makeInstance(SystemResourceFactory::class);
$resource = $resourceFactory->createResource($source);
if ($resource instanceof SystemResourceInterface) {
return $resource->getContents();
}
} catch (SystemResourceDoesNotExistException) {
return null;
} catch (SystemResourceException) {
}
if (!PathUtility::isAbsolutePath($source)) {
$source = GeneralUtility::getFileAbsFileName($source);
}
if (!file_exists($source)) {
return null;
}
return file_get_contents($source) ?: null;
}
}
@@ -0,0 +1,84 @@
<?php
/*
* 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\IconProvider;
use TYPO3\CMS\Core\Imaging\Icon;
use TYPO3\CMS\Core\Imaging\IconProviderInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
/**
* Class provides icons that are classic <img> tags using bitmaps as source
*/
class BitmapIconProvider implements IconProviderInterface
{
public const MARKUP_IDENTIFIER_INLINE = 'inline';
public function prepareIconMarkup(Icon $icon, array $options = [])
{
$icon->setMarkup($this->generateMarkup($icon, $options));
$icon->setAlternativeMarkup(self::MARKUP_IDENTIFIER_INLINE, $this->generateInlineMarkup($icon, $options));
}
/**
* @return string
* @throws \InvalidArgumentException
*/
protected function generateMarkup(Icon $icon, array $options)
{
if (empty($options['source'])) {
throw new \InvalidArgumentException('[' . $icon->getIdentifier() . '] The option "source" is required and must not be empty', 1440754980);
}
$source = $options['source'];
return '<img src="' . htmlspecialchars($this->getPublicPath($source)) . '" width="' . $icon->getDimension()->getWidth() . '" height="' . $icon->getDimension()->getHeight() . '" alt="" />';
}
/**
* Calculate public path of image file
*/
protected function getPublicPath(string $source): string
{
return (string)PathUtility::getSystemResourceUri($source);
}
/**
* @return string
* @throws \InvalidArgumentException
*/
protected function generateInlineMarkup(Icon $icon, array $options)
{
if (empty($options['source'])) {
throw new \InvalidArgumentException('The option "source" is required and must not be empty', 1471460676);
}
$source = $options['source'];
$filePath = PathUtility::isAbsolutePath($source) ? $source : GeneralUtility::getFileAbsFileName($source);
if (!file_exists($filePath)) {
return '';
}
return sprintf(
'<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %1$d %2$d" width="%1$d" height="%2$d"><image width="%1$d" height="%1$d" xlink:href="%3$s"/></svg>',
$icon->getDimension()->getWidth(),
$icon->getDimension()->getHeight(),
$this->getPublicPath($source)
);
}
}
@@ -0,0 +1,51 @@
<?php
/*
* 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\IconProvider;
use TYPO3\CMS\Core\Imaging\Icon;
/**
* Class provides icons that are classic <img> tags using vectors as source
*/
class SvgIconProvider extends AbstractSvgIconProvider
{
/**
* @throws \InvalidArgumentException
*/
protected function generateMarkup(Icon $icon, array $options): string
{
if (empty($options['source'])) {
throw new \InvalidArgumentException('[' . $icon->getIdentifier() . '] The option "source" is required and must not be empty', 1460976566);
}
$source = $options['source'];
return '<img src="' . htmlspecialchars($this->getPublicPath($source)) . '" width="' . $icon->getDimension()->getWidth() . '" height="' . $icon->getDimension()->getHeight() . '" alt="" />';
}
/**
* @throws \InvalidArgumentException
*/
protected function generateInlineMarkup(array $options): string
{
if (empty($options['source'])) {
throw new \InvalidArgumentException('The option "source" is required and must not be empty', 1460976610);
}
$source = $options['source'];
return $this->getInlineSvg($source);
}
}
@@ -0,0 +1,62 @@
<?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\IconProvider;
use TYPO3\CMS\Core\Imaging\Icon;
/**
* SvgSpriteIconProvider provides sprite icons and are rendered via <svg> tag
*/
class SvgSpriteIconProvider extends AbstractSvgIconProvider
{
/**
* @throws \InvalidArgumentException
*/
protected function generateMarkup(Icon $icon, array $options): string
{
if (empty($options['sprite'])) {
throw new \InvalidArgumentException('[' . $icon->getIdentifier() . '] The option "sprite" is required and must not be empty', 1603439142);
}
$source = $options['sprite'];
return $this->generateSpriteUseMarkup($source);
}
/**
* @throws \InvalidArgumentException
*/
protected function generateInlineMarkup(array $options): string
{
if (!empty($options['source'])) {
$source = $options['source'];
return $this->getInlineSvg($source);
}
if (empty($options['sprite'])) {
throw new \InvalidArgumentException('The option "sprite" is required and must not be empty if not source SVG file is provided', 1603439146);
}
$source = $options['sprite'];
return $this->generateSpriteUseMarkup($source);
}
private function generateSpriteUseMarkup(string $sprite): string
{
return '<svg class="icon-color"><use xlink:href="' . htmlspecialchars($this->getPublicPath($sprite)) . '" /></svg>';
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
/*
* 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;
/**
* Interface IconProviderInterface
*/
interface IconProviderInterface
{
/**
* Prepare the icon markup and set it to the icon by setMarkup()
*/
public function prepareIconMarkup(Icon $icon, array $options = []);
}
+576
View File
@@ -0,0 +1,576 @@
<?php
/*
* 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\Cache\Event\CacheWarmupEvent;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Exception;
use TYPO3\CMS\Core\Imaging\IconProvider\BitmapIconProvider;
use TYPO3\CMS\Core\Imaging\IconProvider\SvgIconProvider;
use TYPO3\CMS\Core\Imaging\IconProvider\SvgSpriteIconProvider;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Class which makes it possible to register custom icons
* from within an extension.
*/
class IconRegistry implements SingletonInterface
{
/**
* @var bool
*/
protected $fullInitialized = false;
/**
* @var bool
*/
protected $tcaInitialized = false;
/**
* @var bool
*/
protected $flagsInitialized = false;
/**
* @var bool
*/
protected $backendIconsInitialized = false;
/**
* Registered icons
*
* @var array
*/
protected $icons = [];
/**
* @var string
*/
protected $backendIconDeclaration = 'EXT:core/Resources/Public/Icons/T3Icons/icons.json';
/**
* manually registered icons
* hopefully obsolete one day
*
* @var array
*/
protected $staticIcons = [
/**
* Important Information:
*
* Icons are maintained in an external repository, if new icons are needed
* please request them at: https://github.com/typo3/typo3.icons/issues
*/
];
/**
* Mapping of file extensions to mimetypes
*
* @var string[]
*/
protected $fileExtensionMapping = [
'htm' => 'mimetypes-text-html',
'html' => 'mimetypes-text-html',
'css' => 'mimetypes-text-css',
'js' => 'mimetypes-text-js',
'csv' => 'mimetypes-text-csv',
'php' => 'mimetypes-text-php',
'php6' => 'mimetypes-text-php',
'php5' => 'mimetypes-text-php',
'php4' => 'mimetypes-text-php',
'php3' => 'mimetypes-text-php',
'inc' => 'mimetypes-text-php',
'ts' => 'mimetypes-text-ts',
'typoscript' => 'mimetypes-text-typoscript',
'txt' => 'mimetypes-text-text',
'class' => 'mimetypes-text-text',
'tmpl' => 'mimetypes-text-text',
'jpg' => 'mimetypes-media-image',
'jpeg' => 'mimetypes-media-image',
'gif' => 'mimetypes-media-image',
'png' => 'mimetypes-media-image',
'bmp' => 'mimetypes-media-image',
'tif' => 'mimetypes-media-image',
'tiff' => 'mimetypes-media-image',
'tga' => 'mimetypes-media-image',
'psd' => 'mimetypes-media-image',
'eps' => 'mimetypes-media-image',
'ai' => 'mimetypes-media-image',
'svg' => 'mimetypes-media-image',
'pcx' => 'mimetypes-media-image',
'avi' => 'mimetypes-media-video',
'mpg' => 'mimetypes-media-video',
'mpeg' => 'mimetypes-media-video',
'mov' => 'mimetypes-media-video',
'vimeo' => 'mimetypes-media-video-vimeo',
'youtube' => 'mimetypes-media-video-youtube',
'wav' => 'mimetypes-media-audio',
'mp3' => 'mimetypes-media-audio',
'ogg' => 'mimetypes-media-audio',
'flac' => 'mimetypes-media-audio',
'opus' => 'mimetypes-media-audio',
'mid' => 'mimetypes-media-audio',
'swf' => 'mimetypes-media-flash',
'swa' => 'mimetypes-media-flash',
'exe' => 'mimetypes-application',
'com' => 'mimetypes-application',
't3x' => 'mimetypes-compressed',
't3d' => 'mimetypes-compressed',
'zip' => 'mimetypes-compressed',
'tgz' => 'mimetypes-compressed',
'gz' => 'mimetypes-compressed',
'pdf' => 'mimetypes-pdf',
'doc' => 'mimetypes-word',
'dot' => 'mimetypes-word',
'docm' => 'mimetypes-word',
'docx' => 'mimetypes-word',
'dotm' => 'mimetypes-word',
'dotx' => 'mimetypes-word',
'sxw' => 'mimetypes-word',
'rtf' => 'mimetypes-word',
'xls' => 'mimetypes-excel',
'xlsm' => 'mimetypes-excel',
'xlsx' => 'mimetypes-excel',
'xltm' => 'mimetypes-excel',
'xltx' => 'mimetypes-excel',
'sxc' => 'mimetypes-excel',
'pps' => 'mimetypes-powerpoint',
'ppsx' => 'mimetypes-powerpoint',
'ppt' => 'mimetypes-powerpoint',
'pptm' => 'mimetypes-powerpoint',
'pptx' => 'mimetypes-powerpoint',
'potm' => 'mimetypes-powerpoint',
'potx' => 'mimetypes-powerpoint',
'mount' => 'apps-filetree-mount',
'folder' => 'apps-filetree-folder-default',
'default' => 'mimetypes-other-other',
];
/**
* Mapping of mime types to icons
*
* @var string[]
*/
protected $mimeTypeMapping = [
'video/*' => 'mimetypes-media-video',
'audio/*' => 'mimetypes-media-audio',
'image/*' => 'mimetypes-media-image',
'text/*' => 'mimetypes-text-text',
];
/**
* @var array<string, string>
*/
protected $iconAliases = [];
/**
* Array of deprecated icons, add deprecated icons to this array and remove it from registry
* - Index of this array contains the deprecated icon
* - Value of each entry may contain a possible new identifier
*
* Example:
* [
* 'deprecated-icon-identifier' => ['since' => 'TYPO3 v12', 'until' => 'TYPO3 v13', 'replacement' => 'new-icon-identifier'],
* 'another-deprecated-identifier' => ['since' => 'TYPO3 v12', 'until' => 'TYPO3 v13', 'replacement' => null],
* ]
*
* @var array
*/
protected $deprecatedIcons = [];
/**
* @var string
*/
protected $defaultIconIdentifier = 'default-not-found';
/**
* @var FrontendInterface
*/
protected $cache;
private string $cacheIdentifier;
public function __construct(FrontendInterface $assetsCache, string $cacheIdentifier)
{
$this->cache = $assetsCache;
$this->cacheIdentifier = $cacheIdentifier;
$this->initialize();
}
/**
* Initialize the registry
* This method can be called multiple times, depending on initialization status.
* In some cases e.g. TCA is not available, the method must be called multiple times.
*/
protected function initialize()
{
if (!$this->backendIconsInitialized) {
$this->getCachedBackendIcons();
}
if (!$this->tcaInitialized && !empty($GLOBALS['TCA'])) {
$this->registerTCAIcons();
}
if (!$this->flagsInitialized) {
$this->getCachedFlagIcons();
}
if ($this->backendIconsInitialized
&& $this->tcaInitialized
&& $this->flagsInitialized) {
$this->fullInitialized = true;
}
}
/**
* @internal
*/
public function getBackendIconsCacheIdentifier(): string
{
return $this->cacheIdentifier;
}
/**
* Retrieve the icons from cache render them when not cached yet
*/
protected function getCachedBackendIcons()
{
$cacheIdentifier = $this->getBackendIconsCacheIdentifier();
$cacheEntry = $this->cache->get($cacheIdentifier);
if ($cacheEntry !== false) {
$this->icons = $cacheEntry;
} else {
$this->registerBackendIcons();
// all found icons should now be present, for historic reasons now merge w/ the statically declared icons
$this->icons = array_merge($this->icons, $this->iconAliases, $this->staticIcons);
$this->cache->set($cacheIdentifier, $this->icons);
}
// if there's now at least one icon registered, consider it successful
if (is_array($this->icons) && (count($this->icons) >= count($this->staticIcons))) {
$this->backendIconsInitialized = true;
}
}
/**
* Automatically find and register the core backend icons
*/
protected function registerBackendIcons(): void
{
$dir = dirname($this->backendIconDeclaration);
$absoluteIconDeclarationPath = GeneralUtility::getFileAbsFileName($this->backendIconDeclaration);
$json = json_decode(file_get_contents($absoluteIconDeclarationPath) ?: '', true);
foreach ($json['icons'] ?? [] as $declaration) {
$iconOptions = [
'sprite' => $dir . '/' . $declaration['sprite'],
];
// kind of hotfix for now, needs a nicer concept later
if ($declaration['category'] === 'spinner') {
$iconOptions['spinning'] = true;
}
if (isset($declaration['bidi']) && $declaration['bidi'] === true) {
$iconOptions['bidi'] = true;
}
$this->registerIcon(
$declaration['identifier'],
SvgSpriteIconProvider::class,
$iconOptions
);
}
foreach ($json['aliases'] as $alias => $identifier) {
$this->registerAlias($alias, $identifier);
}
}
/**
* @param string $identifier
* @return bool
*/
public function isRegistered($identifier)
{
if (!$this->fullInitialized) {
$this->initialize();
}
return isset($this->icons[$identifier]);
}
/**
* @param string $identifier
* @return bool
*/
public function isDeprecated($identifier)
{
return isset($this->deprecatedIcons[$identifier]);
}
public function getDefaultIconIdentifier(): string
{
return $this->defaultIconIdentifier;
}
/**
* Registers an icon to be available inside the Icon Factory
*
* @param string $identifier
* @param string $iconProviderClassName
*
* @throws \InvalidArgumentException
*/
public function registerIcon($identifier, $iconProviderClassName, array $options = [])
{
if (!in_array(IconProviderInterface::class, class_implements($iconProviderClassName) ?: [], true)) {
throw new \InvalidArgumentException('An IconProvider must implement '
. IconProviderInterface::class, 1437425803);
}
$this->icons[$identifier] = [
'provider' => $iconProviderClassName,
'options' => $options,
];
if (isset($options['deprecated'])) {
$this->deprecatedIcons[$identifier] = $options['deprecated'];
}
}
/**
* Registers an icon to be available inside the Icon Factory
*
* @param string $alias
* @param string $identifier
*
* @throws \InvalidArgumentException
*/
public function registerAlias($alias, $identifier)
{
if (!isset($this->icons[$identifier])) {
throw new \InvalidArgumentException('No icon with identifier "' . $identifier . '" registered.', 1602251838);
}
$this->iconAliases[$alias] = $this->icons[$identifier];
}
/**
* Register an icon for a file extension
*
* @param string $fileExtension
* @param string $iconIdentifier
*/
public function registerFileExtension($fileExtension, $iconIdentifier)
{
$this->fileExtensionMapping[$fileExtension] = $iconIdentifier;
}
/**
* Register an icon for a mime-type
*
* @param string $mimeType
* @param string $iconIdentifier
*/
public function registerMimeTypeIcon($mimeType, $iconIdentifier)
{
$this->mimeTypeMapping[$mimeType] = $iconIdentifier;
}
/**
* Fetches the configuration provided by registerIcon()
*
* @param string $identifier the icon identifier
* @return mixed
* @throws Exception
*/
public function getIconConfigurationByIdentifier($identifier)
{
if (!$this->fullInitialized) {
$this->initialize();
}
if ($this->isDeprecated($identifier)) {
$deprecation = $this->deprecatedIcons[$identifier];
$since = $deprecation['since'] ?? null;
$until = $deprecation['until'] ?? null;
$replacement = $deprecation['replacement'] ?? null;
$message = 'The icon "%s" is deprecated%s%s.';
$arguments = [
$identifier,
$since !== null ? ' since ' . $since : '',
$until !== null ? ' and will be removed in ' . $until : '',
];
if ($replacement) {
$message .= ' Please use "%s" instead.';
$arguments[] = $replacement;
}
trigger_error(vsprintf($message, $arguments), E_USER_DEPRECATED);
}
if (!$this->isRegistered($identifier)) {
throw new Exception('Icon with identifier "' . $identifier . '" is not registered"', 1437425804);
}
return $this->icons[$identifier];
}
/**
* @return array
*/
public function getAllRegisteredIconIdentifiers()
{
if (!$this->fullInitialized) {
$this->initialize();
}
return array_keys($this->icons);
}
public function getDeprecatedIcons(): array
{
return $this->deprecatedIcons;
}
/**
* @param string $fileExtension
* @return string
*/
public function getIconIdentifierForFileExtension($fileExtension)
{
// If the file extension is not valid use the default one
if (!isset($this->fileExtensionMapping[$fileExtension])) {
$fileExtension = 'default';
}
return $this->fileExtensionMapping[$fileExtension];
}
/**
* Get iconIdentifier for given mimeType
*
* @param string $mimeType
* @return string|null Returns null if no icon is registered for the mimeType
*/
public function getIconIdentifierForMimeType($mimeType)
{
if (!isset($this->mimeTypeMapping[$mimeType])) {
return null;
}
return $this->mimeTypeMapping[$mimeType];
}
/**
* Load icons from TCA for each table and add them as "tcarecords-XX" to $this->icons
*/
protected function registerTCAIcons()
{
$resultArray = [];
$tcaTables = array_keys($GLOBALS['TCA'] ?? []);
// check every table in the TCA, if an icon is needed
foreach ($tcaTables as $tableName) {
// This method is only needed for TCA tables where typeicon_classes are not configured
$iconIdentifier = 'tcarecords-' . $tableName . '-default';
if (
isset($this->icons[$iconIdentifier])
|| !isset($GLOBALS['TCA'][$tableName]['ctrl']['iconfile'])
) {
continue;
}
$resultArray[$iconIdentifier] = $GLOBALS['TCA'][$tableName]['ctrl']['iconfile'];
}
foreach ($resultArray as $iconIdentifier => $iconFilePath) {
$iconProviderClass = $this->detectIconProvider($iconFilePath);
$this->icons[$iconIdentifier] = [
'provider' => $iconProviderClass,
'options' => [
'source' => $iconFilePath,
],
];
}
$this->tcaInitialized = true;
}
protected function getCachedFlagIcons(): void
{
$cacheIdentifier = $this->getBackendIconsCacheIdentifier() . '_flags';
$cacheEntry = $this->cache->get($cacheIdentifier);
if ($cacheEntry === false) {
$cacheEntry = $this->registerFlags();
$this->cache->set($cacheIdentifier, $cacheEntry);
}
$this->icons = array_merge($this->icons, $cacheEntry);
// if there's now at least one icon registered, consider it successful
if (is_array($cacheEntry) && $cacheEntry !== []) {
$this->flagsInitialized = true;
}
}
/**
* Register flags
*/
protected function registerFlags(): array
{
$iconFolder = 'EXT:core/Resources/Public/Icons/Flags/';
$folderPath = GeneralUtility::getFileAbsFileName($iconFolder);
$flagIcons = [];
if ($handle = opendir($folderPath)) {
while (($file = readdir($handle)) !== false) {
$fileInfo = pathinfo($folderPath . $file);
if ($fileInfo['extension'] !== 'webp') {
continue;
}
$flagIcons['flags-' . strtolower($fileInfo['filename'])] = [
'provider' => BitmapIconProvider::class,
'options' => [
'source' => $iconFolder . $file,
],
];
}
closedir($handle);
}
return $flagIcons;
}
/**
* Detect the IconProvider of an icon
*
* @param string $iconReference
* @return string
*/
public function detectIconProvider($iconReference)
{
if (str_ends_with(strtolower((string)$iconReference), 'svg')) {
return SvgIconProvider::class;
}
return BitmapIconProvider::class;
}
public function warmupCaches(CacheWarmupEvent $event): void
{
if ($event->hasGroup('system')) {
$backupIcons = $this->icons;
$backupAliases = $this->iconAliases;
$this->icons = [];
$this->iconAliases = [];
$this->registerBackendIcons();
// all found icons should now be present, for historic reasons now merge w/ the statically declared icons
$this->icons = array_merge($this->icons, $this->iconAliases, $this->staticIcons);
$this->cache->set($this->getBackendIconsCacheIdentifier(), $this->icons);
$this->icons = $backupIcons;
$this->iconAliases = $backupAliases;
}
}
}
+44
View File
@@ -0,0 +1,44 @@
<?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;
enum IconSize: string
{
case DEFAULT = 'default';
case SMALL = 'small';
case MEDIUM = 'medium';
case LARGE = 'large';
case MEGA = 'mega';
/**
* @internal
*/
case OVERLAY = 'overlay';
/**
* @return array{0: positive-int, 1: positive-int}
*/
public function getDimensions(): array
{
return match ($this) {
self::DEFAULT, self::SMALL, self::OVERLAY => [16, 16],
self::MEDIUM => [32, 32],
self::LARGE => [48, 48],
self::MEGA => [64, 64],
};
}
}
+27
View File
@@ -0,0 +1,27 @@
<?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;
/**
* Enumeration for Icon states
*/
enum IconState: string
{
case STATE_DEFAULT = 'default';
case STATE_DISABLED = 'disabled';
}
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Imaging;
use TYPO3\CMS\Core\Imaging\Exception\ZeroImageDimensionException;
use TYPO3\CMS\Core\Resource\Processing\TaskInterface;
/**
* Representing an image dimension (width and height)
* and calculating the dimension from a source with a given processing instruction
*/
class ImageDimension
{
/**
* @param int<0, max> $width
* @param int<0, max> $height
*/
public function __construct(
private readonly int $width,
private readonly int $height
) {}
/**
* @return int<0, max>
*/
public function getWidth(): int
{
return $this->width;
}
/**
* @return int<0, max>
*/
public function getHeight(): int
{
return $this->height;
}
/**
* @throws ZeroImageDimensionException
*/
public static function fromProcessingTask(TaskInterface $task): self
{
$result = ImageProcessingInstructions::fromProcessingTask($task);
return new self($result->width, $result->height);
}
}
+207
View File
@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Imaging;
use TYPO3\CMS\Core\Imaging\Exception\UnsupportedFileException;
use TYPO3\CMS\Core\Type\File\FileInfo;
use TYPO3\CMS\Core\Utility\CommandUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Value object for file to be used for ImageMagick/GraphicsMagick invocation when
* being used as input file (implies and requires that file exists for some evaluations).
*/
class ImageMagickFile
{
/**
* Path to input file to be processed
*
* @var string
*/
protected $filePath;
/**
* Frame to be used (of multi-page document, e.g. PDF)
*
* @var int|null
*/
protected $frame;
/**
* Whether file actually exists
*
* @var bool
*/
protected $fileExists;
/**
* File extension as given in $filePath (e.g. 'file.png' -> 'png')
*
* @var string
*/
protected $fileExtension;
/**
* Resolved mime-type of file
*
* @var string|null
*/
protected $mimeType;
/**
* Resolved extension for mime-type (e.g. 'image/png' -> 'png')
* (might be empty if not defined in magic.mime database)
*
* @var string[]
* @see FileInfo::getMimeExtensions()
*/
protected $mimeExtensions = [];
/**
* Result to be used for ImageMagick/GraphicsMagick invocation containing
* combination of resolved format prefix, $filePath and frame escaped to be
* used as CLI argument (e.g. "'png:file.png'")
*
* @var string
*/
protected $asArgument;
/**
* File extensions that directly can be used (and are considered to be safe).
*
* @var string[]
*/
protected $allowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'tif', 'tiff', 'bmp', 'pcx', 'tga', 'ico', 'avif'];
/**
* File extensions that never shall be used.
*
* @var string[]
*/
protected $deniedExtensions = ['epi', 'eps', 'eps2', 'eps3', 'epsf', 'epsi', 'ept', 'ept2', 'ept3', 'msl', 'ps', 'ps2', 'ps3'];
/**
* File mime-types that have to be matching. Adding custom mime-types is possible using
* $GLOBALS['TYPO3_CONF_VARS']['SYS']['FileInfo']['fileExtensionToMimeType']
*
* @var string[]
* @see FileInfo::getMimeExtensions()
*/
protected $mimeTypeExtensionMap = [
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/gif' => 'gif',
'image/heic' => 'heic',
'image/heif' => 'heif',
'image/webp' => 'webp',
'image/avif' => 'avif',
'image/svg' => 'svg',
'image/svg+xml' => 'svg',
'image/tiff' => 'tif',
'application/pdf' => 'pdf',
];
/**
* @param int|null $frame
*/
public static function fromFilePath(string $filePath, ?int $frame = null): self
{
return GeneralUtility::makeInstance(
static::class,
$filePath,
$frame
);
}
/**
* @param int|null $frame
* @throws UnsupportedFileException
*/
public function __construct(string $filePath, ?int $frame = null)
{
$this->frame = $frame;
$this->fileExists = file_exists($filePath);
$this->filePath = $filePath;
$this->fileExtension = pathinfo($filePath, PATHINFO_EXTENSION);
if ($this->fileExists) {
$fileInfo = $this->getFileInfo($filePath);
$this->mimeType = $fileInfo->getMimeType() ?: null;
$this->mimeExtensions = $fileInfo->getMimeExtensions();
}
$this->asArgument = $this->escape(
$this->resolvePrefix() . $this->filePath
. ($this->frame !== null ? '[' . $this->frame . ']' : '')
);
}
public function __toString(): string
{
return $this->asArgument;
}
/**
* Resolves according ImageMagic/GraphicsMagic format (e.g. 'png:', 'jpg:', ...).
* + in case mime-type could be resolved and is configured, it takes precedence
* + otherwise resolved mime-type extension of mime.magick database is used if available
* (includes custom settings with $GLOBALS['TYPO3_CONF_VARS']['SYS']['FileInfo']['fileExtensionToMimeType'])
* + otherwise "safe" and allowed file extension is used (jpg, png, gif, webp, tif, ...)
* + potentially malicious script formats (eps, ps, ...) are not allowed
*
* @throws UnsupportedFileException
*/
protected function resolvePrefix(): string
{
$prefixExtension = null;
$fileExtension = strtolower($this->fileExtension);
if ($this->mimeType !== null && !empty($this->mimeTypeExtensionMap[$this->mimeType])) {
$prefixExtension = $this->mimeTypeExtensionMap[$this->mimeType];
} elseif (!empty($this->mimeExtensions) && str_starts_with((string)$this->mimeType, 'image/')) {
$prefixExtension = $this->mimeExtensions[0];
} elseif ($this->isInAllowedExtensions($fileExtension)) {
$prefixExtension = $fileExtension;
}
if ($prefixExtension !== null && !in_array(strtolower($prefixExtension), $this->deniedExtensions, true)) {
return $prefixExtension . ':';
}
throw new UnsupportedFileException(
sprintf(
'Unsupported file %s (%s)',
basename($this->filePath),
$this->mimeType ?? 'unknown'
),
1550060977
);
}
protected function escape(string $value): string
{
return CommandUtility::escapeShellArgument($value);
}
protected function isInAllowedExtensions(string $extension): bool
{
return in_array($extension, $this->allowedExtensions, true);
}
protected function getFileInfo(string $filePath): FileInfo
{
return GeneralUtility::makeInstance(FileInfo::class, $filePath);
}
}
+191
View File
@@ -0,0 +1,191 @@
<?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\ImageManipulation;
use TYPO3\CMS\Core\Resource\FileInterface;
class Area
{
/**
* @var float
*/
protected $x;
/**
* @var float
*/
protected $y;
/**
* @var float
*/
protected $width;
/**
* @var float
*/
protected $height;
public function __construct(float $x, float $y, float $width, float $height)
{
$this->x = $x;
$this->y = $y;
$this->width = $width;
$this->height = $height;
}
/**
* @throws InvalidConfigurationException
*/
public static function createFromConfiguration(array $config): Area
{
try {
return new self(
(float)$config['x'],
(float)$config['y'],
(float)$config['width'],
(float)$config['height']
);
} catch (\Throwable $throwable) {
throw new InvalidConfigurationException(sprintf('Invalid type for area property given: %s', $throwable->getMessage()), 1485279226, $throwable);
}
}
/**
* @return Area[]
* @throws InvalidConfigurationException
*/
public static function createMultipleFromConfiguration(array $config): array
{
$areas = [];
foreach ($config as $areaConfig) {
$areas[] = self::createFromConfiguration($areaConfig);
}
return $areas;
}
/**
* @return Area
*/
public static function createEmpty()
{
return new self(0.0, 0.0, 1.0, 1.0);
}
public function getWidth(): float
{
return $this->width;
}
public function getHeight(): float
{
return $this->height;
}
public function getOffsetLeft(): float
{
return $this->x;
}
public function getOffsetTop(): float
{
return $this->y;
}
/**
* @internal
*/
public function asArray(): array
{
return [
'x' => $this->x,
'y' => $this->y,
'width' => $this->width,
'height' => $this->height,
];
}
/**
* @return Area
*/
public function makeAbsoluteBasedOnFile(FileInterface $file)
{
return new self(
$this->x * $file->getProperty('width'),
$this->y * $file->getProperty('height'),
$this->width * $file->getProperty('width'),
$this->height * $file->getProperty('height')
);
}
/**
* @return Area
*/
public function makeRelativeBasedOnFile(FileInterface $file)
{
$width = $file->getProperty('width');
$height = $file->getProperty('height');
if (empty($width) || empty($height)) {
return self::createEmpty();
}
return new self(
$this->x / $width,
$this->y / $height,
$this->width / $width,
$this->height / $height
);
}
public function applyRatioRestriction(Ratio $ratio): Area
{
if ($ratio->isFree()) {
return $this;
}
$expectedRatio = $ratio->getRatioValue();
$newArea = clone $this;
if ($newArea->height * $expectedRatio > $newArea->width) {
$newArea->height = $newArea->width / $expectedRatio;
$newArea->y += ($this->height - $newArea->height) / 2;
} else {
$newArea->width = $newArea->height * $expectedRatio;
$newArea->x += ($this->width - $newArea->width) / 2;
}
return $newArea;
}
/**
* @return bool
*/
public function isEmpty()
{
return $this->x === 0.0 && $this->y === 0.0 && $this->width === 1.0 && $this->height === 1.0;
}
/**
* @return string
*/
public function __toString()
{
if ($this->isEmpty()) {
return '';
}
return json_encode($this->asArray());
}
}
@@ -0,0 +1,191 @@
<?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\ImageManipulation;
use TYPO3\CMS\Core\Resource\FileInterface;
class CropVariant
{
/**
* @var Ratio[]
*/
protected array $allowedAspectRatios = [];
protected string $selectedRatio = '';
protected ?Area $focusArea = null;
/**
* @var Area[]|null
*/
protected ?array $coverAreas = null;
protected bool $excludeFromSync = false;
/**
* @param Ratio[] $allowedAspectRatios
* @param string|null $selectedRatio
* @param Area|null $focusArea
* @param Area[]|null $coverAreas
* @throws InvalidConfigurationException
*/
public function __construct(
protected string $id,
protected string $title,
protected Area $cropArea,
?array $allowedAspectRatios = null,
?string $selectedRatio = null,
?Area $focusArea = null,
?array $coverAreas = null,
bool $excludeFromSync = false
) {
if ($allowedAspectRatios) {
$this->setAllowedAspectRatios(...$allowedAspectRatios);
if ($selectedRatio && isset($this->allowedAspectRatios[$selectedRatio])) {
$this->selectedRatio = $selectedRatio;
} else {
$this->selectedRatio = current($this->allowedAspectRatios)->getId();
}
}
$this->focusArea = $focusArea;
if ($coverAreas !== null) {
$this->setCoverAreas(...$coverAreas);
}
$this->excludeFromSync = $excludeFromSync;
}
/**
* @throws InvalidConfigurationException
*/
public static function createFromConfiguration(string $id, array $config): CropVariant
{
try {
return new self(
$id,
$config['title'] ?? '',
Area::createFromConfiguration($config['cropArea']),
isset($config['allowedAspectRatios']) ? Ratio::createMultipleFromConfiguration($config['allowedAspectRatios']) : null,
$config['selectedRatio'] ?? null,
isset($config['focusArea']) ? Area::createFromConfiguration($config['focusArea']) : null,
isset($config['coverAreas']) ? Area::createMultipleFromConfiguration($config['coverAreas']) : null,
isset($config['excludeFromSync']) ? filter_var($config['excludeFromSync'], FILTER_VALIDATE_BOOLEAN) : false,
);
} catch (\Throwable $throwable) {
throw new InvalidConfigurationException(sprintf('Invalid type in configuration for crop variant: %s', $throwable->getMessage()), 1485278693, $throwable);
}
}
/**
* @internal
*/
public function asArray(): array
{
$coverAreasAsArray = null;
$allowedAspectRatiosAsArray = [];
foreach ($this->allowedAspectRatios as $id => $allowedAspectRatio) {
$allowedAspectRatiosAsArray[$id] = $allowedAspectRatio->asArray();
}
if ($this->coverAreas !== null) {
$coverAreasAsArray = [];
foreach ($this->coverAreas as $coverArea) {
$coverAreasAsArray[] = $coverArea->asArray();
}
}
return [
'id' => $this->id,
'title' => $this->title,
'cropArea' => $this->cropArea->asArray(),
'allowedAspectRatios' => $allowedAspectRatiosAsArray,
'selectedRatio' => $this->selectedRatio,
'focusArea' => $this->focusArea?->asArray(),
'coverAreas' => $coverAreasAsArray ?? null,
'excludeFromSync' => $this->excludeFromSync,
];
}
public function getId(): string
{
return $this->id;
}
public function getCropArea(): Area
{
return $this->cropArea;
}
public function getFocusArea(): ?Area
{
return $this->focusArea;
}
public function applyRatioRestrictionToSelectedCropArea(FileInterface $file): CropVariant
{
if (!$this->selectedRatio) {
return $this;
}
$newVariant = clone $this;
$newArea = $this->cropArea->makeAbsoluteBasedOnFile($file);
$newArea = $newArea->applyRatioRestriction($this->allowedAspectRatios[$this->selectedRatio]);
$newVariant->cropArea = $newArea->makeRelativeBasedOnFile($file);
return $newVariant;
}
/**
* @throws InvalidConfigurationException
*/
protected function setAllowedAspectRatios(Ratio ...$ratios): void
{
$this->allowedAspectRatios = [];
foreach ($ratios as $ratio) {
$this->addAllowedAspectRatio($ratio);
}
}
/**
* @throws InvalidConfigurationException
*/
protected function addAllowedAspectRatio(Ratio $ratio): void
{
if (isset($this->allowedAspectRatios[$ratio->getId()])) {
throw new InvalidConfigurationException(sprintf('Ratio with with duplicate ID (%s) is configured. Make sure all configured ratios have different ids.', $ratio->getId()), 1485274618);
}
$this->allowedAspectRatios[$ratio->getId()] = $ratio;
}
protected function setCoverAreas(Area ...$areas): void
{
$this->coverAreas = [];
foreach ($areas as $area) {
$this->addCoverArea($area);
}
}
protected function addCoverArea(Area $area): void
{
$this->coverAreas[] = $area;
}
public function isExcludeFromSync(): bool
{
return $this->excludeFromSync;
}
public function setExcludeFromSync(bool $excludeFromSync): void
{
$this->excludeFromSync = $excludeFromSync;
}
}
@@ -0,0 +1,158 @@
<?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\ImageManipulation;
use TYPO3\CMS\Core\Resource\FileInterface;
class CropVariantCollection
{
/**
* @var CropVariant[]
*/
protected $cropVariants;
/**
* @param CropVariant[] $cropVariants
* @throws \TYPO3\CMS\Core\Imaging\ImageManipulation\InvalidConfigurationException
*/
public function __construct(array $cropVariants)
{
$this->setCropVariants(...$cropVariants);
}
public static function create(string $jsonString, array $tcaConfig = []): CropVariantCollection
{
$persistedCollectionConfig = empty($jsonString) ? [] : json_decode($jsonString, true);
if (empty($persistedCollectionConfig) && empty($tcaConfig)) {
return self::createEmpty();
}
try {
if ($tcaConfig === []) {
$tcaConfig = (array)$persistedCollectionConfig;
} else {
if (!is_array($persistedCollectionConfig)) {
$persistedCollectionConfig = [];
}
// Merge selected areas with crop tool configuration
reset($persistedCollectionConfig);
foreach ($tcaConfig as $id => &$cropVariantConfig) {
if (!isset($persistedCollectionConfig[$id])) {
$id = key($persistedCollectionConfig);
next($persistedCollectionConfig);
}
if (isset($persistedCollectionConfig[$id ?? '']['cropArea'])) {
$cropVariantConfig['cropArea'] = $persistedCollectionConfig[$id]['cropArea'];
}
if (isset($persistedCollectionConfig[$id ?? '']['focusArea'], $cropVariantConfig['focusArea'])) {
$cropVariantConfig['focusArea'] = $persistedCollectionConfig[$id]['focusArea'];
}
if (isset($persistedCollectionConfig[$id ?? '']['selectedRatio'], $cropVariantConfig['allowedAspectRatios'][$persistedCollectionConfig[$id ?? '']['selectedRatio']])) {
$cropVariantConfig['selectedRatio'] = $persistedCollectionConfig[$id]['selectedRatio'];
}
}
unset($cropVariantConfig);
}
$cropVariants = [];
foreach ($tcaConfig as $id => $cropVariantConfig) {
$cropVariants[] = CropVariant::createFromConfiguration($id, $cropVariantConfig);
}
return new self($cropVariants);
} catch (\Throwable $throwable) {
return self::createEmpty();
}
}
/**
* @internal
*/
public function asArray(): array
{
$cropVariantsAsArray = [];
foreach ($this->cropVariants as $id => $cropVariant) {
$cropVariantsAsArray[$id] = $cropVariant->asArray();
}
return $cropVariantsAsArray;
}
public function applyRatioRestrictionToSelectedCropArea(FileInterface $file): CropVariantCollection
{
$newCollection = clone $this;
foreach ($this->cropVariants as $id => $cropVariant) {
$newCollection->cropVariants[$id] = $cropVariant->applyRatioRestrictionToSelectedCropArea($file);
}
return $newCollection;
}
public function __toString()
{
$filterNonPersistentKeys = static function ($key) {
if (in_array($key, ['id', 'title', 'allowedAspectRatios', 'coverAreas'], true)) {
return false;
}
return true;
};
$cropVariantsAsArray = [];
foreach ($this->cropVariants as $id => $cropVariant) {
$cropVariantsAsArray[$id] = array_filter($cropVariant->asArray(), $filterNonPersistentKeys, ARRAY_FILTER_USE_KEY);
}
return json_encode($cropVariantsAsArray) ?: '[]';
}
public function getCropArea(string $id = 'default'): Area
{
if (isset($this->cropVariants[$id])) {
return $this->cropVariants[$id]->getCropArea();
}
return Area::createEmpty();
}
public function getFocusArea(string $id = 'default'): Area
{
if (isset($this->cropVariants[$id]) && $this->cropVariants[$id]->getFocusArea() !== null) {
return $this->cropVariants[$id]->getFocusArea();
}
return Area::createEmpty();
}
protected static function createEmpty(): CropVariantCollection
{
return new self([]);
}
/**
* @throws \TYPO3\CMS\Core\Imaging\ImageManipulation\InvalidConfigurationException
*/
protected function setCropVariants(CropVariant ...$cropVariants)
{
$this->cropVariants = [];
foreach ($cropVariants as $cropVariant) {
$this->addCropVariant($cropVariant);
}
}
/**
* @throws InvalidConfigurationException
*/
protected function addCropVariant(CropVariant $cropVariant)
{
if (isset($this->cropVariants[$cropVariant->getId()])) {
throw new InvalidConfigurationException(sprintf('Crop variant with with duplicate ID (%s) is configured. Make sure all configured cropVariants have different ids.', $cropVariant->getId()), 1485284352);
}
$this->cropVariants[$cropVariant->getId()] = $cropVariant;
}
}
@@ -0,0 +1,23 @@
<?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\ImageManipulation;
/**
* Thrown when an invalid TCA configuration for the image manipulation is detected
*/
class InvalidConfigurationException extends \Exception {}
+109
View File
@@ -0,0 +1,109 @@
<?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\ImageManipulation;
class Ratio
{
/**
* @var string
*/
protected $id;
/**
* @var string
*/
protected $title;
/**
* @var float
*/
protected $value;
public function __construct(string $id, string $title, float $value)
{
$this->id = self::prepareAspectRatioId($id);
$this->title = $title;
$this->value = $value;
}
public function getId(): string
{
return $this->id;
}
/**
* Adjust names of Ratios for special character replacement.
*
* @todo in 14 - Rework the ImageManipulationElement.fluid.html logic to actually allow dot keys.
* Ratio names are referenced through fluid, see https://forge.typo3.org/issues/80214
* Should be possible by iterating {cropVariant.allowedAspectRatios.{cropVariant.selectedRatio}.title}
* in the controller, and assigning a distinct, un-nested variable.
* This is a breaking change because then aspect ratios defined with a key
* will be referred to differently and wouldn't be resolved as before. Probably a migration wizard
* would be needed.
*
* @internal not part of TYPO3 Core API as this method might vanish soon.
*/
public static function prepareAspectRatioId(string $id): string
{
return str_replace('.', '_', $id);
}
/**
* @return list<Ratio>
* @throws \TYPO3\CMS\Core\Imaging\ImageManipulation\InvalidConfigurationException
*/
public static function createMultipleFromConfiguration(array $config): array
{
$areas = [];
try {
foreach ($config as $id => $ratioConfig) {
$areas[] = new self(
$id,
(string)($ratioConfig['title'] ?? ''),
(float)($ratioConfig['value'] ?? 0.0)
);
}
} catch (\Throwable $throwable) {
throw new InvalidConfigurationException(sprintf('Invalid type for ratio id given: %s', $throwable->getMessage()), 1486313971, $throwable);
}
return $areas;
}
/**
* @internal
*
* @return array{id: string, title: string, value: float}
*/
public function asArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'value' => $this->value,
];
}
public function getRatioValue(): float
{
return $this->value;
}
public function isFree(): bool
{
return $this->value === 0.0;
}
}
@@ -0,0 +1,418 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Imaging;
use TYPO3\CMS\Core\Imaging\Exception\ZeroImageDimensionException;
use TYPO3\CMS\Core\Imaging\ImageManipulation\Area;
use TYPO3\CMS\Core\Resource\ProcessedFile;
use TYPO3\CMS\Core\Resource\Processing\TaskInterface;
/**
* A DTO representing all information needed to process an image,
* mainly the target dimensions.
*
* With this information an image can be processed by ImageMagick/GraphicsMagick.
*
* "cropScaling" refers to the logic where the image is cropped and scaled at the same time, which was
* used back in TYPO3 v3/v4 but the "LocalCropScaleMaskHelper" is actually doing this in subsequent steps,
* but should be merged together again once there is a load of more tests.
*
* @internal This object is still internal as long as cropping isn't migrated yet to the Crop API.
*/
readonly class ImageProcessingInstructions
{
/**
* @param int<0, max> $width
* @param int<0, max> $height
*/
public function __construct(
public int $width = 0,
public int $height = 0,
public ?Area $cropArea = null,
) {}
public static function fromProcessingTask(TaskInterface $task): ImageProcessingInstructions
{
$config = self::getConfigurationForImageCropScaleMask($task);
$processedFile = $task->getTargetFile();
$isCropped = false;
if (($config['crop'] ?? null) instanceof Area) {
$isCropped = true;
$imageWidth = (int)round($config['crop']->getWidth());
$imageHeight = (int)round($config['crop']->getHeight());
} else {
$imageWidth = (int)$processedFile->getOriginalFile()->getProperty('width');
$imageHeight = (int)$processedFile->getOriginalFile()->getProperty('height');
}
if ($imageWidth <= 0 || $imageHeight <= 0) {
throw new ZeroImageDimensionException('Width and height of the image must be greater than zero.', 1597310560);
}
return ImageProcessingInstructions::fromCropScaleValues(
$imageWidth,
$imageHeight,
$config['width'] ?? '',
$config['height'] ?? '',
$config
);
}
/**
* Get numbers for scaling the image based on input.
*
* Notes by Benni in 2023 in order to understand this magic:
* ----------------------------
* Relevant if an image should be
* - scaled
* - cropped
* - keep the aspect ratio while scaling?
* - use a target width or height
* - or rather have a minimum or maximum width and/or height
*
* This method does a lot of magic:
* - $incomingWidth/$incomingHeight contains the size of an original image for example.
* - $w and $h are the width and height that are originally required the image to be like
* when scaled. They could contain a "c" for cropping information or "m" for "Ensure that even though $w and $h are given, one containing an $m that we keep the aspect ratio."
* "m" really allows to say $w="50c" that this might in a result with [0]=100 because $w would follow $h in order to keep aspect ratio.
* Obviously this only works properly if both m and c are working
* - $options contain "maxW" (never go beyond this width, even if scaling larger as this), same with "maxH" and "minW" and "minH" (note these get streamlined to maxWidth, maxHeight, minWidth, minHeight)
*
* The return values are a bit tricky to understand, so I added a few tests:
* - AFAICS "0" and "1" are always used as "these are the target width / height" which my image
* should be scaled to, or cropped down to.
* Notes: If you hand in $info[0] and $incomingHeight a "0", you will get "0" as return value back!
* but
* - "crs" if the image should be cropped (which is indicated by one of $w or $h contain the "c" at the end)
* - "cropH" and "cropV" is also set when one of the incoming $w or $h contains a "c".
* Notes: "cropH" and "cropV" are rather cryptic, and can't really be used outside of this context.
* They are then "magically calculated" outside of this function
* $offsetX = (int)(($data[0] - $data['origW']) * ($data['cropH'] + 100) / 200);
* $offsetY = (int)(($data[1] - $data['origH']) * ($data['cropV'] + 100) / 200);
*
* - "origW" / "origH" seems to be the values that were handed in as $w and $h, but they might be altered
* f.e. "origH" is set when $w is given and $options["maxH"]
* - When such a rearranging calculation was made ("maxH" reduces the original $w due to constraints),
* then the return value "max" is set.
* - When using the "c" argument, origH and origW seem to contain the values that you would expect when NOT doing a crop scenario
* whereas $incomingWidth and $incomingHeight contain the target width and height that could be larger than originally requested.
*
* ----------------------------
* @param int<0, max> $incomingWidth the width of an original image for example, can be "0" if there is no original image
* @param int<0, max> $incomingHeight the height of an original image for example, can be "0" if there is no original image
* @param int<0, max>|string $width "required" width that is requested, can be "" or "0" or a number of a magic "m" or "c" appended
* @param int<0, max>|string $height "required" height that is requested, can be "" or "0" or a number of a magic "m" or "c" appended
* @param array $options Options: Keys are like "maxW", "maxH", "minW", "minH" (streamlined to "maxWidth", "maxHeight", "minWidth", "minHeight")
*/
public static function fromCropScaleValues(int $incomingWidth, int $incomingHeight, int|string $width, int|string $height, array $options): self
{
$options = self::streamlineOptions($options);
if ($incomingWidth === 0 || $incomingHeight === 0) {
// @todo incomingWidth/Height makes no sense, we should ideally throw an exception here…
// this code is here to make existing unit tests happy and should be dropped
return new self(
width: 0,
height: 0,
cropArea: null
);
}
$cropArea = ($options['crop'] ?? null) instanceof Area ? $options['crop'] : new Area(0, 0, $incomingWidth, $incomingHeight);
// If both the width and the height are set and one of the numbers is appended by an m, the proportions will
// be preserved and thus width and height are treated as maximum dimensions for the image. The image will be
// scaled to fit into the rectangle of the dimensions width and height.
$useWidthOrHeightAsMaximumLimits = str_contains($width . $height, 'm');
$useCropScaling = str_contains($width . $height, 'c');
if ($useWidthOrHeightAsMaximumLimits && $useCropScaling) {
throw new \InvalidArgumentException('Cannot mix m and c modifiers for width/height', 1709840402);
}
if ($useWidthOrHeightAsMaximumLimits) {
if (str_contains((string)$width, 'm')) {
$options['maxWidth'] = min((int)$width, (int)($options['maxWidth'] ?? PHP_INT_MAX));
// width: auto
$width = 0;
}
if (str_contains((string)$height, 'm')) {
$options['maxHeight'] = min((int)$height, (int)($options['maxHeight'] ?? PHP_INT_MAX));
// height: auto
$height = 0;
}
}
if ((int)$width !== 0 && (int)$height !== 0 && $useCropScaling) {
$cropOffsetHorizontal = (int)substr((string)strstr((string)$width, 'c'), 1);
$cropOffsetVertical = (int)substr((string)strstr((string)$height, 'c'), 1);
$width = (int)$width;
$height = (int)$height;
$cropArea = self::applyCropScaleToCropArea($cropArea, $width, $height, $cropOffsetVertical, $cropOffsetHorizontal);
}
$width = (int)$width;
$height = (int)$height;
// Rounding in extreme formats like 1920x10 to 64x??? can yield a 0 height/width, which should be at least 1 pixel.
// Because of this, the following checks use a max(1, $maybeZero) assignment.
if ($width > 0 && $height === 0) {
$height = max(1, (int)round($cropArea->getHeight() * ($width / $cropArea->getWidth())));
}
if ($height > 0 && $width === 0) {
$width = max(1, (int)round($cropArea->getWidth() * ($height / $cropArea->getHeight())));
}
// If there are max/min-values...
if (!empty($options['maxWidth'])) {
if ($width > $options['maxWidth'] || ($width === 0 && $cropArea->getWidth() > $options['maxWidth'])) {
$width = (int)$options['maxWidth'];
$height = max(1, (int)round($cropArea->getHeight() * ($width / $cropArea->getWidth())));
}
}
if (!empty($options['maxHeight'])) {
if ($height > $options['maxHeight'] || ($height === 0 && $cropArea->getHeight() > $options['maxHeight'])) {
$height = (int)$options['maxHeight'];
$width = max(1, (int)round($cropArea->getWidth() * ($height / $cropArea->getHeight())));
}
}
if (!empty($options['minWidth'])) {
if ($width < $options['minWidth'] || ($width === 0 && $cropArea->getWidth() < $options['minWidth'])) {
$width = (int)$options['minWidth'];
$height = max(1, (int)round($cropArea->getHeight() * ($width / $cropArea->getWidth())));
}
}
if (!empty($options['minHeight'])) {
if ($height < $options['minHeight'] || ($height === 0 && $cropArea->getHeight() < $options['minHeight'])) {
$height = (int)$options['minHeight'];
$width = max(1, (int)round($cropArea->getWidth() * ($height / $cropArea->getHeight())));
}
}
if ($width === 0 && $height === 0) {
$width = (int)round($cropArea->getWidth());
$height = (int)round($cropArea->getHeight());
// This here may return "0", which should continue to throw a LogicException. Probably.
}
if ($width === 0 || $height === 0) {
$extraDetails = [];
$extraDetails[] = 'incomingWidth: ' . $incomingWidth;
$extraDetails[] = 'incomingHeight: ' . $incomingHeight;
$extraDetails[] = 'width: ' . $width;
$extraDetails[] = 'height: ' . $height;
$extraDetails[] = 'options: ' . json_encode($options, JSON_PRETTY_PRINT);
$extraDetails[] = 'cropArea: ' . json_encode($cropArea->asArray(), JSON_PRETTY_PRINT);
// Exceptions have no HTML/Text formatting.
throw new \LogicException('Image processing instructions did not resolve into coherent positive width and height values. This is a bug. Please report. Extra details: ' . implode(', ', $extraDetails), 1709806820);
}
if (!($GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_allowUpscaling'] ?? false)) {
if ($width > $cropArea->getWidth()) {
$width = (int)round($cropArea->getWidth());
$height = (int)round($cropArea->getHeight() * ($width / $cropArea->getWidth()));
}
if ($height > $cropArea->getHeight()) {
$height = (int)round($cropArea->getHeight());
$width = (int)round($cropArea->getWidth() * ($height / $cropArea->getHeight()));
}
}
if ((int)$cropArea->getOffsetLeft() === 0
&& (int)$cropArea->getOffsetTop() === 0
&& (int)$cropArea->getWidth() === $incomingWidth
&& (int)$cropArea->getHeight() === $incomingHeight) {
$cropArea = null;
}
return new self(
width: $width,
height: $height,
cropArea: $cropArea,
);
}
/**
* @param Area $cropArea with absolute crop data (not relative!)
* @param positive-int $width
* @param positive-int $height
* @param int<-100,100> $cropOffsetVertical
* @param int<-100,100> $cropOffsetHorizontal
*/
private static function applyCropScaleToCropArea(
Area $cropArea,
int $width,
int $height,
int $cropOffsetVertical,
int $cropOffsetHorizontal
): Area {
if (!($width > 0 && $height > 0 && $cropArea->getWidth() > 0 && $cropArea->getHeight() > 0)) {
throw new \InvalidArgumentException('Apply crop scale must use concrete width and height', 1709810881);
}
$destRatio = $width / $height;
$cropRatio = $cropArea->getWidth() / $cropArea->getHeight();
if ($destRatio > $cropRatio) {
$w = $cropArea->getWidth();
$h = $cropArea->getWidth() / $destRatio;
$x = $cropArea->getOffsetLeft();
$y = $cropArea->getOffsetTop() + (float)(($cropArea->getHeight() - $h) * ($cropOffsetVertical + 100) / 200);
} else {
$w = $cropArea->getHeight() * $destRatio;
$h = $cropArea->getHeight();
$x = $cropArea->getOffsetLeft() + (float)(($cropArea->getWidth() - $w) * ($cropOffsetHorizontal + 100) / 200);
$y = $cropArea->getOffsetTop();
}
return new Area($x, $y, $w, $h);
}
/**
* @return array{
* maxWidth?: int,
* maxHeight?: int,
* minWidth?: int,
* minHeight?: int,
* crop?: Area,
* }
*/
private static function streamlineOptions(array $options): array
{
if (isset($options['maxW'])) {
$options['maxWidth'] = $options['maxW'];
unset($options['maxW']);
}
if (isset($options['maxH'])) {
$options['maxHeight'] = $options['maxH'];
unset($options['maxH']);
}
if (isset($options['minW'])) {
$options['minWidth'] = $options['minW'];
unset($options['minW']);
}
if (isset($options['minH'])) {
$options['minHeight'] = $options['minH'];
unset($options['minH']);
}
if (($options['maxWidth'] ?? null) <= 0) {
unset($options['maxWidth']);
}
if (($options['maxHeight'] ?? null) <= 0) {
unset($options['maxHeight']);
}
if (($options['minWidth'] ?? null) <= 0) {
unset($options['minWidth']);
}
if (($options['minHeight'] ?? null) <= 0) {
unset($options['minHeight']);
}
if (isset($options['crop'])) {
if ($options['crop'] === '') {
unset($options['crop']);
} elseif (is_string($options['crop'])) {
// check if it is a json object
$cropData = json_decode($options['crop']);
if ($cropData) {
// happens when $options['crop'] = '{"default":{"cropArea":{"x":0,"y":0,"width":1,"height":1},"selectedRatio":"NaN","focusArea":null}}'
if (!isset($cropData->x) || !isset($cropData->y) || !isset($cropData->width) || !isset($cropData->height)) {
unset($options['crop']);
} else {
$options['crop'] = new Area((float)$cropData->x, (float)$cropData->y, (float)$cropData->width, (float)$cropData->height);
}
} elseif (substr_count($options['crop'], ',') === 3) {
[$offsetLeft, $offsetTop, $newWidth, $newHeight] = explode(',', $options['crop'], 4);
$options['crop'] = new Area((float)$offsetLeft, (float)$offsetTop, (float)$newWidth, (float)$newHeight);
} else {
unset($options['crop']);
}
if (isset($options['crop']) && $options['crop']->isEmpty()) {
unset($options['crop']);
}
} elseif (!$options['crop'] instanceof Area) {
unset($options['crop']);
}
}
return $options;
}
/**
* @return array{
* width?: int<0, max>|string,
* height?: int<0, max>|string,
* maxWidth?: int<0, max>,
* maxHeight?: int<0, max>,
* maxW?: int<0, max>,
* maxH?: int<0, max>,
* minW?: int<0, max>,
* minH?: int<0, max>,
* crop?: Area,
* noScale?: bool
* }
*/
private static function getConfigurationForImageCropScaleMask(TaskInterface $task): array
{
$configuration = $task->getConfiguration();
if ($task->getTargetFile()->getTaskIdentifier() === ProcessedFile::CONTEXT_IMAGEPREVIEW) {
$task->sanitizeConfiguration();
// @todo: this transformation needs to happen in the PreviewTask, but if we do this,
// all preview images would be re-created, so we should be careful when to do this.
$configuration = $task->getConfiguration();
$configuration['maxWidth'] = $configuration['width'];
unset($configuration['width']);
$configuration['maxHeight'] = $configuration['height'];
unset($configuration['height']);
}
$options = $configuration;
if ($configuration['maxWidth'] ?? null) {
$options['maxW'] = $configuration['maxWidth'];
}
if ($configuration['maxHeight'] ?? null) {
$options['maxH'] = $configuration['maxHeight'];
}
if ($configuration['minWidth'] ?? null) {
$options['minW'] = $configuration['minWidth'];
}
if ($configuration['minHeight'] ?? null) {
$options['minH'] = $configuration['minHeight'];
}
if ($configuration['crop'] ?? null) {
$options['crop'] = $configuration['crop'];
if (is_string($configuration['crop'])) {
// check if it is a json object
$cropData = json_decode($configuration['crop']);
if ($cropData) {
$options['crop'] = new Area((float)$cropData->x, (float)$cropData->y, (float)$cropData->width, (float)$cropData->height);
} else {
[$offsetLeft, $offsetTop, $newWidth, $newHeight] = explode(',', $configuration['crop'], 4);
$options['crop'] = new Area((float)$offsetLeft, (float)$offsetTop, (float)$newWidth, (float)$newHeight);
}
if ($options['crop']->isEmpty()) {
unset($options['crop']);
}
}
}
if ($configuration['noScale'] ?? null) {
$options['noScale'] = $configuration['noScale'];
}
return $options;
}
}
+103
View File
@@ -0,0 +1,103 @@
<?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\Type\File\ImageInfo;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Decorator around ImageInfo.
*
* The main benefit over ImageInfo is
* - Can have different values due to processing instructions being set, or "noScale" option being set.
* - Can be filled with arbitrary information without the file path being accessed at all.
* - The result object can be used to retrieve virtual or possible values, so the file does not need to exist (yet).
*
* @internal because this might only make sense if it used in the local environment.
*/
class ImageProcessingResult
{
public function __construct(
private readonly string $filePath,
/**
* @var int<0, max>
*/
private readonly int $width,
/**
* @var int<0, max>
*/
private readonly int $height,
private readonly ?ImageInfo $imageInfo = null
) {}
public function isFile(): bool
{
return $this->getImageInfoObject()->isFile();
}
public function getRealPath(): string
{
return $this->getImageInfoObject()->getRealPath();
}
/**
* @return int<0, max>
*/
public function getWidth(): int
{
return $this->width;
}
/**
* @return int<0, max>
*/
public function getHeight(): int
{
return $this->height;
}
public function getExtension(): string
{
return $this->getImageInfoObject()->getExtension();
}
public static function createFromImageInfo(ImageInfo $imageInfo): self
{
return new self($imageInfo->getRealPath(), $imageInfo->getWidth(), $imageInfo->getHeight(), $imageInfo);
}
/**
* @return array{0: int<0, max>, 1: int<0, max>, 2: string, 3: string}
*/
public function toLegacyArray(): array
{
return [
0 => $this->width,
1 => $this->height,
2 => $this->getExtension(),
3 => $this->filePath,
];
}
private function getImageInfoObject(): ImageInfo
{
return !$this->imageInfo instanceof ImageInfo
? GeneralUtility::makeInstance(ImageInfo::class, $this->filePath)
: $this->imageInfo;
}
}
+167
View File
@@ -0,0 +1,167 @@
<?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\Resource\File;
use TYPO3\CMS\Core\Resource\ProcessedFile;
use TYPO3\CMS\Core\Type\File\ImageInfo;
use TYPO3\CMS\Core\Utility\PathUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/**
* DTO for a resolved image resource. Mainly used by ContentObjectRenderer.
*
* @see ContentObjectRenderer::getImgResource()
*/
class ImageResource
{
public function __construct(
protected int $width,
protected int $height,
protected string $extension,
protected string $fullPath,
protected ?string $publicUrl = null,
protected ?File $originalFile = null,
protected ?ProcessedFile $processedFile = null
) {}
public static function createFromImageInfo(ImageInfo $imageInfo): self
{
return new self(
width: $imageInfo->getWidth(),
height: $imageInfo->getHeight(),
extension: $imageInfo->getExtension(),
fullPath: $imageInfo->getPathname(),
publicUrl: PathUtility::getAbsoluteWebPath($imageInfo->getPathname(), false),
);
}
public static function createFromProcessedFile(ProcessedFile $processedFile): self
{
return new self(
width: (int)$processedFile->getProperty('width'),
height: (int)$processedFile->getProperty('height'),
extension: $processedFile->getExtension(),
fullPath: $processedFile->getForLocalProcessing(false),
publicUrl: $processedFile->getPublicUrl(),
originalFile: $processedFile->getOriginalFile(),
processedFile: $processedFile
);
}
public function getWidth(): int
{
return $this->width;
}
public function withWidth(int $width): self
{
$imageResource = clone $this;
$imageResource->width = $width;
return $imageResource;
}
public function getHeight(): int
{
return $this->height;
}
public function withHeight(int $height): self
{
$imageResource = clone $this;
$imageResource->height = $height;
return $imageResource;
}
public function getExtension(): string
{
return $this->extension;
}
public function withExtension(string $extension): self
{
$imageResource = clone $this;
$imageResource->extension = $extension;
return $imageResource;
}
public function getFullPath(): string
{
return $this->fullPath;
}
public function withFullPath(string $fullPath): self
{
$imageResource = clone $this;
$imageResource->fullPath = $fullPath;
return $imageResource;
}
public function getPublicUrl(): ?string
{
return $this->publicUrl;
}
public function withPublicUrl(string $publicUrl): self
{
$imageResource = clone $this;
$imageResource->publicUrl = $publicUrl;
return $imageResource;
}
public function getOriginalFile(): ?File
{
return $this->originalFile;
}
public function withOriginalFile(?File $originalFile): self
{
$imageResource = clone $this;
$imageResource->originalFile = $originalFile;
return $imageResource;
}
public function getProcessedFile(): ?ProcessedFile
{
return $this->processedFile;
}
public function withProcessedFile(?ProcessedFile $processedFile): self
{
$imageResource = clone $this;
$imageResource->processedFile = $processedFile;
return $imageResource;
}
/**
* Legacy image resource information, used for asset collector and GifBuilder BBOX
*
* @return array{0: int<0, max>, 1: int<0, max>, 2: string, 3: string, 'origFile': string|null, 'origFile_mtime': int}
*/
public function getLegacyImageResourceInformation(): array
{
return [
0 => $this->width,
1 => $this->height,
2 => $this->extension,
3 => $this->fullPath,
'origFile' => $this->publicUrl,
'origFile_mtime' => $this->originalFile?->getModificationTime(),
];
}
}
+32
View File
@@ -0,0 +1,32 @@
<?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\Svg;
/**
* A parsed SVG document.
*
* This is intentionally an otherwise empty subclass of {@see \DOMDocument}.
* It exists only to give SVG handling its own dedicated, type-hintable type
* while inheriting the full DOM API for traversal and serialization.
*
* Obtain instances via {@see SvgDocumentFactory}. Resolve dimensions,
* serialize and crop them via {@see SvgDocumentService}.
*
* @internal not part of TYPO3 Core API.
*/
final class SvgDocument extends \DOMDocument {}
@@ -0,0 +1,99 @@
<?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\Svg;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Imaging\Exception\InvalidSvgException;
use TYPO3\CMS\Core\Resource\FileInterface;
use TYPO3\CMS\Core\Resource\Security\SvgSanitizer;
/**
* Creates {@see SvgDocument} instances from strings or files.
*
* Inject this service wherever an SVG needs to be loaded, do not
* construct SvgDocument directly. Transformations and serialization of a
* loaded document are handled by {@see SvgDocumentService}.
*
* @internal not part of TYPO3 Core API.
*/
#[Autoconfigure(public: true)]
final readonly class SvgDocumentFactory
{
public function __construct(
private SvgSanitizer $svgSanitizer,
) {}
public function fromString(string $svg): SvgDocument
{
if (trim($svg) === '') {
throw new InvalidSvgException('SVG content is empty.', 1744620001);
}
$document = new SvgDocument();
$previousUseErrors = libxml_use_internal_errors(true);
try {
$loaded = $document->loadXML($svg, LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_NONET);
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previousUseErrors);
}
if ($loaded === false || $document->documentElement === null) {
throw new InvalidSvgException('SVG content could not be parsed as XML.', 1744620002);
}
return $document;
}
/**
* Parse and fully sanitize an SVG string in one step.
*
* @param bool $removeLinks additionally drop `<a>` elements, for
* contexts where the rendered SVG must not contain
* clickable areas.
*/
public function fromStringAndSanitize(string $svg, bool $removeLinks = false): SvgDocument
{
$document = $this->fromString($svg);
// Minify so the reparsed document carries no whitespace text nodes
// from the sanitizer's pretty-printer.
$sanitizedXml = $this->svgSanitizer->sanitizeContent(
(string)$document->saveXML($document->documentElement),
true,
$removeLinks,
);
return $this->fromString($sanitizedXml);
}
public function fromFile(FileInterface|string $file): SvgDocument
{
if ($file instanceof FileInterface) {
$content = $file->getContents();
} else {
$content = @file_get_contents($file);
}
if ($content === false) {
$name = $file instanceof FileInterface ? $file->getIdentifier() : $file;
throw new InvalidSvgException(
sprintf('SVG file "%s" could not be read.', $name),
1744620003,
);
}
return $this->fromString($content);
}
}
+195
View File
@@ -0,0 +1,195 @@
<?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\Svg;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Imaging\ImageDimension;
use TYPO3\CMS\Core\Imaging\ImageManipulation\Area;
/**
* Stateless operations on {@see SvgDocument} instances: dimension
* resolution, serialization and crop-scaling.
*
* The document to operate on is always passed in as the first argument,
* no instance state is kept. Obtain documents via {@see SvgDocumentFactory}.
*
* @internal not part of TYPO3 Core API.
*/
#[Autoconfigure(public: true)]
final readonly class SvgDocumentService
{
private const int DEFAULT_DIMENSION = 64;
/**
* Resolve pixel dimensions of the SVG.
*
* Preference order:
* 1. `viewBox` attribute (width = index 2, height = index 3)
* 2. numeric `width` / `height` attributes (override viewBox values)
* 3. non-numeric `width` / `height` (e.g. "100mm", "50%") only when
* no viewBox-derived value is present, stripped of their unit
* 4. 64x64 fallback when nothing usable is found
*/
public function getDimensions(SvgDocument $document): ImageDimension
{
$root = $document->documentElement;
$viewBox = $root->getAttribute('viewBox');
$widthAttr = $root->getAttribute('width');
$heightAttr = $root->getAttribute('height');
$width = null;
$height = null;
if ($viewBox !== '') {
// SVG spec allows whitespace or comma separators.
$parts = preg_split('/[\s,]+/', trim($viewBox)) ?: [];
if (isset($parts[2]) && is_numeric($parts[2])) {
$width = (int)(float)$parts[2];
}
if (isset($parts[3]) && is_numeric($parts[3])) {
$height = (int)(float)$parts[3];
}
}
if ($widthAttr !== '') {
if (is_numeric($widthAttr)) {
$width = (int)(float)$widthAttr;
} elseif ($width === null) {
// Unit like "mm", "cm", "%" - stripped because without an
// output device (dpi) we cannot translate to pixels.
$width = (int)$widthAttr;
}
}
if ($heightAttr !== '') {
if (is_numeric($heightAttr)) {
$height = (int)(float)$heightAttr;
} elseif ($height === null) {
$height = (int)$heightAttr;
}
}
return new ImageDimension(
max(0, $width ?? self::DEFAULT_DIMENSION),
max(0, $height ?? self::DEFAULT_DIMENSION),
);
}
/**
* Serialize the document as plain XML markup of its root element,
* without an `<?xml ?>` prolog.
*/
public function toXml(SvgDocument $document): string
{
return (string)$document->saveXML($document->documentElement);
}
/**
* Serialize the document as inline HTML5-ready markup.
*
* Emits only the root `<svg>` element (no `<?xml ?>` prolog) and
* applies the clean-ups needed when inlining SVG into an HTML
* document:
*
* - drops `xmlns="http://www.w3.org/2000/svg"` (HTML5 auto-places
* `<svg>` in the SVG namespace),
* - drops the legacy `version` attribute,
* - synthesizes a `viewBox` from `width`/`height` when missing so
* the SVG scales with CSS instead of rendering at intrinsic
* pixel size.
*
* Operates on a detached clone; the passed document is not mutated.
*/
public function toInlineMarkup(SvgDocument $document): string
{
$clone = new \DOMDocument();
$clone->appendChild($clone->importNode($document->documentElement, true));
/** @var \DOMElement $root */
$root = $clone->documentElement;
$root->removeAttributeNS('http://www.w3.org/2000/svg', '');
if ($root->hasAttribute('version')) {
$root->removeAttribute('version');
}
if (!$root->hasAttribute('viewBox')
&& $root->hasAttribute('width')
&& $root->hasAttribute('height')
) {
$root->setAttribute(
'viewBox',
sprintf('0 0 %d %d', (int)$root->getAttribute('width'), (int)$root->getAttribute('height')),
);
}
return (string)$clone->saveXML($root);
}
/**
* Wrap the source SVG in an outer `<svg>` that carries the crop viewBox
* and target dimensions. The passed document is not mutated, a new
* {@see SvgDocument} is returned.
*/
public function cropScale(SvgDocument $document, Area $cropArea, ImageDimension $targetDimension): SvgDocument
{
$offsetLeft = (int)$cropArea->getOffsetLeft();
$offsetTop = (int)$cropArea->getOffsetTop();
// Rounding matches ImageDimension's width/height calculation.
$newWidth = (int)round($cropArea->getWidth());
$newHeight = (int)round($cropArea->getHeight());
$sourceRoot = $document->documentElement;
$intrinsic = $this->getDimensions($document);
$wrapper = new SvgDocument('1.0');
$wrapper->preserveWhiteSpace = true;
$wrapper->formatOutput = true;
// Deep-copy the source tree into the wrapper document so we never
// mutate the passed document.
/** @var \DOMElement $innerSvg */
$innerSvg = $wrapper->importNode($sourceRoot, true);
// Ensure the inner <svg> carries width/height; without them the
// crop cannot render correctly when the file is embedded via <img>.
if ($sourceRoot->getAttribute('width') === '') {
$innerSvg->setAttribute('width', (string)$intrinsic->getWidth());
$innerSvg->setAttribute('data-manipulated-width', 'true');
}
if ($sourceRoot->getAttribute('height') === '') {
$innerSvg->setAttribute('height', (string)$intrinsic->getHeight());
$innerSvg->setAttribute('data-manipulated-height', 'true');
}
$outerSvg = $wrapper->createElement('svg');
$outerSvg->setAttribute('xmlns', 'http://www.w3.org/2000/svg');
$outerSvg->setAttribute('viewBox', $offsetLeft . ' ' . $offsetTop . ' ' . $newWidth . ' ' . $newHeight);
$outerSvg->setAttribute('width', (string)$targetDimension->getWidth());
$outerSvg->setAttribute('height', (string)$targetDimension->getHeight());
// Propagate preserveAspectRatio from the inner root onto the wrapper.
$preserveAspectRatio = $sourceRoot->getAttribute('preserveAspectRatio');
if ($preserveAspectRatio !== '') {
$outerSvg->setAttribute('preserveAspectRatio', $preserveAspectRatio);
}
$outerSvg->appendChild($innerSvg);
$wrapper->appendChild($outerSvg);
return $wrapper;
}
}