Passed
Push — main ( c747e2...d61a4b )
by Oscar
11:48
created

Svg::createFromString()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 2
c 1
b 0
f 0
dl 0
loc 5
ccs 0
cts 0
cp 0
rs 10
cc 1
nc 1
nop 2
crap 2
1
<?php
2
3
/*
4
 * This file is part of ocubom/twig-svg-extension
5
 *
6
 * © Oscar Cubo Medina <https://ocubom.github.io>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace Ocubom\Twig\Extension\Svg;
13
14
use function BenTools\IterableFunctions\iterable_merge;
15
use function BenTools\IterableFunctions\iterable_to_array;
16
17
use enshrined\svgSanitize\Sanitizer;
18
19
use function Ocubom\Twig\Extension\is_string;
20
21
use Ocubom\Twig\Extension\Svg\Exception\FileNotFoundException;
22
use Ocubom\Twig\Extension\Svg\Exception\ParseException;
23
use Ocubom\Twig\Extension\Svg\Processor\ClassProcessor;
24
use Ocubom\Twig\Extension\Svg\Processor\RemoveAttributeProcessor;
25
use Ocubom\Twig\Extension\Svg\Processor\TitleProcessor;
26
use Ocubom\Twig\Extension\Svg\Util\DomHelper;
27
use Ocubom\Twig\Extension\Svg\Util\DomIdent;
28
use Symfony\Component\OptionsResolver\Options;
29
use Symfony\Component\OptionsResolver\OptionsResolver;
30
31
class Svg implements SvgInterface
32
{
33
    use SvgTrait;
34
35
    /** @var array<string, array<string, array<int, callable>|callable>> */
36
    private static array $processors = [];
37
38
    /**
39
     * @param mixed $data The SVG data
40
     */
41 8
    public function __construct($data, iterable $options = null)
42
    {
43
        switch (true) {
44 8
            case $data instanceof Svg : // "Copy" constructor
45
                $node = $this->constructFromString(DomHelper::toXml($data->svg)); // @codeCoverageIgnore
46
                break; // @codeCoverageIgnore
47
48 8
            case $data instanceof \DOMNode :
49 3
                $node = $this->constructFromString(DomHelper::toXml($data));
50 3
                break;
51
52 5
            case $data instanceof \SplFileInfo :
53 5
                $node = $this->constructFromFile($data);
54 4
                break;
55
56
            case is_string($data) :
57
                $node = $this->constructFromString($data);
58
                break;
59
60
            default:
61
                throw new ParseException(sprintf('Unable to create an SVG from "%s"', get_debug_type($data))); // @codeCoverageIgnore
62
        }
63
64
        // Merge options with SVG attributes
65 7
        $options = iterable_to_array(iterable_merge(
66 7
            DomHelper::getElementAttributes($node),
67 7
            $options ?? /* @scrutinizer ignore-type */ []
68 7
        ));
69
70
        // Define SVG attributes and resolve options + attributes
71 7
        $options = static::configureOptions()
72 7
            ->setDefined(array_keys($options)) // enables all optional options
73 7
            ->resolve($options);
74
75
        // Retrieve processors for this class with a naive cache
76 7
        $processors = self::$processors[static::class]
77 7
            ?? self::$processors[static::class] = array_map(
78 7
                function ($processors): array {
79 2
                    return is_iterable($processors) ? $processors : [$processors];
80 7
                },
81 7
                static::getProcessors()
82 7
            );
83 7
        if (isset($processors[''])) {
84 2
            $options[''] = null; // Force execution of global processors.
85
        }
86
87
        // Apply processors on a flesh clone in new DOM document
88 7
        $this->svg = DomHelper::cloneElement($node);
89 7
        foreach ($options as $key => $val) {
90 7
            if (isset($processors[$key])) {
91
                // Apply processors
92 7
                foreach ($processors[$key] as $processor) {
93 7
                    $this->svg = $processor($this->svg, $options);
94
                }
95 7
            } elseif (empty($val)) {
96
                // Remove empty attributes
97 4
                $this->svg->removeAttribute($key);
98
            } else {
99
                // Set attribute value
100 7
                $this->svg->setAttribute($key, $val);
101
            }
102
        }
103
    }
104
105 5
    protected function constructFromFile(\SplFileInfo $path): \DOMElement
106
    {
107 5
        $path = (string) $path;
108
109 5
        if (!is_file($path)) {
110
            throw new FileNotFoundException(sprintf('File "%s" does not exist.', $path)); // @codeCoverageIgnore
111
        }
112
113 5
        if (!is_readable($path)) {
114
            throw new FileNotFoundException(sprintf('File "%s" cannot be read.', $path)); // @codeCoverageIgnore
115
        }
116
117
        try {
118 5
            return $this->constructFromString(file_get_contents($path));
119 1
        } catch (ParseException $exc) {
120 1
            throw new ParseException(sprintf('File "%s" does not contain a valid SVG.', $path), 0, $exc);
121
        }
122
    }
123
124 8
    protected function constructFromString(string $contents): \DOMElement
125
    {
126
        // Sanitize contents (if enshrined\svgSanitize is installed)
127 8
        if (class_exists(Sanitizer::class)) {
128 8
            $contents = (new Sanitizer())->sanitize($contents);
129
        }
130
131
        // Remove all namespaces
132 8
        $contents = preg_replace('@xmlns(:.*)?=(\"[^\"]*\"|\'[^\']*\')@Uis', '', $contents);
133 8
        if (empty($contents)) {
134 1
            throw new ParseException(sprintf('Invalid SVG string "%s".', func_get_arg(0)));
135
        }
136
137
        // Parse contents into DOM
138 7
        $doc = DomHelper::createDocument();
139 7
        if (false === $doc->loadXML($contents)) {
140
            throw new ParseException(sprintf('Unable to load SVG string "%s".', func_get_arg(0))); // @codeCoverageIgnore
141
        }
142
143
        // Get first svg item
144 7
        $node = $doc->getElementsByTagName('svg')->item(0);
145 7
        if ($node instanceof \DOMElement) {
146 7
            return $node; // Return first SVG element
147
        }
148
149
        throw new ParseException(sprintf('String "%s" does not contain any SVG.', func_get_arg(0))); // @codeCoverageIgnore
150
    }
151
152
    /**
153
     * @return array<string, array<int, callable>|callable>
154
     */
155 2
    protected static function getProcessors(): array
156
    {
157 2
        return [
158
            // Options will be ignored & removed
159 2
            'debug' => new RemoveAttributeProcessor('debug'),
160 2
            'minimize' => new RemoveAttributeProcessor('minimize'),
161 2
            'class_block' => new RemoveAttributeProcessor('class_block'),
162
163
            // Custom process
164 2
            'class' => new ClassProcessor(),
165 2
            'title' => new TitleProcessor(),
166
167
            // Ignore attributes that depends on other options
168 2
            'aria-labelledby' => [], // generated on TitleProcessor
169 2
        ];
170
    }
171
172
    /** @psalm-suppress MissingClosureParamType */
173 7
    protected static function configureOptions(OptionsResolver $resolver = null): OptionsResolver
174
    {
175 7
        $resolver = $resolver ?? new OptionsResolver();
176
177
        // Options
178
179 7
        $resolver->define('debug')
180 7
            ->default(false)
181 7
            ->allowedTypes('bool')
182 7
            ->info('Enable debug output');
183
184 7
        $resolver->define('minimize')
185 7
            ->default(true)
186 7
            ->allowedTypes('bool')
187 7
            ->info('Minimize output');
188
189
        // Attributes
190
191
        /** @psalm-suppress MissingClosureParamType */
192 7
        $normalizeClass = function (Options $options, $value): array {
193 7
            return array_filter(
194 7
                is_string($value) ? preg_split('@\s+@Uis', $value) : ($value ?? []),
195 7
                function (string $item): bool {
196 7
                    return !empty($item);
197 7
                }
198 7
            );
199 7
        };
200
201 7
        $resolver->define('class')
202 7
            ->default('')
203 7
            ->allowedTypes('null', 'string', 'string[]')
204 7
            ->normalize($normalizeClass)
205 7
            ->info('Classes to apply');
206
207 7
        $resolver->define('class_block')
208 7
            ->default('')
209 7
            ->allowedTypes('null', 'string', 'string[]')
210 7
            ->normalize($normalizeClass)
211 7
            ->info('Classes to block');
212
213 7
        $resolver->define('title')
214 7
            ->default(null)
215 7
            ->allowedTypes('null', 'string')
216 7
            ->normalize(function (Options $options, ?string $value): ?string {
217 7
                return empty($value ?? '') ? null : $value;
218 7
            })
219 7
            ->info('Title of the icon. Used for semantic icons.');
220
221 7
        $resolver->define('focusable')
222 7
            ->default('false')
223 7
            ->allowedTypes('null', 'bool', 'string')
224 7
            ->normalize(function (Options $options, $value): ?string {
225 7
                if (is_bool($value)) {
226 1
                    return $value ? 'true' : 'false';
227
                }
228
229 7
                return empty($value) ? null : $value;
230 7
            })
231 7
            ->info('Indicates if the element can take the focus');
232
233 7
        $resolver->define('role')
234 7
            ->default('img')
235 7
            ->allowedTypes('null', 'string')
236 7
            ->info('Indicates the semantic meaning of the content');
237
238 7
        $resolver->define('aria-hidden')
239 7
            ->default(null)
240 7
            ->allowedTypes('null', 'bool', 'string')
241 7
            ->allowedValues(null, true, 'true', false, 'false')
242 7
            ->normalize(function (Options $options, $value): ?string {
243
                // Decorative icon: no title and not previously defined aria-hidden value
244
                if (
245 7
                    null === $value
246 7
                    && empty($options['aria-labelledby'])
247 7
                    && empty($options['title'])
248
                ) {
249 5
                    return 'true';
250
                }
251
252
                // Convert bool value into text
253 4
                if (is_bool($value)) {
254 1
                    return $value ? 'true' : 'false';
255
                }
256
257 4
                return empty($value)
258 4
                    ? null
259 4
                    : ('true' === $value ? 'true' : 'false');
260 7
            })
261 7
            ->info('Indicates whether the element is exposed to an accessibility API');
262
263 7
        $resolver->define('aria-labelledby')
264 7
            ->default(null)
265 7
            ->allowedTypes('null', 'string')
266 7
            ->normalize(function (Options $options, $value): ?string {
267
                // Decorative icon: no title and not previously defined aria-hidden value
268 7
                if (empty($options['title'])) {
269 6
                    return null;
270
                }
271
272
                // Generate an identifier based on title contents
273 4
                return $value ?? DomIdent::generate(DomHelper::createElement('title', $options['title']));
274 7
            })
275 7
            ->info('Identifies the element that labels this element');
276
277 7
        return $resolver;
278
    }
279
}
280