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