diff --git a/typo3/sysext/seo/Classes/MetaTag/MetaTagGenerator.php b/typo3/sysext/seo/Classes/MetaTag/MetaTagGenerator.php index 4093928c1726b764331ec5115491f99c6c8e4141..bd1821e30f6914529c1791a9d503445976b15353 100644 --- a/typo3/sysext/seo/Classes/MetaTag/MetaTagGenerator.php +++ b/typo3/sysext/seo/Classes/MetaTag/MetaTagGenerator.php @@ -19,6 +19,7 @@ namespace TYPO3\CMS\Seo\MetaTag; use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection; use TYPO3\CMS\Core\MetaTag\MetaTagManagerRegistry; +use TYPO3\CMS\Core\Resource\FileInterface; use TYPO3\CMS\Core\Resource\FileReference; use TYPO3\CMS\Core\Resource\ProcessedFile; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -128,45 +129,52 @@ class MetaTagGenerator } /** - * @param array $fileReferences + * @param list<FileReference> $fileReferences * @return array */ protected function generateSocialImages(array $fileReferences): array { $socialImages = []; - /** @var FileReference $file */ - foreach ($fileReferences as $file) { - $arguments = $file->getProperties(); - $cropVariantCollection = CropVariantCollection::create((string)$arguments['crop']); - $cropVariant = ($arguments['cropVariant'] ?? false) ?: 'social'; - $cropArea = $cropVariantCollection->getCropArea($cropVariant); - $crop = $cropArea->makeAbsoluteBasedOnFile($file); - - $processingConfiguration = [ - 'crop' => $crop, - 'maxWidth' => 2000, - ]; - - if ($file->getProperty('width') > $processingConfiguration['maxWidth'] || ($cropArea->getHeight() !== 1.0 && $cropArea->getWidth() !== 1.0)) { - $image = $file->getOriginalFile()->process( - ProcessedFile::CONTEXT_IMAGECROPSCALEMASK, - $processingConfiguration - ); - } else { - $image = $file->getOriginalFile(); - } - - $imageUri = $this->imageService->getImageUri($image, true); - + foreach ($fileReferences as $fileReference) { + $arguments = $fileReference->getProperties(); + $image = $this->processSocialImage($fileReference); $socialImages[] = [ - 'url' => $imageUri, - 'width' => floor($image->getProperty('width')), - 'height' => floor($image->getProperty('height')), + 'url' => $this->imageService->getImageUri($image, true), + 'width' => floor((float)$image->getProperty('width')), + 'height' => floor((float)$image->getProperty('height')), 'alternative' => $arguments['alternative'], ]; } return $socialImages; } + + protected function processSocialImage(FileReference $fileReference): FileInterface + { + $arguments = $fileReference->getProperties(); + $cropVariantCollection = CropVariantCollection::create((string)($arguments['crop'] ?? '')); + $cropVariantName = ($arguments['cropVariant'] ?? false) ?: 'social'; + $cropArea = $cropVariantCollection->getCropArea($cropVariantName); + $crop = $cropArea->makeAbsoluteBasedOnFile($fileReference); + + $processingConfiguration = [ + 'crop' => $crop, + 'maxWidth' => 2000, + ]; + + // The image needs to be processed if: + // - the image width is greater than the defined maximum width, or + // - there is a cropping other than the full image (starts at 0,0 and has a width and height of 100%) defined + $needsProcessing = $fileReference->getProperty('width') > $processingConfiguration['maxWidth'] + || !$cropArea->isEmpty(); + if (!$needsProcessing) { + return $fileReference->getOriginalFile(); + } + + return $fileReference->getOriginalFile()->process( + ProcessedFile::CONTEXT_IMAGECROPSCALEMASK, + $processingConfiguration + ); + } } diff --git a/typo3/sysext/seo/Tests/Functional/MetaTag/MetaTagGeneratorTest.php b/typo3/sysext/seo/Tests/Functional/MetaTag/MetaTagGeneratorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..3bd71940ec861b8897ca4ef73f50287fac8770b2 --- /dev/null +++ b/typo3/sysext/seo/Tests/Functional/MetaTag/MetaTagGeneratorTest.php @@ -0,0 +1,230 @@ +<?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\Seo\Tests\Functional\MetaTag; + +use org\bovigo\vfs\vfsStream; +use org\bovigo\vfs\vfsStreamDirectory; +use TYPO3\CMS\Core\Imaging\ImageManipulation\Area; +use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection; +use TYPO3\CMS\Core\Resource\File; +use TYPO3\CMS\Core\Resource\FileInterface; +use TYPO3\CMS\Core\Resource\ProcessedFile; +use TYPO3\CMS\Core\Resource\ResourceFactory; +use TYPO3\CMS\Core\Resource\ResourceStorage; +use TYPO3\CMS\Core\Resource\StorageRepository; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Install\Configuration\Image\GraphicsMagickPreset; +use TYPO3\CMS\Seo\MetaTag\MetaTagGenerator; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; + +class MetaTagGeneratorTest extends FunctionalTestCase +{ + private MetaTagGenerator $subject; + private ResourceStorage $defaultStorage; + private vfsStreamDirectory $temporaryFileSystem; + protected array $coreExtensionsToLoad = ['seo']; + + protected function setUp(): void + { + parent::setUp(); + // functional tests use GraphicMagick per default, resolve the corresponding path in current OS + $GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_path'] = $this->determineGraphicMagickBinaryPath(); + $this->subject = GeneralUtility::makeInstance(MetaTagGenerator::class); + $this->defaultStorage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage(); + $this->temporaryFileSystem = vfsStream::setup(); + } + + public static function socialImageIsProcessedDataProvider(): \Generator + { + // having a valid `crop` definition, images are only process if there's a necessity + yield 'social: 600x600 enforced ratio' => [ + true, + ['width' => 600, 'height' => 600], + ['width' => 600, 'height' => 315], + ProcessedFile::class, + ]; + yield 'social: 600x315 kept as is' => [ + true, + ['width' => 600, 'height' => 315], + ['width' => 600, 'height' => 315], + File::class, + ]; + yield 'social: 1200x630 kept as is' => [ + true, + ['width' => 1200, 'height' => 630], + ['width' => 1200, 'height' => 630], + File::class, + ]; + yield 'social: 2400x1260 limited to maxWidth, kept ratio' => [ + true, + ['width' => 2400, 'height' => 1260], + ['width' => 2000, 'height' => 1050], + ProcessedFile::class, + ]; + yield 'social: 3000x3000 limited to maxWidth, enforced ratio' => [ + true, + ['width' => 3000, 'height' => 3000], + ['width' => 2000, 'height' => 1050], + ProcessedFile::class, + ]; + yield 'social: 600x300 enforced ratio (no up-scaling)' => [ + true, + ['width' => 600, 'height' => 3000], + ['width' => 600, 'height' => 315], + ProcessedFile::class, + ]; + yield 'social: 3000x600 enforced ratio (no up-scaling)' => [ + true, + ['width' => 3000, 'height' => 600], + ['width' => 1142, 'height' => 600], + ProcessedFile::class, + ]; + + // in case `crop` is not defined, no target ratio is defined for these images + // (data created prior to https://review.typo3.org/c/Packages/TYPO3.CMS/+/58774/ in v9.5.1 behaves like this) + yield 'empty crop: 600x600 kept as is' => [ + false, + ['width' => 600, 'height' => 600], + ['width' => 600, 'height' => 600], + File::class, + ]; + yield 'empty crop: 600x315 kept as is' => [ + false, + ['width' => 600, 'height' => 315], + ['width' => 600, 'height' => 315], + File::class, + ]; + yield 'empty crop: 1200x630 kept as is' => [ + false, + ['width' => 1200, 'height' => 630], + ['width' => 1200, 'height' => 630], + File::class, + ]; + yield 'empty crop: 2400x1260 limited to maxWidth' => [ + false, + ['width' => 2400, 'height' => 1260], + ['width' => 2000, 'height' => 1050], + ProcessedFile::class, + ]; + yield 'empty crop: 3000x3000 limited to maxWidth' => [ + false, + ['width' => 3000, 'height' => 3000], + ['width' => 2000, 'height' => 2000], + ProcessedFile::class, + ]; + yield 'empty crop: 600x300 kept as is' => [ + false, + ['width' => 600, 'height' => 3000], + ['width' => 600, 'height' => 3000], + File::class, + ]; + yield 'empty crop: 3000x600 limited to maxWidth' => [ + false, + ['width' => 3000, 'height' => 600], + ['width' => 2000, 'height' => 400], + ProcessedFile::class, + ]; + } + + /** + * @param array{width: int, height: int} $imageDimension + * @param array{width: int, height: int} $expectedDimension + * @test + * @dataProvider socialImageIsProcessedDataProvider + */ + public function socialImageIsProcessed(bool $hasCrop, array $imageDimension, array $expectedDimension, string $expectedClassName): void + { + $fileName = sprintf('test_%dx%d.png', $imageDimension['width'], $imageDimension['height']); + $folder = $this->defaultStorage->getFolder('/'); + // drop file if it exists + $file = $folder->getFile($fileName); + if ($file !== null) { + $file->delete(); + } + // create new file, fill it dummy PNG data for given dimension + /** @var File $file */ + $file = $this->defaultStorage->createFile($fileName, $folder); + $file->setContents($this->createImagePngContent($imageDimension['width'], $imageDimension['height'])); + // temporary file reference to an actual existing file + $fileReferenceProperties = [ + 'uid_local' => $file->getUid(), + 'uid_foreign' => 0, + 'uid' => 0, + 'crop' => '', + ]; + if ($hasCrop) { + $cropVariantCollection = CropVariantCollection::create('', $this->resolveCropVariantsConfiguration()); + $cropVariantCollection = $cropVariantCollection->applyRatioRestrictionToSelectedCropArea($file); + $fileReferenceProperties['crop'] = (string)$cropVariantCollection; + } + $fileReference = GeneralUtility::makeInstance(ResourceFactory::class) + ->createFileReferenceObject($fileReferenceProperties); + // invoke processing of social image + $reflectionSubject = new \ReflectionObject($this->subject); + $reflectionMethod = $reflectionSubject->getMethod('processSocialImage'); + $reflectionMethod->setAccessible(true); // no-op in PHP 8.1 + /** @var FileInterface $processedSocialImage */ + $processedSocialImage = $reflectionMethod->invoke($this->subject, $fileReference); + + self::assertSame($expectedDimension, [ + 'width' => (int)$processedSocialImage->getProperty('width'), + 'height' => (int)$processedSocialImage->getProperty('height'), + ]); + self::assertInstanceOf($expectedClassName, $processedSocialImage); + } + + private function createImagePngContent(int $width, int $height): string + { + $filePath = $this->temporaryFileSystem->url() . '/image.png'; + $gdImage = imagecreatetruecolor($width, $height); + imagepng($gdImage, $filePath); + return file_get_contents($filePath); + } + + private function determineGraphicMagickBinaryPath(): string + { + $values = GeneralUtility::makeInstance(GraphicsMagickPreset::class)->getConfigurationValues(); + return $values['GFX/processor_path'] ?? $GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_path']; + } + + /** + * A little bit like `\TYPO3\CMS\Backend\Form\Element\ImageManipulationElement::populateConfiguration`... + */ + private function resolveCropVariantsConfiguration(): array + { + $config = $GLOBALS['TCA']['pages']['columns']['og_image']['config']['overrideChildTca']['columns']['crop']['config']; + $cropVariants = []; + foreach ($config['cropVariants'] as $id => $cropVariant) { + // Filter allowed aspect ratios + $cropVariant['allowedAspectRatios'] = array_filter( + $cropVariant['allowedAspectRatios'] ?? [], + static fn ($aspectRatio) => empty($aspectRatio['disabled']) + ); + // Ignore disabled crop variants + if (!empty($cropVariant['disabled'])) { + continue; + } + // Enforce a crop area (default is full image) + if (empty($cropVariant['cropArea'])) { + $cropVariant['cropArea'] = Area::createEmpty()->asArray(); + } + $cropVariants[$id] = $cropVariant; + } + return $cropVariants; + } +}