xref: /plugin/dev/SVGIcon.php (revision 9273840774e623180ba408f4f4f3ba5468aaa5c8)
11a23d1dbSAndreas Gohr<?php
21a23d1dbSAndreas Gohr
31a23d1dbSAndreas Gohrnamespace dokuwiki\plugin\dev;
41a23d1dbSAndreas Gohr
51a23d1dbSAndreas Gohruse dokuwiki\HTTP\DokuHTTPClient;
61a23d1dbSAndreas Gohruse splitbrain\phpcli\CLI;
71a23d1dbSAndreas Gohr
81a23d1dbSAndreas Gohr/**
91a23d1dbSAndreas Gohr * Download and clean SVG icons
101a23d1dbSAndreas Gohr */
111a23d1dbSAndreas Gohrclass SVGIcon
121a23d1dbSAndreas Gohr{
131a23d1dbSAndreas Gohr
141a23d1dbSAndreas Gohr    const SOURCES = [
151a23d1dbSAndreas Gohr        'mdi' => "https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/%s.svg",
161a23d1dbSAndreas Gohr        'fab' => "https://raw.githubusercontent.com/FortAwesome/Font-Awesome/master/svgs/brands/%s.svg",
171a23d1dbSAndreas Gohr        'fas' => "https://raw.githubusercontent.com/FortAwesome/Font-Awesome/master/svgs/solid/%s.svg",
181a23d1dbSAndreas Gohr        'fa' => "https://raw.githubusercontent.com/FortAwesome/Font-Awesome/master/svgs/regular/%s.svg",
191a23d1dbSAndreas Gohr        'twbs' => "https://raw.githubusercontent.com/twbs/icons/main/icons/%s.svg",
201a23d1dbSAndreas Gohr    ];
211a23d1dbSAndreas Gohr
22*92738407SAndreas Gohr    /** @var CLI for logging */
231a23d1dbSAndreas Gohr    protected $logger;
241a23d1dbSAndreas Gohr
25*92738407SAndreas Gohr    /** @var bool keep the SVG namespace for when the image is not used in embed? */
26*92738407SAndreas Gohr    protected $keepns = false;
27*92738407SAndreas Gohr
281a23d1dbSAndreas Gohr    /**
291a23d1dbSAndreas Gohr     * @throws \Exception
301a23d1dbSAndreas Gohr     */
311a23d1dbSAndreas Gohr    public function __construct(CLI $logger)
321a23d1dbSAndreas Gohr    {
331a23d1dbSAndreas Gohr        $this->logger = $logger;
341a23d1dbSAndreas Gohr    }
351a23d1dbSAndreas Gohr
361a23d1dbSAndreas Gohr    /**
37*92738407SAndreas Gohr     * Call before cleaning to keep the SVG namespace
38*92738407SAndreas Gohr     *
39*92738407SAndreas Gohr     * @param bool $keep
40*92738407SAndreas Gohr     */
41*92738407SAndreas Gohr    public function keepNamespace($keep = true)
42*92738407SAndreas Gohr    {
43*92738407SAndreas Gohr        $this->keepns = $keep;
44*92738407SAndreas Gohr    }
45*92738407SAndreas Gohr
46*92738407SAndreas Gohr    /**
471a23d1dbSAndreas Gohr     * Download and save a remote icon
481a23d1dbSAndreas Gohr     *
491a23d1dbSAndreas Gohr     * @param string $ident prefixed name of the icon
501a23d1dbSAndreas Gohr     * @param string $save
511a23d1dbSAndreas Gohr     * @return bool
521a23d1dbSAndreas Gohr     * @throws \Exception
531a23d1dbSAndreas Gohr     */
541a23d1dbSAndreas Gohr    public function downloadRemoteIcon($ident, $save = '')
551a23d1dbSAndreas Gohr    {
561a23d1dbSAndreas Gohr        $icon = $this->remoteIcon($ident);
571a23d1dbSAndreas Gohr        $svgdata = $this->fetchSVG($icon['url']);
581a23d1dbSAndreas Gohr        $svgdata = $this->cleanSVG($svgdata);
591a23d1dbSAndreas Gohr
601a23d1dbSAndreas Gohr        if (!$save) {
611a23d1dbSAndreas Gohr            $save = $icon['name'] . '.svg';
621a23d1dbSAndreas Gohr        }
631a23d1dbSAndreas Gohr
641a23d1dbSAndreas Gohr        io_makeFileDir($save);
651a23d1dbSAndreas Gohr        $ok = io_saveFile($save, $svgdata);
661a23d1dbSAndreas Gohr        if ($ok) $this->logger->success('saved ' . $save);
671a23d1dbSAndreas Gohr        return $ok;
681a23d1dbSAndreas Gohr    }
691a23d1dbSAndreas Gohr
701a23d1dbSAndreas Gohr    /**
711a23d1dbSAndreas Gohr     * Clean an existing SVG file
721a23d1dbSAndreas Gohr     *
731a23d1dbSAndreas Gohr     * @param string $file
741a23d1dbSAndreas Gohr     * @return bool
751a23d1dbSAndreas Gohr     * @throws \Exception
761a23d1dbSAndreas Gohr     */
771a23d1dbSAndreas Gohr    public function cleanSVGFile($file)
781a23d1dbSAndreas Gohr    {
791a23d1dbSAndreas Gohr        $svgdata = io_readFile($file, false);
801a23d1dbSAndreas Gohr        if (!$svgdata) {
811a23d1dbSAndreas Gohr            throw new \Exception('Failed to read ' . $file);
821a23d1dbSAndreas Gohr        }
831a23d1dbSAndreas Gohr
841a23d1dbSAndreas Gohr        $svgdata = $this->cleanSVG($svgdata);
851a23d1dbSAndreas Gohr        $ok = io_saveFile($file, $svgdata);
861a23d1dbSAndreas Gohr        if ($ok) $this->logger->success('saved ' . $file);
871a23d1dbSAndreas Gohr        return $ok;
881a23d1dbSAndreas Gohr    }
891a23d1dbSAndreas Gohr
901a23d1dbSAndreas Gohr    /**
911a23d1dbSAndreas Gohr     * Get info about an icon from a known remote repository
921a23d1dbSAndreas Gohr     *
931a23d1dbSAndreas Gohr     * @param string $ident prefixed name of the icon
941a23d1dbSAndreas Gohr     * @return array
951a23d1dbSAndreas Gohr     * @throws \Exception
961a23d1dbSAndreas Gohr     */
971a23d1dbSAndreas Gohr    public function remoteIcon($ident)
981a23d1dbSAndreas Gohr    {
991a23d1dbSAndreas Gohr        if (strpos($ident, ':')) {
1001a23d1dbSAndreas Gohr            list($prefix, $name) = explode(':', $ident);
1011a23d1dbSAndreas Gohr        } else {
1021a23d1dbSAndreas Gohr            $prefix = 'mdi';
1031a23d1dbSAndreas Gohr            $name = $ident;
1041a23d1dbSAndreas Gohr        }
1051a23d1dbSAndreas Gohr        if (!isset(self::SOURCES[$prefix])) {
1061a23d1dbSAndreas Gohr            throw new \Exception("Unknown prefix $prefix");
1071a23d1dbSAndreas Gohr        }
1081a23d1dbSAndreas Gohr
1091a23d1dbSAndreas Gohr        $url = sprintf(self::SOURCES[$prefix], $name);
1101a23d1dbSAndreas Gohr
1111a23d1dbSAndreas Gohr        return [
1121a23d1dbSAndreas Gohr            'prefix' => $prefix,
1131a23d1dbSAndreas Gohr            'name' => $name,
1141a23d1dbSAndreas Gohr            'url' => $url,
1151a23d1dbSAndreas Gohr        ];
1161a23d1dbSAndreas Gohr    }
1171a23d1dbSAndreas Gohr
1181a23d1dbSAndreas Gohr    /**
119*92738407SAndreas Gohr     * Minify SVG
1201a23d1dbSAndreas Gohr     *
1211a23d1dbSAndreas Gohr     * @param string $svgdata
1221a23d1dbSAndreas Gohr     * @return string
1231a23d1dbSAndreas Gohr     */
1241a23d1dbSAndreas Gohr    protected function cleanSVG($svgdata)
1251a23d1dbSAndreas Gohr    {
1261a23d1dbSAndreas Gohr        $old = strlen($svgdata);
1271a23d1dbSAndreas Gohr
1281a23d1dbSAndreas Gohr        // strip namespace declarations FIXME is there a cleaner way?
1291a23d1dbSAndreas Gohr        $svgdata = preg_replace('/\sxmlns(:.*?)?="(.*?)"/', '', $svgdata);
1301a23d1dbSAndreas Gohr
1311a23d1dbSAndreas Gohr        $dom = new \DOMDocument();
1321a23d1dbSAndreas Gohr        $dom->loadXML($svgdata, LIBXML_NOBLANKS);
1331a23d1dbSAndreas Gohr        $dom->formatOutput = false;
1341a23d1dbSAndreas Gohr        $dom->preserveWhiteSpace = false;
1351a23d1dbSAndreas Gohr
1361a23d1dbSAndreas Gohr        $svg = $dom->getElementsByTagName('svg')->item(0);
1371a23d1dbSAndreas Gohr
1381a23d1dbSAndreas Gohr        // prefer viewbox over width/height
1391a23d1dbSAndreas Gohr        if (!$svg->hasAttribute('viewBox')) {
1401a23d1dbSAndreas Gohr            $w = $svg->getAttribute('width');
1411a23d1dbSAndreas Gohr            $h = $svg->getAttribute('height');
1421a23d1dbSAndreas Gohr            if ($w && $h) {
1431a23d1dbSAndreas Gohr                $svg->setAttribute('viewBox', "0 0 $w $h");
1441a23d1dbSAndreas Gohr            }
1451a23d1dbSAndreas Gohr        }
1461a23d1dbSAndreas Gohr
1471a23d1dbSAndreas Gohr        // remove unwanted attributes from root
1481a23d1dbSAndreas Gohr        $this->removeAttributes($svg, ['viewBox']);
1491a23d1dbSAndreas Gohr
1501a23d1dbSAndreas Gohr        // remove unwanted attributes from primitives
1511a23d1dbSAndreas Gohr        foreach ($dom->getElementsByTagName('path') as $elem) {
1521a23d1dbSAndreas Gohr            $this->removeAttributes($elem, ['d']);
1531a23d1dbSAndreas Gohr        }
1541a23d1dbSAndreas Gohr        foreach ($dom->getElementsByTagName('rect') as $elem) {
1551a23d1dbSAndreas Gohr            $this->removeAttributes($elem, ['x', 'y', 'rx', 'ry']);
1561a23d1dbSAndreas Gohr        }
1571a23d1dbSAndreas Gohr        foreach ($dom->getElementsByTagName('circle') as $elem) {
1581a23d1dbSAndreas Gohr            $this->removeAttributes($elem, ['cx', 'cy', 'r']);
1591a23d1dbSAndreas Gohr        }
1601a23d1dbSAndreas Gohr        foreach ($dom->getElementsByTagName('ellipse') as $elem) {
1611a23d1dbSAndreas Gohr            $this->removeAttributes($elem, ['cx', 'cy', 'rx', 'ry']);
1621a23d1dbSAndreas Gohr        }
1631a23d1dbSAndreas Gohr        foreach ($dom->getElementsByTagName('line') as $elem) {
1641a23d1dbSAndreas Gohr            $this->removeAttributes($elem, ['x1', 'x2', 'y1', 'y2']);
1651a23d1dbSAndreas Gohr        }
1661a23d1dbSAndreas Gohr        foreach ($dom->getElementsByTagName('polyline') as $elem) {
1671a23d1dbSAndreas Gohr            $this->removeAttributes($elem, ['points']);
1681a23d1dbSAndreas Gohr        }
1691a23d1dbSAndreas Gohr        foreach ($dom->getElementsByTagName('polygon') as $elem) {
1701a23d1dbSAndreas Gohr            $this->removeAttributes($elem, ['points']);
1711a23d1dbSAndreas Gohr        }
1721a23d1dbSAndreas Gohr
1731a23d1dbSAndreas Gohr        // remove comments see https://stackoverflow.com/a/60420210
1741a23d1dbSAndreas Gohr        $xpath = new \DOMXPath($dom);
1751a23d1dbSAndreas Gohr        for ($els = $xpath->query('//comment()'), $i = $els->length - 1; $i >= 0; $i--) {
1761a23d1dbSAndreas Gohr            $els->item($i)->parentNode->removeChild($els->item($i));
1771a23d1dbSAndreas Gohr        }
1781a23d1dbSAndreas Gohr
179*92738407SAndreas Gohr        // readd namespace if not meant for embedding
180*92738407SAndreas Gohr        if ($this->keepns) {
181*92738407SAndreas Gohr            $svg->setAttribute('xmlns', 'http://www.w3.org/2000/svg');
182*92738407SAndreas Gohr        }
183*92738407SAndreas Gohr
1841a23d1dbSAndreas Gohr        $svgdata = $dom->saveXML($svg);
1851a23d1dbSAndreas Gohr        $new = strlen($svgdata);
1861a23d1dbSAndreas Gohr
1871a23d1dbSAndreas Gohr        $this->logger->info(sprintf('Minified SVG %d bytes -> %d bytes (%.2f%%)', $old, $new, $new * 100 / $old));
1881a23d1dbSAndreas Gohr        if ($new > 2048) {
1891a23d1dbSAndreas Gohr            $this->logger->warning('%d bytes is still too big for standard inlineSVG() limit!');
1901a23d1dbSAndreas Gohr        }
1911a23d1dbSAndreas Gohr        return $svgdata;
1921a23d1dbSAndreas Gohr    }
1931a23d1dbSAndreas Gohr
1941a23d1dbSAndreas Gohr    /**
1951a23d1dbSAndreas Gohr     * Remove all attributes except the given keepers
1961a23d1dbSAndreas Gohr     *
1971a23d1dbSAndreas Gohr     * @param \DOMNode $element
1981a23d1dbSAndreas Gohr     * @param string[] $keep
1991a23d1dbSAndreas Gohr     */
2001a23d1dbSAndreas Gohr    protected function removeAttributes($element, $keep)
2011a23d1dbSAndreas Gohr    {
2021a23d1dbSAndreas Gohr        $attributes = $element->attributes;
2031a23d1dbSAndreas Gohr        for ($i = $attributes->length - 1; $i >= 0; $i--) {
2041a23d1dbSAndreas Gohr            $name = $attributes->item($i)->name;
2051a23d1dbSAndreas Gohr            if (in_array($name, $keep)) continue;
2061a23d1dbSAndreas Gohr            $element->removeAttribute($name);
2071a23d1dbSAndreas Gohr        }
2081a23d1dbSAndreas Gohr    }
2091a23d1dbSAndreas Gohr
2101a23d1dbSAndreas Gohr    /**
2111a23d1dbSAndreas Gohr     * Fetch the content from the given URL
2121a23d1dbSAndreas Gohr     *
2131a23d1dbSAndreas Gohr     * @param string $url
2141a23d1dbSAndreas Gohr     * @return string
2151a23d1dbSAndreas Gohr     * @throws \Exception
2161a23d1dbSAndreas Gohr     */
2171a23d1dbSAndreas Gohr    protected function fetchSVG($url)
2181a23d1dbSAndreas Gohr    {
2191a23d1dbSAndreas Gohr        $http = new DokuHTTPClient();
2201a23d1dbSAndreas Gohr        $svg = $http->get($url);
2211a23d1dbSAndreas Gohr
2221a23d1dbSAndreas Gohr        if (!$svg) {
2231a23d1dbSAndreas Gohr            throw new \Exception("Failed to download $url: " . $http->status . ' ' . $http->error);
2241a23d1dbSAndreas Gohr        }
2251a23d1dbSAndreas Gohr
2261a23d1dbSAndreas Gohr        return $svg;
2271a23d1dbSAndreas Gohr    }
2281a23d1dbSAndreas Gohr
2291a23d1dbSAndreas Gohr}
230