5 use wcf\system\exception\SystemException
;
8 * Provides helper methods to work with PHP's DOM implementation.
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
18 * Moves all child nodes from given element into a document fragment.
20 * @param \DOMElement $element element
21 * @return \DOMDocumentFragment document fragment containing all child nodes from `$element`
23 public static function childNodesToFragment(\DOMElement
$element)
25 $fragment = $element->ownerDocument
->createDocumentFragment();
27 while ($element->hasChildNodes()) {
28 $fragment->appendChild($element->childNodes
->item(0));
35 * Returns true if `$ancestor` contains the node `$node`.
37 * @param \DOMNode $ancestor ancestor node
38 * @param \DOMNode $node node
39 * @return bool true if `$ancestor` contains the node `$node`
41 public static function contains(\DOMNode
$ancestor, \DOMNode
$node)
43 // nodes cannot contain themselves
44 if ($ancestor === $node) {
48 // text nodes cannot contain any other nodes
49 if ($ancestor->nodeType
=== \XML_TEXT_NODE
) {
54 while ($parent = $parent->parentNode
) {
55 if ($parent === $ancestor) {
64 * Returns a static list of child nodes of provided element.
66 * @param \DOMElement $element target element
67 * @return \DOMNode[] list of child nodes
69 public static function getChildNodes(\DOMElement
$element)
72 foreach ($element->childNodes
as $node) {
80 * Returns the common ancestor of both nodes.
82 * @param \DOMNode $node1 first node
83 * @param \DOMNode $node2 second node
84 * @return \DOMNode|null common ancestor or null
86 public static function getCommonAncestor(\DOMNode
$node1, \DOMNode
$node2)
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
;
94 // collect the list of all direct ancestors of `$node1`
95 $parents = self
::getParents($node1);
97 // compare each ancestor of `$node2` to the known list of parents of `$node1`
99 while ($parent = $parent->parentNode
) {
100 // requires strict type check
101 if (\
in_array($parent, $parents, true)) {
110 * Returns a non-live collection of elements.
112 * @param (\DOMDocument|\DOMElement) $context context element
113 * @param string $tagName tag name
114 * @return \DOMElement[] list of elements
115 * @throws SystemException
117 public static function getElements($context, $tagName)
119 if (!($context instanceof \DOMDocument
) && !($context instanceof \DOMElement
)) {
120 throw new SystemException("Expected context to be either of type \\DOMDocument or \\DOMElement.");
124 foreach ($context->getElementsByTagName($tagName) as $element) {
125 $elements[] = $element;
132 * Returns the immediate parent element before provided ancestor element. Returns null if
133 * the ancestor element is the direct parent of provided node.
135 * @param \DOMNode $node node
136 * @param \DOMElement $ancestor ancestor node
137 * @return \DOMElement|null immediate parent element before ancestor element
139 public static function getParentBefore(\DOMNode
$node, \DOMElement
$ancestor)
141 if ($node->parentNode
=== $ancestor) {
145 $parents = self
::getParents($node);
146 for ($i = \
count($parents) - 1; $i >= 0; $i--) {
147 if ($parents[$i] === $ancestor) {
148 return $parents[$i - 1];
152 throw new \
InvalidArgumentException("Provided node is a not a descendant of ancestor element.");
156 * Returns the parent node of given node.
158 * @param \DOMNode $node node
159 * @return \DOMNode parent node, can be `\DOMElement` or `\DOMDocument`
161 public static function getParentNode(\DOMNode
$node)
163 return $node->parentNode ?
: $node->ownerDocument
;
167 * Returns all ancestors nodes for given node.
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
173 public static function getParents(\DOMNode
$node, $reverseOrder = false)
178 while ($parent = $parent->parentNode
) {
179 $parents[] = $parent;
182 return $reverseOrder ? \array_reverse
($parents) : $parents;
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
190 * @param \DOMNode $node node
191 * @return \DOMElement[] list of parent elements
193 public static function getReadonlyParentTree(\DOMNode
$node)
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') {
203 $tree[] = $parent->cloneNode(false);
210 * Determines the relative position of two nodes to each other.
212 * @param \DOMNode $node1 first node
213 * @param \DOMNode $node2 second node
216 public static function getRelativePosition(\DOMNode
$node1, \DOMNode
$node2)
218 if ($node1->ownerDocument
!== $node2->ownerDocument
) {
219 throw new \
InvalidArgumentException("Both nodes must be contained in the same DOM document.");
222 $nodeList1 = self
::getParents($node1, true);
223 $nodeList1[] = $node1;
225 $nodeList2 = self
::getParents($node2, true);
226 $nodeList2[] = $node2;
229 while ($nodeList1[$i] === $nodeList2[$i]) {
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]) {
241 $nextSibling = $nodeList1[$i];
242 while ($nextSibling = $nextSibling->nextSibling
) {
243 if ($nextSibling === $nodeList2[$i]) {
248 throw new \
RuntimeException("Unable to determine relative node position.");
252 * Returns true if there is at least one parent with the provided tag name.
254 * @param \DOMElement $element start element
255 * @param string $tagName tag name to match
258 public static function hasParent(\DOMElement
$element, $tagName)
260 while ($element = $element->parentNode
) {
261 if ($element->nodeName
=== $tagName) {
270 * Inserts given DOM node after the reference node.
272 * @param \DOMNode $node node
273 * @param \DOMNode $refNode reference node
275 public static function insertAfter(\DOMNode
$node, \DOMNode
$refNode)
277 if ($refNode->nextSibling
) {
278 self
::insertBefore($node, $refNode->nextSibling
);
280 self
::getParentNode($refNode)->appendChild($node);
285 * Inserts given node before the reference node.
287 * @param \DOMNode $node node
288 * @param \DOMNode $refNode reference node
290 public static function insertBefore(\DOMNode
$node, \DOMNode
$refNode)
292 self
::getParentNode($refNode)->insertBefore($node, $refNode);
296 * Returns true if this node is empty.
298 * @param \DOMNode $node node
299 * @return bool true if node is empty
301 public static function isEmpty(\DOMNode
$node)
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)) {
309 } elseif ($node->hasChildNodes()) {
310 for ($i = 0, $length = $node->childNodes
->length
; $i < $length; $i++
) {
311 if (!self
::isEmpty($node->childNodes
->item($i))) {
324 * Returns true if given node is the first node of its given ancestor.
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
330 public static function isFirstNode(\DOMNode
$node, \DOMElement
$ancestor)
332 if ($node->previousSibling
=== null) {
333 if ($node->parentNode
=== $ancestor ||
$node->parentNode
->nodeName
=== 'body') {
336 return self
::isFirstNode($node->parentNode
, $ancestor);
338 } elseif ($node->parentNode
->nodeName
=== 'body') {
346 * Returns true if given node is the last node of its given ancestor.
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
352 public static function isLastNode(\DOMNode
$node, \DOMElement
$ancestor)
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') {
360 return self
::isLastNode($node->parentNode
, $ancestor);
362 } elseif ($node->parentNode
->nodeName
=== 'body') {
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.
374 * @param \DOMNode $node node
375 * @return bool true if node has been destroyed
377 public static function isRemoved(\DOMNode
$node)
379 return !isset($node->nodeType
);
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>`.
386 * @param \DOMElement $element element
387 * @return bool true if provided element is a void element
389 public static function isVoidElement(\DOMElement
$element)
393 '~^(area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$~',
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
408 * @param \DOMElement $container destination element
409 * @param \DOMElement $lastElement last element to move
410 * @param \DOMElement $commonAncestor common ancestor of `$container` and `$lastElement`
412 public static function moveNodesInto(\DOMElement
$container, \DOMElement
$lastElement, \DOMElement
$commonAncestor)
414 if (!self
::contains($commonAncestor, $container)) {
415 throw new \
InvalidArgumentException(
416 "The container element must be a child of the common ancestor element."
418 } elseif ($lastElement->parentNode
!== $commonAncestor) {
419 throw new \
InvalidArgumentException(
420 "The last element must be a direct child of the common ancestor element."
424 $relativePosition = self
::getRelativePosition($container, $lastElement);
426 // move everything that is logically after `$container` but within
427 // `$commonAncestor` into `$container` until `$lastElement` has been moved
428 $element = $container;
430 if ($relativePosition === 'before') {
431 while ($sibling = $element->previousSibling
) {
432 self
::prepend($sibling, $container);
433 if ($sibling === $lastElement) {
438 while ($sibling = $element->nextSibling
) {
439 $container->appendChild($sibling);
440 if ($sibling === $lastElement) {
446 $element = $element->parentNode
;
447 } while ($element !== $commonAncestor);
451 * Normalizes an element by joining adjacent text nodes.
453 * @param \DOMElement $element target element
455 public static function normalize(\DOMElement
$element)
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;
466 if ($lastTextNode === null) {
467 $lastTextNode = $childNode;
469 // merge with last text node
470 $newTextNode = $childNode
472 ->createTextNode($lastTextNode->textContent
. $childNode->textContent
);
473 $element->insertBefore($newTextNode, $lastTextNode);
475 $element->removeChild($lastTextNode);
476 $element->removeChild($childNode);
478 $lastTextNode = $newTextNode;
484 * Prepends a node to provided element.
486 * @param \DOMNode $node node
487 * @param \DOMElement $element target element
489 public static function prepend(\DOMNode
$node, \DOMElement
$element)
491 if ($element->firstChild
=== null) {
492 $element->appendChild($node);
494 $element->insertBefore($node, $element->firstChild
);
499 * Removes a node, optionally preserves the child nodes if `$node` is an element.
501 * @param \DOMNode $node target node
502 * @param bool $preserveChildNodes preserve child nodes, only supported for elements
504 public static function removeNode(\DOMNode
$node, $preserveChildNodes = false)
506 $parent = $node->parentNode ?
: $node->ownerDocument
;
508 if ($preserveChildNodes) {
509 if (!($node instanceof \DOMElement
)) {
510 throw new \
InvalidArgumentException("Preserving child nodes is only supported for DOMElement.");
514 foreach ($node->childNodes
as $childNode) {
515 $children[] = $childNode;
518 foreach ($children as $child) {
519 $parent->insertBefore($child, $node);
523 $parent->removeChild($node);
527 * Replaces a DOM element with another, preserving all child nodes by default.
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
533 public static function replaceElement(\DOMElement
$oldElement, \DOMElement
$newElement, $preserveChildNodes = true)
535 self
::insertBefore($newElement, $oldElement);
537 // move all child nodes
538 if ($preserveChildNodes) {
539 while ($oldElement->hasChildNodes()) {
540 $newElement->appendChild($oldElement->childNodes
->item(0));
544 // remove old element
545 self
::getParentNode($oldElement)->removeChild($oldElement);
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.
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`
559 public static function splitParentsUntil(\DOMNode
$node, \DOMElement
$ancestor, $splitBefore = true)
561 if (!self
::contains($ancestor, $node)) {
562 throw new \
InvalidArgumentException("Node is not contained in ancestor node.");
565 // clone the parent node right "below" `$ancestor`
566 $cloneNode = self
::getParentBefore($node, $ancestor);
569 if ($cloneNode === null) {
570 // target node is already a direct descendant of the ancestor
571 // node, no need to split anything
573 } elseif (self
::isFirstNode($node, $cloneNode)) {
574 // target node is at the very start, we can safely move the
575 // entire parent node around
579 $currentNode = $node;
580 while (($parent = $currentNode->parentNode
) !== $ancestor) {
581 /** @var \DOMElement $newNode */
582 $newNode = $parent->cloneNode();
583 self
::insertBefore($newNode, $parent);
585 while ($currentNode->previousSibling
) {
586 self
::prepend($currentNode->previousSibling
, $newNode);
589 $currentNode = $parent;
592 if ($cloneNode === null) {
593 // target node is already a direct descendant of the ancestor
594 // node, no need to split anything
596 } elseif (self
::isLastNode($node, $cloneNode)) {
597 // target node is at the very end, we can safely move the
598 // entire parent node around
602 $currentNode = $node;
603 while (($parent = $currentNode->parentNode
) !== $ancestor) {
604 $newNode = $parent->cloneNode();
605 self
::insertAfter($newNode, $parent);
607 while ($currentNode->nextSibling
) {
608 $newNode->appendChild($currentNode->nextSibling
);
611 $currentNode = $parent;
615 return self
::getParentBefore($node, $ancestor);
619 * Forbid creation of DOMUtil objects.
621 private function __construct()