Add explicit `return null;` statements
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / util / DOMUtil.class.php
1 <?php
2
3 namespace wcf\util;
4
5 use wcf\system\exception\SystemException;
6
7 /**
8 * Provides helper methods to work with PHP's DOM implementation.
9 *
10 * @author Alexander Ebert
11 * @copyright 2001-2019 WoltLab GmbH
12 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
13 * @package WoltLabSuite\Core\Util
14 */
15 final class DOMUtil
16 {
17 /**
18 * Moves all child nodes from given element into a document fragment.
19 *
20 * @param \DOMElement $element element
21 * @return \DOMDocumentFragment document fragment containing all child nodes from `$element`
22 */
23 public static function childNodesToFragment(\DOMElement $element)
24 {
25 $fragment = $element->ownerDocument->createDocumentFragment();
26
27 while ($element->hasChildNodes()) {
28 $fragment->appendChild($element->childNodes->item(0));
29 }
30
31 return $fragment;
32 }
33
34 /**
35 * Returns true if `$ancestor` contains the node `$node`.
36 *
37 * @param \DOMNode $ancestor ancestor node
38 * @param \DOMNode $node node
39 * @return bool true if `$ancestor` contains the node `$node`
40 */
41 public static function contains(\DOMNode $ancestor, \DOMNode $node)
42 {
43 // nodes cannot contain themselves
44 if ($ancestor === $node) {
45 return false;
46 }
47
48 // text nodes cannot contain any other nodes
49 if ($ancestor->nodeType === \XML_TEXT_NODE) {
50 return false;
51 }
52
53 $parent = $node;
54 while ($parent = $parent->parentNode) {
55 if ($parent === $ancestor) {
56 return true;
57 }
58 }
59
60 return false;
61 }
62
63 /**
64 * Returns a static list of child nodes of provided element.
65 *
66 * @param \DOMElement $element target element
67 * @return \DOMNode[] list of child nodes
68 */
69 public static function getChildNodes(\DOMElement $element)
70 {
71 $nodes = [];
72 foreach ($element->childNodes as $node) {
73 $nodes[] = $node;
74 }
75
76 return $nodes;
77 }
78
79 /**
80 * Returns the common ancestor of both nodes.
81 *
82 * @param \DOMNode $node1 first node
83 * @param \DOMNode $node2 second node
84 * @return \DOMNode|null common ancestor or null
85 */
86 public static function getCommonAncestor(\DOMNode $node1, \DOMNode $node2)
87 {
88 // abort if both elements share a common element or are both direct descendants
89 // of the same document
90 if ($node1->parentNode === $node2->parentNode) {
91 return $node1->parentNode;
92 }
93
94 // collect the list of all direct ancestors of `$node1`
95 $parents = self::getParents($node1);
96
97 // compare each ancestor of `$node2` to the known list of parents of `$node1`
98 $parent = $node2;
99 while ($parent = $parent->parentNode) {
100 // requires strict type check
101 if (\in_array($parent, $parents, true)) {
102 return $parent;
103 }
104 }
105
106 return null;
107 }
108
109 /**
110 * Returns a non-live collection of elements.
111 *
112 * @param (\DOMDocument|\DOMElement) $context context element
113 * @param string $tagName tag name
114 * @return \DOMElement[] list of elements
115 * @throws SystemException
116 */
117 public static function getElements($context, $tagName)
118 {
119 if (!($context instanceof \DOMDocument) && !($context instanceof \DOMElement)) {
120 throw new SystemException("Expected context to be either of type \\DOMDocument or \\DOMElement.");
121 }
122
123 $elements = [];
124 foreach ($context->getElementsByTagName($tagName) as $element) {
125 $elements[] = $element;
126 }
127
128 return $elements;
129 }
130
131 /**
132 * Returns the immediate parent element before provided ancestor element. Returns null if
133 * the ancestor element is the direct parent of provided node.
134 *
135 * @param \DOMNode $node node
136 * @param \DOMElement $ancestor ancestor node
137 * @return \DOMElement|null immediate parent element before ancestor element
138 */
139 public static function getParentBefore(\DOMNode $node, \DOMElement $ancestor)
140 {
141 if ($node->parentNode === $ancestor) {
142 return null;
143 }
144
145 $parents = self::getParents($node);
146 for ($i = \count($parents) - 1; $i >= 0; $i--) {
147 if ($parents[$i] === $ancestor) {
148 return $parents[$i - 1];
149 }
150 }
151
152 throw new \InvalidArgumentException("Provided node is a not a descendant of ancestor element.");
153 }
154
155 /**
156 * Returns the parent node of given node.
157 *
158 * @param \DOMNode $node node
159 * @return \DOMNode parent node, can be `\DOMElement` or `\DOMDocument`
160 */
161 public static function getParentNode(\DOMNode $node)
162 {
163 return $node->parentNode ?: $node->ownerDocument;
164 }
165
166 /**
167 * Returns all ancestors nodes for given node.
168 *
169 * @param \DOMNode $node node
170 * @param bool $reverseOrder reversing the order causes the most top ancestor to appear first
171 * @return \DOMElement[] list of ancestor nodes
172 */
173 public static function getParents(\DOMNode $node, $reverseOrder = false)
174 {
175 $parents = [];
176
177 $parent = $node;
178 while ($parent = $parent->parentNode) {
179 $parents[] = $parent;
180 }
181
182 return $reverseOrder ? \array_reverse($parents) : $parents;
183 }
184
185 /**
186 * Returns a cloned parent tree that is virtually readonly. In fact it can be
187 * modified, but all changes are non permanent and do not affect the source
188 * document at all.
189 *
190 * @param \DOMNode $node node
191 * @return \DOMElement[] list of parent elements
192 */
193 public static function getReadonlyParentTree(\DOMNode $node)
194 {
195 $tree = [];
196 /** @var \DOMElement $parent */
197 foreach (self::getParents($node) as $parent) {
198 // do not include <body>, <html> and the document itself
199 if ($parent->nodeName === 'body') {
200 break;
201 }
202
203 $tree[] = $parent->cloneNode(false);
204 }
205
206 return $tree;
207 }
208
209 /**
210 * Determines the relative position of two nodes to each other.
211 *
212 * @param \DOMNode $node1 first node
213 * @param \DOMNode $node2 second node
214 * @return string
215 */
216 public static function getRelativePosition(\DOMNode $node1, \DOMNode $node2)
217 {
218 if ($node1->ownerDocument !== $node2->ownerDocument) {
219 throw new \InvalidArgumentException("Both nodes must be contained in the same DOM document.");
220 }
221
222 $nodeList1 = self::getParents($node1, true);
223 $nodeList1[] = $node1;
224
225 $nodeList2 = self::getParents($node2, true);
226 $nodeList2[] = $node2;
227
228 $i = 0;
229 while ($nodeList1[$i] === $nodeList2[$i]) {
230 $i++;
231 }
232
233 // check if parent of node 2 appears before parent of node 1
234 $previousSibling = $nodeList1[$i];
235 while ($previousSibling = $previousSibling->previousSibling) {
236 if ($previousSibling === $nodeList2[$i]) {
237 return 'before';
238 }
239 }
240
241 $nextSibling = $nodeList1[$i];
242 while ($nextSibling = $nextSibling->nextSibling) {
243 if ($nextSibling === $nodeList2[$i]) {
244 return 'after';
245 }
246 }
247
248 throw new \RuntimeException("Unable to determine relative node position.");
249 }
250
251 /**
252 * Returns true if there is at least one parent with the provided tag name.
253 *
254 * @param \DOMElement $element start element
255 * @param string $tagName tag name to match
256 * @return bool
257 */
258 public static function hasParent(\DOMElement $element, $tagName)
259 {
260 while ($element = $element->parentNode) {
261 if ($element->nodeName === $tagName) {
262 return true;
263 }
264 }
265
266 return false;
267 }
268
269 /**
270 * Inserts given DOM node after the reference node.
271 *
272 * @param \DOMNode $node node
273 * @param \DOMNode $refNode reference node
274 */
275 public static function insertAfter(\DOMNode $node, \DOMNode $refNode)
276 {
277 if ($refNode->nextSibling) {
278 self::insertBefore($node, $refNode->nextSibling);
279 } else {
280 self::getParentNode($refNode)->appendChild($node);
281 }
282 }
283
284 /**
285 * Inserts given node before the reference node.
286 *
287 * @param \DOMNode $node node
288 * @param \DOMNode $refNode reference node
289 */
290 public static function insertBefore(\DOMNode $node, \DOMNode $refNode)
291 {
292 self::getParentNode($refNode)->insertBefore($node, $refNode);
293 }
294
295 /**
296 * Returns true if this node is empty.
297 *
298 * @param \DOMNode $node node
299 * @return bool true if node is empty
300 */
301 public static function isEmpty(\DOMNode $node)
302 {
303 if ($node->nodeType === \XML_TEXT_NODE) {
304 return StringUtil::trim($node->nodeValue) === '';
305 } elseif ($node->nodeType === \XML_ELEMENT_NODE) {
306 /** @var \DOMElement $node */
307 if (self::isVoidElement($node)) {
308 return false;
309 } elseif ($node->hasChildNodes()) {
310 for ($i = 0, $length = $node->childNodes->length; $i < $length; $i++) {
311 if (!self::isEmpty($node->childNodes->item($i))) {
312 return false;
313 }
314 }
315 }
316
317 return true;
318 }
319
320 return true;
321 }
322
323 /**
324 * Returns true if given node is the first node of its given ancestor.
325 *
326 * @param \DOMNode $node node
327 * @param \DOMElement $ancestor ancestor element
328 * @return bool true if `$node` is the first node of its given ancestor
329 */
330 public static function isFirstNode(\DOMNode $node, \DOMElement $ancestor)
331 {
332 if ($node->previousSibling === null) {
333 if ($node->parentNode === $ancestor || $node->parentNode->nodeName === 'body') {
334 return true;
335 } else {
336 return self::isFirstNode($node->parentNode, $ancestor);
337 }
338 } elseif ($node->parentNode->nodeName === 'body') {
339 return true;
340 }
341
342 return false;
343 }
344
345 /**
346 * Returns true if given node is the last node of its given ancestor.
347 *
348 * @param \DOMNode $node node
349 * @param \DOMElement $ancestor ancestor element
350 * @return bool true if `$node` is the last node of its given ancestor
351 */
352 public static function isLastNode(\DOMNode $node, \DOMElement $ancestor)
353 {
354 if ($node->nextSibling === null) {
355 if ($node->parentNode === null) {
356 throw new \InvalidArgumentException("Provided node is a not a descendant of ancestor element.");
357 } elseif ($node->parentNode === $ancestor || $node->parentNode->nodeName === 'body') {
358 return true;
359 } else {
360 return self::isLastNode($node->parentNode, $ancestor);
361 }
362 } elseif ($node->parentNode->nodeName === 'body') {
363 return true;
364 }
365
366 return false;
367 }
368
369 /**
370 * Nodes can get partially destroyed in which they're still an
371 * actual DOM node (such as \DOMElement) but almost their entire
372 * body is gone, including the `nodeType` attribute.
373 *
374 * @param \DOMNode $node node
375 * @return bool true if node has been destroyed
376 */
377 public static function isRemoved(\DOMNode $node)
378 {
379 return !isset($node->nodeType);
380 }
381
382 /**
383 * Returns true if provided element is a void element. Void elements are elements
384 * that neither contain content nor have a closing tag, such as `<br>`.
385 *
386 * @param \DOMElement $element element
387 * @return bool true if provided element is a void element
388 */
389 public static function isVoidElement(\DOMElement $element)
390 {
391 if (
392 \preg_match(
393 '~^(area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$~',
394 $element->nodeName
395 )
396 ) {
397 return true;
398 }
399
400 return false;
401 }
402
403 /**
404 * Moves all nodes into `$container` until it reaches `$lastElement`. The direction
405 * in which nodes will be considered for moving is determined by the logical position
406 * of `$lastElement`.
407 *
408 * @param \DOMElement $container destination element
409 * @param \DOMElement $lastElement last element to move
410 * @param \DOMElement $commonAncestor common ancestor of `$container` and `$lastElement`
411 */
412 public static function moveNodesInto(\DOMElement $container, \DOMElement $lastElement, \DOMElement $commonAncestor)
413 {
414 if (!self::contains($commonAncestor, $container)) {
415 throw new \InvalidArgumentException(
416 "The container element must be a child of the common ancestor element."
417 );
418 } elseif ($lastElement->parentNode !== $commonAncestor) {
419 throw new \InvalidArgumentException(
420 "The last element must be a direct child of the common ancestor element."
421 );
422 }
423
424 $relativePosition = self::getRelativePosition($container, $lastElement);
425
426 // move everything that is logically after `$container` but within
427 // `$commonAncestor` into `$container` until `$lastElement` has been moved
428 $element = $container;
429 do {
430 if ($relativePosition === 'before') {
431 while ($sibling = $element->previousSibling) {
432 self::prepend($sibling, $container);
433 if ($sibling === $lastElement) {
434 return;
435 }
436 }
437 } else {
438 while ($sibling = $element->nextSibling) {
439 $container->appendChild($sibling);
440 if ($sibling === $lastElement) {
441 return;
442 }
443 }
444 }
445
446 $element = $element->parentNode;
447 } while ($element !== $commonAncestor);
448 }
449
450 /**
451 * Normalizes an element by joining adjacent text nodes.
452 *
453 * @param \DOMElement $element target element
454 */
455 public static function normalize(\DOMElement $element)
456 {
457 $childNodes = self::getChildNodes($element);
458 /** @var \DOMNode $lastTextNode */
459 $lastTextNode = null;
460 foreach ($childNodes as $childNode) {
461 if ($childNode->nodeType !== \XML_TEXT_NODE) {
462 $lastTextNode = null;
463 continue;
464 }
465
466 if ($lastTextNode === null) {
467 $lastTextNode = $childNode;
468 } else {
469 // merge with last text node
470 $newTextNode = $childNode
471 ->ownerDocument
472 ->createTextNode($lastTextNode->textContent . $childNode->textContent);
473 $element->insertBefore($newTextNode, $lastTextNode);
474
475 $element->removeChild($lastTextNode);
476 $element->removeChild($childNode);
477
478 $lastTextNode = $newTextNode;
479 }
480 }
481 }
482
483 /**
484 * Prepends a node to provided element.
485 *
486 * @param \DOMNode $node node
487 * @param \DOMElement $element target element
488 */
489 public static function prepend(\DOMNode $node, \DOMElement $element)
490 {
491 if ($element->firstChild === null) {
492 $element->appendChild($node);
493 } else {
494 $element->insertBefore($node, $element->firstChild);
495 }
496 }
497
498 /**
499 * Removes a node, optionally preserves the child nodes if `$node` is an element.
500 *
501 * @param \DOMNode $node target node
502 * @param bool $preserveChildNodes preserve child nodes, only supported for elements
503 */
504 public static function removeNode(\DOMNode $node, $preserveChildNodes = false)
505 {
506 $parent = $node->parentNode ?: $node->ownerDocument;
507
508 if ($preserveChildNodes) {
509 if (!($node instanceof \DOMElement)) {
510 throw new \InvalidArgumentException("Preserving child nodes is only supported for DOMElement.");
511 }
512
513 $children = [];
514 foreach ($node->childNodes as $childNode) {
515 $children[] = $childNode;
516 }
517
518 foreach ($children as $child) {
519 $parent->insertBefore($child, $node);
520 }
521 }
522
523 $parent->removeChild($node);
524 }
525
526 /**
527 * Replaces a DOM element with another, preserving all child nodes by default.
528 *
529 * @param \DOMElement $oldElement old element
530 * @param \DOMElement $newElement new element
531 * @param bool $preserveChildNodes true if child nodes should be moved, otherwise they'll be implicitly removed
532 */
533 public static function replaceElement(\DOMElement $oldElement, \DOMElement $newElement, $preserveChildNodes = true)
534 {
535 self::insertBefore($newElement, $oldElement);
536
537 // move all child nodes
538 if ($preserveChildNodes) {
539 while ($oldElement->hasChildNodes()) {
540 $newElement->appendChild($oldElement->childNodes->item(0));
541 }
542 }
543
544 // remove old element
545 self::getParentNode($oldElement)->removeChild($oldElement);
546 }
547
548 /**
549 * Splits all parent nodes until `$ancestor` and moved other nodes after/before
550 * (determined by `$splitBefore`) into the newly created nodes. This allows
551 * extraction of DOM parts while preserving nesting for both the extracted nodes
552 * and the remaining siblings.
553 *
554 * @param \DOMNode $node reference node
555 * @param \DOMElement $ancestor ancestor element that should not be split
556 * @param bool $splitBefore true if nodes before `$node` should be moved into a new node, false to split nodes after `$node`
557 * @return \DOMNode parent node containing `$node`, direct child of `$ancestor`
558 */
559 public static function splitParentsUntil(\DOMNode $node, \DOMElement $ancestor, $splitBefore = true)
560 {
561 if (!self::contains($ancestor, $node)) {
562 throw new \InvalidArgumentException("Node is not contained in ancestor node.");
563 }
564
565 // clone the parent node right "below" `$ancestor`
566 $cloneNode = self::getParentBefore($node, $ancestor);
567
568 if ($splitBefore) {
569 if ($cloneNode === null) {
570 // target node is already a direct descendant of the ancestor
571 // node, no need to split anything
572 return $node;
573 } elseif (self::isFirstNode($node, $cloneNode)) {
574 // target node is at the very start, we can safely move the
575 // entire parent node around
576 return $cloneNode;
577 }
578
579 $currentNode = $node;
580 while (($parent = $currentNode->parentNode) !== $ancestor) {
581 /** @var \DOMElement $newNode */
582 $newNode = $parent->cloneNode();
583 self::insertBefore($newNode, $parent);
584
585 while ($currentNode->previousSibling) {
586 self::prepend($currentNode->previousSibling, $newNode);
587 }
588
589 $currentNode = $parent;
590 }
591 } else {
592 if ($cloneNode === null) {
593 // target node is already a direct descendant of the ancestor
594 // node, no need to split anything
595 return $node;
596 } elseif (self::isLastNode($node, $cloneNode)) {
597 // target node is at the very end, we can safely move the
598 // entire parent node around
599 return $cloneNode;
600 }
601
602 $currentNode = $node;
603 while (($parent = $currentNode->parentNode) !== $ancestor) {
604 $newNode = $parent->cloneNode();
605 self::insertAfter($newNode, $parent);
606
607 while ($currentNode->nextSibling) {
608 $newNode->appendChild($currentNode->nextSibling);
609 }
610
611 $currentNode = $parent;
612 }
613 }
614
615 return self::getParentBefore($node, $ancestor);
616 }
617
618 /**
619 * Forbid creation of DOMUtil objects.
620 */
621 private function __construct()
622 {
623 // does nothing
624 }
625 }