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