3 use wcf\system\exception\SystemException
;
6 * Provides helper methods to work with PHP's DOM implementation.
8 * @author Alexander Ebert
9 * @copyright 2001-2019 WoltLab GmbH
10 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
11 * @package WoltLabSuite\Core\Util
15 * Moves all child nodes from given element into a document fragment.
17 * @param \DOMElement $element element
18 * @return \DOMDocumentFragment document fragment containing all child nodes from `$element`
20 public static function childNodesToFragment(\DOMElement
$element) {
21 $fragment = $element->ownerDocument
->createDocumentFragment();
23 while ($element->hasChildNodes()) {
24 $fragment->appendChild($element->childNodes
->item(0));
31 * Returns true if `$ancestor` contains the node `$node`.
33 * @param \DOMNode $ancestor ancestor node
34 * @param \DOMNode $node node
35 * @return boolean true if `$ancestor` contains the node `$node`
37 public static function contains(\DOMNode
$ancestor, \DOMNode
$node) {
38 // nodes cannot contain themselves
39 if ($ancestor === $node) {
43 // text nodes cannot contain any other nodes
44 if ($ancestor->nodeType
=== XML_TEXT_NODE
) {
49 while ($parent = $parent->parentNode
) {
50 if ($parent === $ancestor) {
59 * Returns a static list of child nodes of provided element.
61 * @param \DOMElement $element target element
62 * @return \DOMNode[] list of child nodes
64 public static function getChildNodes(\DOMElement
$element) {
66 foreach ($element->childNodes
as $node) {
74 * Returns the common ancestor of both nodes.
76 * @param \DOMNode $node1 first node
77 * @param \DOMNode $node2 second node
78 * @return \DOMNode|null common ancestor or null
80 public static function getCommonAncestor(\DOMNode
$node1, \DOMNode
$node2) {
81 // abort if both elements share a common element or are both direct descendants
82 // of the same document
83 if ($node1->parentNode
=== $node2->parentNode
) {
84 return $node1->parentNode
;
87 // collect the list of all direct ancestors of `$node1`
88 $parents = self
::getParents($node1);
90 // compare each ancestor of `$node2` to the known list of parents of `$node1`
92 while ($parent = $parent->parentNode
) {
93 // requires strict type check
94 if (in_array($parent, $parents, true)) {
103 * Returns a non-live collection of elements.
105 * @param (\DOMDocument|\DOMElement) $context context element
106 * @param string $tagName tag name
107 * @return \DOMElement[] list of elements
108 * @throws SystemException
110 public static function getElements($context, $tagName) {
111 if (!($context instanceof \DOMDocument
) && !($context instanceof \DOMElement
)) {
112 throw new SystemException("Expected context to be either of type \\DOMDocument or \\DOMElement.");
116 foreach ($context->getElementsByTagName($tagName) as $element) {
117 $elements[] = $element;
124 * Returns the immediate parent element before provided ancestor element. Returns null if
125 * the ancestor element is the direct parent of provided node.
127 * @param \DOMNode $node node
128 * @param \DOMElement $ancestor ancestor node
129 * @return \DOMElement|null immediate parent element before ancestor element
131 public static function getParentBefore(\DOMNode
$node, \DOMElement
$ancestor) {
132 if ($node->parentNode
=== $ancestor) {
136 $parents = self
::getParents($node);
137 for ($i = count($parents) - 1; $i >= 0; $i--) {
138 if ($parents[$i] === $ancestor) {
139 return $parents[$i - 1];
143 throw new \
InvalidArgumentException("Provided node is a not a descendant of ancestor element.");
147 * Returns the parent node of given node.
149 * @param \DOMNode $node node
150 * @return \DOMNode parent node, can be `\DOMElement` or `\DOMDocument`
152 public static function getParentNode(\DOMNode
$node) {
153 return $node->parentNode ?
: $node->ownerDocument
;
157 * Returns all ancestors nodes for given node.
159 * @param \DOMNode $node node
160 * @param boolean $reverseOrder reversing the order causes the most top ancestor to appear first
161 * @return \DOMElement[] list of ancestor nodes
163 public static function getParents(\DOMNode
$node, $reverseOrder = false) {
167 while ($parent = $parent->parentNode
) {
168 $parents[] = $parent;
171 return $reverseOrder ?
array_reverse($parents) : $parents;
175 * Returns a cloned parent tree that is virtually readonly. In fact it can be
176 * modified, but all changes are non permanent and do not affect the source
179 * @param \DOMNode $node node
180 * @return \DOMElement[] list of parent elements
182 public static function getReadonlyParentTree(\DOMNode
$node) {
184 /** @var \DOMElement $parent */
185 foreach (self
::getParents($node) as $parent) {
186 // do not include <body>, <html> and the document itself
187 if ($parent->nodeName
=== 'body') break;
189 $tree[] = $parent->cloneNode(false);
196 * Determines the relative position of two nodes to each other.
198 * @param \DOMNode $node1 first node
199 * @param \DOMNode $node2 second node
202 public static function getRelativePosition(\DOMNode
$node1, \DOMNode
$node2) {
203 if ($node1->ownerDocument
!== $node2->ownerDocument
) {
204 throw new \
InvalidArgumentException("Both nodes must be contained in the same DOM document.");
207 $nodeList1 = self
::getParents($node1, true);
208 $nodeList1[] = $node1;
210 $nodeList2 = self
::getParents($node2, true);
211 $nodeList2[] = $node2;
214 while ($nodeList1[$i] === $nodeList2[$i]) {
218 // check if parent of node 2 appears before parent of node 1
219 $previousSibling = $nodeList1[$i];
220 while ($previousSibling = $previousSibling->previousSibling
) {
221 if ($previousSibling === $nodeList2[$i]) {
226 $nextSibling = $nodeList1[$i];
227 while ($nextSibling = $nextSibling->nextSibling
) {
228 if ($nextSibling === $nodeList2[$i]) {
233 throw new \
RuntimeException("Unable to determine relative node position.");
237 * Returns true if there is at least one parent with the provided tag name.
239 * @param \DOMElement $element start element
240 * @param string $tagName tag name to match
243 public static function hasParent(\DOMElement
$element, $tagName) {
244 while ($element = $element->parentNode
) {
245 if ($element->nodeName
=== $tagName) {
254 * Inserts given DOM node after the reference node.
256 * @param \DOMNode $node node
257 * @param \DOMNode $refNode reference node
259 public static function insertAfter(\DOMNode
$node, \DOMNode
$refNode) {
260 if ($refNode->nextSibling
) {
261 self
::insertBefore($node, $refNode->nextSibling
);
264 self
::getParentNode($refNode)->appendChild($node);
269 * Inserts given node before the reference node.
271 * @param \DOMNode $node node
272 * @param \DOMNode $refNode reference node
274 public static function insertBefore(\DOMNode
$node, \DOMNode
$refNode) {
275 self
::getParentNode($refNode)->insertBefore($node, $refNode);
279 * Returns true if this node is empty.
281 * @param \DOMNode $node node
282 * @return boolean true if node is empty
284 public static function isEmpty(\DOMNode
$node) {
285 if ($node->nodeType
=== XML_TEXT_NODE
) {
286 return (StringUtil
::trim($node->nodeValue
) === '');
288 else if ($node->nodeType
=== XML_ELEMENT_NODE
) {
289 /** @var \DOMElement $node */
290 if (self
::isVoidElement($node)) {
293 else if ($node->hasChildNodes()) {
294 for ($i = 0, $length = $node->childNodes
->length
; $i < $length; $i++
) {
295 if (!self
::isEmpty($node->childNodes
->item($i))) {
308 * Returns true if given node is the first node of its given ancestor.
310 * @param \DOMNode $node node
311 * @param \DOMElement $ancestor ancestor element
312 * @return boolean true if `$node` is the first node of its given ancestor
314 public static function isFirstNode(\DOMNode
$node, \DOMElement
$ancestor) {
315 if ($node->previousSibling
=== null) {
316 if ($node->parentNode
=== $ancestor ||
$node->parentNode
->nodeName
=== 'body') {
320 return self
::isFirstNode($node->parentNode
, $ancestor);
323 else if ($node->parentNode
->nodeName
=== 'body') {
331 * Returns true if given node is the last node of its given ancestor.
333 * @param \DOMNode $node node
334 * @param \DOMElement $ancestor ancestor element
335 * @return boolean true if `$node` is the last node of its given ancestor
337 public static function isLastNode(\DOMNode
$node, \DOMElement
$ancestor) {
338 if ($node->nextSibling
=== null) {
339 if ($node->parentNode
=== null) {
340 throw new \
InvalidArgumentException("Provided node is a not a descendant of ancestor element.");
342 else if ($node->parentNode
=== $ancestor ||
$node->parentNode
->nodeName
=== 'body') {
346 return self
::isLastNode($node->parentNode
, $ancestor);
349 else if ($node->parentNode
->nodeName
=== 'body') {
357 * Nodes can get partially destroyed in which they're still an
358 * actual DOM node (such as \DOMElement) but almost their entire
359 * body is gone, including the `nodeType` attribute.
361 * @param \DOMNode $node node
362 * @return boolean true if node has been destroyed
364 public static function isRemoved(\DOMNode
$node) {
365 return !isset($node->nodeType
);
369 * Returns true if provided element is a void element. Void elements are elements
370 * that neither contain content nor have a closing tag, such as `<br>`.
372 * @param \DOMElement $element element
373 * @return boolean true if provided element is a void element
375 public static function isVoidElement(\DOMElement
$element) {
376 if (preg_match('~^(area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$~', $element->nodeName
)) {
384 * Moves all nodes into `$container` until it reaches `$lastElement`. The direction
385 * in which nodes will be considered for moving is determined by the logical position
388 * @param \DOMElement $container destination element
389 * @param \DOMElement $lastElement last element to move
390 * @param \DOMElement $commonAncestor common ancestor of `$container` and `$lastElement`
392 public static function moveNodesInto(\DOMElement
$container, \DOMElement
$lastElement, \DOMElement
$commonAncestor) {
393 if (!self
::contains($commonAncestor, $container)) {
394 throw new \
InvalidArgumentException("The container element must be a child of the common ancestor element.");
396 else if ($lastElement->parentNode
!== $commonAncestor) {
397 throw new \
InvalidArgumentException("The last element must be a direct child of the common ancestor element.");
400 $relativePosition = self
::getRelativePosition($container, $lastElement);
402 // move everything that is logically after `$container` but within
403 // `$commonAncestor` into `$container` until `$lastElement` has been moved
404 $element = $container;
406 if ($relativePosition === 'before') {
407 while ($sibling = $element->previousSibling
) {
408 self
::prepend($sibling, $container);
409 if ($sibling === $lastElement) {
415 while ($sibling = $element->nextSibling
) {
416 $container->appendChild($sibling);
417 if ($sibling === $lastElement) {
423 $element = $element->parentNode
;
425 while ($element !== $commonAncestor);
429 * Normalizes an element by joining adjacent text nodes.
431 * @param \DOMElement $element target element
433 public static function normalize(\DOMElement
$element) {
434 $childNodes = self
::getChildNodes($element);
435 /** @var \DOMNode $lastTextNode */
436 $lastTextNode = null;
437 foreach ($childNodes as $childNode) {
438 if ($childNode->nodeType
!== XML_TEXT_NODE
) {
439 $lastTextNode = null;
443 if ($lastTextNode === null) {
444 $lastTextNode = $childNode;
447 // merge with last text node
448 $newTextNode = $childNode->ownerDocument
->createTextNode($lastTextNode->textContent
. $childNode->textContent
);
449 $element->insertBefore($newTextNode, $lastTextNode);
451 $element->removeChild($lastTextNode);
452 $element->removeChild($childNode);
454 $lastTextNode = $newTextNode;
460 * Prepends a node to provided element.
462 * @param \DOMNode $node node
463 * @param \DOMElement $element target element
465 public static function prepend(\DOMNode
$node, \DOMElement
$element) {
466 if ($element->firstChild
=== null) {
467 $element->appendChild($node);
470 $element->insertBefore($node, $element->firstChild
);
475 * Removes a node, optionally preserves the child nodes if `$node` is an element.
477 * @param \DOMNode $node target node
478 * @param boolean $preserveChildNodes preserve child nodes, only supported for elements
480 public static function removeNode(\DOMNode
$node, $preserveChildNodes = false) {
481 if ($preserveChildNodes) {
482 if (!($node instanceof \DOMElement
)) {
483 throw new \
InvalidArgumentException("Preserving child nodes is only supported for DOMElement.");
486 while ($node->hasChildNodes()) {
487 self
::insertBefore($node->childNodes
->item(0), $node);
491 self
::getParentNode($node)->removeChild($node);
495 * Replaces a DOM element with another, preserving all child nodes by default.
497 * @param \DOMElement $oldElement old element
498 * @param \DOMElement $newElement new element
499 * @param boolean $preserveChildNodes true if child nodes should be moved, otherwise they'll be implicitly removed
501 public static function replaceElement(\DOMElement
$oldElement, \DOMElement
$newElement, $preserveChildNodes = true) {
502 self
::insertBefore($newElement, $oldElement);
504 // move all child nodes
505 if ($preserveChildNodes) {
506 while ($oldElement->hasChildNodes()) {
507 $newElement->appendChild($oldElement->childNodes
->item(0));
511 // remove old element
512 self
::getParentNode($oldElement)->removeChild($oldElement);
516 * Splits all parent nodes until `$ancestor` and moved other nodes after/before
517 * (determined by `$splitBefore`) into the newly created nodes. This allows
518 * extraction of DOM parts while preserving nesting for both the extracted nodes
519 * and the remaining siblings.
521 * @param \DOMNode $node reference node
522 * @param \DOMElement $ancestor ancestor element that should not be split
523 * @param boolean $splitBefore true if nodes before `$node` should be moved into a new node, false to split nodes after `$node`
524 * @return \DOMNode parent node containing `$node`, direct child of `$ancestor`
526 public static function splitParentsUntil(\DOMNode
$node, \DOMElement
$ancestor, $splitBefore = true) {
527 if (!self
::contains($ancestor, $node)) {
528 throw new \
InvalidArgumentException("Node is not contained in ancestor node.");
531 // clone the parent node right "below" `$ancestor`
532 $cloneNode = self
::getParentBefore($node, $ancestor);
535 if ($cloneNode === null) {
536 // target node is already a direct descendant of the ancestor
537 // node, no need to split anything
540 else if (self
::isFirstNode($node, $cloneNode)) {
541 // target node is at the very start, we can safely move the
542 // entire parent node around
546 $currentNode = $node;
547 while (($parent = $currentNode->parentNode
) !== $ancestor) {
548 /** @var \DOMElement $newNode */
549 $newNode = $parent->cloneNode();
550 self
::insertBefore($newNode, $parent);
552 while ($currentNode->previousSibling
) {
553 self
::prepend($currentNode->previousSibling
, $newNode);
556 $currentNode = $parent;
560 if ($cloneNode === null) {
561 // target node is already a direct descendant of the ancestor
562 // node, no need to split anything
565 else if (self
::isLastNode($node, $cloneNode)) {
566 // target node is at the very end, we can safely move the
567 // entire parent node around
571 $currentNode = $node;
572 while (($parent = $currentNode->parentNode
) !== $ancestor) {
573 $newNode = $parent->cloneNode();
574 self
::insertAfter($newNode, $parent);
576 while ($currentNode->nextSibling
) {
577 $newNode->appendChild($currentNode->nextSibling
);
580 $currentNode = $parent;
584 return self
::getParentBefore($node, $ancestor);
588 * Forbid creation of DOMUtil objects.
590 private function __construct() {