diff --git a/CHANGELOG.md b/CHANGELOG.md
index 59eb292..ac1c5e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,8 @@ QueryPath Changelog
- Add `QueryPathTests\ExamplesTest`, which runs every offline example on each supported PHP version and fails if one stops working. The examples that call third-party services are run by the new `Examples` workflow, weekly and whenever an example changes
- Rewrite the cURL example against the PubMed E-utilities API. MusicBrainz throttles by IP address, which made the example unusable from any shared address
- Add `composer run test:examples` (and `test:examples:network`) to run the examples locally
+- Fix processing instructions gaining an extra `?` each time an HTML-parsed document was serialized, so `` came back out of `html()`, `innerHTML()`, `innerXML()`, `innerXHTML()`, `xml()`, `html5()`, `innerHTML5()`, and `writeXML()` as ``. libxml's HTML parser keeps the closing `?` as part of the node's data, unlike its XML parser and the Masterminds HTML5 parser, so QueryPath now strips it on load. `DOMProcessingInstruction::$data` consequently no longer has a stray `?` on the end for documents read with `htmlqp()` or `qp()` on an `.html` file. Note that this normalisation applies only to documents QueryPath parses itself, and that taking the underlying `DOMDocument` out of QueryPath and calling libxml's own `saveHTML()` on it will emit `` without the terminator, since libxml's HTML serializer never adds one
+- Add `QueryPath\Document`, a `DOMDocument` subclass QueryPath parses into. The type is how QueryPath records that a document's processing instruction data does not carry the closing `?`, which cannot be determined by inspecting the document afterwards. It travels with the document, so every route to a second `DOMQuery` over one document -- iteration, `add()`, `remove()`, `replaceAll()`, `branch()`, `QueryPath::with()`, and the bundled extensions -- serializes it correctly. A `DOMDocument` supplied by the caller is a plain `DOMDocument`, makes no such promise, and is still serialized exactly as it was handed over
# 4.1.0
diff --git a/examples/parsing-php-source/index.php b/examples/parsing-php-source/index.php
index 90171e7..4adef77 100644
--- a/examples/parsing-php-source/index.php
+++ b/examples/parsing-php-source/index.php
@@ -42,9 +42,9 @@
* PHP blocks survive parsing as processing instruction nodes, so they can be
* located with XPath and inspected like any other node.
*
- * The `data` property of the node holds the PHP source. libxml stores the
- * trailing "?" of the closing tag as part of that data and puts the ">" back
- * on when the document is written out, so trim it off before displaying it.
+ * The `data` property of the node holds the PHP source. QueryPath normalises
+ * it on load, so the closing "?" of the "?>" is never part of the data no
+ * matter which parser read the document.
*/
echo '
The PHP blocks in the template
';
@@ -53,9 +53,9 @@
echo '';
foreach ($blocks as $block) {
- $code = rtrim(trim($block->get(0)->data), '?');
+ $code = trim($block->get(0)->data);
- echo '' . htmlspecialchars(trim($code)) . ' ';
+ echo '' . htmlspecialchars($code) . ' ';
}
echo '
';
@@ -64,11 +64,12 @@
* Because it is a normal DOM, the template can be rewritten too. Here a new
* menu item is added and the heading is retitled.
*
- * Use writeHTML() (or writeXML()) to serialize a template containing PHP
- * blocks - it hands the document back to libxml, which restores the closing
- * "?>" correctly. Capturing it with an output buffer makes it easy to send
- * the result somewhere other than standard output, such as back to disk with
- * file_put_contents().
+ * Every serializer restores the closing "?>" of a PHP block, so a template
+ * can be written back out in whichever format suits: writeHTML(), writeXML(),
+ * and writeHTML5() print it, and html(), xml(), and html5() return it as a
+ * string. Capturing writeHTML() with an output buffer, as below, makes it
+ * easy to send the result somewhere other than standard output, such as back
+ * to disk with file_put_contents().
*/
echo 'Rewriting the template
';
diff --git a/src/DOM.php b/src/DOM.php
index 076fad9..b9e95eb 100644
--- a/src/DOM.php
+++ b/src/DOM.php
@@ -5,6 +5,7 @@
use Countable;
use DOMDocument;
use DOMNode;
+use DOMXPath;
use IteratorAggregate;
use Masterminds\HTML5;
use QueryPath\CSS\DOMTraverser;
@@ -91,10 +92,9 @@ public function __construct($document = null, $selector = null, $options = [])
// Empty: Just create an empty QP.
if (empty($document)) {
- $this->document = isset($this->options['encoding']) ? new DOMDocument(
- '1.0',
- $this->options['encoding']
- ) : new DOMDocument();
+ $this->document = isset($this->options['encoding'])
+ ? self::createDocument('1.0', $this->options['encoding'])
+ : self::createDocument();
$this->setMatches(new SplObjectStorage());
} // Figure out if document is DOM, HTML/XML, or a filename
elseif (is_object($document)) {
@@ -175,7 +175,7 @@ public function __construct($document = null, $selector = null, $options = [])
private function parseXMLString($string, $flags = 0)
{
- $document = new DOMDocument('1.0');
+ $document = self::createDocument('1.0');
$lead = strtolower(substr($string, 0, 5)); // errTypes);
@@ -206,7 +206,7 @@ private function parseXMLString($string, $flags = 0)
// If HTML parser is requested, we use it.
if ($useParser === 'html') {
- $document->loadHTML($string);
+ self::loadHTMLString($document, $string);
} // Parse as XML if it looks like XML, or if XML parser is requested.
elseif ($lead === 'options['replace_entities']) {
@@ -215,7 +215,7 @@ private function parseXMLString($string, $flags = 0)
$document->loadXML($string, $flags);
} // In all other cases, we try the HTML parser.
else {
- $document->loadHTML($string);
+ self::loadHTMLString($document, $string);
}
} // Emulate 'finally' behavior.
catch (Exception $e) {
@@ -231,6 +231,111 @@ private function parseXMLString($string, $flags = 0)
return $document;
}
+ /**
+ * Create the document QueryPath parses into.
+ *
+ * registerNodeClass() is what makes the marker type stick. PHP rebuilds the wrapper object for
+ * a document whenever it is reached through $node->ownerDocument and the original wrapper has
+ * since been released, and without the registration it rebuilds it as a plain DOMDocument --
+ * losing exactly the fact the type is there to record.
+ *
+ * @param string $version
+ * @param string|null $encoding
+ *
+ * @return Document
+ */
+ private static function createDocument($version = '1.0', $encoding = null)
+ {
+ $document = $encoding === null ? new Document($version) : new Document($version, $encoding);
+ $document->registerNodeClass(DOMDocument::class, Document::class);
+
+ return $document;
+ }
+
+ /**
+ * Parse a string with libxml's HTML parser, keeping QueryPath's processing instruction invariant.
+ *
+ * @param DOMDocument $document
+ * @param string $string
+ *
+ * @return void
+ *
+ * @see normalizeProcessingInstructions()
+ */
+ private static function loadHTMLString(DOMDocument $document, $string)
+ {
+ $document->loadHTML($string);
+
+ // Processing instruction data can only end in "?" if a "?" sat immediately before the
+ // closing ">", so a source without that sequence -- almost every document -- has nothing
+ // to normalize, and looking for two bytes is far cheaper than walking the tree.
+ if (strpos($string, '?>') !== false) {
+ self::normalizeProcessingInstructions($document);
+ }
+ }
+
+ /**
+ * Parse a file with libxml's HTML parser, keeping QueryPath's processing instruction invariant.
+ *
+ * The counterpart of loadHTMLString(). There is no source string to scan here, so the document
+ * is always walked; the walk costs roughly 3% of the parse it follows.
+ *
+ * @param DOMDocument $document
+ * @param string $filename
+ *
+ * @return void
+ *
+ * @see normalizeProcessingInstructions()
+ */
+ private static function loadHTMLFile(DOMDocument $document, $filename)
+ {
+ $document->loadHTMLFile($filename);
+ self::normalizeProcessingInstructions($document);
+ }
+
+ /**
+ * Strip the closing "?" that libxml's HTML parser leaves in processing instruction data.
+ *
+ * libxml's HTML parser stores the terminating "?" of a `` block as part of the
+ * node's data, while its XML parser -- and the Masterminds HTML5 parser -- do not. Leaving it
+ * in place corrupts every serializer that appends its own "?>": `saveXML()` turns
+ * `` into ``, and each further round trip adds another "?".
+ *
+ * Normalising on load gives every parser QueryPath supports the same invariant -- processing
+ * instruction data never contains the closing "?" -- which fixes the serializers and also means
+ * a caller reading `$pi->data` gets usable source rather than source with a stray "?" glued to
+ * the end.
+ *
+ * Exactly one "?" is removed, so a processing instruction whose content legitimately ends in
+ * "?" (``) still round trips correctly.
+ *
+ * @param DOMDocument $document
+ *
+ * @return void
+ */
+ protected static function normalizeProcessingInstructions(DOMDocument $document)
+ {
+ foreach (self::processingInstructions($document) as $instruction) {
+ if (substr($instruction->data, -1) === '?') {
+ $instruction->data = substr($instruction->data, 0, -1);
+ }
+ }
+ }
+
+ /**
+ * Every processing instruction in a document, as a static node list.
+ *
+ * @param DOMDocument $document
+ *
+ * @return iterable
+ */
+ protected static function processingInstructions(DOMDocument $document): iterable
+ {
+ $xpath = new DOMXPath($document);
+
+ return $xpath->query('//processing-instruction()');
+ }
+
/**
* EXPERT: Be very, very careful using this.
* A utility function for setting the current set of matches.
@@ -454,7 +559,7 @@ private function parseXMLFile($filename, $flags = 0, $context = null)
return $this->parseXMLString($contents, $flags);
}
- $document = new DOMDocument();
+ $document = self::createDocument();
$lastDot = strrpos($filename, '.');
$htmlExtensions = [
@@ -480,7 +585,7 @@ private function parseXMLFile($filename, $flags = 0, $context = null)
} // Otherwise, see if it looks like HTML.
elseif ($useParser === 'html' || isset($htmlExtensions[$ext])) {
// Try parsing it as HTML.
- $document->loadHTMLFile($filename);
+ self::loadHTMLFile($document, $filename);
} // Default to XML.
else {
$document->load($filename, $flags);
diff --git a/src/DOMQuery.php b/src/DOMQuery.php
index 5f2eb87..d4b395d 100644
--- a/src/DOMQuery.php
+++ b/src/DOMQuery.php
@@ -700,13 +700,52 @@ public function html($markup = null)
}
if ($first instanceof DOMDocument || $first->isSameNode($first->ownerDocument->documentElement)) {
- return $this->document->saveHTML();
+ return $this->saveDocumentHTML();
}
// saveHTML cannot take a node and serialize it.
return $this->document->saveXML($first);
}
+ /**
+ * Serialize the whole document with libxml's HTML serializer.
+ *
+ * libxml writes a processing instruction verbatim as `` and never appends the
+ * closing "?" itself, so the terminator has to be put back for the duration of the write and
+ * taken off again afterwards. A document QueryPath did not parse is written as-is, because it
+ * makes no promise about where its terminators are.
+ *
+ * @param string|null $path
+ * When given, the document is written to this file rather than returned.
+ *
+ * @return string|int|false
+ * The serialized document, or the number of bytes written when $path is given.
+ *
+ * @see \QueryPath\Document
+ */
+ protected function saveDocumentHTML($path = null)
+ {
+ // One walk drives both passes. DOMXPath::query() hands back a static node list and
+ // serializing does not move nodes, so the same list is still good for the restore.
+ $instructions = $this->document instanceof Document
+ ? self::processingInstructions($this->document)
+ : [];
+
+ foreach ($instructions as $instruction) {
+ $instruction->data .= '?';
+ }
+
+ try {
+ return $path === null
+ ? $this->document->saveHTML()
+ : $this->document->saveHTMLFile($path);
+ } finally {
+ foreach ($instructions as $instruction) {
+ $instruction->data = substr($instruction->data, 0, -1);
+ }
+ }
+ }
+
/**
* Set or get the markup for an element using the HTML5 parser
*
@@ -1335,11 +1374,11 @@ public function writeXML($path = null, $options = 0)
public function writeHTML($path = null)
{
if ($path === null) {
- print $this->document->saveHTML();
+ print $this->saveDocumentHTML();
} else {
try {
set_error_handler(['\QueryPath\ParseException', 'initializeFromError']);
- $this->document->saveHTMLFile($path);
+ $this->saveDocumentHTML($path);
} catch (Exception $e) {
restore_error_handler();
throw $e;
diff --git a/src/Document.php b/src/Document.php
new file mode 100644
index 0000000..dd303fa
--- /dev/null
+++ b/src/Document.php
@@ -0,0 +1,34 @@
+". Every parser QueryPath drives satisfies it, libxml's XML
+ * parser and Masterminds natively and libxml's HTML parser once
+ * DOM::normalizeProcessingInstructions() has run over the result.
+ *
+ * The invariant cannot be established by inspection after the fact, because the XML parser reading
+ * `` leaves exactly the trailing "?" that the HTML parser leaves for
+ * ``. Nor can it be re-established by normalising again, which would strip a "?"
+ * that legitimately belongs to the content. It has to be recorded when the document is built, and
+ * recording it on the document rather than on the query object means it survives every route by
+ * which a second DOMQuery comes to share the same document -- iteration, add(), remove(),
+ * replaceAll(), branch(), and the extensions.
+ *
+ * A DOMDocument supplied by the caller is a plain DOMDocument and makes no such promise, so
+ * QueryPath serializes it exactly as it was handed over.
+ *
+ * @see DOM::normalizeProcessingInstructions()
+ * @see DOMQuery::saveDocumentHTML()
+ *
+ * @ingroup querypath_core
+ */
+class Document extends DOMDocument
+{
+}
diff --git a/tests/Issues/Issue65Test.php b/tests/Issues/Issue65Test.php
new file mode 100644
index 0000000..c0ececf
--- /dev/null
+++ b/tests/Issues/Issue65Test.php
@@ -0,0 +1,381 @@
+" therefore doubled it up, so `` came back out as
+ * `` and grew another "?" on every round trip.
+ */
+
+namespace QueryPathTests;
+
+use DOMDocument;
+use DOMProcessingInstruction;
+use QueryPath\Document;
+
+class Issue65Test extends TestCase
+{
+ private const PI_FILE_HTML = 'tests/processing-instruction.html';
+
+ /**
+ * A document with a PHP block.
+ */
+ private const HTML = '';
+
+ /**
+ * The expected serialization of self::HTML's , for every serializer.
+ */
+ private const EXPECTED = '';
+
+ /**
+ * Keeps the serialized output compact enough to compare exactly.
+ */
+ private const OPTIONS = ['format_output' => false];
+
+ /**
+ * Every serializer that appends its own "?>", with the selector each one is called on.
+ *
+ * @return array
+ */
+ public function serializerProvider(): array
+ {
+ return [
+ 'innerHTML' => ['body', 'innerHTML'],
+ 'innerXML' => ['body', 'innerXML'],
+ 'innerXHTML' => ['body', 'innerXHTML'],
+ 'innerHTML5' => ['body', 'innerHTML5'],
+ 'html' => ['h1', 'html'],
+ 'html5' => ['h1', 'html5'],
+ 'xml' => ['h1', 'xml'],
+ ];
+ }
+
+ /**
+ * @dataProvider serializerProvider
+ *
+ * @param string $selector
+ * @param string $method
+ */
+ public function testSerializersDoNotDoubleUpTheProcessingInstructionTerminator($selector, $method)
+ {
+ $qp = htmlqp(self::HTML, null, self::OPTIONS);
+
+ $this->assertSame(self::EXPECTED, $qp->top()->find($selector)->$method());
+ }
+
+ public function testHtmlOfTheWholeDocumentKeepsASingleTerminator()
+ {
+ $qp = htmlqp(self::HTML, null, self::OPTIONS);
+
+ $this->assertStringContainsString(self::EXPECTED, $qp->top()->html());
+ }
+
+ /**
+ * A document parsed from a .html file goes through loadHTMLFile() rather than loadHTML().
+ */
+ public function testHtmlFileOnDiskIsNormalisedToo()
+ {
+ $qp = qp(self::PI_FILE_HTML, null, self::OPTIONS);
+
+ $this->assertSame(self::EXPECTED, $qp->top()->find('body')->innerHTML());
+ }
+
+ /**
+ * The bug compounded: every extra round trip used to add another "?".
+ */
+ public function testRepeatedRoundTripsAreStable()
+ {
+ $markup = self::HTML;
+
+ for ($i = 0; $i < 3; $i++) {
+ $markup = htmlqp($markup, null, self::OPTIONS)->top()->html();
+ $this->assertStringContainsString(self::EXPECTED, $markup);
+ $this->assertStringNotContainsString('??>', $markup);
+ }
+ }
+
+ public function testRepeatedInnerHtmlRoundTripsAreStable()
+ {
+ $markup = self::HTML;
+
+ for ($i = 0; $i < 3; $i++) {
+ $markup = htmlqp($markup, null, self::OPTIONS)->top()->find('body')->innerHTML();
+ $this->assertSame(self::EXPECTED, $markup);
+ }
+ }
+
+ /**
+ * Reading the node directly should hand back usable PHP, not source with a stray "?" glued on.
+ */
+ public function testProcessingInstructionDataHasNoTrailingQuestionMark()
+ {
+ $instruction = htmlqp(self::HTML, null, self::OPTIONS)->top()->find('h1')->get(0)->firstChild;
+
+ $this->assertInstanceOf(DOMProcessingInstruction::class, $instruction);
+ $this->assertSame('php', $instruction->target);
+ $this->assertSame('echo $title; ', $instruction->data);
+ }
+
+ /**
+ * Exactly one "?" is stripped, so content that legitimately ends in "?" still round trips.
+ */
+ public function testProcessingInstructionEndingInAQuestionMarkIsNotDoubleStripped()
+ {
+ $markup = '';
+ $qp = htmlqp($markup, null, self::OPTIONS);
+
+ $this->assertSame('', $qp->top()->find('body')->innerHTML());
+ $this->assertStringContainsString('', $qp->top()->html());
+ }
+
+ /**
+ * Every method that prints the whole document, all of which must emit exactly one terminator.
+ *
+ * @return array
+ */
+ public function writerProvider(): array
+ {
+ return [
+ 'writeHTML' => ['writeHTML'],
+ 'writeHTML5' => ['writeHTML5'],
+ 'writeXML' => ['writeXML'],
+ ];
+ }
+
+ /**
+ * @dataProvider writerProvider
+ *
+ * @param string $method
+ */
+ public function testWritersEmitASingleTerminator($method)
+ {
+ $qp = htmlqp(self::HTML, null, self::OPTIONS);
+
+ $output = $this->capture(function () use ($qp, $method) {
+ $qp->top()->$method();
+ });
+
+ $this->assertStringContainsString(self::EXPECTED, $output);
+ $this->assertStringNotContainsString('??>', $output);
+ }
+
+ public function testWriteHtmlToAFileEmitsASingleTerminator()
+ {
+ $qp = htmlqp(self::HTML, null, self::OPTIONS);
+ $path = tempnam(sys_get_temp_dir(), 'qp65');
+
+ try {
+ $qp->top()->writeHTML($path);
+ $output = file_get_contents($path);
+ } finally {
+ unlink($path);
+ }
+
+ $this->assertStringContainsString(self::EXPECTED, $output);
+ $this->assertStringNotContainsString('??>', $output);
+ }
+
+ /**
+ * The temporary terminator writeHTML() needs must not leak into the document afterwards.
+ */
+ public function testWriteHtmlLeavesTheDocumentUnchanged()
+ {
+ $qp = htmlqp(self::HTML, null, self::OPTIONS);
+
+ $this->capture(function () use ($qp) {
+ $qp->top()->writeHTML();
+ });
+
+ $this->assertSame(self::EXPECTED, $qp->top()->find('body')->innerHTML());
+ }
+
+ /**
+ * html5qp() was never affected, because the Masterminds parser does not keep the "?".
+ */
+ public function testHtml5ParserIsUnaffected()
+ {
+ $qp = html5qp(self::HTML, null, self::OPTIONS);
+
+ $this->assertSame(self::EXPECTED, $qp->top()->find('body')->innerHTML());
+ $this->assertSame(self::EXPECTED, $qp->top()->find('body')->innerHTML5());
+ }
+
+ /**
+ * A document parsed in XML mode was never affected either.
+ */
+ public function testXmlParserIsUnaffected()
+ {
+ $xml = ' ';
+ $qp = qp($xml, null, self::OPTIONS);
+
+ $this->assertSame('', $qp->top()->find('item')->innerXML());
+ $this->assertSame(' ', $qp->top()->find('item')->xml());
+
+ $instruction = $qp->top()->find('item')->get(0)->firstChild;
+ $this->assertSame('echo $title; ', $instruction->data);
+ }
+
+ /**
+ * writeHTML() on an XML-parsed document used to drop the terminator entirely, because libxml's
+ * HTML serializer writes a processing instruction verbatim and never adds one of its own.
+ */
+ public function testWriteHtmlOnAnXmlParsedDocumentEmitsATerminator()
+ {
+ $xml = ' ';
+ $qp = qp($xml, null, self::OPTIONS);
+
+ $output = $this->capture(function () use ($qp) {
+ $qp->top()->writeHTML();
+ });
+
+ $this->assertStringContainsString(' ', $output);
+ }
+
+ /**
+ * An HTML processing instruction with no "?" before the ">" has nothing to strip.
+ */
+ public function testProcessingInstructionWithoutATerminatorIsLeftAlone()
+ {
+ $qp = htmlqp('', null, self::OPTIONS);
+
+ $instruction = $qp->top()->find('h1')->get(0)->firstChild;
+ $this->assertSame('foo', $instruction->target);
+ $this->assertSame('bar', $instruction->data);
+ }
+
+ /**
+ * Documents with no processing instructions must serialize exactly as they did before.
+ */
+ public function testDocumentsWithoutProcessingInstructionsAreUntouched()
+ {
+ $qp = htmlqp('', null, self::OPTIONS);
+
+ $this->assertSame('
hi
', $qp->top()->find('#d')->innerHTML());
+
+ $output = $this->capture(function () use ($qp) {
+ $qp->top()->writeHTML();
+ });
+
+ $this->assertStringContainsString('', $output);
+ }
+
+ /**
+ * Normalisation only happens on documents QueryPath parses, so the HTML serializer must leave a
+ * caller-supplied document alone -- its processing instructions still carry their own "?".
+ */
+ public function testCallerSuppliedHtmlDocumentIsSerializedAsIs()
+ {
+ $doc = new DOMDocument();
+ @$doc->loadHTML(self::HTML);
+
+ $this->assertStringContainsString(self::EXPECTED, qp($doc, null, self::OPTIONS)->top()->html());
+ $this->assertStringContainsString(self::EXPECTED, qp($doc->documentElement, null, self::OPTIONS)->top()->html());
+ }
+
+ /**
+ * branch() assigns $this->document directly rather than going through the constructor.
+ */
+ public function testBranchKeepsTheDocumentSerializingCorrectly()
+ {
+ $qp = htmlqp(self::HTML, null, self::OPTIONS);
+
+ $this->assertStringContainsString(self::EXPECTED, $qp->top()->branch()->html());
+ }
+
+ /**
+ * A DOMQuery built from another DOMQuery inherits its document, and with it the invariant.
+ */
+ public function testDocumentCopiedFromAnotherQueryPathSerializesCorrectly()
+ {
+ $qp = qp(htmlqp(self::HTML, null, self::OPTIONS), null, self::OPTIONS);
+
+ $this->assertStringContainsString(self::EXPECTED, $qp->top()->html());
+ }
+
+ /**
+ * Iterating a match set builds a fresh DOMQuery per element over the same document.
+ */
+ public function testIteratingAMatchSetKeepsTheDocumentSerializingCorrectly()
+ {
+ $qp = htmlqp(self::HTML, null, self::OPTIONS);
+
+ $output = $this->capture(function () use ($qp) {
+ foreach ($qp->top()->find('html') as $element) {
+ $element->writeHTML();
+ }
+ });
+
+ $this->assertStringContainsString(self::EXPECTED, $output);
+ $this->assertStringNotContainsString('??>', $output);
+ }
+
+ /**
+ * Every route to a second query over one document, none of which run the parser again.
+ *
+ * @return array
+ */
+ public function sharedDocumentProvider(): array
+ {
+ return [
+ 'from the document' => [true],
+ 'from a node' => [false],
+ ];
+ }
+
+ /**
+ * @dataProvider sharedDocumentProvider
+ *
+ * @param bool $fromDocument
+ */
+ public function testASecondQueryOverTheSameDocumentSerializesCorrectly($fromDocument)
+ {
+ $element = htmlqp(self::HTML, null, self::OPTIONS)->top()->get(0);
+ $source = $fromDocument ? $element->ownerDocument : $element;
+
+ $this->assertStringContainsString(self::EXPECTED, qp($source, null, self::OPTIONS)->top()->html());
+ }
+
+ /**
+ * remove() runs the legacy selector engine and returns a new DOMQuery over the same document.
+ */
+ public function testRemoveKeepsTheDocumentSerializingCorrectly()
+ {
+ $html = 'gone
';
+ $qp = htmlqp($html, null, self::OPTIONS);
+ $qp->top()->find('p')->remove();
+
+ $this->assertStringContainsString(self::EXPECTED, $qp->top()->html());
+ }
+
+ /**
+ * The marker is the document's type, and PHP rebuilds that wrapper object whenever a document
+ * is reached through ownerDocument after the original wrapper has been released.
+ */
+ public function testTheDocumentKeepsItsTypeWhenReachedThroughOwnerDocument()
+ {
+ $element = htmlqp(self::HTML, null, self::OPTIONS)->top()->get(0);
+
+ $this->assertInstanceOf(Document::class, $element->ownerDocument);
+ }
+
+ /**
+ * Normalisation runs once, when the document is parsed. Re-using it must not strip a second
+ * "?" from content that legitimately ends in one.
+ */
+ public function testProcessingInstructionEndingInAQuestionMarkSurvivesReuse()
+ {
+ $markup = '';
+ $expected = '';
+ $qp = htmlqp($markup, null, self::OPTIONS);
+
+ $this->assertStringContainsString($expected, $qp->top()->html());
+ $this->assertStringContainsString($expected, $qp->top()->branch()->html());
+ $this->assertStringContainsString(
+ $expected,
+ qp($qp->top()->get(0)->ownerDocument, null, self::OPTIONS)->top()->html()
+ );
+ }
+}
diff --git a/tests/QueryPath/DOMQueryTest.php b/tests/QueryPath/DOMQueryTest.php
index 4e4027f..228b662 100644
--- a/tests/QueryPath/DOMQueryTest.php
+++ b/tests/QueryPath/DOMQueryTest.php
@@ -1383,12 +1383,9 @@ public function testWriteXML()
{
$xml = 'foobar';
- if (! ob_start()) {
- die("Could not start OB.");
- }
- qp($xml, 'tml')->writeXML();
- $out = ob_get_contents();
- ob_end_clean();
+ $out = $this->capture(function () use ($xml) {
+ qp($xml, 'tml')->writeXML();
+ });
// We expect an XML declaration at the top.
$this->assertEquals('
foobar