Repository: jhy/jsoup
Pull request #1720 (2022-02-11 15:36:42)

Wrap Datanodes in CDATA when producing XML
When parsing html with ampersands in <script> sections and outputting xhtml, the ampersands remain unescaped and break parsing of the resulting document. Setting XML syntax, as suggested in [#202], does no result in <script> content being wrapped in CDATA. 

Input html:
```html
<!DOCTYPE html>
<html lang="en">
    <head>
        <title>test</title>
        <script>
            var html = "This is '&nbsp;'";
        </script>
    </head>
    <body>
        <p>
            var html = "This is '&nbsp;'";
        </p>
    </body>
</html>
```

Java code:
```java
Document document = Jsoup.parse(html);
document.outputSettings().charset("UTF-8");
document.outputSettings().syntax(Document.OutputSettings.Syntax.xml);
System.out.println(document.toString());
```

Output xhtml:
```html
<!DOCTYPE html>
<html lang="en">
 <head>
  <title>test</title>
  <script>
                        var html = "This is '&nbsp;'";
                </script>
 </head>
 <body>
  <p> var html = "This is '&nbsp;'"; </p>
 </body>
</html>
```

If we also set `Entities.EscapeMode.xhtml`, the output will be a bit more meaningful, but <script> sections are still ignored

Java code:
```java
Document document = Jsoup.parse(html);
document.outputSettings().charset("UTF-8");
document.outputSettings().syntax(Document.OutputSettings.Syntax.xml);
document.outputSettings().escapeMode(Entities.EscapeMode.xhtml);
System.out.println(document.toString());
```

Output xhtml:
```html
<!DOCTYPE html>
<html lang="en">
 <head>
  <title>test</title>
  <script>
                        var html = "This is '&nbsp;'";
                </script>
 </head>
 <body>
  <p> var html = "This is '&#xa0;'"; </p>
 </body>
</html>
```

This PR wraps data node content, <script> and <style>,  in `CADATA` to produce the following xhtml:
```html
<!DOCTYPE html>
<html lang="en">
 <head>
  <title>test</title>
  <script><![CDATA[
                        var html = "This is '&nbsp;'";
                ]]></script>
 </head>
 <body>
  <p> var html = "This is '&#xa0;'"; </p>
 </body>
</html>
```

jhy: Thanks! I modified the change to pivot on XML syntax vs EscapeMode. The EscapeMode is more for how entities should be escaped. It is conceivable to output HTML with EscapeMode = xhtml; in which case these should not go out as CDATA.

Also added test cases.
jhy: (Back in the #202 days those tags were escaped as mentioned, but were not parsed as datanodes, hence the change)

Patch:
diff --git a/CHANGES b/CHANGES
index 197df4fa..64229137 100644
--- a/CHANGES
+++ b/CHANGES
@@ -6,6 +6,10 @@ Release 1.17.1 [PENDING]
     `#replaceAll(operator)`. These methods update the original DOM, as well as the Elements list.
     <https://github.com/jhy/jsoup/pull/2017>
 
+  * Bugfix: when outputting with XML syntax, HTML elements that were parsed as data nodes (<script> and <style>) should
+    be emitted as CDATA nodes, so that they can be parsed correctly by an XML parser.
+    <https://github.com/jhy/jsoup/pull/1720>
+
 Release 1.16.2 [20-Oct-2023]
   * Improvement: optimized the performance of complex CSS selectors, by adding a cost-based query planner. Evaluators
     are sorted by their relative execution cost, and executed in order of lower to higher cost. This speeds the
diff --git a/src/main/java/org/jsoup/nodes/DataNode.java b/src/main/java/org/jsoup/nodes/DataNode.java
index 65ae7a33..4a0cf434 100644
--- a/src/main/java/org/jsoup/nodes/DataNode.java
+++ b/src/main/java/org/jsoup/nodes/DataNode.java
@@ -1,6 +1,7 @@
 package org.jsoup.nodes;
 
 import java.io.IOException;
+import org.jsoup.nodes.Entities.EscapeMode;
 
 /**
  A data node, for contents of style, script tags etc, where contents should not show in text().
@@ -40,7 +41,16 @@ public class DataNode extends LeafNode {
 
     @Override
     void outerHtmlHead(Appendable accum, int depth, Document.OutputSettings out) throws IOException {
-        accum.append(getWholeData()); // data is not escaped in return from data nodes, so " in script, style is plain
+        if (out.syntax() == Document.OutputSettings.Syntax.xml) {
+            // In XML mode, output data nodes as CDATA, so can parse as XML
+            accum
+                .append("<![CDATA[")
+                .append(getWholeData())
+                .append("]]>");
+        } else {
+            // In HTML, data is not escaped in return from data nodes, so " in script, style is plain
+            accum.append(getWholeData());
+        }
     }
 
     @Override


Test patch:
diff --git a/src/test/java/org/jsoup/helper/W3CDomTest.java b/src/test/java/org/jsoup/helper/W3CDomTest.java
index c1daeb57..d79a2b6b 100644
--- a/src/test/java/org/jsoup/helper/W3CDomTest.java
+++ b/src/test/java/org/jsoup/helper/W3CDomTest.java
@@ -345,5 +345,18 @@ public class W3CDomTest {
         org.jsoup.nodes.TextNode jText = (TextNode) jDiv.childNode(0).childNode(0);
         assertEquals(jText, textNode.getUserData(W3CDom.SourceProperty));
     }
+    
+    @Test public void canXmlParseCdataNodes() throws XPathExpressionException {
+        String html = "<p><script>1 && 2</script><style>3 && 4</style> 5 &amp;&amp; 6</p>";
+        org.jsoup.nodes.Document jdoc = Jsoup.parse(html);
+        jdoc.outputSettings().syntax(org.jsoup.nodes.Document.OutputSettings.Syntax.xml);
+        String xml = jdoc.body().html();
+        assertTrue(xml.contains("<script><![CDATA[")); // as asserted in ElementTest
+        Document doc = parseXml(xml, false);
+        NodeList list = xpath(doc, "//script");
+        assertEquals(1, list.getLength());
+        Node script = list.item(0); // will be the cdata node
+        assertEquals("1 && 2", script.getTextContent());
+    }
 
 }
diff --git a/src/test/java/org/jsoup/nodes/ElementTest.java b/src/test/java/org/jsoup/nodes/ElementTest.java
index bba0940f..549a2244 100644
--- a/src/test/java/org/jsoup/nodes/ElementTest.java
+++ b/src/test/java/org/jsoup/nodes/ElementTest.java
@@ -2733,6 +2733,28 @@ public class ElementTest {
         assertEquals("Hello", parse.data());
     }
 
+    @Test void datanodesOutputCdataInXhtml() {
+        String html = "<p><script>1 && 2</script><style>3 && 4</style> 5 &amp;&amp; 6</p>";
+        Document doc = Jsoup.parse(html); // parsed as HTML
+        String out = TextUtil.normalizeSpaces(doc.body().html());
+        assertEquals(html, out);
+        Element scriptEl = doc.expectFirst("script");
+        DataNode scriptDataNode = (DataNode) scriptEl.childNode(0);
+        assertEquals("1 && 2", scriptDataNode.getWholeData());
+
+        doc.outputSettings().syntax(Document.OutputSettings.Syntax.xml);
+        String xml = doc.body().html();
+        assertEquals(
+            "<p><script><![CDATA[1 && 2]]></script><style><![CDATA[3 && 4]]></style> 5 &amp;&amp; 6</p>",
+            TextUtil.normalizeSpaces(xml));
+
+        Document xmlDoc = Jsoup.parse(xml, Parser.xmlParser());
+        assertEquals(xml, xmlDoc.html());
+        Element scriptXmlEl = xmlDoc.expectFirst("script");
+        CDataNode scriptCdata = (CDataNode) scriptXmlEl.childNode(0);
+        assertEquals(scriptCdata.text(), scriptDataNode.getWholeData());
+    }
+
     @Test void outerHtmlAppendable() {
         // tests not string builder flow
         Document doc = Jsoup.parse("<div>One</div>");