Merge branch '5.3'
authorMatthias Schmidt <gravatronics@live.com>
Tue, 13 Apr 2021 12:08:45 +0000 (14:08 +0200)
committerMatthias Schmidt <gravatronics@live.com>
Tue, 13 Apr 2021 12:08:54 +0000 (14:08 +0200)
1  2 
com.woltlab.wcf/page.xml
wcfsetup/install/files/lib/system/clipboard/ClipboardHandler.class.php
wcfsetup/install/files/lib/system/html/node/AbstractHtmlNodeProcessor.class.php
wcfsetup/install/files/lib/system/html/output/node/HtmlOutputNodeProcessor.class.php
wcfsetup/install/files/lib/util/DOMUtil.class.php

Simple merge
index db2790b0d2aafc82c2683f24bb36392dec6363b9,a5158594d91e6e278f24dc0c86bba67face362fb..4e167837496d641ec248c140200eaec4a51fbb56
@@@ -17,457 -15,440 +17,462 @@@ use wcf\system\WCF
  
  /**
   * Handles clipboard-related actions.
 - * 
 - * @author    Alexander Ebert
 - * @copyright 2001-2019 WoltLab GmbH
 - * @license   GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 - * @package   WoltLabSuite\Core\System\Clipboard
 + *
 + * @author  Alexander Ebert
 + * @copyright   2001-2019 WoltLab GmbH
 + * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 + * @package WoltLabSuite\Core\System\Clipboard
   */
 -class ClipboardHandler extends SingletonFactory {
 -      /**
 -       * cached list of actions
 -       * @var array
 -       */
 -      protected $actionCache;
 -      
 -      /**
 -       * cached list of clipboard item types
 -       * @var mixed[][]
 -       */
 -      protected $cache;
 -      
 -      /**
 -       * list of marked items
 -       * @var DatabaseObject[][]
 -       */
 -      protected $markedItems;
 -      
 -      /**
 -       * cached list of page actions
 -       * @var array
 -       */
 -      protected $pageCache;
 -      
 -      /**
 -       * list of page class names
 -       * @var string[]
 -       */
 -      protected $pageClasses = [];
 -      
 -      /**
 -       * page object id
 -       * @var integer
 -       */
 -      protected $pageObjectID = 0;
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      protected function init() {
 -              $this->cache = [
 -                      'objectTypes' => [],
 -                      'objectTypeNames' => []
 -              ];
 -              $cache = ObjectTypeCache::getInstance()->getObjectTypes('com.woltlab.wcf.clipboardItem');
 -              foreach ($cache as $objectType) {
 -                      $this->cache['objectTypes'][$objectType->objectTypeID] = $objectType;
 -                      $this->cache['objectTypeNames'][$objectType->objectType] = $objectType->objectTypeID;
 -              }
 -              
 -              $this->pageCache = ClipboardPageCacheBuilder::getInstance()->getData();
 -      }
 -      
 -      /**
 -       * Loads action cache.
 -       */
 -      protected function loadActionCache() {
 -              if ($this->actionCache !== null) return;
 -              
 -              $this->actionCache = ClipboardActionCacheBuilder::getInstance()->getData();
 -      }
 -      
 -      /**
 -       * Marks objects as marked.
 -       * 
 -       * @param       array           $objectIDs
 -       * @param       integer         $objectTypeID
 -       */
 -      public function mark(array $objectIDs, $objectTypeID) {
 -              // remove existing entries first, prevents conflict with INSERT
 -              $this->unmark($objectIDs, $objectTypeID);
 -              
 -              $sql = "INSERT INTO     wcf".WCF_N."_clipboard_item
 -                                      (objectTypeID, userID, objectID)
 -                      VALUES          (?, ?, ?)";
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              foreach ($objectIDs as $objectID) {
 -                      $statement->execute([
 -                              $objectTypeID,
 -                              WCF::getUser()->userID,
 -                              $objectID
 -                      ]);
 -              }
 -      }
 -      
 -      /**
 -       * Removes an object marking.
 -       * 
 -       * @param       array           $objectIDs
 -       * @param       integer         $objectTypeID
 -       */
 -      public function unmark(array $objectIDs, $objectTypeID) {
 -              $conditions = new PreparedStatementConditionBuilder();
 -              $conditions->add("objectTypeID = ?", [$objectTypeID]);
 -              $conditions->add("objectID IN (?)", [$objectIDs]);
 -              $conditions->add("userID = ?", [WCF::getUser()->userID]);
 -              
 -              $sql = "DELETE FROM     wcf".WCF_N."_clipboard_item
 -                      ".$conditions;
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              $statement->execute($conditions->getParameters());
 -      }
 -      
 -      /**
 -       * Unmarks all items of given type.
 -       * 
 -       * @param       integer         $objectTypeID
 -       */
 -      public function unmarkAll($objectTypeID) {
 -              $sql = "DELETE FROM     wcf".WCF_N."_clipboard_item
 -                      WHERE           objectTypeID = ?
 -                                      AND userID = ?";
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              $statement->execute([
 -                      $objectTypeID,
 -                      WCF::getUser()->userID
 -              ]);
 -      }
 -      
 -      /**
 -       * Returns the id of the clipboard object type with the given name or `null` if no such
 -       * clipboard object type exists.
 -       * 
 -       * @param       string          $typeName
 -       * @return      integer|null
 -       */
 -      public function getObjectTypeID($typeName) {
 -              if (isset($this->cache['objectTypeNames'][$typeName])) {
 -                      return $this->cache['objectTypeNames'][$typeName];
 -              }
 -              
 -              return null;
 -      }
 -      
 -      /**
 -       * Returns the clipboard object type with the given id or `null` if no such
 -       * clipboard object type exists.
 -       * 
 -       * @param       integer         $objectTypeID
 -       * @return      ObjectType|null
 -       */
 -      public function getObjectType($objectTypeID) {
 -              if (isset($this->cache['objectTypes'][$objectTypeID])) {
 -                      return $this->cache['objectTypes'][$objectTypeID];
 -              }
 -              
 -              return null;
 -      }
 -      
 -      /**
 -       * Returns the id of the clipboard object type with the given name or `null` if no such
 -       * clipboard object type exists.
 -       * 
 -       * @param       string          $objectType
 -       * @return      integer|null
 -       */
 -      public function getObjectTypeByName($objectType) {
 -              foreach ($this->cache['objectTypes'] as $objectTypeID => $objectTypeObj) {
 -                      if ($objectTypeObj->objectType == $objectType) {
 -                              return $objectTypeID;
 -                      }
 -              }
 -              
 -              return null;
 -      }
 -      
 -      /**
 -       * Loads a list of marked items grouped by type name.
 -       * 
 -       * @param       integer         $objectTypeID
 -       * @throws      SystemException
 -       */
 -      protected function loadMarkedItems($objectTypeID = null) {
 -              if ($this->markedItems === null) {
 -                      $this->markedItems = [];
 -              }
 -              
 -              if ($objectTypeID !== null) {
 -                      $objectType = $this->getObjectType($objectTypeID);
 -                      if ($objectType === null) {
 -                              throw new SystemException("object type id ".$objectTypeID." is invalid");
 -                      }
 -                      
 -                      if (!isset($this->markedItems[$objectType->objectType])) {
 -                              $this->markedItems[$objectType->objectType] = [];
 -                      }
 -              }
 -              
 -              $conditions = new PreparedStatementConditionBuilder();
 -              $conditions->add("userID = ?", [WCF::getUser()->userID]);
 -              if ($objectTypeID !== null) {
 -                      $conditions->add("objectTypeID = ?", [$objectTypeID]);
 -              }
 -              
 -              // fetch object ids
 -              $sql = "SELECT  objectTypeID, objectID
 -                      FROM    wcf".WCF_N."_clipboard_item
 -                      ".$conditions;
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              $statement->execute($conditions->getParameters());
 -              
 -              // group object ids by type name
 -              $data = [];
 -              while ($row = $statement->fetchArray()) {
 -                      $objectType = $this->getObjectType($row['objectTypeID']);
 -                      if ($objectType === null) {
 -                              continue;
 -                      }
 -                      
 -                      if (!isset($data[$objectType->objectType])) {
 -                              /** @noinspection PhpUndefinedFieldInspection */
 -                              $listClassName = $objectType->listclassname;
 -                              if ($listClassName == '') {
 -                                      throw new SystemException("Missing list class for object type '".$objectType->objectType."'");
 -                              }
 -                              
 -                              $data[$objectType->objectType] = [
 -                                      'className' => $listClassName,
 -                                      'objectIDs' => []
 -                              ];
 -                      }
 -                      
 -                      $data[$objectType->objectType]['objectIDs'][] = $row['objectID'];
 -              }
 -              
 -              // read objects
 -              foreach ($data as $objectType => $objectData) {
 -                      /** @var DatabaseObjectList $objectList */
 -                      $objectList = new $objectData['className']();
 -                      $objectList->getConditionBuilder()->add($objectList->getDatabaseTableAlias() . "." . $objectList->getDatabaseTableIndexName() . " IN (?)", [$objectData['objectIDs']]);
 -                      $objectList->readObjects();
 -                      
 -                      $this->markedItems[$objectType] = $objectList->getObjects();
 -                      
 -                      // validate object ids against loaded items (check for zombie object ids)
 -                      $indexName = $objectList->getDatabaseTableIndexName();
 -                      foreach ($this->markedItems[$objectType] as $object) {
 -                              /** @noinspection PhpVariableVariableInspection */
 -                              $index = array_search($object->$indexName, $objectData['objectIDs']);
 -                              unset($objectData['objectIDs'][$index]);
 -                      }
 -                      
 -                      if (!empty($objectData['objectIDs'])) {
 -                              $conditions = new PreparedStatementConditionBuilder();
 -                              $conditions->add("objectTypeID = ?", [$this->getObjectTypeByName($objectType)]);
 -                              $conditions->add("userID = ?", [WCF::getUser()->userID]);
 -                              $conditions->add("objectID IN (?)", [$objectData['objectIDs']]);
 -                              
 -                              $sql = "DELETE FROM     wcf".WCF_N."_clipboard_item
 -                                      ".$conditions;
 -                              $statement = WCF::getDB()->prepareStatement($sql);
 -                              $statement->execute($conditions->getParameters());
 -                      }
 -              }
 -      }
 -      
 -      /**
 -       * Loads a list of marked items grouped by type name.
 -       * 
 -       * @param       integer         $objectTypeID
 -       * @return      array
 -       */
 -      public function getMarkedItems($objectTypeID = null) {
 -              if ($this->markedItems === null) {
 -                      $this->loadMarkedItems($objectTypeID);
 -              }
 -              
 -              if ($objectTypeID !== null) {
 -                      $objectType = $this->getObjectType($objectTypeID);
 -                      if (!isset($this->markedItems[$objectType->objectType])) {
 -                              $this->loadMarkedItems($objectTypeID);
 -                      }
 -                      
 -                      return $this->markedItems[$objectType->objectType];
 -              }
 -              
 -              return $this->markedItems;
 -      }
 -      
 -      /**
 -       * Returns the data of the items for clipboard editor or `null` if no items are marked.
 -       * 
 -       * @param       string|string[]         $page
 -       * @param       integer                 $pageObjectID
 -       * @return      array|null
 -       * @throws      ImplementationException
 -       */
 -      public function getEditorItems($page, $pageObjectID) {
 -              $pages = $page;
 -              if (!is_array($pages)) {
 -                      $pages = [$page];
 -              }
 -              
 -              $this->pageClasses = [];
 -              $this->pageObjectID = 0;
 -              
 -              // get objects
 -              $this->loadMarkedItems();
 -              if (empty($this->markedItems)) return null;
 -              
 -              $this->pageClasses = $pages;
 -              $this->pageObjectID = $pageObjectID;
 -              
 -              // fetch action ids
 -              $this->loadActionCache();
 -              $actionIDs = [];
 -              foreach ($pages as $page) {
 -                      foreach ($this->pageCache[$page] as $actionID) {
 -                              if (isset($this->actionCache[$actionID])) {
 -                                      $actionIDs[] = $actionID;
 -                              }
 -                      }
 -              }
 -              $actionIDs = array_unique($actionIDs);
 -              
 -              // load actions
 -              $actions = [];
 -              foreach ($actionIDs as $actionID) {
 -                      $actionObject = $this->actionCache[$actionID];
 -                      $actionClassName = $actionObject->actionClassName;
 -                      if (!isset($actions[$actionClassName])) {
 -                              // validate class
 -                              if (!is_subclass_of($actionClassName, IClipboardAction::class)) {
 -                                      throw new ImplementationException($actionClassName, IClipboardAction::class);
 -                              }
 -                              
 -                              $actions[$actionClassName] = [
 -                                      'actions' => [],
 -                                      'object' => new $actionClassName()
 -                              ];
 -                      }
 -                      
 -                      $actions[$actionClassName]['actions'][] = $actionObject;
 -              }
 -              
 -              // execute actions
 -              $editorData = [];
 -              foreach ($actions as $actionData) {
 -                      /** @var IClipboardAction $clipboardAction */
 -                      $clipboardAction = $actionData['object'];
 -                      
 -                      // get accepted objects
 -                      $typeName = $clipboardAction->getTypeName();
 -                      if (!isset($this->markedItems[$typeName]) || empty($this->markedItems[$typeName])) continue;
 -                      
 -                      if (!isset($editorData[$typeName])) {
 -                              $editorData[$typeName] = [
 -                                      'label' => $clipboardAction->getEditorLabel($this->markedItems[$typeName]),
 -                                      'items' => [],
 -                                      'reloadPageOnSuccess' => $clipboardAction->getReloadPageOnSuccess()
 -                              ];
 -                      }
 -                      else {
 -                              $editorData[$typeName]['reloadPageOnSuccess'] = array_unique(array_merge(
 -                                      $editorData[$typeName]['reloadPageOnSuccess'],
 -                                      $clipboardAction->getReloadPageOnSuccess()
 -                              ));
 -                      }
 -                      
 -                      foreach ($actionData['actions'] as $actionObject) {
 -                              $data = $clipboardAction->execute($this->markedItems[$typeName], $actionObject);
 -                              if ($data === null) {
 -                                      continue;
 -                              }
 -                              
 -                              $editorData[$typeName]['items'][$actionObject->showOrder] = $data;
 -                      }
 -              }
 -              
 -              return $editorData;
 -      }
 -      
 -      /**
 -       * Removes items from clipboard.
 -       * 
 -       * @param       integer         $typeID
 -       */
 -      public function removeItems($typeID = null) {
 -              $conditions = new PreparedStatementConditionBuilder();
 -              $conditions->add("userID = ?", [WCF::getUser()->userID]);
 -              if ($typeID !== null) $conditions->add("objectTypeID = ?", [$typeID]);
 -              
 -              $sql = "DELETE FROM     wcf".WCF_N."_clipboard_item
 -                      ".$conditions;
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              $statement->execute($conditions->getParameters());
 -      }
 -      
 -      /**
 -       * Returns true (1) if at least one item (of the given object type) is marked.
 -       * 
 -       * @param       integer         $objectTypeID
 -       * @return      integer
 -       */
 -      public function hasMarkedItems($objectTypeID = null) {
 -              if (!WCF::getUser()->userID) return 0;
 -              
 -              $conditionBuilder = new PreparedStatementConditionBuilder();
 -              $conditionBuilder->add("userID = ?", [WCF::getUser()->userID]);
 -              if ($objectTypeID !== null) {
 -                      $conditionBuilder->add("objectTypeID = ?", [$objectTypeID]);
 -              }
 -              
 -              $sql = "SELECT  COUNT(*)
 -                      FROM    wcf".WCF_N."_clipboard_item
 -                      ".$conditionBuilder;
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              $statement->execute($conditionBuilder->getParameters());
 -              
 -              return $statement->fetchSingleColumn() ? 1 : 0;
 -      }
 -      
 -      /**
 -       * Returns the list of page class names.
 -       * 
 -       * @return      string[]
 -       */
 -      public function getPageClasses() {
 -              return $this->pageClasses;
 -      }
 -      
 -      /**
 -       * Returns page object id.
 -       * 
 -       * @return      integer
 -       */
 -      public function getPageObjectID() {
 -              return $this->pageObjectID;
 -      }
 +class ClipboardHandler extends SingletonFactory
 +{
 +    /**
 +     * cached list of actions
 +     * @var array
 +     */
 +    protected $actionCache;
 +
 +    /**
 +     * cached list of clipboard item types
 +     * @var mixed[][]
 +     */
 +    protected $cache;
 +
 +    /**
 +     * list of marked items
 +     * @var DatabaseObject[][]
 +     */
 +    protected $markedItems;
 +
 +    /**
 +     * cached list of page actions
 +     * @var array
 +     */
 +    protected $pageCache;
 +
 +    /**
 +     * list of page class names
 +     * @var string[]
 +     */
 +    protected $pageClasses = [];
 +
 +    /**
 +     * page object id
 +     * @var int
 +     */
 +    protected $pageObjectID = 0;
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    protected function init()
 +    {
 +        $this->cache = [
 +            'objectTypes' => [],
 +            'objectTypeNames' => [],
 +        ];
 +        $cache = ObjectTypeCache::getInstance()->getObjectTypes('com.woltlab.wcf.clipboardItem');
 +        foreach ($cache as $objectType) {
 +            $this->cache['objectTypes'][$objectType->objectTypeID] = $objectType;
 +            $this->cache['objectTypeNames'][$objectType->objectType] = $objectType->objectTypeID;
 +        }
 +
 +        $this->pageCache = ClipboardPageCacheBuilder::getInstance()->getData();
 +    }
 +
 +    /**
 +     * Loads action cache.
 +     */
 +    protected function loadActionCache()
 +    {
 +        if ($this->actionCache !== null) {
 +            return;
 +        }
 +
 +        $this->actionCache = ClipboardActionCacheBuilder::getInstance()->getData();
 +    }
 +
 +    /**
 +     * Marks objects as marked.
 +     *
 +     * @param array $objectIDs
 +     * @param int $objectTypeID
 +     */
 +    public function mark(array $objectIDs, $objectTypeID)
 +    {
 +        // remove existing entries first, prevents conflict with INSERT
 +        $this->unmark($objectIDs, $objectTypeID);
 +
 +        $sql = "INSERT INTO wcf" . WCF_N . "_clipboard_item
 +                            (objectTypeID, userID, objectID)
 +                VALUES      (?, ?, ?)";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +        foreach ($objectIDs as $objectID) {
 +            $statement->execute([
 +                $objectTypeID,
 +                WCF::getUser()->userID,
 +                $objectID,
 +            ]);
 +        }
 +    }
 +
 +    /**
 +     * Removes an object marking.
 +     *
 +     * @param array $objectIDs
 +     * @param int $objectTypeID
 +     */
 +    public function unmark(array $objectIDs, $objectTypeID)
 +    {
 +        $conditions = new PreparedStatementConditionBuilder();
 +        $conditions->add("objectTypeID = ?", [$objectTypeID]);
 +        $conditions->add("objectID IN (?)", [$objectIDs]);
 +        $conditions->add("userID = ?", [WCF::getUser()->userID]);
 +
 +        $sql = "DELETE FROM wcf" . WCF_N . "_clipboard_item
 +                " . $conditions;
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +        $statement->execute($conditions->getParameters());
 +    }
 +
 +    /**
 +     * Unmarks all items of given type.
 +     *
 +     * @param int $objectTypeID
 +     */
 +    public function unmarkAll($objectTypeID)
 +    {
 +        $sql = "DELETE FROM wcf" . WCF_N . "_clipboard_item
 +                WHERE       objectTypeID = ?
 +                        AND userID = ?";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +        $statement->execute([
 +            $objectTypeID,
 +            WCF::getUser()->userID,
 +        ]);
 +    }
 +
 +    /**
 +     * Returns the id of the clipboard object type with the given name or `null` if no such
 +     * clipboard object type exists.
 +     *
 +     * @param string $typeName
 +     * @return  int|null
 +     */
 +    public function getObjectTypeID($typeName)
 +    {
 +        if (isset($this->cache['objectTypeNames'][$typeName])) {
 +            return $this->cache['objectTypeNames'][$typeName];
 +        }
 +    }
 +
 +    /**
 +     * Returns the clipboard object type with the given id or `null` if no such
 +     * clipboard object type exists.
 +     *
 +     * @param int $objectTypeID
 +     * @return  ObjectType|null
 +     */
 +    public function getObjectType($objectTypeID)
 +    {
 +        if (isset($this->cache['objectTypes'][$objectTypeID])) {
 +            return $this->cache['objectTypes'][$objectTypeID];
 +        }
 +    }
 +
 +    /**
 +     * Returns the id of the clipboard object type with the given name or `null` if no such
 +     * clipboard object type exists.
 +     *
 +     * @param string $objectType
 +     * @return  int|null
 +     */
 +    public function getObjectTypeByName($objectType)
 +    {
 +        foreach ($this->cache['objectTypes'] as $objectTypeID => $objectTypeObj) {
 +            if ($objectTypeObj->objectType == $objectType) {
 +                return $objectTypeID;
 +            }
 +        }
 +    }
 +
 +    /**
 +     * Loads a list of marked items grouped by type name.
 +     *
 +     * @param int $objectTypeID
 +     * @throws  SystemException
 +     */
 +    protected function loadMarkedItems($objectTypeID = null)
 +    {
 +        if ($this->markedItems === null) {
 +            $this->markedItems = [];
 +        }
 +
 +        if ($objectTypeID !== null) {
 +            $objectType = $this->getObjectType($objectTypeID);
 +            if ($objectType === null) {
 +                throw new SystemException("object type id " . $objectTypeID . " is invalid");
 +            }
 +
 +            if (!isset($this->markedItems[$objectType->objectType])) {
 +                $this->markedItems[$objectType->objectType] = [];
 +            }
 +        }
 +
 +        $conditions = new PreparedStatementConditionBuilder();
 +        $conditions->add("userID = ?", [WCF::getUser()->userID]);
 +        if ($objectTypeID !== null) {
 +            $conditions->add("objectTypeID = ?", [$objectTypeID]);
 +        }
 +
 +        // fetch object ids
 +        $sql = "SELECT  objectTypeID, objectID
 +                FROM    wcf" . WCF_N . "_clipboard_item
 +                " . $conditions;
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +        $statement->execute($conditions->getParameters());
 +
 +        // group object ids by type name
 +        $data = [];
 +        while ($row = $statement->fetchArray()) {
 +            $objectType = $this->getObjectType($row['objectTypeID']);
 +            if ($objectType === null) {
 +                continue;
 +            }
 +
 +            if (!isset($data[$objectType->objectType])) {
 +                /** @noinspection PhpUndefinedFieldInspection */
 +                $listClassName = $objectType->listclassname;
 +                if ($listClassName == '') {
 +                    throw new SystemException("Missing list class for object type '" . $objectType->objectType . "'");
 +                }
 +
 +                $data[$objectType->objectType] = [
 +                    'className' => $listClassName,
 +                    'objectIDs' => [],
 +                ];
 +            }
 +
 +            $data[$objectType->objectType]['objectIDs'][] = $row['objectID'];
 +        }
 +
 +        // read objects
 +        foreach ($data as $objectType => $objectData) {
 +            /** @var DatabaseObjectList $objectList */
 +            $objectList = new $objectData['className']();
 +            $objectList->getConditionBuilder()->add(
 +                $objectList->getDatabaseTableAlias() . "." . $objectList->getDatabaseTableIndexName() . " IN (?)",
 +                [$objectData['objectIDs']]
 +            );
 +            $objectList->readObjects();
 +
 +            $this->markedItems[$objectType] = $objectList->getObjects();
 +
 +            // validate object ids against loaded items (check for zombie object ids)
 +            $indexName = $objectList->getDatabaseTableIndexName();
 +            foreach ($this->markedItems[$objectType] as $object) {
 +                /** @noinspection PhpVariableVariableInspection */
 +                $index = \array_search($object->{$indexName}, $objectData['objectIDs']);
 +                unset($objectData['objectIDs'][$index]);
 +            }
 +
 +            if (!empty($objectData['objectIDs'])) {
 +                $conditions = new PreparedStatementConditionBuilder();
 +                $conditions->add("objectTypeID = ?", [$this->getObjectTypeByName($objectType)]);
 +                $conditions->add("userID = ?", [WCF::getUser()->userID]);
 +                $conditions->add("objectID IN (?)", [$objectData['objectIDs']]);
 +
 +                $sql = "DELETE FROM wcf" . WCF_N . "_clipboard_item
 +                        " . $conditions;
 +                $statement = WCF::getDB()->prepareStatement($sql);
 +                $statement->execute($conditions->getParameters());
 +            }
 +        }
 +    }
 +
 +    /**
 +     * Loads a list of marked items grouped by type name.
 +     *
 +     * @param int $objectTypeID
 +     * @return  array
 +     */
 +    public function getMarkedItems($objectTypeID = null)
 +    {
 +        if ($this->markedItems === null) {
 +            $this->loadMarkedItems($objectTypeID);
 +        }
 +
 +        if ($objectTypeID !== null) {
 +            $objectType = $this->getObjectType($objectTypeID);
 +            if (!isset($this->markedItems[$objectType->objectType])) {
 +                $this->loadMarkedItems($objectTypeID);
 +            }
 +
 +            return $this->markedItems[$objectType->objectType];
 +        }
 +
 +        return $this->markedItems;
 +    }
 +
 +    /**
 +     * Returns the data of the items for clipboard editor or `null` if no items are marked.
 +     *
 +     * @param string|string[] $page
 +     * @param int $pageObjectID
 +     * @return  array|null
 +     * @throws  ImplementationException
 +     */
 +    public function getEditorItems($page, $pageObjectID)
 +    {
 +        $pages = $page;
 +        if (!\is_array($pages)) {
 +            $pages = [$page];
 +        }
 +
 +        $this->pageClasses = [];
 +        $this->pageObjectID = 0;
 +
 +        // get objects
 +        $this->loadMarkedItems();
 +        if (empty($this->markedItems)) {
 +            return;
 +        }
 +
 +        $this->pageClasses = $pages;
 +        $this->pageObjectID = $pageObjectID;
 +
 +        // fetch action ids
 +        $this->loadActionCache();
 +        $actionIDs = [];
 +        foreach ($pages as $page) {
 +            foreach ($this->pageCache[$page] as $actionID) {
 +                if (isset($this->actionCache[$actionID])) {
 +                    $actionIDs[] = $actionID;
 +                }
 +            }
 +        }
 +        $actionIDs = \array_unique($actionIDs);
 +
 +        // load actions
 +        $actions = [];
 +        foreach ($actionIDs as $actionID) {
 +            $actionObject = $this->actionCache[$actionID];
 +            $actionClassName = $actionObject->actionClassName;
 +            if (!isset($actions[$actionClassName])) {
 +                // validate class
 +                if (!\is_subclass_of($actionClassName, IClipboardAction::class)) {
 +                    throw new ImplementationException($actionClassName, IClipboardAction::class);
 +                }
 +
 +                $actions[$actionClassName] = [
 +                    'actions' => [],
 +                    'object' => new $actionClassName(),
 +                ];
 +            }
 +
 +            $actions[$actionClassName]['actions'][] = $actionObject;
 +        }
 +
 +        // execute actions
 +        $editorData = [];
 +        foreach ($actions as $actionData) {
 +            /** @var IClipboardAction $clipboardAction */
 +            $clipboardAction = $actionData['object'];
 +
 +            // get accepted objects
 +            $typeName = $clipboardAction->getTypeName();
 +            if (!isset($this->markedItems[$typeName]) || empty($this->markedItems[$typeName])) {
 +                continue;
 +            }
 +
 +            if (!isset($editorData[$typeName])) {
 +                $editorData[$typeName] = [
 +                    'label' => $clipboardAction->getEditorLabel($this->markedItems[$typeName]),
 +                    'items' => [],
 +                    'reloadPageOnSuccess' => $clipboardAction->getReloadPageOnSuccess(),
 +                ];
++            } else {
++                $editorData[$typeName]['reloadPageOnSuccess'] = array_unique(array_merge(
++                    $editorData[$typeName]['reloadPageOnSuccess'],
++                    $clipboardAction->getReloadPageOnSuccess()
++                ));
 +            }
 +
 +            foreach ($actionData['actions'] as $actionObject) {
 +                $data = $clipboardAction->execute($this->markedItems[$typeName], $actionObject);
 +                if ($data === null) {
 +                    continue;
 +                }
 +
 +                $editorData[$typeName]['items'][$actionObject->showOrder] = $data;
 +            }
 +        }
 +
 +        return $editorData;
 +    }
 +
 +    /**
 +     * Removes items from clipboard.
 +     *
 +     * @param int $typeID
 +     */
 +    public function removeItems($typeID = null)
 +    {
 +        $conditions = new PreparedStatementConditionBuilder();
 +        $conditions->add("userID = ?", [WCF::getUser()->userID]);
 +        if ($typeID !== null) {
 +            $conditions->add("objectTypeID = ?", [$typeID]);
 +        }
 +
 +        $sql = "DELETE FROM wcf" . WCF_N . "_clipboard_item
 +                " . $conditions;
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +        $statement->execute($conditions->getParameters());
 +    }
 +
 +    /**
 +     * Returns true (1) if at least one item (of the given object type) is marked.
 +     *
 +     * @param int $objectTypeID
 +     * @return  int
 +     */
 +    public function hasMarkedItems($objectTypeID = null)
 +    {
 +        if (!WCF::getUser()->userID) {
 +            return 0;
 +        }
 +
 +        $conditionBuilder = new PreparedStatementConditionBuilder();
 +        $conditionBuilder->add("userID = ?", [WCF::getUser()->userID]);
 +        if ($objectTypeID !== null) {
 +            $conditionBuilder->add("objectTypeID = ?", [$objectTypeID]);
 +        }
 +
 +        $sql = "SELECT  COUNT(*)
 +                FROM    wcf" . WCF_N . "_clipboard_item
 +                " . $conditionBuilder;
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +        $statement->execute($conditionBuilder->getParameters());
 +
 +        return $statement->fetchSingleColumn() ? 1 : 0;
 +    }
 +
 +    /**
 +     * Returns the list of page class names.
 +     *
 +     * @return      string[]
 +     */
 +    public function getPageClasses()
 +    {
 +        return $this->pageClasses;
 +    }
 +
 +    /**
 +     * Returns page object id.
 +     *
 +     * @return  int
 +     */
 +    public function getPageObjectID()
 +    {
 +        return $this->pageObjectID;
 +    }
  }
index 155d6c75d8bf9b95752aa7e165bafc3e5983e1b7,e07b677798f1d497d280807f3a7dc737123fc2de..9192786554d3b5c795bc73d81e078fbcc424e0c7
@@@ -15,345 -13,319 +15,345 @@@ use wcf\util\JSON
   * @package     WoltLabSuite\Core\System\Html\Node
   * @since       3.0
   */
 -abstract class AbstractHtmlNodeProcessor implements IHtmlNodeProcessor {
 -      /**
 -       * active DOM document
 -       * @var \DOMDocument
 -       */
 -      protected $document;
 -      
 -      /**
 -       * html processor instance
 -       * @var IHtmlProcessor
 -       */
 -      protected $htmlProcessor;
 -      
 -      /**
 -       * required interface for html nodes
 -       * @var string
 -       */
 -      protected $nodeInterface = '';
 -      
 -      /**
 -       * storage for node replacements
 -       * @var array
 -       */
 -      protected $nodeData = [];
 -      
 -      /**
 -       * XPath instance
 -       * @var \DOMXPath
 -       */
 -      protected $xpath;
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function load(IHtmlProcessor $htmlProcessor, $html) {
 -              $this->htmlProcessor = $htmlProcessor;
 -              
 -              $this->document = new \DOMDocument('1.0', 'UTF-8');
 -              $this->xpath = null;
 -              
 -              $html = preg_replace_callback('~(<pre[^>]*>)(.*?)(</pre>)~s', function($matches) {
 -                      return $matches[1] . preg_replace('~\r?\n~', '@@@WCF_PRE_LINEBREAK@@@', $matches[2]) . $matches[3];
 -              }, $html);
 -              
 -              // strip UTF-8 zero-width whitespace
 -              $html = preg_replace('~\x{200B}~u', '', $html);
 -              
 -              // discard any non-breaking spaces
 -              $html = str_replace('&nbsp;', ' ', $html);
 -              
 -              // work-around for a libxml bug that causes a single space between
 -              // some inline elements to be dropped 
 -              $html = str_replace('> <', '>&nbsp;<', $html);
 -              
 -              // Ignore all errors when loading the HTML string, because DOMDocument does not
 -              // provide a proper way to add custom HTML elements (even though explicitly allowed
 -              // in HTML5) and the input HTML has already been sanitized by HTMLPurifier.
 -              // 
 -              // We're also injecting a bogus meta tag that magically enables DOMDocument
 -              // to handle UTF-8 properly. This avoids encoding non-ASCII characters as it
 -              // would conflict with already existing entities when reverting them.
 -              @$this->document->loadHTML('<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8" /></head><body>' . $html . '</body></html>');
 -              
 -              // flush libxml's error buffer, after all we don't care for any errors caused
 -              // by the `loadHTML()` call above anyway
 -              libxml_clear_errors();
 -              
 -              // fix the `<pre>` linebreaks again
 -              $pres = $this->document->getElementsByTagName('pre');
 -              for ($i = 0, $length = $pres->length; $i < $length; $i++) {
 -                      /** @var \DOMElement $pre */
 -                      $pre = $pres->item($i);
 -                      /** @var \DOMNode $node */
 -                      foreach ($pre->childNodes as $node) {
 -                              if ($node->nodeType === XML_TEXT_NODE && mb_strpos($node->textContent, '@@@WCF_PRE_LINEBREAK@@@') !== false) {
 -                                      $node->nodeValue = str_replace('@@@WCF_PRE_LINEBREAK@@@', "\n", $node->textContent);
 -                              }
 -                      }
 -              }
 -              
 -              $this->nodeData = [];
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function getHtml() {
 -              $html = $this->document->saveHTML($this->document->getElementsByTagName('body')->item(0));
 -              
 -              // remove nuisance added by PHP
 -              $html = preg_replace('~^<!DOCTYPE[^>]+>\n~', '', $html);
 -              $html = preg_replace('~^<body>~', '', $html);
 -              $html = preg_replace('~</body>$~', '', $html);
 -              
 -              foreach ($this->nodeData as $data) {
 -                      $html = preg_replace_callback('~<wcfNode-' . $data['identifier'] . '>(?P<content>[\s\S]*)</wcfNode-' . $data['identifier'] . '>~', function($matches) use ($data) {
 -                              /** @var IHtmlNode $obj */
 -                              $obj = $data['object'];
 -                              $string = $obj->replaceTag($data['data']);
 -                              
 -                              if (!isset($data['data']['skipInnerContent']) || $data['data']['skipInnerContent'] !== true) {
 -                                      if (mb_strpos($string, '<!-- META_CODE_INNER_CONTENT -->') !== false) {
 -                                              return str_replace('<!-- META_CODE_INNER_CONTENT -->', $matches['content'], $string);
 -                                      }
 -                                      else {
 -                                              if (mb_strpos($string, '&lt;!-- META_CODE_INNER_CONTENT --&gt;') !== false) {
 -                                                      return str_replace('&lt;!-- META_CODE_INNER_CONTENT --&gt;', $matches['content'], $string);
 -                                              }
 -                                      }
 -                              }
 -                              
 -                              return $string;
 -                      }, $html);
 -                      
 -              }
 -              
 -              // work-around for a libxml bug that causes a single space between
 -              // some inline elements to be dropped
 -              $html = str_replace('&nbsp;', ' ', $html);
 -              $html = preg_replace('~>\x{00A0}<~u', '> <', $html);
 -              
 -              return $html;
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function getDocument() {
 -              return $this->document;
 -      }
 -      
 -      /**
 -       * Returns a XPath instance for the current DOM document.
 -       * 
 -       * @return      \DOMXPath       XPath instance
 -       */
 -      public function getXPath() {
 -              if ($this->xpath === null) {
 -                      $this->xpath = new \DOMXPath($this->getDocument());
 -              }
 -              
 -              return $this->xpath;
 -      }
 -      
 -      /**
 -       * Renames a tag by creating a new element, moving all child nodes and
 -       * eventually removing the original element.
 -       * 
 -       * @param       \DOMElement     $element        old element
 -       * @param       string          $tagName        tag name for the new element
 -       * @param       bool            $preserveAttributes     retain attributes for the new element
 -       * @return      \DOMElement     newly created element
 -       */
 -      public function renameTag(\DOMElement $element, $tagName, $preserveAttributes = false) {
 -              $newElement = $this->document->createElement($tagName);
 -              if ($preserveAttributes) {
 -                      /** @var \DOMNode $attribute */
 -                      foreach ($element->attributes as $attribute) {
 -                              $newElement->setAttribute($attribute->nodeName, $attribute->nodeValue);
 -                      }
 -              }
 -              
 -              $element->parentNode->insertBefore($newElement, $element);
 -              while ($element->hasChildNodes()) {
 -                      $newElement->appendChild($element->firstChild);
 -              }
 -              
 -              $element->parentNode->removeChild($element);
 -              
 -              return $newElement;
 -      }
 -      
 -      /**
 -       * Replaces an element with plain text.
 -       * 
 -       * @param       \DOMElement     $element        target element
 -       * @param       string          $text           text used to replace target 
 -       * @param       boolean         $isBlockElement true if element is a block element
 -       */
 -      public function replaceElementWithText(\DOMElement $element, $text, $isBlockElement) {
 -              $textNode = $element->ownerDocument->createTextNode($text);
 -              $element->parentNode->insertBefore($textNode, $element);
 -              
 -              if ($isBlockElement) {
 -                      for ($i = 0; $i < 2; $i++) {
 -                              $br = $element->ownerDocument->createElement('br');
 -                              $element->parentNode->insertBefore($br, $element);
 -                      }
 -              }
 -              
 -              $element->parentNode->removeChild($element);
 -      }
 -      
 -      /**
 -       * Removes an element but preserves child nodes by moving them into
 -       * its original position.
 -       * 
 -       * @param       \DOMElement     $element        element to be removed
 -       */
 -      public function unwrapContent(\DOMElement $element) {
 -              while ($element->hasChildNodes()) {
 -                      $element->parentNode->insertBefore($element->firstChild, $element);
 -              }
 -              
 -              $element->parentNode->removeChild($element);
 -      }
 -      
 -      /**
 -       * Adds node replacement data.
 -       * 
 -       * @param       IHtmlNode       $htmlNode               node processor instance
 -       * @param       string          $nodeIdentifier         replacement node identifier
 -       * @param       array           $data                   replacement data
 -       */
 -      public function addNodeData(IHtmlNode $htmlNode, $nodeIdentifier, array $data) {
 -              $this->nodeData[] = [
 -                      'data' => $data,
 -                      'identifier' => $nodeIdentifier,
 -                      'object' => $htmlNode,
 -              ];
 -      }
 -      
 -      /**
 -       * Parses an attribute string.
 -       * 
 -       * @param       string          $attributes             base64 and JSON encoded attributes
 -       * @return      array           parsed attributes
 -       */
 -      public function parseAttributes($attributes) {
 -              if (empty($attributes)) {
 -                      return [];
 -              }
 -              
 -              $parsedAttributes = base64_decode($attributes, true);
 -              if ($parsedAttributes !== false) {
 -                      try {
 -                              $parsedAttributes = JSON::decode($parsedAttributes);
 -                      }
 -                      catch (SystemException $e) {
 -                              /* parse errors can occur if user provided malicious content - ignore them */
 -                              $parsedAttributes = [];
 -                      }
 -              }
 -              
 -              return $parsedAttributes;
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function getHtmlProcessor() {
 -              return $this->htmlProcessor;
 -      }
 -      
 -      /**
 -       * Invokes a html node processor.
 -       * 
 -       * @param       IHtmlNode       $htmlNode       html node processor
 -       */
 -      protected function invokeHtmlNode(IHtmlNode $htmlNode) {
 -              if (!($htmlNode instanceof $this->nodeInterface)) {
 -                      throw new \InvalidArgumentException("Node '" . get_class($htmlNode) . "' does not implement the interface '" . $this->nodeInterface . "'.");
 -              }
 -              
 -              $tagName = $htmlNode->getTagName();
 -              if (empty($tagName)) {
 -                      throw new \UnexpectedValueException("Missing tag name for " . get_class($htmlNode));
 -              }
 -              
 -              $elements = [];
 -              foreach ($this->getXPath()->query("//{$tagName}") as $element) {
 -                      $elements[] = $element;
 -              }
 -              
 -              if (!empty($elements)) {
 -                      $htmlNode->process($elements, $this);
 -              }
 -      }
 -      
 -      /**
 -       * Invokes possible html node processors based on found element tag names.
 -       * 
 -       * @param       string          $classNamePattern       full namespace pattern for class guessing
 -       * @param       string[]        $skipTags               list of tag names that should be ignored
 -       * @param       callable        $callback               optional callback
 -       */
 -      protected function invokeNodeHandlers($classNamePattern, array $skipTags = [], callable $callback = null) {
 -              $skipTags = array_merge($skipTags, ['html', 'head', 'title', 'meta', 'body', 'link']);
 -              
 -              $tags = [];
 -              /** @var \DOMElement $tag */
 -              foreach ($this->getXPath()->query("//*") as $tag) {
 -                      $tagName = $tag->nodeName;
 -                      if (!isset($tags[$tagName])) $tags[$tagName] = $tagName;
 -              }
 -              
 -              foreach ($tags as $tagName) {
 -                      if (in_array($tagName, $skipTags)) {
 -                              continue;
 -                      }
 -                      
 -                      $tagName = preg_replace_callback('/-([a-z])/', function($matches) {
 -                              return ucfirst($matches[1]);
 -                      }, $tagName);
 -                      $className = $classNamePattern . ucfirst($tagName);
 -                      if (class_exists($className)) {
 -                              if ($callback === null) {
 -                                      $this->invokeHtmlNode(new $className);
 -                              }
 -                              else {
 -                                      $callback(new $className);
 -                              }
 -                      }
 -              }
 -      }
 +abstract class AbstractHtmlNodeProcessor implements IHtmlNodeProcessor
 +{
 +    /**
 +     * active DOM document
 +     * @var \DOMDocument
 +     */
 +    protected $document;
 +
 +    /**
 +     * html processor instance
 +     * @var IHtmlProcessor
 +     */
 +    protected $htmlProcessor;
 +
 +    /**
 +     * required interface for html nodes
 +     * @var string
 +     */
 +    protected $nodeInterface = '';
 +
 +    /**
 +     * storage for node replacements
 +     * @var array
 +     */
 +    protected $nodeData = [];
 +
 +    /**
 +     * XPath instance
 +     * @var \DOMXPath
 +     */
 +    protected $xpath;
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function load(IHtmlProcessor $htmlProcessor, $html)
 +    {
 +        $this->htmlProcessor = $htmlProcessor;
 +
 +        $this->document = new \DOMDocument('1.0', 'UTF-8');
 +        $this->xpath = null;
 +
 +        $html = \preg_replace_callback('~(<pre[^>]*>)(.*?)(</pre>)~s', static function ($matches) {
 +            return $matches[1] . \preg_replace('~\r?\n~', '@@@WCF_PRE_LINEBREAK@@@', $matches[2]) . $matches[3];
 +        }, $html);
 +
 +        // strip UTF-8 zero-width whitespace
 +        $html = \preg_replace('~\x{200B}~u', '', $html);
 +
 +        // discard any non-breaking spaces
 +        $html = \str_replace('&nbsp;', ' ', $html);
 +
 +        // work-around for a libxml bug that causes a single space between
 +        // some inline elements to be dropped
 +        $html = \str_replace('> <', '>&nbsp;<', $html);
 +
 +        // Ignore all errors when loading the HTML string, because DOMDocument does not
 +        // provide a proper way to add custom HTML elements (even though explicitly allowed
 +        // in HTML5) and the input HTML has already been sanitized by HTMLPurifier.
 +        //
 +        // We're also injecting a bogus meta tag that magically enables DOMDocument
 +        // to handle UTF-8 properly. This avoids encoding non-ASCII characters as it
 +        // would conflict with already existing entities when reverting them.
 +        @$this->document->loadHTML(
 +            '<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8" /></head><body>' . $html . '</body></html>'
 +        );
 +
 +        // flush libxml's error buffer, after all we don't care for any errors caused
 +        // by the `loadHTML()` call above anyway
 +        \libxml_clear_errors();
 +
 +        // fix the `<pre>` linebreaks again
 +        $pres = $this->document->getElementsByTagName('pre');
 +        for ($i = 0, $length = $pres->length; $i < $length; $i++) {
 +            /** @var \DOMElement $pre */
 +            $pre = $pres->item($i);
 +            /** @var \DOMNode $node */
 +            foreach ($pre->childNodes as $node) {
 +                if (
 +                    $node->nodeType === \XML_TEXT_NODE
 +                    && \mb_strpos($node->textContent, '@@@WCF_PRE_LINEBREAK@@@') !== false
 +                ) {
 +                    $node->nodeValue = \str_replace('@@@WCF_PRE_LINEBREAK@@@', "\n", $node->textContent);
 +                }
 +            }
 +        }
 +
 +        $this->nodeData = [];
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function getHtml()
 +    {
 +        $html = $this->document->saveHTML($this->document->getElementsByTagName('body')->item(0));
 +
 +        // remove nuisance added by PHP
 +        $html = \preg_replace('~^<!DOCTYPE[^>]+>\n~', '', $html);
 +        $html = \preg_replace('~^<body>~', '', $html);
 +        $html = \preg_replace('~</body>$~', '', $html);
 +
 +        foreach ($this->nodeData as $data) {
 +            $html = \preg_replace_callback(
 +                '~<wcfNode-' . $data['identifier'] . '>(?P<content>[\s\S]*)</wcfNode-' . $data['identifier'] . '>~',
 +                static function ($matches) use ($data) {
 +                    /** @var IHtmlNode $obj */
 +                    $obj = $data['object'];
 +                    $string = $obj->replaceTag($data['data']);
 +
 +                    if (!isset($data['data']['skipInnerContent']) || $data['data']['skipInnerContent'] !== true) {
 +                        if (\mb_strpos($string, '<!-- META_CODE_INNER_CONTENT -->') !== false) {
 +                            return \str_replace('<!-- META_CODE_INNER_CONTENT -->', $matches['content'], $string);
 +                        } else {
 +                            if (\mb_strpos($string, '&lt;!-- META_CODE_INNER_CONTENT --&gt;') !== false) {
 +                                return \str_replace(
 +                                    '&lt;!-- META_CODE_INNER_CONTENT --&gt;',
 +                                    $matches['content'],
 +                                    $string
 +                                );
 +                            }
 +                        }
 +                    }
 +
 +                    return $string;
 +                },
 +                $html
 +            );
 +        }
 +
 +        // work-around for a libxml bug that causes a single space between
 +        // some inline elements to be dropped
 +        $html = \str_replace('&nbsp;', ' ', $html);
 +        $html = \preg_replace('~>\x{00A0}<~u', '> <', $html);
 +
 +        return $html;
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function getDocument()
 +    {
 +        return $this->document;
 +    }
 +
 +    /**
 +     * Returns a XPath instance for the current DOM document.
 +     *
 +     * @return      \DOMXPath       XPath instance
 +     */
 +    public function getXPath()
 +    {
 +        if ($this->xpath === null) {
 +            $this->xpath = new \DOMXPath($this->getDocument());
 +        }
 +
 +        return $this->xpath;
 +    }
 +
 +    /**
 +     * Renames a tag by creating a new element, moving all child nodes and
 +     * eventually removing the original element.
 +     *
 +     * @param \DOMElement $element old element
 +     * @param string $tagName tag name for the new element
 +     * @param bool $preserveAttributes retain attributes for the new element
 +     * @return      \DOMElement     newly created element
 +     */
 +    public function renameTag(\DOMElement $element, $tagName, $preserveAttributes = false)
 +    {
 +        $newElement = $this->document->createElement($tagName);
 +        if ($preserveAttributes) {
 +            /** @var \DOMNode $attribute */
 +            foreach ($element->attributes as $attribute) {
 +                $newElement->setAttribute($attribute->nodeName, $attribute->nodeValue);
 +            }
 +        }
 +
 +        $element->parentNode->insertBefore($newElement, $element);
 +        while ($element->hasChildNodes()) {
 +            $newElement->appendChild($element->firstChild);
 +        }
 +
 +        $element->parentNode->removeChild($element);
 +
 +        return $newElement;
 +    }
 +
 +    /**
 +     * Replaces an element with plain text.
 +     *
 +     * @param \DOMElement $element target element
 +     * @param string $text text used to replace target
 +     * @param bool $isBlockElement true if element is a block element
 +     */
 +    public function replaceElementWithText(\DOMElement $element, $text, $isBlockElement)
 +    {
 +        $textNode = $element->ownerDocument->createTextNode($text);
 +        $element->parentNode->insertBefore($textNode, $element);
 +
 +        if ($isBlockElement) {
 +            for ($i = 0; $i < 2; $i++) {
 +                $br = $element->ownerDocument->createElement('br');
 +                $element->parentNode->insertBefore($br, $element);
 +            }
 +        }
 +
 +        $element->parentNode->removeChild($element);
 +    }
 +
 +    /**
 +     * Removes an element but preserves child nodes by moving them into
 +     * its original position.
 +     *
 +     * @param \DOMElement $element element to be removed
 +     */
 +    public function unwrapContent(\DOMElement $element)
 +    {
 +        while ($element->hasChildNodes()) {
 +            $element->parentNode->insertBefore($element->firstChild, $element);
 +        }
 +
 +        $element->parentNode->removeChild($element);
 +    }
 +
 +    /**
 +     * Adds node replacement data.
 +     *
 +     * @param IHtmlNode $htmlNode node processor instance
 +     * @param string $nodeIdentifier replacement node identifier
 +     * @param array $data replacement data
 +     */
 +    public function addNodeData(IHtmlNode $htmlNode, $nodeIdentifier, array $data)
 +    {
 +        $this->nodeData[] = [
 +            'data' => $data,
 +            'identifier' => $nodeIdentifier,
 +            'object' => $htmlNode,
 +        ];
 +    }
 +
 +    /**
 +     * Parses an attribute string.
 +     *
 +     * @param string $attributes base64 and JSON encoded attributes
 +     * @return      array           parsed attributes
 +     */
 +    public function parseAttributes($attributes)
 +    {
 +        if (empty($attributes)) {
 +            return [];
 +        }
 +
 +        $parsedAttributes = \base64_decode($attributes, true);
 +        if ($parsedAttributes !== false) {
 +            try {
 +                $parsedAttributes = JSON::decode($parsedAttributes);
 +            } catch (SystemException $e) {
 +                /* parse errors can occur if user provided malicious content - ignore them */
 +                $parsedAttributes = [];
 +            }
 +        }
 +
 +        return $parsedAttributes;
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function getHtmlProcessor()
 +    {
 +        return $this->htmlProcessor;
 +    }
 +
 +    /**
 +     * Invokes a html node processor.
 +     *
 +     * @param IHtmlNode $htmlNode html node processor
 +     */
 +    protected function invokeHtmlNode(IHtmlNode $htmlNode)
 +    {
 +        if (!($htmlNode instanceof $this->nodeInterface)) {
 +            throw new \InvalidArgumentException(
 +                "Node '" . \get_class($htmlNode) . "' does not implement the interface '" . $this->nodeInterface . "'."
 +            );
 +        }
 +
 +        $tagName = $htmlNode->getTagName();
 +        if (empty($tagName)) {
 +            throw new \UnexpectedValueException("Missing tag name for " . \get_class($htmlNode));
 +        }
 +
 +        $elements = [];
-         foreach ($this->getDocument()->getElementsByTagName($tagName) as $element) {
++        foreach ($this->getXPath()->query("//{$tagName}") as $element) {
 +            $elements[] = $element;
 +        }
 +
 +        if (!empty($elements)) {
 +            $htmlNode->process($elements, $this);
 +        }
 +    }
 +
 +    /**
 +     * Invokes possible html node processors based on found element tag names.
 +     *
 +     * @param string $classNamePattern full namespace pattern for class guessing
 +     * @param string[] $skipTags list of tag names that should be ignored
 +     * @param callable $callback optional callback
 +     */
 +    protected function invokeNodeHandlers($classNamePattern, array $skipTags = [], ?callable $callback = null)
 +    {
 +        $skipTags = \array_merge($skipTags, ['html', 'head', 'title', 'meta', 'body', 'link']);
 +
 +        $tags = [];
 +        /** @var \DOMElement $tag */
-         foreach ($this->getDocument()->getElementsByTagName('*') as $tag) {
++        foreach ($this->getXPath()->query("//*") as $tag) {
 +            $tagName = $tag->nodeName;
 +            if (!isset($tags[$tagName])) {
 +                $tags[$tagName] = $tagName;
 +            }
 +        }
 +
 +        foreach ($tags as $tagName) {
 +            if (\in_array($tagName, $skipTags)) {
 +                continue;
 +            }
 +
 +            $tagName = \preg_replace_callback('/-([a-z])/', static function ($matches) {
 +                return \ucfirst($matches[1]);
 +            }, $tagName);
 +            $className = $classNamePattern . \ucfirst($tagName);
 +            if (\class_exists($className)) {
 +                if ($callback === null) {
 +                    $this->invokeHtmlNode(new $className());
 +                } else {
 +                    $callback(new $className());
 +                }
 +            }
 +        }
 +    }
  }
index e8c13dc5ea82a4d5388fd145058b86d8a515e98d,9ac423ce5195d85a67e47f78b4185bfd5fb6d641..7592666094aeb247cb8a67bc398171413c884b09
@@@ -22,293 -20,267 +22,285 @@@ use wcf\util\StringUtil
   * @since       3.0
   * @method      HtmlOutputProcessor     getHtmlProcessor()
   */
 -class HtmlOutputNodeProcessor extends AbstractHtmlNodeProcessor {
 -      /**
 -       * @inheritDoc
 -       */
 -      protected $nodeInterface = IHtmlOutputNode::class;
 -      
 -      /**
 -       * desired output type
 -       * @var string
 -       */
 -      protected $outputType = 'text/html';
 -      
 -      /**
 -       * enables keyword highlighting
 -       * @var boolean
 -       */
 -      protected $keywordHighlighting = true;
 -      
 -      /**
 -       * @var string[]
 -       */
 -      protected $sourceBBCodes = [];
 -      
 -      /**
 -       * list of HTML tags that should have a trailing newline when converted
 -       * to text/plain content
 -       * @var string[]
 -       */
 -      public static $plainTextNewlineTags = ['br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'td', 'tr'];
 -      
 -      /**
 -       * HtmlOutputNodeProcessor constructor.
 -       */
 -      public function __construct() {
 -              $this->sourceBBCodes = HtmlBBCodeParser::getInstance()->getSourceBBCodes();
 -      }
 -      
 -      /**
 -       * Sets the desired output type.
 -       * 
 -       * @param       string          $outputType     desired output type
 -       */
 -      public function setOutputType($outputType) {
 -              $this->outputType = $outputType;
 -      }
 -      
 -      /**
 -       * Returns the current output type.
 -       * 
 -       * @return      string
 -       */
 -      public function getOutputType() {
 -              return $this->outputType;
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function process() {
 -              // fire event action
 -              EventHandler::getInstance()->fireAction($this, 'beforeProcess');
 -              
 -              // highlight keywords
 -              $this->highlightKeywords();
 -              
 -              $this->invokeHtmlNode(new HtmlOutputNodeWoltlabMetacode());
 -              
 -              // dynamic node handlers
 -              $this->invokeNodeHandlers('wcf\system\html\output\node\HtmlOutputNode', ['woltlab-metacode']);
 -              
 -              if ($this->getHtmlProcessor()->removeLinks) {
 -                      foreach ($this->getXPath()->query('//a') as $link) {
 -                              DOMUtil::removeNode($link, true);
 -                      }
 -              }
 -              
 -              if ($this->outputType !== 'text/html') {
 -                      // convert `<p>...</p>` into `...<br><br>`
 -                      foreach ($this->getXPath()->query('//p') as $paragraph) {
 -                              $isLastNode = true;
 -                              $sibling = $paragraph;
 -                              while ($sibling = $sibling->nextSibling) {
 -                                      if ($sibling->nodeType === XML_ELEMENT_NODE) {
 -                                              if ($sibling->nodeName !== 'br') {
 -                                                      $isLastNode = false;
 -                                                      break;
 -                                              }
 -                                      }
 -                                      else if ($sibling->nodeType === XML_TEXT_NODE) {
 -                                              if (StringUtil::trim($sibling->textContent) !== '') {
 -                                                      $isLastNode = false;
 -                                                      break;
 -                                              }
 -                                      }
 -                              }
 -                              
 -                              if (!$isLastNode) {
 -                                      // check if paragraph only contains <br>
 -                                      if (StringUtil::trim($paragraph->textContent) === '') {
 -                                              // get last element
 -                                              $element = $paragraph->firstChild;
 -                                              while ($element && $element->nodeType !== XML_ELEMENT_NODE) {
 -                                                      $element = $element->nextSibling;
 -                                              }
 -                                              
 -                                              if ($paragraph->childNodes->length === 0 || ($element && $element->nodeName === 'br')) {
 -                                                      DOMUtil::removeNode($paragraph, true);
 -                                                      continue;
 -                                              }
 -                                      }
 -                                      
 -                                      //for ($i = 0; $i < 2; $i++) {
 -                                              $br = $this->getDocument()->createElement('br');
 -                                              $paragraph->appendChild($br);
 -                                      //}
 -                              }
 -                              
 -                              DOMUtil::removeNode($paragraph, true);
 -                      }
 -                      
 -                      if ($this->outputType === 'text/plain') {
 -                              // remove all `\n` first
 -                              $nodes = [];
 -                              /** @var \DOMText $node */
 -                              foreach ($this->getXPath()->query('//text()') as $node) {
 -                                      if (strpos($node->textContent, "\n") !== false) {
 -                                              $nodes[] = $node;
 -                                      }
 -                              }
 -                              foreach ($nodes as $node) {
 -                                      $textNode = $this->getDocument()->createTextNode(preg_replace('~\r?\n~', '', $node->textContent));
 -                                      $node->parentNode->insertBefore($textNode, $node);
 -                                      $node->parentNode->removeChild($node);
 -                              }
 -                              
 -                              // insert a trailing newline for certain elements, such as `<br>` or `<li>`
 -                              foreach (self::$plainTextNewlineTags as $tagName) {
 -                                      foreach ($this->getXPath()->query("//{$tagName}") as $element) {
 -                                              $newline = $this->getDocument()->createTextNode("\n");
 -                                              $element->parentNode->insertBefore($newline, $element->nextSibling);
 -                                              DOMUtil::removeNode($element, true);
 -                                      }
 -                              }
 -                              
 -                              // remove all other elements
 -                              foreach ($this->getXPath()->query('//*') as $element) {
 -                                      DOMUtil::removeNode($element, true);
 -                              }
 -                      }
 -              }
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function getHtml() {
 -              $toc = '';
 -              if (MESSAGE_ENABLE_TOC && $this->getHtmlProcessor()->enableToc && $this->outputType === 'text/html') {
 -                      $context = $this->getHtmlProcessor()->getContext();
 -                      $idPrefix = substr(sha1($context['objectType'] . '-' . $context['objectID']), 0, 8);
 -                      
 -                      $toc = HtmlToc::forMessage($this->getDocument(), $idPrefix);
 -              }
 -              
 -              $html = $toc . parent::getHtml();
 -              
 -              if ($this->outputType === 'text/plain') {
 -                      $html = StringUtil::trim($html);
 -                      $html = StringUtil::decodeHTML($html);
 -              }
 -              
 -              return $html;
 -      }
 -      
 -      /**
 -       * Enables the keyword highlighting.
 -       * 
 -       * @param       boolean         $enable
 -       */
 -      public function enableKeywordHighlighting($enable = true) {
 -              $this->keywordHighlighting = $enable;
 -      }
 -      
 -      /**
 -       * Executes the keyword highlighting.
 -       */
 -      protected function highlightKeywords() {
 -              if (!$this->keywordHighlighting) return;
 -              if (!count(KeywordHighlighter::getInstance()->getKeywords())) return;
 -              $keywordPattern = '('.implode('|', KeywordHighlighter::getInstance()->getKeywords()).')';
 -              
 -              $nodes = [];
 -              foreach ($this->getXPath()->query('//text()') as $node) {
 -                      $value = StringUtil::trim($node->textContent);
 -                      if (empty($value)) {
 -                              // skip empty nodes
 -                              continue;
 -                      }
 -                      
 -                      // check if node is within a code element or link
 -                      if ($this->hasCodeParent($node)) {
 -                              continue;
 -                      }
 -                      
 -                      $nodes[] = $node;
 -              }
 -              foreach ($nodes as $node) {
 -                      $split = preg_split('+'.$keywordPattern.'+i', $node->textContent, -1, PREG_SPLIT_DELIM_CAPTURE);
 -                      $count = count($split);
 -                      if ($count == 1) continue;
 -                      
 -                      for ($i = 0; $i < $count; $i++) {
 -                              // text
 -                              if ($i % 2 == 0) {
 -                                      $node->parentNode->insertBefore($node->ownerDocument->createTextNode($split[$i]), $node);
 -                              }
 -                              // match
 -                              else {
 -                                      /** @var \DOMElement $element */
 -                                      $element = $node->ownerDocument->createElement('span');
 -                                      $element->setAttribute('class', 'highlight');
 -                                      $element->appendChild($node->ownerDocument->createTextNode($split[$i]));
 -                                      $node->parentNode->insertBefore($element, $node);
 -                              }
 -                      }
 -                      
 -                      DOMUtil::removeNode($node);
 -              }
 -      }
 -      
 -      /**
 -       * Returns true if text node is inside a code element, suppressing any
 -       * auto-detection of content.
 -       *
 -       * @param       \DOMText        $text           text node
 -       * @return      boolean         true if text node is inside a code element
 -       */
 -      protected function hasCodeParent(\DOMText $text) {
 -              $parent = $text;
 -              /** @var \DOMElement $parent */
 -              while ($parent = $parent->parentNode) {
 -                      $nodeName = $parent->nodeName;
 -                      if ($nodeName === 'code' || $nodeName === 'kbd' || $nodeName === 'pre') {
 -                              return true;
 -                      }
 -                      else if ($nodeName === 'woltlab-metacode' && in_array($parent->getAttribute('data-name'), $this->sourceBBCodes)) {
 -                              return true;
 -                      }
 -              }
 -              
 -              return false;
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      protected function invokeHtmlNode(IHtmlNode $htmlNode) {
 -              /** @var IHtmlOutputNode $htmlNode */
 -              $htmlNode->setOutputType($this->outputType);
 -              $htmlNode->setRemoveLinks($this->getHtmlProcessor()->removeLinks);
 -              
 -              parent::invokeHtmlNode($htmlNode);
 -      }
 +class HtmlOutputNodeProcessor extends AbstractHtmlNodeProcessor
 +{
 +    /**
 +     * @inheritDoc
 +     */
 +    protected $nodeInterface = IHtmlOutputNode::class;
 +
 +    /**
 +     * desired output type
 +     * @var string
 +     */
 +    protected $outputType = 'text/html';
 +
 +    /**
 +     * enables keyword highlighting
 +     * @var bool
 +     */
 +    protected $keywordHighlighting = true;
 +
 +    /**
 +     * @var string[]
 +     */
 +    protected $sourceBBCodes = [];
 +
 +    /**
 +     * list of HTML tags that should have a trailing newline when converted
 +     * to text/plain content
 +     * @var string[]
 +     */
 +    public static $plainTextNewlineTags = ['br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'td', 'tr'];
 +
 +    /**
 +     * HtmlOutputNodeProcessor constructor.
 +     */
 +    public function __construct()
 +    {
 +        $this->sourceBBCodes = HtmlBBCodeParser::getInstance()->getSourceBBCodes();
 +    }
 +
 +    /**
 +     * Sets the desired output type.
 +     *
 +     * @param string $outputType desired output type
 +     */
 +    public function setOutputType($outputType)
 +    {
 +        $this->outputType = $outputType;
 +    }
 +
 +    /**
 +     * Returns the current output type.
 +     *
 +     * @return  string
 +     */
 +    public function getOutputType()
 +    {
 +        return $this->outputType;
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function process()
 +    {
 +        // fire event action
 +        EventHandler::getInstance()->fireAction($this, 'beforeProcess');
 +
 +        // highlight keywords
 +        $this->highlightKeywords();
 +
 +        $this->invokeHtmlNode(new HtmlOutputNodeWoltlabMetacode());
 +
 +        $this->invokeHtmlNode(new HtmlOutputUnfurlUrlNode());
 +
 +        // dynamic node handlers
 +        $this->invokeNodeHandlers('wcf\system\html\output\node\HtmlOutputNode', ['woltlab-metacode']);
 +
 +        if ($this->getHtmlProcessor()->removeLinks) {
-             $links = $this->getDocument()->getElementsByTagName('a');
-             while ($links->length) {
-                 DOMUtil::removeNode($links->item(0), true);
++            foreach ($this->getXPath()->query('//a') as $link) {
++                DOMUtil::removeNode($link, true);
 +            }
 +        }
 +
 +        if ($this->outputType !== 'text/html') {
 +            // convert `<p>...</p>` into `...<br><br>`
-             $paragraphs = $this->getDocument()->getElementsByTagName('p');
-             while ($paragraphs->length) {
-                 $paragraph = $paragraphs->item(0);
++            foreach ($this->getXPath()->query('//p') as $paragraph) {
 +                $isLastNode = true;
 +                $sibling = $paragraph;
 +                while ($sibling = $sibling->nextSibling) {
 +                    if ($sibling->nodeType === \XML_ELEMENT_NODE) {
 +                        if ($sibling->nodeName !== 'br') {
 +                            $isLastNode = false;
 +                            break;
 +                        }
 +                    } elseif ($sibling->nodeType === \XML_TEXT_NODE) {
 +                        if (StringUtil::trim($sibling->textContent) !== '') {
 +                            $isLastNode = false;
 +                            break;
 +                        }
 +                    }
 +                }
 +
 +                if (!$isLastNode) {
 +                    // check if paragraph only contains <br>
 +                    if (StringUtil::trim($paragraph->textContent) === '') {
 +                        // get last element
 +                        $element = $paragraph->firstChild;
 +                        while ($element && $element->nodeType !== \XML_ELEMENT_NODE) {
 +                            $element = $element->nextSibling;
 +                        }
 +
 +                        if ($paragraph->childNodes->length === 0 || ($element && $element->nodeName === 'br')) {
 +                            DOMUtil::removeNode($paragraph, true);
 +                            continue;
 +                        }
 +                    }
 +
 +                    //for ($i = 0; $i < 2; $i++) {
 +                    $br = $this->getDocument()->createElement('br');
 +                    $paragraph->appendChild($br);
 +                    //}
 +                }
 +
 +                DOMUtil::removeNode($paragraph, true);
 +            }
 +
 +            if ($this->outputType === 'text/plain') {
 +                // remove all `\n` first
 +                $nodes = [];
 +                /** @var \DOMText $node */
 +                foreach ($this->getXPath()->query('//text()') as $node) {
 +                    if (\strpos($node->textContent, "\n") !== false) {
 +                        $nodes[] = $node;
 +                    }
 +                }
 +                foreach ($nodes as $node) {
 +                    $textNode = $this->getDocument()->createTextNode(\preg_replace('~\r?\n~', '', $node->textContent));
 +                    $node->parentNode->insertBefore($textNode, $node);
 +                    $node->parentNode->removeChild($node);
 +                }
 +
 +                // insert a trailing newline for certain elements, such as `<br>` or `<li>`
 +                foreach (self::$plainTextNewlineTags as $tagName) {
-                     $elements = $this->getDocument()->getElementsByTagName($tagName);
-                     while ($elements->length) {
-                         $element = $elements->item(0);
++                    foreach ($this->getXPath()->query("//{$tagName}") as $element) {
 +                        $newline = $this->getDocument()->createTextNode("\n");
 +                        $element->parentNode->insertBefore($newline, $element->nextSibling);
 +                        DOMUtil::removeNode($element, true);
 +                    }
 +                }
 +
 +                // remove all other elements
-                 $elements = $this->getDocument()->getElementsByTagName('*');
-                 while ($elements->length) {
-                     DOMUtil::removeNode($elements->item(0), true);
++                foreach ($this->getXPath()->query('//*') as $element) {
++                    DOMUtil::removeNode($element, true);
 +                }
 +            }
 +        }
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function getHtml()
 +    {
 +        $toc = '';
 +        if (MESSAGE_ENABLE_TOC && $this->getHtmlProcessor()->enableToc && $this->outputType === 'text/html') {
 +            $context = $this->getHtmlProcessor()->getContext();
 +            $idPrefix = \substr(\sha1($context['objectType'] . '-' . $context['objectID']), 0, 8);
 +
 +            $toc = HtmlToc::forMessage($this->getDocument(), $idPrefix);
 +        }
 +
 +        $html = $toc . parent::getHtml();
 +
 +        if ($this->outputType === 'text/plain') {
 +            $html = StringUtil::trim($html);
 +            $html = StringUtil::decodeHTML($html);
 +        }
 +
 +        return $html;
 +    }
 +
 +    /**
 +     * Enables the keyword highlighting.
 +     *
 +     * @param bool $enable
 +     */
 +    public function enableKeywordHighlighting($enable = true)
 +    {
 +        $this->keywordHighlighting = $enable;
 +    }
 +
 +    /**
 +     * Executes the keyword highlighting.
 +     */
 +    protected function highlightKeywords()
 +    {
 +        if (!$this->keywordHighlighting) {
 +            return;
 +        }
 +        if (!\count(KeywordHighlighter::getInstance()->getKeywords())) {
 +            return;
 +        }
 +        $keywordPattern = '(' . \implode('|', KeywordHighlighter::getInstance()->getKeywords()) . ')';
 +
 +        $nodes = [];
 +        foreach ($this->getXPath()->query('//text()') as $node) {
 +            $value = StringUtil::trim($node->textContent);
 +            if (empty($value)) {
 +                // skip empty nodes
 +                continue;
 +            }
 +
 +            // check if node is within a code element or link
 +            if ($this->hasCodeParent($node)) {
 +                continue;
 +            }
 +
 +            $nodes[] = $node;
 +        }
 +        foreach ($nodes as $node) {
 +            $split = \preg_split('+' . $keywordPattern . '+i', $node->textContent, -1, \PREG_SPLIT_DELIM_CAPTURE);
 +            $count = \count($split);
 +            if ($count == 1) {
 +                continue;
 +            }
 +
 +            for ($i = 0; $i < $count; $i++) {
 +                // text
 +                if ($i % 2 == 0) {
 +                    $node->parentNode->insertBefore($node->ownerDocument->createTextNode($split[$i]), $node);
 +                } // match
 +                else {
 +                    /** @var \DOMElement $element */
 +                    $element = $node->ownerDocument->createElement('span');
 +                    $element->setAttribute('class', 'highlight');
 +                    $element->appendChild($node->ownerDocument->createTextNode($split[$i]));
 +                    $node->parentNode->insertBefore($element, $node);
 +                }
 +            }
 +
 +            DOMUtil::removeNode($node);
 +        }
 +    }
 +
 +    /**
 +     * Returns true if text node is inside a code element, suppressing any
 +     * auto-detection of content.
 +     *
 +     * @param \DOMText $text text node
 +     * @return      bool         true if text node is inside a code element
 +     */
 +    protected function hasCodeParent(\DOMText $text)
 +    {
 +        $parent = $text;
 +        /** @var \DOMElement $parent */
 +        while ($parent = $parent->parentNode) {
 +            $nodeName = $parent->nodeName;
 +            if ($nodeName === 'code' || $nodeName === 'kbd' || $nodeName === 'pre') {
 +                return true;
 +            } elseif (
 +                $nodeName === 'woltlab-metacode'
 +                && \in_array($parent->getAttribute('data-name'), $this->sourceBBCodes)
 +            ) {
 +                return true;
 +            }
 +        }
 +
 +        return false;
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    protected function invokeHtmlNode(IHtmlNode $htmlNode)
 +    {
 +        /** @var IHtmlOutputNode $htmlNode */
 +        $htmlNode->setOutputType($this->outputType);
 +        $htmlNode->setRemoveLinks($this->getHtmlProcessor()->removeLinks);
 +
 +        parent::invokeHtmlNode($htmlNode);
 +    }
  }
index a759ff4c97daebd39fc95fc823390990d745a977,f362bb93622ee4429781293fd00d1ea15949f1da..69bf0e842ba39469994fd0fc996174632bfcd022
@@@ -6,611 -4,597 +6,618 @@@ use wcf\system\exception\SystemExceptio
  
  /**
   * Provides helper methods to work with PHP's DOM implementation.
 - * 
 - * @author    Alexander Ebert
 - * @copyright 2001-2019 WoltLab GmbH
 - * @license   GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 - * @package   WoltLabSuite\Core\Util
 + *
 + * @author  Alexander Ebert
 + * @copyright   2001-2019 WoltLab GmbH
 + * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 + * @package WoltLabSuite\Core\Util
   */
 -final class DOMUtil {
 -      /**
 -       * Moves all child nodes from given element into a document fragment.
 -       * 
 -       * @param       \DOMElement     $element        element
 -       * @return      \DOMDocumentFragment            document fragment containing all child nodes from `$element`
 -       */
 -      public static function childNodesToFragment(\DOMElement $element) {
 -              $fragment = $element->ownerDocument->createDocumentFragment();
 -              
 -              while ($element->hasChildNodes()) {
 -                      $fragment->appendChild($element->childNodes->item(0));
 -              }
 -              
 -              return $fragment;
 -      }
 -      
 -      /**
 -       * Returns true if `$ancestor` contains the node `$node`.
 -       * 
 -       * @param       \DOMNode        $ancestor       ancestor node
 -       * @param       \DOMNode        $node           node
 -       * @return      boolean         true if `$ancestor` contains the node `$node`
 -       */
 -      public static function contains(\DOMNode $ancestor, \DOMNode $node) {
 -              // nodes cannot contain themselves
 -              if ($ancestor === $node) {
 -                      return false;
 -              }
 -              
 -              // text nodes cannot contain any other nodes
 -              if ($ancestor->nodeType === XML_TEXT_NODE) {
 -                      return false;
 -              }
 -              
 -              $parent = $node;
 -              while ($parent = $parent->parentNode) {
 -                      if ($parent === $ancestor) {
 -                              return true;
 -                      }
 -              }
 -              
 -              return false;
 -      }
 -      
 -      /**
 -       * Returns a static list of child nodes of provided element.
 -       * 
 -       * @param       \DOMElement     $element        target element
 -       * @return      \DOMNode[]      list of child nodes
 -       */
 -      public static function getChildNodes(\DOMElement $element) {
 -              $nodes = [];
 -              foreach ($element->childNodes as $node) {
 -                      $nodes[] = $node;
 -              }
 -              
 -              return $nodes;
 -      }
 -      
 -      /**
 -       * Returns the common ancestor of both nodes.
 -       * 
 -       * @param       \DOMNode                $node1          first node
 -       * @param       \DOMNode                $node2          second node
 -       * @return      \DOMNode|null   common ancestor or null
 -       */
 -      public static function getCommonAncestor(\DOMNode $node1, \DOMNode $node2) {
 -              // abort if both elements share a common element or are both direct descendants
 -              // of the same document
 -              if ($node1->parentNode === $node2->parentNode) {
 -                      return $node1->parentNode;
 -              }
 -              
 -              // collect the list of all direct ancestors of `$node1`
 -              $parents = self::getParents($node1);
 -              
 -              // compare each ancestor of `$node2` to the known list of parents of `$node1`
 -              $parent = $node2;
 -              while ($parent = $parent->parentNode) {
 -                      // requires strict type check
 -                      if (in_array($parent, $parents, true)) {
 -                              return $parent;
 -                      }
 -              }
 -              
 -              return null;
 -      }
 -      
 -      /**
 -       * Returns a non-live collection of elements.
 -       * 
 -       * @param       (\DOMDocument|\DOMElement)      $context        context element
 -       * @param       string                          $tagName        tag name
 -       * @return      \DOMElement[]                   list of elements
 -       * @throws      SystemException
 -       */
 -      public static function getElements($context, $tagName) {
 -              if (!($context instanceof \DOMDocument) && !($context instanceof \DOMElement)) {
 -                      throw new SystemException("Expected context to be either of type \\DOMDocument or \\DOMElement.");
 -              }
 -              
 -              $elements = [];
 -              foreach ($context->getElementsByTagName($tagName) as $element) {
 -                      $elements[] = $element;
 -              }
 -              
 -              return $elements;
 -      }
 -      
 -      /**
 -       * Returns the immediate parent element before provided ancestor element. Returns null if
 -       * the ancestor element is the direct parent of provided node.
 -       * 
 -       * @param       \DOMNode                $node           node
 -       * @param       \DOMElement             $ancestor       ancestor node
 -       * @return      \DOMElement|null        immediate parent element before ancestor element
 -       */
 -      public static function getParentBefore(\DOMNode $node, \DOMElement $ancestor) {
 -              if ($node->parentNode === $ancestor) {
 -                      return null;
 -              }
 -              
 -              $parents = self::getParents($node);
 -              for ($i = count($parents) - 1; $i >= 0; $i--) {
 -                      if ($parents[$i] === $ancestor) {
 -                              return $parents[$i - 1];
 -                      }
 -              }
 -              
 -              throw new \InvalidArgumentException("Provided node is a not a descendant of ancestor element.");
 -      }
 -      
 -      /**
 -       * Returns the parent node of given node.
 -       *
 -       * @param       \DOMNode        $node           node
 -       * @return      \DOMNode        parent node, can be `\DOMElement` or `\DOMDocument`
 -       */
 -      public static function getParentNode(\DOMNode $node) {
 -              return $node->parentNode ?: $node->ownerDocument;
 -      }
 -      
 -      /**
 -       * Returns all ancestors nodes for given node.
 -       * 
 -       * @param       \DOMNode        $node           node
 -       * @param       boolean         $reverseOrder   reversing the order causes the most top ancestor to appear first
 -       * @return      \DOMElement[]   list of ancestor nodes
 -       */
 -      public static function getParents(\DOMNode $node, $reverseOrder = false) {
 -              $parents = [];
 -              
 -              $parent = $node;
 -              while ($parent = $parent->parentNode) {
 -                      $parents[] = $parent;
 -              }
 -              
 -              return $reverseOrder ? array_reverse($parents) : $parents;
 -      }
 -      
 -      /**
 -       * Returns a cloned parent tree that is virtually readonly. In fact it can be
 -       * modified, but all changes are non permanent and do not affect the source
 -       * document at all.
 -       * 
 -       * @param       \DOMNode        $node           node
 -       * @return      \DOMElement[]   list of parent elements
 -       */
 -      public static function getReadonlyParentTree(\DOMNode $node) {
 -              $tree = [];
 -              /** @var \DOMElement $parent */
 -              foreach (self::getParents($node) as $parent) {
 -                      // do not include <body>, <html> and the document itself
 -                      if ($parent->nodeName === 'body') break;
 -                      
 -                      $tree[] = $parent->cloneNode(false);
 -              }
 -              
 -              return $tree;
 -      }
 -      
 -      /**
 -       * Determines the relative position of two nodes to each other.
 -       * 
 -       * @param       \DOMNode        $node1          first node
 -       * @param       \DOMNode        $node2          second node
 -       * @return      string
 -       */
 -      public static function getRelativePosition(\DOMNode $node1, \DOMNode $node2) {
 -              if ($node1->ownerDocument !== $node2->ownerDocument) {
 -                      throw new \InvalidArgumentException("Both nodes must be contained in the same DOM document.");
 -              }
 -              
 -              $nodeList1 = self::getParents($node1, true);
 -              $nodeList1[] = $node1;
 -              
 -              $nodeList2 = self::getParents($node2, true);
 -              $nodeList2[] = $node2;
 -              
 -              $i = 0;
 -              while ($nodeList1[$i] === $nodeList2[$i]) {
 -                      $i++;
 -              }
 -              
 -              // check if parent of node 2 appears before parent of node 1
 -              $previousSibling = $nodeList1[$i];
 -              while ($previousSibling = $previousSibling->previousSibling) {
 -                      if ($previousSibling === $nodeList2[$i]) {
 -                              return 'before';
 -                      }
 -              }
 -              
 -              $nextSibling = $nodeList1[$i];
 -              while ($nextSibling = $nextSibling->nextSibling) {
 -                      if ($nextSibling === $nodeList2[$i]) {
 -                              return 'after';
 -                      }
 -              }
 -              
 -              throw new \RuntimeException("Unable to determine relative node position.");
 -      }
 -      
 -      /**
 -       * Returns true if there is at least one parent with the provided tag name.
 -       * 
 -       * @param       \DOMElement     $element        start element
 -       * @param       string          $tagName        tag name to match
 -       * @return      boolean         
 -       */
 -      public static function hasParent(\DOMElement $element, $tagName) {
 -              while ($element = $element->parentNode) {
 -                      if ($element->nodeName === $tagName) {
 -                              return true;
 -                      }
 -              }
 -              
 -              return false;
 -      }
 -      
 -      /**
 -       * Inserts given DOM node after the reference node.
 -       * 
 -       * @param       \DOMNode        $node           node
 -       * @param       \DOMNode        $refNode        reference node
 -       */
 -      public static function insertAfter(\DOMNode $node, \DOMNode $refNode) {
 -              if ($refNode->nextSibling) {
 -                      self::insertBefore($node, $refNode->nextSibling);
 -              }
 -              else {
 -                      self::getParentNode($refNode)->appendChild($node);
 -              }
 -      }
 -      
 -      /**
 -       * Inserts given node before the reference node.
 -       * 
 -       * @param       \DOMNode        $node           node
 -       * @param       \DOMNode        $refNode        reference node
 -       */
 -      public static function insertBefore(\DOMNode $node, \DOMNode $refNode) {
 -              self::getParentNode($refNode)->insertBefore($node, $refNode);
 -      }
 -      
 -      /**
 -       * Returns true if this node is empty.
 -       * 
 -       * @param       \DOMNode        $node           node
 -       * @return      boolean         true if node is empty
 -       */
 -      public static function isEmpty(\DOMNode $node) {
 -              if ($node->nodeType === XML_TEXT_NODE) {
 -                      return (StringUtil::trim($node->nodeValue) === '');
 -              }
 -              else if ($node->nodeType === XML_ELEMENT_NODE) {
 -                      /** @var \DOMElement $node */
 -                      if (self::isVoidElement($node)) {
 -                              return false;
 -                      }
 -                      else if ($node->hasChildNodes()) {
 -                              for ($i = 0, $length = $node->childNodes->length; $i < $length; $i++) {
 -                                      if (!self::isEmpty($node->childNodes->item($i))) {
 -                                              return false;
 -                                      }
 -                              }
 -                      }
 -                      
 -                      return true;
 -              }
 -              
 -              return true;
 -      }
 -      
 -      /**
 -       * Returns true if given node is the first node of its given ancestor.
 -       * 
 -       * @param       \DOMNode        $node           node
 -       * @param       \DOMElement     $ancestor       ancestor element
 -       * @return      boolean         true if `$node` is the first node of its given ancestor
 -       */
 -      public static function isFirstNode(\DOMNode $node, \DOMElement $ancestor) {
 -              if ($node->previousSibling === null) {
 -                      if ($node->parentNode === $ancestor || $node->parentNode->nodeName === 'body') {
 -                              return true;
 -                      }
 -                      else {
 -                              return self::isFirstNode($node->parentNode, $ancestor);
 -                      }
 -              }
 -              else if ($node->parentNode->nodeName === 'body') {
 -                      return true;
 -              }
 -              
 -              return false;
 -      }
 -      
 -      /**
 -       * Returns true if given node is the last node of its given ancestor.
 -       * 
 -       * @param       \DOMNode        $node           node
 -       * @param       \DOMElement     $ancestor       ancestor element
 -       * @return      boolean         true if `$node` is the last node of its given ancestor
 -       */
 -      public static function isLastNode(\DOMNode $node, \DOMElement $ancestor) {
 -              if ($node->nextSibling === null) {
 -                      if ($node->parentNode === null) {
 -                              throw new \InvalidArgumentException("Provided node is a not a descendant of ancestor element.");
 -                      }
 -                      else if ($node->parentNode === $ancestor || $node->parentNode->nodeName === 'body') {
 -                              return true;
 -                      }
 -                      else {
 -                              return self::isLastNode($node->parentNode, $ancestor);
 -                      }
 -              }
 -              else if ($node->parentNode->nodeName === 'body') {
 -                      return true;
 -              }
 -              
 -              return false;
 -      }
 -      
 -      /**
 -       * Nodes can get partially destroyed in which they're still an
 -       * actual DOM node (such as \DOMElement) but almost their entire
 -       * body is gone, including the `nodeType` attribute.
 -       * 
 -       * @param       \DOMNode        $node           node
 -       * @return      boolean         true if node has been destroyed
 -       */
 -      public static function isRemoved(\DOMNode $node) {
 -              return !isset($node->nodeType);
 -      }
 -      
 -      /**
 -       * Returns true if provided element is a void element. Void elements are elements
 -       * that neither contain content nor have a closing tag, such as `<br>`.
 -       * 
 -       * @param       \DOMElement     $element        element
 -       * @return      boolean true if provided element is a void element
 -       */
 -      public static function isVoidElement(\DOMElement $element) {
 -              if (preg_match('~^(area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$~', $element->nodeName)) {
 -                      return true;
 -              }
 -              
 -              return false;
 -      }
 -      
 -      /**
 -       * Moves all nodes into `$container` until it reaches `$lastElement`. The direction
 -       * in which nodes will be considered for moving is determined by the logical position
 -       * of `$lastElement`.
 -       * 
 -       * @param       \DOMElement     $container              destination element
 -       * @param       \DOMElement     $lastElement            last element to move
 -       * @param       \DOMElement     $commonAncestor         common ancestor of `$container` and `$lastElement`
 -       */
 -      public static function moveNodesInto(\DOMElement $container, \DOMElement $lastElement, \DOMElement $commonAncestor) {
 -              if (!self::contains($commonAncestor, $container)) {
 -                      throw new \InvalidArgumentException("The container element must be a child of the common ancestor element.");
 -              }
 -              else if ($lastElement->parentNode !== $commonAncestor) {
 -                      throw new \InvalidArgumentException("The last element must be a direct child of the common ancestor element.");
 -              }
 -              
 -              $relativePosition = self::getRelativePosition($container, $lastElement);
 -              
 -              // move everything that is logically after `$container` but within
 -              // `$commonAncestor` into `$container` until `$lastElement` has been moved
 -              $element = $container;
 -              do {
 -                      if ($relativePosition === 'before') {
 -                              while ($sibling = $element->previousSibling) {
 -                                      self::prepend($sibling, $container);
 -                                      if ($sibling === $lastElement) {
 -                                              return;
 -                                      }
 -                              }
 -                      }
 -                      else {
 -                              while ($sibling = $element->nextSibling) {
 -                                      $container->appendChild($sibling);
 -                                      if ($sibling === $lastElement) {
 -                                              return;
 -                                      }
 -                              }
 -                      }
 -                      
 -                      $element = $element->parentNode;
 -              }
 -              while ($element !== $commonAncestor);
 -      }
 -      
 -      /**
 -       * Normalizes an element by joining adjacent text nodes.
 -       * 
 -       * @param       \DOMElement     $element        target element
 -       */
 -      public static function normalize(\DOMElement $element) {
 -              $childNodes = self::getChildNodes($element);
 -              /** @var \DOMNode $lastTextNode */
 -              $lastTextNode = null;
 -              foreach ($childNodes as $childNode) {
 -                      if ($childNode->nodeType !== XML_TEXT_NODE) {
 -                              $lastTextNode = null;
 -                              continue;
 -                      }
 -                      
 -                      if ($lastTextNode === null) {
 -                              $lastTextNode = $childNode;
 -                      }
 -                      else {
 -                              // merge with last text node
 -                              $newTextNode = $childNode->ownerDocument->createTextNode($lastTextNode->textContent . $childNode->textContent);
 -                              $element->insertBefore($newTextNode, $lastTextNode);
 -                              
 -                              $element->removeChild($lastTextNode);
 -                              $element->removeChild($childNode);
 -                              
 -                              $lastTextNode = $newTextNode;
 -                      }
 -              }
 -      }
 -      
 -      /**
 -       * Prepends a node to provided element.
 -       * 
 -       * @param       \DOMNode        $node           node
 -       * @param       \DOMElement     $element        target element
 -       */
 -      public static function prepend(\DOMNode $node, \DOMElement $element) {
 -              if ($element->firstChild === null) {
 -                      $element->appendChild($node);
 -              }
 -              else {
 -                      $element->insertBefore($node, $element->firstChild);
 -              }
 -      }
 -      
 -      /**
 -       * Removes a node, optionally preserves the child nodes if `$node` is an element.
 -       * 
 -       * @param       \DOMNode        $node                   target node
 -       * @param       boolean         $preserveChildNodes     preserve child nodes, only supported for elements
 -       */
 -      public static function removeNode(\DOMNode $node, $preserveChildNodes = false) {
 -              $parent = $node->parentNode ?: $node->ownerDocument;
 -
 -              if ($preserveChildNodes) {
 -                      if (!($node instanceof \DOMElement)) {
 -                              throw new \InvalidArgumentException("Preserving child nodes is only supported for DOMElement.");
 -                      }
 -                      
 -                      $children = [];
 -                      foreach ($node->childNodes as $childNode) {
 -                              $children[] = $childNode;
 -                      }
 -
 -                      foreach ($children as $child) {
 -                              $parent->insertBefore($child, $node);
 -                      }
 -              }
 -              
 -              $parent->removeChild($node);
 -      }
 -      
 -      /**
 -       * Replaces a DOM element with another, preserving all child nodes by default.
 -       * 
 -       * @param       \DOMElement     $oldElement             old element
 -       * @param       \DOMElement     $newElement             new element
 -       * @param       boolean         $preserveChildNodes     true if child nodes should be moved, otherwise they'll be implicitly removed
 -       */
 -      public static function replaceElement(\DOMElement $oldElement, \DOMElement $newElement, $preserveChildNodes = true) {
 -              self::insertBefore($newElement, $oldElement);
 -              
 -              // move all child nodes
 -              if ($preserveChildNodes) {
 -                      while ($oldElement->hasChildNodes()) {
 -                              $newElement->appendChild($oldElement->childNodes->item(0));
 -                      }
 -              }
 -              
 -              // remove old element
 -              self::getParentNode($oldElement)->removeChild($oldElement);
 -      }
 -      
 -      /**
 -       * Splits all parent nodes until `$ancestor` and moved other nodes after/before
 -       * (determined by `$splitBefore`) into the newly created nodes. This allows
 -       * extraction of DOM parts while preserving nesting for both the extracted nodes
 -       * and the remaining siblings.
 -       * 
 -       * @param       \DOMNode        $node           reference node
 -       * @param       \DOMElement     $ancestor       ancestor element that should not be split
 -       * @param       boolean         $splitBefore    true if nodes before `$node` should be moved into a new node, false to split nodes after `$node`
 -       * @return      \DOMNode        parent node containing `$node`, direct child of `$ancestor`
 -       */
 -      public static function splitParentsUntil(\DOMNode $node, \DOMElement $ancestor, $splitBefore = true) {
 -              if (!self::contains($ancestor, $node)) {
 -                      throw new \InvalidArgumentException("Node is not contained in ancestor node.");
 -              }
 -              
 -              // clone the parent node right "below" `$ancestor`
 -              $cloneNode = self::getParentBefore($node, $ancestor);
 -              
 -              if ($splitBefore) {
 -                      if ($cloneNode === null) {
 -                              // target node is already a direct descendant of the ancestor
 -                              // node, no need to split anything
 -                              return $node;
 -                      }
 -                      else if (self::isFirstNode($node, $cloneNode)) {
 -                              // target node is at the very start, we can safely move the
 -                              // entire parent node around
 -                              return $cloneNode;
 -                      }
 -                      
 -                      $currentNode = $node;
 -                      while (($parent = $currentNode->parentNode) !== $ancestor) {
 -                              /** @var \DOMElement $newNode */
 -                              $newNode = $parent->cloneNode();
 -                              self::insertBefore($newNode, $parent);
 -                              
 -                              while ($currentNode->previousSibling) {
 -                                      self::prepend($currentNode->previousSibling, $newNode);
 -                              }
 -                              
 -                              $currentNode = $parent;
 -                      }
 -              }
 -              else {
 -                      if ($cloneNode === null) {
 -                              // target node is already a direct descendant of the ancestor
 -                              // node, no need to split anything
 -                              return $node;
 -                      }
 -                      else if (self::isLastNode($node, $cloneNode)) {
 -                              // target node is at the very end, we can safely move the
 -                              // entire parent node around
 -                              return $cloneNode;
 -                      }
 -                      
 -                      $currentNode = $node;
 -                      while (($parent = $currentNode->parentNode) !== $ancestor) {
 -                              $newNode = $parent->cloneNode();
 -                              self::insertAfter($newNode, $parent);
 -                              
 -                              while ($currentNode->nextSibling) {
 -                                      $newNode->appendChild($currentNode->nextSibling);
 -                              }
 -                              
 -                              $currentNode = $parent;
 -                      }
 -              }
 -              
 -              return self::getParentBefore($node, $ancestor);
 -      }
 -      
 -      /**
 -       * Forbid creation of DOMUtil objects.
 -       */
 -      private function __construct() {
 -              // does nothing
 -      }
 +final class DOMUtil
 +{
 +    /**
 +     * Moves all child nodes from given element into a document fragment.
 +     *
 +     * @param \DOMElement $element element
 +     * @return  \DOMDocumentFragment        document fragment containing all child nodes from `$element`
 +     */
 +    public static function childNodesToFragment(\DOMElement $element)
 +    {
 +        $fragment = $element->ownerDocument->createDocumentFragment();
 +
 +        while ($element->hasChildNodes()) {
 +            $fragment->appendChild($element->childNodes->item(0));
 +        }
 +
 +        return $fragment;
 +    }
 +
 +    /**
 +     * Returns true if `$ancestor` contains the node `$node`.
 +     *
 +     * @param \DOMNode $ancestor ancestor node
 +     * @param \DOMNode $node node
 +     * @return  bool        true if `$ancestor` contains the node `$node`
 +     */
 +    public static function contains(\DOMNode $ancestor, \DOMNode $node)
 +    {
 +        // nodes cannot contain themselves
 +        if ($ancestor === $node) {
 +            return false;
 +        }
 +
 +        // text nodes cannot contain any other nodes
 +        if ($ancestor->nodeType === \XML_TEXT_NODE) {
 +            return false;
 +        }
 +
 +        $parent = $node;
 +        while ($parent = $parent->parentNode) {
 +            if ($parent === $ancestor) {
 +                return true;
 +            }
 +        }
 +
 +        return false;
 +    }
 +
 +    /**
 +     * Returns a static list of child nodes of provided element.
 +     *
 +     * @param \DOMElement $element target element
 +     * @return      \DOMNode[]      list of child nodes
 +     */
 +    public static function getChildNodes(\DOMElement $element)
 +    {
 +        $nodes = [];
 +        foreach ($element->childNodes as $node) {
 +            $nodes[] = $node;
 +        }
 +
 +        return $nodes;
 +    }
 +
 +    /**
 +     * Returns the common ancestor of both nodes.
 +     *
 +     * @param \DOMNode $node1 first node
 +     * @param \DOMNode $node2 second node
 +     * @return  \DOMNode|null   common ancestor or null
 +     */
 +    public static function getCommonAncestor(\DOMNode $node1, \DOMNode $node2)
 +    {
 +        // abort if both elements share a common element or are both direct descendants
 +        // of the same document
 +        if ($node1->parentNode === $node2->parentNode) {
 +            return $node1->parentNode;
 +        }
 +
 +        // collect the list of all direct ancestors of `$node1`
 +        $parents = self::getParents($node1);
 +
 +        // compare each ancestor of `$node2` to the known list of parents of `$node1`
 +        $parent = $node2;
 +        while ($parent = $parent->parentNode) {
 +            // requires strict type check
 +            if (\in_array($parent, $parents, true)) {
 +                return $parent;
 +            }
 +        }
 +    }
 +
 +    /**
 +     * Returns a non-live collection of elements.
 +     *
 +     * @param       (\DOMDocument|\DOMElement)      $context        context element
 +     * @param string $tagName tag name
 +     * @return      \DOMElement[]                   list of elements
 +     * @throws      SystemException
 +     */
 +    public static function getElements($context, $tagName)
 +    {
 +        if (!($context instanceof \DOMDocument) && !($context instanceof \DOMElement)) {
 +            throw new SystemException("Expected context to be either of type \\DOMDocument or \\DOMElement.");
 +        }
 +
 +        $elements = [];
 +        foreach ($context->getElementsByTagName($tagName) as $element) {
 +            $elements[] = $element;
 +        }
 +
 +        return $elements;
 +    }
 +
 +    /**
 +     * Returns the immediate parent element before provided ancestor element. Returns null if
 +     * the ancestor element is the direct parent of provided node.
 +     *
 +     * @param \DOMNode $node node
 +     * @param \DOMElement $ancestor ancestor node
 +     * @return  \DOMElement|null    immediate parent element before ancestor element
 +     */
 +    public static function getParentBefore(\DOMNode $node, \DOMElement $ancestor)
 +    {
 +        if ($node->parentNode === $ancestor) {
 +            return;
 +        }
 +
 +        $parents = self::getParents($node);
 +        for ($i = \count($parents) - 1; $i >= 0; $i--) {
 +            if ($parents[$i] === $ancestor) {
 +                return $parents[$i - 1];
 +            }
 +        }
 +
 +        throw new \InvalidArgumentException("Provided node is a not a descendant of ancestor element.");
 +    }
 +
 +    /**
 +     * Returns the parent node of given node.
 +     *
 +     * @param \DOMNode $node node
 +     * @return  \DOMNode    parent node, can be `\DOMElement` or `\DOMDocument`
 +     */
 +    public static function getParentNode(\DOMNode $node)
 +    {
 +        return $node->parentNode ?: $node->ownerDocument;
 +    }
 +
 +    /**
 +     * Returns all ancestors nodes for given node.
 +     *
 +     * @param \DOMNode $node node
 +     * @param bool $reverseOrder reversing the order causes the most top ancestor to appear first
 +     * @return  \DOMElement[]   list of ancestor nodes
 +     */
 +    public static function getParents(\DOMNode $node, $reverseOrder = false)
 +    {
 +        $parents = [];
 +
 +        $parent = $node;
 +        while ($parent = $parent->parentNode) {
 +            $parents[] = $parent;
 +        }
 +
 +        return $reverseOrder ? \array_reverse($parents) : $parents;
 +    }
 +
 +    /**
 +     * Returns a cloned parent tree that is virtually readonly. In fact it can be
 +     * modified, but all changes are non permanent and do not affect the source
 +     * document at all.
 +     *
 +     * @param \DOMNode $node node
 +     * @return      \DOMElement[]   list of parent elements
 +     */
 +    public static function getReadonlyParentTree(\DOMNode $node)
 +    {
 +        $tree = [];
 +        /** @var \DOMElement $parent */
 +        foreach (self::getParents($node) as $parent) {
 +            // do not include <body>, <html> and the document itself
 +            if ($parent->nodeName === 'body') {
 +                break;
 +            }
 +
 +            $tree[] = $parent->cloneNode(false);
 +        }
 +
 +        return $tree;
 +    }
 +
 +    /**
 +     * Determines the relative position of two nodes to each other.
 +     *
 +     * @param \DOMNode $node1 first node
 +     * @param \DOMNode $node2 second node
 +     * @return  string
 +     */
 +    public static function getRelativePosition(\DOMNode $node1, \DOMNode $node2)
 +    {
 +        if ($node1->ownerDocument !== $node2->ownerDocument) {
 +            throw new \InvalidArgumentException("Both nodes must be contained in the same DOM document.");
 +        }
 +
 +        $nodeList1 = self::getParents($node1, true);
 +        $nodeList1[] = $node1;
 +
 +        $nodeList2 = self::getParents($node2, true);
 +        $nodeList2[] = $node2;
 +
 +        $i = 0;
 +        while ($nodeList1[$i] === $nodeList2[$i]) {
 +            $i++;
 +        }
 +
 +        // check if parent of node 2 appears before parent of node 1
 +        $previousSibling = $nodeList1[$i];
 +        while ($previousSibling = $previousSibling->previousSibling) {
 +            if ($previousSibling === $nodeList2[$i]) {
 +                return 'before';
 +            }
 +        }
 +
 +        $nextSibling = $nodeList1[$i];
 +        while ($nextSibling = $nextSibling->nextSibling) {
 +            if ($nextSibling === $nodeList2[$i]) {
 +                return 'after';
 +            }
 +        }
 +
 +        throw new \RuntimeException("Unable to determine relative node position.");
 +    }
 +
 +    /**
 +     * Returns true if there is at least one parent with the provided tag name.
 +     *
 +     * @param \DOMElement $element start element
 +     * @param string $tagName tag name to match
 +     * @return      bool
 +     */
 +    public static function hasParent(\DOMElement $element, $tagName)
 +    {
 +        while ($element = $element->parentNode) {
 +            if ($element->nodeName === $tagName) {
 +                return true;
 +            }
 +        }
 +
 +        return false;
 +    }
 +
 +    /**
 +     * Inserts given DOM node after the reference node.
 +     *
 +     * @param \DOMNode $node node
 +     * @param \DOMNode $refNode reference node
 +     */
 +    public static function insertAfter(\DOMNode $node, \DOMNode $refNode)
 +    {
 +        if ($refNode->nextSibling) {
 +            self::insertBefore($node, $refNode->nextSibling);
 +        } else {
 +            self::getParentNode($refNode)->appendChild($node);
 +        }
 +    }
 +
 +    /**
 +     * Inserts given node before the reference node.
 +     *
 +     * @param \DOMNode $node node
 +     * @param \DOMNode $refNode reference node
 +     */
 +    public static function insertBefore(\DOMNode $node, \DOMNode $refNode)
 +    {
 +        self::getParentNode($refNode)->insertBefore($node, $refNode);
 +    }
 +
 +    /**
 +     * Returns true if this node is empty.
 +     *
 +     * @param \DOMNode $node node
 +     * @return  bool        true if node is empty
 +     */
 +    public static function isEmpty(\DOMNode $node)
 +    {
 +        if ($node->nodeType === \XML_TEXT_NODE) {
 +            return StringUtil::trim($node->nodeValue) === '';
 +        } elseif ($node->nodeType === \XML_ELEMENT_NODE) {
 +            /** @var \DOMElement $node */
 +            if (self::isVoidElement($node)) {
 +                return false;
 +            } elseif ($node->hasChildNodes()) {
 +                for ($i = 0, $length = $node->childNodes->length; $i < $length; $i++) {
 +                    if (!self::isEmpty($node->childNodes->item($i))) {
 +                        return false;
 +                    }
 +                }
 +            }
 +
 +            return true;
 +        }
 +
 +        return true;
 +    }
 +
 +    /**
 +     * Returns true if given node is the first node of its given ancestor.
 +     *
 +     * @param \DOMNode $node node
 +     * @param \DOMElement $ancestor ancestor element
 +     * @return  bool        true if `$node` is the first node of its given ancestor
 +     */
 +    public static function isFirstNode(\DOMNode $node, \DOMElement $ancestor)
 +    {
 +        if ($node->previousSibling === null) {
 +            if ($node->parentNode === $ancestor || $node->parentNode->nodeName === 'body') {
 +                return true;
 +            } else {
 +                return self::isFirstNode($node->parentNode, $ancestor);
 +            }
 +        } elseif ($node->parentNode->nodeName === 'body') {
 +            return true;
 +        }
 +
 +        return false;
 +    }
 +
 +    /**
 +     * Returns true if given node is the last node of its given ancestor.
 +     *
 +     * @param \DOMNode $node node
 +     * @param \DOMElement $ancestor ancestor element
 +     * @return  bool        true if `$node` is the last node of its given ancestor
 +     */
 +    public static function isLastNode(\DOMNode $node, \DOMElement $ancestor)
 +    {
 +        if ($node->nextSibling === null) {
 +            if ($node->parentNode === null) {
 +                throw new \InvalidArgumentException("Provided node is a not a descendant of ancestor element.");
 +            } elseif ($node->parentNode === $ancestor || $node->parentNode->nodeName === 'body') {
 +                return true;
 +            } else {
 +                return self::isLastNode($node->parentNode, $ancestor);
 +            }
 +        } elseif ($node->parentNode->nodeName === 'body') {
 +            return true;
 +        }
 +
 +        return false;
 +    }
 +
 +    /**
 +     * Nodes can get partially destroyed in which they're still an
 +     * actual DOM node (such as \DOMElement) but almost their entire
 +     * body is gone, including the `nodeType` attribute.
 +     *
 +     * @param \DOMNode $node node
 +     * @return      bool         true if node has been destroyed
 +     */
 +    public static function isRemoved(\DOMNode $node)
 +    {
 +        return !isset($node->nodeType);
 +    }
 +
 +    /**
 +     * Returns true if provided element is a void element. Void elements are elements
 +     * that neither contain content nor have a closing tag, such as `<br>`.
 +     *
 +     * @param \DOMElement $element element
 +     * @return  bool    true if provided element is a void element
 +     */
 +    public static function isVoidElement(\DOMElement $element)
 +    {
 +        if (
 +            \preg_match(
 +                '~^(area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$~',
 +                $element->nodeName
 +            )
 +        ) {
 +            return true;
 +        }
 +
 +        return false;
 +    }
 +
 +    /**
 +     * Moves all nodes into `$container` until it reaches `$lastElement`. The direction
 +     * in which nodes will be considered for moving is determined by the logical position
 +     * of `$lastElement`.
 +     *
 +     * @param \DOMElement $container destination element
 +     * @param \DOMElement $lastElement last element to move
 +     * @param \DOMElement $commonAncestor common ancestor of `$container` and `$lastElement`
 +     */
 +    public static function moveNodesInto(\DOMElement $container, \DOMElement $lastElement, \DOMElement $commonAncestor)
 +    {
 +        if (!self::contains($commonAncestor, $container)) {
 +            throw new \InvalidArgumentException(
 +                "The container element must be a child of the common ancestor element."
 +            );
 +        } elseif ($lastElement->parentNode !== $commonAncestor) {
 +            throw new \InvalidArgumentException(
 +                "The last element must be a direct child of the common ancestor element."
 +            );
 +        }
 +
 +        $relativePosition = self::getRelativePosition($container, $lastElement);
 +
 +        // move everything that is logically after `$container` but within
 +        // `$commonAncestor` into `$container` until `$lastElement` has been moved
 +        $element = $container;
 +        do {
 +            if ($relativePosition === 'before') {
 +                while ($sibling = $element->previousSibling) {
 +                    self::prepend($sibling, $container);
 +                    if ($sibling === $lastElement) {
 +                        return;
 +                    }
 +                }
 +            } else {
 +                while ($sibling = $element->nextSibling) {
 +                    $container->appendChild($sibling);
 +                    if ($sibling === $lastElement) {
 +                        return;
 +                    }
 +                }
 +            }
 +
 +            $element = $element->parentNode;
 +        } while ($element !== $commonAncestor);
 +    }
 +
 +    /**
 +     * Normalizes an element by joining adjacent text nodes.
 +     *
 +     * @param \DOMElement $element target element
 +     */
 +    public static function normalize(\DOMElement $element)
 +    {
 +        $childNodes = self::getChildNodes($element);
 +        /** @var \DOMNode $lastTextNode */
 +        $lastTextNode = null;
 +        foreach ($childNodes as $childNode) {
 +            if ($childNode->nodeType !== \XML_TEXT_NODE) {
 +                $lastTextNode = null;
 +                continue;
 +            }
 +
 +            if ($lastTextNode === null) {
 +                $lastTextNode = $childNode;
 +            } else {
 +                // merge with last text node
 +                $newTextNode = $childNode
 +                    ->ownerDocument
 +                    ->createTextNode($lastTextNode->textContent . $childNode->textContent);
 +                $element->insertBefore($newTextNode, $lastTextNode);
 +
 +                $element->removeChild($lastTextNode);
 +                $element->removeChild($childNode);
 +
 +                $lastTextNode = $newTextNode;
 +            }
 +        }
 +    }
 +
 +    /**
 +     * Prepends a node to provided element.
 +     *
 +     * @param \DOMNode $node node
 +     * @param \DOMElement $element target element
 +     */
 +    public static function prepend(\DOMNode $node, \DOMElement $element)
 +    {
 +        if ($element->firstChild === null) {
 +            $element->appendChild($node);
 +        } else {
 +            $element->insertBefore($node, $element->firstChild);
 +        }
 +    }
 +
 +    /**
 +     * Removes a node, optionally preserves the child nodes if `$node` is an element.
 +     *
 +     * @param \DOMNode $node target node
 +     * @param bool $preserveChildNodes preserve child nodes, only supported for elements
 +     */
 +    public static function removeNode(\DOMNode $node, $preserveChildNodes = false)
 +    {
++        $parent = $node->parentNode ?: $node->ownerDocument;
++        
 +        if ($preserveChildNodes) {
 +            if (!($node instanceof \DOMElement)) {
 +                throw new \InvalidArgumentException("Preserving child nodes is only supported for DOMElement.");
 +            }
 +
-             while ($node->hasChildNodes()) {
-                 self::insertBefore($node->childNodes->item(0), $node);
++            $children = [];
++            foreach ($node->childNodes as $childNode) {
++                $children[] = $childNode;
++            }
++
++            foreach ($children as $child) {
++                $parent->insertBefore($child, $node);
 +            }
 +        }
 +
-         self::getParentNode($node)->removeChild($node);
++        $parent->removeChild($node);
 +    }
 +
 +    /**
 +     * Replaces a DOM element with another, preserving all child nodes by default.
 +     *
 +     * @param \DOMElement $oldElement old element
 +     * @param \DOMElement $newElement new element
 +     * @param bool $preserveChildNodes true if child nodes should be moved, otherwise they'll be implicitly removed
 +     */
 +    public static function replaceElement(\DOMElement $oldElement, \DOMElement $newElement, $preserveChildNodes = true)
 +    {
 +        self::insertBefore($newElement, $oldElement);
 +
 +        // move all child nodes
 +        if ($preserveChildNodes) {
 +            while ($oldElement->hasChildNodes()) {
 +                $newElement->appendChild($oldElement->childNodes->item(0));
 +            }
 +        }
 +
 +        // remove old element
 +        self::getParentNode($oldElement)->removeChild($oldElement);
 +    }
 +
 +    /**
 +     * Splits all parent nodes until `$ancestor` and moved other nodes after/before
 +     * (determined by `$splitBefore`) into the newly created nodes. This allows
 +     * extraction of DOM parts while preserving nesting for both the extracted nodes
 +     * and the remaining siblings.
 +     *
 +     * @param \DOMNode $node reference node
 +     * @param \DOMElement $ancestor ancestor element that should not be split
 +     * @param bool $splitBefore true if nodes before `$node` should be moved into a new node, false to split nodes after `$node`
 +     * @return  \DOMNode    parent node containing `$node`, direct child of `$ancestor`
 +     */
 +    public static function splitParentsUntil(\DOMNode $node, \DOMElement $ancestor, $splitBefore = true)
 +    {
 +        if (!self::contains($ancestor, $node)) {
 +            throw new \InvalidArgumentException("Node is not contained in ancestor node.");
 +        }
 +
 +        // clone the parent node right "below" `$ancestor`
 +        $cloneNode = self::getParentBefore($node, $ancestor);
 +
 +        if ($splitBefore) {
 +            if ($cloneNode === null) {
 +                // target node is already a direct descendant of the ancestor
 +                // node, no need to split anything
 +                return $node;
 +            } elseif (self::isFirstNode($node, $cloneNode)) {
 +                // target node is at the very start, we can safely move the
 +                // entire parent node around
 +                return $cloneNode;
 +            }
 +
 +            $currentNode = $node;
 +            while (($parent = $currentNode->parentNode) !== $ancestor) {
 +                /** @var \DOMElement $newNode */
 +                $newNode = $parent->cloneNode();
 +                self::insertBefore($newNode, $parent);
 +
 +                while ($currentNode->previousSibling) {
 +                    self::prepend($currentNode->previousSibling, $newNode);
 +                }
 +
 +                $currentNode = $parent;
 +            }
 +        } else {
 +            if ($cloneNode === null) {
 +                // target node is already a direct descendant of the ancestor
 +                // node, no need to split anything
 +                return $node;
 +            } elseif (self::isLastNode($node, $cloneNode)) {
 +                // target node is at the very end, we can safely move the
 +                // entire parent node around
 +                return $cloneNode;
 +            }
 +
 +            $currentNode = $node;
 +            while (($parent = $currentNode->parentNode) !== $ancestor) {
 +                $newNode = $parent->cloneNode();
 +                self::insertAfter($newNode, $parent);
 +
 +                while ($currentNode->nextSibling) {
 +                    $newNode->appendChild($currentNode->nextSibling);
 +                }
 +
 +                $currentNode = $parent;
 +            }
 +        }
 +
 +        return self::getParentBefore($node, $ancestor);
 +    }
 +
 +    /**
 +     * Forbid creation of DOMUtil objects.
 +     */
 +    private function __construct()
 +    {
 +        // does nothing
 +    }
  }