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