'0', "y" => '0', "width" => '100%', "height" => '100%', "preserveAspectRatio" => 'xMidYMid meet', "zoomAndPan" => 'magnify', "version" => '1.1', "baseProfile" => 'none', "contentScriptType" => 'application/ecmascript', "contentStyleType" => 'text/css', ); /** * The namespace of the editors * https://github.com/svg/svgo/blob/master/plugins/_collections.js#L1841 */ public const EDITOR_NAMESPACE = [ 'http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd', 'http://inkscape.sourceforge.net/DTD/sodipodi-0.dtd', 'http://www.inkscape.org/namespaces/inkscape', 'http://www.bohemiancoding.com/sketch/ns', 'http://ns.adobe.com/AdobeIllustrator/10.0/', 'http://ns.adobe.com/Graphs/1.0/', 'http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/', 'http://ns.adobe.com/Variables/1.0/', 'http://ns.adobe.com/SaveForWeb/1.0/', 'http://ns.adobe.com/Extensibility/1.0/', 'http://ns.adobe.com/Flows/1.0/', 'http://ns.adobe.com/ImageReplacement/1.0/', 'http://ns.adobe.com/GenericCustomNamespace/1.0/', 'http://ns.adobe.com/XPath/1.0/', 'http://schemas.microsoft.com/visio/2003/SVGExtensions/', 'http://taptrix.com/vectorillustrator/svg_extensions', 'http://www.figma.com/figma/ns', 'http://purl.org/dc/elements/1.1/', 'http://creativecommons.org/ns#', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', 'http://www.serif.com/', 'http://www.vector.evaxdesign.sk', ]; public const CONF_PRESERVE_ASPECT_RATIO_DEFAULT = "svgPreserveAspectRatioDefault"; public const TILE_TYPE = "tile"; public const CONF_OPTIMIZATION_ELEMENTS_TO_DELETE = "svgOptimizationElementsToDelete"; public const VIEW_BOX = "viewBox"; /** * Optimization Configuration */ public const CONF_OPTIMIZATION_NAMESPACES_TO_KEEP = "svgOptimizationNamespacesToKeep"; public const CONF_SVG_OPTIMIZATION_ENABLE = "svgOptimizationEnable"; public const COLOR_TYPE_STROKE_OUTLINE = FetcherSvg::STROKE_ATTRIBUTE; public const CONF_OPTIMIZATION_ATTRIBUTES_TO_DELETE = "svgOptimizationAttributesToDelete"; public const CONF_OPTIMIZATION_ELEMENTS_TO_DELETE_IF_EMPTY = "svgOptimizationElementsToDeleteIfEmpty"; public const SVG_NAMESPACE_URI = "http://www.w3.org/2000/svg"; public const STROKE_ATTRIBUTE = "stroke"; public const DEFAULT_ICON_LENGTH = 24; public const REQUESTED_NAME_ATTRIBUTE = "name"; public const REQUESTED_PRESERVE_ATTRIBUTE = "preserve"; public const ILLUSTRATION_TYPE = "illustration"; /** * There is only two type of svg icon / tile * * fill color is on the surface (known also as Solid) * * stroke, the color is on the path (known as Outline */ public const COLOR_TYPE_FILL_SOLID = "fill"; /** * Type of svg * * Icon and tile have the same characteristic (ie viewbox = 0 0 A A) and the color can be set) * * An illustration does not have rectangle shape and the color is not set */ public const ICON_TYPE = "icon"; /** * Namespace (used to query with xpath only the svg node) */ public const SVG_NAMESPACE_PREFIX = "svg"; const TAG = "svg"; public const NAME_ATTRIBUTE = "name"; public const DATA_NAME_HTML_ATTRIBUTE = "data-name"; const DEFAULT_TILE_WIDTH = 192; private ?ColorRgb $color = null; private ?string $preserveAspectRatio = null; private ?bool $preserveStyle = null; private ?string $requestedType = null; private bool $processed = false; private ?float $zoomFactor = null; private ?string $requestedClass = null; private int $intrinsicHeight; private int $intrinsicWidth; private string $name; private static function createSvgEmpty(): FetcherSvg { return new FetcherSvg(); } /** */ public static function createSvgFromPath(WikiPath $path): FetcherSvg { $fetcher = self::createSvgEmpty(); $fetcher->setSourcePath($path); return $fetcher; } /** * @throws ExceptionBadArgument */ public static function createSvgFromFetchUrl(Url $fetchUrl): FetcherSvg { $fetchSvg = self::createSvgEmpty(); $fetchSvg->buildFromUrl($fetchUrl); return $fetchSvg; } /** * @param string $markup - the svg as a string * @param string $name - a name identifier (used in diff) * @return FetcherSvg * @throws ExceptionBadSyntax */ public static function createSvgFromMarkup(string $markup, string $name): FetcherSvg { return self::createSvgEmpty()->setMarkup($markup, $name); } /** * @param TagAttributes $tagAttributes * @return FetcherSvg * @throws ExceptionBadArgument * @throws ExceptionBadSyntax * @throws ExceptionCompile */ public static function createFromAttributes(TagAttributes $tagAttributes): FetcherSvg { $fetcher = FetcherSvg::createSvgEmpty(); $fetcher->buildFromTagAttributes($tagAttributes); return $fetcher; } /** * @throws ExceptionNotFound */ public function getRequestedOptimization(): bool { if ($this->requestedOptimization === null) { throw new ExceptionNotFound("Optimization was not set"); } return $this->requestedOptimization; } public function getRequestedOptimizeOrDefault(): bool { try { return $this->getRequestedOptimization(); } catch (ExceptionNotFound $e) { return SiteConfig::getConfValue(FetcherSvg::CONF_SVG_OPTIMIZATION_ENABLE, 1); } } /** * @throws ExceptionNotFound */ public function getRequestedPreserveStyle(): bool { if ($this->preserveStyle === null) { throw new ExceptionNotFound("No preserve style attribute was set"); } return $this->preserveStyle; } /** * @param $boolean * @return FetcherSvg */ public function setRequestedOptimization($boolean): FetcherSvg { $this->requestedOptimization = $boolean; return $this; } /** * Optimization * Based on https://jakearchibald.github.io/svgomg/ * (gui of https://github.com/svg/svgo) * * @throws ExceptionBadSyntax * @throws ExceptionBadState */ public function optimize() { if ($this->getRequestedOptimizeOrDefault()) { /** * Delete Editor namespace * https://github.com/svg/svgo/blob/master/plugins/removeEditorsNSData.js */ $confNamespaceToKeeps = SiteConfig::getConfValue(FetcherSvg::CONF_OPTIMIZATION_NAMESPACES_TO_KEEP); $namespaceToKeep = StringUtility::explodeAndTrim($confNamespaceToKeeps, ","); foreach ($this->getXmlDocument()->getNamespaces() as $namespacePrefix => $namespaceUri) { if ( !empty($namespacePrefix) && $namespacePrefix != "svg" && !in_array($namespacePrefix, $namespaceToKeep) && in_array($namespaceUri, FetcherSvg::EDITOR_NAMESPACE) ) { $this->getXmlDocument()->removeNamespace($namespaceUri); } } /** * Delete empty namespace rules */ $documentElement = $this->getXmlDocument()->getDomDocument()->documentElement; foreach ($this->getXmlDocument()->getNamespaces() as $namespacePrefix => $namespaceUri) { $nodes = $this->getXmlDocument()->xpath("//*[namespace-uri()='$namespaceUri']"); $attributes = $this->getXmlDocument()->xpath("//@*[namespace-uri()='$namespaceUri']"); if ($nodes->length == 0 && $attributes->length == 0) { $result = $documentElement->removeAttributeNS($namespaceUri, $namespacePrefix); if ($result === false) { LogUtility::msg("Internal error: The deletion of the empty namespace ($namespacePrefix:$namespaceUri) didn't succeed", LogUtility::LVL_MSG_WARNING, "support"); } } } /** * Delete comments */ $commentNodes = $this->getXmlDocument()->xpath("//comment()"); foreach ($commentNodes as $commentNode) { $this->getXmlDocument()->removeNode($commentNode); } /** * Delete default value (version=1.1 for instance) */ $defaultValues = FetcherSvg::SVG_DEFAULT_ATTRIBUTES_VALUE; foreach ($documentElement->attributes as $attribute) { /** @var DOMAttr $attribute */ $name = $attribute->name; if (isset($defaultValues[$name])) { if ($defaultValues[$name] == $attribute->value) { $documentElement->removeAttributeNode($attribute); } } } /** * Suppress the attributes (by default id, style and class, data-name) */ $attributeConfToDelete = SiteConfig::getConfValue(FetcherSvg::CONF_OPTIMIZATION_ATTRIBUTES_TO_DELETE, "id, style, class, data-name"); $attributesNameToDelete = StringUtility::explodeAndTrim($attributeConfToDelete, ","); foreach ($attributesNameToDelete as $value) { if (in_array($value, ["style", "class", "id"]) && $this->getRequestedPreserveStyleOrDefault()) { // we preserve the style, we preserve the class continue; } $nodes = $this->getXmlDocument()->xpath("//@$value"); foreach ($nodes as $node) { /** @var DOMAttr $node */ /** @var DOMElement $DOMNode */ $DOMNode = $node->parentNode; $DOMNode->removeAttributeNode($node); } } /** * Remove width/height that coincides with a viewBox attr * https://www.w3.org/TR/SVG11/coords.html#ViewBoxAttribute * Example: * * * */ $widthAttributeValue = $documentElement->getAttribute("width"); if (!empty($widthAttributeValue)) { $widthPixel = Unit::toPixel($widthAttributeValue); $heightAttributeValue = $documentElement->getAttribute("height"); if (!empty($heightAttributeValue)) { $heightPixel = Unit::toPixel($heightAttributeValue); // ViewBox $viewBoxAttribute = $documentElement->getAttribute(FetcherSvg::VIEW_BOX); if (!empty($viewBoxAttribute)) { $viewBoxAttributeAsArray = StringUtility::explodeAndTrim($viewBoxAttribute, " "); if (sizeof($viewBoxAttributeAsArray) == 4) { $minX = $viewBoxAttributeAsArray[0]; $minY = $viewBoxAttributeAsArray[1]; $widthViewPort = $viewBoxAttributeAsArray[2]; $heightViewPort = $viewBoxAttributeAsArray[3]; if ( $minX == 0 & $minY == 0 & $widthViewPort == $widthPixel & $heightViewPort == $heightPixel ) { $documentElement->removeAttribute("width"); $documentElement->removeAttribute("height"); } } } } } /** * Suppress script and style * * * Delete of scripts https://developer.mozilla.org/en-US/docs/Web/SVG/Element/script * * And defs/style * * The style can leak in other icon/svg inlined in the document * * Technically on icon, there should be no `style` * on inline icon otherwise, the css style can leak * * Example with carbon that use cls-1 on all icons * https://github.com/carbon-design-system/carbon/issues/5568 * The facebook icon has a class cls-1 with an opacity of 0 * that leaks to the tumblr icon that has also a cls-1 class * * The illustration uses inline fill to color and styled * For instance, all un-draw: https://undraw.co/illustrations */ $elementsToDeleteConf = SiteConfig::getConfValue(FetcherSvg::CONF_OPTIMIZATION_ELEMENTS_TO_DELETE, "script, style, title, desc"); $elementsToDelete = StringUtility::explodeAndTrim($elementsToDeleteConf, ","); foreach ($elementsToDelete as $elementToDelete) { if ($elementToDelete === "style" && $this->getRequestedPreserveStyleOrDefault()) { continue; } XmlSystems::deleteAllElementsByName($elementToDelete, $this->getXmlDocument()); } // Delete If Empty // * https://developer.mozilla.org/en-US/docs/Web/SVG/Element/defs // * https://developer.mozilla.org/en-US/docs/Web/SVG/Element/metadata $elementsToDeleteIfEmptyConf = SiteConfig::getConfValue(FetcherSvg::CONF_OPTIMIZATION_ELEMENTS_TO_DELETE_IF_EMPTY, "metadata, defs, g"); $elementsToDeleteIfEmpty = StringUtility::explodeAndTrim($elementsToDeleteIfEmptyConf); foreach ($elementsToDeleteIfEmpty as $elementToDeleteIfEmpty) { $elementNodeList = $this->getXmlDocument()->xpath("//*[local-name()='$elementToDeleteIfEmpty']"); foreach ($elementNodeList as $element) { /** @var DOMElement $element */ if (!$element->hasChildNodes()) { $element->parentNode->removeChild($element); } } } /** * Delete the svg prefix namespace definition * At the end to be able to query with svg as prefix */ if (!in_array("svg", $namespaceToKeep)) { $documentElement->removeAttributeNS(FetcherSvg::SVG_NAMESPACE_URI, FetcherSvg::SVG_NAMESPACE_PREFIX); } } } /** * The height of the viewbox * @return int */ public function getIntrinsicHeight(): int { try { $this->buildXmlDocumentIfNeeded(); } catch (ExceptionBadSyntax $e) { throw new ExceptionBadSyntaxRuntime($e->getMessage(), self::CANONICAL, 1, $e); } return $this->intrinsicHeight; } /** * The width of the view box * @return int */ public function getIntrinsicWidth(): int { try { $this->buildXmlDocumentIfNeeded(); } catch (ExceptionBadSyntax $e) { throw new ExceptionBadSyntaxRuntime($e->getMessage(), self::CANONICAL, 1, $e); } return $this->intrinsicWidth; } /** * @return string * @throws ExceptionBadArgument * @throws ExceptionBadState * @throws ExceptionBadSyntax * @throws ExceptionCompile * @throws ExceptionNotFound */ public function processAndGetMarkup(): string { return $this->process()->getMarkup(); } /** * @throws ExceptionBadState - if no svg was set to be processed */ public function getMarkup(): string { return $this->getXmlDocument()->toXml(); } /** * @throws ExceptionBadSyntax * @throws ExceptionNotFound */ public function setSourcePath(WikiPath $path): IFetcherLocalImage { $this->setOriginalPathTraitAlias($path); return $this; } /** * * @return Url - the fetch url * */ public function getFetchUrl(Url $url = null): Url { $url = parent::getFetchUrl($url); /** * Trait */ $this->addLocalPathParametersToFetchUrl($url, self::$MEDIA_QUERY_PARAMETER); /** * Specific properties */ try { $url->addQueryParameter(ColorRgb::COLOR, $this->getRequestedColor()->toCssValue()); } catch (ExceptionNotFound $e) { // no color ok } try { $url->addQueryParameter(self::REQUESTED_PRESERVE_ASPECT_RATIO_KEY, $this->getRequestedPreserveAspectRatio()); } catch (ExceptionNotFound $e) { // no preserve ratio ok } try { $url->addQueryParameter(self::REQUESTED_NAME_ATTRIBUTE, $this->getRequestedName()); } catch (ExceptionNotFound $e) { // no name } try { $url->addQueryParameter(Dimension::ZOOM_ATTRIBUTE, $this->getRequestedZoom()); } catch (ExceptionNotFound $e) { // no name } try { $url->addQueryParameter(TagAttributes::CLASS_KEY, $this->getRequestedClass()); } catch (ExceptionNotFound $e) { // no name } try { $url->addQueryParameter(TagAttributes::TYPE_KEY, $this->getRequestedType()); } catch (ExceptionNotFound $e) { // no name } return $url; } /** * @throws ExceptionNotFound */ public function getRequestedPreserveAspectRatio(): string { if ($this->preserveAspectRatio === null) { throw new ExceptionNotFound("No preserve Aspect Ratio was requested"); } return $this->preserveAspectRatio; } /** * Return the svg file transformed by the attributes * from cache if possible. Used when making a fetch with the URL * @return LocalPath * @throws ExceptionBadArgument * @throws ExceptionBadState * @throws ExceptionBadSyntax - the file is not a svg file * @throws ExceptionCompile * @throws ExceptionNotFound - the file was not found */ public function getFetchPath(): LocalPath { /** * Generated svg file cache init */ $fetchCache = FetcherCache::createFrom($this); $files[] = $this->getSourcePath(); try { $files[] = ClassUtility::getClassPath(FetcherSvg::class); } catch (\ReflectionException $e) { LogUtility::internalError("Unable to add the FetchImageSvg class as dependency. Error: {$e->getMessage()}"); } try { $files[] = ClassUtility::getClassPath(XmlDocument::class); } catch (\ReflectionException $e) { LogUtility::internalError("Unable to add the XmlDocument class as dependency. Error: {$e->getMessage()}"); } $files = array_merge(Site::getConfigurationFiles(), $files); // svg generation depends on configuration foreach ($files as $file) { $fetchCache->addFileDependency($file); } global $ACT; if (PluginUtility::isDev() && $ACT === ExecutionContext::PREVIEW_ACTION) { // in dev mode, don't cache $isCacheUsable = false; } else { $isCacheUsable = $fetchCache->isCacheUsable(); } if (!$isCacheUsable) { $content = self::processAndGetMarkup(); $fetchCache->storeCache($content); } return $fetchCache->getFile(); } /** * The buster is also based on the configuration file * * It the user changes the configuration, the svg file is generated * again and the browser cache should be deleted (ie the buster regenerated) * * {@link ResourceCombo::getBuster()} * @return string * * @throws ExceptionNotFound */ public function getBuster(): string { $buster = FileSystems::getCacheBuster($this->getSourcePath()); try { $configFile = FileSystems::getCacheBuster(DirectoryLayout::getConfLocalFilePath()); $buster = "$buster-$configFile"; } catch (ExceptionNotFound $e) { // no local conf file if (PluginUtility::isDevOrTest()) { LogUtility::internalError("A local configuration file should be present in dev"); } } return $buster; } function acceptsFetchUrl(Url $url): bool { try { $dokuPath = FetcherRawLocalPath::createEmpty()->buildFromUrl($url)->processIfNeededAndGetFetchPath(); } catch (ExceptionBadArgument $e) { return false; } try { $mime = FileSystems::getMime($dokuPath); } catch (ExceptionNotFound $e) { return false; } if ($mime->toString() === Mime::SVG) { return true; } return false; } public function getMime(): Mime { return Mime::create(Mime::SVG); } public function setRequestedColor(ColorRgb $color): FetcherSvg { $this->color = $color; return $this; } /** * @throws ExceptionNotFound */ public function getRequestedColor(): ColorRgb { if ($this->color === null) { throw new ExceptionNotFound("No requested color"); } return $this->color; } /** * @param string $preserveAspectRatio - the aspect ratio of the svg * @return $this */ public function setRequestedPreserveAspectRatio(string $preserveAspectRatio): FetcherSvg { $this->preserveAspectRatio = $preserveAspectRatio; return $this; } /** * @var string|null - a name identifier that is added in the SVG */ private ?string $requestedName = null; /** * @var ?boolean do the svg should be optimized */ private ?bool $requestedOptimization = null; /** * @var XmlDocument|null */ private ?XmlDocument $xmlDocument = null; /** * The name: * * if this is a icon, this is the icon name of the {@link IconDownloader}. It's used to download the icon if not present. * * is used to add a data attribute in the svg to be able to select it for test purpose * * @param string $name * @return FetcherSvg */ public function setRequestedName(string $name): FetcherSvg { $this->requestedName = $name; return $this; } public function __toString() { if (isset($this->name)) { return $this->name; } if (isset($this->path)) { try { return $this->path->getLastNameWithoutExtension(); } catch (ExceptionNotFound $e) { LogUtility::internalError("root not possible, we should have a last name", self::CANONICAL); return "Anonymous"; } } return "Anonymous"; } /** * @param string $viewBox * @return string[] */ private function getViewBoxAttributes(string $viewBox): array { $attributes = explode(" ", $viewBox); if (sizeof($attributes) === 1) { /** * We may find also comma. Example: * viewBox="0,0,433.62,289.08" */ $attributes = explode(",", $viewBox); } return $attributes; } private function getXmlDocument(): XmlDocument { $this->buildXmlDocumentIfNeeded(); return $this->xmlDocument; } /** * Utility function * @return \DOMDocument */ public function getXmlDom(): \DOMDocument { return $this->getXmlDocument()->getDomDocument(); } /** * @throws ExceptionNotFound */ public function getRequestedName(): string { if ($this->requestedName === null) { throw new ExceptionNotFound("Name was not set"); } return $this->requestedName; } public function setPreserveStyle(bool $bool): FetcherSvg { $this->preserveStyle = $bool; return $this; } public function getRequestedPreserveStyleOrDefault(): bool { try { return $this->getRequestedPreserveStyle(); } catch (ExceptionNotFound $e) { return false; } } /** * @throws ExceptionNotFound */ public function getRequestedType(): string { if ($this->requestedType === null) { throw new ExceptionNotFound("The requested type was not specified"); } return $this->requestedType; } /** * @param string $markup - the svg as a string * @param string $name - a name identifier (used in diff) * @throws ExceptionBadSyntax */ private function setMarkup(string $markup, string $name): FetcherSvg { $this->name = $name; $this->buildXmlDocumentIfNeeded($markup); return $this; } public function setRequestedType(string $requestedType): FetcherSvg { $this->requestedType = $requestedType; return $this; } public function getRequestedHeight(): int { try { return $this->getDefaultWidhtAndHeightForIconAndTileIfNotSet(); } catch (ExceptionNotFound $e) { return parent::getRequestedHeight(); } } public function getRequestedWidth(): int { try { return $this->getDefaultWidhtAndHeightForIconAndTileIfNotSet(); } catch (ExceptionNotFound $e) { return parent::getRequestedWidth(); } } /** * @throws ExceptionBadSyntax * @throws ExceptionBadArgument * @throws ExceptionBadState * @throws ExceptionCompile */ public function process(): FetcherSvg { if ($this->processed) { LogUtility::internalError("The svg was already processed"); return $this; } $this->processed = true; // Handy variable $documentElement = $this->getXmlDocument()->getElement(); if ($this->getRequestedOptimizeOrDefault()) { $this->optimize(); } // Set the name (icon) attribute for test selection try { $name = $this->getRequestedNameOrDefault(); $documentElement->setAttribute('data-name', $name); } catch (ExceptionNotFound $e) { // ok no name } // Width requested try { $requestedWidth = $this->getRequestedWidth(); } catch (ExceptionNotFound $e) { $requestedWidth = null; } // Height requested try { $requestedHeight = $this->getRequestedHeight(); } catch (ExceptionNotFound $e) { $requestedHeight = null; } try { $requestedType = $this->getRequestedType(); } catch (ExceptionNotFound $e) { $requestedType = null; } /** * Svg Structure * * All attributes that are applied for all usage (output independent) * and that depends only on the structure of the icon * * Why ? Because {@link \syntax_plugin_combo_pageimage} * can be an icon or an illustrative image * */ $intrinsicWidth = $this->getIntrinsicWidth(); $intrinsicHeight = $this->getIntrinsicHeight(); $svgStructureType = $this->getInternalStructureType(); /** * Svg type * The svg type is the svg usage * How the svg should be shown (the usage) * * We need it to make the difference between an icon * * in a paragraph (the width and height are the same) * * as an illustration in a page image (the width and height may be not the same) */ if ($requestedType === null) { switch ($svgStructureType) { case FetcherSvg::ICON_TYPE: $requestedType = FetcherSvg::ICON_TYPE; break; default: $requestedType = FetcherSvg::ILLUSTRATION_TYPE; break; } } /** * A tag attributes to manage the add of style properties * in the style attribute */ $extraAttributes = TagAttributes::createEmpty(self::TAG); /** * Zoom occurs after the crop/dimenions setting if any */ try { $zoomFactor = $this->getRequestedZoom(); } catch (ExceptionNotFound $e) { if ($svgStructureType === FetcherSvg::ICON_TYPE && $requestedType === FetcherSvg::ILLUSTRATION_TYPE) { $zoomFactor = -4; } else { $zoomFactor = 1; // 0r 1 :) } } /** * Dimension processing (heigth, width, viewbox) * * ViewBox should exist * * Ratio / Width / Height Cropping happens via the viewbox * * Width and height used to set the viewBox of a svg * to crop it (In a raster image, there is not this distinction) * * We set the viewbox everytime: * If width and height are not the same, this is a crop * If width and height are the same, this is not a crop */ $targetWidth = $this->getTargetWidth(); $targetHeight = $this->getTargetHeight(); if ($this->isCropRequested() || $zoomFactor !== 1) { /** * ViewBox is the logical view * * with an icon case, we zoom out for illustation otherwise, this is ugly as the icon takes the whole place * * Zoom applies on the target/cropped dimension * so that we can center all at once in the next step */ /** * The crop happens when we set the height and width on the svg. * There is no need to manipulate the view box coordinate */ /** * Note: if the svg is an icon of width 24 with a viewbox of 0 0 24 24, * if you double the viewbox to 0 0 48 48, you have applied of -2 * The icon is two times smaller smaller */ $viewBoxWidth = $this->getIntrinsicWidth(); $viewBoxHeight = $this->getIntrinsicHeight(); if ($zoomFactor < 0) { $viewBoxWidth = -$zoomFactor * $viewBoxWidth; $viewBoxHeight = -$zoomFactor * $viewBoxHeight; } else { $viewBoxWidth = $viewBoxWidth / $zoomFactor; $viewBoxHeight = $viewBoxHeight / $zoomFactor; } /** * Center * * We center by moving the origin (ie x and y) */ $x = -($viewBoxWidth - $intrinsicWidth) / 2; $y = -($viewBoxHeight - $intrinsicHeight) / 2; $documentElement->setAttribute(FetcherSvg::VIEW_BOX, "$x $y $viewBoxWidth $viewBoxHeight"); } else { $viewBox = $documentElement->getAttribute(FetcherSvg::VIEW_BOX); if (empty($viewBox)) { // viewbox is mandatory $documentElement->setAttribute(FetcherSvg::VIEW_BOX, "0 0 {$this->getIntrinsicWidth()} {$this->getIntrinsicHeight()}"); } } /** * Dimension are mandatory * Why ? * - 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) * - to show the crop * - to have internal calculate dimension otherwise, it's tiny * - To have an internal width and not shrink on the css property `width: auto !important;` of a table * - To have an internal height and not shrink on the css property `height: auto !important;` of a table * - 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 * - ... * */ $documentElement ->setAttribute(Dimension::WIDTH_KEY, $targetWidth) ->setAttribute(Dimension::HEIGHT_KEY, $targetHeight); /** * Css styling due to dimension */ switch ($requestedType) { case FetcherSvg::ICON_TYPE: case FetcherSvg::TILE_TYPE: if ($targetWidth !== $targetHeight) { /** * Check if the widht and height are the same * * Note: this is not the case for an illustration, * they may be different * They are not the width and height of the icon but * the width and height of the viewbox */ 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."); } break; default: /** * Illustration / Image */ /** * Responsive SVG */ try { $aspectRatio = $this->getRequestedPreserveAspectRatio(); } catch (ExceptionNotFound $e) { /** * * Keep the same height * Image in the Middle and border deleted when resizing * https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/preserveAspectRatio * Default is xMidYMid meet */ $aspectRatio = SiteConfig::getConfValue(FetcherSvg::CONF_PRESERVE_ASPECT_RATIO_DEFAULT, "xMidYMid slice"); } $documentElement->setAttribute("preserveAspectRatio", $aspectRatio); /** * Note on dimension width and height * Width and height element attribute are in reality css style properties. * ie the max-width style * They are treated in {@link PluginUtility::processStyle()} */ /** * Adapt to the container by default * Height `auto` and not `100%` otherwise you get a layout shift */ $extraAttributes->addStyleDeclarationIfNotSet("width", "100%"); $extraAttributes->addStyleDeclarationIfNotSet("height", "auto"); if ($requestedWidth !== null) { /** * If a dimension was set, it's seen by default as a max-width * If it should not such as in a card, this property is already set * and is not overwritten */ try { $widthInPixel = ConditionalLength::createFromString($requestedWidth)->toPixelNumber(); } catch (ExceptionCompile $e) { LogUtility::msg("The requested width $requestedWidth could not be converted to pixel. It returns the following error ({$e->getMessage()}). Processing was stopped"); return $this; } $extraAttributes->addStyleDeclarationIfNotSet("max-width", "{$widthInPixel}px"); } if ($requestedHeight !== null) { /** * If a dimension was set, it's seen by default as a max-width * If it should not such as in a card, this property is already set * and is not overwritten */ try { $heightInPixel = ConditionalLength::createFromString($requestedHeight)->toPixelNumber(); } catch (ExceptionCompile $e) { LogUtility::msg("The requested height $requestedHeight could not be converted to pixel. It returns the following error ({$e->getMessage()}). Processing was stopped"); return $this; } $extraAttributes->addStyleDeclarationIfNotSet("max-height", "{$heightInPixel}px"); } break; } switch ($svgStructureType) { case FetcherSvg::ICON_TYPE: case FetcherSvg::TILE_TYPE: /** * Determine if this is a: * * fill one color * * fill two colors * * or stroke svg icon * * The color can be set: * * on fill (surface) * * on stroke (line) * * If the stroke attribute is not present this is a fill icon */ $svgColorType = FetcherSvg::COLOR_TYPE_FILL_SOLID; if ($documentElement->hasAttribute(FetcherSvg::STROKE_ATTRIBUTE)) { $svgColorType = FetcherSvg::COLOR_TYPE_STROKE_OUTLINE; } /** * Double color icon ? */ $isDoubleColor = false; if ($svgColorType === FetcherSvg::COLOR_TYPE_FILL_SOLID) { $svgFillsElement = $this->getXmlDocument()->xpath("//*[@fill]"); $fillColors = []; for ($i = 0; $i < $svgFillsElement->length; $i++) { /** * @var DOMElement $nodeElement */ $nodeElement = $svgFillsElement[$i]; $value = $nodeElement->getAttribute("fill"); if ($value !== "none") { /** * Icon may have none alongside colors * Example: */ $fillColors[$value] = $value; } } if (sizeof($fillColors) > 1) { $isDoubleColor = true; } } /** * CurrentColor * * By default, the icon should have this property when downloaded * but if this not the case (such as for Material design), we set them * * Feather set it on the stroke * Example: view-source:https://raw.githubusercontent.com/feathericons/feather/master/icons/airplay.svg * */ if (!$isDoubleColor && !$documentElement->hasAttribute("fill")) { /** * Note: if fill was not set, the default color would be black */ $documentElement->setAttribute("fill", FetcherSvg::CURRENT_COLOR); } /** * Eva/Carbon Source Icon are not optimized at the source * Example: * * eva:facebook-fill * * carbon:logo-tumblr (https://github.com/carbon-design-system/carbon/issues/5568) * * We delete the rectangle * Style should have already been deleted by the optimization * * This optimization should happen if the color is set * or not because we set the color value to `currentColor` * * If the rectangle stay, we just see a black rectangle */ try { $path = $this->getSourcePath(); $pathString = $path->toAbsolutePath()->toAbsoluteId(); if ( preg_match("/carbon|eva/i", $pathString) === 1 ) { XmlSystems::deleteAllElementsByName("rect", $this->getXmlDocument()); } } catch (ExceptionNotFound $e) { // ok } $color = null; try { $color = $this->getRequestedColor(); } catch (ExceptionNotFound $e) { if ($requestedType === FetcherSvg::ILLUSTRATION_TYPE) { $primaryColor = Site::getPrimaryColorValue(); if ($primaryColor !== null) { $color = ColorRgb::createFromString($primaryColor); } } } /** * Color * Color applies only if this is an icon. * */ if ($color !== null) { /** * * We say that this is used only for an icon (<72 px) * * Not that an icon svg file can also be used as {@link \syntax_plugin_combo_pageimage} * * We don't set it as a styling attribute * because it's not taken into account if the * svg is used as a background image * fill or stroke should have at minimum "currentColor" */ $colorValue = $color->toCssValue(); switch ($svgColorType) { case FetcherSvg::COLOR_TYPE_FILL_SOLID: if (!$isDoubleColor) { $documentElement->setAttribute("fill", $colorValue); if ($colorValue !== FetcherSvg::CURRENT_COLOR) { /** * Update the fill property on sub-path * If the fill is set on sub-path, it will not work * * fill may be set on group or whatever */ $svgPaths = $this->getXmlDocument()->xpath("//*[local-name()='path' or local-name()='g']"); for ($i = 0; $i < $svgPaths->length; $i++) { /** * @var DOMElement $nodeElement */ $nodeElement = $svgPaths[$i]; $value = $nodeElement->getAttribute("fill"); if ($value !== "none") { if ($nodeElement->parentNode->tagName !== "svg") { $nodeElement->setAttribute("fill", FetcherSvg::CURRENT_COLOR); } else { $this->getXmlDocument()->removeAttributeValue("fill", $nodeElement); } } } } } else { // double color $firsFillElement = $this->getXmlDocument()->xpath("//*[@fill][1]")->item(0); if ($firsFillElement instanceof DOMElement) { $firsFillElement->setAttribute("fill", $colorValue); } } break; case FetcherSvg::COLOR_TYPE_STROKE_OUTLINE: $documentElement->setAttribute("fill", "none"); $documentElement->setAttribute(FetcherSvg::STROKE_ATTRIBUTE, $colorValue); if ($colorValue !== FetcherSvg::CURRENT_COLOR) { /** * Delete the stroke property on sub-path */ // if the fill is set on sub-path, it will not work $svgPaths = $this->getXmlDocument()->xpath("//*[local-name()='path']"); for ($i = 0; $i < $svgPaths->length; $i++) { /** * @var DOMElement $nodeElement */ $nodeElement = $svgPaths[$i]; $value = $nodeElement->getAttribute(FetcherSvg::STROKE_ATTRIBUTE); if ($value !== "none") { $this->getXmlDocument()->removeAttributeValue(FetcherSvg::STROKE_ATTRIBUTE, $nodeElement); } else { $this->getXmlDocument()->removeNode($nodeElement); } } } break; } } break; } /** * Set the attributes to the root element * Svg attribute are case sensitive * Styling */ $extraAttributeAsArray = $extraAttributes->toHtmlArray(); foreach ($extraAttributeAsArray as $name => $value) { $documentElement->setAttribute($name, $value); } /** * Class */ try { $class = $this->getRequestedClass(); $documentElement->addClass($class); } catch (ExceptionNotFound $e) { // no class } // add class with svg type $documentElement ->addClass(StyleAttribute::addComboStrapSuffix(self::TAG)) ->addClass(StyleAttribute::addComboStrapSuffix(self::TAG . "-" . $requestedType)); // Add a class on each path for easy styling try { $name = $this->getRequestedNameOrDefault(); $svgPaths = $documentElement->querySelectorAll('path'); for ($i = 0; $i < count($svgPaths); $i++) { $element = $svgPaths[$i]; $stylingClass = $name . "-" . $i; $element->addClass($stylingClass); } } catch (ExceptionNotFound $e) { // no name } return $this; } public function getFetcherName(): string { return self::CANONICAL; } /** * @throws ExceptionBadArgument * @throws ExceptionBadSyntax * @throws ExceptionCompile */ public function buildFromTagAttributes(TagAttributes $tagAttributes): FetcherImage { foreach (array_keys($tagAttributes->getComponentAttributes()) as $svgAttribute) { $svgAttribute = strtolower($svgAttribute); switch ($svgAttribute) { case Dimension::WIDTH_KEY: case Dimension::HEIGHT_KEY: /** * Length may be defined with CSS unit * https://www.w3.org/TR/SVG2/coords.html#Units */ $value = $tagAttributes->getValueAndRemove($svgAttribute); try { $lengthInt = ConditionalLength::createFromString($value)->toPixelNumber(); } catch (ExceptionBadArgument $e) { LogUtility::error("The $svgAttribute value ($value) of the svg ($this) is not an integer", self::CANONICAL); continue 2; } if ($svgAttribute === Dimension::WIDTH_KEY) { $this->setRequestedWidth($lengthInt); } else { $this->setRequestedHeight($lengthInt); } continue 2; case Dimension::ZOOM_ATTRIBUTE; $value = $tagAttributes->getValueAndRemove($svgAttribute); try { $lengthFloat = DataType::toFloat($value); } catch (ExceptionBadArgument $e) { LogUtility::error("The $svgAttribute value ($value) of the svg ($this) is not a float", self::CANONICAL); continue 2; } $this->setRequestedZoom($lengthFloat); continue 2; case ColorRgb::COLOR: $value = $tagAttributes->getValueAndRemove($svgAttribute); try { $color = ColorRgb::createFromString($value); } catch (ExceptionBadArgument $e) { LogUtility::error("The $svgAttribute value ($value) of the svg ($this) is not an valid color", self::CANONICAL); continue 2; } $this->setRequestedColor($color); continue 2; case TagAttributes::TYPE_KEY: $value = $tagAttributes->getValue($svgAttribute); $this->setRequestedType($value); continue 2; case self::REQUESTED_PRESERVE_ATTRIBUTE: $value = $tagAttributes->getValueAndRemove($svgAttribute); if ($value === "style") { $preserve = true; } else { $preserve = false; } $this->setPreserveStyle($preserve); continue 2; case self::NAME_ATTRIBUTE: $value = $tagAttributes->getValueAndRemove($svgAttribute); $this->setRequestedName($value); continue 2; case TagAttributes::CLASS_KEY: $value = $tagAttributes->getValueAndRemove($svgAttribute); $this->setRequestedClass($value); continue 2; case strtolower(self::REQUESTED_PRESERVE_ASPECT_RATIO_KEY): $value = $tagAttributes->getValueAndRemove($svgAttribute); $this->setRequestedPreserveAspectRatio($value); continue 2; } } /** * Icon case */ try { $iconDownload = !$tagAttributes->hasAttribute(FetcherTraitWikiPath::$MEDIA_QUERY_PARAMETER) && $this->getRequestedType() === self::ICON_TYPE && $this->getRequestedName() !== null; if ($iconDownload) { try { $dokuPath = $this->downloadAndGetIconPath(); } catch (ExceptionCompile $e) { 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); } $this->setSourcePath($dokuPath); } } catch (ExceptionNotFound $e) { // no requested type or name } /** * Raw Trait */ $this->buildOriginalPathFromTagAttributes($tagAttributes); parent::buildFromTagAttributes($tagAttributes); return $this; } /** * @throws ExceptionBadArgument * @throws ExceptionCompile * @throws ExceptionBadSyntax * @throws ExceptionNotFound */ private function downloadAndGetIconPath(): WikiPath { /** * It may be a Svg icon that we needs to download */ try { $requestedType = $this->getRequestedType(); $requestedName = $this->getRequestedName(); } catch (ExceptionNotFound $e) { throw new ExceptionNotFound("No path was defined and no icon name was defined"); } if ($requestedType !== self::ICON_TYPE) { throw new ExceptionNotFound("No original path was set and no icon was defined"); } try { $iconDownloader = IconDownloader::createFromName($requestedName); } catch (ExceptionBadArgument $e) { throw new ExceptionNotFound("The name ($requestedName) is not a valid icon name. Error: ({$e->getMessage()}.", self::CANONICAL, 1, $e); } $originalPath = $iconDownloader->getPath(); if (FileSystems::exists($originalPath)) { return $originalPath; } try { $iconDownloader->download(); } catch (ExceptionCompile $e) { throw new ExceptionCompile("The icon ($requestedName) could not be downloaded. Error: ({$e->getMessage()}.", self::CANONICAL); } $this->setSourcePath($originalPath); return $originalPath; } /** * This is used to add a name and class to the svg to make selection more easy * @throws ExceptionBadState * @throws ExceptionNotFound */ private function getRequestedNameOrDefault(): string { try { return $this->getRequestedName(); } catch (ExceptionNotFound $e) { return $this->getSourcePath()->getLastNameWithoutExtension(); } } /** * @return bool - true if no width or height was requested */ private function norWidthNorHeightWasRequested(): bool { if ($this->requestedWidth !== null) { return false; } if ($this->requestedHeight !== null) { return false; } return true; } /** * @throws ExceptionNotFound */ private function getRequestedZoom(): float { $zoom = $this->zoomFactor; if ($zoom === null) { throw new ExceptionNotFound("No zoom requested"); } return $zoom; } public function setRequestedZoom(float $zoomFactor): FetcherSvg { $this->zoomFactor = $zoomFactor; return $this; } public function setRequestedClass(string $value): FetcherSvg { $this->requestedClass = $value; return $this; } /** * @throws ExceptionNotFound */ private function getRequestedClass(): string { if ($this->requestedClass === null) { throw new ExceptionNotFound("No class was set"); } return $this->requestedClass; } /** * Analyse and set the mandatory intrinsic dimensions * @throws ExceptionBadSyntax */ private function setIntrinsicDimensions() { $this->setIntrinsicHeight() ->setIntrinsicWidth(); } /** * @throws ExceptionBadSyntax */ private function setIntrinsicHeight(): FetcherSvg { $viewBox = $this->getXmlDocument()->getDomDocument()->documentElement->getAttribute(FetcherSvg::VIEW_BOX); if ($viewBox !== "") { $attributes = $this->getViewBoxAttributes($viewBox); $viewBoxHeight = $attributes[3]; try { /** * Ceil because we want to see a border if there is one */ $this->intrinsicHeight = DataType::toIntegerCeil($viewBoxHeight); return $this; } catch (ExceptionBadArgument $e) { throw new ExceptionBadSyntax("The media height ($viewBoxHeight) of the svg image ($this) is not a valid integer value"); } } /** * Case with some icon such as * https://raw.githubusercontent.com/fefanto/fontaudio/master/svgs/fad-random-1dice.svg */ $height = $this->getXmlDocument()->getDomDocument()->documentElement->getAttribute("height"); if ($height === "") { throw new ExceptionBadSyntax("The svg ($this) does not have a viewBox or height attribute, the intrinsic height cannot be determined"); } try { $this->intrinsicHeight = DataType::toInteger($height); } catch (ExceptionBadArgument $e) { throw new ExceptionBadSyntax("The media width ($height) of the svg image ($this) is not a valid integer value"); } return $this; } /** * @throws ExceptionBadSyntax */ private function setIntrinsicWidth(): FetcherSvg { $viewBox = $this->getXmlDom()->documentElement->getAttribute(FetcherSvg::VIEW_BOX); if ($viewBox !== "") { $attributes = $this->getViewBoxAttributes($viewBox); $viewBoxWidth = $attributes[2]; try { /** * Ceil because we want to see a border if there is one */ $this->intrinsicWidth = DataType::toIntegerCeil($viewBoxWidth); return $this; } catch (ExceptionCompile $e) { throw new ExceptionBadSyntax("The media with ($viewBoxWidth) of the svg image ($this) is not a valid integer value"); } } /** * Case with some icon such as * https://raw.githubusercontent.com/fefanto/fontaudio/master/svgs/fad-random-1dice.svg */ $width = $this->getXmlDom()->documentElement->getAttribute("width"); if ($width === "") { throw new ExceptionBadSyntax("The svg ($this) does not have a viewBox or width attribute, the intrinsic width cannot be determined"); } try { $this->intrinsicWidth = DataType::toInteger($width); return $this; } catch (ExceptionCompile $e) { throw new ExceptionBadSyntax("The media width ($width) of the svg image ($this) is not a valid integer value"); } } /** * Build is done late because we want to be able to create a fetch url even if the file is not a correct svg * * The downside is that there is an exception that may be triggered all over the place * * * @throws ExceptionBadSyntax */ private function buildXmlDocumentIfNeeded(string $markup = null): FetcherSvg { /** * The svg document may be build * via markup (See {@link self::setMarkup()} */ if ($this->xmlDocument !== null) { return $this; } /** * Markup string passed directly or * via the source path below */ if ($markup !== null) { $this->xmlDocument = XmlDocument::createXmlDocFromMarkup($markup); $localName = $this->xmlDocument->getElement()->getLocalName(); if ($localName !== "svg") { throw new ExceptionBadSyntax("This is not a svg but a $localName element."); } $this->setIntrinsicDimensions(); return $this; } /** * A svg path * * Because we test bad svg, we want to be able to build an url. * We don't want therefore to throw when the svg file is not valid * We therefore check the validity at runtime */ $path = $this->getSourcePath(); try { $markup = FileSystems::getContent($path); } catch (ExceptionNotFound $e) { throw new ExceptionRuntime("The svg file ($path) was not found", self::CANONICAL); } try { $this->buildXmlDocumentIfNeeded($markup); } catch (ExceptionBadSyntax $e) { throw new ExceptionRuntime("The svg file ($path) is not a valid svg. Error: {$e->getMessage()}"); } // dimension return $this; } /** * @return bool - true if the svg is an icon */ public function isIconStructure(): bool { return $this->getInternalStructureType() === self::ICON_TYPE; } /** * @return string - the internal structure of the svg * of {@link self::ICON_TYPE} or {@link self::ILLUSTRATION_TYPE} */ private function getInternalStructureType(): string { $mediaWidth = $this->getIntrinsicWidth(); $mediaHeight = $this->getIntrinsicHeight(); if ( $mediaWidth == $mediaHeight && $mediaWidth < 400) // 356 for logos telegram are the size of the twitter emoji but tile may be bigger ? { return FetcherSvg::ICON_TYPE; } else { $svgStructureType = FetcherSvg::ILLUSTRATION_TYPE; // some icon may be bigger // in size than 400. example 1024 for ant-design:table-outlined // https://github.com/ant-design/ant-design-icons/blob/master/packages/icons-svg/svg/outlined/table.svg // or not squared // if the usage is determined or the svg is in the icon directory, it just takes over. try { $isInIconDirectory = IconDownloader::isInIconDirectory($this->getSourcePath()); } catch (ExceptionNotFound $e) { // not a svg from a path $isInIconDirectory = false; } try { $requestType = $this->getRequestedType(); } catch (ExceptionNotFound $e) { $requestType = false; } if ($requestType === FetcherSvg::ICON_TYPE || $isInIconDirectory) { $svgStructureType = FetcherSvg::ICON_TYPE; } return $svgStructureType; } } /** * * This function returns a consistent requested width and height for icon and tile * * @throws ExceptionNotFound - if not a icon or tile requested */ private function getDefaultWidhtAndHeightForIconAndTileIfNotSet(): int { if (!$this->norWidthNorHeightWasRequested()) { throw new ExceptionNotFound(); } if ($this->isCropRequested()) { /** * With a crop, the internal dimension takes over */ throw new ExceptionNotFound(); } $internalStructure = $this->getInternalStructureType(); switch ($internalStructure) { case FetcherSvg::ICON_TYPE: try { $requestedType = $this->getRequestedType(); } catch (ExceptionNotFound $e) { $requestedType = FetcherSvg::ICON_TYPE; } switch ($requestedType) { case FetcherSvg::TILE_TYPE: return self::DEFAULT_TILE_WIDTH; default: case FetcherSvg::ICON_TYPE: return FetcherSvg::DEFAULT_ICON_LENGTH; } default: throw new ExceptionNotFound(); } } }