Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.87% covered (warning)
86.87%
86 / 99
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
icon
86.87% covered (warning)
86.87%
86 / 99
33.33% covered (danger)
33.33%
3 / 9
44.81
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFunctions
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 icon
83.67% covered (warning)
83.67%
41 / 49
0.00% covered (danger)
0.00%
0 / 1
18.26
 insert_fa_class
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 prepare_svg
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
9
 get_first_icon
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 implode_attributes
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_style_list
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
1<?php
2/**
3 *
4 * This file is part of the phpBB Forum Software package.
5 *
6 * @copyright (c) phpBB Limited <https://www.phpbb.com>
7 * @license GNU General Public License, version 2 (GPL-2.0)
8 *
9 * For full copyright and license information, please see
10 * the docs/CREDITS.txt file.
11 *
12 */
13
14namespace phpbb\template\twig\extension;
15
16use phpbb\template\twig\environment;
17use Twig\Extension\AbstractExtension;
18
19class icon extends AbstractExtension
20{
21    /** @var \phpbb\user */
22    protected $user;
23
24    /**
25     * Constructor.
26     *
27     * @param \phpbb\user    $user            User object
28     */
29    public function __construct(\phpbb\user $user)
30    {
31        $this->user = $user;
32    }
33
34    /**
35     * Returns the name of this extension.
36     *
37     * @return string                        The extension name
38     */
39    public function getName()
40    {
41        return 'icon';
42    }
43
44    /**
45     * Returns a list of functions to add to the existing list.
46     *
47     * @return \Twig\TwigFunction[]            Array of twig functions
48     */
49    public function getFunctions()
50    {
51        return [
52            new \Twig\TwigFunction('Icon', [$this, 'icon'], ['needs_environment' => true]),
53        ];
54    }
55
56    /**
57     * Generate icon HTML for use in the template, depending on the mode.
58     *
59     * @param environment    $environment    Twig environment object
60     * @param string        $type            Icon type (font|png|svg)
61     * @param array|string    $icon            Icon name (eg. "bold")
62     * @param string        $title            Icon title
63     * @param bool            $hidden            Hide the icon title from view
64     * @param string        $classes        Additional classes (eg. "fa-fw")
65     * @param array            $attributes        Additional attributes for the icon, where the key is the attribute.
66     *                                      {'data-ajax': 'mark_forums'} results in ' data-ajax="mark_forums"'
67     * @return string
68     */
69    public function icon(environment $environment, $type, $icon, $title = '', $hidden = false, $classes = '', array $attributes = [])
70    {
71        $type = strtolower($type);
72        $icon = is_array($icon) ? $this->get_first_icon($icon) : $icon;
73
74        if (empty($icon))
75        {
76            return '';
77        }
78
79        $not_found    = false;
80        $source        = '';
81        $view_box    = '';
82
83        switch ($type)
84        {
85            case 'font':
86                $classes = $this->insert_fa_class($classes);
87            break;
88
89            case 'png':
90                $filesystem    = $environment->get_filesystem();
91                $root_path    = $environment->get_web_root_path();
92
93                // Iterate over the user's styles and check for icon existance
94                foreach ($this->get_style_list() as $style_path)
95                {
96                    if ($filesystem->exists("{$root_path}styles/{$style_path}/theme/png/{$icon}.png"))
97                    {
98                        $source = "{$root_path}styles/{$style_path}/theme/png/{$icon}.png";
99
100                        break;
101                    }
102                }
103
104                // Check if the icon was found or not
105                $not_found = empty($source);
106            break;
107
108            case 'svg':
109                try
110                {
111                    // Try to load and prepare the SVG icon
112                    $file    = $environment->load('svg/' . $icon . '.svg');
113                    $source    = $this->prepare_svg($file, $view_box);
114
115                    if (empty($view_box))
116                    {
117                        return '';
118                    }
119                }
120                catch (\Twig\Error\LoaderError $e)
121                {
122                    // Icon was not found
123                    $not_found = true;
124                }
125                catch (\Twig\Error\Error $e)
126                {
127                    return $e->getMessage();
128                }
129            break;
130
131            default:
132                return '';
133        }
134
135        // If no PNG or SVG icon was found, display a default 404 SVG icon.
136        if ($not_found)
137        {
138            try
139            {
140                $file    = $environment->load('svg/404.svg');
141                $source    = $this->prepare_svg($file, $view_box);
142            }
143            catch (\Twig\Error\Error $e)
144            {
145                return $e->getMessage();
146            }
147
148            $type = 'svg';
149            $icon = '404';
150        }
151
152        try
153        {
154            return $environment->render("macros/icons/{$type}.twig", [
155                'ATTRIBUTES'    => (string) $this->implode_attributes($attributes),
156                'CLASSES'        => (string) $classes,
157                'ICON'            => (string) $icon,
158                'SOURCE'        => (string) $source,
159                'TITLE'            => (string) $title,
160                'TITLE_ID'        => $title && $type === 'svg' ? unique_id() : '',
161                'VIEW_BOX'        => (string) $view_box,
162                'S_HIDDEN'        => (bool) $hidden,
163            ]);
164        }
165        catch (\Twig\Error\Error $e)
166        {
167            return $e->getMessage();
168        }
169    }
170
171    /**
172     * Insert fa class into class string by checking if class string contains any fa classes
173     *
174     * @param string $class_string
175     * @return string Updated class string or original class string if fa class is already set or string is empty
176     */
177    protected function insert_fa_class(string $class_string): string
178    {
179        if (empty($class_string))
180        {
181            return $class_string;
182        }
183
184        // These also include pro class name we don't use, but handle them properly anyway
185        $fa_classes = ['fa-solid', 'fas', 'fa-regular', 'far', 'fal', 'fa-light', 'fab', 'fa-brands'];
186
187        // Split the class string into individual words
188        $icon_classes = explode(' ', $class_string);
189
190        // Check if the class string contains any of the fa classes, just return class string in that case
191        foreach ($icon_classes as $word)
192        {
193            if (in_array($word, $fa_classes))
194            {
195                return $class_string;
196            }
197        }
198
199        // If we reach this it means we didn't have any fa classes in the class string.
200        // Prepend class string with fas for fa-solid
201        return 'fas ' . $class_string;
202    }
203
204    /**
205     * Prepare an SVG for usage in the template icon.
206     *
207     * This removes any <?xml ?> and <!DOCTYPE> elements,
208     * aswell as the root <svg> and any <title> elements.
209     *
210     * @param \Twig\TemplateWrapper    $file        The SVG file loaded from the environment
211     * @param string                $view_box    The viewBox attribute value
212     * @return string                            The cleaned SVG
213     */
214    protected function prepare_svg(\Twig\TemplateWrapper $file, &$view_box = '')
215    {
216        $code = $file->render();
217        $code = preg_replace( "/<\?xml.+?\?>/", '', $code);
218
219        $doc = new \DOMDocument();
220        $doc->preserveWhiteSpace = false;
221
222        // Hide html5/svg errors
223        libxml_use_internal_errors(true);
224
225        // Options parameter prevents $dom->saveHTML() from adding an <html> element.
226        $doc->loadHTML($code, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
227
228        // Remove any DOCTYPE
229        foreach ($doc->childNodes as $child)
230        {
231            if ($child->nodeType === XML_DOCUMENT_TYPE_NODE)
232            {
233                $child->parentNode->removeChild($child);
234            }
235        }
236
237        $xpath = new \DOMXPath($doc);
238
239        /**
240         * Remove the root <svg> element
241         * and all <title> elements.
242         *
243         * @var \DOMElement $element
244         */
245        foreach ($xpath->query('/svg | //title') as $element)
246        {
247            if ($element->nodeName === 'svg')
248            {
249                // Return the viewBox attribute value of the root SVG element by reference
250                $view_box = $element->getAttribute('viewbox');
251
252                $width = $element->getAttribute('width');
253                $height = $element->getAttribute('height');
254
255                if (empty($view_box) && $width && $height)
256                {
257                    $view_box = "0 0 {$width} {$height}";
258                }
259
260                while (isset($element->firstChild))
261                {
262                    $element->parentNode->insertBefore($element->firstChild, $element);
263                }
264            }
265
266            $element->parentNode->removeChild($element);
267        }
268
269        $string = $doc->saveHTML();
270        $string = preg_replace('/\s+/', ' ', $string);
271
272        return $string;
273    }
274
275    /**
276     * Finds the first icon that has a "true" value and returns it.
277     *
278     * This allows sending an array to the Icon() function,
279     * where the keys are the icon names and the values are their checks.
280     *
281     * {{ Icon('font', {
282     *         'bullhorn': topicrow.S_POST_GLOBAL or topicrow.S_POST_ANNOUNCE,
283     *         'thumbtack': topicrow.S_POST_STICKY,
284     *         'lock': topicrow.S_TOPIC_LOCKED,
285     *         'fire': topicrow.S_TOPIC_HOT,
286     *         'file': true,
287     * }, lang('MY_TITLE'), true) }}
288     *
289     * @param array        $icons            Array of icons and their booleans
290     * @return string                    The first 'true' icon
291     */
292    protected function get_first_icon(array $icons)
293    {
294        foreach ($icons as $icon => $boolean)
295        {
296            // In case the key is not a string,
297            // this icon does not have a check
298            // so instantly return it
299            if (!is_string($icon))
300            {
301                return $boolean;
302            }
303
304            if ($boolean)
305            {
306                return $icon;
307            }
308        }
309
310        return '';
311    }
312
313    /**
314     * Implode an associated array of attributes to a string for usage in a template.
315     *
316     * @param array        $attributes        Associated array of attributes
317     * @return string
318     */
319    protected function implode_attributes(array $attributes)
320    {
321        $string = '';
322
323        foreach ($attributes as $key => $value)
324        {
325            $string .= ' ' . $key . '="' . $value . '"';
326        }
327
328        return $string;
329    }
330
331    /**
332     * Get the style tree of the style preferred by the current user.
333     *
334     * @return array                    Style tree, most specific first
335     */
336    protected function get_style_list()
337    {
338        $style_list = [$this->user->style['style_path']];
339
340        if ($this->user->style['style_parent_id'])
341        {
342            $style_list = array_merge($style_list, array_reverse(explode('/', $this->user->style['style_parent_tree'])));
343        }
344
345        return $style_list;
346    }
347}