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
@@ -0,0 +1,184 @@
<?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\Resource\Processing;
use TYPO3\CMS\Core\Resource;
use TYPO3\CMS\Core\Resource\ProcessedFile;
use TYPO3\CMS\Core\Resource\Service\ConfigurationService;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Abstract base implementation of a processing task.
*/
abstract class AbstractTask implements TaskInterface
{
protected Resource\File $sourceFile;
protected bool $executed = false;
protected bool $successful;
public function __construct(
protected ProcessedFile $targetFile,
protected array $configuration
) {
$this->sourceFile = $targetFile->getOriginalFile();
}
/**
* Sets parameters needed in the checksum. Can be overridden to add additional parameters to the checksum.
* This should include all parameters that could possibly vary between different task instances, e.g. the
* TYPO3 image configuration in TYPO3_CONF_VARS[GFX] for graphic processing tasks.
*/
protected function getChecksumData(): array
{
return [
$this->getSourceFile()->getUid(),
$this->getType() . '.' . $this->getName() . $this->getSourceFile()->getModificationTime(),
(new ConfigurationService())->serialize($this->configuration),
];
}
/**
* Returns the checksum for this task's configuration, also taking the file and task type into account.
*/
public function getConfigurationChecksum(): string
{
return substr((string)md5(implode('|', $this->getChecksumData())), 0, 10);
}
/**
* Returns the filename
*/
public function getTargetFilename(): string
{
return $this->targetFile->getNameWithoutExtension()
. '_' . $this->getConfigurationChecksum()
. '.' . $this->getTargetFileExtension();
}
/**
* Gets the file extension the processed file should
* have in the filesystem.
*/
public function getTargetFileExtension(): string
{
return $this->targetFile->getExtension();
}
/**
* Returns the name of this task
*/
abstract public function getName(): string;
/**
* Returns the type of this task
*/
abstract public function getType(): string;
public function getTargetFile(): Resource\ProcessedFile
{
return $this->targetFile;
}
public function getSourceFile(): Resource\File
{
return $this->sourceFile;
}
public function getConfiguration(): array
{
return $this->configuration;
}
/**
* Returns TRUE if this task has been executed, no matter if the execution was successful.
*/
public function isExecuted(): bool
{
return $this->executed;
}
/**
* Set this task executed. This is used by the Processors in order to transfer the state of this task to
* the file processing service.
*
* @param bool $successful Set this to FALSE if executing the task failed
*/
public function setExecuted(bool $successful): void
{
$this->executed = true;
$this->successful = $successful;
}
/**
* Returns TRUE if this task has been successfully executed. Only call this method if the task has been processed
* at all.
*
* @throws \LogicException If the task has not been executed already
*/
public function isSuccessful(): bool
{
if (!$this->executed) {
throw new \LogicException('Task has not been executed; cannot determine success.', 1352549235);
}
return $this->successful;
}
/**
* We only have to trigger the file processing if the file either is new, does not exist or the
* original file has changed since the last processing run (the last case has to trigger a reprocessing
* even if the original file was used until now).
*/
public function fileNeedsProcessing(): bool
{
$processedFile = $this->getTargetFile();
if (!$processedFile->isProcessed()) {
return true;
}
$checksum = $this->getTargetFile()->getProperty('checksum');
$checksumCalculationOk = !$checksum || $this->getConfigurationChecksum() === $checksum;
$fileNeedsReprocessing = $processedFile->isNew()
|| (!$processedFile->usesOriginalFile() && !$processedFile->exists())
|| ($processedFile->needsReprocessing() || !$checksumCalculationOk);
if ($fileNeedsReprocessing && $this->getTargetFile()->exists()) {
$this->getTargetFile()->delete();
}
return $fileNeedsReprocessing;
}
/**
* Can be extended in the actual subclasses, but be careful on what to sanitize, as Processors might need
* information that you actually throw away.
*
* Ensure that the processing configuration which is part of the hash sum is properly cast, so
* unnecessary duplicate images are not produced, see #80942
*/
public function sanitizeConfiguration(): void
{
foreach ($this->configuration as &$value) {
if (MathUtility::canBeInterpretedAsInteger($value)) {
$value = (int)$value;
}
}
// @todo: ideally we would do a sort() on the array to really structure this, but then the checksums would change
// @todo: and we would need to re-create all processed files again, but this would be something we should tackle at some point
}
}
@@ -0,0 +1,131 @@
<?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\Resource\Processing;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\Event\AfterFileAddedEvent;
use TYPO3\CMS\Core\Resource\Event\AfterFileDeletedEvent;
use TYPO3\CMS\Core\Resource\Event\AfterFileReplacedEvent;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\FileInterface;
use TYPO3\CMS\Core\Resource\Index\FileIndexRepository;
use TYPO3\CMS\Core\Resource\Index\MetaDataRepository;
use TYPO3\CMS\Core\Resource\ProcessedFile;
use TYPO3\CMS\Core\Resource\ProcessedFileRepository;
/**
* Clean up database records, processed files and file references
*
* The aspect which deals with deleted files is a list of PSR-14
* event listeners which react on file deletion.
*
* @internal this is a list of Event Listeners, and not part of TYPO3 Core API.
*/
final readonly class FileDeletionAspect
{
public function __construct(
private ConnectionPool $connectionPool,
private MetaDataRepository $metaDataRepository,
private ProcessedFileRepository $processedFileRepository,
private FileIndexRepository $fileIndexRepository,
) {}
#[AsEventListener('delete-processed-files-after-add')]
public function cleanupProcessedFilesPostFileAdd(AfterFileAddedEvent $event): void
{
$this->cleanupProcessedFiles($event->getFile());
}
#[AsEventListener('delete-processed-files-after-replace')]
public function cleanupProcessedFilesPostFileReplace(AfterFileReplacedEvent $event): void
{
$this->cleanupProcessedFiles($event->getFile());
}
#[AsEventListener('delete-processed-files-after-delete')]
public function removeFromRepositoryAfterFileDeleted(AfterFileDeletedEvent $event): void
{
$this->removeFromRepository($event->getFile());
}
/**
* Cleanup database record for a deleted file
*/
private function removeFromRepository(FileInterface $fileObject): void
{
// remove file from repository
if ($fileObject instanceof File) {
$this->cleanupProcessedFiles($fileObject);
$this->cleanupCategoryReferences($fileObject);
$this->fileIndexRepository->remove($fileObject->getUid());
$this->metaDataRepository->removeByFileUid($fileObject->getUid());
// remove all references
$this->connectionPool->getConnectionForTable('sys_file_reference')->delete(
'sys_file_reference',
[
'uid_local' => $fileObject->getUid(),
]
);
} elseif ($fileObject instanceof ProcessedFile) {
$this->processedFileRepository->remove($fileObject);
}
}
/**
* Remove all category references of the deleted file.
*/
private function cleanupCategoryReferences(File $fileObject): void
{
// Retrieve the file metadata uid which is different from the file uid.
$metadataProperties = $fileObject->getMetaData()->get();
$metaDataUid = (int)($metadataProperties['_ORIG_uid'] ?? $metadataProperties['uid'] ?? 0);
if ($metaDataUid <= 0) {
// No metadata record exists for the given file. The file might not
// have been indexed or the metadata record was deleted manually.
return;
}
$this->connectionPool->getConnectionForTable('sys_category_record_mm')->delete(
'sys_category_record_mm',
[
'uid_foreign' => $metaDataUid,
'tablenames' => 'sys_file_metadata',
]
);
}
/**
* Remove all processed files that belong to the given File object
*/
private function cleanupProcessedFiles(FileInterface $fileObject): void
{
// only delete processed files of File objects
if (!$fileObject instanceof File) {
return;
}
foreach ($this->processedFileRepository->findAllByOriginalFile($fileObject) as $processedFile) {
if ($processedFile->exists()) {
$processedFile->delete(true);
}
$this->removeFromRepository($processedFile);
}
}
}
@@ -0,0 +1,75 @@
<?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\Resource\Processing;
use TYPO3\CMS\Core\Imaging\GraphicalFunctions;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* A task that takes care of cropping, scaling and/or masking an image.
*/
class ImageCropScaleMaskTask extends AbstractTask
{
protected ?string $targetFileExtension;
public function getType(): string
{
return 'Image';
}
public function getName(): string
{
return 'CropScaleMask';
}
/**
* Determines the file extension the processed file
* should have in the filesystem.
*/
public function getTargetFileExtension(): string
{
if (!isset($this->targetFileExtension)) {
$this->targetFileExtension = $this->determineTargetFileExtension();
}
return $this->targetFileExtension;
}
/**
* Gets the file extension the processed file should
* have in the filesystem by either using the configuration
* setting, or the extension of the original file.
*/
protected function determineTargetFileExtension(): string
{
if (!empty($this->configuration['fileExtension'])) {
return $this->configuration['fileExtension'];
}
// @todo - See note of determineDefaultProcessingFileExtension() - find a better place for this
$imageService = GeneralUtility::makeInstance(GraphicalFunctions::class);
return $imageService->determineDefaultProcessingFileExtension($this->getSourceFile()->getExtension());
}
public function getTargetFileName(): string
{
return 'csm_'
. $this->getSourceFile()->getNameWithoutExtension()
. '_' . $this->getConfigurationChecksum()
. '.' . $this->getTargetFileExtension();
}
}
@@ -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\Resource\Processing;
use TYPO3\CMS\Core\Imaging\GraphicalFunctions;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* A task for generating an image preview.
*/
class ImagePreviewTask extends AbstractTask
{
protected ?string $targetFileExtension;
public function getType(): string
{
return 'Image';
}
public function getName(): string
{
return 'Preview';
}
/**
* Returns the name the processed file should have
* in the filesystem.
*/
public function getTargetFilename(): string
{
return 'preview_'
. $this->getSourceFile()->getNameWithoutExtension()
. '_' . $this->getConfigurationChecksum()
. '.' . $this->getTargetFileExtension();
}
/**
* Determines the file extension the processed file
* should have in the filesystem.
*/
public function getTargetFileExtension(): string
{
if (!isset($this->targetFileExtension)) {
$this->targetFileExtension = $this->determineTargetFileExtension();
}
return $this->targetFileExtension;
}
/**
* Gets the file extension the processed file should
* have in the filesystem by either using the configuration
* setting, or the extension of the original file.
*/
protected function determineTargetFileExtension(): string
{
if (!empty($this->configuration['fileExtension'])) {
return $this->configuration['fileExtension'];
}
// @todo - See note of determineDefaultProcessingFileExtension() - find a better place for this
$imageService = GeneralUtility::makeInstance(GraphicalFunctions::class);
return $imageService->determineDefaultProcessingFileExtension($this->getSourceFile()->getExtension());
}
/**
* Enforce default configuration for preview processing here,
* to be sure we find already processed files below,
* which we wouldn't if we would change the configuration later, as configuration is part of the lookup.
*/
public function sanitizeConfiguration(): void
{
$configuration = array_replace(
[
'width' => 64,
'height' => 64,
],
$this->configuration
);
$configuration['width'] = MathUtility::forceIntegerInRange($configuration['width'], 1, 1000);
$configuration['height'] = MathUtility::forceIntegerInRange($configuration['height'], 1, 1000);
$this->configuration = array_filter(
$configuration,
static function (string|int|bool|array|null $value, string $name): bool {
return !empty($value) && in_array($name, ['width', 'height'], true);
},
ARRAY_FILTER_USE_BOTH
);
parent::sanitizeConfiguration();
}
}
@@ -0,0 +1,436 @@
<?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\Resource\Processing;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Imaging\GraphicalFunctions;
use TYPO3\CMS\Core\Imaging\ImageProcessingInstructions;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\FileInterface;
use TYPO3\CMS\Core\Resource\FileType;
use TYPO3\CMS\Core\Type\File\ImageInfo;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\Imaging\GifBuilder;
/**
* Processes Local Images files
*/
class LocalImageProcessor implements ProcessorInterface, LoggerAwareInterface
{
use LoggerAwareTrait;
/**
* Returns TRUE if this processor can process the given task.
*/
public function canProcessTask(TaskInterface $task): bool
{
return $task->getType() === 'Image'
&& in_array($task->getName(), ['Preview', 'CropScaleMask'], true);
}
/**
* Processes the given task.
*
* @throws \InvalidArgumentException
*/
public function processTask(TaskInterface $task): void
{
if ($this->checkForExistingTargetFile($task)) {
return;
}
$this->processTaskWithLocalFile($task, null);
}
/**
* Processes an image described in a task, but optionally uses a given local image
*
* @throws \InvalidArgumentException
*/
protected function processTaskWithLocalFile(TaskInterface $task, ?string $localFile): void
{
try {
if ($task->getName() === 'CropScaleMask') {
if ($localFile === null) {
$result = $this->processCropScaleMask($task);
} else {
$result = $this->processCropScaleMaskWithLocalFile($task, $localFile);
}
} elseif ($task->getName() === 'Preview') {
if ($localFile === null) {
$result = $this->processPreview($task);
} else {
$result = $this->processPreviewWithLocalFile($task, $localFile);
}
} else {
throw new \InvalidArgumentException('Cannot find helper for task name: "' . $task->getName() . '"', 1353401352);
}
if ($result === null) {
$task->setExecuted(true);
$task->getTargetFile()->setUsesOriginalFile();
} elseif (!empty($result['filePath']) && file_exists($result['filePath'])) {
$task->setExecuted(true);
$imageInformation = GeneralUtility::makeInstance(ImageInfo::class, $result['filePath']);
if (($result['remapProcessedTargetFileExtension'] ?? null) !== null) {
// Processing changed the target filename extension to something else.
// We need to react on this, because otherwise the file contents will not
// match the file extension.
$task->getTargetFile()->setName($task->getTargetFileName() . '.' . $result['remapProcessedTargetFileExtension']);
} else {
$task->getTargetFile()->setName($task->getTargetFileName());
}
$task->getTargetFile()->updateProperties([
'width' => $imageInformation->getWidth(),
'height' => $imageInformation->getHeight(),
'size' => $imageInformation->getSize(),
'checksum' => $task->getConfigurationChecksum(),
]);
$task->getTargetFile()->updateWithLocalFile($result['filePath']);
} else {
// Seems we have no valid processing result
$task->setExecuted(false);
}
} catch (\Exception $e) {
// @todo: Swallowing all exceptions including PHP warnings here is a bad idea.
// @todo: This should be restricted to more specific exceptions - if at all.
// @todo: For now, we at least log the situation.
$this->logger->error(sprintf('Processing task of image file'), ['exception' => $e]);
$task->setExecuted(false);
}
}
/**
* Check if the target file that is to be processed already exists.
* If it exists, use the metadata from that file and mark task as done.
*/
protected function checkForExistingTargetFile(TaskInterface $task): bool
{
// the storage of the processed file, not of the original file!
$storage = $task->getTargetFile()->getStorage();
$processingFolder = $storage->getProcessingFolder($task->getSourceFile());
// explicitly check for the raw filename here, as we check for files that existed before we even started
// processing, i.e. that were processed earlier
if ($processingFolder->hasFile($task->getTargetFileName())) {
// When the processed file already exists set it as processed file
$task->getTargetFile()->setName($task->getTargetFileName());
// If the processed file is stored on a remote server, we must fetch a local copy of the file, as we
// have no API for fetching file metadata from a remote file.
$localProcessedFile = $storage->getFileForLocalProcessing($task->getTargetFile(), false);
$task->setExecuted(true);
$imageInformation = GeneralUtility::makeInstance(ImageInfo::class, $localProcessedFile);
$properties = [
'width' => $imageInformation->getWidth(),
'height' => $imageInformation->getHeight(),
'size' => $imageInformation->getSize(),
'checksum' => $task->getConfigurationChecksum(),
];
$task->getTargetFile()->updateProperties($properties);
return true;
}
return false;
}
/**
* Helper methods to locally perform a crop/scale/mask task with the TYPO3 image processing classes.
*/
/**
* This method actually does the processing of files locally
*
* Takes the original file (for remote storages this will be fetched from the remote server),
* does the IM magic on the local server by creating a temporary typo3temp/ file,
* copies the typo3temp/ file to the processing folder of the target storage and
* removes the typo3temp/ file.
*
* The returned array has the following structure:
* width => 100
* height => 200
* filePath => /some/path
*
* If filePath isn't set but width and height are the original file is used as ProcessedFile
* with the returned width and height. This is for example useful for SVG images.
*/
protected function processCropScaleMask(TaskInterface $task): ?array
{
return $this->processCropScaleMaskWithLocalFile($task, $task->getSourceFile()->getForLocalProcessing(false));
}
/**
* Does the heavy lifting prescribed in processTask()
* except that the processing can be performed on any given local image.
* Note that the resize() method usually does not upscale images (depends on "noScale" option),
* so the original file would be used for the processor result.
*/
protected function processCropScaleMaskWithLocalFile(TaskInterface $task, string $originalFileName): ?array
{
$result = null;
$targetFile = $task->getTargetFile();
$targetFileExtension = $task->getTargetFileExtension();
$imageOperations = GeneralUtility::makeInstance(GraphicalFunctions::class);
$configuration = $targetFile->getProcessingConfiguration();
$configuration['additionalParameters'] ??= '';
// Normal situation (no masking) - just scale the image
if (!is_array($configuration['maskImages'] ?? null)) {
// the result info is an array with 0=width,1=height,2=extension,3=filename
$result = $imageOperations->resize(
$originalFileName,
$targetFileExtension,
$configuration['width'] ?? '',
$configuration['height'] ?? '',
$configuration['additionalParameters'],
$configuration,
);
} else {
$temporaryFileName = $this->getFilenameForImageCropScaleMask($task);
$maskImage = $configuration['maskImages']['maskImage'] ?? null;
$maskBackgroundImage = $configuration['maskImages']['backgroundImage'];
if ($maskImage instanceof FileInterface && $maskBackgroundImage instanceof FileInterface) {
// This converts the original image to a temporary PNG file during all steps of the masking process
$tempFileInfo = $imageOperations->resize(
$originalFileName,
'png',
$configuration['width'] ?? '',
$configuration['height'] ?? '',
$configuration['additionalParameters'],
$configuration
);
if ($tempFileInfo !== null) {
// Scaling
$command = '-geometry ' . $tempFileInfo->getWidth() . 'x' . $tempFileInfo->getHeight() . '!';
$imageOperations->mask(
$tempFileInfo->getRealPath(),
$temporaryFileName,
$maskImage->getForLocalProcessing(),
$maskBackgroundImage->getForLocalProcessing(),
$command,
$configuration
);
$maskBottomImage = $configuration['maskImages']['maskBottomImage'] ?? null;
$maskBottomImageMask = $configuration['maskImages']['maskBottomImageMask'] ?? null;
if ($maskBottomImage instanceof FileInterface && $maskBottomImageMask instanceof FileInterface) {
// Uses the temporary PNG file from the previous step and applies another mask
$imageOperations->mask(
$temporaryFileName,
$temporaryFileName,
$maskBottomImage->getForLocalProcessing(),
$maskBottomImageMask->getForLocalProcessing(),
$command,
$configuration
);
}
}
$result = $tempFileInfo;
}
}
// check if the processing really generated a new file (scaled and/or cropped)
if ($result !== null) {
// The file processing yielded a different file extension than we anticipated. Most likely because
// the processing service found out a file type needed to use fallback storage. In this case, we
// append the actually received file extension to our file to be stored, which will also hint at
// a failed conversion, like some-file.avif.jpg. Otherwise use the same file extension. This is
// evaluated for persistence in @see LocalImageProcessor->processTaskWithLocalFile().
$remapProcessedTargetFileExtension = ($targetFileExtension !== $result->getExtension())
// Remap to correct image type extension.
? $result->getExtension()
// No file extension remap required.
: null;
// @todo: realpath handling should be revisited, they may produce issues
// with open_basedir restrictions and/or lockRootPath.
if ($result->getRealPath() !== realpath($originalFileName)) {
$result = [
'width' => $result->getWidth(),
'height' => $result->getHeight(),
'filePath' => $result->getRealPath(),
'remapProcessedTargetFileExtension' => $remapProcessedTargetFileExtension,
];
} else {
// No file was generated
$result = null;
}
}
// If noScale option is applied, we need to reset the width and height to ensure the scaled values
// are used for the generated image tag even if the image itself is not scaled. This is needed, as
// the result is discarded due to the fact that the original image is used.
// @see https://forge.typo3.org/issues/100972
// Note: This should only happen if no image has been generated ($result === null).
if ($result === null && ($configuration['noScale'] ?? false)) {
$configuration = $task->getConfiguration();
$localProcessedFile = $task->getSourceFile()->getForLocalProcessing(false);
$imageDimensions = $imageOperations->getImageDimensions($localProcessedFile, true);
$imageScaleInfo = ImageProcessingInstructions::fromCropScaleValues(
$imageDimensions->getWidth(),
$imageDimensions->getHeight(),
$configuration['width'] ?? '',
$configuration['height'] ?? '',
$configuration
);
$targetFile->updateProperties([
'width' => $imageScaleInfo->width,
'height' => $imageScaleInfo->height,
]);
}
return $result;
}
/**
* Returns the filename for a cropped/scaled/masked file which will be put in typo3temp for the time being.
*/
protected function getFilenameForImageCropScaleMask(TaskInterface $task): string
{
$targetFileExtension = $task->getTargetFileExtension();
$name = $this->generateProcessedFileNameWithoutExtension($task);
return Environment::getPublicPath() . '/typo3temp/' . $name . '.' . ltrim(trim($targetFileExtension), '.');
}
/**
* Generate the name of the new File. Should be placed somwhere else?
*/
protected function generateProcessedFileNameWithoutExtension(TaskInterface $task): string
{
return implode('_', [
$task->getSourceFile()->getNameWithoutExtension(),
$task->getSourceFile()->getUid(),
$task->getConfigurationChecksum(),
]);
}
/**
* Helper for creating local image previews using TYPO3s image processing classes.
*/
/**
* This method actually does the processing of files locally
*
* takes the original file (on remote storages this will be fetched from the remote server)
* does the IM magic on the local server by creating a temporary typo3temp/ file
* copies the typo3temp/ file to the processing folder of the target storage
* removes the typo3temp/ file
*
* The returned array has the following structure:
* width => 100
* height => 200
* filePath => /some/path
*
* If filePath isn't set but width and height are the original file is used as ProcessedFile
* with the returned width and height. This is for example useful for SVG images.
*/
protected function processPreview(TaskInterface $task): ?array
{
$sourceFile = $task->getSourceFile();
$task->sanitizeConfiguration();
$configuration = $task->getConfiguration();
// Do not scale up, if the source file has dimensions and any target dimension (width/height) is larger
// This is related to $TYPO3_CONF_VARS['GFX']['processor_allowUpscaling'] = false to ensure the original
// file can be used (instead of getting processed)
if ($sourceFile->getProperty('width') > 0 && $sourceFile->getProperty('height') > 0
&& (
$configuration['width'] > $sourceFile->getProperty('width')
|| $configuration['height'] > $sourceFile->getProperty('height')
)
) {
return null;
}
return $this->generatePreviewFromFile($sourceFile, $configuration, $this->getTemporaryFilePathForPreview($task));
}
/**
* Does the heavy lifting prescribed in processTask()
* except that the processing can be performed on any given local image
*/
protected function processPreviewWithLocalFile(TaskInterface $task, string $localFile): ?array
{
return $this->generatePreviewFromLocalFile($localFile, $task->getConfiguration(), $this->getTemporaryFilePathForPreview($task));
}
/**
* Returns the path to a temporary file for processing
*
* @return non-empty-string
*/
protected function getTemporaryFilePathForPreview(TaskInterface $task): string
{
return GeneralUtility::tempnam('preview_', '.' . $task->getTargetFileExtension());
}
/**
* Generates a preview for a file
*
* @param File $file The source file
* @param array $configuration Processing configuration
* @param string $targetFilePath Output file path
*/
protected function generatePreviewFromFile(File $file, array $configuration, string $targetFilePath): array
{
// Check file extension
if (!$file->isType(FileType::IMAGE) && !$file->isImage()) {
// Create a default image
$graphicalFunctions = GeneralUtility::makeInstance(GifBuilder::class);
$graphicalFunctions->getTemporaryImageWithText(
$targetFilePath,
'Not imagefile!',
'No ext!',
$file->getName()
);
return [
'filePath' => $targetFilePath,
];
}
return $this->generatePreviewFromLocalFile($file->getForLocalProcessing(false), $configuration, $targetFilePath);
}
/**
* Generates a preview for a local file
*
* @param string $originalFileName Optional input file path
* @param array $configuration Processing configuration
* @param string $targetFilePath Output file path
*/
protected function generatePreviewFromLocalFile(string $originalFileName, array $configuration, string $targetFilePath): array
{
// Create the temporary file
$imageService = GeneralUtility::makeInstance(GraphicalFunctions::class);
$result = $imageService->resize($originalFileName, 'WEB', $configuration['width'] . 'm', $configuration['height'] . 'm', '', ['sample' => true]);
if ($result) {
$targetFilePath = $result->getRealPath();
}
if (!file_exists($targetFilePath)) {
// Create an error gif
$graphicalFunctions = GeneralUtility::makeInstance(GifBuilder::class);
$graphicalFunctions->getTemporaryImageWithText(
$targetFilePath,
'No thumb',
'generated!'
);
}
return [
'filePath' => $targetFilePath,
];
}
}
@@ -0,0 +1,34 @@
<?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\Resource\Processing;
/**
* Interface for file processors. All classes capable of processing a file have to implement this interface.
*/
interface ProcessorInterface
{
/**
* Returns TRUE if this processor can process the given task.
*
* @return bool
*/
public function canProcessTask(TaskInterface $task);
/**
* Processes the given task and sets the processing result in the task object.
*/
public function processTask(TaskInterface $task);
}
@@ -0,0 +1,85 @@
<?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\Resource\Processing;
use TYPO3\CMS\Core\Service\DependencyOrderingService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Registry for images processors.
*/
class ProcessorRegistry
{
protected array $registeredProcessors = [];
/**
* Auto register processors from configuration
*/
public function __construct(DependencyOrderingService $dependencyOrderingService)
{
$this->registeredProcessors = $dependencyOrderingService->orderByDependencies(
$GLOBALS['TYPO3_CONF_VARS']['SYS']['fal']['processors'] ?? []
);
}
/**
* Finds a matching processor that can process the given task.
* Registered processors will be tested by their priority from high to low.
*/
public function getProcessorByTask(TaskInterface $task): ProcessorInterface
{
$processor = null;
foreach ($this->registeredProcessors as $key => $processorConfiguration) {
if (!isset($processorConfiguration['className'])) {
throw new \RuntimeException(
'Missing key "className" for processor configuration "' . $key . '".',
1560875741
);
}
$processor = GeneralUtility::makeInstance($processorConfiguration['className']);
if (!$processor instanceof ProcessorInterface) {
throw new \RuntimeException(
'Processor "' . get_class($processor) . '" needs to implement interface "' . ProcessorInterface::class . '".',
1560876288
);
}
if ($processor->canProcessTask($task)) {
/*
* Stop checking for further processors to speed up image processing.
* If another processor should be used, it can be registered with higher priority.
*/
break;
}
$processor = null;
}
if ($processor === null) {
throw new \RuntimeException(
sprintf('No matching file processor found for task type "%s" and name "%s".', $task->getType(), $task->getName()),
1560876294
);
}
return $processor;
}
}
@@ -0,0 +1,192 @@
<?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\Resource\Processing;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Imaging\Exception\InvalidSvgException;
use TYPO3\CMS\Core\Imaging\Exception\ZeroImageDimensionException;
use TYPO3\CMS\Core\Imaging\ImageDimension;
use TYPO3\CMS\Core\Imaging\ImageManipulation\Area;
use TYPO3\CMS\Core\Imaging\ImageProcessingInstructions;
use TYPO3\CMS\Core\Imaging\Svg\SvgDocumentFactory;
use TYPO3\CMS\Core\Imaging\Svg\SvgDocumentService;
use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderReadPermissionsException;
use TYPO3\CMS\Core\Type\File\ImageInfo;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Processes (scales) SVG Images files or crops them via \DOMDocument
* and creates a new locally created processed file which is then pushed
* into FAL again.
*/
#[Autoconfigure(public: true)]
readonly class SvgImageProcessor implements ProcessorInterface
{
private const int DEFAULT_SVG_DIMENSION = 64;
public function __construct(
private SvgDocumentFactory $svgDocumentFactory,
private SvgDocumentService $svgDocumentService,
) {}
public function canProcessTask(TaskInterface $task): bool
{
return $task->getType() === 'Image'
&& in_array($task->getName(), ['Preview', 'CropScaleMask'], true)
&& $task->getTargetFileExtension() === 'svg';
}
/**
* Processes the given task.
*
* @throws \InvalidArgumentException|InsufficientFolderReadPermissionsException
*/
public function processTask(TaskInterface $task): void
{
try {
$processingInstructions = ImageProcessingInstructions::fromProcessingTask($task);
$imageDimension = new ImageDimension($processingInstructions->width, $processingInstructions->height);
} catch (ZeroImageDimensionException) {
$processingInstructions = new ImageProcessingInstructions(
width: self::DEFAULT_SVG_DIMENSION,
height: self::DEFAULT_SVG_DIMENSION,
);
// To not fail image processing, we just assume an SVG image dimension here
$imageDimension = new ImageDimension(
width: self::DEFAULT_SVG_DIMENSION,
height: self::DEFAULT_SVG_DIMENSION
);
}
$task->getTargetFile()->updateProperties(
[
'width' => $imageDimension->getWidth(),
'height' => $imageDimension->getHeight(),
'size' => $task->getSourceFile()->getSize(),
'checksum' => $task->getConfigurationChecksum(),
]
);
if ($this->checkForExistingTargetFile($task)) {
return;
}
$cropArea = $processingInstructions->cropArea;
if ($cropArea === null || $cropArea->makeRelativeBasedOnFile($task->getSourceFile())->isEmpty()) {
$task->setExecuted(true);
$task->getTargetFile()->setUsesOriginalFile();
return;
}
$this->applyCropping($task, $cropArea, $imageDimension);
}
/**
* Wrap the source SVG in a crop container and write it to a temporary
* processed file. The wrapper carries the viewBox crop and target
* dimensions so the result is self-contained when embedded via <img>.
*/
protected function applyCropping(TaskInterface $task, Area $cropArea, ImageDimension $imageDimension): void
{
try {
$document = $this->svgDocumentFactory->fromFile($task->getSourceFile());
$processedSvg = $this->svgDocumentService->cropScale($document, $cropArea, $imageDimension);
} catch (InvalidSvgException) {
// Source SVG could not be parsed - fall back to the unprocessed original.
$task->setExecuted(true);
$task->getTargetFile()->setUsesOriginalFile();
return;
}
$temporaryFilename = $this->getFilenameForSvgCropScaleMask($task);
GeneralUtility::writeFile($temporaryFilename, $this->svgDocumentService->toXml($processedSvg), true);
$task->setExecuted(true);
$imageInformation = GeneralUtility::makeInstance(ImageInfo::class, $temporaryFilename);
$task->getTargetFile()->setName($task->getTargetFileName());
$task->getTargetFile()->updateProperties([
// @todo: Use round() instead of int-cast to avoid an implicit floor()?
'width' => (string)$imageDimension->getWidth(),
'height' => (string)$imageDimension->getHeight(),
'size' => $imageInformation->getSize(),
'checksum' => $task->getConfigurationChecksum(),
]);
$task->getTargetFile()->updateWithLocalFile($temporaryFilename);
GeneralUtility::unlink_tempfile($temporaryFilename);
}
/**
* Check if the target file that is to be processed already exists.
* If it exists, use the metadata from that file and mark task as done.
*
* @throws InsufficientFolderReadPermissionsException
* @todo - Refactor this 80% duplicate code of LocalImageProcessor::checkForExistingTargetFile
*/
protected function checkForExistingTargetFile(TaskInterface $task): bool
{
// the storage of the processed file, not of the original file!
$storage = $task->getTargetFile()->getStorage();
$processingFolder = $storage->getProcessingFolder($task->getSourceFile());
// explicitly check for the raw filename here, as we check for files that existed before we even started
// processing, i.e. that were processed earlier
if ($processingFolder->hasFile($task->getTargetFileName())) {
// When the processed file already exists set it as processed file
$task->getTargetFile()->setName($task->getTargetFileName());
// If the processed file is stored on a remote server, we must fetch a local copy of the file, as we
// have no API for fetching file metadata from a remote file.
$localProcessedFile = $storage->getFileForLocalProcessing($task->getTargetFile(), false);
$task->setExecuted(true);
$imageInformation = GeneralUtility::makeInstance(ImageInfo::class, $localProcessedFile);
$properties = [
'width' => $imageInformation->getWidth(),
'height' => $imageInformation->getHeight(),
'size' => $imageInformation->getSize(),
'checksum' => $task->getConfigurationChecksum(),
];
$task->getTargetFile()->updateProperties($properties);
return true;
}
return false;
}
/**
* Returns the filename for a cropped/scaled/masked file which will be put
* in typo3temp for the time being.
*/
protected function getFilenameForSvgCropScaleMask(TaskInterface $task): string
{
$targetFileExtension = $task->getTargetFileExtension();
return GeneralUtility::tempnam($this->generateProcessedFileNameWithoutExtension($task), '.' . ltrim(trim($targetFileExtension)));
}
/**
* Generate the name of the new File. Should be placed somwhere else?
*/
protected function generateProcessedFileNameWithoutExtension(TaskInterface $task): string
{
return implode('_', [
$task->getSourceFile()->getNameWithoutExtension(),
$task->getSourceFile()->getUid(),
$task->getConfigurationChecksum(),
]);
}
}
@@ -0,0 +1,111 @@
<?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\Resource\Processing;
use TYPO3\CMS\Core\Resource;
/**
* A task is a unit of work that can be performed by a file processor. This may include multiple steps in any order,
* details depend on the configuration of the task and the tools the processor uses.
*
* Each task has a type and a name. The type describes the category of the task, like "image" and "video". If your task
* is generic or applies to multiple types of files, use "general".
*
* A task also already has to know the target file it should be executed on, so there is no "abstract" task that just
* specifies the steps to be executed without a concrete file. However, new tasks can easily be created from an
* existing task object.
*/
interface TaskInterface
{
/**
* Returns the name of this task.
*/
public function getName(): string;
/**
* Returns the type of this task.
*/
public function getType(): string;
/**
* Returns the processed file this task is executed on.
*/
public function getTargetFile(): Resource\ProcessedFile;
/**
* Returns the original file this task is based on.
*/
public function getSourceFile(): Resource\File;
/**
* Returns the configuration for this task.
*/
public function getConfiguration(): array;
/**
* Returns the configuration checksum of this task.
*/
public function getConfigurationChecksum(): string;
/**
* Returns the name the processed file should have in the filesystem.
*/
public function getTargetFileName(): string;
/**
* Gets the file extension the processed file should have in the filesystem.
*/
public function getTargetFileExtension(): string;
/**
* Returns TRUE if the file has to be processed at all, such as e.g. the original file does.
*
* Note: This does not indicate if the concrete ProcessedFile attached to this task has to be (re)processed.
* This check is done in ProcessedFile::isOutdated(). @todo isOutdated()/needsReprocessing()?
*/
public function fileNeedsProcessing(): bool;
/**
* Returns TRUE if this task has been executed, no matter if the execution was successful.
*/
public function isExecuted(): bool;
/**
* Mark this task as executed. This is used by the Processors in order to transfer the state of this task to
* the file processing service.
*
* @param bool $successful Set this to FALSE if executing the task failed
*/
public function setExecuted(bool $successful): void;
/**
* Returns TRUE if this task has been successfully executed. Only call this method if the task has been processed
* at all.
*
* @throws \LogicException If the task has not been executed already
*/
public function isSuccessful(): bool;
/**
* For some tasks it might be important and useful to clean up the configuration, in order to find the
* ProcessedFile that uses this configuration.
*
* Ideally, a task has some information what needs to be used or not.
*/
public function sanitizeConfiguration(): void;
}
@@ -0,0 +1,57 @@
<?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\Resource\Processing;
use TYPO3\CMS\Core\Resource\ProcessedFile;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* The registry for task types.
*/
class TaskTypeRegistry implements SingletonInterface
{
protected array $registeredTaskTypes = [];
/**
* Register task types from configuration
*/
public function __construct()
{
$this->registeredTaskTypes = $GLOBALS['TYPO3_CONF_VARS']['SYS']['fal']['processingTaskTypes'];
}
/**
* Returns the class that implements the given task type.
*/
protected function getClassForTaskType(string $taskType): ?string
{
return $this->registeredTaskTypes[$taskType] ?? null;
}
/**
* @throws \RuntimeException
*/
public function getTaskForType(string $taskType, ProcessedFile $processedFile, array $processingConfiguration): TaskInterface
{
$taskClass = $this->getClassForTaskType($taskType);
if ($taskClass === null) {
throw new \RuntimeException('Unknown processing task "' . $taskType . '"', 1476049767);
}
return GeneralUtility::makeInstance($taskClass, $processedFile, $processingConfiguration);
}
}