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