1<?php
2
3/**
4 * Copyright (c) 2021. ComboStrap, Inc. and its affiliates. All Rights Reserved.
5 *
6 * This source code is licensed under the GPL license found in the
7 * COPYING  file in the root directory of this source tree.
8 *
9 * @license  GPL 3 (https://www.gnu.org/licenses/gpl-3.0.en.html)
10 * @author   ComboStrap <support@combostrap.com>
11 *
12 */
13
14namespace ComboStrap\Xml;
15
16use ComboStrap\ExceptionBadState;
17use ComboStrap\ExceptionBadSyntax;
18use ComboStrap\ExceptionNotFound;
19use ComboStrap\FileSystems;
20use ComboStrap\LogUtility;
21use ComboStrap\Path;
22use ComboStrap\PluginUtility;
23use DOMAttr;
24use DOMDocument;
25use DOMElement;
26use DOMNodeList;
27use DOMXPath;
28use LibXMLError;
29use PhpCss;
30
31
32/**
33 * A xml document that follows the Web Api interface.
34 *
35 * Note Dokuwiki now uses since [jack_jackrum](https://www.dokuwiki.org/changes#release_2023-04-04_jack_jackrum):
36 * the [dom-wrapper](https://github.com/scotteh/php-dom-wrapper)
37 * that follow the Jquery API and uses [css-selector](https://symfony.com/doc/current/components/css_selector.html)
38 * to get Xpath expression from Css selector
39 *
40 */
41class XmlDocument
42{
43    const HTML_TYPE = "html";
44    const XML_TYPE = "xml";
45    /**
46     * The error that the HTML loading
47     * may returns
48     */
49    const KNOWN_HTML_LOADING_ERRORS = [
50        "Tag section invalid\n", // section is HTML5 tag
51        "Tag footer invalid\n", // footer is HTML5 tag
52        "error parsing attribute name\n", // name is an HTML5 attribute
53        "Unexpected end tag : blockquote\n", // name is an HTML5 attribute
54        "Tag bdi invalid\n",
55        "Tag path invalid\n", // svg
56        "Tag svg invalid\n", // svg
57        "Unexpected end tag : a\n", // when the document is only a anchor
58        "Unexpected end tag : p\n", // when the document is only a p
59        "Unexpected end tag : button\n", // when the document is only a button
60    ];
61
62    const CANONICAL = "xml";
63
64    /**
65     * @var DOMDocument
66     */
67    private DOMDocument $domDocument;
68    /**
69     * @var DOMXPath
70     */
71    private DOMXPath $domXpath;
72
73    /**
74     * XmlFile constructor.
75     * @param $text
76     * @param string $type - HTML or not
77     * @throws ExceptionBadSyntax - if the document is not valid or the lib xml is not available
78     *
79     * Getting the width of an error HTML document if the file was downloaded
80     * from a server has no use at all
81     */
82    public function __construct($text, string $type = self::XML_TYPE)
83    {
84
85        if (empty($text)) {
86            throw new ExceptionBadSyntax("The xml text markup should not be empty.", self::CANONICAL);
87        }
88        if (!$this->isXmlExtensionLoaded()) {
89            /**
90             * If the XML module is not present
91             */
92            throw new ExceptionBadSyntax("The php `libxml` module was not found on your installation, the xml/svg file could not be modified / instantiated", self::CANONICAL);
93        }
94
95        // https://www.php.net/manual/en/libxml.constants.php
96        $options = LIBXML_NOCDATA
97            // | LIBXML_NOBLANKS // same as preserveWhiteSpace=true, not set to be able to format the output
98            | LIBXML_NOXMLDECL // Drop the XML declaration when saving a document
99            | LIBXML_NONET // No network during load
100            | LIBXML_NSCLEAN // Remove redundant namespace declarations - for whatever reason, the formatting does not work if this is set
101        ;
102
103        // HTML
104        if ($type == self::HTML_TYPE) {
105
106            // Options that cause the process to hang if this is not for a html file
107            // Empty tag option may also be used only on save
108            //   at https://www.php.net/manual/en/domdocument.save.php
109            //   and https://www.php.net/manual/en/domdocument.savexml.php
110            $options = $options
111                // | LIBXML_NOEMPTYTAG // Expand empty tags (e.g. <br/> to <br></br>)
112                | LIBXML_HTML_NODEFDTD // No doctype
113                | LIBXML_HTML_NOIMPLIED;
114
115
116        }
117
118        /**
119         * No warning reporting
120         * Load XML issue E_STRICT warning seen in the log
121         */
122        if (!PluginUtility::isTest()) {
123            $oldLevel = error_reporting(E_ERROR);
124        }
125
126        $this->domDocument = new DOMDocument('1.0', 'UTF-8');
127
128        $this->mandatoryFormatConfigBeforeLoading();
129
130
131        $text = $this->processTextBeforeLoading($text);
132
133        /**
134         * Because the load does handle HTML5tag as error
135         * (ie section for instance)
136         * We take over the errors and handle them after the below load
137         *
138         * https://www.php.net/manual/en/function.libxml-use-internal-errors.php
139         *
140         */
141        libxml_use_internal_errors(true);
142
143        if ($type == self::XML_TYPE) {
144
145            $result = $this->domDocument->loadXML($text, $options);
146
147        } else {
148
149            /**
150             * Unlike loading XML, HTML does not have to be well-formed to load.
151             * While malformed HTML should load successfully, this function may generate E_WARNING errors
152             * @deprecated as we try to be XHTML compliantXML but yeah this is not always possible
153             */
154
155            /**
156             * Bug: Even if we set that the document is an UTF-8
157             * loadHTML treat the string as being in ISO-8859-1 if without any heading
158             * (ie <xml encoding="utf-8"..>
159             * https://stackoverflow.com/questions/8218230/php-domdocument-loadhtml-not-encoding-utf-8-correctly
160             * Otherwise French and other language are not well loaded
161             *
162             * We use the trick to transform UTF-8 to HTML
163             */
164            $htmlEntityEncoded = mb_convert_encoding($text, 'HTML-ENTITIES', 'UTF-8');
165            $result = $this->domDocument->loadHTML($htmlEntityEncoded, $options);
166
167        }
168        if ($result === false) {
169
170            /**
171             * Error
172             */
173            $errors = libxml_get_errors();
174
175            foreach ($errors as $error) {
176
177                /* @var LibXMLError
178                 * @noinspection PhpComposerExtensionStubsInspection
179                 *
180                 * Section is an html5 tag (and is invalid for libxml)
181                 */
182                if (!in_array($error->message, self::KNOWN_HTML_LOADING_ERRORS)) {
183                    /**
184                     * This error is an XML and HTML error
185                     */
186                    if (
187                        strpos($error->message, "htmlParseEntityRef: expecting ';' in Entity") !== false
188                        ||
189                        $error->message == "EntityRef: expecting ';'\n"
190                    ) {
191                        $message = "There is big probability that there is an ampersand alone `&`. ie You forgot to call html/Xml entities in a `src` or `url` attribute.";
192                    } else {
193                        $message = "Error while loading HTML";
194                    }
195                    /**
196                     * inboolean attribute XML loading error
197                     */
198                    if (strpos($error->message, "Specification mandates value for attribute") !== false) {
199                        $message = "Xml does not allow boolean attribute (ie without any value). If you skip this error, you will get a general attribute constructing error as next error. Load as HTML.";
200                    }
201
202                    $message .= "Error: " . $error->message . ", Loaded text: " . $text;
203
204                    /**
205                     * We clean the errors, otherwise
206                     * in a test series, they failed the next test
207                     *
208                     */
209                    libxml_clear_errors();
210
211                    // The xml dom object is null, we got NULL pointer exception everywhere
212                    // just throw, the code will see it
213                    throw new ExceptionBadSyntax($message, self::CANONICAL);
214
215                }
216
217            }
218        }
219
220        /**
221         * We clean the known errors (otherwise they are added in a queue)
222         */
223        libxml_clear_errors();
224
225        /**
226         * Error reporting back
227         */
228        if (!PluginUtility::isTest() && isset($oldLevel)) {
229            error_reporting($oldLevel);
230        }
231
232        // namespace error : Namespace prefix dc on format is not defined
233        // missing the ns declaration in the file. example:
234        // xmlns:dc="http://purl.org/dc/elements/1.1/"
235
236
237    }
238
239    /**
240     * To not have a collusion with {@link FetcherSvg::createFetchImageSvgFromPath()}
241     * @param Path $path
242     * @return XmlDocument
243     * @throws ExceptionNotFound - if the file does not exist
244     * @throws ExceptionBadSyntax - if the content is not valid
245     */
246    public
247    static function createXmlDocFromPath(Path $path): XmlDocument
248    {
249        $mime = XmlDocument::XML_TYPE;
250        if (in_array($path->getExtension(), ["html", "htm"])) {
251            $mime = XmlDocument::HTML_TYPE;
252        }
253        $content = FileSystems::getContent($path);
254        return (new XmlDocument($content, $mime));
255    }
256
257    /**
258     *
259     * @throws ExceptionBadSyntax
260     */
261    public
262    static function createXmlDocFromMarkup($string, $asHtml = false): XmlDocument
263    {
264
265        $mime = XmlDocument::XML_TYPE;
266        if ($asHtml) {
267            $mime = XmlDocument::HTML_TYPE;
268        }
269        return new XmlDocument($string, $mime);
270    }
271
272    /**
273     * HTML loading is more permissive
274     *
275     * For instance, you would not get an error on boolean attribute
276     * ```
277     * Error while loading HTMLError: Specification mandates value for attribute defer
278     * ```
279     * In Xml, it's mandatory but not in HTML, they are known as:
280     * https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#boolean-attribute
281     *
282     *
283     * @throws ExceptionBadSyntax
284     */
285    public static function createHtmlDocFromMarkup($markup): XmlDocument
286    {
287        return self::createXmlDocFromMarkup($markup, true);
288    }
289
290    public
291    function &getDomDocument(): DOMDocument
292    {
293        return $this->domDocument;
294    }
295
296    /**
297     * @param $name
298     * @param $value
299     * @return void
300     * @deprecated use {@link XmlDocument::getElement()} instead
301     */
302    public function setRootAttribute($name, $value)
303    {
304        if ($this->isXmlExtensionLoaded()) {
305            $this->domDocument->documentElement->setAttribute($name, $value);
306        }
307    }
308
309    /**
310     * @param $name
311     * @return string null if not found
312     * @deprecated uses {@link XmlElement::getAttribute()} of {@link self::getElement()}
313     */
314    public function getRootAttributeValue($name): ?string
315    {
316        $value = $this->domDocument->documentElement->getAttribute($name);
317        if ($value === "") {
318            return null;
319        }
320        return $value;
321    }
322
323    public function toXhtml(DOMElement $element = null): string
324    {
325        return $this->toXml($element);
326    }
327
328    public function toXml(DOMElement $element = null): string
329    {
330
331        if ($element === null) {
332            $element = $this->getDomDocument()->documentElement;
333        }
334        /**
335         * LIBXML_NOXMLDECL (no xml declaration) does not work because only empty tag is recognized
336         * https://www.php.net/manual/en/domdocument.savexml.php
337         */
338        $xmlText = $this->getDomDocument()->saveXML(
339            $element,
340            LIBXML_NOXMLDECL
341        );
342        // Delete doctype (for svg optimization)
343        // php has only doctype manipulation for HTML
344        $xmlText = preg_replace('/^<!DOCTYPE.+?>/', '', $xmlText);
345        return trim($xmlText);
346
347    }
348
349    /**
350     * https://www.php.net/manual/en/dom.installation.php
351     *
352     * Check it with
353     * ```
354     * php -m
355     * ```
356     * Install with
357     * ```
358     * sudo apt-get install php-xml
359     * ```
360     * @return bool
361     */
362    public function isXmlExtensionLoaded(): bool
363    {
364        // A suffix used in the bad message
365        $suffixBadMessage = "php extension is not installed. To install it, you need to install xml. Example: `sudo apt-get install php-xml`, `yum install php-xml`";
366
367        // https://www.php.net/manual/en/dom.requirements.php
368        $loaded = extension_loaded("libxml");
369        if ($loaded === false) {
370            LogUtility::msg("The libxml {$suffixBadMessage}");
371        } else {
372            $loaded = extension_loaded("xml");
373            if ($loaded === false) {
374                LogUtility::msg("The xml {$suffixBadMessage}");
375            } else {
376                $loaded = extension_loaded("dom");
377                if ($loaded === false) {
378                    LogUtility::msg("The dom {$suffixBadMessage}");
379                }
380            }
381        }
382        return $loaded;
383    }
384
385    /**
386     * https://stackoverflow.com/questions/30257438/how-to-completely-remove-a-namespace-using-domdocument
387     * @param $namespaceUri
388     */
389    function removeNamespace($namespaceUri)
390    {
391        if (empty($namespaceUri)) {
392            throw new \RuntimeException("The namespace is empty and should be specified");
393        }
394
395        if (strpos($namespaceUri, "http") === false) {
396            LogUtility::msg("Internal warning: The namespaceURI ($namespaceUri) does not seems to be an URI", LogUtility::LVL_MSG_WARNING, "support");
397        }
398
399        /**
400         * @var DOMNodeList $nodes
401         * finds all nodes that have a namespace node called $ns where their parent node doesn't also have the same namespace.
402         * @var DOMNodeList $nodes
403         */
404        try {
405            $nodes = $this->xpath("//*[namespace-uri()='$namespaceUri']");
406            foreach ($nodes as $node) {
407                /** @var DOMElement $node */
408                $node->parentNode->removeChild($node);
409            }
410        } catch (ExceptionBadSyntax $e) {
411            LogUtility::error("Internal Error on xpath: {$e->getMessage()}");
412        }
413
414        try {
415            $nodes = $this->xpath("//@*[namespace-uri()='$namespaceUri']");
416            foreach ($nodes as $node) {
417                /** @var DOMAttr $node */
418                /** @var DOMElement $DOMNode */
419                $DOMNode = $node->parentNode;
420                $DOMNode->removeAttributeNode($node);
421            }
422        } catch (ExceptionBadSyntax $e) {
423            LogUtility::error("Internal Error on xpath: {$e->getMessage()}");
424        }
425
426
427        //Node namespace can be select only from the document
428        $xpath = new DOMXPath($this->getDomDocument());
429        $DOMNodeList = $xpath->query("namespace::*", $this->getDomDocument()->ownerDocument);
430        foreach ($DOMNodeList as $node) {
431            $namespaceURI = $node->namespaceURI;
432            if ($namespaceURI == $namespaceUri) {
433                $parentNode = $node->parentNode;
434                $parentNode->removeAttributeNS($namespaceUri, $node->localName);
435            }
436        }
437
438
439    }
440
441    public function getNamespaces(): array
442    {
443        /**
444         * We can't query with the library {@link XmlDocument::xpath()} function because
445         * we register in the xpath the namespace
446         */
447        $xpath = new DOMXPath($this->getDomDocument());
448        // `namespace::*` means selects all the namespace attribute of the context node
449        // namespace is an axes
450        // See https://www.w3.org/TR/1999/REC-xpath-19991116/#axes
451        // the namespace axis contains the namespace nodes of the context node; the axis will be empty unless the context node is an element
452        $DOMNodeList = $xpath->query('namespace::*', $this->getDomDocument()->ownerDocument);
453        $nameSpace = array();
454        foreach ($DOMNodeList as $node) {
455            /** @var DOMElement $node */
456
457            $namespaceURI = $node->namespaceURI;
458            $localName = $node->prefix;
459            if ($namespaceURI != null) {
460                $nameSpace[$localName] = $namespaceURI;
461            }
462        }
463        return $nameSpace;
464    }
465
466    /**
467     * A wrapper that register namespace for the query
468     * with the defined prefix
469     * See comment:
470     * https://www.php.net/manual/en/domxpath.registernamespace.php#51480
471     * @param $query
472     * @param DOMElement|null $contextNode
473     * @return DOMNodeList
474     *
475     * Note that this is possible to do evaluation to return a string instead
476     * https://www.php.net/manual/en/domxpath.evaluate.php
477     * @throws ExceptionBadSyntax - if the query is invalid
478     */
479    public
480    function xpath($query, DOMElement $contextNode = null): DOMNodeList
481    {
482        if (!isset($this->domXpath)) {
483
484            $this->domXpath = new DOMXPath($this->getDomDocument());
485
486            /**
487             * Prefix mapping
488             * It is necessary to use xpath to handle documents which have default namespaces.
489             * The xpath expression will search for items with no namespace by default.
490             */
491            foreach ($this->getNamespaces() as $prefix => $namespaceUri) {
492                /**
493                 * You can't register an empty prefix
494                 * Default namespace (without a prefix) can only be accessed by the local-name() and namespace-uri() attributes.
495                 */
496                if (!empty($prefix)) {
497                    $result = $this->domXpath->registerNamespace($prefix, $namespaceUri);
498                    if (!$result) {
499                        LogUtility::msg("Not able to register the prefix ($prefix) for the namespace uri ($namespaceUri)");
500                    }
501                }
502            }
503        }
504
505        if ($contextNode === null) {
506            $contextNode = $this->domDocument;
507        }
508        $domList = $this->domXpath->query($query, $contextNode);
509        if ($domList === false) {
510            throw new ExceptionBadSyntax("The query expression ($query) may be malformed");
511        }
512        return $domList;
513
514    }
515
516
517    public
518    function removeRootAttribute($attribute)
519    {
520
521        // This function does not work
522        // $result = $this->getXmlDom()->documentElement->removeAttribute($attribute);
523
524        for ($i = 0; $i < $this->getDomDocument()->documentElement->attributes->length; $i++) {
525            if ($this->getDomDocument()->documentElement->attributes[$i]->name == $attribute) {
526                $result = $this->getDomDocument()->documentElement->removeAttributeNode($this->getDomDocument()->documentElement->attributes[$i]);
527                if ($result === false) {
528                    throw new \RuntimeException("Not able to delete the $attribute");
529                }
530                // There is no break here because you may find multiple version attribute for instance
531            }
532        }
533
534    }
535
536    public
537    function removeRootChildNode($nodeName)
538    {
539        for ($i = 0; $i < $this->getDomDocument()->documentElement->childNodes->length; $i++) {
540            $childNode = &$this->getDomDocument()->documentElement->childNodes[$i];
541            if ($childNode->nodeName == $nodeName) {
542                $result = $this->getDomDocument()->documentElement->removeChild($childNode);
543                if ($result == false) {
544                    throw new \RuntimeException("Not able to delete the child node $nodeName");
545                }
546                break;
547            }
548        }
549    }
550
551    /**
552     *
553     * Add a value to an attribute value
554     * Example
555     * <a class="actual">
556     *
557     * if you add "new"
558     * <a class="actual new">
559     *
560     * @param $attName
561     * @param $attValue
562     * @param DOMElement $xml
563     */
564    public
565    function addAttributeValue($attName, $attValue, $xml)
566    {
567
568        /**
569         * Empty condition is better than {@link DOMElement::hasAttribute()}
570         * because even if the dom element has the attribute, the value
571         * may be empty
572         */
573        $value = $xml->getAttribute($attName);
574        if (empty($value)) {
575            $xml->setAttribute($attName, $attValue);
576        } else {
577            $actualAttValue = $xml->getAttribute($attName);
578            $explodeArray = explode(" ", $actualAttValue);
579            if (!in_array($attValue, $explodeArray)) {
580                $xml->setAttribute($attName, (string)$actualAttValue . " $attValue");
581            }
582        }
583
584    }
585
586    public function diff(XmlDocument $rightDocument): string
587    {
588        $error = "";
589        XmlSystems::diffNode($this->getDomDocument(), $rightDocument->getDomDocument(), $error);
590        return $error;
591    }
592
593    /**
594     * @return string a XML formatted
595     *
596     * !!!! The parameter preserveWhiteSpace should have been set to false before loading
597     * https://www.php.net/manual/en/class.domdocument.php#domdocument.props.formatoutput
598     * $this->xmlDom->preserveWhiteSpace = false;
599     *
600     * We do it with the function {@link XmlDocument::mandatoryFormatConfigBeforeLoading()}
601     *
602     */
603    public function toXmlFormatted(DOMElement $element = null): string
604    {
605
606        $this->domDocument->formatOutput = true;
607        return $this->toXml($element);
608
609    }
610
611    /**
612     * @return string that can be diff
613     *   * EOL diff are not seen
614     *   * space are
615     *
616     * See also {@link XmlDocument::processTextBeforeLoading()}
617     * that is needed before loading
618     */
619    public function toXmlNormalized(DOMElement $element = null): string
620    {
621
622        /**
623         * If the text was a list
624         * of sibling text without parent
625         * We may get a body
626         * @deprecated letting the code until
627         * TODO: delete this code when the test pass
628         */
629//        $body = $doc->getElementsByTagName("body");
630//        if ($body->length != 0) {
631//            $DOMNodeList = $body->item(0)->childNodes;
632//            $output = "";
633//            foreach ($DOMNodeList as $value) {
634//                $output .= $doc->saveXML($value) . DOKU_LF;
635//            }
636//        }
637
638        if ($element == null) {
639            $element = $this->domDocument->documentElement;
640        }
641        $element->normalize();
642        return $this->toXmlFormatted($element);
643    }
644
645    /**
646     * Not really conventional but
647     * to be able to {@link toXmlNormalized}
648     * the EOL should be deleted
649     * We do it before loading and not with a XML documentation
650     */
651    private function processTextBeforeLoading($text)
652    {
653        $text = str_replace(DOKU_LF, "", $text);
654        $text = preg_replace("/\r\n\s*\r\n/", "\r\n", $text);
655        $text = preg_replace("/\n\s*\n/", "\n", $text);
656        $text = preg_replace("/\n\n/", "\n", $text);
657        return $text;
658
659    }
660
661
662    /**
663     * This function is called just before loading
664     * in order to be able to {@link XmlDocument::toXmlFormatted() format the output }
665     * https://www.php.net/manual/en/class.domdocument.php#domdocument.props.formatoutput
666     * Mandatory for a a good formatting before loading
667     *
668     */
669    private function mandatoryFormatConfigBeforeLoading()
670    {
671        // not that
672        // the loading option: LIBXML_NOBLANKS
673        // is equivalent to $this->xmlDom->preserveWhiteSpace = true;
674        $this->domDocument->preserveWhiteSpace = false;
675    }
676
677    /**
678     * @param string $attributeName
679     * @param DOMElement $nodeElement
680     * @return void
681     * @deprecated use the {@link XmlElement::removeAttribute()} if possible
682     */
683    public function removeAttributeValue(string $attributeName, DOMElement $nodeElement)
684    {
685        $attr = $nodeElement->getAttributeNode($attributeName);
686        if (!$attr) {
687            return;
688        }
689        $result = $nodeElement->removeAttributeNode($attr);
690        if ($result === false) {
691            LogUtility::msg("Not able to delete the attribute $attributeName of the node element $nodeElement->tagName in the Xml document");
692        }
693    }
694
695
696    /**
697     * Query via a CSS selector
698     * (not that it will not work with other namespace than the default one, ie xmlns will not work)
699     * @throws ExceptionBadSyntax - if the selector is not valid
700     * @throws ExceptionNotFound - if the selector selects nothing
701     */
702    public function querySelector(string $selector): XmlElement
703    {
704        $domNodeList = $this->querySelectorAll($selector);
705        if (sizeof($domNodeList) >= 1) {
706            return $domNodeList[0];
707        }
708        throw new ExceptionNotFound("No element was found with the selector $selector");
709
710    }
711
712    /**
713     * @return XmlElement[]
714     * @throws ExceptionBadSyntax
715     */
716    public function querySelectorAll(string $selector): array
717    {
718        $xpath = $this->cssSelectorToXpath($selector);
719        $domNodeList = $this->xpath($xpath);
720        $domNodes = [];
721        foreach ($domNodeList as $domNode) {
722            if ($domNode instanceof DOMElement) {
723                $domNodes[] = new XmlElement($domNode, $this);
724            }
725        }
726        return $domNodes;
727
728    }
729
730    /**
731     * @throws ExceptionBadSyntax
732     */
733    public function cssSelectorToXpath(string $selector): string
734    {
735        try {
736            return PhpCss::toXpath($selector);
737        } catch (PhpCss\Exception\ParserException $e) {
738            throw new ExceptionBadSyntax("The selector ($selector) is not valid. Error: {$e->getMessage()}");
739        }
740    }
741
742    /**
743     * An utility function to know how to remove a node
744     * @param \DOMNode $nodeElement
745     * @deprecated use {@link XmlElement::remove} instead
746     */
747    public function removeNode(\DOMNode $nodeElement)
748    {
749
750        $nodeElement->parentNode->removeChild($nodeElement);
751
752    }
753
754    public function getElement(): XmlElement
755    {
756        return XmlElement::create($this->getDomDocument()->documentElement, $this);
757    }
758
759    public function toHtml()
760    {
761        return $this->domDocument->saveHTML();
762    }
763
764    /**
765     * @throws \DOMException - if invalid local name
766     */
767    public function createElement(string $localName): XmlElement
768    {
769        $element = $this->domDocument->createElement($localName);
770        return XmlElement::create($element, $this);
771    }
772
773    /**
774     * @throws ExceptionBadSyntax
775     * @throws ExceptionBadState
776     */
777    public function xpathFirstDomElement(string $xpath): DOMElement
778    {
779        $domList = $this->xpath($xpath);
780        $domElement = $domList->item(0);
781        if ($domElement instanceof DOMElement) {
782            return $domElement;
783        } else {
784            throw new ExceptionBadState("The first DOM node is not a DOM element");
785        }
786    }
787
788
789}
790