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