Passed
Push — main ( 77e259...371264 )
by Oscar
08:09 queued 06:34
created

Svg::__construct()   B

Complexity

Conditions 6
Paths 4

Size

Total Lines 47
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 32
CRAP Score 6

Importance

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