1<?php
2
3namespace dokuwiki\plugin\icon;
4
5use dokuwiki\HTTP\HTTPClient;
6use dokuwiki\StyleUtils;
7
8class SVG
9{
10    const SOURCES = [
11        'mdi' => "https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/%s.svg",
12        'fab' => "https://raw.githubusercontent.com/FortAwesome/Font-Awesome/master/svgs/brands/%s.svg",
13        'fas' => "https://raw.githubusercontent.com/FortAwesome/Font-Awesome/master/svgs/solid/%s.svg",
14        'fa' => "https://raw.githubusercontent.com/FortAwesome/Font-Awesome/master/svgs/regular/%s.svg",
15        'twbs' => "https://raw.githubusercontent.com/twbs/icons/main/icons/%s.svg",
16    ];
17
18    protected $file;
19    protected $svg;
20
21    protected $url = '';
22    protected $color = 'currentColor';
23    protected $width = 'auto';
24    protected $height = '1.2em';
25
26    public function __construct($source, $icon)
27    {
28        if (!isset(self::SOURCES[$source])) throw new \RuntimeException('Unknown icon source');
29        $this->url = sprintf(self::SOURCES[$source], $icon);
30    }
31
32    public function getFile()
33    {
34        $cache = getCacheName(
35            join("\n", [$this->url, $this->color, $this->width, $this->height]),
36            '.icon.svg'
37        );
38
39        if (!file_exists($cache) || filemtime($cache) < filemtime(__FILE__)) {
40            $http = new HTTPClient();
41            $svg = $http->get($this->url);
42            if (!$svg) throw new \RuntimeException('Failed to download SVG: ' . $http->error);
43            $svg = $this->processSVG($svg);
44            io_saveFile($cache, $svg);
45        }
46
47        return $cache;
48    }
49
50    /**
51     * Set the intrinsic width
52     *
53     * @param string $width
54     * @return void
55     */
56    public function setWidth($width)
57    {
58        // these are fine
59        if ($width == 'auto' || $width == '') {
60            $this->width = $width;
61            return;
62        }
63
64        // we accept numbers and units only
65        if (preg_match('/^\d*\.?\d+(px|em|ex|pt|in|pc|mm|cm|rem|vh|vw)?$/', $width)) {
66            $this->width = $width;
67            return;
68        }
69
70        $this->width = 'auto'; // fall back to default
71    }
72
73    /**
74     * Set the intrinsic height
75     *
76     * @param string $height
77     * @return void
78     */
79    public function setHeight($height)
80    {
81        // these are fine
82        if ($height == 'auto' || $height == '') {
83            $this->height = $height;
84            return;
85        }
86
87        // we accept numbers and units only
88        if (preg_match('/^\d*\.?\d+(px|em|ex|pt|in|pc|mm|cm|rem|vh|vw)?$/', $height)) {
89            $this->height = $height;
90            return;
91        }
92
93        $this->height = '1.3em'; // fall back to default
94    }
95
96    /**
97     * Set the fill color to use
98     *
99     * Can be a hex color or a style.ini replacement
100     *
101     * @param string $color
102     * @return void
103     */
104    public function setColor($color)
105    {
106        // these are fine
107        if ($color == 'currentColor' || $color == '') {
108            $this->color = $color;
109            return;
110        }
111
112        // hex colors are easy too
113        if (self::isHexColor($color)) {
114            $this->color = '#' . ltrim($color, '#');
115            return;
116        }
117        // see if the given color is an ini replacement
118        $styleUtil = new StyleUtils();
119        $ini = $styleUtil->cssStyleini();
120        if (isset($ini['replacements'][$color]) && self::isHexColor($ini['replacements'][$color])) {
121            $this->color = '#' . ltrim($ini['replacements'][$color], '#');
122            return;
123        }
124        if (isset($ini['replacements']["__{$color}__"]) && self::isHexColor($ini['replacements']["__{$color}__"])) {
125            $this->color = '#' . ltrim($ini['replacements']["__{$color}__"], '#');
126            return;
127        }
128
129        $this->color = 'currentColor';
130    }
131
132    /**
133     * Check if the given value is a hex color
134     *
135     * @link https://stackoverflow.com/a/53330328
136     * @param string $color
137     * @return bool
138     */
139    public static function isHexColor($color)
140    {
141        return (bool)preg_match('/^#?(?:(?:[\da-f]{3}){1,2}|(?:[\da-f]{4}){1,2})$/i', $color);
142    }
143
144    /**
145     * Minify SVG and apply transofrmations
146     *
147     * @param string $svgdata
148     * @return string
149     */
150    protected function processSVG($svgdata)
151    {
152        // strip namespace declarations FIXME is there a cleaner way?
153        $svgdata = preg_replace('/\sxmlns(:.*?)?="(.*?)"/', '', $svgdata);
154
155        $dom = new \DOMDocument();
156        $dom->loadXML($svgdata, LIBXML_NOBLANKS);
157        $dom->formatOutput = false;
158        $dom->preserveWhiteSpace = false;
159
160        $svg = $dom->getElementsByTagName('svg')->item(0);
161
162        // prefer viewbox over width/height
163        if (!$svg->hasAttribute('viewBox')) {
164            $w = $svg->getAttribute('width');
165            $h = $svg->getAttribute('height');
166            if ($w && $h) {
167                $svg->setAttribute('viewBox', "0 0 $w $h");
168            }
169        }
170
171        // remove unwanted attributes from root
172        $this->removeAttributes($svg, ['viewBox']);
173
174        // remove unwanted attributes from primitives
175        foreach ($dom->getElementsByTagName('path') as $elem) {
176            $this->removeAttributes($elem, ['d']);
177        }
178        foreach ($dom->getElementsByTagName('rect') as $elem) {
179            $this->removeAttributes($elem, ['x', 'y', 'rx', 'ry']);
180        }
181        foreach ($dom->getElementsByTagName('circle') as $elem) {
182            $this->removeAttributes($elem, ['cx', 'cy', 'r']);
183        }
184        foreach ($dom->getElementsByTagName('ellipse') as $elem) {
185            $this->removeAttributes($elem, ['cx', 'cy', 'rx', 'ry']);
186        }
187        foreach ($dom->getElementsByTagName('line') as $elem) {
188            $this->removeAttributes($elem, ['x1', 'x2', 'y1', 'y2']);
189        }
190        foreach ($dom->getElementsByTagName('polyline') as $elem) {
191            $this->removeAttributes($elem, ['points']);
192        }
193        foreach ($dom->getElementsByTagName('polygon') as $elem) {
194            $this->removeAttributes($elem, ['points']);
195        }
196
197        // remove comments see https://stackoverflow.com/a/60420210
198        $xpath = new \DOMXPath($dom);
199        for ($els = $xpath->query('//comment()'), $i = $els->length - 1; $i >= 0; $i--) {
200            $els->item($i)->parentNode->removeChild($els->item($i));
201        }
202
203        // readd namespace
204        $svg->setAttribute('xmlns', 'http://www.w3.org/2000/svg');
205
206        if ($this->color) $svg->setAttribute('fill', $this->color);
207        if ($this->width) $svg->setAttribute('width', $this->width);
208        if ($this->height) $svg->setAttribute('height', $this->height);
209
210        $svgdata = $dom->saveXML($svg);
211        return $svgdata;
212    }
213
214    /**
215     * Remove all attributes except the given keepers
216     *
217     * @param \DOMNode $element
218     * @param string[] $keep
219     */
220    protected function removeAttributes($element, $keep)
221    {
222        $attributes = $element->attributes;
223        for ($i = $attributes->length - 1; $i >= 0; $i--) {
224            $name = $attributes->item($i)->name;
225            if (in_array($name, $keep)) continue;
226            $element->removeAttribute($name);
227        }
228    }
229
230}
231