1<?php 2 3namespace dokuwiki\plugin\dev; 4 5use dokuwiki\HTTP\DokuHTTPClient; 6use splitbrain\phpcli\CLI; 7 8/** 9 * Download and clean SVG icons 10 */ 11class SVGIcon 12{ 13 public const SOURCES = [ 14 'mdi' => "https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/%s.svg", 15 'fab' => "https://raw.githubusercontent.com/FortAwesome/Font-Awesome/master/svgs/brands/%s.svg", 16 'fas' => "https://raw.githubusercontent.com/FortAwesome/Font-Awesome/master/svgs/solid/%s.svg", 17 'fa' => "https://raw.githubusercontent.com/FortAwesome/Font-Awesome/master/svgs/regular/%s.svg", 18 'twbs' => "https://raw.githubusercontent.com/twbs/icons/main/icons/%s.svg", 19 ]; 20 21 /** @var CLI for logging */ 22 protected $logger; 23 24 /** @var bool keep the SVG namespace for when the image is not used in embed? */ 25 protected $keepns = false; 26 27 /** 28 * @throws \Exception 29 */ 30 public function __construct(CLI $logger) 31 { 32 $this->logger = $logger; 33 } 34 35 /** 36 * Call before cleaning to keep the SVG namespace 37 * 38 * @param bool $keep 39 */ 40 public function keepNamespace($keep = true) 41 { 42 $this->keepns = $keep; 43 } 44 45 /** 46 * Download and save a remote icon 47 * 48 * @param string $ident prefixed name of the icon 49 * @param string $save 50 * @return bool 51 * @throws \Exception 52 */ 53 public function downloadRemoteIcon($ident, $save = '') 54 { 55 $icon = $this->remoteIcon($ident); 56 $svgdata = $this->fetchSVG($icon['url']); 57 $svgdata = $this->cleanSVG($svgdata); 58 59 if (!$save) { 60 $save = $icon['name'] . '.svg'; 61 } 62 63 io_makeFileDir($save); 64 $ok = io_saveFile($save, $svgdata); 65 if ($ok) $this->logger->success('saved ' . $save); 66 return $ok; 67 } 68 69 /** 70 * Clean an existing SVG file 71 * 72 * @param string $file 73 * @return bool 74 * @throws \Exception 75 */ 76 public function cleanSVGFile($file) 77 { 78 $svgdata = io_readFile($file, false); 79 if (!$svgdata) { 80 throw new \Exception('Failed to read ' . $file); 81 } 82 83 $svgdata = $this->cleanSVG($svgdata); 84 $ok = io_saveFile($file, $svgdata); 85 if ($ok) $this->logger->success('saved ' . $file); 86 return $ok; 87 } 88 89 /** 90 * Get info about an icon from a known remote repository 91 * 92 * @param string $ident prefixed name of the icon 93 * @return array 94 * @throws \Exception 95 */ 96 public function remoteIcon($ident) 97 { 98 if (strpos($ident, ':')) { 99 [$prefix, $name] = explode(':', $ident); 100 } else { 101 $prefix = 'mdi'; 102 $name = $ident; 103 } 104 if (!isset(self::SOURCES[$prefix])) { 105 throw new \Exception("Unknown prefix $prefix"); 106 } 107 108 $url = sprintf(self::SOURCES[$prefix], $name); 109 110 return [ 111 'prefix' => $prefix, 112 'name' => $name, 113 'url' => $url, 114 ]; 115 } 116 117 /** 118 * Minify SVG 119 * 120 * @param string $svgdata 121 * @return string 122 */ 123 protected function cleanSVG($svgdata) 124 { 125 $old = strlen($svgdata); 126 127 // strip namespace declarations FIXME is there a cleaner way? 128 $svgdata = preg_replace('/\sxmlns(:.*?)?="(.*?)"/', '', $svgdata); 129 130 $dom = new \DOMDocument(); 131 $dom->loadXML($svgdata, LIBXML_NOBLANKS); 132 $dom->formatOutput = false; 133 $dom->preserveWhiteSpace = false; 134 135 $svg = $dom->getElementsByTagName('svg')->item(0); 136 137 // prefer viewbox over width/height 138 if (!$svg->hasAttribute('viewBox')) { 139 $w = $svg->getAttribute('width'); 140 $h = $svg->getAttribute('height'); 141 if ($w && $h) { 142 $svg->setAttribute('viewBox', "0 0 $w $h"); 143 } 144 } 145 146 // remove unwanted attributes from root 147 $this->removeAttributes($svg, ['viewBox']); 148 149 // remove unwanted attributes from primitives 150 foreach ($dom->getElementsByTagName('path') as $elem) { 151 $this->removeAttributes($elem, ['d']); 152 } 153 foreach ($dom->getElementsByTagName('rect') as $elem) { 154 $this->removeAttributes($elem, ['x', 'y', 'rx', 'ry']); 155 } 156 foreach ($dom->getElementsByTagName('circle') as $elem) { 157 $this->removeAttributes($elem, ['cx', 'cy', 'r']); 158 } 159 foreach ($dom->getElementsByTagName('ellipse') as $elem) { 160 $this->removeAttributes($elem, ['cx', 'cy', 'rx', 'ry']); 161 } 162 foreach ($dom->getElementsByTagName('line') as $elem) { 163 $this->removeAttributes($elem, ['x1', 'x2', 'y1', 'y2']); 164 } 165 foreach ($dom->getElementsByTagName('polyline') as $elem) { 166 $this->removeAttributes($elem, ['points']); 167 } 168 foreach ($dom->getElementsByTagName('polygon') as $elem) { 169 $this->removeAttributes($elem, ['points']); 170 } 171 172 // remove comments see https://stackoverflow.com/a/60420210 173 $xpath = new \DOMXPath($dom); 174 for ($els = $xpath->query('//comment()'), $i = $els->length - 1; $i >= 0; $i--) { 175 $els->item($i)->parentNode->removeChild($els->item($i)); 176 } 177 178 // readd namespace if not meant for embedding 179 if ($this->keepns) { 180 $svg->setAttribute('xmlns', 'http://www.w3.org/2000/svg'); 181 } 182 183 $svgdata = $dom->saveXML($svg); 184 $new = strlen($svgdata); 185 186 $this->logger->info(sprintf('Minified SVG %d bytes -> %d bytes (%.2f%%)', $old, $new, $new * 100 / $old)); 187 if ($new > 2048) { 188 $this->logger->warning('%d bytes is still too big for standard inlineSVG() limit!'); 189 } 190 return $svgdata; 191 } 192 193 /** 194 * Remove all attributes except the given keepers 195 * 196 * @param \DOMNode $element 197 * @param string[] $keep 198 */ 199 protected function removeAttributes($element, $keep) 200 { 201 $attributes = $element->attributes; 202 for ($i = $attributes->length - 1; $i >= 0; $i--) { 203 $name = $attributes->item($i)->name; 204 if (in_array($name, $keep)) continue; 205 $element->removeAttribute($name); 206 } 207 } 208 209 /** 210 * Fetch the content from the given URL 211 * 212 * @param string $url 213 * @return string 214 * @throws \Exception 215 */ 216 protected function fetchSVG($url) 217 { 218 $http = new DokuHTTPClient(); 219 $svg = $http->get($url); 220 221 if (!$svg) { 222 throw new \Exception("Failed to download $url: " . $http->status . ' ' . $http->error); 223 } 224 225 return $svg; 226 } 227} 228