1<?php 2 3 4namespace ComboStrap; 5 6use ComboStrap\TagAttribute\StyleAttribute; 7use ComboStrap\Web\Url; 8use ComboStrap\Xml\XmlDocument; 9use ComboStrap\Xml\XmlSystems; 10use DOMAttr; 11use DOMElement; 12use splitbrain\phpcli\Colors; 13 14/** 15 * Class ImageSvg 16 * @package ComboStrap 17 * 18 * Svg image fetch processing that can output: 19 * * an URL for an HTTP request 20 * * an SvgFile for an HTTP response or any further processing 21 * 22 * The original svg can be set with: 23 * * the {@link FetcherSvg::setSourcePath() original path} 24 * * the {@link FetcherSvg::setRequestedName() name} if this is an {@link FetcherSvg::setRequestedType() icon type}, the original path is then determined on {@link FetcherSvg::getSourcePath() get} 25 * * or by {@link FetcherSvg::setMarkup() Svg Markup} 26 * 27 */ 28class FetcherSvg extends IFetcherLocalImage 29{ 30 31 use FetcherTraitWikiPath { 32 setSourcePath as protected setOriginalPathTraitAlias; 33 } 34 35 const EXTENSION = "svg"; 36 const CANONICAL = "svg"; 37 38 const REQUESTED_PRESERVE_ASPECT_RATIO_KEY = "preserveAspectRatio"; 39 public const CURRENT_COLOR = "currentColor"; 40 /** 41 * Default SVG values 42 * https://github.com/svg/svgo/blob/master/plugins/_collections.js#L1579 43 * The key are exact (not lowercase) to be able to look them up 44 * for optimization 45 */ 46 public const SVG_DEFAULT_ATTRIBUTES_VALUE = array( 47 "x" => '0', 48 "y" => '0', 49 "width" => '100%', 50 "height" => '100%', 51 "preserveAspectRatio" => 'xMidYMid meet', 52 "zoomAndPan" => 'magnify', 53 "version" => '1.1', 54 "baseProfile" => 'none', 55 "contentScriptType" => 'application/ecmascript', 56 "contentStyleType" => 'text/css', 57 ); 58 /** 59 * The namespace of the editors 60 * https://github.com/svg/svgo/blob/master/plugins/_collections.js#L1841 61 */ 62 public const EDITOR_NAMESPACE = [ 63 'http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd', 64 'http://inkscape.sourceforge.net/DTD/sodipodi-0.dtd', 65 'http://www.inkscape.org/namespaces/inkscape', 66 'http://www.bohemiancoding.com/sketch/ns', 67 'http://ns.adobe.com/AdobeIllustrator/10.0/', 68 'http://ns.adobe.com/Graphs/1.0/', 69 'http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/', 70 'http://ns.adobe.com/Variables/1.0/', 71 'http://ns.adobe.com/SaveForWeb/1.0/', 72 'http://ns.adobe.com/Extensibility/1.0/', 73 'http://ns.adobe.com/Flows/1.0/', 74 'http://ns.adobe.com/ImageReplacement/1.0/', 75 'http://ns.adobe.com/GenericCustomNamespace/1.0/', 76 'http://ns.adobe.com/XPath/1.0/', 77 'http://schemas.microsoft.com/visio/2003/SVGExtensions/', 78 'http://taptrix.com/vectorillustrator/svg_extensions', 79 'http://www.figma.com/figma/ns', 80 'http://purl.org/dc/elements/1.1/', 81 'http://creativecommons.org/ns#', 82 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', 83 'http://www.serif.com/', 84 'http://www.vector.evaxdesign.sk', 85 ]; 86 public const CONF_PRESERVE_ASPECT_RATIO_DEFAULT = "svgPreserveAspectRatioDefault"; 87 public const TILE_TYPE = "tile"; 88 public const CONF_OPTIMIZATION_ELEMENTS_TO_DELETE = "svgOptimizationElementsToDelete"; 89 public const VIEW_BOX = "viewBox"; 90 /** 91 * Optimization Configuration 92 */ 93 public const CONF_OPTIMIZATION_NAMESPACES_TO_KEEP = "svgOptimizationNamespacesToKeep"; 94 public const CONF_SVG_OPTIMIZATION_ENABLE = "svgOptimizationEnable"; 95 public const COLOR_TYPE_STROKE_OUTLINE = FetcherSvg::STROKE_ATTRIBUTE; 96 public const CONF_OPTIMIZATION_ATTRIBUTES_TO_DELETE = "svgOptimizationAttributesToDelete"; 97 public const CONF_OPTIMIZATION_ELEMENTS_TO_DELETE_IF_EMPTY = "svgOptimizationElementsToDeleteIfEmpty"; 98 public const SVG_NAMESPACE_URI = "http://www.w3.org/2000/svg"; 99 public const STROKE_ATTRIBUTE = "stroke"; 100 public const DEFAULT_ICON_LENGTH = 24; 101 public const REQUESTED_NAME_ATTRIBUTE = "name"; 102 public const REQUESTED_PRESERVE_ATTRIBUTE = "preserve"; 103 public const ILLUSTRATION_TYPE = "illustration"; 104 /** 105 * There is only two type of svg icon / tile 106 * * fill color is on the surface (known also as Solid) 107 * * stroke, the color is on the path (known as Outline 108 */ 109 public const COLOR_TYPE_FILL_SOLID = "fill"; 110 /** 111 * Type of svg 112 * * Icon and tile have the same characteristic (ie viewbox = 0 0 A A) and the color can be set) 113 * * An illustration does not have rectangle shape and the color is not set 114 */ 115 public const ICON_TYPE = "icon"; 116 /** 117 * Namespace (used to query with xpath only the svg node) 118 */ 119 public const SVG_NAMESPACE_PREFIX = "svg"; 120 const TAG = "svg"; 121 public const NAME_ATTRIBUTE = "name"; 122 public const DATA_NAME_HTML_ATTRIBUTE = "data-name"; 123 const DEFAULT_TILE_WIDTH = 192; 124 125 126 private ?ColorRgb $color = null; 127 private ?string $preserveAspectRatio = null; 128 private ?bool $preserveStyle = null; 129 private ?string $requestedType = null; 130 private bool $processed = false; 131 private ?float $zoomFactor = null; 132 private ?string $requestedClass = null; 133 private int $intrinsicHeight; 134 private int $intrinsicWidth; 135 private string $name; 136 137 138 private static function createSvgEmpty(): FetcherSvg 139 { 140 return new FetcherSvg(); 141 } 142 143 /** 144 */ 145 public static function createSvgFromPath(WikiPath $path): FetcherSvg 146 { 147 $fetcher = self::createSvgEmpty(); 148 149 $fetcher->setSourcePath($path); 150 return $fetcher; 151 } 152 153 /** 154 * @throws ExceptionBadArgument 155 */ 156 public static function createSvgFromFetchUrl(Url $fetchUrl): FetcherSvg 157 { 158 $fetchSvg = self::createSvgEmpty(); 159 $fetchSvg->buildFromUrl($fetchUrl); 160 return $fetchSvg; 161 } 162 163 /** 164 * @param string $markup - the svg as a string 165 * @param string $name - a name identifier (used in diff) 166 * @return FetcherSvg 167 * @throws ExceptionBadSyntax 168 */ 169 public static function createSvgFromMarkup(string $markup, string $name): FetcherSvg 170 { 171 return self::createSvgEmpty()->setMarkup($markup, $name); 172 } 173 174 /** 175 * @param TagAttributes $tagAttributes 176 * @return FetcherSvg 177 * @throws ExceptionBadArgument 178 * @throws ExceptionBadSyntax 179 * @throws ExceptionCompile 180 */ 181 public static function createFromAttributes(TagAttributes $tagAttributes): FetcherSvg 182 { 183 $fetcher = FetcherSvg::createSvgEmpty(); 184 $fetcher->buildFromTagAttributes($tagAttributes); 185 return $fetcher; 186 } 187 188 /** 189 * @throws ExceptionNotFound 190 */ 191 public function getRequestedOptimization(): bool 192 { 193 194 if ($this->requestedOptimization === null) { 195 throw new ExceptionNotFound("Optimization was not set"); 196 } 197 return $this->requestedOptimization; 198 199 } 200 201 public function getRequestedOptimizeOrDefault(): bool 202 { 203 try { 204 return $this->getRequestedOptimization(); 205 } catch (ExceptionNotFound $e) { 206 return SiteConfig::getConfValue(FetcherSvg::CONF_SVG_OPTIMIZATION_ENABLE, 1); 207 } 208 209 } 210 211 /** 212 * @throws ExceptionNotFound 213 */ 214 public function getRequestedPreserveStyle(): bool 215 { 216 217 if ($this->preserveStyle === null) { 218 throw new ExceptionNotFound("No preserve style attribute was set"); 219 } 220 return $this->preserveStyle; 221 222 } 223 224 225 /** 226 * @param $boolean 227 * @return FetcherSvg 228 */ 229 public function setRequestedOptimization($boolean): FetcherSvg 230 { 231 $this->requestedOptimization = $boolean; 232 return $this; 233 } 234 235 /** 236 * Optimization 237 * Based on https://jakearchibald.github.io/svgomg/ 238 * (gui of https://github.com/svg/svgo) 239 * 240 * @throws ExceptionBadSyntax 241 * @throws ExceptionBadState 242 */ 243 public 244 function optimize() 245 { 246 247 if ($this->getRequestedOptimizeOrDefault()) { 248 249 /** 250 * Delete Editor namespace 251 * https://github.com/svg/svgo/blob/master/plugins/removeEditorsNSData.js 252 */ 253 $confNamespaceToKeeps = SiteConfig::getConfValue(FetcherSvg::CONF_OPTIMIZATION_NAMESPACES_TO_KEEP); 254 $namespaceToKeep = StringUtility::explodeAndTrim($confNamespaceToKeeps, ","); 255 foreach ($this->getXmlDocument()->getNamespaces() as $namespacePrefix => $namespaceUri) { 256 if ( 257 !empty($namespacePrefix) 258 && $namespacePrefix != "svg" 259 && !in_array($namespacePrefix, $namespaceToKeep) 260 && in_array($namespaceUri, FetcherSvg::EDITOR_NAMESPACE) 261 ) { 262 $this->getXmlDocument()->removeNamespace($namespaceUri); 263 } 264 } 265 266 /** 267 * Delete empty namespace rules 268 */ 269 $documentElement = $this->getXmlDocument()->getDomDocument()->documentElement; 270 foreach ($this->getXmlDocument()->getNamespaces() as $namespacePrefix => $namespaceUri) { 271 $nodes = $this->getXmlDocument()->xpath("//*[namespace-uri()='$namespaceUri']"); 272 $attributes = $this->getXmlDocument()->xpath("//@*[namespace-uri()='$namespaceUri']"); 273 if ($nodes->length == 0 && $attributes->length == 0) { 274 $result = $documentElement->removeAttributeNS($namespaceUri, $namespacePrefix); 275 if ($result === false) { 276 LogUtility::msg("Internal error: The deletion of the empty namespace ($namespacePrefix:$namespaceUri) didn't succeed", LogUtility::LVL_MSG_WARNING, "support"); 277 } 278 } 279 } 280 281 /** 282 * Delete comments 283 */ 284 $commentNodes = $this->getXmlDocument()->xpath("//comment()"); 285 foreach ($commentNodes as $commentNode) { 286 $this->getXmlDocument()->removeNode($commentNode); 287 } 288 289 /** 290 * Delete default value (version=1.1 for instance) 291 */ 292 $defaultValues = FetcherSvg::SVG_DEFAULT_ATTRIBUTES_VALUE; 293 foreach ($documentElement->attributes as $attribute) { 294 /** @var DOMAttr $attribute */ 295 $name = $attribute->name; 296 if (isset($defaultValues[$name])) { 297 if ($defaultValues[$name] == $attribute->value) { 298 $documentElement->removeAttributeNode($attribute); 299 } 300 } 301 } 302 303 /** 304 * Suppress the attributes (by default id, style and class, data-name) 305 */ 306 $attributeConfToDelete = SiteConfig::getConfValue(FetcherSvg::CONF_OPTIMIZATION_ATTRIBUTES_TO_DELETE, "id, style, class, data-name"); 307 $attributesNameToDelete = StringUtility::explodeAndTrim($attributeConfToDelete, ","); 308 foreach ($attributesNameToDelete as $value) { 309 310 if (in_array($value, ["style", "class", "id"]) && $this->getRequestedPreserveStyleOrDefault()) { 311 // we preserve the style, we preserve the class 312 continue; 313 } 314 315 $nodes = $this->getXmlDocument()->xpath("//@$value"); 316 foreach ($nodes as $node) { 317 /** @var DOMAttr $node */ 318 /** @var DOMElement $DOMNode */ 319 $DOMNode = $node->parentNode; 320 $DOMNode->removeAttributeNode($node); 321 } 322 } 323 324 /** 325 * Remove width/height that coincides with a viewBox attr 326 * https://www.w3.org/TR/SVG11/coords.html#ViewBoxAttribute 327 * Example: 328 * <svg width="100" height="50" viewBox="0 0 100 50"> 329 * <svg viewBox="0 0 100 50"> 330 * 331 */ 332 $widthAttributeValue = $documentElement->getAttribute("width"); 333 if (!empty($widthAttributeValue)) { 334 $widthPixel = Unit::toPixel($widthAttributeValue); 335 336 $heightAttributeValue = $documentElement->getAttribute("height"); 337 if (!empty($heightAttributeValue)) { 338 $heightPixel = Unit::toPixel($heightAttributeValue); 339 340 // ViewBox 341 $viewBoxAttribute = $documentElement->getAttribute(FetcherSvg::VIEW_BOX); 342 if (!empty($viewBoxAttribute)) { 343 $viewBoxAttributeAsArray = StringUtility::explodeAndTrim($viewBoxAttribute, " "); 344 345 if (sizeof($viewBoxAttributeAsArray) == 4) { 346 $minX = $viewBoxAttributeAsArray[0]; 347 $minY = $viewBoxAttributeAsArray[1]; 348 $widthViewPort = $viewBoxAttributeAsArray[2]; 349 $heightViewPort = $viewBoxAttributeAsArray[3]; 350 if ( 351 $minX == 0 & 352 $minY == 0 & 353 $widthViewPort == $widthPixel & 354 $heightViewPort == $heightPixel 355 ) { 356 $documentElement->removeAttribute("width"); 357 $documentElement->removeAttribute("height"); 358 } 359 360 } 361 } 362 } 363 } 364 365 366 /** 367 * Suppress script and style 368 * 369 * 370 * Delete of scripts https://developer.mozilla.org/en-US/docs/Web/SVG/Element/script 371 * 372 * And defs/style 373 * 374 * The style can leak in other icon/svg inlined in the document 375 * 376 * Technically on icon, there should be no `style` 377 * on inline icon otherwise, the css style can leak 378 * 379 * Example with carbon that use cls-1 on all icons 380 * https://github.com/carbon-design-system/carbon/issues/5568 381 * The facebook icon has a class cls-1 with an opacity of 0 382 * that leaks to the tumblr icon that has also a cls-1 class 383 * 384 * The illustration uses inline fill to color and styled 385 * For instance, all un-draw: https://undraw.co/illustrations 386 */ 387 $elementsToDeleteConf = SiteConfig::getConfValue(FetcherSvg::CONF_OPTIMIZATION_ELEMENTS_TO_DELETE, "script, style, title, desc"); 388 $elementsToDelete = StringUtility::explodeAndTrim($elementsToDeleteConf, ","); 389 foreach ($elementsToDelete as $elementToDelete) { 390 if ($elementToDelete === "style" && $this->getRequestedPreserveStyleOrDefault()) { 391 continue; 392 } 393 XmlSystems::deleteAllElementsByName($elementToDelete, $this->getXmlDocument()); 394 } 395 396 // Delete If Empty 397 // * https://developer.mozilla.org/en-US/docs/Web/SVG/Element/defs 398 // * https://developer.mozilla.org/en-US/docs/Web/SVG/Element/metadata 399 $elementsToDeleteIfEmptyConf = SiteConfig::getConfValue(FetcherSvg::CONF_OPTIMIZATION_ELEMENTS_TO_DELETE_IF_EMPTY, "metadata, defs, g"); 400 $elementsToDeleteIfEmpty = StringUtility::explodeAndTrim($elementsToDeleteIfEmptyConf); 401 foreach ($elementsToDeleteIfEmpty as $elementToDeleteIfEmpty) { 402 $elementNodeList = $this->getXmlDocument()->xpath("//*[local-name()='$elementToDeleteIfEmpty']"); 403 foreach ($elementNodeList as $element) { 404 /** @var DOMElement $element */ 405 if (!$element->hasChildNodes()) { 406 $element->parentNode->removeChild($element); 407 } 408 } 409 } 410 411 /** 412 * Delete the svg prefix namespace definition 413 * At the end to be able to query with svg as prefix 414 */ 415 if (!in_array("svg", $namespaceToKeep)) { 416 $documentElement->removeAttributeNS(FetcherSvg::SVG_NAMESPACE_URI, FetcherSvg::SVG_NAMESPACE_PREFIX); 417 } 418 419 } 420 } 421 422 423 /** 424 * The height of the viewbox 425 * @return int 426 */ 427 public function getIntrinsicHeight(): int 428 { 429 430 try { 431 $this->buildXmlDocumentIfNeeded(); 432 } catch (ExceptionBadSyntax $e) { 433 throw new ExceptionBadSyntaxRuntime($e->getMessage(), self::CANONICAL, 1, $e); 434 } 435 return $this->intrinsicHeight; 436 437 } 438 439 /** 440 * The width of the view box 441 * @return int 442 */ 443 public 444 function getIntrinsicWidth(): int 445 { 446 try { 447 $this->buildXmlDocumentIfNeeded(); 448 } catch (ExceptionBadSyntax $e) { 449 throw new ExceptionBadSyntaxRuntime($e->getMessage(), self::CANONICAL, 1, $e); 450 } 451 return $this->intrinsicWidth; 452 } 453 454 /** 455 * @return string 456 * @throws ExceptionBadArgument 457 * @throws ExceptionBadState 458 * @throws ExceptionBadSyntax 459 * @throws ExceptionCompile 460 * @throws ExceptionNotFound 461 */ 462 public function processAndGetMarkup(): string 463 { 464 465 return $this->process()->getMarkup(); 466 467 468 } 469 470 471 /** 472 * @throws ExceptionBadState - if no svg was set to be processed 473 */ 474 public function getMarkup(): string 475 { 476 return $this->getXmlDocument()->toXml(); 477 } 478 479 /** 480 * @throws ExceptionBadSyntax 481 * @throws ExceptionNotFound 482 */ 483 public function setSourcePath(WikiPath $path): IFetcherLocalImage 484 { 485 486 $this->setOriginalPathTraitAlias($path); 487 return $this; 488 489 } 490 491 492 /** 493 * 494 * @return Url - the fetch url 495 * 496 */ 497 public function getFetchUrl(Url $url = null): Url 498 { 499 500 $url = parent::getFetchUrl($url); 501 502 /** 503 * Trait 504 */ 505 $this->addLocalPathParametersToFetchUrl($url, MediaMarkup::$MEDIA_QUERY_PARAMETER); 506 507 /** 508 * Specific properties 509 */ 510 try { 511 $url->addQueryParameter(ColorRgb::COLOR, $this->getRequestedColor()->toCssValue()); 512 } catch (ExceptionNotFound $e) { 513 // no color ok 514 } 515 try { 516 $url->addQueryParameter(self::REQUESTED_PRESERVE_ASPECT_RATIO_KEY, $this->getRequestedPreserveAspectRatio()); 517 } catch (ExceptionNotFound $e) { 518 // no preserve ratio ok 519 } 520 try { 521 $url->addQueryParameter(self::REQUESTED_NAME_ATTRIBUTE, $this->getRequestedName()); 522 } catch (ExceptionNotFound $e) { 523 // no name 524 } 525 try { 526 $url->addQueryParameter(Dimension::ZOOM_ATTRIBUTE, $this->getRequestedZoom()); 527 } catch (ExceptionNotFound $e) { 528 // no name 529 } 530 try { 531 $url->addQueryParameter(TagAttributes::CLASS_KEY, $this->getRequestedClass()); 532 } catch (ExceptionNotFound $e) { 533 // no name 534 } 535 try { 536 $url->addQueryParameter(TagAttributes::TYPE_KEY, $this->getRequestedType()); 537 } catch (ExceptionNotFound $e) { 538 // no name 539 } 540 541 return $url; 542 543 } 544 545 /** 546 * @throws ExceptionNotFound 547 */ 548 public function getRequestedPreserveAspectRatio(): string 549 { 550 if ($this->preserveAspectRatio === null) { 551 throw new ExceptionNotFound("No preserve Aspect Ratio was requested"); 552 } 553 return $this->preserveAspectRatio; 554 } 555 556 /** 557 * Return the svg file transformed by the attributes 558 * from cache if possible. Used when making a fetch with the URL 559 * @return LocalPath 560 * @throws ExceptionBadArgument 561 * @throws ExceptionBadState 562 * @throws ExceptionBadSyntax - the file is not a svg file 563 * @throws ExceptionCompile 564 * @throws ExceptionNotFound - the file was not found 565 */ 566 public function getFetchPath(): LocalPath 567 { 568 569 /** 570 * Generated svg file cache init 571 */ 572 $fetchCache = FetcherCache::createFrom($this); 573 $files[] = $this->getSourcePath(); 574 try { 575 $files[] = ClassUtility::getClassPath(FetcherSvg::class); 576 } catch (\ReflectionException $e) { 577 LogUtility::internalError("Unable to add the FetchImageSvg class as dependency. Error: {$e->getMessage()}"); 578 } 579 try { 580 $files[] = ClassUtility::getClassPath(XmlDocument::class); 581 } catch (\ReflectionException $e) { 582 LogUtility::internalError("Unable to add the XmlDocument class as dependency. Error: {$e->getMessage()}"); 583 } 584 $files = array_merge(Site::getConfigurationFiles(), $files); // svg generation depends on configuration 585 foreach ($files as $file) { 586 $fetchCache->addFileDependency($file); 587 } 588 589 global $ACT; 590 if (PluginUtility::isDev() && $ACT === ExecutionContext::PREVIEW_ACTION) { 591 // in dev mode, don't cache 592 $isCacheUsable = false; 593 } else { 594 $isCacheUsable = $fetchCache->isCacheUsable(); 595 } 596 if (!$isCacheUsable) { 597 $content = self::processAndGetMarkup(); 598 $fetchCache->storeCache($content); 599 } 600 return $fetchCache->getFile(); 601 602 } 603 604 /** 605 * The buster is also based on the configuration file 606 * 607 * It the user changes the configuration, the svg file is generated 608 * again and the browser cache should be deleted (ie the buster regenerated) 609 * 610 * {@link ResourceCombo::getBuster()} 611 * @return string 612 * 613 * @throws ExceptionNotFound 614 */ 615 public function getBuster(): string 616 { 617 $buster = FileSystems::getCacheBuster($this->getSourcePath()); 618 try { 619 $configFile = FileSystems::getCacheBuster(DirectoryLayout::getConfLocalFilePath()); 620 $buster = "$buster-$configFile"; 621 } catch (ExceptionNotFound $e) { 622 // no local conf file 623 if (PluginUtility::isDevOrTest()) { 624 LogUtility::internalError("A local configuration file should be present in dev"); 625 } 626 } 627 return $buster; 628 629 } 630 631 632 function acceptsFetchUrl(Url $url): bool 633 { 634 635 try { 636 $dokuPath = FetcherRawLocalPath::createEmpty()->buildFromUrl($url)->processIfNeededAndGetFetchPath(); 637 } catch (ExceptionBadArgument $e) { 638 return false; 639 } 640 try { 641 $mime = FileSystems::getMime($dokuPath); 642 } catch (ExceptionNotFound $e) { 643 return false; 644 } 645 if ($mime->toString() === Mime::SVG) { 646 return true; 647 } 648 return false; 649 650 } 651 652 public function getMime(): Mime 653 { 654 return Mime::create(Mime::SVG); 655 } 656 657 658 public function setRequestedColor(ColorRgb $color): FetcherSvg 659 { 660 $this->color = $color; 661 return $this; 662 } 663 664 /** 665 * @throws ExceptionNotFound 666 */ 667 public function getRequestedColor(): ColorRgb 668 { 669 if ($this->color === null) { 670 throw new ExceptionNotFound("No requested color"); 671 } 672 return $this->color; 673 } 674 675 /** 676 * @param string $preserveAspectRatio - the aspect ratio of the svg 677 * @return $this 678 */ 679 public function setRequestedPreserveAspectRatio(string $preserveAspectRatio): FetcherSvg 680 { 681 $this->preserveAspectRatio = $preserveAspectRatio; 682 return $this; 683 } 684 685 686 /** 687 * @var string|null - a name identifier that is added in the SVG 688 */ 689 private ?string $requestedName = null; 690 691 /** 692 * @var ?boolean do the svg should be optimized 693 */ 694 private ?bool $requestedOptimization = null; 695 696 /** 697 * @var XmlDocument|null 698 */ 699 private ?XmlDocument $xmlDocument = null; 700 701 702 /** 703 * The name: 704 * * if this is a icon, this is the icon name of the {@link IconDownloader}. It's used to download the icon if not present. 705 * * is used to add a data attribute in the svg to be able to select it for test purpose 706 * 707 * @param string $name 708 * @return FetcherSvg 709 */ 710 public 711 function setRequestedName(string $name): FetcherSvg 712 { 713 $this->requestedName = $name; 714 return $this; 715 } 716 717 718 public 719 function __toString() 720 { 721 if (isset($this->name)) { 722 return $this->name; 723 } 724 if (isset($this->path)) { 725 try { 726 return $this->path->getLastNameWithoutExtension(); 727 } catch (ExceptionNotFound $e) { 728 LogUtility::internalError("root not possible, we should have a last name", self::CANONICAL); 729 return "Anonymous"; 730 } 731 } 732 return "Anonymous"; 733 734 735 } 736 737 738 /** 739 * @param string $viewBox 740 * @return string[] 741 */ 742 private function getViewBoxAttributes(string $viewBox): array 743 { 744 $attributes = explode(" ", $viewBox); 745 if (sizeof($attributes) === 1) { 746 /** 747 * We may find also comma. Example: 748 * viewBox="0,0,433.62,289.08" 749 */ 750 $attributes = explode(",", $viewBox); 751 } 752 return $attributes; 753 } 754 755 756 private function getXmlDocument(): XmlDocument 757 { 758 759 $this->buildXmlDocumentIfNeeded(); 760 return $this->xmlDocument; 761 } 762 763 /** 764 * Utility function 765 * @return \DOMDocument 766 */ 767 public function getXmlDom(): \DOMDocument 768 { 769 return $this->getXmlDocument()->getDomDocument(); 770 } 771 772 /** 773 * @throws ExceptionNotFound 774 */ 775 public function getRequestedName(): string 776 { 777 if ($this->requestedName === null) { 778 throw new ExceptionNotFound("Name was not set"); 779 } 780 return $this->requestedName; 781 } 782 783 public function setPreserveStyle(bool $bool): FetcherSvg 784 { 785 $this->preserveStyle = $bool; 786 return $this; 787 } 788 789 public function getRequestedPreserveStyleOrDefault(): bool 790 { 791 try { 792 return $this->getRequestedPreserveStyle(); 793 } catch (ExceptionNotFound $e) { 794 return false; 795 } 796 } 797 798 /** 799 * @throws ExceptionNotFound 800 */ 801 public function getRequestedType(): string 802 { 803 if ($this->requestedType === null) { 804 throw new ExceptionNotFound("The requested type was not specified"); 805 } 806 return $this->requestedType; 807 } 808 809 /** 810 * @param string $markup - the svg as a string 811 * @param string $name - a name identifier (used in diff) 812 * @throws ExceptionBadSyntax 813 */ 814 private function setMarkup(string $markup, string $name): FetcherSvg 815 { 816 $this->name = $name; 817 $this->buildXmlDocumentIfNeeded($markup); 818 return $this; 819 } 820 821 822 public function setRequestedType(string $requestedType): FetcherSvg 823 { 824 $this->requestedType = $requestedType; 825 return $this; 826 } 827 828 public function getRequestedHeight(): int 829 { 830 try { 831 return $this->getDefaultWidhtAndHeightForIconAndTileIfNotSet(); 832 } catch (ExceptionNotFound $e) { 833 return parent::getRequestedHeight(); 834 } 835 } 836 837 public function getRequestedWidth(): int 838 { 839 try { 840 return $this->getDefaultWidhtAndHeightForIconAndTileIfNotSet(); 841 } catch (ExceptionNotFound $e) { 842 return parent::getRequestedWidth(); 843 } 844 } 845 846 /** 847 * @throws ExceptionBadSyntax 848 * @throws ExceptionBadArgument 849 * @throws ExceptionBadState 850 * @throws ExceptionCompile 851 */ 852 public function process(): FetcherSvg 853 { 854 855 if ($this->processed) { 856 LogUtility::internalError("The svg was already processed"); 857 return $this; 858 } 859 860 $this->processed = true; 861 862 // Handy variable 863 $documentElement = $this->getXmlDocument()->getElement(); 864 865 866 if ($this->getRequestedOptimizeOrDefault()) { 867 $this->optimize(); 868 } 869 870 // Set the name (icon) attribute for test selection 871 try { 872 $name = $this->getRequestedNameOrDefault(); 873 $documentElement->setAttribute('data-name', $name); 874 } catch (ExceptionNotFound $e) { 875 // ok no name 876 } 877 878 879 // Width requested 880 try { 881 $requestedWidth = $this->getRequestedWidth(); 882 } catch (ExceptionNotFound $e) { 883 $requestedWidth = null; 884 } 885 886 // Height requested 887 try { 888 $requestedHeight = $this->getRequestedHeight(); 889 } catch (ExceptionNotFound $e) { 890 $requestedHeight = null; 891 } 892 893 894 try { 895 $requestedType = $this->getRequestedType(); 896 } catch (ExceptionNotFound $e) { 897 $requestedType = null; 898 } 899 900 /** 901 * Svg Structure 902 * 903 * All attributes that are applied for all usage (output independent) 904 * and that depends only on the structure of the icon 905 * 906 * Why ? Because {@link \syntax_plugin_combo_pageimage} 907 * can be an icon or an illustrative image 908 * 909 */ 910 $intrinsicWidth = $this->getIntrinsicWidth(); 911 $intrinsicHeight = $this->getIntrinsicHeight(); 912 913 914 $svgStructureType = $this->getInternalStructureType(); 915 916 917 /** 918 * Svg type 919 * The svg type is the svg usage 920 * How the svg should be shown (the usage) 921 * 922 * We need it to make the difference between an icon 923 * * in a paragraph (the width and height are the same) 924 * * as an illustration in a page image (the width and height may be not the same) 925 */ 926 if ($requestedType === null) { 927 switch ($svgStructureType) { 928 case FetcherSvg::ICON_TYPE: 929 $requestedType = FetcherSvg::ICON_TYPE; 930 break; 931 default: 932 $requestedType = FetcherSvg::ILLUSTRATION_TYPE; 933 break; 934 } 935 } 936 937 /** 938 * A tag attributes to manage the add of style properties 939 * in the style attribute 940 */ 941 $extraAttributes = TagAttributes::createEmpty(self::TAG); 942 943 /** 944 * Zoom occurs after the crop/dimenions setting if any 945 */ 946 try { 947 $zoomFactor = $this->getRequestedZoom(); 948 } catch (ExceptionNotFound $e) { 949 if ($svgStructureType === FetcherSvg::ICON_TYPE && $requestedType === FetcherSvg::ILLUSTRATION_TYPE) { 950 $zoomFactor = -4; 951 } else { 952 $zoomFactor = 1; // 0r 1 :) 953 } 954 } 955 956 957 /** 958 * Dimension processing (heigth, width, viewbox) 959 * 960 * ViewBox should exist 961 * 962 * Ratio / Width / Height Cropping happens via the viewbox 963 * 964 * Width and height used to set the viewBox of a svg 965 * to crop it (In a raster image, there is not this distinction) 966 * 967 * We set the viewbox everytime: 968 * If width and height are not the same, this is a crop 969 * If width and height are the same, this is not a crop 970 */ 971 $targetWidth = $this->getTargetWidth(); 972 $targetHeight = $this->getTargetHeight(); 973 if ($this->isCropRequested() || $zoomFactor !== 1) { 974 975 /** 976 * ViewBox is the logical view 977 * 978 * with an icon case, we zoom out for illustation otherwise, this is ugly as the icon takes the whole place 979 * 980 * Zoom applies on the target/cropped dimension 981 * so that we can center all at once in the next step 982 */ 983 984 /** 985 * The crop happens when we set the height and width on the svg. 986 * There is no need to manipulate the view box coordinate 987 */ 988 989 /** 990 * Note: if the svg is an icon of width 24 with a viewbox of 0 0 24 24, 991 * if you double the viewbox to 0 0 48 48, you have applied of -2 992 * The icon is two times smaller smaller 993 */ 994 $viewBoxWidth = $this->getIntrinsicWidth(); 995 $viewBoxHeight = $this->getIntrinsicHeight(); 996 if ($zoomFactor < 0) { 997 $viewBoxWidth = -$zoomFactor * $viewBoxWidth; 998 $viewBoxHeight = -$zoomFactor * $viewBoxHeight; 999 } else { 1000 $viewBoxWidth = $viewBoxWidth / $zoomFactor; 1001 $viewBoxHeight = $viewBoxHeight / $zoomFactor; 1002 } 1003 1004 1005 /** 1006 * Center 1007 * 1008 * We center by moving the origin (ie x and y) 1009 */ 1010 $x = -($viewBoxWidth - $intrinsicWidth) / 2; 1011 $y = -($viewBoxHeight - $intrinsicHeight) / 2; 1012 $documentElement->setAttribute(FetcherSvg::VIEW_BOX, "$x $y $viewBoxWidth $viewBoxHeight"); 1013 1014 } else { 1015 $viewBox = $documentElement->getAttribute(FetcherSvg::VIEW_BOX); 1016 if (empty($viewBox)) { 1017 // viewbox is mandatory 1018 $documentElement->setAttribute(FetcherSvg::VIEW_BOX, "0 0 {$this->getIntrinsicWidth()} {$this->getIntrinsicHeight()}"); 1019 } 1020 } 1021 /** 1022 * Dimension are mandatory 1023 * Why ? 1024 * - to not take the dimension of the parent - Setting the width and height is important, otherwise it takes the dimension of the parent (that are generally a squared) 1025 * - to show the crop 1026 * - to have internal calculate dimension otherwise, it's tiny 1027 * - To have an internal width and not shrink on the css property `width: auto !important;` of a table 1028 * - To have an internal height and not shrink on the css property `height: auto !important;` of a table 1029 * - Using a icon in the navbrand component of bootstrap require the set of width and height otherwise the svg has a calculated width of null and the bar component are below the brand text 1030 * - ... 1031 * */ 1032 $documentElement 1033 ->setAttribute(Dimension::WIDTH_KEY, $targetWidth) 1034 ->setAttribute(Dimension::HEIGHT_KEY, $targetHeight); 1035 1036 /** 1037 * Css styling due to dimension 1038 */ 1039 switch ($requestedType) { 1040 case FetcherSvg::ICON_TYPE: 1041 case FetcherSvg::TILE_TYPE: 1042 1043 if ($targetWidth !== $targetHeight) { 1044 /** 1045 * Check if the widht and height are the same 1046 * 1047 * Note: this is not the case for an illustration, 1048 * they may be different 1049 * They are not the width and height of the icon but 1050 * the width and height of the viewbox 1051 */ 1052 LogUtility::info("An icon or tile is defined as having the same dimension but the svg ($this) has a target width of ($targetWidth) that is different from the target height ($targetHeight). The icon will be cropped."); 1053 } 1054 1055 break; 1056 default: 1057 /** 1058 * Illustration / Image 1059 */ 1060 /** 1061 * Responsive SVG 1062 */ 1063 try { 1064 $aspectRatio = $this->getRequestedPreserveAspectRatio(); 1065 } catch (ExceptionNotFound $e) { 1066 /** 1067 * 1068 * Keep the same height 1069 * Image in the Middle and border deleted when resizing 1070 * https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/preserveAspectRatio 1071 * Default is xMidYMid meet 1072 */ 1073 $aspectRatio = SiteConfig::getConfValue(FetcherSvg::CONF_PRESERVE_ASPECT_RATIO_DEFAULT, "xMidYMid slice"); 1074 } 1075 $documentElement->setAttribute("preserveAspectRatio", $aspectRatio); 1076 1077 /** 1078 * Note on dimension width and height 1079 * Width and height element attribute are in reality css style properties. 1080 * ie the max-width style 1081 * They are treated in {@link PluginUtility::processStyle()} 1082 */ 1083 1084 /** 1085 * Adapt to the container by default 1086 * Height `auto` and not `100%` otherwise you get a layout shift 1087 */ 1088 $extraAttributes->addStyleDeclarationIfNotSet("width", "100%"); 1089 $extraAttributes->addStyleDeclarationIfNotSet("height", "auto"); 1090 1091 1092 if ($requestedWidth !== null) { 1093 1094 /** 1095 * If a dimension was set, it's seen by default as a max-width 1096 * If it should not such as in a card, this property is already set 1097 * and is not overwritten 1098 */ 1099 try { 1100 $widthInPixel = ConditionalLength::createFromString($requestedWidth)->toPixelNumber(); 1101 } catch (ExceptionCompile $e) { 1102 LogUtility::msg("The requested width $requestedWidth could not be converted to pixel. It returns the following error ({$e->getMessage()}). Processing was stopped"); 1103 return $this; 1104 } 1105 $extraAttributes->addStyleDeclarationIfNotSet("max-width", "{$widthInPixel}px"); 1106 1107 } 1108 1109 1110 if ($requestedHeight !== null) { 1111 /** 1112 * If a dimension was set, it's seen by default as a max-width 1113 * If it should not such as in a card, this property is already set 1114 * and is not overwritten 1115 */ 1116 try { 1117 $heightInPixel = ConditionalLength::createFromString($requestedHeight)->toPixelNumber(); 1118 } catch (ExceptionCompile $e) { 1119 LogUtility::msg("The requested height $requestedHeight could not be converted to pixel. It returns the following error ({$e->getMessage()}). Processing was stopped"); 1120 return $this; 1121 } 1122 $extraAttributes->addStyleDeclarationIfNotSet("max-height", "{$heightInPixel}px"); 1123 1124 1125 } 1126 1127 break; 1128 } 1129 1130 1131 switch ($svgStructureType) { 1132 case FetcherSvg::ICON_TYPE: 1133 case FetcherSvg::TILE_TYPE: 1134 /** 1135 * Determine if this is a: 1136 * * fill one color 1137 * * fill two colors 1138 * * or stroke svg icon 1139 * 1140 * The color can be set: 1141 * * on fill (surface) 1142 * * on stroke (line) 1143 * 1144 * If the stroke attribute is not present this is a fill icon 1145 */ 1146 $svgColorType = FetcherSvg::COLOR_TYPE_FILL_SOLID; 1147 if ($documentElement->hasAttribute(FetcherSvg::STROKE_ATTRIBUTE)) { 1148 $svgColorType = FetcherSvg::COLOR_TYPE_STROKE_OUTLINE; 1149 } 1150 /** 1151 * Double color icon ? 1152 */ 1153 $isDoubleColor = false; 1154 if ($svgColorType === FetcherSvg::COLOR_TYPE_FILL_SOLID) { 1155 $svgFillsElement = $this->getXmlDocument()->xpath("//*[@fill]"); 1156 $fillColors = []; 1157 for ($i = 0; $i < $svgFillsElement->length; $i++) { 1158 /** 1159 * @var DOMElement $nodeElement 1160 */ 1161 $nodeElement = $svgFillsElement[$i]; 1162 $value = $nodeElement->getAttribute("fill"); 1163 if ($value !== "none") { 1164 /** 1165 * Icon may have none alongside colors 1166 * Example: 1167 */ 1168 $fillColors[$value] = $value; 1169 } 1170 } 1171 if (sizeof($fillColors) > 1) { 1172 $isDoubleColor = true; 1173 } 1174 } 1175 1176 /** 1177 * CurrentColor 1178 * 1179 * By default, the icon should have this property when downloaded 1180 * but if this not the case (such as for Material design), we set them 1181 * 1182 * Feather set it on the stroke 1183 * Example: view-source:https://raw.githubusercontent.com/feathericons/feather/master/icons/airplay.svg 1184 * <svg 1185 * fill="none" 1186 * stroke="currentColor"> 1187 */ 1188 if (!$isDoubleColor && !$documentElement->hasAttribute("fill")) { 1189 1190 /** 1191 * Note: if fill was not set, the default color would be black 1192 */ 1193 $documentElement->setAttribute("fill", FetcherSvg::CURRENT_COLOR); 1194 1195 } 1196 1197 1198 /** 1199 * Eva/Carbon Source Icon are not optimized at the source 1200 * Example: 1201 * * eva:facebook-fill 1202 * * carbon:logo-tumblr (https://github.com/carbon-design-system/carbon/issues/5568) 1203 * 1204 * We delete the rectangle 1205 * Style should have already been deleted by the optimization 1206 * 1207 * This optimization should happen if the color is set 1208 * or not because we set the color value to `currentColor` 1209 * 1210 * If the rectangle stay, we just see a black rectangle 1211 */ 1212 try { 1213 $path = $this->getSourcePath(); 1214 $pathString = $path->toAbsolutePath()->toAbsoluteId(); 1215 if ( 1216 preg_match("/carbon|eva/i", $pathString) === 1 1217 ) { 1218 XmlSystems::deleteAllElementsByName("rect", $this->getXmlDocument()); 1219 } 1220 } catch (ExceptionNotFound $e) { 1221 // ok 1222 } 1223 1224 1225 $color = null; 1226 try { 1227 $color = $this->getRequestedColor(); 1228 } catch (ExceptionNotFound $e) { 1229 if ($requestedType === FetcherSvg::ILLUSTRATION_TYPE) { 1230 $primaryColor = Site::getPrimaryColorValue(); 1231 if ($primaryColor !== null) { 1232 $color = ColorRgb::createFromString($primaryColor); 1233 } 1234 } 1235 } 1236 1237 1238 /** 1239 * Color 1240 * Color applies only if this is an icon. 1241 * 1242 */ 1243 if ($color !== null) { 1244 /** 1245 * 1246 * We say that this is used only for an icon (<72 px) 1247 * 1248 * Not that an icon svg file can also be used as {@link \syntax_plugin_combo_pageimage} 1249 * 1250 * We don't set it as a styling attribute 1251 * because it's not taken into account if the 1252 * svg is used as a background image 1253 * fill or stroke should have at minimum "currentColor" 1254 */ 1255 $colorValue = $color->toCssValue(); 1256 1257 1258 switch ($svgColorType) { 1259 case FetcherSvg::COLOR_TYPE_FILL_SOLID: 1260 1261 if (!$isDoubleColor) { 1262 1263 $documentElement->setAttribute("fill", $colorValue); 1264 1265 if ($colorValue !== FetcherSvg::CURRENT_COLOR) { 1266 /** 1267 * Update the fill property on sub-path 1268 * If the fill is set on sub-path, it will not work 1269 * 1270 * fill may be set on group or whatever 1271 */ 1272 $svgPaths = $this->getXmlDocument()->xpath("//*[local-name()='path' or local-name()='g']"); 1273 for ($i = 0; $i < $svgPaths->length; $i++) { 1274 /** 1275 * @var DOMElement $nodeElement 1276 */ 1277 $nodeElement = $svgPaths[$i]; 1278 $value = $nodeElement->getAttribute("fill"); 1279 if ($value !== "none") { 1280 if ($nodeElement->parentNode->tagName !== "svg") { 1281 $nodeElement->setAttribute("fill", FetcherSvg::CURRENT_COLOR); 1282 } else { 1283 $this->getXmlDocument()->removeAttributeValue("fill", $nodeElement); 1284 } 1285 } 1286 } 1287 1288 } 1289 } else { 1290 // double color 1291 $firsFillElement = $this->getXmlDocument()->xpath("//*[@fill][1]")->item(0); 1292 if ($firsFillElement instanceof DOMElement) { 1293 $firsFillElement->setAttribute("fill", $colorValue); 1294 } 1295 } 1296 break; 1297 1298 case FetcherSvg::COLOR_TYPE_STROKE_OUTLINE: 1299 $documentElement->setAttribute("fill", "none"); 1300 $documentElement->setAttribute(FetcherSvg::STROKE_ATTRIBUTE, $colorValue); 1301 1302 if ($colorValue !== FetcherSvg::CURRENT_COLOR) { 1303 /** 1304 * Delete the stroke property on sub-path 1305 */ 1306 // if the fill is set on sub-path, it will not work 1307 $svgPaths = $this->getXmlDocument()->xpath("//*[local-name()='path']"); 1308 for ($i = 0; $i < $svgPaths->length; $i++) { 1309 /** 1310 * @var DOMElement $nodeElement 1311 */ 1312 $nodeElement = $svgPaths[$i]; 1313 $value = $nodeElement->getAttribute(FetcherSvg::STROKE_ATTRIBUTE); 1314 if ($value !== "none") { 1315 $this->getXmlDocument()->removeAttributeValue(FetcherSvg::STROKE_ATTRIBUTE, $nodeElement); 1316 } else { 1317 $this->getXmlDocument()->removeNode($nodeElement); 1318 } 1319 } 1320 1321 } 1322 break; 1323 } 1324 1325 } 1326 break; 1327 1328 } 1329 1330 1331 /** 1332 * Set the attributes to the root element 1333 * Svg attribute are case sensitive 1334 * Styling 1335 */ 1336 $extraAttributeAsArray = $extraAttributes->toHtmlArray(); 1337 foreach ($extraAttributeAsArray as $name => $value) { 1338 $documentElement->setAttribute($name, $value); 1339 } 1340 1341 /** 1342 * Class 1343 */ 1344 try { 1345 $class = $this->getRequestedClass(); 1346 $documentElement->addClass($class); 1347 } catch (ExceptionNotFound $e) { 1348 // no class 1349 } 1350 // add class with svg type 1351 $documentElement 1352 ->addClass(StyleAttribute::addComboStrapSuffix(self::TAG)) 1353 ->addClass(StyleAttribute::addComboStrapSuffix(self::TAG . "-" . $requestedType)); 1354 // Add a class on each path for easy styling 1355 try { 1356 $name = $this->getRequestedNameOrDefault(); 1357 $svgPaths = $documentElement->querySelectorAll('path'); 1358 for ($i = 0; 1359 $i < count($svgPaths); 1360 $i++) { 1361 $element = $svgPaths[$i]; 1362 $stylingClass = $name . "-" . $i; 1363 $element->addClass($stylingClass); 1364 } 1365 } catch (ExceptionNotFound $e) { 1366 // no name 1367 } 1368 1369 return $this; 1370 1371 } 1372 1373 1374 public function getFetcherName(): string 1375 { 1376 return self::CANONICAL; 1377 } 1378 1379 /** 1380 * @throws ExceptionBadArgument 1381 * @throws ExceptionBadSyntax 1382 * @throws ExceptionCompile 1383 */ 1384 public function buildFromTagAttributes(TagAttributes $tagAttributes): FetcherImage 1385 { 1386 1387 foreach (array_keys($tagAttributes->getComponentAttributes()) as $svgAttribute) { 1388 $svgAttribute = strtolower($svgAttribute); 1389 switch ($svgAttribute) { 1390 case Dimension::WIDTH_KEY: 1391 case Dimension::HEIGHT_KEY: 1392 /** 1393 * Length may be defined with CSS unit 1394 * https://www.w3.org/TR/SVG2/coords.html#Units 1395 */ 1396 $value = $tagAttributes->getValueAndRemove($svgAttribute); 1397 try { 1398 $lengthInt = ConditionalLength::createFromString($value)->toPixelNumber(); 1399 } catch (ExceptionBadArgument $e) { 1400 LogUtility::error("The $svgAttribute value ($value) of the svg ($this) is not an integer", self::CANONICAL); 1401 continue 2; 1402 } 1403 if ($svgAttribute === Dimension::WIDTH_KEY) { 1404 $this->setRequestedWidth($lengthInt); 1405 } else { 1406 $this->setRequestedHeight($lengthInt); 1407 } 1408 continue 2; 1409 case Dimension::ZOOM_ATTRIBUTE; 1410 $value = $tagAttributes->getValueAndRemove($svgAttribute); 1411 try { 1412 $lengthFloat = DataType::toFloat($value); 1413 } catch (ExceptionBadArgument $e) { 1414 LogUtility::error("The $svgAttribute value ($value) of the svg ($this) is not a float", self::CANONICAL); 1415 continue 2; 1416 } 1417 $this->setRequestedZoom($lengthFloat); 1418 continue 2; 1419 case ColorRgb::COLOR: 1420 $value = $tagAttributes->getValueAndRemove($svgAttribute); 1421 try { 1422 $color = ColorRgb::createFromString($value); 1423 } catch (ExceptionBadArgument $e) { 1424 LogUtility::error("The $svgAttribute value ($value) of the svg ($this) is not an valid color", self::CANONICAL); 1425 continue 2; 1426 } 1427 $this->setRequestedColor($color); 1428 continue 2; 1429 case TagAttributes::TYPE_KEY: 1430 $value = $tagAttributes->getValue($svgAttribute); 1431 $this->setRequestedType($value); 1432 continue 2; 1433 case self::REQUESTED_PRESERVE_ATTRIBUTE: 1434 $value = $tagAttributes->getValueAndRemove($svgAttribute); 1435 if ($value === "style") { 1436 $preserve = true; 1437 } else { 1438 $preserve = false; 1439 } 1440 $this->setPreserveStyle($preserve); 1441 continue 2; 1442 case self::NAME_ATTRIBUTE: 1443 $value = $tagAttributes->getValueAndRemove($svgAttribute); 1444 $this->setRequestedName($value); 1445 continue 2; 1446 case TagAttributes::CLASS_KEY: 1447 $value = $tagAttributes->getValueAndRemove($svgAttribute); 1448 $this->setRequestedClass($value); 1449 continue 2; 1450 case strtolower(self::REQUESTED_PRESERVE_ASPECT_RATIO_KEY): 1451 $value = $tagAttributes->getValueAndRemove($svgAttribute); 1452 $this->setRequestedPreserveAspectRatio($value); 1453 continue 2; 1454 } 1455 1456 } 1457 1458 /** 1459 * Icon case 1460 */ 1461 try { 1462 $iconDownload = 1463 !$tagAttributes->hasAttribute(MediaMarkup::$MEDIA_QUERY_PARAMETER) && 1464 $this->getRequestedType() === self::ICON_TYPE 1465 && $this->getRequestedName() !== null; 1466 if ($iconDownload) { 1467 try { 1468 $dokuPath = $this->downloadAndGetIconPath(); 1469 } catch (ExceptionCompile $e) { 1470 throw new ExceptionBadArgument("We can't get the icon path. Error: {$e->getMessage()}. (ie media or icon name attribute is mandatory).", self::CANONICAL, 1, $e); 1471 } 1472 $this->setSourcePath($dokuPath); 1473 1474 } 1475 } catch (ExceptionNotFound $e) { 1476 // no requested type or name 1477 } 1478 1479 /** 1480 * Raw Trait 1481 */ 1482 $this->buildOriginalPathFromTagAttributes($tagAttributes); 1483 parent::buildFromTagAttributes($tagAttributes); 1484 return $this; 1485 } 1486 1487 /** 1488 * @throws ExceptionBadArgument 1489 * @throws ExceptionCompile 1490 * @throws ExceptionBadSyntax 1491 * @throws ExceptionNotFound 1492 */ 1493 private function downloadAndGetIconPath(): WikiPath 1494 { 1495 /** 1496 * It may be a Svg icon that we needs to download 1497 */ 1498 try { 1499 $requestedType = $this->getRequestedType(); 1500 $requestedName = $this->getRequestedName(); 1501 } catch (ExceptionNotFound $e) { 1502 throw new ExceptionNotFound("No path was defined and no icon name was defined"); 1503 } 1504 if ($requestedType !== self::ICON_TYPE) { 1505 throw new ExceptionNotFound("No original path was set and no icon was defined"); 1506 } 1507 1508 try { 1509 $iconDownloader = IconDownloader::createFromName($requestedName); 1510 } catch (ExceptionBadArgument $e) { 1511 throw new ExceptionNotFound("The name ($requestedName) is not a valid icon name. Error: ({$e->getMessage()}.", self::CANONICAL, 1, $e); 1512 } 1513 $originalPath = $iconDownloader->getPath(); 1514 if (FileSystems::exists($originalPath)) { 1515 return $originalPath; 1516 } 1517 try { 1518 $iconDownloader->download(); 1519 } catch (ExceptionCompile $e) { 1520 throw new ExceptionCompile("The icon ($requestedName) could not be downloaded. Error: ({$e->getMessage()}.", self::CANONICAL); 1521 } 1522 $this->setSourcePath($originalPath); 1523 return $originalPath; 1524 } 1525 1526 /** 1527 * This is used to add a name and class to the svg to make selection more easy 1528 * @throws ExceptionBadState 1529 * @throws ExceptionNotFound 1530 */ 1531 private function getRequestedNameOrDefault(): string 1532 { 1533 try { 1534 return $this->getRequestedName(); 1535 } catch (ExceptionNotFound $e) { 1536 return $this->getSourcePath()->getLastNameWithoutExtension(); 1537 } 1538 } 1539 1540 /** 1541 * @return bool - true if no width or height was requested 1542 */ 1543 private function norWidthNorHeightWasRequested(): bool 1544 { 1545 1546 if ($this->requestedWidth !== null) { 1547 return false; 1548 } 1549 if ($this->requestedHeight !== null) { 1550 return false; 1551 } 1552 return true; 1553 1554 } 1555 1556 /** 1557 * @throws ExceptionNotFound 1558 */ 1559 private function getRequestedZoom(): float 1560 { 1561 $zoom = $this->zoomFactor; 1562 if ($zoom === null) { 1563 throw new ExceptionNotFound("No zoom requested"); 1564 } 1565 return $zoom; 1566 } 1567 1568 public function setRequestedZoom(float $zoomFactor): FetcherSvg 1569 { 1570 $this->zoomFactor = $zoomFactor; 1571 return $this; 1572 } 1573 1574 public function setRequestedClass(string $value): FetcherSvg 1575 { 1576 $this->requestedClass = $value; 1577 return $this; 1578 1579 } 1580 1581 /** 1582 * @throws ExceptionNotFound 1583 */ 1584 private function getRequestedClass(): string 1585 { 1586 if ($this->requestedClass === null) { 1587 throw new ExceptionNotFound("No class was set"); 1588 } 1589 return $this->requestedClass; 1590 } 1591 1592 /** 1593 * Analyse and set the mandatory intrinsic dimensions 1594 * @throws ExceptionBadSyntax 1595 */ 1596 private function setIntrinsicDimensions() 1597 { 1598 $this->setIntrinsicHeight() 1599 ->setIntrinsicWidth(); 1600 } 1601 1602 /** 1603 * @throws ExceptionBadSyntax 1604 */ 1605 private function setIntrinsicHeight(): FetcherSvg 1606 { 1607 $viewBox = $this->getXmlDocument()->getDomDocument()->documentElement->getAttribute(FetcherSvg::VIEW_BOX); 1608 if ($viewBox !== "") { 1609 $attributes = $this->getViewBoxAttributes($viewBox); 1610 $viewBoxHeight = $attributes[3]; 1611 try { 1612 /** 1613 * Ceil because we want to see a border if there is one 1614 */ 1615 $this->intrinsicHeight = DataType::toIntegerCeil($viewBoxHeight); 1616 return $this; 1617 } catch (ExceptionBadArgument $e) { 1618 throw new ExceptionBadSyntax("The media height ($viewBoxHeight) of the svg image ($this) is not a valid integer value"); 1619 } 1620 } 1621 /** 1622 * Case with some icon such as 1623 * https://raw.githubusercontent.com/fefanto/fontaudio/master/svgs/fad-random-1dice.svg 1624 */ 1625 $height = $this->getXmlDocument()->getDomDocument()->documentElement->getAttribute("height"); 1626 if ($height === "") { 1627 throw new ExceptionBadSyntax("The svg ($this) does not have a viewBox or height attribute, the intrinsic height cannot be determined"); 1628 } 1629 try { 1630 $this->intrinsicHeight = DataType::toInteger($height); 1631 } catch (ExceptionBadArgument $e) { 1632 throw new ExceptionBadSyntax("The media width ($height) of the svg image ($this) is not a valid integer value"); 1633 } 1634 return $this; 1635 } 1636 1637 /** 1638 * @throws ExceptionBadSyntax 1639 */ 1640 private function setIntrinsicWidth(): FetcherSvg 1641 { 1642 $viewBox = $this->getXmlDom()->documentElement->getAttribute(FetcherSvg::VIEW_BOX); 1643 if ($viewBox !== "") { 1644 $attributes = $this->getViewBoxAttributes($viewBox); 1645 $viewBoxWidth = $attributes[2]; 1646 try { 1647 /** 1648 * Ceil because we want to see a border if there is one 1649 */ 1650 $this->intrinsicWidth = DataType::toIntegerCeil($viewBoxWidth); 1651 return $this; 1652 } catch (ExceptionCompile $e) { 1653 throw new ExceptionBadSyntax("The media with ($viewBoxWidth) of the svg image ($this) is not a valid integer value"); 1654 } 1655 } 1656 1657 /** 1658 * Case with some icon such as 1659 * https://raw.githubusercontent.com/fefanto/fontaudio/master/svgs/fad-random-1dice.svg 1660 */ 1661 $width = $this->getXmlDom()->documentElement->getAttribute("width"); 1662 if ($width === "") { 1663 throw new ExceptionBadSyntax("The svg ($this) does not have a viewBox or width attribute, the intrinsic width cannot be determined"); 1664 } 1665 try { 1666 $this->intrinsicWidth = DataType::toInteger($width); 1667 return $this; 1668 } catch (ExceptionCompile $e) { 1669 throw new ExceptionBadSyntax("The media width ($width) of the svg image ($this) is not a valid integer value"); 1670 } 1671 } 1672 1673 /** 1674 * Build is done late because we want to be able to create a fetch url even if the file is not a correct svg 1675 * 1676 * The downside is that there is an exception that may be triggered all over the place 1677 * 1678 * 1679 * @throws ExceptionBadSyntax 1680 */ 1681 private function buildXmlDocumentIfNeeded(string $markup = null): FetcherSvg 1682 { 1683 /** 1684 * The svg document may be build 1685 * via markup (See {@link self::setMarkup()} 1686 */ 1687 if ($this->xmlDocument !== null) { 1688 return $this; 1689 } 1690 1691 /** 1692 * Markup string passed directly or 1693 * via the source path below 1694 */ 1695 if ($markup !== null) { 1696 $this->xmlDocument = XmlDocument::createXmlDocFromMarkup($markup); 1697 $localName = $this->xmlDocument->getElement()->getLocalName(); 1698 if ($localName !== "svg") { 1699 throw new ExceptionBadSyntax("This is not a svg but a $localName element."); 1700 } 1701 $this->setIntrinsicDimensions(); 1702 return $this; 1703 } 1704 1705 /** 1706 * A svg path 1707 * 1708 * Because we test bad svg, we want to be able to build an url. 1709 * We don't want therefore to throw when the svg file is not valid 1710 * We therefore check the validity at runtime 1711 */ 1712 $path = $this->getSourcePath(); 1713 try { 1714 $markup = FileSystems::getContent($path); 1715 } catch (ExceptionNotFound $e) { 1716 throw new ExceptionRuntime("The svg file ($path) was not found", self::CANONICAL); 1717 } 1718 try { 1719 $this->buildXmlDocumentIfNeeded($markup); 1720 } catch (ExceptionBadSyntax $e) { 1721 throw new ExceptionRuntime("The svg file ($path) is not a valid svg. Error: {$e->getMessage()}"); 1722 } 1723 1724 // dimension 1725 return $this; 1726 1727 } 1728 1729 /** 1730 * @return bool - true if the svg is an icon 1731 */ 1732 public function isIconStructure(): bool 1733 { 1734 return $this->getInternalStructureType() === self::ICON_TYPE; 1735 } 1736 1737 /** 1738 * @return string - the internal structure of the svg 1739 * of {@link self::ICON_TYPE} or {@link self::ILLUSTRATION_TYPE} 1740 */ 1741 private function getInternalStructureType(): string 1742 { 1743 1744 $mediaWidth = $this->getIntrinsicWidth(); 1745 $mediaHeight = $this->getIntrinsicHeight(); 1746 1747 if ( 1748 $mediaWidth == $mediaHeight 1749 && $mediaWidth < 400) // 356 for logos telegram are the size of the twitter emoji but tile may be bigger ? 1750 { 1751 return FetcherSvg::ICON_TYPE; 1752 } else { 1753 $svgStructureType = FetcherSvg::ILLUSTRATION_TYPE; 1754 1755 // some icon may be bigger 1756 // in size than 400. example 1024 for ant-design:table-outlined 1757 // https://github.com/ant-design/ant-design-icons/blob/master/packages/icons-svg/svg/outlined/table.svg 1758 // or not squared 1759 // if the usage is determined or the svg is in the icon directory, it just takes over. 1760 try { 1761 $isInIconDirectory = IconDownloader::isInIconDirectory($this->getSourcePath()); 1762 } catch (ExceptionNotFound $e) { 1763 // not a svg from a path 1764 $isInIconDirectory = false; 1765 } 1766 try { 1767 $requestType = $this->getRequestedType(); 1768 } catch (ExceptionNotFound $e) { 1769 $requestType = false; 1770 } 1771 1772 if ($requestType === FetcherSvg::ICON_TYPE || $isInIconDirectory) { 1773 $svgStructureType = FetcherSvg::ICON_TYPE; 1774 } 1775 1776 return $svgStructureType; 1777 1778 } 1779 } 1780 1781 /** 1782 * 1783 * This function returns a consistent requested width and height for icon and tile 1784 * 1785 * @throws ExceptionNotFound - if not a icon or tile requested 1786 */ 1787 private function getDefaultWidhtAndHeightForIconAndTileIfNotSet(): int 1788 { 1789 1790 if (!$this->norWidthNorHeightWasRequested()) { 1791 throw new ExceptionNotFound(); 1792 } 1793 1794 if ($this->isCropRequested()) { 1795 /** 1796 * With a crop, the internal dimension takes over 1797 */ 1798 throw new ExceptionNotFound(); 1799 } 1800 1801 $internalStructure = $this->getInternalStructureType(); 1802 switch ($internalStructure) { 1803 case FetcherSvg::ICON_TYPE: 1804 try { 1805 $requestedType = $this->getRequestedType(); 1806 } catch (ExceptionNotFound $e) { 1807 $requestedType = FetcherSvg::ICON_TYPE; 1808 } 1809 switch ($requestedType) { 1810 case FetcherSvg::TILE_TYPE: 1811 return self::DEFAULT_TILE_WIDTH; 1812 default: 1813 case FetcherSvg::ICON_TYPE: 1814 return FetcherSvg::DEFAULT_ICON_LENGTH; 1815 } 1816 default: 1817 throw new ExceptionNotFound(); 1818 } 1819 1820 1821 } 1822 1823 1824} 1825