Fix processing instructions gaining an extra "?" when an HTML-parsed document is serialized - #68
Draft
jakejackson1 wants to merge 2 commits into
Draft
Fix processing instructions gaining an extra "?" when an HTML-parsed document is serialized#68jakejackson1 wants to merge 2 commits into
jakejackson1 wants to merge 2 commits into
Conversation
libxml's HTML parser stores the closing "?" of a processing instruction as part of the node'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; ??>`, growing another "?" on every round trip. Normalise processing instruction data when the libxml HTML parser is used, so every parser QueryPath supports shares one invariant: the data never contains the closing "?". That fixes all of the serializers at once, and also means callers reading `$pi->data` get usable source rather than source with a stray "?" glued to the end. libxml's HTML *serializer* writes a processing instruction verbatim as `<?target data>` and never adds the "?" back -- it only looked correct because the parser had left one in the data -- so `writeHTML()` and the whole-document branch of `html()` now restore the terminator for the duration of the write. As a side effect `writeHTML()` on an XML-parsed document, which used to emit `<?php echo $title; >`, is correct too. Fixes #65 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #68 +/- ##
============================================
+ Coverage 89.44% 89.87% +0.42%
- Complexity 1342 1353 +11
============================================
Files 26 26
Lines 3023 3052 +29
============================================
+ Hits 2704 2743 +39
+ Misses 319 309 -10 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The XPath and the walk were written twice — once to strip the terminator on load, once to put it back around an HTML write — so the invariant was established in DOM and inverted in DOMQuery with no shared expression of what it selects. Both now go through DOM::processingInstructions(). Two things fall out of that: - The `documentElement === null` guards are gone. //processing-instruction() returns nothing on a rootless document anyway, so they bought nothing — and they skipped normalization entirely for a document whose only PI precedes the root element. - $usedHTMLParser is initialized rather than tested with empty() while possibly undefined, which was hiding a typo in the name from the reader. 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(), called from the threeloadHTML()/loadHTMLFile()sites inDOM::parseXMLString()andDOM::parseXMLFile()).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 through a privateDOMQuery::saveDocumentHTML()that 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.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(22 tests, 41 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 stripped14 of the 22 fail on
mainand all pass with the fix.Verification
vendor/bin/phpunit— 341 tests, 1114 assertions, 2 pre-existing skips (create_functionremoved in PHP 8), 0 failurescomposer run lint— cleancomposer run lint:min-php— clean (PHPCompatibility,testVersion7.1-)Verified locally on PHP 8.3.16 / libxml 2.9.13; CI covers 7.1–8.5.
🤖 Generated with Claude Code