Merge branch '3.1' into 5.2
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / util / DOMUtil.class.php
1 <?php
2 namespace wcf\util;
3 use wcf\system\exception\SystemException;
4
5 /**
6 * Provides helper methods to work with PHP's DOM implementation.
7 *
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
12 */
13 final class DOMUtil {
14 /**
15 * Moves all child nodes from given element into a document fragment.
16 *
17 * @param \DOMElement $element element
18 * @return \DOMDocumentFragment document fragment containing all child nodes from `$element`
19 */
20 public static function childNodesToFragment(\DOMElement $element) {
21 $fragment = $element->ownerDocument->createDocumentFragment();
22
23 while ($element->hasChildNodes()) {
24 $fragment->appendChild($element->childNodes->item(0));
25 }
26
27 return $fragment;
28 }
29
30 /**
31 * Returns true if `$ancestor` contains the node `$node`.
32 *
33 * @param \DOMNode $ancestor ancestor node
34 * @param \DOMNode $node node
35 * @return boolean true if `$ancestor` contains the node `$node`
36 */
37 public static function contains(\DOMNode $ancestor, \DOMNode $node) {
38 // nodes cannot contain themselves
39 if ($ancestor === $node) {
40 return false;
41 }
42
43 // text nodes cannot contain any other nodes
44 if ($ancestor->nodeType === XML_TEXT_NODE) {
45 return false;
46 }
47
48 $parent = $node;
49 while ($parent = $parent->parentNode) {
50 if ($parent === $ancestor) {
51 return true;
52 }
53 }
54
55 return false;
56 }
57
58 /**
59 * Returns a static list of child nodes of provided element.
60 *
61 * @param \DOMElement $element target element
62 * @return \DOMNode[] list of child nodes
63 */
64 public static function getChildNodes(\DOMElement $element) {
65 $nodes = [];
66 foreach ($element->childNodes as $node) {
67 $nodes[] = $node;
68 }
69
70 return $nodes;
71 }
72
73 /**
74 * Returns the common ancestor of both nodes.
75 *
76 * @param \DOMNode $node1 first node
77 * @param \DOMNode $node2 second node
78 * @return \DOMNode|null common ancestor or null
79 */
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;
85 }
86
87 // collect the list of all direct ancestors of `$node1`
88 $parents = self::getParents($node1);
89
90 // compare each ancestor of `$node2` to the known list of parents of `$node1`
91 $parent = $node2;
92 while ($parent = $parent->parentNode) {
93 // requires strict type check
94 if (in_array($parent, $parents, true)) {
95 return $parent;
96 }
97 }
98
99 return null;
100 }
101
102 /**
103 * Returns a non-live collection of elements.
104 *
105 * @param (\DOMDocument|\DOMElement) $context context element
106 * @param string $tagName tag name
107 * @return \DOMElement[] list of elements
108 * @throws SystemException
109 */
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.");
113 }
114
115 $elements = [];
116 foreach ($context->getElementsByTagName($tagName) as $element) {
117 $elements[] = $element;
118 }
119
120 return $elements;
121 }
122
123 /**
124 * Returns the immediate parent element before provided ancestor element. Returns null if
125 * the ancestor element is the direct parent of provided node.
126 *
127 * @param \DOMNode $node node
128 * @param \DOMElement $ancestor ancestor node
129 * @return \DOMElement|null immediate parent element before ancestor element
130 */
131 public static function getParentBefore(\DOMNode $node, \DOMElement $ancestor) {
132 if ($node->parentNode === $ancestor) {
133 return null;
134 }
135
136 $parents = self::getParents($node);
137 for ($i = count($parents) - 1; $i >= 0; $i--) {
138 if ($parents[$i] === $ancestor) {
139 return $parents[$i - 1];
140 }
141 }
142
143 throw new \InvalidArgumentException("Provided node is a not a descendant of ancestor element.");
144 }
145
146 /**
147 * Returns the parent node of given node.
148 *
149 * @param \DOMNode $node node
150 * @return \DOMNode parent node, can be `\DOMElement` or `\DOMDocument`
151 */
152 public static function getParentNode(\DOMNode $node) {
153 return $node->parentNode ?: $node->ownerDocument;
154 }
155
156 /**
157 * Returns all ancestors nodes for given node.
158 *
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
162 */
163 public static function getParents(\DOMNode $node, $reverseOrder = false) {
164 $parents = [];
165
166 $parent = $node;
167 while ($parent = $parent->parentNode) {
168 $parents[] = $parent;
169 }
170
171 return $reverseOrder ? array_reverse($parents) : $parents;
172 }
173
174 /**
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
177 * document at all.
178 *
179 * @param \DOMNode $node node
180 * @return \DOMElement[] list of parent elements
181 */
182 public static function getReadonlyParentTree(\DOMNode $node) {
183 $tree = [];
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;
188
189 $tree[] = $parent->cloneNode(false);
190 }
191
192 return $tree;
193 }
194
195 /**
196 * Determines the relative position of two nodes to each other.
197 *
198 * @param \DOMNode $node1 first node
199 * @param \DOMNode $node2 second node
200 * @return string
201 */
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.");
205 }
206
207 $nodeList1 = self::getParents($node1, true);
208 $nodeList1[] = $node1;
209
210 $nodeList2 = self::getParents($node2, true);
211 $nodeList2[] = $node2;
212
213 $i = 0;
214 while ($nodeList1[$i] === $nodeList2[$i]) {
215 $i++;
216 }
217
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]) {
222 return 'before';
223 }
224 }
225
226 $nextSibling = $nodeList1[$i];
227 while ($nextSibling = $nextSibling->nextSibling) {
228 if ($nextSibling === $nodeList2[$i]) {
229 return 'after';
230 }
231 }
232
233 throw new \RuntimeException("Unable to determine relative node position.");
234 }
235
236 /**
237 * Returns true if there is at least one parent with the provided tag name.
238 *
239 * @param \DOMElement $element start element
240 * @param string $tagName tag name to match
241 * @return boolean
242 */
243 public static function hasParent(\DOMElement $element, $tagName) {
244 while ($element = $element->parentNode) {
245 if ($element->nodeName === $tagName) {
246 return true;
247 }
248 }
249
250 return false;
251 }
252
253 /**
254 * Inserts given DOM node after the reference node.
255 *
256 * @param \DOMNode $node node
257 * @param \DOMNode $refNode reference node
258 */
259 public static function insertAfter(\DOMNode $node, \DOMNode $refNode) {
260 if ($refNode->nextSibling) {
261 self::insertBefore($node, $refNode->nextSibling);
262 }
263 else {
264 self::getParentNode($refNode)->appendChild($node);
265 }
266 }
267
268 /**
269 * Inserts given node before the reference node.
270 *
271 * @param \DOMNode $node node
272 * @param \DOMNode $refNode reference node
273 */
274 public static function insertBefore(\DOMNode $node, \DOMNode $refNode) {
275 self::getParentNode($refNode)->insertBefore($node, $refNode);
276 }
277
278 /**
279 * Returns true if this node is empty.
280 *
281 * @param \DOMNode $node node
282 * @return boolean true if node is empty
283 */
284 public static function isEmpty(\DOMNode $node) {
285 if ($node->nodeType === XML_TEXT_NODE) {
286 return (StringUtil::trim($node->nodeValue) === '');
287 }
288 else if ($node->nodeType === XML_ELEMENT_NODE) {
289 /** @var \DOMElement $node */
290 if (self::isVoidElement($node)) {
291 return false;
292 }
293 else if ($node->hasChildNodes()) {
294 for ($i = 0, $length = $node->childNodes->length; $i < $length; $i++) {
295 if (!self::isEmpty($node->childNodes->item($i))) {
296 return false;
297 }
298 }
299 }
300
301 return true;
302 }
303
304 return true;
305 }
306
307 /**
308 * Returns true if given node is the first node of its given ancestor.
309 *
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
313 */
314 public static function isFirstNode(\DOMNode $node, \DOMElement $ancestor) {
315 if ($node->previousSibling === null) {
316 if ($node->parentNode === $ancestor || $node->parentNode->nodeName === 'body') {
317 return true;
318 }
319 else {
320 return self::isFirstNode($node->parentNode, $ancestor);
321 }
322 }
323 else if ($node->parentNode->nodeName === 'body') {
324 return true;
325 }
326
327 return false;
328 }
329
330 /**
331 * Returns true if given node is the last node of its given ancestor.
332 *
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
336 */
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.");
341 }
342 else if ($node->parentNode === $ancestor || $node->parentNode->nodeName === 'body') {
343 return true;
344 }
345 else {
346 return self::isLastNode($node->parentNode, $ancestor);
347 }
348 }
349 else if ($node->parentNode->nodeName === 'body') {
350 return true;
351 }
352
353 return false;
354 }
355
356 /**
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.
360 *
361 * @param \DOMNode $node node
362 * @return boolean true if node has been destroyed
363 */
364 public static function isRemoved(\DOMNode $node) {
365 return !isset($node->nodeType);
366 }
367
368 /**
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>`.
371 *
372 * @param \DOMElement $element element
373 * @return boolean true if provided element is a void element
374 */
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)) {
377 return true;
378 }
379
380 return false;
381 }
382
383 /**
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
386 * of `$lastElement`.
387 *
388 * @param \DOMElement $container destination element
389 * @param \DOMElement $lastElement last element to move
390 * @param \DOMElement $commonAncestor common ancestor of `$container` and `$lastElement`
391 */
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.");
395 }
396 else if ($lastElement->parentNode !== $commonAncestor) {
397 throw new \InvalidArgumentException("The last element must be a direct child of the common ancestor element.");
398 }
399
400 $relativePosition = self::getRelativePosition($container, $lastElement);
401
402 // move everything that is logically after `$container` but within
403 // `$commonAncestor` into `$container` until `$lastElement` has been moved
404 $element = $container;
405 do {
406 if ($relativePosition === 'before') {
407 while ($sibling = $element->previousSibling) {
408 self::prepend($sibling, $container);
409 if ($sibling === $lastElement) {
410 return;
411 }
412 }
413 }
414 else {
415 while ($sibling = $element->nextSibling) {
416 $container->appendChild($sibling);
417 if ($sibling === $lastElement) {
418 return;
419 }
420 }
421 }
422
423 $element = $element->parentNode;
424 }
425 while ($element !== $commonAncestor);
426 }
427
428 /**
429 * Normalizes an element by joining adjacent text nodes.
430 *
431 * @param \DOMElement $element target element
432 */
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;
440 continue;
441 }
442
443 if ($lastTextNode === null) {
444 $lastTextNode = $childNode;
445 }
446 else {
447 // merge with last text node
448 $newTextNode = $childNode->ownerDocument->createTextNode($lastTextNode->textContent . $childNode->textContent);
449 $element->insertBefore($newTextNode, $lastTextNode);
450
451 $element->removeChild($lastTextNode);
452 $element->removeChild($childNode);
453
454 $lastTextNode = $newTextNode;
455 }
456 }
457 }
458
459 /**
460 * Prepends a node to provided element.
461 *
462 * @param \DOMNode $node node
463 * @param \DOMElement $element target element
464 */
465 public static function prepend(\DOMNode $node, \DOMElement $element) {
466 if ($element->firstChild === null) {
467 $element->appendChild($node);
468 }
469 else {
470 $element->insertBefore($node, $element->firstChild);
471 }
472 }
473
474 /**
475 * Removes a node, optionally preserves the child nodes if `$node` is an element.
476 *
477 * @param \DOMNode $node target node
478 * @param boolean $preserveChildNodes preserve child nodes, only supported for elements
479 */
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.");
484 }
485
486 while ($node->hasChildNodes()) {
487 self::insertBefore($node->childNodes->item(0), $node);
488 }
489 }
490
491 self::getParentNode($node)->removeChild($node);
492 }
493
494 /**
495 * Replaces a DOM element with another, preserving all child nodes by default.
496 *
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
500 */
501 public static function replaceElement(\DOMElement $oldElement, \DOMElement $newElement, $preserveChildNodes = true) {
502 self::insertBefore($newElement, $oldElement);
503
504 // move all child nodes
505 if ($preserveChildNodes) {
506 while ($oldElement->hasChildNodes()) {
507 $newElement->appendChild($oldElement->childNodes->item(0));
508 }
509 }
510
511 // remove old element
512 self::getParentNode($oldElement)->removeChild($oldElement);
513 }
514
515 /**
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.
520 *
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`
525 */
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.");
529 }
530
531 // clone the parent node right "below" `$ancestor`
532 $cloneNode = self::getParentBefore($node, $ancestor);
533
534 if ($splitBefore) {
535 if ($cloneNode === null) {
536 // target node is already a direct descendant of the ancestor
537 // node, no need to split anything
538 return $node;
539 }
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
543 return $cloneNode;
544 }
545
546 $currentNode = $node;
547 while (($parent = $currentNode->parentNode) !== $ancestor) {
548 /** @var \DOMElement $newNode */
549 $newNode = $parent->cloneNode();
550 self::insertBefore($newNode, $parent);
551
552 while ($currentNode->previousSibling) {
553 self::prepend($currentNode->previousSibling, $newNode);
554 }
555
556 $currentNode = $parent;
557 }
558 }
559 else {
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 }
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
568 return $cloneNode;
569 }
570
571 $currentNode = $node;
572 while (($parent = $currentNode->parentNode) !== $ancestor) {
573 $newNode = $parent->cloneNode();
574 self::insertAfter($newNode, $parent);
575
576 while ($currentNode->nextSibling) {
577 $newNode->appendChild($currentNode->nextSibling);
578 }
579
580 $currentNode = $parent;
581 }
582 }
583
584 return self::getParentBefore($node, $ancestor);
585 }
586
587 /**
588 * Forbid creation of DOMUtil objects.
589 */
590 private function __construct() {
591 // does nothing
592 }
593 }