Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.95% covered (warning)
84.95%
79 / 93
27.27% covered (danger)
27.27%
3 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Local
84.95% covered (warning)
84.95%
79 / 93
27.27% covered (danger)
27.27%
3 / 11
50.60
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
 icon
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
 split
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
5.09
 normalizePaths
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
6.22
 file
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 injectAttributes
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
6.01
 appendAttribute
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
5.93
 mergeStyle
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 joinStyles
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 clean
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 isSvg
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Icons;
6
7use function Cosray\escape;
8
9final class Local implements Provider
10{
11    /** @var list<string> */
12    private array $paths;
13
14    /** @param array<array-key, mixed> $paths */
15    public function __construct(array $paths)
16    {
17        $this->paths = $this->normalizePaths($paths);
18    }
19
20    /** @param array<array-key, mixed> $args */
21    public function icon(string $id, array $args = []): string
22    {
23        $parts = $this->split($id);
24
25        if ($parts === null) {
26            return '';
27        }
28
29        foreach ($this->paths as $path) {
30            $file = $this->file($path, $parts['prefix'], $parts['name']);
31
32            if ($file === null) {
33                continue;
34            }
35
36            $svg = file_get_contents($file);
37
38            if (is_string($svg) && $this->isSvg($svg)) {
39                return $this->injectAttributes($svg, $args);
40            }
41        }
42
43        return '';
44    }
45
46    /**
47     * @return array{prefix: ?string, name: string}|null
48     */
49    private function split(string $id): ?array
50    {
51        $id = trim($id);
52
53        if (!preg_match(
54            '/^(?:(?<prefix>[a-z0-9]+(?:[-_][a-z0-9]+)*):)?(?<name>[a-z0-9]+(?:[-_][a-z0-9]+)*)$/i',
55            $id,
56            $matches,
57        )) {
58            return null;
59        }
60
61        $name = strtolower((string) $matches['name']);
62
63        if ($name === '') {
64            return null;
65        }
66
67        $prefix = $matches['prefix'] ?? null;
68        $prefix = is_string($prefix) && $prefix !== '' ? strtolower($prefix) : null;
69
70        return ['prefix' => $prefix, 'name' => $name];
71    }
72
73    /**
74     * @param array<array-key, mixed> $paths
75     * @return list<string>
76     */
77    private function normalizePaths(array $paths): array
78    {
79        $result = [];
80
81        foreach ($paths as $path) {
82            if (!is_string($path)) {
83                continue;
84            }
85
86            $path = trim($path);
87
88            if ($path === '') {
89                continue;
90            }
91
92            $real = realpath($path);
93
94            if ($real !== false && is_dir($real)) {
95                $result[] = $real;
96            }
97        }
98
99        return array_values(array_unique($result));
100    }
101
102    private function file(string $path, ?string $prefix, string $name): ?string
103    {
104        $file = $prefix === null
105            ? $path . DIRECTORY_SEPARATOR . $name . '.svg'
106            : $path . DIRECTORY_SEPARATOR . $prefix . DIRECTORY_SEPARATOR . $name . '.svg';
107        $resolved = realpath($file);
108
109        if ($resolved === false || !is_file($resolved)) {
110            return null;
111        }
112
113        $root = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
114
115        if (!str_starts_with($resolved, $root)) {
116            return null;
117        }
118
119        return $resolved;
120    }
121
122    /** @param array<array-key, mixed> $args */
123    private function injectAttributes(string $svg, array $args): string
124    {
125        $class = $this->clean($args['class'] ?? null);
126        $style = $this->mergeStyle(
127            $this->clean($args['style'] ?? null),
128            $this->clean($args['color'] ?? null),
129        );
130
131        if ($class === null && $style === null) {
132            return $svg;
133        }
134
135        if (!preg_match('/<svg\\b[^>]*>/i', $svg, $matches, PREG_OFFSET_CAPTURE)) {
136            return $svg;
137        }
138
139        $tag = $matches[0][0];
140        $offset = $matches[0][1];
141        $length = strlen($tag);
142
143        if ($class !== null) {
144            $tag = $this->appendAttribute($tag, 'class', $class);
145        }
146
147        if ($style !== null) {
148            $tag = $this->appendAttribute($tag, 'style', $style);
149        }
150
151        return substr_replace($svg, $tag, $offset, $length);
152    }
153
154    private function appendAttribute(string $tag, string $name, string $value): string
155    {
156        $pattern = sprintf('/\\s%s\\s*=\\s*(?:"([^"]*)"|\'([^\']*)\')/i', preg_quote($name, '/'));
157
158        if (preg_match($pattern, $tag, $matches) === 1) {
159            $current = ($matches[1] ?? '') !== '' ? $matches[1] : $matches[2] ?? '';
160            $merged = $name === 'style'
161                ? $this->joinStyles($current, $value)
162                : trim($current . ' ' . $value);
163            $replacement = sprintf(' %s="%s"', $name, escape($merged));
164
165            return (string) preg_replace($pattern, $replacement, $tag, 1);
166        }
167
168        $injection = sprintf(' %s="%s"', $name, escape($value));
169        $closer = str_ends_with($tag, '/>') ? '/>' : '>';
170        $base = substr($tag, 0, -strlen($closer));
171
172        return $base . $injection . $closer;
173    }
174
175    private function mergeStyle(?string $style, ?string $color): ?string
176    {
177        if ($color === null) {
178            return $style;
179        }
180
181        $colorStyle = 'color: ' . $color;
182
183        if ($style === null) {
184            return $colorStyle;
185        }
186
187        return rtrim($style, '; ') . '; ' . $colorStyle;
188    }
189
190    private function joinStyles(string $base, string $append): string
191    {
192        $base = trim($base);
193        $append = trim($append);
194
195        if ($base === '') {
196            return $append;
197        }
198
199        if ($append === '') {
200            return $base;
201        }
202
203        return rtrim($base, '; ') . '; ' . ltrim($append, '; ');
204    }
205
206    private function clean(mixed $value): ?string
207    {
208        if (!is_scalar($value)) {
209            return null;
210        }
211
212        $value = trim((string) $value);
213
214        return $value === '' ? null : $value;
215    }
216
217    private function isSvg(string $svg): bool
218    {
219        return str_starts_with(ltrim($svg), '<svg');
220    }
221}