DomUtil::configureIdentOptions()   A
last analyzed

Complexity

Conditions 2
Paths 1

Size

Total Lines 32
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 26
CRAP Score 2

Importance

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