xref: /plugin/dev/SVGIcon.php (revision fe060d0d4c4c42403a614910f1d8cdcd8d360b96)
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