Passed
Push — main ( 1a7f0e...7d39cf )
by Oscar
03:17
created

Svg::createFromFile()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

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