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