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