From d46abcf56450fffe76c59bc7c8cce6cac3252c43 Mon Sep 17 00:00:00 2001 From: Luke Fromhold Date: Sat, 6 Jan 2024 16:53:13 +1100 Subject: [PATCH] Initial commit --- _config/config.yml | 53 +++ client/css/svgenius.css | 3 + composer.json | 32 ++ src/Extensions/SVGFileExtension.php | 18 + .../SVGImageFormFactoryExtension.php | 20 + src/Model/SVGImage.php | 67 ++++ src/SVGeniusTrait.php | 368 ++++++++++++++++++ 7 files changed, 561 insertions(+) create mode 100644 _config/config.yml create mode 100644 client/css/svgenius.css create mode 100644 composer.json create mode 100644 src/Extensions/SVGFileExtension.php create mode 100644 src/Extensions/SVGImageFormFactoryExtension.php create mode 100644 src/Model/SVGImage.php create mode 100644 src/SVGeniusTrait.php diff --git a/_config/config.yml b/_config/config.yml new file mode 100644 index 0000000..48c35cd --- /dev/null +++ b/_config/config.yml @@ -0,0 +1,53 @@ +--- +Name: fromholdio-svgenius +--- + +SilverStripe\Assets\File: + extensions: + - Fromholdio\SVGenius\Extensions\SVGFileExtension + allowed_extensions: + - svg + app_categories: + image: + - svg + image/supported: + - svg + file_types: + svg: 'Scalable Vector Graphic (SVG)' + class_for_file_extension: + svg: 'Fromholdio\SVGenius\Model\SVGImage' + +SilverStripe\Admin\LeftAndMain: + extra_requirements_css: + - 'fromholdio/silverstripe-svgenius: client/css/svgenius.css' + +--- +Name: fromholdio-svgenius-imageformfactory +After: focuspoint +Only: + moduleexists: jonom/focuspoint +--- +SilverStripe\AssetAdmin\Forms\ImageFormFactory: + extensions: + - Fromholdio\SVGenius\Extensions\SVGImageFormFactoryExtension + +--- +Name: fromholdio-svgenius-model +After: '#assetsfieldtypes' +--- +SilverStripe\Assets\Storage\DBFile: + supported_images: + - 'image/svg+xml' + - 'image/svg' + +--- +Name: fromholdio-svgenius-mimevalidator +After: '#mimevalidator' +Only: + moduleexists: silverstripe/mimevalidator +--- +SilverStripe\MimeValidator\MimeUploadValidator: + MimeTypes: + svg: + - 'image/svg+xml' + - 'image/svg' diff --git a/client/css/svgenius.css b/client/css/svgenius.css new file mode 100644 index 0000000..e178c5e --- /dev/null +++ b/client/css/svgenius.css @@ -0,0 +1,3 @@ +.gallery-item__thumbnail[style*=".svg"] { + background-size: contain; +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d9a5bb7 --- /dev/null +++ b/composer.json @@ -0,0 +1,32 @@ +{ + "name": "fromholdio/silverstripe-svgenius", + "type": "silverstripe-vendormodule", + "description": "First-class SVG handling for Silverstripe CMS & front-end", + "license": "BSD-3-Clause", + "keywords": ["silverstripe", "assets", "svg"], + "homepage": "https://github.com/fromholdio/silverstripe-svgenius", + "support": { + "issues": "https://github.com/fromholdio/silverstripe-svgenius/issues" + }, + "authors": [{ + "name": "Luke Fromhold", + "homepage": "https://fromhold.io" + }], + "require": { + "silverstripe/framework": "^5.0.0", + "enshrined/svg-sanitize": "^0.16.0", + "meyfa/php-svg": "^0.14.5" + }, + "autoload": { + "psr-4": { + "Fromholdio\\SVGenius\\": "src/" + } + }, + "extra": { + "expose": [ + "client" + ] + }, + "prefer-stable": true, + "minimum-stability": "dev" +} diff --git a/src/Extensions/SVGFileExtension.php b/src/Extensions/SVGFileExtension.php new file mode 100644 index 0000000..f9daead --- /dev/null +++ b/src/Extensions/SVGFileExtension.php @@ -0,0 +1,18 @@ +getOwner()->getIsSVG(); + } + + public function getIsSVG(): bool + { + return $this->getOwner()->getExtension() === 'svg'; + } +} diff --git a/src/Extensions/SVGImageFormFactoryExtension.php b/src/Extensions/SVGImageFormFactoryExtension.php new file mode 100644 index 0000000..1baf5f6 --- /dev/null +++ b/src/Extensions/SVGImageFormFactoryExtension.php @@ -0,0 +1,20 @@ +getIsSVG()) { + $fields->removeByName([ + 'FocusPoint', + 'FocusPointTab' + ]); + } + } +} diff --git a/src/Model/SVGImage.php b/src/Model/SVGImage.php new file mode 100644 index 0000000..4e11e12 --- /dev/null +++ b/src/Model/SVGImage.php @@ -0,0 +1,67 @@ +File->exists()) { + $this->initSVGFromString($this->File->getString(), false); + } + } + + public function onBeforeWrite(): void + { + $sanitisedString = $this->sanitiseSVGString($this->File->getString()); + $this->File->setFromString( + $sanitisedString, + $this->File->getFilename() + ); + $this->svg = SVGParser::fromString($sanitisedString); + parent::onBeforeWrite(); + } + + + public function getImageTag(): string + { + return parent::getTag(); + } + + public function Thumbnail($width, $height): AssetContainer + { + return $this->resizeByRatio($width, $height); + } + + public function manipulate($variant, $callback): AssetContainer + { + return $this; + } + + public function existingOnly(): AssetContainer + { + return $this->setAllowGeneration(false); + } + + public function Resampled(): AssetContainer + { + return $this; + } + + public function Quality($quality): AssetContainer + { + return $this; + } +} diff --git a/src/SVGeniusTrait.php b/src/SVGeniusTrait.php new file mode 100644 index 0000000..22637b3 --- /dev/null +++ b/src/SVGeniusTrait.php @@ -0,0 +1,368 @@ + 'HTMLText', + 'Tag' => 'HTMLFragment', + 'getTag' => 'HTMLFragment', + 'ImageTag' => 'HTMLFragment', + 'getImageTag' => 'HTMLFragment', + ]; + + private ?SVGParser $svg; + + protected ?int $width = null; + protected ?int $height = null; + protected array $extraCSSClasses = []; + + + protected function initSVGFromPath(string $path, bool $doSanitise = true): void + { + $string = file_exists($path) ? SVGParser::fromFile($path)->toXMLString() : null; + $this->initSVGFromString($string, $doSanitise); + } + + protected function initSVGFromString(?string $string, bool $doSanitise = true): void + { + if (empty($string)) { + $this->svg = null; + } + else { + if ($doSanitise) { + $string = $this->sanitiseSVGString($string); + } + $this->svg = SVGParser::fromString($string); + } + } + + protected function sanitiseSVGString(?string $string): ?string + { + if (empty($string)) return null; + $sanitiser = new Sanitizer(); + return $sanitiser->sanitize($string); + } + + + public function getInline(): ?string + { + if (empty($this->svg)) return null; + + $svg = $this->svg; + $doc = $svg->getDocument(); + $doc->setWidth($this->getWidth()); + $doc->setHeight($this->getHeight()); + $extraCSSClasses = $this->getExtraCSSClasses(); + if (!empty($extraCSSClasses)) { + $doc->setAttribute('class', $extraCSSClasses); + } + $doc->removeAttribute('id'); + +// $token = Tokenator::generate_tokenator(5); +// $title = $this->getField('Title') ?? 'My title'; +// $altText = $this->getField('AltText') ?? 'asdf asdlf asdfklj'; +// $titleNode = new SVGTitle($title); +// $titleNode->setAttribute('id', $token . '-id'); +// $descNode = new SVGDesc(); +// $descNode->setValue($altText); +// $descNode->setAttribute('id', $token . '-desc'); +// $doc->setAttribute('aria-labelledby', $token . '-id ' . $token . '-desc'); +// $doc->setAttribute('role', 'img'); +// $doc->addChild($descNode, 0); +// $doc->addChild($titleNode, 0); + + return $svg->toXMLString(); + } + + public function getTag() + { + if (empty($this->svg)) return null; + return (string) $this->renderWith('DBFile_svg'); + } + + public function getImageTag() + { + if (empty($this->svg)) return null; + return parent::getTag(); + } + + + public function addExtraCSSClass(string $class): self + { + $this->extraCSSClasses[$class] = $class; + return $this; + } + + public function setExtraCSSClasses(string|array $classes): self + { + if (is_string($classes)) { + $classes = explode(' ', $classes); + } + $this->extraCSSClasses = []; + foreach ($classes as $class) { + $this->addExtraCSSClass($class); + } + return $this; + } + + public function removeExtraCSSClass(string $class): self + { + unset($this->extraCSSClasses[$class]); + return $this; + } + + public function getExtraCSSClassesArray(): array + { + return $this->extraCSSClasses; + } + + public function getExtraCSSClasses(): ?string + { + return implode(' ', array_values($this->getExtraCSSClassesArray())); + } + + + public function getDimensions(): array + { + return [ + $this->getWidth(), + $this->getHeight() + ]; + } + + public function getWidth(): int + { + return $this->width ?? (int) $this->svg?->getDocument()->getWidth(); + } + + public function getHeight(): int + { + return $this->height ?? (int) $this->svg?->getDocument()->getHeight(); + } + + + protected function resize($width, $height) + { + $this->width = $width; + $this->height = $height; + return $this; + } + + protected function resizeByWidth($width) + { + list($currWidth, $currHeight) = $this->getDimensions(); + if ($currWidth <= 0 || $width <= 0) { + return $this; + } + $ratio = $width / $currWidth; + $newHeight = $currHeight * $ratio; + return $this->resize($width, $newHeight); + } + + protected function resizeByHeight($height) + { + list($currWidth, $currHeight) = $this->getDimensions(); + if ($currHeight <= 0 || $height <= 0) { + return $this; + } + $ratio = $height / $currHeight; + $newWidth = $currWidth * $ratio; + return $this->resize($newWidth, $height); + } + + protected function resizeRatio($width, $height) + { + list($currWidth, $currHeight) = $this->getDimensions(); + if ($currWidth <= 0 || $width <= 0) { + return $this; + } + if ($currHeight <= 0 || $height <= 0) { + return $this; + } + + $widthRatio = $width / $currWidth; + $heightRatio = $height / $currHeight; + + if ($widthRatio < $heightRatio && $currWidth === $width) { + return $this; + } + elseif ($currHeight === $heightRatio) { + return $this; + } + + $dominantWidth_height = $currHeight * $widthRatio; + if ($dominantWidth_height <= $height) { + return $this->ResizedImage($width, $dominantWidth_height); + } + + $dominantHeight_width = $currWidth * $heightRatio; + if ($dominantHeight_width <= $width) { + return $this->ResizedImage($dominantHeight_width, $height); + } + + return $this; + } + + + public function ResizedImage($width, $height) + { + return $this->resize($width, $height); + } + + /** + * Scale image proportionally to fit within the specified bounds + * + * @param int $width The width to size within + * @param int $height The height to size within + * @return AssetContainer + */ + public function Fit($width, $height) + { + return $this->resizeRatio($width, $height); + } + + /** + * Proportionally scale down this image if it is wider or taller than the specified dimensions. + * Similar to Fit but without up-sampling. Use in templates with $FitMax. + * + * @uses ScalingManipulation::Fit() + * @param int $width The maximum width of the output image + * @param int $height The maximum height of the output image + * @return AssetContainer + */ + public function FitMax($width, $height) + { + return $this->Fit($width, $height); + } + + /** + * Scale image proportionally by width. Use in templates with $ScaleWidth. + * + * @param int $width The width to set + * @return AssetContainer + */ + public function ScaleWidth($width) + { + return $this->resizeByWidth($width); + } + + /** + * Proportionally scale down this image if it is wider than the specified width. + * Similar to ScaleWidth but without up-sampling. Use in templates with $ScaleMaxWidth. + * + * @uses ScalingManipulation::ScaleWidth() + * @param int $width The maximum width of the output image + * @return AssetContainer + */ + public function ScaleMaxWidth($width) + { + return $this->ScaleWidth($width); + } + + /** + * Scale image proportionally by height. Use in templates with $ScaleHeight. + * + * @param int $height The height to set + * @return AssetContainer + */ + public function ScaleHeight($height) + { + return $this->resizeByHeight($height); + } + + /** + * Proportionally scale down this image if it is taller than the specified height. + * Similar to ScaleHeight but without up-sampling. Use in templates with $ScaleMaxHeight. + * + * @uses ScalingManipulation::ScaleHeight() + * @param int $height The maximum height of the output image + * @return AssetContainer + */ + public function ScaleMaxHeight($height) + { + return $this->ScaleHeight($height); + } + + + // Not done; + + /** + * Resize and crop image to fill specified dimensions. + * Use in templates with $Fill + * + * @param int $width Width to crop to + * @param int $height Height to crop to + * @return AssetContainer + */ + public function Fill($width, $height) + { + return $this->Fit($width, $height); + } + + /** + * Crop this image to the aspect ratio defined by the specified width and height, + * then scale down the image to those dimensions if it exceeds them. + * Similar to Fill but without up-sampling. Use in templates with $FillMax. + * + * @uses ImageManipulation::Fill() + * @param int $width The relative (used to determine aspect ratio) and maximum width of the output image + * @param int $height The relative (used to determine aspect ratio) and maximum height of the output image + * @return AssetContainer + */ + public function FillMax($width, $height) + { + return $this->Fill($width, $height); + } + + /** + * Crop image on X axis if it exceeds specified width. Retain height. + * Use in templates with $CropWidth. Example: $Image.ScaleHeight(100).$CropWidth(100) + * + * @uses CropManipulation::Fill() + * @param int $width The maximum width of the output image + * @return AssetContainer + */ + public function CropWidth($width) + { + return $this->ScaleWidth($width); + } + + /** + * Crop image on Y axis if it exceeds specified height. Retain width. + * Use in templates with $CropHeight. Example: $Image.ScaleWidth(100).CropHeight(100) + * + * @uses CropManipulation::Fill() + * @param int $height The maximum height of the output image + * @return AssetContainer + */ + public function CropHeight($height) + { + return $this->ScaleHeight($height); + } + + public function FocusFill(int $width, int $height) + { + return $this->Fill($width, $height); + } + + public function FocusFillMax(int $width, int $height) + { + return $this->FillMax($width, $height); + } + + public function FocusCropWidth(int $width, int $height) + { + return $this->CropWidth($width, $height); + } + + public function FocusCropHeight(int $width, int $height) + { + return $this->CropHeight($width, $height); + } +}