Fix processing instructions gaining an extra "?" when an HTML-parsed document is serialized - #68
Merged
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #68 +/- ##
============================================
+ Coverage 89.74% 90.11% +0.37%
- Complexity 1407 1421 +14
============================================
Files 26 26
Lines 3170 3198 +28
============================================
+ Hits 2845 2882 +37
+ Misses 325 316 -9 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
libxml's HTML parser stores the closing "?" of a `<?php ?>` block as part of the processing instruction's data, while its XML parser and the Masterminds HTML5 parser do not. Every serializer that appends its own "?>" therefore doubled it up, so `<?php echo $title; ?>` came back out of html(), innerHTML(), innerXML(), innerXHTML(), xml(), html5(), innerHTML5(), and writeXML() as `<?php echo $title; ??>`, gaining another "?" on every round trip. Reading $pi->data directly handed back source with a stray "?" glued to the end. QueryPath now strips it on load, giving every parser it supports the same invariant: processing instruction data never carries the closing "?". Exactly one "?" is removed, so a block whose content legitimately ends in one, `<?php $a = 1; ??>`, still round trips. The scan is skipped when the source contains no "?>" at all, which is almost every document. libxml's HTML serializer is then the one output path that needs the terminator back, since it writes a processing instruction verbatim and never appends one itself. saveDocumentHTML() restores it for the duration of the write and takes it off again afterwards, driving both passes from a single XPath walk. Knowing whether a document holds to the invariant cannot be worked out by inspecting it: the XML parser reading `<?php $a = 1; ??>` leaves exactly the trailing "?" that the HTML parser leaves for `<?php $a = 1; ?>`. Nor can it be re-established by normalising a second time, which would strip a "?" that belongs to the content. It is recorded instead by the document's type - documents QueryPath parses are QueryPath\Document - so it travels with the document rather than with the query object, and every route to a second DOMQuery over one document serializes correctly: iteration, add(), remove(), replaceAll(), branch(), QueryPath::with(), and the bundled extensions. registerNodeClass() keeps that type in place, because PHP otherwise rebuilds a document's wrapper as a plain DOMDocument once the original wrapper has been released. A DOMDocument supplied by the caller stays a plain DOMDocument, makes no such promise, and is written exactly as it was handed over. Note that html5qp() is unaffected by the doubling but still loses the terminator through html() and writeHTML(), which predates this change and is tracked separately in #86. Also adds TestCase::capture() for the many QueryPath methods that print rather than return, and folds the eight hand-rolled output-buffering blocks in DOMQueryTest onto it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #65
The bug
A processing instruction in an HTML-parsed document gained an extra
?every time the document was serialized:Round-trip twice and you got
???>, and so on — the output was no longer valid PHP.Root cause
libxml's HTML parser stores the closing
?of a processing instruction as part of the node'sdata; its XML parser and the Masterminds HTML5 parser do not.Anything that appends its own
?>then doubles it up. That issaveXML()(used byhtml()on a non-root node,innerHTML(),innerXML(),innerXHTML(),xml(),writeXML()) and the Masterminds serializer (html5(),innerHTML5(),writeHTML5()).One correction to the issue's analysis, which changed the shape of the fix:
saveHTML()does not compensate. libxml's HTML serializer writes a processing instruction verbatim as<?target data>and never adds a?of its own —writeHTML()only looked correct because the parser had left one in the data. Confirmed on PHP 8.3.16 / libxml 2.9.13:The corollary is that
writeHTML()on an XML-parsed document was already broken in the same way, emitting<?php echo $t; >.Approach: normalise on load
I went with the reporter's second suggestion — strip one trailing
?from processing instruction data whenever the libxml HTML parser is used (DOM::normalizeProcessingInstructions(), reached fromDOM::loadHTMLString()andDOM::loadHTMLFile(), which are the only two ways QueryPath drives libxml's HTML parser).The scan for processing instructions is skipped unless the source contains the literal
?>. Data can only end in?if a?sat immediately before the closing>, so this drops no real match, and it keeps load cost at parity withmainfor the documents — almost all of them — that have no processing instructions to normalise.This gives every parser QueryPath supports a single invariant — processing instruction data never contains the closing
?— which fixes all of the affected serializers at once, matches what the XML and HTML5 parsers already produce, and fixes the read side:$pi->datanow hands back usable PHP source instead of source with a stray?glued on.Exactly one
?is stripped, so a processing instruction whose content legitimately ends in?(<?php $a = 1; ??>) still round-trips. XML-parsed documents andhtml5qp()documents are not touched, since only the libxml HTML paths call the normaliser.Because libxml's HTML serializer does not add the terminator back, the two
saveHTML()-based output paths —writeHTML()and the whole-document branch ofhtml()— now go throughDOMQuery::saveDocumentHTML(), which re-appends the?for the duration of the write and removes it again in afinally. There is a test asserting the document is unchanged afterwards.Documents QueryPath did not parse
The invariant can only be established for a document QueryPath parses itself, so it is only paid back for one. Whether an already-parsed document handed to the constructor — a
DOMDocument,DOMNode,SimpleXMLElementor node list — holds to it cannot be determined after the fact:Both end in
?, and nothing in the tree records which parser produced them. Stripping would corrupt the first; re-appending in the serializer would corrupt the second. Normalising a second time is not an option either — it would strip a?that legitimately belongs to the content, eroding<?php $a = 1; ??>a character per pass.So the fact is recorded rather than guessed at, and it is recorded on the document: documents QueryPath parses are
QueryPath\Document, aDOMDocumentsubclass that carries no state of its own. The type is the marker.saveDocumentHTML()compensates when it sees one and writes anything else as-is, which is whatmainalready did for caller-supplied documents.Recording it on the document rather than on the
DOMQuerymatters, because a document outlives the query object that parsed it. Every route to a secondDOMQueryover one document reaches the same document and therefore the same answer — iteration,add(),remove(),replaceAll(),branch(),QueryPath::with(), and the bundled extensions. A boolean on the query object would have to be hand-copied at each of those sites, and any new one would silently lose it.One subtlety worth flagging for review:
DOM::createDocument()callsregisterNodeClass(DOMDocument::class, Document::class), and the fix does not work without it. PHP rebuilds a document's wrapper object whenever it is reached through$node->ownerDocumentafter the original wrapper has been released, and rebuilds it as a plainDOMDocument— discarding the one fact the type exists to record. With the registration the type survives; there is a test for it.Rejected alternative:
saveHTML($node)in the HTML-oriented serializersThe narrower option was to swap
saveXML($node)forsaveHTML($node)inhtml()/innerHTML(). I measured the difference on an HTML-parsed document and it is far too large to be a bug fix:saveXML($node)(today)saveHTML($node)<br/>,<hr/>,<img …/><br>,<hr>,<img …>checked="checked"checked<span/><span></span><script>contents<![CDATA[…]]>That would break every caller relying on the current XHTML-ish output, and would not have covered
innerHTML5()/html5()(Masterminds serializer) or the read side at all.Behaviour changes
DOMProcessingInstruction::$datano longer carries a trailing?for documents read viahtmlqp(),qp()on an.html/.htmfile, oruse_parser => 'html'. Code that trimmed the?itself withrtrim($pi->data, '?')is unaffected; code that usedsubstr($data, 0, -1)unconditionally would now cut a real character.writeHTML()on an XML-parsed document now emits<?php … ?>instead of<?php … >. This was a latent bug, fixed as a side effect.?before the>(<?foo bar>) is serialized bywriteHTML()as<?foo bar?>rather than<?foo bar>. Data is unchanged (nothing to strip); only the HTML write path now terminates it consistently with every other serializer.Tests
tests/Issues/Issue65Test.php(32 tests, 55 assertions), plus atests/processing-instruction.htmlfixture to exercise theloadHTMLFile()path.tests/Issues/is picked up by the existing recursive<directory>./tests/</directory>suite config, so nophpunit.xmlchange was needed.Coverage:
html()(node and whole-document),innerHTML(),innerXML(),innerXHTML(),innerHTML5(),html5(),xml()qp()on a.htmlfile, i.e.loadHTMLFile()as well asloadHTML()??>never appearswriteHTML()(stdout and to a file),writeHTML5(),html5qp(),qp()in XML modewriteHTML()leaves the document unchanged afterwards<?php $a = 1; ??>is not double-stripped<?foo bar>(no terminator) has nothing strippedDOMDocument/DOMNodeis serialized as-is, so the compensation cannot fire on a document that never had the?strippedbranch(), and aDOMQueryconstructed from anotherDOMQuery, both keep serializing correctlyqp()on a node and on the document, andremove()(which runs the legacy selector engine)ownerDocumentafter the original wrapper is gone<?php $a = 1; ??>survives re-use —branch()and a second query over the same document do not erode itwriteHTML()on an XML-parsed document emits the terminator it used to drop16 of the 32 fail on
mainand all pass with the fix.capture()— the output-buffering helper these tests need, becausephpunit.xmlsetsbeStrictAboutOutputDuringTests— is onQueryPathTests\TestCaserather than this file, since the suite already open-codes that dance in eight places.Verification
vendor/bin/phpunit— 387 tests, 1176 assertions, 2 pre-existing skips (create_functionremoved in PHP 8), 0 failurescomposer run lint— cleancomposer run lint:min-php— clean (PHPCompatibility,testVersion7.1-)composer run test:examples— all 17 examples passVerified locally on PHP 8.3.16 / libxml 2.9.13; CI covers 7.1–8.5.
Out of scope
html5qp()is unaffected by the doubling this PR fixes, but it still drops the terminator throughhtml()andwriteHTML()—<?php echo $title; >. That predates this change and reproduces onmainand on 4.1.0, so it is tracked separately in #86 rather than folded in here; the fix for it needs a way to give a Masterminds-parsed document a doctype, which is a behaviour change of its own.🤖 Generated with Claude Code