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