From 1a5a1ca37a8e3bf7e9d2a8572bbf188acdc1028a Mon Sep 17 00:00:00 2001 From: Janek Urbitsch Date: Wed, 22 Oct 2025 12:18:37 +0200 Subject: [PATCH] Task: add svg handling and (possible) cropping ISSUE: https://github.com/neos/neos-development-collection/issues/5652 --- .../Command/MediaCommandController.php | 56 ++++++++++ .../Model/Adjustment/CropImageAdjustment.php | 14 ++- .../Classes/Domain/Service/ImageService.php | 104 ++++++++++++++++-- composer.json | 1 + 4 files changed, 161 insertions(+), 14 deletions(-) diff --git a/Neos.Media/Classes/Command/MediaCommandController.php b/Neos.Media/Classes/Command/MediaCommandController.php index 8301c20dcd7..1a76d609eca 100644 --- a/Neos.Media/Classes/Command/MediaCommandController.php +++ b/Neos.Media/Classes/Command/MediaCommandController.php @@ -22,6 +22,8 @@ use Neos\Flow\Cli\CommandController; use Neos\Flow\Cli\Exception\StopCommandException; use Neos\Flow\Persistence\Exception\IllegalObjectTypeException; +use Neos\Flow\Persistence\Exception\InvalidQueryException; +use Neos\Flow\Persistence\Exception\UnknownObjectException; use Neos\Flow\Persistence\PersistenceManagerInterface; use Neos\Flow\Reflection\ReflectionService; use Neos\Flow\ResourceManagement\PersistentResource; @@ -29,9 +31,11 @@ use Neos\Media\Domain\Model\AssetCollection; use Neos\Media\Domain\Model\AssetInterface; use Neos\Media\Domain\Model\AssetSource\AssetSourceAwareInterface; +use Neos\Media\Domain\Model\Image; use Neos\Media\Domain\Model\Tag; use Neos\Media\Domain\Model\VariantSupportInterface; use Neos\Media\Domain\Repository\AssetRepository; +use Neos\Media\Domain\Repository\ImageRepository; use Neos\Media\Domain\Repository\ImageVariantRepository; use Neos\Media\Domain\Repository\ThumbnailRepository; use Neos\Media\Domain\Service\AssetService; @@ -40,6 +44,7 @@ use Neos\Media\Domain\Strategy\AssetModelMappingStrategyInterface; use Neos\Media\Exception\AssetServiceException; use Neos\Media\Exception\AssetVariantGeneratorException; +use Neos\Media\Exception\ImageFileException; use Neos\Media\Exception\ThumbnailServiceException; use Neos\Utility\Arrays; use Neos\Utility\Files; @@ -118,6 +123,9 @@ class MediaCommandController extends CommandController */ protected $assetVariantGenerator; + #[Flow\Inject] + protected ImageRepository $imageRepository; + /** * Import resources to asset management * @@ -593,6 +601,54 @@ public function renderVariantsCommand(?int $limit = null, bool $quiet = false, b !$quiet && $this->outputLine($resultMessage ?? sprintf('Generated %u variants', $generatedVariants)); } + /** + * Calculate missing dimensions for SVG assets + * + * @param bool $force update all svg asset dimensions + * @throws InvalidQueryException + * @throws ImageFileException + * @throws UnknownObjectException + */ + public function refreshSvgDimensionsCommand(bool $force = false): void + { + $this->outputLine('Looking for SVG Assets without dimensions'); + + $queryResult = $this->imageRepository->findAll(); + $totalCount = $queryResult->count(); + $this->output->progressStart($totalCount); + $updatedCount = 0; + + /** @var Image $image */ + foreach ($queryResult as $image) { + $this->output->progressAdvance(1); + + if (!$this->isSvgImage($image)) { + continue; + } + + if ($force || $this->hasMissingDimensions($image)) { + $updatedCount++; + $image->refresh(); + // the image repository tries magic we need to circumvent + $this->persistenceManager->update($image); + } + } + + $this->output->progressFinish(); + $this->outputLine(); + $this->outputLine('Added dimensions to %s SVG Assets', [$updatedCount]); + } + + private function isSvgImage(Image $image): bool + { + return $image->getResource()->getMediaType() === 'image/svg+xml'; + } + + private function hasMissingDimensions(Image $image): bool + { + return $image->getWidth() === 0 || $image->getHeight() === 0; + } + /** * Used as a callback when iterating large results sets */ diff --git a/Neos.Media/Classes/Domain/Model/Adjustment/CropImageAdjustment.php b/Neos.Media/Classes/Domain/Model/Adjustment/CropImageAdjustment.php index 3de6a9ae207..7a0e561d922 100644 --- a/Neos.Media/Classes/Domain/Model/Adjustment/CropImageAdjustment.php +++ b/Neos.Media/Classes/Domain/Model/Adjustment/CropImageAdjustment.php @@ -13,6 +13,8 @@ * source code. */ +use Contao\ImagineSvg\Image as ContaoSvgImage; +use Contao\ImagineSvg\SvgBox; use Doctrine\ORM\Mapping as ORM; use Imagine\Image\Box; use Imagine\Image\ImageInterface as ImagineImageInterface; @@ -255,14 +257,22 @@ public function applyToImage(ImagineImageInterface $image): ImagineImageInterfac [$newX, $newY, $newWidth, $newHeight] = self::calculateDimensionsByAspectRatio($originalWidth, $originalHeight, $desiredAspectRatio); $point = new Point($newX, $newY); - $box = new Box($newWidth, $newHeight); + $box = $this->createBox($image, $newWidth, $newHeight); } else { $point = new Point($this->x, $this->y); - $box = new Box($this->width, $this->height); + $box = $this->createBox($image, $this->width, $this->height); } return $image->crop($point, $box); } + private function createBox(ImagineImageInterface $image, float $width, float $height): Box|SvgBox + { + if ($image instanceof ContaoSvgImage) { + return new SvgBox((int)round($width), (int)round($height)); + } + return new Box($width, $height); + } + /** * Refits the crop proportions to be the maximum size within the image boundaries. * diff --git a/Neos.Media/Classes/Domain/Service/ImageService.php b/Neos.Media/Classes/Domain/Service/ImageService.php index 585d313a796..f7bb0345495 100644 --- a/Neos.Media/Classes/Domain/Service/ImageService.php +++ b/Neos.Media/Classes/Domain/Service/ImageService.php @@ -11,6 +11,7 @@ * source code. */ +use Contao\ImagineSvg\Imagine as SvgImagine; use Imagine\Image\ImageInterface; use Imagine\Image\ImagineInterface; use Imagine\Image\Palette\CMYK; @@ -21,7 +22,9 @@ use Neos\Flow\ResourceManagement\ResourceManager; use Neos\Flow\Utility\Algorithms; use Neos\Flow\Utility\Environment; +use Neos\Media\Domain\Model\Adjustment\CropImageAdjustment; use Neos\Media\Domain\Model\Adjustment\QualityImageAdjustment; +use Neos\Media\Domain\Model\Adjustment\ResizeImageAdjustment; use Neos\Media\Domain\Repository\AssetRepository; use Neos\Media\Imagine\Box; use Neos\Flow\Annotations as Flow; @@ -103,17 +106,8 @@ public function processImage(PersistentResource $originalResource, array $adjust $additionalOptions = []; $adjustmentsApplied = false; - // TODO: Special handling for SVG should be refactored at a later point. if ($originalResource->getMediaType() === 'image/svg+xml') { - $originalResourceStream = $originalResource->getStream(); - $resource = $this->resourceManager->importResource($originalResourceStream, $originalResource->getCollectionName()); - fclose($originalResourceStream); - $resource->setFilename($originalResource->getFilename()); - return [ - 'width' => null, - 'height' => null, - 'resource' => $resource - ]; + return $this->processSvgImage($originalResource, $adjustments); } $resourceUri = $originalResource->createTemporaryLocalCopy(); @@ -214,6 +208,71 @@ public function processImage(PersistentResource $originalResource, array $adjust return $result; } + /** + * Process SVG images with adjustments + * + * @param PersistentResource $originalResource + * @param array $adjustments + * @return array{width: int|null, height: int|null, resource: PersistentResource} + * @throws Exception + */ + protected function processSvgImage(PersistentResource $originalResource, array $adjustments): array + { + $originalResourceStream = $originalResource->getStream(); + + $nonAdjustedResult = [ + 'width' => null, + 'height' => null, + 'resource' => $originalResource + ]; + + if (is_bool($originalResourceStream)) { + return $nonAdjustedResult; + } + + try { + $svgImage = (new SvgImagine())->read($originalResourceStream); + } catch (\Exception) { + return $nonAdjustedResult; + } + + $svgImage = $this->applySvgAdjustments($svgImage, $adjustments); + $size = $svgImage->getSize(); + + $transformedImageTemporaryPathAndFilename = $this->environment->getPathToTemporaryDirectory() + . 'ProcessedImage-' . Algorithms::generateRandomString(13) . '.svg'; + + $svgImage->save($transformedImageTemporaryPathAndFilename); + $resource = $this->resourceManager->importResource( + $transformedImageTemporaryPathAndFilename, + $originalResource->getCollectionName() + ); + $resource->setFilename($originalResource->getFilename()); + unlink($transformedImageTemporaryPathAndFilename); + + return [ + 'width' => $size->getWidth(), + 'height' => $size->getHeight(), + 'resource' => $resource, + ]; + } + + /** + * Apply supported adjustments to SVG image + * + * @param ImageInterface $svgImage + * @param array $adjustments + * @return ImageInterface + */ + protected function applySvgAdjustments(ImageInterface $svgImage, array $adjustments): ImageInterface + { + foreach ($adjustments as $adjustment) { + $svgImage = $adjustment->applyToImage($svgImage); + } + + return $svgImage; + } + /** * @param array $additionalOptions * @return array @@ -260,9 +319,8 @@ public function getImageSize(PersistentResource $resource) return $imageSize; } - // TODO: Special handling for SVG should be refactored at a later point. if ($resource->getMediaType() === 'image/svg+xml') { - $imageSize = ['width' => null, 'height' => null]; + return $this->getSvgImageSize($resource); } else { try { $imagineImage = $this->imagineService->read($resource->getStream()); @@ -303,6 +361,28 @@ protected function applyAdjustments(ImageInterface $image, array $adjustments, & return $image; } + /** + * Get the size of an SVG image + * + * @param PersistentResource $resource + * @return array{width: int|null, height: int|null} + */ + protected function getSvgImageSize(PersistentResource $resource): array + { + try { + $resourceStream = $resource->getStream(); + if (is_bool($resourceStream)) { + throw new \Exception('the stream of the given resource is not available'); + } + + $svgImage = (new SvgImagine())->read($resourceStream); + $size = $svgImage->getSize(); + return ['width' => $size->getWidth(), 'height' => $size->getHeight()]; + } catch (\Exception) { + return ['width' => null, 'height' => null]; + } + } + /** * Detects whether the given GIF image data contains more than one frame * diff --git a/composer.json b/composer.json index 92553eb65b7..f60dcab03bf 100644 --- a/composer.json +++ b/composer.json @@ -41,6 +41,7 @@ "enshrined/svg-sanitize": "^0.22.0", "neos/imagine": "^3.1.0", "imagine/imagine": "*", + "contao/imagine-svg": "^1.0", "neos/party": "~7.0.3", "neos/fusion-form": "^2.1 || ^3.0", "neos/form": "*",