Passed
Push — main ( d61a4b...a3ea10 )
by Oscar
12:17
created

DomUtil::createElement()   A

Complexity

Conditions 6
Paths 26

Size

Total Lines 30
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 6

Importance

Changes 0
Metric Value
eloc 17
dl 0
loc 30
ccs 11
cts 11
cp 1
rs 9.0777
c 0
b 0
f 0
cc 6
nc 26
nop 4
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\Util;
13
14
use Ocubom\Twig\Extension\Svg\Exception\RuntimeException;
15
use Symfony\Component\OptionsResolver\Options;
16
use Symfony\Component\OptionsResolver\OptionsResolver;
17
18
use function BenTools\IterableFunctions\iterable_to_array;
19
use function Ocubom\Math\base_convert;
20
21
/** @internal */
22
final class DomUtil
23
{
24
    private const IDENT_REPLACEMENTS = [
25
        '> <' => '><',
26
        '" >' => '">',
27
        '\' >' => '\'>',
28
    ];
29
30
    /** @var OptionsResolver[] */
31
    private static array $resolver = [];
32
33
    // @codeCoverageIgnoreStart
34
    private function __construct()
35
    {
36
    }
37
38
    // @codeCoverageIgnoreEnd
39
40 6
    public static function generateId(
41
        \DOMElement $element,
42
        iterable $options = null
43
    ): string {
44 6
        $resolver = self::$resolver[__METHOD__] = self::$resolver[__METHOD__]
45 1
            ?? self::configureIdentOptions();
46
47
        // Resolve options
48 6
        $options = $resolver->resolve(iterable_to_array($options ?? []));
49
50
        // Work on an optimized clone
51 6
        $node = DomUtil::cloneElement($element);
52
        // Remove identifier
53 6
        $node->removeAttribute('id');
54
55
        // Convert to text
56 6
        $text = DomUtil::toXml($node);
57 6
        $text = preg_replace('/\s\s+/', ' ', $text);
58 6
        $text = trim($text);
59 6
        foreach (self::IDENT_REPLACEMENTS as $search => $replace) {
60 6
            $text = str_replace($search, $replace, $text);
61
        }
62
63
        // Generate a hash
64 6
        $hash = hash($options['algo'], $text);
65
66
        // Convert to base and pad to maximum length in new base
67 6
        $hash = str_pad(
68 6
            base_convert($hash, 16, $options['base']),
69 6
            (int) ceil(strlen($hash) * log(16, $options['base'])),
70 6
            '0',
71 6
            \STR_PAD_LEFT
72 6
        );
73
74 6
        return sprintf($options['format'], substr($hash, -$options['length']));
75
    }
76
77 10
    public static function createDocument(iterable $options = null): \DOMDocument
78
    {
79 10
        $resolver = self::$resolver[__METHOD__] = self::$resolver[__METHOD__]
80 1
            ?? self::configureDocumentOptions();
81
82
        // Resolve options
83 10
        $options = $resolver->resolve(iterable_to_array($options ?? []));
84
85
        // Create document
86 10
        $doc = new \DOMDocument($options['version'], $options['encoding']);
87
88
        // Apply options
89 10
        foreach ($options as $key => $val) {
90 10
            if (in_array($key, ['version', 'encoding', 'standalone'])) {
91 10
                continue; // Ignore constructor arguments and deprecated options
92
            }
93
94
            // Apply property
95 10
            $doc->{$key} = $val;
96
        }
97
98 10
        return $doc;
99
    }
100
101 3
    public static function appendChildElement(
102
        \DOMElement $element,
103
        \DOMNode $parent
104
    ): \DOMElement {
105 3
        $child = self::appendChildNode($element, $parent);
106
        assert($child instanceof \DOMElement);
107
108 3
        return $child;
109
    }
110
111 3
    public static function appendChildNode(
112
        \DOMNode $node,
113
        \DOMNode $parent
114
    ): \DOMNode {
115
        assert($parent->ownerDocument instanceof \DOMDocument);
116
117 3
        $child = $parent->appendChild(
118 3
            $parent->ownerDocument === $node->ownerDocument
119 3
                ? $node->cloneNode(true)
120 3
                : $parent->ownerDocument->importNode($node, true)
121 3
        );
122
        assert($child instanceof \DOMNode);
123
124 3
        return $child;
125
    }
126
127 10
    public static function cloneElement(
128
        \DOMElement $element,
129
        bool $deep = true,
130
        \DOMDocument $document = null
131
    ): \DOMElement {
132 10
        $clone = self::cloneNode($element, $deep, $document);
133
134
        assert($clone instanceof \DOMElement);
135
136 10
        return $clone;
137
    }
138
139 10
    public static function cloneNode(
140
        \DOMNode $node,
141
        bool $deep = true,
142
        \DOMDocument $document = null
143
    ): \DOMNode {
144 10
        $doc = $document ?? self::createDocument();
145
146 10
        $clone = $node->ownerDocument === $doc ? $node->cloneNode($deep) : $doc->importNode($node, $deep);
147
        assert($clone instanceof \DOMNode);
148
149 10
        return $clone;
150
    }
151
152 4
    public static function createComment(
153
        string $data = '',
154
        \DOMNode $node = null,
155
        bool $before = false
156
    ): \DOMComment {
157
        // Get node document ($node if is a DOMdocument)
158 4
        $doc = $node instanceof \DOMNode
159 4
            ? ($node->ownerDocument ?? $node)
160
            : self::createDocument();
161
        assert($doc instanceof \DOMDocument);
162
163 4
        $new = $doc->createComment($data);
164 4
        if (null === $node || $doc === $node) {
165
            // Append child to the document
166
            $new = $doc->appendChild($new);
167 4
        } elseif ($before) {
168
            // Insert child before the element
169
            assert($node->parentNode instanceof \DOMNode);
170 4
            $new = $node->parentNode->insertBefore($new, $node);
171
        } else {
172
            // Insert child before the end of the element
173
            $new = $node->insertBefore($new);
174
        }
175
        assert($new instanceof \DOMComment);
176
177 4
        return $new;
178
    }
179
180 6
    public static function createElement(
181
        string $name,
182
        string $value = '',
183
        \DOMNode $node = null,
184
        bool $before = false
185
    ): \DOMElement {
186
        try {
187
            // Get node document ($node if is a DOMdocument)
188 6
            $doc = $node instanceof \DOMNode
189 6
                ? ($node->ownerDocument ?? $node)
190 6
                : self::createDocument();
191
            assert($doc instanceof \DOMDocument);
192
193 6
            $new = $doc->createElement($name, $value);
194 6
            if (null === $node || $doc === $node) {
195
                // Append child to the document
196 6
                $new = $doc->appendChild($new);
197 6
            } elseif ($before) {
198
                // Insert child before the element
199
                assert($node->parentNode instanceof \DOMNode);
200 4
                $new = $node->parentNode->insertBefore($new, $node);
201
            } else {
202
                // Insert child before the end of the element
203 3
                $new = $node->insertBefore($new);
204
            }
205
            assert($new instanceof \DOMElement);
206
207 6
            return $new;
208
        } catch (\DOMException $exc) { // @codeCoverageIgnore
209
            throw new RuntimeException($exc->getMessage(), $exc->getCode(), $exc); // @codeCoverageIgnore
210
        }
211
    }
212
213 7
    public static function query(
214
        string $expression,
215
        \DOMNode $node,
216
        bool $registerNodeNS = true
217
    ): \DOMNodeList {
218 7
        $doc = $node instanceof \DOMDocument ? $node : $node->ownerDocument;
219
        assert($doc instanceof \DOMDocument);
220
221 7
        $nodes = (new \DOMXPath($doc))->query(
222 7
            $expression,
223 7
            $node instanceof \DOMDocument ? null : $node,
224 7
            $registerNodeNS
225 7
        );
226
227
        assert($nodes instanceof \DOMNodeList);
228
229 7
        return $nodes;
230
    }
231
232 1
    public static function removeNode(
233
        \DOMNode $node
234
    ): void {
235
        assert($node->parentNode instanceof \DOMNode);
236
237 1
        $node->parentNode->removeChild($node);
238
    }
239
240 7
    public static function replaceNode(
241
        \DOMNode $old,
242
        \DOMNode $new
243
    ): \DOMNode {
244 7
        $doc = $old->ownerDocument;
245
        assert($doc instanceof \DOMDocument);
246
247
        // Clone new node in the document
248 7
        $new = $doc->importNode($new, true);
249
        assert($new instanceof \DOMNode);
250
251
        // Obtain parent
252 7
        $parent = $old->parentNode;
253
        assert($parent instanceof \DOMNode);
254
255 7
        $parent->replaceChild($new, $old);
256
257 7
        return $new;
258
    }
259
260 10
    public static function getElementAttributes(
261
        \DOMElement $node
262
    ): iterable {
263
        /** @var \DOMAttr $attribute */
264 10
        foreach ($node->attributes as $attribute) {
265 10
            yield $attribute->name => $attribute->value;
266
        }
267
    }
268
269 5
    public static function toHtml(
270
        \DOMNode $node
271
    ): string {
272
        assert($node->ownerDocument instanceof \DOMDocument);
273
274 5
        $output = $node->ownerDocument->saveHTML($node);
275 5
        if (false === $output) {
276
            // @codeCoverageIgnoreStart
277
            throw new RuntimeException(sprintf(
278
                'Unable to convert %s to HTML',
279
                get_class($node)
280
            ));
281
            // @codeCoverageIgnoreEnd
282
        }
283
284 5
        return $output;
285
    }
286
287 7
    public static function toXml(
288
        \DOMNode $node,
289
        int $options = 0
290
    ): string {
291
        assert($node->ownerDocument instanceof \DOMDocument);
292
293 7
        $output = $node->ownerDocument->saveXML($node, $options);
294 7
        if (false === $output) {
295
            // @codeCoverageIgnoreStart
296
            throw new RuntimeException(sprintf(
297
                'Unable to convert %s to XML',
298
                get_class($node)
299
            ));
300
            // @codeCoverageIgnoreEnd
301
        }
302
303 7
        return $output;
304
    }
305
306 10
    private static function configureDocumentOptions(OptionsResolver $resolver = null): OptionsResolver
307
    {
308 1
        $resolver = $resolver ?? new OptionsResolver();
309
310 1
        $resolver->define('encoding')
311 1
            ->default('')
312 1
            ->allowedTypes('string')
313 1
            ->info('Encoding of the document, as specified by the XML declaration');
314
315 1
        $resolver->define('standalone')
316 1
            ->default(true)
317 1
            ->allowedTypes('bool')
318 1
            ->deprecated(
319 1
                'ocubom/twig-svg-extension',
320 1
                '1.0',
321 1
                'The option "standalone" is deprecated, use "xmlStandalone" instead.'
322 1
            )
323 1
            ->info('Whether or not the document is standalone, as specified by the XML declaration');
324
325 1
        $resolver->define('xmlStandalone')
326 1
            ->default(true)
327 1
            ->allowedTypes('null', 'bool')
328 1
            ->normalize(function (Options $options, ?bool $value): bool {
329 10
                return $value ?? (bool) $options
330 10
                    ->/* @scrutinizer ignore-call */ offsetGet('standalone', false);
331 1
            })
332 1
            ->info('Whether or not the document is standalone, as specified by the XML declaration');
333
334 1
        $resolver->define('version')
335 1
            ->default('1.0')
336 1
            ->allowedTypes('string')
337 1
            ->deprecated(
338 1
                'ocubom/twig-svg-extension',
339 1
                '1.0',
340 1
                'The option "version" is deprecated, use "xmlVersion" instead.'
341 1
            )
342 1
            ->info('The version number of this document, as specified by the XML declaration');
343
344 1
        $resolver->define('xmlVersion')
345 1
            ->default('1.0')
346 1
            ->allowedTypes('null', 'string')
347 1
            ->normalize(function (Options $options, ?string $value): string {
348 10
                return $value ?? (string) $options
349 10
                    ->/* @scrutinizer ignore-call */ offsetGet('version', false);
350 1
            })
351 1
            ->info('The version number of this document, as specified by the XML declaration');
352
353 1
        $resolver->define('strictErrorChecking')
354 1
            ->default(true)
355 1
            ->allowedTypes('bool')
356 1
            ->info('Throws \DOMException on errors');
357
358 1
        $resolver->define('documentURI')
359 1
            ->default(null)
360 1
            ->allowedTypes('null', 'string')
361 1
            ->info('The location of the document');
362
363 1
        $resolver->define('formatOutput')
364 1
            ->default(true) // LibXML default is false
365 1
            ->allowedTypes('bool')
366 1
            ->info('Nicely formats output with indentation and extra space');
367
368 1
        $resolver->define('validateOnParse')
369 1
            ->default(false)
370 1
            ->allowedTypes('bool')
371 1
            ->info('Loads and validates against the DTD');
372
373 1
        $resolver->define('resolveExternals')
374 1
            ->default(false)
375 1
            ->allowedTypes('bool')
376 1
            ->info('Load external entities from a doctype declaration');
377
378 1
        $resolver->define('preserveWhiteSpace')
379 1
            ->default(true)
380 1
            ->allowedTypes('bool')
381 1
            ->normalize(function (Options $options, bool $value): bool {
382
                // Must be false to enable formatOutput
383 10
                return $options['formatOutput'] ? false : $value;
384 1
            })
385 1
            ->info('Do not remove redundant white space');
386
387
        // LibXML specific
388 1
        $resolver->define('recover')
389 1
            ->default(false)
390 1
            ->allowedTypes('bool')
391 1
            ->info('Enables recovery mode');
392
393
        // LibXML specific
394 1
        $resolver->define('substituteEntities')
395 1
            ->default(false)
396 1
            ->allowedTypes('bool')
397 1
            ->info('Whether or not to substitute entities');
398
399 1
        return $resolver;
400
    }
401
402 6
    private static function configureIdentOptions(OptionsResolver $resolver = null): OptionsResolver
403
    {
404 1
        $resolver = $resolver ?? new OptionsResolver();
405
406 1
        $resolver->define('algo')
407 1
            ->default('sha512')
408 1
            ->allowedTypes('string')
409 1
            ->allowedValues(...hash_algos())
410 1
            ->info('Hash algorithm used to hash values');
411
412 1
        $resolver->define('format')
413 1
            ->default('%s')
414 1
            ->allowedTypes('string')
415 1
            ->allowedValues(function (string $value) {
416 6
                return str_contains($value, '%');
417 1
            })
418 1
            ->info('Native printf format string to generate final output. Must include %s');
419
420 1
        $resolver->define('base')
421 1
            ->default(62)
422 1
            ->allowedTypes('int')
423 1
            ->allowedValues(function (int $value) {
424 6
                return $value >= 2 && $value <= 62;
425 1
            })
426 1
            ->info('The base used to encode value to reduce its length or increase entropy');
427
428 1
        $resolver->define('length')
429 1
            ->default(7)
430 1
            ->allowedTypes('int')
431 1
            ->info('Length of the generated identifier (after base conversion)');
432
433 1
        return $resolver;
434
    }
435
}
436