Repository: jhy/jsoup
Pull request #2297 (2025-04-07 18:13:01)

Change `TokenQueue.consumeCssIdentifier()` to support hex digit unescaping
Rewrite `TokenQueue.consumeCssIdentifier()` to support escaped hex digits and to more closely match the relevant [CSS spec](https://www.w3.org/TR/css-syntax-3/#consume-name).

Part 1 of fixing #1967.


Patch:
diff --git a/CHANGES.md b/CHANGES.md
index b294091a..1c0a58da 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -4,14 +4,28 @@
 
 ### Changes
 
+* To better follow the HTML5 spec and current browsers, the HTML parser no longer allows self-closing tags (`<foo />`)
+  to close HTML elements by default. Foreign content (SVG, MathML), and content parsed with the XML parser, still
+  supports self-closing tags. If you need specific HTML tags to support self-closing, you can register a custom tag via
+  the `TagSet` configured in `Parser.tagSet()`, using `Tag#set(Tag.SelfClose)`. Void/empty tags (like `<img>`) are
+  unaffected by this change. [#2300](https://github.com/jhy/jsoup/issues/2300).
+
 ### Improvements
 
+* Added the ability to define custom tags, and to modify properties of known tags, via the `TagSet` tag collection.
+  Their properties can impact both the parse and how content is
+  serialized. [#2285](https://github.com/jhy/jsoup/issues/2285).
 * `Element.cssSelector()` will prefer to return shorter selectors by using ancestor IDs when available and unique. E.g.
   `#id > div > p` instead of  `html > body > div > div > p` [#2283](https://github.com/jhy/jsoup/pull/2283).
 * Added `Elements.deselect(int index)`, `Elements.deselect(Object o)`, and `Elements.deselectAll()` methods to remove
   elements from the `Elements`
   list without affecting the DOM. And added `Elements.asList()` method to get a modifiable list of elements without
   affecting the DOM. (Each Element is still connected to the DOM.) [#2100](https://github.com/jhy/jsoup/issues/2100).
+* Added support for sending a request body from an InputStream with
+  `Connection.requestBodyStream(InputStream stream)`. [#1122](https://github.com/jhy/jsoup/issues/1122).
+* The XML parser now supports scoped xmlns: prefix namespace declarations, and applies the correct namespace to Tags and
+  Attributes. Also, added `Tag#prefix()`, `Tag#localName()`, `Attribute#prefix()`, `Attribute#localName()`, and
+  `Attribute#namespace()` to retrieve these. [#2299](https://github.com/jhy/jsoup/issues/2299).
 
 ### Bug Fixes
 
@@ -23,6 +37,8 @@
   gets a declaration of `xmlns:prefix="undefined"`. This allows subsequent serializations to XML via `W3CDom.asString()`
   to succeed. [#2087](https://github.com/jhy/jsoup/issues/2087).
 * The `StreamParser` could emit the final elements of a document twice, due to how `onNodeCompleted` was fired when closing out the stack. [#2295](https://github.com/jhy/jsoup/issues/2295).
+* When parsing with the XML parser and error tracking enabled, the trailing `?` in `<?xml version="1.0"?>` would
+  incorrectly emit an error. [#2298](https://github.com/jhy/jsoup/issues/2298).
 
 ## 1.19.1 (2025-03-04)
 
diff --git a/pom.xml b/pom.xml
index da2d0b72..ebf14926 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
 
   <groupId>org.jsoup</groupId>
   <artifactId>jsoup</artifactId>
-  <version>1.19.2-SNAPSHOT</version><!-- remember to update previous version below for japicmp -->
+  <version>1.20.1-SNAPSHOT</version><!-- remember to update previous version below for japicmp -->
   <url>https://jsoup.org/</url>
   <description>jsoup is a Java library that simplifies working with real-world HTML and XML. It offers an easy-to-use API for URL fetching, data parsing, extraction, and manipulation using DOM API methods, CSS, and xpath selectors. jsoup implements the WHATWG HTML5 specification, and parses HTML to the same DOM as modern browsers.</description>
   <inceptionYear>2009</inceptionYear>
@@ -282,7 +282,7 @@
             <dependency>
               <groupId>org.jsoup</groupId>
               <artifactId>jsoup</artifactId>
-              <version>1.18.3</version>
+              <version>1.19.1</version>
               <type>jar</type>
             </dependency>
           </oldVersion>
@@ -505,7 +505,7 @@
     <dependency>
       <groupId>org.junit.jupiter</groupId>
       <artifactId>junit-jupiter</artifactId>
-      <version>5.12.1</version>
+      <version>5.12.2</version>
       <scope>test</scope>
     </dependency>
 
@@ -513,7 +513,7 @@
       <!-- gson, to fetch entities from w3.org -->
       <groupId>com.google.code.gson</groupId>
       <artifactId>gson</artifactId>
-      <version>2.12.1</version>
+      <version>2.13.0</version>
       <scope>test</scope>
     </dependency>
 
diff --git a/src/main/java/org/jsoup/Connection.java b/src/main/java/org/jsoup/Connection.java
index 037da0ac..22f6a75e 100644
--- a/src/main/java/org/jsoup/Connection.java
+++ b/src/main/java/org/jsoup/Connection.java
@@ -289,10 +289,31 @@ public interface Connection {
      * .header("Content-Type", "application/json")
      * .post();</pre></code>
      * If any data key/vals are supplied, they will be sent as URL query params.
+     * @see #requestBodyStream(InputStream)
      * @return this Request, for chaining
      */
     Connection requestBody(String body);
 
+    /**
+     Set the request body. Useful for posting data such as byte arrays or files, and the server expects a single request
+     body (and not a multipart upload). E.g.:
+     <code><pre> Jsoup.connect(url)
+     .requestBody(new ByteArrayInputStream(bytes))
+     .header("Content-Type", "application/octet-stream")
+     .post();
+     </pre></code>
+     <p>Or, use a FileInputStream to data from disk.</p>
+     <p>You should close the stream in a finally block.</p>
+
+     @param stream the input stream to send.
+     @return this Request, for chaining
+     @see #requestBody(String)
+     @since 1.20.1
+     */
+    default Connection requestBodyStream(InputStream stream) {
+        throw new UnsupportedOperationException();
+    }
+
     /**
      * Set a request header. Replaces any existing header with the same case-insensitive name.
      * @param name header name
@@ -767,9 +788,10 @@ public interface Connection {
          * .requestBody(json)
          * .header("Content-Type", "application/json")
          * .post();</pre></code>
-         * If any data key/vals are supplied, they will be sent as URL query params.
+         * <p>If any data key/vals are supplied, they will be sent as URL query params.</p>
          * @param body to use as the request body. Set to null to clear a previously set body.
          * @return this Request, for chaining
+         * @see #requestBodyStream(InputStream)
          */
         Request requestBody(@Nullable String body);
 
@@ -779,6 +801,26 @@ public interface Connection {
          */
         @Nullable String requestBody();
 
+        /**
+         Set the request body. Useful for posting data such as byte arrays or files, and the server expects a single
+         request body (and not a multipart upload). E.g.:
+         <code><pre> Jsoup.connect(url)
+         .requestBody(new ByteArrayInputStream(bytes))
+         .header("Content-Type", "application/octet-stream")
+         .post();
+         </pre></code>
+         <p>Or, use a FileInputStream to data from disk.</p>
+         <p>You should close the stream in a finally block.</p>
+
+         @param stream the input stream to send.
+         @return this Request, for chaining
+         @see #requestBody(String)
+         @since 1.20.1
+         */
+        default Request requestBodyStream(InputStream stream) {
+            throw new UnsupportedOperationException();
+        }
+
         /**
          * Specify the parser to use when parsing the document.
          * @param parser parser to use.
diff --git a/src/main/java/org/jsoup/helper/HttpConnection.java b/src/main/java/org/jsoup/helper/HttpConnection.java
index 403a6146..f31bff0d 100644
--- a/src/main/java/org/jsoup/helper/HttpConnection.java
+++ b/src/main/java/org/jsoup/helper/HttpConnection.java
@@ -281,6 +281,12 @@ public class HttpConnection implements Connection {
         return this;
     }
 
+    @Override
+    public Connection requestBodyStream(InputStream stream) {
+        req.requestBodyStream(stream);
+        return this;
+    }
+
     @Override
     public Connection header(String name, String value) {
         req.header(name, value);
@@ -602,7 +608,7 @@ public class HttpConnection implements Connection {
         private int maxBodySizeBytes;
         private boolean followRedirects;
         private final Collection<Connection.KeyVal> data;
-        private @Nullable String body = null;
+        private @Nullable Object body = null; // String or InputStream
         @Nullable String mimeBoundary;
         private boolean ignoreHttpErrors = false;
         private boolean ignoreContentType = false;
@@ -754,7 +760,13 @@ public class HttpConnection implements Connection {
 
         @Override @Nullable
         public String requestBody() {
-            return body;
+            return body instanceof String ? (String) body : null;
+        }
+
+        @Override
+        public Connection.Request requestBodyStream(InputStream stream) {
+            body = stream;
+            return this;
         }
 
         @Override
@@ -845,7 +857,7 @@ public class HttpConnection implements Connection {
             if (!protocol.equals("http") && !protocol.equals("https"))
                 throw new MalformedURLException("Only http & https protocols supported");
             final boolean supportsBody = req.method().hasBody();
-            final boolean hasBody = req.requestBody() != null;
+            final boolean hasBody = req.body != null;
             if (!supportsBody)
                 Validate.isFalse(hasBody, "Cannot set a request body for HTTP method " + req.method());
 
@@ -1205,11 +1217,10 @@ public class HttpConnection implements Connection {
 
         static void writePost(final HttpConnection.Request req, final OutputStream outputStream) throws IOException {
             final Collection<Connection.KeyVal> data = req.data();
-            final BufferedWriter w = new BufferedWriter(new OutputStreamWriter(outputStream, Charset.forName(req.postDataCharset())));
+            final BufferedWriter w = new BufferedWriter(new OutputStreamWriter(outputStream, req.postDataCharset()));
             final String boundary = req.mimeBoundary;
 
-            if (boundary != null) {
-                // boundary will be set if we're in multipart mode
+            if (boundary != null) { // a multipart post
                 for (Connection.KeyVal keyVal : data) {
                     w.write("--");
                     w.write(boundary);
@@ -1225,7 +1236,7 @@ public class HttpConnection implements Connection {
                         String contentType = keyVal.contentType();
                         w.write(contentType != null ? contentType : DefaultUploadType);
                         w.write("\r\n\r\n");
-                        w.flush(); // flush
+                        w.flush();
                         DataUtil.crossStreams(input, outputStream);
                         outputStream.flush();
                     } else {
@@ -1237,25 +1248,24 @@ public class HttpConnection implements Connection {
                 w.write("--");
                 w.write(boundary);
                 w.write("--");
-            } else {
-                String body = req.requestBody();
-                if (body != null) {
-                    // data will be in query string, we're sending a plaintext body
-                    w.write(body);
+            } else if (req.body != null) { // a single body (bytes or plain text);  data will be in query string
+                if (req.body instanceof String) {
+                    w.write((String) req.body);
+                } else if (req.body instanceof InputStream) {
+                    DataUtil.crossStreams((InputStream) req.body, outputStream);
+                    outputStream.flush();
+                } else {
+                    throw new IllegalStateException();
                 }
-                else {
-                    // regular form data (application/x-www-form-urlencoded)
-                    boolean first = true;
-                    for (Connection.KeyVal keyVal : data) {
-                        if (!first)
-                            w.append('&');
-                        else
-                            first = false;
-
-                        w.write(URLEncoder.encode(keyVal.key(), req.postDataCharset()));
-                        w.write('=');
-                        w.write(URLEncoder.encode(keyVal.value(), req.postDataCharset()));
-                    }
+            } else { // regular form data (application/x-www-form-urlencoded)
+                boolean first = true;
+                for (Connection.KeyVal keyVal : data) {
+                    if (!first) w.append('&');
+                    else first = false;
+
+                    w.write(URLEncoder.encode(keyVal.key(), req.postDataCharset()));
+                    w.write('=');
+                    w.write(URLEncoder.encode(keyVal.value(), req.postDataCharset()));
                 }
             }
             w.close();
diff --git a/src/main/java/org/jsoup/helper/W3CDom.java b/src/main/java/org/jsoup/helper/W3CDom.java
index e472e0f3..ffe8d062 100644
--- a/src/main/java/org/jsoup/helper/W3CDom.java
+++ b/src/main/java/org/jsoup/helper/W3CDom.java
@@ -5,7 +5,7 @@ import org.jsoup.internal.StringUtil;
 import org.jsoup.nodes.Attribute;
 import org.jsoup.nodes.Attributes;
 import org.jsoup.parser.HtmlTreeBuilder;
-import org.jsoup.parser.Parser;
+import org.jsoup.parser.XmlTreeBuilder;
 import org.jsoup.select.NodeTraversor;
 import org.jsoup.select.NodeVisitor;
 import org.jsoup.select.Selector;
@@ -35,7 +35,6 @@ import javax.xml.xpath.XPathExpressionException;
 import javax.xml.xpath.XPathFactory;
 import javax.xml.xpath.XPathFactoryConfigurationException;
 import java.io.StringWriter;
-import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -345,39 +344,23 @@ public class W3CDom {
      * Implements the conversion by walking the input.
      */
     protected static class W3CBuilder implements NodeVisitor {
-        // TODO: move the namespace handling stuff into XmlTreeBuilder / HtmlTreeBuilder, now that Tags have namespaces
-        private static final String xmlnsKey = "xmlns";
-        private static final String xmlnsPrefix = "xmlns:";
-
         private final Document doc;
         private boolean namespaceAware = true;
-        private final ArrayDeque<HashMap<String, String>> namespacesStack = new ArrayDeque<>(); // stack of namespaces, prefix => urn
         private Node dest;
         private Syntax syntax = Syntax.xml; // the syntax (to coerce attributes to). From the input doc if available.
         /*@Nullable*/ private final org.jsoup.nodes.Element contextElement; // todo - unsure why this can't be marked nullable?
 
         public W3CBuilder(Document doc) {
             this.doc = doc;
-            namespacesStack.push(new HashMap<>());
             dest = doc;
             contextElement = (org.jsoup.nodes.Element) doc.getUserData(ContextProperty); // Track the context jsoup Element, so we can save the corresponding w3c element
-            if (contextElement != null) {
-                final org.jsoup.nodes.Document inDoc = contextElement.ownerDocument();
-                if ( namespaceAware && inDoc != null && inDoc.parser().getTreeBuilder() instanceof HtmlTreeBuilder ) {
-                    // as per the WHATWG HTML5 spec § 2.1.3, elements are in the HTML namespace by default
-                    namespacesStack.peek().put("", Parser.NamespaceHtml);
-                }
-            }
         }
 
         @Override
         public void head(org.jsoup.nodes.Node source, int depth) {
-            namespacesStack.push(new HashMap<>(namespacesStack.peek())); // inherit from above on the stack
             if (source instanceof org.jsoup.nodes.Element) {
                 org.jsoup.nodes.Element sourceEl = (org.jsoup.nodes.Element) source;
-
-                String prefix = updateNamespaces(sourceEl);
-                String namespace = namespaceAware ? namespacesStack.peek().get(prefix) : null;
+                String namespace = namespaceAware ? sourceEl.tag().namespace() : null;
                 String tagName = Normalizer.xmlSafeTagName(sourceEl.tagName());
                 try {
                     // use an empty namespace if none is present but the tag name has a prefix
@@ -419,71 +402,63 @@ public class W3CDom {
             if (source instanceof org.jsoup.nodes.Element && dest.getParentNode() instanceof Element) {
                 dest = dest.getParentNode(); // undescend
             }
-            namespacesStack.pop();
         }
 
-        private void copyAttributes(org.jsoup.nodes.Node source, Element el) {
-            for (Attribute attribute : source.attributes()) {
+        private void copyAttributes(org.jsoup.nodes.Element jEl, Element wEl) {
+            for (Attribute attribute : jEl.attributes()) {
                 try {
-                    String key = Attribute.getValidKey(attribute.getKey(), syntax);
-                    if (key != null) {
-                        el.setAttribute(key, attribute.getValue());
-                        addUndeclaredAttrNs(key, el);
-                    }
+                    setAttribute(jEl, wEl, attribute, syntax);
                 } catch (DOMException e) {
-                    if (syntax != Syntax.xml) {
-                        String key = Attribute.getValidKey(attribute.getKey(), Syntax.xml);
-                        if (key != null) {
-                            el.setAttribute(key, attribute.getValue());
-                            addUndeclaredAttrNs(key, el);
-                        }
-                    }
+                    if (syntax != Syntax.xml)
+                        setAttribute(jEl, wEl, attribute, Syntax.xml);
                 }
             }
         }
 
+        private void setAttribute(org.jsoup.nodes.Element jEl, Element wEl, Attribute attribute, Syntax syntax) throws DOMException {
+            String key = Attribute.getValidKey(attribute.getKey(), syntax);
+            if (key != null) {
+                String namespace = attribute.namespace();
+                if (namespaceAware && !namespace.isEmpty())
+                    wEl.setAttributeNS(namespace, key, attribute.getValue());
+                else
+                    wEl.setAttribute(key, attribute.getValue());
+                maybeAddUndeclaredNs(namespace, key, jEl, wEl);
+            }
+        }
+
         /**
          Add a namespace declaration for an attribute with a prefix if it is not already present. Ensures that attributes
          with prefixes have the corresponding namespace declared, E.g. attribute "v-bind:foo" gets another attribute
          "xmlns:v-bind='undefined'. So that the asString() transformation pass is valid.
+         If the parser was HTML we don't have a discovered namespace but we are trying to coerce it, so walk up the
+         element stack and find it.
          */
-        private void addUndeclaredAttrNs(String attrKey, Element wEl) {
-            if (!namespaceAware) return;
+        private void maybeAddUndeclaredNs(String namespace, String attrKey, org.jsoup.nodes.Element jEl, Element wEl) {
+            if (!namespaceAware || !namespace.isEmpty()) return;
             int pos = attrKey.indexOf(':');
-            if (pos > 0) {
+            if (pos != -1) { // prefixed but no namespace defined during parse, add a fake so that w3c serialization doesn't blow up
                 String prefix = attrKey.substring(0, pos);
-                if (!namespacesStack.peek().containsKey(prefix)) {
-                    wEl.setAttribute("xmlns:" + prefix, undefinedNs);
-                    namespacesStack.peek().put(prefix, undefinedNs);
+                if (prefix.equals("xmlns")) return;
+                org.jsoup.nodes.Document doc = jEl.ownerDocument();
+                if (doc != null && doc.parser().getTreeBuilder() instanceof HtmlTreeBuilder) {
+                    // try walking up the stack and seeing if there is a namespace declared for this prefix (and that we didn't parse because HTML)
+                    for (org.jsoup.nodes.Element el = jEl; el != null; el = el.parent()) {
+                        String ns = el.attr("xmlns:" + prefix);
+                        if (!ns.isEmpty()) {
+                            namespace = ns;
+                            // found it, set it
+                            wEl.setAttributeNS(namespace, attrKey, jEl.attr(attrKey));
+                            return;
+                        }
+                    }
                 }
-            }
-        }
-        private static final String undefinedNs = "undefined";
 
-        /**
-         * Finds any namespaces defined in this element. Returns any tag prefix.
-         */
-        private String updateNamespaces(org.jsoup.nodes.Element el) {
-            // scan the element for namespace declarations
-            // like: xmlns="blah" or xmlns:prefix="blah"
-            Attributes attributes = el.attributes();
-            for (Attribute attr : attributes) {
-                String key = attr.getKey();
-                String prefix;
-                if (key.equals(xmlnsKey)) {
-                    prefix = "";
-                } else if (key.startsWith(xmlnsPrefix)) {
-                    prefix = key.substring(xmlnsPrefix.length());
-                } else {
-                    continue;
-                }
-                namespacesStack.peek().put(prefix, attr.getValue());
+                // otherwise, put in a fake one
+                wEl.setAttribute("xmlns:" + prefix, undefinedNs);
             }
-
-            // get the element prefix if any
-            int pos = el.tagName().indexOf(':');
-            return pos > 0 ? el.tagName().substring(0, pos) : "";
         }
-
+        private static final String undefinedNs = "undefined";
     }
+
 }
diff --git a/src/main/java/org/jsoup/internal/SharedConstants.java b/src/main/java/org/jsoup/internal/SharedConstants.java
index 2b4b9e29..baff2a36 100644
--- a/src/main/java/org/jsoup/internal/SharedConstants.java
+++ b/src/main/java/org/jsoup/internal/SharedConstants.java
@@ -9,6 +9,7 @@ public final class SharedConstants {
     public final static String AttrRangeKey = "jsoup.attrs";
     public static final String RangeKey = "jsoup.start";
     public static final String EndRangeKey = "jsoup.end";
+    public static final String XmlnsAttr = "jsoup.xmlns-";
 
     public static final int DefaultBufferSize = 8 * 1024;
 
diff --git a/src/main/java/org/jsoup/internal/StringUtil.java b/src/main/java/org/jsoup/internal/StringUtil.java
index 9d6b0154..8a417dcb 100644
--- a/src/main/java/org/jsoup/internal/StringUtil.java
+++ b/src/main/java/org/jsoup/internal/StringUtil.java
@@ -387,5 +387,4 @@ public final class StringUtil {
             },
             StringJoiner::complete);
     }
-
 }
diff --git a/src/main/java/org/jsoup/nodes/Attribute.java b/src/main/java/org/jsoup/nodes/Attribute.java
index 9f82881e..96f13a9b 100644
--- a/src/main/java/org/jsoup/nodes/Attribute.java
+++ b/src/main/java/org/jsoup/nodes/Attribute.java
@@ -3,6 +3,7 @@ package org.jsoup.nodes;
 import org.jsoup.SerializationException;
 import org.jsoup.helper.Validate;
 import org.jsoup.internal.Normalizer;
+import org.jsoup.internal.SharedConstants;
 import org.jsoup.internal.StringUtil;
 import org.jsoup.nodes.Document.OutputSettings.Syntax;
 import org.jspecify.annotations.Nullable;
@@ -54,7 +55,7 @@ public class Attribute implements Map.Entry<String, String>, Cloneable  {
     }
 
     /**
-     Get the attribute key.
+     Get the attribute's key (aka name).
      @return the attribute key
      */
     @Override
@@ -122,6 +123,48 @@ public class Attribute implements Map.Entry<String, String>, Cloneable  {
         return Attributes.checkNotNull(oldVal);
     }
 
+    /**
+     Get this attribute's key prefix, if it has one; else the empty string.
+     <p>For example, the attribute {@code og:title} has prefix {@code og}, and local {@code title}.</p>
+
+     @return the tag's prefix
+     @since 1.20.1
+     */
+    public String prefix() {
+        int pos = key.indexOf(':');
+        if (pos == -1) return "";
+        else return key.substring(0, pos);
+    }
+
+    /**
+     Get this attribute's local name. The local name is the name without the prefix (if any).
+     <p>For example, the attribute key {@code og:title} has local name {@code title}.</p>
+
+     @return the tag's local name
+     @since 1.20.1
+     */
+    public String localName() {
+        int pos = key.indexOf(':');
+        if (pos == -1) return key;
+        else return key.substring(pos + 1);
+    }
+
+    /**
+     Get this attribute's namespace URI, if the attribute was prefixed with a defined namespace name. Otherwise, returns
+     the empty string. These will only be defined if using the XML parser.
+     @return the tag's namespace URI, or empty string if not defined
+     @since 1.20.1
+     */
+    public String namespace() {
+        // set as el.attributes.userData(SharedConstants.XmlnsAttr + prefix, ns)
+        if (parent != null) {
+            String ns = (String) parent.userData(SharedConstants.XmlnsAttr + prefix());
+            if (ns != null)
+                return ns;
+        }
+        return "";
+    }
+
     /**
      Get the HTML representation of this attribute; e.g. {@code href="index.html"}.
      @return HTML
@@ -263,8 +306,7 @@ public class Attribute implements Map.Entry<String, String>, Cloneable  {
 
     // collapse unknown foo=null, known checked=null, checked="", checked=checked; write out others
     protected static boolean shouldCollapseAttribute(final String key, @Nullable final String val, final Document.OutputSettings out) {
-        return (
-            out.syntax() == Syntax.html &&
+        return (out.syntax() == Syntax.html &&
                 (val == null || (val.isEmpty() || val.equalsIgnoreCase(key)) && Attribute.isBooleanAttribute(key)));
     }
 
diff --git a/src/main/java/org/jsoup/nodes/CDataNode.java b/src/main/java/org/jsoup/nodes/CDataNode.java
index 3affd7e5..32594cca 100644
--- a/src/main/java/org/jsoup/nodes/CDataNode.java
+++ b/src/main/java/org/jsoup/nodes/CDataNode.java
@@ -25,15 +25,11 @@ public class CDataNode extends TextNode {
     }
 
     @Override
-    void outerHtmlHead(Appendable accum, int depth, Document.OutputSettings out) throws IOException {
+    void outerHtmlHead(Appendable accum, Document.OutputSettings out) throws IOException {
         accum
             .append("<![CDATA[")
-            .append(getWholeText());
-    }
-
-    @Override
-    void outerHtmlTail(Appendable accum, int depth, Document.OutputSettings out) throws IOException {
-        accum.append("]]>");
+            .append(getWholeText())
+            .append("]]>");
     }
 
     @Override
diff --git a/src/main/java/org/jsoup/nodes/Comment.java b/src/main/java/org/jsoup/nodes/Comment.java
index 12108d72..9a68a084 100644
--- a/src/main/java/org/jsoup/nodes/Comment.java
+++ b/src/main/java/org/jsoup/nodes/Comment.java
@@ -37,18 +37,13 @@ public class Comment extends LeafNode {
     }
 
     @Override
-	void outerHtmlHead(Appendable accum, int depth, Document.OutputSettings out) throws IOException {
-        if (out.prettyPrint() && ((isEffectivelyFirst() && parentNode instanceof Element && ((Element) parentNode).tag().formatAsBlock()) || (out.outline() )))
-            indent(accum, depth, out);
+    void outerHtmlHead(Appendable accum, Document.OutputSettings out) throws IOException {
         accum
-                .append("<!--")
-                .append(getData())
-                .append("-->");
+            .append("<!--")
+            .append(getData())
+            .append("-->");
     }
 
-    @Override
-    void outerHtmlTail(Appendable accum, int depth, Document.OutputSettings out) {}
-
     @Override
     public Comment clone() {
         return (Comment) super.clone();
diff --git a/src/main/java/org/jsoup/nodes/DataNode.java b/src/main/java/org/jsoup/nodes/DataNode.java
index c63fe740..0cb72908 100644
--- a/src/main/java/org/jsoup/nodes/DataNode.java
+++ b/src/main/java/org/jsoup/nodes/DataNode.java
@@ -39,7 +39,7 @@ public class DataNode extends LeafNode {
     }
 
     @Override
-    void outerHtmlHead(Appendable accum, int depth, Document.OutputSettings out) throws IOException {
+    void outerHtmlHead(Appendable accum, Document.OutputSettings out) throws IOException {
         /* For XML output, escape the DataNode in a CData section. The data may contain pseudo-CData content if it was
         parsed as HTML, so don't double up Cdata. Output in polyglot HTML / XHTML / XML format. */
         final String data = getWholeData();
@@ -52,13 +52,10 @@ public class DataNode extends LeafNode {
                 accum.append("<![CDATA[").append(data).append("]]>");
         } else {
             // In HTML, data is not escaped in the output of data nodes, so < and & in script, style is OK
-            accum.append(getWholeData());
+            accum.append(data);
         }
     }
 
-    @Override
-    void outerHtmlTail(Appendable accum, int depth, Document.OutputSettings out) {}
-
     @Override
     public DataNode clone() {
         return (DataNode) super.clone();
diff --git a/src/main/java/org/jsoup/nodes/Document.java b/src/main/java/org/jsoup/nodes/Document.java
index bbe03bec..6be74281 100644
--- a/src/main/java/org/jsoup/nodes/Document.java
+++ b/src/main/java/org/jsoup/nodes/Document.java
@@ -37,7 +37,7 @@ public class Document extends Element {
      @see #createShell
      */
     public Document(String namespace, String baseUri) {
-        super(Tag.valueOf("#root", namespace, ParseSettings.htmlDefault), baseUri);
+        super(new Tag("#root", namespace), baseUri);
         this.location = baseUri;
         this.parser = Parser.htmlParser(); // default, but overridable
     }
@@ -217,7 +217,7 @@ public class Document extends Element {
      @return new element
      */
     public Element createElement(String tagName) {
-        return new Element(Tag.valueOf(tagName, parser.defaultNamespace(), ParseSettings.preserveCase), this.baseUri());
+        return new Element(parser.tagSet().valueOf(tagName, parser.defaultNamespace(), ParseSettings.preserveCase), this.baseUri());
     }
 
     @Override
diff --git a/src/main/java/org/jsoup/nodes/DocumentType.java b/src/main/java/org/jsoup/nodes/DocumentType.java
index 544ccaed..91294e9a 100644
--- a/src/main/java/org/jsoup/nodes/DocumentType.java
+++ b/src/main/java/org/jsoup/nodes/DocumentType.java
@@ -78,11 +78,7 @@ public class DocumentType extends LeafNode {
     }
 
     @Override
-    void outerHtmlHead(Appendable accum, int depth, Document.OutputSettings out) throws IOException {
-        // add a newline if the doctype has a preceding node (which must be a comment)
-        if (siblingIndex > 0 && out.prettyPrint())
-            accum.append('\n');
-
+    void outerHtmlHead(Appendable accum, Document.OutputSettings out) throws IOException {
         if (out.syntax() == Syntax.html && !has(PublicId) && !has(SystemId)) {
             // looks like a html5 doctype, go lowercase for aesthetics
             accum.append("<!doctype");
@@ -100,9 +96,6 @@ public class DocumentType extends LeafNode {
         accum.append('>');
     }
 
-    @Override
-    void outerHtmlTail(Appendable accum, int depth, Document.OutputSettings out) {
-    }
 
     private boolean has(final String attribute) {
         return !StringUtil.isBlank(attr(attribute));
diff --git a/src/main/java/org/jsoup/nodes/Element.java b/src/main/java/org/jsoup/nodes/Element.java
index 6e94c205..a624678d 100644
--- a/src/main/java/org/jsoup/nodes/Element.java
+++ b/src/main/java/org/jsoup/nodes/Element.java
@@ -52,7 +52,7 @@ public class Element extends Node implements Iterable<Element> {
     private static final List<Element> EmptyChildren = Collections.emptyList();
     private static final Pattern ClassSplit = Pattern.compile("\\s+");
     private static final String BaseUriKey = Attributes.internalKey("baseUri");
-    private Tag tag;
+    Tag tag;
     private @Nullable WeakReference<List<Element>> shadowChildrenRef; // points to child elements shadowed from node children
     List<Node> childNodes;
     @Nullable Attributes attributes; // field is nullable but all methods for attributes are non-null
@@ -72,7 +72,7 @@ public class Element extends Node implements Iterable<Element> {
      * @see #Element(String tag, String namespace)
      */
     public Element(String tag) {
-        this(Tag.valueOf(tag, Parser.NamespaceHtml, ParseSettings.preserveCase), "", null);
+        this(tag, Parser.NamespaceHtml);
     }
 
     /**
@@ -216,7 +216,8 @@ public class Element extends Node implements Iterable<Element> {
     public Element tagName(String tagName, String namespace) {
         Validate.notEmptyParam(tagName, "tagName");
         Validate.notEmptyParam(namespace, "namespace");
-        tag = Tag.valueOf(tagName, namespace, NodeUtils.parser(this).settings()); // maintains the case option of the original parse
+        Parser parser = NodeUtils.parser(this);
+        tag = parser.tagSet().valueOf(tagName, namespace, parser.settings()); // maintains the case option of the original parse
         return this;
     }
 
@@ -229,6 +230,18 @@ public class Element extends Node implements Iterable<Element> {
         return tag;
     }
 
+    /**
+     Change the Tag of this element.
+     @param tag the new tag
+     @return this element, for chaining
+     @since 1.20.1
+     */
+    public Element tag(Tag tag) {
+        Validate.notNull(tag);
+        this.tag = tag;
+        return this;
+    }
+
     /**
      * Test if this element is a block-level element. (E.g. {@code <div> == true} or an inline element
      * {@code <span> == false}).
@@ -784,7 +797,8 @@ public class Element extends Node implements Iterable<Element> {
      * @return the new element, in the specified namespace
      */
     public Element appendElement(String tagName, String namespace) {
-        Element child = new Element(Tag.valueOf(tagName, namespace, NodeUtils.parser(this).settings()), baseUri());
+        Parser parser = NodeUtils.parser(this);
+        Element child = new Element(parser.tagSet().valueOf(tagName, namespace, parser.settings()), baseUri());
         appendChild(child);
         return child;
     }
@@ -808,7 +822,8 @@ public class Element extends Node implements Iterable<Element> {
      * @return the new element, in the specified namespace
      */
     public Element prependElement(String tagName, String namespace) {
-        Element child = new Element(Tag.valueOf(tagName, namespace, NodeUtils.parser(this).settings()), baseUri());
+        Parser parser = NodeUtils.parser(this);
+        Element child = new Element(parser.tagSet().valueOf(tagName, namespace, parser.settings()), baseUri());
         prependChild(child);
         return child;
     }
@@ -1463,7 +1478,7 @@ public class Element extends Node implements Iterable<Element> {
             if (node instanceof Element) {
                 Element element = (Element) node;
                 Node next = node.nextSibling();
-                if (element.isBlock() && (next instanceof TextNode || next instanceof Element && !((Element) next).tag.formatAsBlock()) && !lastCharIsWhitespace(accum))
+                if (!element.tag.isInline() && (next instanceof TextNode || next instanceof Element && ((Element) next).tag.isInline()) && !lastCharIsWhitespace(accum))
                     accum.append(' ');
             }
 
@@ -1564,10 +1579,8 @@ public class Element extends Node implements Iterable<Element> {
     public Element text(String text) {
         Validate.notNull(text);
         empty();
-        // special case for script/style in HTML: should be data node
-        Document owner = ownerDocument();
-        // an alternate impl would be to run through the parser
-        if (owner != null && owner.parser().isContentForTagData(normalName()))
+        // special case for script/style in HTML (or customs): should be data node
+        if (tag().is(Tag.Data))
             appendChild(new DataNode(text));
         else
             appendChild(new TextNode(text));
@@ -1797,44 +1810,31 @@ public class Element extends Node implements Iterable<Element> {
         return Range.of(this, false);
     }
 
-    boolean shouldIndent(final Document.OutputSettings out) {
-        return out.prettyPrint() && isFormatAsBlock(out) && !isInlineable(out) && !preserveWhitespace(parentNode);
-    }
-
     @Override
-    void outerHtmlHead(final Appendable accum, int depth, final Document.OutputSettings out) throws IOException {
-        if (shouldIndent(out)) {
-            if (accum instanceof StringBuilder) {
-                if (((StringBuilder) accum).length() > 0)
-                    indent(accum, depth, out);
-            } else {
-                indent(accum, depth, out);
-            }
-        }
-        accum.append('<').append(safeTagName(out.syntax()));
+    void outerHtmlHead(final Appendable accum, Document.OutputSettings out) throws IOException {
+        String tagName = safeTagName(out.syntax());
+        accum.append('<').append(tagName);
         if (attributes != null) attributes.html(accum, out);
 
-        // selfclosing includes unknown tags, isEmpty defines tags that are always empty
-        if (childNodes.isEmpty() && tag.isSelfClosing()) {
-            if (out.syntax() == html && tag.isEmpty())
+        if (childNodes.isEmpty()) {
+            boolean xmlMode = out.syntax() == xml || !tag.namespace().equals(NamespaceHtml);
+            if (xmlMode && (tag.is(Tag.SeenSelfClose) || (tag.isKnownTag() && (tag.isEmpty() || tag.isSelfClosing())))) {
+                accum.append(" />");
+            } else if (!xmlMode && tag.isEmpty()) { // html void element
                 accum.append('>');
-            else
-                accum.append(" />"); // <img> in html, <img /> in xml
-        }
-        else
+            } else {
+                accum.append("></").append(tagName).append('>');
+            }
+        } else {
             accum.append('>');
+        }
     }
 
     @Override
-    void outerHtmlTail(Appendable accum, int depth, Document.OutputSettings out) throws IOException {
-        if (!(childNodes.isEmpty() && tag.isSelfClosing())) {
-            if (out.prettyPrint() && (!childNodes.isEmpty() && (
-                (tag.formatAsBlock() && !preserveWhitespace(parentNode)) ||
-                    (out.outline() && (childNodes.size()>1 || (childNodes.size()==1 && (childNodes.get(0) instanceof Element))))
-            )))
-                indent(accum, depth, out);
+    void outerHtmlTail(Appendable accum, Document.OutputSettings out) throws IOException {
+        if (!childNodes.isEmpty())
             accum.append("</").append(safeTagName(out.syntax())).append('>');
-        }
+        // if empty, we have already closed in htmlHead
     }
 
     /* If XML syntax, normalizes < to _ in tag name. */
@@ -1857,12 +1857,16 @@ public class Element extends Node implements Iterable<Element> {
     }
 
     @Override
-    public <T extends Appendable> T html(T appendable) {
-        final int size = childNodes.size();
-        for (int i = 0; i < size; i++)
-            childNodes.get(i).outerHtml(appendable);
-
-        return appendable;
+    public <T extends Appendable> T html(T accum) {
+        Node child = firstChild();
+        if (child != null) {
+            Printer printer = Printer.printerFor(child, accum);
+            while (child != null) {
+                NodeTraversor.traverse(printer, child);
+                child = child.nextSibling();
+            }
+        }
+        return accum;
     }
 
     /**
@@ -1969,17 +1973,4 @@ public class Element extends Node implements Iterable<Element> {
             owner.nodelistChanged();
         }
     }
-
-    private boolean isFormatAsBlock(Document.OutputSettings out) {
-        return tag.isBlock() || (parent() != null && parent().tag().formatAsBlock()) || out.outline();
-    }
-
-    private boolean isInlineable(Document.OutputSettings out) {
-        if (!tag.isInline())
-            return false;
-        return (parent() == null || parent().isBlock())
-            && !isEffectivelyFirst()
-            && !out.outline()
-            && !nameIs("br");
-    }
 }
diff --git a/src/main/java/org/jsoup/nodes/LeafNode.java b/src/main/java/org/jsoup/nodes/LeafNode.java
index adffc302..e23693c1 100644
--- a/src/main/java/org/jsoup/nodes/LeafNode.java
+++ b/src/main/java/org/jsoup/nodes/LeafNode.java
@@ -2,6 +2,7 @@ package org.jsoup.nodes;
 
 import org.jsoup.helper.Validate;
 
+import java.io.IOException;
 import java.util.List;
 
 /**
@@ -108,6 +109,9 @@ public abstract class LeafNode extends Node {
         return EmptyNodes;
     }
 
+    @Override
+    void outerHtmlTail(Appendable accum, Document.OutputSettings out) throws IOException {}
+
     @Override
     protected LeafNode doClone(Node parent) {
         LeafNode clone = (LeafNode) super.doClone(parent);
diff --git a/src/main/java/org/jsoup/nodes/Node.java b/src/main/java/org/jsoup/nodes/Node.java
index 0bd18c33..19ea12cb 100644
--- a/src/main/java/org/jsoup/nodes/Node.java
+++ b/src/main/java/org/jsoup/nodes/Node.java
@@ -1,6 +1,5 @@
 package org.jsoup.nodes;
 
-import org.jsoup.SerializationException;
 import org.jsoup.helper.Validate;
 import org.jsoup.internal.StringUtil;
 import org.jsoup.select.NodeFilter;
@@ -767,17 +766,20 @@ public abstract class Node implements Cloneable {
     }
 
     protected void outerHtml(Appendable accum) {
-        NodeTraversor.traverse(new OuterHtmlVisitor(accum, NodeUtils.outputSettings(this)), this);
+        Printer printer = Printer.printerFor(this, accum);
+        NodeTraversor.traverse(printer, this);
     }
 
     /**
      Get the outer HTML of this node.
+
      @param accum accumulator to place HTML into
+     @param out
      @throws IOException if appending to the given accumulator fails.
      */
-    abstract void outerHtmlHead(final Appendable accum, int depth, final Document.OutputSettings out) throws IOException;
+    abstract void outerHtmlHead(final Appendable accum, final Document.OutputSettings out) throws IOException;
 
-    abstract void outerHtmlTail(final Appendable accum, int depth, final Document.OutputSettings out) throws IOException;
+    abstract void outerHtmlTail(final Appendable accum, final Document.OutputSettings out) throws IOException;
 
     /**
      * Write this node and its children to the given {@link Appendable}.
@@ -935,32 +937,4 @@ public abstract class Node implements Cloneable {
 
         return clone;
     }
-
-    private static class OuterHtmlVisitor implements NodeVisitor {
-        private final Appendable accum;
-        private final Document.OutputSettings out;
-
-        OuterHtmlVisitor(Appendable accum, Document.OutputSettings out) {
-            this.accum = accum;
-            this.out = out;
-        }
-
-        @Override public void head(Node node, int depth) {
-            try {
-				node.outerHtmlHead(accum, depth, out);
-			} catch (IOException exception) {
-				throw new SerializationException(exception);
-			}
-        }
-
-        @Override public void tail(Node node, int depth) {
-            if (!node.nodeName().equals("#text")) { // saves a void hit.
-				try {
-					node.outerHtmlTail(accum, depth, out);
-				} catch (IOException exception) {
-					throw new SerializationException(exception);
-				}
-            }
-        }
-    }
 }
diff --git a/src/main/java/org/jsoup/nodes/Printer.java b/src/main/java/org/jsoup/nodes/Printer.java
new file mode 100644
index 00000000..c5e9f584
--- /dev/null
+++ b/src/main/java/org/jsoup/nodes/Printer.java
@@ -0,0 +1,240 @@
+package org.jsoup.nodes;
+
+import org.jsoup.SerializationException;
+import org.jsoup.internal.StringUtil;
+import org.jsoup.nodes.Document.OutputSettings;
+import org.jsoup.parser.Tag;
+import org.jsoup.select.NodeVisitor;
+import org.jspecify.annotations.Nullable;
+
+import java.io.IOException;
+
+/** Base Printer */
+class Printer implements NodeVisitor {
+    final Node root;
+    final Appendable accum;
+    final OutputSettings settings;
+
+    public Printer(Node root, Appendable accum, OutputSettings settings) {
+        this.root = root;
+        this.accum = accum;
+        this.settings = settings;
+    }
+
+    void addHead(Element el, int depth) throws IOException {
+        el.outerHtmlHead(accum, settings);
+    }
+
+    void addTail(Element el, int depth) throws IOException {
+        el.outerHtmlTail(accum, settings);
+    }
+
+    void addText(TextNode textNode, int textOptions, int depth) throws IOException {
+        int options = Entities.ForText | textOptions;
+        Entities.escape(accum, textNode.coreValue(), settings, options);
+    }
+
+    void addNode(LeafNode node, int depth) throws IOException  {
+        node.outerHtmlHead(accum, settings);
+    }
+
+    void indent(int depth) throws IOException {
+        accum.append('\n').append(StringUtil.padding(depth * settings.indentAmount(), settings.maxPaddingWidth()));
+    }
+
+    @Override
+    public void head(Node node, int depth) {
+        try {
+            if (node.getClass() == TextNode.class)  addText((TextNode) node, 0, depth); // Excludes CData; falls to addNode
+            else if (node instanceof Element)       addHead((Element) node, depth);
+            else                                    addNode((LeafNode) node, depth);
+        } catch (IOException exception) {
+            throw new SerializationException(exception);
+        }
+    }
+
+    @Override
+    public void tail(Node node, int depth) {
+        if (node instanceof Element) { // otherwise a LeafNode
+            try {
+                addTail((Element) node, depth);
+            } catch (IOException exception) {
+                throw new SerializationException(exception);
+            }
+        }
+    }
+
+    /** Pretty Printer */
+    static class Pretty extends Printer {
+        boolean preserveWhitespace = false;
+
+        public Pretty(Node root, Appendable accum, OutputSettings settings) {
+            super(root, accum, settings);
+
+            // check if there is a pre on stack
+            for (Node node = root; node != null; node = node.parentNode()) {
+                if (tagIs(Tag.PreserveWhitespace, node)) {
+                    preserveWhitespace = true;
+                    break;
+                }
+            }
+        }
+
+        @Override
+        void addHead(Element el, int depth) throws IOException {
+            if (shouldIndent(el))
+                indent(depth);
+            super.addHead(el, depth);
+            if (tagIs(Tag.PreserveWhitespace, el)) preserveWhitespace = true;
+        }
+
+        @Override
+        void addTail(Element el, int depth) throws IOException {
+            if (shouldIndent(nextNonBlank(el.firstChild()))) {
+                indent(depth);
+            }
+            super.addTail(el, depth);
+
+            // clear the preserveWhitespace if this element is not, and there are none on the stack above
+            if (preserveWhitespace && el.tag.is(Tag.PreserveWhitespace)) {
+                for (Element parent = el.parent(); parent != null; parent = parent.parent()) {
+                    if (parent.tag().preserveWhitespace()) return; // keep
+                }
+                preserveWhitespace = false;
+            }
+        }
+
+        @Override
+        void addNode(LeafNode node, int depth) throws IOException {
+            if (shouldIndent(node))
+                indent(depth);
+            super.addNode(node, depth);
+        }
+
+        @Override
+        void addText(TextNode node, int textOptions, int depth) throws IOException {
+            if (!preserveWhitespace) {
+                textOptions |= Entities.Normalise;
+                textOptions = textTrim(node, textOptions);
+
+                if (!node.isBlank() && isBlockEl(node.parentNode) && shouldIndent(node))
+                    indent(depth);
+            }
+
+            super.addText(node, textOptions, depth);
+        }
+
+        int textTrim(TextNode node, int options) {
+            if (!isBlockEl(node.parentNode)) return options; // don't trim inline, whitespace significant
+            Node prev = node.previousSibling();
+            Node next = node.nextSibling();
+
+            if (prev == null || !(prev instanceof TextNode) && shouldIndent(prev))
+                options |= Entities.TrimLeading;
+            if (next == null || !(next instanceof TextNode) && shouldIndent(next))
+                options |= Entities.TrimTrailing; // don't trim if there is a TextNode sequence
+
+            return options;
+        }
+
+        boolean shouldIndent(@Nullable Node node) {
+            if (node == null || node == root || preserveWhitespace || isBlankText(node))
+                return false;
+            if (isBlockEl(node))
+                return true;
+
+            Node prevSib = previousNonblank(node);
+            if (isBlockEl(prevSib)) return true;
+
+            Element parent = (Element) node.parentNode;
+            if (!isBlockEl(parent) || parent.tag().is(Tag.InlineContainer) || !hasNonTextNodes(parent))
+                return false;
+
+            return prevSib == null ||
+                (!(prevSib instanceof TextNode) &&
+                    (isBlockEl(prevSib) || !(prevSib instanceof Element)));
+        }
+
+        boolean isBlockEl(@Nullable Node node) {
+            if (node == null) return false;
+            if (node instanceof Element) {
+                Element el = (Element) node;
+                return el.isBlock() ||
+                    (!el.tag.isKnownTag() && (el.parentNode instanceof Document || hasChildBlocks(el)));
+            }
+
+            return false;
+        }
+
+        /**
+         Returns true if any of the Element's child nodes should indent. Checks the last 5 nodes only (to minimize
+         scans).
+         */
+        static boolean hasChildBlocks(Element el) {
+            Element child = el.firstElementChild();
+            for (int i = 0; i < maxScan && child != null; i++) {
+                if (child.isBlock() || !child.tag.isKnownTag()) return true;
+                child = child.nextElementSibling();
+            }
+            return false;
+        }
+        static private final int maxScan = 5;
+
+        static boolean hasNonTextNodes(Element el) {
+            Node child = el.firstChild();
+            for (int i = 0; i < maxScan && child != null; i++) {
+                if (!(child instanceof TextNode)) return true;
+                child = child.nextSibling();
+            }
+            return false;
+        }
+
+        static @Nullable Node previousNonblank(Node node) {
+            Node prev = node.previousSibling();
+            while (isBlankText(prev)) prev = prev.previousSibling();
+            return prev;
+        }
+
+        static @Nullable Node nextNonBlank(@Nullable Node node) {
+            while (isBlankText(node)) node = node.nextSibling();
+            return node;
+        }
+
+        static boolean isBlankText(@Nullable Node node) {
+            return node instanceof TextNode && ((TextNode) node).isBlank();
+        }
+
+        static boolean tagIs(int option, @Nullable Node node) {
+            return node instanceof Element && ((Element) node).tag.is(option);
+        }
+    }
+
+    /** Outline Printer */
+    static class Outline extends Pretty {
+        public Outline(Node root, Appendable accum, OutputSettings settings) {
+            super(root, accum, settings);
+        }
+
+        @Override
+        boolean isBlockEl(@Nullable Node node) {
+            return node != null;
+        }
+
+        @Override
+        boolean shouldIndent(@Nullable Node node) {
+            if (node == null || node == root || preserveWhitespace || isBlankText(node))
+                return false;
+            if (node instanceof TextNode) {
+                return node.previousSibling() != null || node.nextSibling() != null;
+            }
+            return true;
+        }
+    }
+
+    static Printer printerFor(Node root, Appendable accum) {
+        OutputSettings settings = NodeUtils.outputSettings(root);
+        if (settings.outline())     return new Printer.Outline(root, accum, settings);
+        if (settings.prettyPrint()) return new Printer.Pretty(root, accum, settings);
+        return new Printer(root, accum, settings);
+    }
+}
diff --git a/src/main/java/org/jsoup/nodes/PseudoTextElement.java b/src/main/java/org/jsoup/nodes/PseudoTextElement.java
index 5d0cc143..f51acb2b 100644
--- a/src/main/java/org/jsoup/nodes/PseudoTextElement.java
+++ b/src/main/java/org/jsoup/nodes/PseudoTextElement.java
@@ -13,10 +13,10 @@ public class PseudoTextElement extends Element {
     }
 
     @Override
-    void outerHtmlHead(Appendable accum, int depth, Document.OutputSettings out) {
+    void outerHtmlHead(Appendable accum, Document.OutputSettings out) {
     }
 
     @Override
-    void outerHtmlTail(Appendable accum, int depth, Document.OutputSettings out) {
+    void outerHtmlTail(Appendable accum, Document.OutputSettings out) {
     }
 }
diff --git a/src/main/java/org/jsoup/nodes/TextNode.java b/src/main/java/org/jsoup/nodes/TextNode.java
index e2a8f3d5..c45a9e14 100644
--- a/src/main/java/org/jsoup/nodes/TextNode.java
+++ b/src/main/java/org/jsoup/nodes/TextNode.java
@@ -81,44 +81,10 @@ public class TextNode extends LeafNode {
     }
 
     @Override
-    void outerHtmlHead(Appendable accum, int depth, Document.OutputSettings out) throws IOException {
-        final boolean prettyPrint = out.prettyPrint();
-        final boolean normaliseWhite = prettyPrint && !Element.preserveWhitespace(parentNode);
-        int escape = Entities.ForText;
-
-        if (normaliseWhite) {
-            escape |= Entities.Normalise;
-            final Element parent = parentNode instanceof Element ? ((Element) parentNode) : null;
-            final boolean trimLikeBlock = parent != null && (parent.tag().isBlock() || parent.tag().formatAsBlock());
-            if ((trimLikeBlock && siblingIndex == 0) || parentNode instanceof Document)
-                escape |= Entities.TrimLeading;
-            if (trimLikeBlock && nextSibling() == null)
-                escape |= Entities.TrimTrailing;
-
-            // if this text is just whitespace, and the next node will cause an indent, skip this text:
-            Node next = nextSibling();
-            Node prev = previousSibling();
-            boolean isBlank = isBlank();
-            boolean couldSkip = (next instanceof Element && ((Element) next).shouldIndent(out)) // next will indent
-                || (next instanceof TextNode && (((TextNode) next).isBlank())) // next is blank text, from re-parenting
-                || (prev instanceof Element && (((Element) prev).isBlock() || prev.nameIs("br"))) // br is a bit special - make sure we don't get a dangling blank line, but not a block otherwise wraps in head
-                ;
-            if (couldSkip && isBlank) return;
-
-            if (
-                (prev == null && parent != null && parent.tag().formatAsBlock() && !isBlank) ||
-                (out.outline() && siblingNodes().size() > 0 && !isBlank) ||
-                (prev != null && prev.nameIs("br")) // special case wrap on inline <br> - doesn't make sense as a block tag
-            )
-                indent(accum, depth, out);
-        }
-
-        Entities.escape(accum, coreValue(), out, escape);
+    void outerHtmlHead(Appendable accum, Document.OutputSettings out) throws IOException {
+        Entities.escape(accum, coreValue(), out, Entities.ForText);
     }
 
-    @Override
-    void outerHtmlTail(Appendable accum, int depth, Document.OutputSettings out) throws IOException {}
-
     @Override
     public String toString() {
         return outerHtml();
diff --git a/src/main/java/org/jsoup/nodes/XmlDeclaration.java b/src/main/java/org/jsoup/nodes/XmlDeclaration.java
index c6b03208..f7a3a92b 100644
--- a/src/main/java/org/jsoup/nodes/XmlDeclaration.java
+++ b/src/main/java/org/jsoup/nodes/XmlDeclaration.java
@@ -70,7 +70,7 @@ public class XmlDeclaration extends LeafNode {
     }
 
     @Override
-    void outerHtmlHead(Appendable accum, int depth, Document.OutputSettings out) throws IOException {
+    void outerHtmlHead(Appendable accum, Document.OutputSettings out) throws IOException {
         accum
             .append("<")
             .append(isDeclaration ? "!" : "?")
@@ -82,7 +82,7 @@ public class XmlDeclaration extends LeafNode {
     }
 
     @Override
-    void outerHtmlTail(Appendable accum, int depth, Document.OutputSettings out) {
+    void outerHtmlTail(Appendable accum, Document.OutputSettings out) {
     }
 
     @Override
diff --git a/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java b/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java
index afd20906..690c87b8 100644
--- a/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java
+++ b/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java
@@ -56,6 +56,9 @@ public class HtmlTreeBuilder extends TreeBuilder {
     static String[] TagSearchSpecialMath = {"annotation-xml", "mi", "mn", "mo", "ms", "mtext"}; // differs to MathML text integration point; adds annotation-xml
     static final String[] TagMathMlTextIntegration = new String[]{"mi", "mn", "mo", "ms", "mtext"};
     static final String[] TagSvgHtmlIntegration = new String[]{"desc", "foreignObject", "title"};
+    static final String[] TagFormListed = {
+        "button", "fieldset", "input", "keygen", "object", "output", "select", "textarea"
+    };
 
     public static final int MaxScopeSearchDepth = 100; // prevents the parser bogging down in exceptionally broken pages
 
@@ -117,17 +120,6 @@ public class HtmlTreeBuilder extends TreeBuilder {
 
             // initialise the tokeniser state:
             switch (contextName) {
-                case "title":
-                case "textarea":
-                    tokeniser.transition(TokeniserState.Rcdata);
-                    break;
-                case "iframe":
-                case "noembed":
-                case "noframes":
-                case "style":
-                case "xmp":
-                    tokeniser.transition(TokeniserState.Rawtext);
-                    break;
                 case "script":
                     tokeniser.transition(TokeniserState.ScriptData);
                     break;
@@ -139,7 +131,12 @@ public class HtmlTreeBuilder extends TreeBuilder {
                     pushTemplateMode(HtmlTreeBuilderState.InTemplate);
                     break;
                 default:
-                    tokeniser.transition(TokeniserState.Data);
+                    Tag tag = contextElement.tag();
+                    TokeniserState textState = tag.textState();
+                    if (textState != null)
+                        tokeniser.transition(textState); // style, xmp, title, textarea, etc; or custom
+                    else
+                        tokeniser.transition(TokeniserState.Data);
             }
             doc.appendChild(contextElement);
             push(contextElement);
@@ -329,23 +326,20 @@ public class HtmlTreeBuilder extends TreeBuilder {
     /** Inserts an HTML element for the given tag) */
     Element insertElementFor(final Token.StartTag startTag) {
         Element el = createElementFor(startTag, NamespaceHtml, false);
-        doInsertElement(el, startTag);
+        doInsertElement(el);
 
-        // handle self-closing tags. when the spec expects an empty tag, will directly hit insertEmpty, so won't generate this fake end tag.
+        // handle self-closing tags. when the spec expects an empty (void) tag, will directly hit insertEmpty, so won't generate this fake end tag.
         if (startTag.isSelfClosing()) {
             Tag tag = el.tag();
-            if (tag.isKnownTag()) {
-                if (!tag.isEmpty())
-                    tokeniser.error("Tag [%s] cannot be self closing; not a void tag", tag.normalName());
-                // else: ok
+            tag.setSeenSelfClose(); // can infer output if in xml syntax
+            if (tag.isKnownTag() && (tag.isEmpty() || tag.isSelfClosing())) {
+                // ok, allow it. effectively a pop, but fiddles with the state. handles empty style, title etc which would otherwise leave us in data state
+                tokeniser.transition(TokeniserState.Data); // handles <script />, otherwise needs breakout steps from script data
+                tokeniser.emit(emptyEnd.reset().name(el.tagName()));  // ensure we get out of whatever state we are in. emitted for yielded processing
+            } else {
+                // error it, and leave the inserted element on
+                tokeniser.error("Tag [%s] cannot be self-closing; not a void tag", tag.normalName());
             }
-            else { // unknown tag: remember this is self-closing, for output
-                tag.setSelfClosing();
-            }
-
-            // effectively a pop, but fiddles with the state. handles empty style, title etc which would otherwise leave us in data state
-            tokeniser.transition(TokeniserState.Data); // handles <script />, otherwise needs breakout steps from script data
-            tokeniser.emit(emptyEnd.reset().name(el.tagName()));  // ensure we get out of whatever state we are in. emitted for yielded processing
         }
 
         return el;
@@ -356,10 +350,10 @@ public class HtmlTreeBuilder extends TreeBuilder {
      */
     Element insertForeignElementFor(final Token.StartTag startTag, String namespace) {
         Element el = createElementFor(startTag, namespace, true);
-        doInsertElement(el, startTag);
+        doInsertElement(el);
 
-        if (startTag.isSelfClosing()) {
-            el.tag().setSelfClosing(); // remember this is self-closing for output
+        if (startTag.isSelfClosing()) { // foreign els are OK to self-close
+            el.tag().setSeenSelfClose(); // remember this is self-closing for output
             pop();
         }
 
@@ -368,7 +362,7 @@ public class HtmlTreeBuilder extends TreeBuilder {
 
     Element insertEmptyElementFor(Token.StartTag startTag) {
         Element el = createElementFor(startTag, NamespaceHtml, false);
-        doInsertElement(el, startTag);
+        doInsertElement(el);
         pop();
         return el;
     }
@@ -382,7 +376,7 @@ public class HtmlTreeBuilder extends TreeBuilder {
         } else
             setFormElement(el);
 
-        doInsertElement(el, startTag);
+        doInsertElement(el);
         if (!onStack) pop();
         return el;
     }
@@ -390,10 +384,9 @@ public class HtmlTreeBuilder extends TreeBuilder {
     /** Inserts the Element onto the stack. All element inserts must run through this method. Performs any general
      tests on the Element before insertion.
      * @param el the Element to insert and make the current element
-     * @param token the token this element was parsed from. If null, uses a zero-width current token as intrinsic insert
      */
-    private void doInsertElement(Element el, @Nullable Token token) {
-        if (el.tag().isFormListed() && formElement != null)
+    private void doInsertElement(Element el) {
+        if (formElement != null && el.tag().namespace.equals(NamespaceHtml) && StringUtil.inSorted(el.normalName(), TagFormListed))
             formElement.addElement(el); // connect form controls to their form element
 
         // in HTML, the xmlns attribute if set must match what the parser set the tag's namespace to
@@ -423,12 +416,11 @@ public class HtmlTreeBuilder extends TreeBuilder {
     /** Inserts the provided character token into the provided element. */
     void insertCharacterToElement(Token.Character characterToken, Element el) {
         final Node node;
-        final String tagName = el.normalName();
         final String data = characterToken.getData();
 
         if (characterToken.isCData())
             node = new CDataNode(data);
-        else if (isContentForTagData(tagName))
+        else if (el.tag().is(Tag.Data))
             node = new DataNode(data);
         else
             node = new TextNode(data);
@@ -949,7 +941,7 @@ public class HtmlTreeBuilder extends TreeBuilder {
             // 8. create new element from element, 9 insert into current node, onto stack
             skip = false; // can only skip increment from 4.
             Element newEl = new Element(tagFor(entry.nodeName(), entry.normalName(), defaultNamespace(), settings), null, entry.attributes().clone());
-            doInsertElement(newEl, null);
+            doInsertElement(newEl);
 
             // 10. replace entry with new entry
             formattingElements.set(pos, newEl);
@@ -1055,7 +1047,10 @@ public class HtmlTreeBuilder extends TreeBuilder {
                 '}';
     }
 
-    @Override protected boolean isContentForTagData(final String normalName) {
+    /** @deprecated this unused internal method will be removed. */
+    @Deprecated
+    protected boolean isContentForTagData(final String normalName) {
         return (normalName.equals("script") || normalName.equals("style"));
     }
+
 }
diff --git a/src/main/java/org/jsoup/parser/HtmlTreeBuilderState.java b/src/main/java/org/jsoup/parser/HtmlTreeBuilderState.java
index 00a439e7..037d031c 100644
--- a/src/main/java/org/jsoup/parser/HtmlTreeBuilderState.java
+++ b/src/main/java/org/jsoup/parser/HtmlTreeBuilderState.java
@@ -132,9 +132,9 @@ enum HtmlTreeBuilderState {
                         tb.insertEmptyElementFor(start);
                         // todo: charset switches
                     } else if (name.equals("title")) {
-                        handleRcData(start, tb);
+                        HandleTextState(start, tb, TokeniserState.Rcdata);
                     } else if (inSorted(name, InHeadRaw)) {
-                        handleRawtext(start, tb);
+                        HandleTextState(start, tb, TokeniserState.Rawtext);
                     } else if (name.equals("noscript")) {
                         // else if noscript && scripting flag = true: rawtext (jsoup doesn't run script, to handle as noscript)
                         tb.insertElementFor(start);
@@ -479,13 +479,8 @@ enum HtmlTreeBuilderState {
                         tb.insertElementFor(startTag);
                     break;
                 case "textarea":
-                    tb.insertElementFor(startTag);
-                    if (!startTag.isSelfClosing()) {
-                        tb.tokeniser.transition(TokeniserState.Rcdata);
-                        tb.markInsertionMode();
-                        tb.framesetOk(false);
-                        tb.transition(Text);
-                    }
+                    tb.framesetOk(false);
+                    HandleTextState(startTag, tb, TokeniserState.Rcdata);
                     break;
                 case "xmp":
                     if (tb.inButtonScope("p")) {
@@ -493,15 +488,15 @@ enum HtmlTreeBuilderState {
                     }
                     tb.reconstructFormattingElements();
                     tb.framesetOk(false);
-                    handleRawtext(startTag, tb);
+                    HandleTextState(startTag, tb, TokeniserState.Rawtext);
                     break;
                 case "iframe":
                     tb.framesetOk(false);
-                    handleRawtext(startTag, tb);
+                    HandleTextState(startTag, tb, TokeniserState.Rawtext);
                     break;
                 case "noembed":
                     // also handle noscript if script enabled
-                    handleRawtext(startTag, tb);
+                    HandleTextState(startTag, tb, TokeniserState.Rawtext);
                     break;
                 case "select":
                     tb.reconstructFormattingElements();
@@ -628,13 +623,14 @@ enum HtmlTreeBuilderState {
                     tb.pushActiveFormattingElements(el);
                     break;
                 default:
-                    // todo - bring scan groups in if desired
-                    if (!Tag.isKnownTag(name)) { // no special rules for custom tags
+                    Tag tag = tb.tagFor(startTag);
+                    TokeniserState textState = tag.textState();
+                    // custom rcdata or rawtext (if we were in head, will have auto-transitioned here)
+                    if (textState != null) HandleTextState(startTag, tb, textState);
+                    else if (!tag.isKnownTag()) { // no other special rules for custom tags
                         tb.insertElementFor(startTag);
                     } else if (inSorted(name, Constants.InBodyStartPClosers)) {
-                        if (tb.inButtonScope("p")) {
-                            tb.processEndTag("p");
-                        }
+                        if (tb.inButtonScope("p")) tb.processEndTag("p");
                         tb.insertElementFor(startTag);
                     } else if (inSorted(name, Constants.InBodyStartToHead)) {
                         return tb.process(t, InHead);
@@ -1887,15 +1883,8 @@ enum HtmlTreeBuilderState {
         return false;
     }
 
-    private static void handleRcData(Token.StartTag startTag, HtmlTreeBuilder tb) {
-        tb.tokeniser.transition(TokeniserState.Rcdata);
-        tb.markInsertionMode();
-        tb.transition(Text);
-        tb.insertElementFor(startTag);
-    }
-
-    private static void handleRawtext(Token.StartTag startTag, HtmlTreeBuilder tb) {
-        tb.tokeniser.transition(TokeniserState.Rawtext);
+    private static void HandleTextState(Token.StartTag startTag, HtmlTreeBuilder tb, TokeniserState state) {
+        tb.tokeniser.transition(state);
         tb.markInsertionMode();
         tb.transition(Text);
         tb.insertElementFor(startTag);
@@ -1925,7 +1914,6 @@ enum HtmlTreeBuilderState {
             "nav", "ol", "pre", "section", "summary", "ul"};
         static final String[] InBodyEndOtherErrors = new String[] {"body", "dd", "dt", "html", "li", "optgroup", "option", "p", "rb", "rp", "rt", "rtc", "tbody", "td", "tfoot", "th", "thead", "tr"};
         static final String[] InBodyEndAdoptionFormatters = new String[]{"a", "b", "big", "code", "em", "font", "i", "nobr", "s", "small", "strike", "strong", "tt", "u"};
-        static final String[] InBodyEndTableFosters = new String[]{"table", "tbody", "tfoot", "thead", "tr"};
         static final String[] InTableToBody = new String[]{"tbody", "tfoot", "thead"};
         static final String[] InTableAddBody = new String[]{"td", "th", "tr"};
         static final String[] InTableToHead = new String[]{"script", "style", "template"};
diff --git a/src/main/java/org/jsoup/parser/Parser.java b/src/main/java/org/jsoup/parser/Parser.java
index ca8f1641..d853bff8 100644
--- a/src/main/java/org/jsoup/parser/Parser.java
+++ b/src/main/java/org/jsoup/parser/Parser.java
@@ -1,5 +1,6 @@
 package org.jsoup.parser;
 
+import org.jsoup.helper.Validate;
 import org.jsoup.nodes.Document;
 import org.jsoup.nodes.Element;
 import org.jsoup.nodes.Node;
@@ -24,6 +25,7 @@ public class Parser implements Cloneable {
     private ParseErrorList errors;
     private ParseSettings settings;
     private boolean trackPosition = false;
+    @Nullable TagSet tagSet;
 
     /**
      * Create a new Parser, using the specified TreeBuilder
@@ -157,12 +159,39 @@ public class Parser implements Cloneable {
         return settings;
     }
 
+    /**
+     Set a custom TagSet to use for this Parser. This allows you to define your own tags, and control how they are
+     parsed. For example, you can set a tag to preserve whitespace, or to be treated as a block tag.
+     <p>You can start with the {@link TagSet#Html()} defaults and customize, or a new empty TagSet.</p>
+
+     @param tagSet the TagSet to use. This gets copied, so that changes that the parse makes (tags found in the document will be added) do not clobber the original TagSet.
+     @return this Parser
+     @since 1.20.1
+     */
+    public Parser tagSet(TagSet tagSet) {
+        Validate.notNull(tagSet);
+        this.tagSet = new TagSet(tagSet); // copy it as we are going to mutate it
+        return this;
+    }
+
+    /**
+     Get the current TagSet for this Parser, which will be either this parser's default, or one that you have set.
+     @return the current TagSet. After the parse, this will contain any new tags that were found in the document.
+     @since 1.20.1
+     */
+    public TagSet tagSet() {
+        if (tagSet == null)
+            tagSet = treeBuilder.defaultTagSet();
+        return tagSet;
+    }
+
     /**
      (An internal method, visible for Element. For HTML parse, signals that script and style text should be treated as
      Data Nodes).
+     @deprecated internal method, no longer used, and will be removed in 1.12.1.
      */
-    public boolean isContentForTagData(String normalName) {
-        return getTreeBuilder().isContentForTagData(normalName);
+    @Deprecated public boolean isContentForTagData(String normalName) {
+        return tagSet().valueOf(normalName, defaultNamespace()).is(Tag.Data);
     }
 
     public String defaultNamespace() {
diff --git a/src/main/java/org/jsoup/parser/Tag.java b/src/main/java/org/jsoup/parser/Tag.java
index 333274ce..3077696d 100644
--- a/src/main/java/org/jsoup/parser/Tag.java
+++ b/src/main/java/org/jsoup/parser/Tag.java
@@ -1,34 +1,71 @@
 package org.jsoup.parser;
 
-import org.jsoup.helper.Validate;
-import org.jsoup.internal.Normalizer;
-import org.jsoup.internal.SharedConstants;
+import org.jsoup.internal.StringUtil;
+import org.jspecify.annotations.Nullable;
 
-import java.util.HashMap;
-import java.util.Map;
 import java.util.Objects;
-import java.util.function.Consumer;
+
+import static org.jsoup.parser.Parser.NamespaceHtml;
 
 /**
- * Tag capabilities.
- *
- * @author Jonathan Hedley, jonathan@hedley.net
- */
+ A Tag represents an Element's name and configured options, common throughout the Document. Options may affect the parse
+ and output.
+
+ @see TagSet
+ @see Parser#tagSet(TagSet) */
 public class Tag implements Cloneable {
-    private static final Map<String, Tag> Tags = new HashMap<>(); // map of known tags
-
-    private String tagName;
-    private final String normalName; // always the lower case version of this tag, regardless of case preservation mode
-    private String namespace;
-    private boolean isBlock = true; // block
-    private boolean formatAsBlock = true; // should be formatted as a block
-    private boolean empty = false; // can hold nothing; e.g. img
-    private boolean selfClosing = false; // can self close (<foo />). used for unknown tags that self close, without forcing them as empty.
-    private boolean preserveWhitespace = false; // for pre, textarea, script etc
-    private boolean formList = false; // a control that appears in forms: input, textarea, output etc
-    private boolean formSubmit = false; // a control that can be submitted in a form: input etc
-
-    private Tag(String tagName, String normalName, String namespace) {
+    /** Tag option: the tag is known (specifically defined). This impacts if options may need to be inferred (when not
+     known) in, e.g., the pretty-printer. Set when a tag is added to a TagSet, or when settings are set(). */
+    public static int Known = 1;
+    /** Tag option: the tag is a void tag (e.g. {@code <img>}), that can contain no children, and in HTML does not require closing. */
+    public static int Void = 1 << 1;
+    /** Tag option: the tag is a block tag (e.g. {@code <div>}, {@code <p>}). Causes the element to be indented when pretty-printing. If not a block, it is inline. */
+    public static int Block = 1 << 2;
+    /** Tag option: the tag is a block tag that will only hold inline tags (e.g. {@code <p>}); used for formatting. (Must also set Block.) */
+    public static int InlineContainer = 1 << 3;
+    /** Tag option: the tag can self-close (e.g. <foo />). */
+    public static int SelfClose = 1 << 4;
+    /** Tag option: the tag has been seen self-closing in this parse. */
+    public static int SeenSelfClose = 1 << 5;
+    /** Tag option: the tag preserves whitespace (e.g. {@code <pre>}). */
+    public static int PreserveWhitespace = 1 << 6;
+    /** Tag option: the tag is an RCDATA element that can have text and character references (e.g. {@code <title>}, {@code <textarea>}). */
+    public static int RcData = 1 << 7;
+    /** Tag option: the tag is a Data element that can have text but not character references (e.g. {@code <style>}, {@code <script>}). */
+    public static int Data = 1 << 8;
+    /** Tag option: the tag's value will be included when submitting a form (e.g. {@code <input>}). */
+    public static int FormSubmittable = 1 << 9;
+
+    String namespace;
+    String tagName;
+    String normalName; // always the lower case version of this tag, regardless of case preservation mode
+    int options = 0;
+
+    /**
+     Create a new Tag, with the given name and namespace.
+     <p>The tag is not implicitly added to any TagSet.</p>
+     @param tagName the name of the tag. Case-sensitive.
+     @param namespace the namespace for the tag.
+     @see TagSet#valueOf(String, String)
+     @since 1.20.1
+     */
+    public Tag(String tagName, String namespace) {
+        this(tagName, ParseSettings.normalName(tagName), namespace);
+    }
+
+    /**
+     Create a new Tag, with the given name, in the HTML namespace.
+     <p>The tag is not implicitly added to any TagSet.</p>
+     @param tagName the name of the tag. Case-sensitive.
+     @see TagSet#valueOf(String, String)
+     @since 1.20.1
+     */
+    public Tag(String tagName) {
+        this(tagName, ParseSettings.normalName(tagName), NamespaceHtml);
+    }
+
+    /** Path for TagSet defaults, no options set; normal name is already LC. */
+    Tag(String tagName, String normalName, String namespace) {
         this.tagName = tagName;
         this.normalName = normalName;
         this.namespace = namespace;
@@ -43,6 +80,50 @@ public class Tag implements Cloneable {
         return tagName;
     }
 
+    /**
+     Get this tag's name.
+     @return the tag's name
+     */
+    public String name() {
+        return tagName;
+    }
+
+    /**
+     Change the tag's name. As Tags are reused throughout a Document, this will change the name for all uses of this tag.
+     @param tagName the new name of the tag. Case-sensitive.
+     @return this tag
+     @since 1.20.1
+     */
+    public Tag name(String tagName) {
+        this.tagName = tagName;
+        this.normalName = ParseSettings.normalName(tagName);
+        return this;
+    }
+
+    /**
+     Get this tag's prefix, if it has one; else the empty string.
+     <p>For example, {@code <book:title>} has prefix {@code book}, and tag name {@code book:title}.</p>
+     @return the tag's prefix
+     @since 1.20.1
+     */
+    public String prefix() {
+        int pos = tagName.indexOf(':');
+        if (pos == -1) return "";
+        else return tagName.substring(0, pos);
+    }
+
+    /**
+     Get this tag's local name. The local name is the name without the prefix (if any).
+     <p>For exmaple, {@code <book:title>} has local name {@code title}, and tag name {@code book:title}.</p>
+     @return the tag's local name
+     @since 1.20.1
+     */
+    public String localName() {
+        int pos = tagName.indexOf(':');
+        if (pos == -1) return tagName;
+        else return tagName.substring(pos + 1);
+    }
+
     /**
      * Get this tag's normalized (lowercased) name.
      * @return the tag's normal name.
@@ -51,10 +132,62 @@ public class Tag implements Cloneable {
         return normalName;
     }
 
+    /**
+     Get this tag's namespace.
+     @return the tag's namespace
+     */
     public String namespace() {
         return namespace;
     }
 
+    /**
+     Set the tag's namespace. As Tags are reused throughout a Document, this will change the namespace for all uses of this tag.
+     @param namespace the new namespace of the tag.
+     @return this tag
+     @since 1.20.1
+     */
+    public Tag namespace(String namespace) {
+        this.namespace = namespace;
+        return this;
+    }
+
+    /**
+     Set an option on this tag.
+     <p>Once a tag has a setting applied, it will be considered a known tag.</p>
+     @param option the option to set
+     @return this tag
+     @since 1.20.1
+     */
+    public Tag set(int option) {
+        options |= option;
+        options |= Tag.Known; // considered known if touched
+        return this;
+    }
+
+    /**
+     Test if an option is set on this tag.
+
+     @param option the option to test
+     @return true if the option is set
+     @since 1.20.1
+     */
+    public boolean is(int option) {
+        return (options & option) != 0;
+    }
+
+    /**
+     Clear (unset) an option from this tag.
+     @param option the option to clear
+     @return this tag
+     @since 1.20.1
+     */
+    public Tag clear(int option) {
+        options &= ~option;
+        // considered known if touched, unless explicitly clearing known
+        if (option != Tag.Known) options |= Tag.Known;
+        return this;
+    }
+
     /**
      * Get a Tag by name. If not previously defined (unknown), returns a new generic tag, that can do anything.
      * <p>
@@ -64,40 +197,13 @@ public class Tag implements Cloneable {
      * @param tagName Name of tag, e.g. "p". Case-insensitive.
      * @param namespace the namespace for the tag.
      * @param settings used to control tag name sensitivity
+     * @see TagSet
      * @return The tag, either defined or new generic.
      */
     public static Tag valueOf(String tagName, String namespace, ParseSettings settings) {
-        return valueOf(tagName, ParseSettings.normalName(tagName), namespace, settings);
-    }
-
-    /** Tag.valueOf with the normalName via the token.normalName, to save redundant lower-casing passes. */
-    static Tag valueOf(String tagName, String normalName, String namespace, ParseSettings settings) {
-        Validate.notNull(tagName);
-        tagName = tagName.trim();
-        Validate.notEmpty(tagName);
-        Validate.notNull(namespace);
-        Tag tag = Tags.get(tagName);
-        if (tag != null && tag.namespace.equals(namespace))
-            return tag;
-
-        tagName = settings.preserveTagCase() ? tagName : normalName;
-        tag = Tags.get(normalName);
-        if (tag != null && tag.namespace.equals(namespace)) {
-            if (settings.preserveTagCase() && !tagName.equals(normalName)) {
-                tag = tag.clone(); // get a new version vs the static one, so name update doesn't reset all
-                tag.tagName = tagName;
-            }
-            return tag;
-        }
-
-        // not defined: create default; go anywhere, do anything! (incl be inside a <p>)
-        tag = new Tag(tagName, normalName, namespace);
-        tag.isBlock = false;
-
-        return tag;
+        return TagSet.Html().valueOf(tagName, ParseSettings.normalName(tagName), namespace, settings.preserveTagCase());
     }
 
-
     /**
      * Get a Tag by name. If not previously defined (unknown), returns a new generic tag, that can do anything.
      * <p>
@@ -109,7 +215,7 @@ public class Tag implements Cloneable {
      * @see #valueOf(String tagName, String namespace, ParseSettings settings)
      */
     public static Tag valueOf(String tagName) {
-        return valueOf(tagName, Parser.NamespaceHtml, ParseSettings.preserveCase);
+        return valueOf(tagName, NamespaceHtml, ParseSettings.preserveCase);
     }
 
     /**
@@ -124,7 +230,7 @@ public class Tag implements Cloneable {
      * @see #valueOf(String tagName, String namespace, ParseSettings settings)
      */
     public static Tag valueOf(String tagName, ParseSettings settings) {
-        return valueOf(tagName, Parser.NamespaceHtml, settings);
+        return valueOf(tagName, NamespaceHtml, settings);
     }
 
     /**
@@ -133,34 +239,35 @@ public class Tag implements Cloneable {
      * @return if block tag
      */
     public boolean isBlock() {
-        return isBlock;
+        return (options & Block) != 0;
     }
 
     /**
-     * Gets if this tag should be formatted as a block (or as inline)
-     *
-     * @return if should be formatted as block or inline
+     Get if this is an InlineContainer tag.
+
+     @return true if an InlineContainer (which formats children as inline).
+     @deprecated setting is only used within the Printer. Will be removed in 1.21.
      */
-    public boolean formatAsBlock() {
-        return formatAsBlock;
+    @Deprecated public boolean formatAsBlock() {
+        return (options & InlineContainer) != 0;
     }
 
     /**
-     * Gets if this tag is an inline tag.
+     * Gets if this tag is an inline tag. Just the opposite of isBlock.
      *
      * @return if this tag is an inline tag.
      */
     public boolean isInline() {
-        return !isBlock;
+        return (options & Block) == 0;
     }
 
     /**
-     * Get if this is an empty tag
-     *
-     * @return if this is an empty tag
+     Get if this is void (aka empty) tag.
+
+     @return true if this is a void tag
      */
     public boolean isEmpty() {
-        return empty;
+        return (options & Void) != 0;
     }
 
     /**
@@ -169,26 +276,26 @@ public class Tag implements Cloneable {
      * @return if this tag should be output as self-closing.
      */
     public boolean isSelfClosing() {
-        return empty || selfClosing;
+        return (options & SelfClose) != 0 || (options & Void) != 0;
     }
 
     /**
-     * Get if this is a pre-defined tag, or was auto created on parsing.
+     * Get if this is a pre-defined tag in the TagSet, or was auto created on parsing.
      *
      * @return if a known tag
      */
     public boolean isKnownTag() {
-        return Tags.containsKey(tagName);
+        return (options & Known) != 0;
     }
 
     /**
-     * Check if this tagname is a known tag.
+     * Check if this tag name is a known HTML tag.
      *
      * @param tagName name of tag
      * @return if known HTML tag
      */
     public static boolean isKnownTag(String tagName) {
-        return Tags.containsKey(tagName);
+        return TagSet.HtmlTagSet.get(tagName, NamespaceHtml) != null;
     }
 
     /**
@@ -197,15 +304,16 @@ public class Tag implements Cloneable {
      * @return if preserve whitespace
      */
     public boolean preserveWhitespace() {
-        return preserveWhitespace;
+        return (options & PreserveWhitespace) != 0;
     }
 
     /**
      * Get if this tag represents a control associated with a form. E.g. input, textarea, output
      * @return if associated with a form
+     * @deprecated this method is internal to HtmlTreeBuilder only, and will be removed in 1.21.1.
      */
-    public boolean isFormListed() {
-        return formList;
+    @Deprecated public boolean isFormListed() {
+        return namespace.equals(NamespaceHtml) && StringUtil.inSorted(normalName, HtmlTreeBuilder.TagFormListed);
     }
 
     /**
@@ -213,35 +321,36 @@ public class Tag implements Cloneable {
      * @return if submittable with a form
      */
     public boolean isFormSubmittable() {
-        return formSubmit;
+        return (options &= FormSubmittable) != 0;
     }
 
-    Tag setSelfClosing() {
-        selfClosing = true;
-        return this;
+    void setSeenSelfClose() {
+        options |= Tag.SeenSelfClose; // does not change known status
+    }
+
+    /**
+     If this Tag uses a specific text TokeniserState for its content, returns that; otherwise null.
+     */
+    @Nullable TokeniserState textState() {
+        if (is(RcData)) return TokeniserState.Rcdata;
+        if (is(Data))   return TokeniserState.Rawtext;
+        else            return null;
     }
 
     @Override
     public boolean equals(Object o) {
         if (this == o) return true;
         if (!(o instanceof Tag)) return false;
-
         Tag tag = (Tag) o;
-
-        if (!tagName.equals(tag.tagName)) return false;
-        if (empty != tag.empty) return false;
-        if (formatAsBlock != tag.formatAsBlock) return false;
-        if (isBlock != tag.isBlock) return false;
-        if (preserveWhitespace != tag.preserveWhitespace) return false;
-        if (selfClosing != tag.selfClosing) return false;
-        if (formList != tag.formList) return false;
-        return formSubmit == tag.formSubmit;
+        return Objects.equals(tagName, tag.tagName) &&
+            Objects.equals(namespace, tag.namespace) &&
+            Objects.equals(normalName, tag.normalName) &&
+            options == tag.options;
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(tagName, isBlock, formatAsBlock, empty, selfClosing, preserveWhitespace,
-            formList, formSubmit);
+        return Objects.hash(tagName, namespace, normalName, options);
     }
 
     @Override
@@ -258,83 +367,5 @@ public class Tag implements Cloneable {
         }
     }
 
-    // internal static initialisers:
-    // prepped from http://www.w3.org/TR/REC-html40/sgml/dtd.html and other sources
-    private static final String[] blockTags = {
-            "html", "head", "body", "frameset", "script", "noscript", "style", "meta", "link", "title", "frame",
-            "noframes", "section", "nav", "aside", "hgroup", "header", "footer", "p", "h1", "h2", "h3", "h4", "h5", "h6",
-            "ul", "ol", "pre", "div", "blockquote", "hr", "address", "figure", "figcaption", "form", "fieldset", "ins",
-            "del", "dl", "dt", "dd", "li", "table", "caption", "thead", "tfoot", "tbody", "colgroup", "col", "tr", "th",
-            "td", "video", "audio", "canvas", "details", "menu", "plaintext", "template", "article", "main",
-            "svg", "math", "center", "template",
-            "dir", "applet", "marquee", "listing" // deprecated but still known / special handling
-    };
-    private static final String[] inlineTags = {
-            "object", "base", "font", "tt", "i", "b", "u", "big", "small", "em", "strong", "dfn", "code", "samp", "kbd",
-            "var", "cite", "abbr", "time", "acronym", "mark", "ruby", "rt", "rp", "rtc", "a", "img", "br", "wbr", "map", "q",
-            "sub", "sup", "bdo", "iframe", "embed", "span", "input", "select", "textarea", "label", "optgroup",
-            "option", "legend", "datalist", "keygen", "output", "progress", "meter", "area", "param", "source", "track",
-            "summary", "command", "device", "area", "basefont", "bgsound", "menuitem", "param", "source", "track",
-            "data", "bdi", "s", "strike", "nobr",
-            "rb", // deprecated but still known / special handling
-            "text", // in SVG NS
-            "mi", "mo", "msup", "mn", "mtext" // in MathML NS, to ensure inline
-    };
-    private static final String[] emptyTags = {
-            "meta", "link", "base", "frame", "img", "br", "wbr", "embed", "hr", "input", "keygen", "col", "command",
-            "device", "area", "basefont", "bgsound", "menuitem", "param", "source", "track"
-    };
-    // todo - rework this to format contents as inline; and update html emitter in Element. Same output, just neater.
-    private static final String[] formatAsInlineTags = {
-            "title", "a", "p", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "address", "li", "th", "td", "script", "style",
-            "ins", "del", "s", "button"
-    };
-    private static final String[] preserveWhitespaceTags = {
-            "pre", "plaintext", "title", "textarea"
-            // script is not here as it is a data node, which always preserve whitespace
-    };
-    // todo: I think we just need submit tags, and can scrub listed
-    private static final String[] formListedTags = {
-            "button", "fieldset", "input", "keygen", "object", "output", "select", "textarea"
-    };
-    private static final String[] formSubmitTags = SharedConstants.FormSubmitTags;
-
-    private static final Map<String, String[]> namespaces = new HashMap<>();
-    static {
-        namespaces.put(Parser.NamespaceMathml, new String[]{"math", "mi", "mo", "msup", "mn", "mtext"});
-        namespaces.put(Parser.NamespaceSvg, new String[]{"svg", "text"});
-        // We don't need absolute coverage here as other cases will be inferred by the HtmlTreeBuilder
-    }
-
-    private static void setupTags(String[] tagNames, Consumer<Tag> tagModifier) {
-        for (String tagName : tagNames) {
-            Tag tag = Tags.get(tagName);
-            if (tag == null) {
-                tag = new Tag(tagName, tagName, Parser.NamespaceHtml);
-                Tags.put(tag.tagName, tag);
-            }
-            tagModifier.accept(tag);
-        }
-    }
 
-    static {
-        setupTags(blockTags, tag -> {
-            tag.isBlock = true;
-            tag.formatAsBlock = true;
-        });
-
-        setupTags(inlineTags, tag -> {
-            tag.isBlock = false;
-            tag.formatAsBlock = false;
-        });
-
-        setupTags(emptyTags, tag -> tag.empty = true);
-        setupTags(formatAsInlineTags, tag -> tag.formatAsBlock = false);
-        setupTags(preserveWhitespaceTags, tag -> tag.preserveWhitespace = true);
-        setupTags(formListedTags, tag -> tag.formList = true);
-        setupTags(formSubmitTags, tag -> tag.formSubmit = true);
-        for (Map.Entry<String, String[]> ns : namespaces.entrySet()) {
-            setupTags(ns.getValue(), tag -> tag.namespace = ns.getKey());
-        }
-    }
 }
diff --git a/src/main/java/org/jsoup/parser/TagSet.java b/src/main/java/org/jsoup/parser/TagSet.java
new file mode 100644
index 00000000..c2aa170b
--- /dev/null
+++ b/src/main/java/org/jsoup/parser/TagSet.java
@@ -0,0 +1,222 @@
+package org.jsoup.parser;
+
+import org.jsoup.helper.Validate;
+import org.jsoup.internal.SharedConstants;
+import org.jspecify.annotations.Nullable;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Consumer;
+
+import static org.jsoup.parser.Parser.NamespaceHtml;
+import static org.jsoup.parser.Parser.NamespaceMathml;
+import static org.jsoup.parser.Parser.NamespaceSvg;
+
+/**
+ A TagSet controls the {@link Tag} configuration for a Document's parse, and its serialization. It contains the initial
+ defaults, and after the parse, any additionally discovered tags.
+
+ @see Parser#tagSet(TagSet)
+ @since 1.20.1
+ */
+public class TagSet {
+    static final TagSet HtmlTagSet = initHtmlDefault();
+    final Map<String, HashMap<String, Tag>> tags = new HashMap<>(); // namespace -> tag name -> Tag
+
+    /**
+     Returns a mutable copy of the default HTML tag set.
+     */
+    public static TagSet Html() {
+        return new TagSet(HtmlTagSet);
+    }
+
+    public TagSet() {
+    }
+
+    public TagSet(TagSet original) {
+        for (HashMap<String, Tag> nsTags : original.tags.values()) {
+            for (Tag tag : nsTags.values()) {
+                this.doAdd(tag.clone());
+            }
+        }
+    }
+
+    /**
+     Insert a tag into this TagSet. If the tag already exists, it is replaced.
+     <p>Tags explicitly added like this are considered to be known tags (vs those that are dynamically created via
+     .valueOf() if not already in the set.</p>
+
+     @param tag the tag to add
+     @return this TagSet
+     */
+    public TagSet add(Tag tag) {
+        tag.set(Tag.Known);
+        doAdd(tag);
+        return this;
+    }
+
+    /** Adds the tag, but does not set defined. Used in .valueOf */
+    private void doAdd(Tag tag) {
+        tags.computeIfAbsent(tag.namespace, ns -> new HashMap<>())
+            .put(tag.tagName, tag);
+    }
+
+    /**
+     Get an existing Tag from this TagSet by tagName and namespace. The tag name is not normalized, to support mixed
+     instances.
+
+     @param tagName the case-sensitive tag name
+     @param namespace the namespace
+     @return the tag, or null if not found
+     */
+    public @Nullable Tag get(String tagName, String namespace) {
+        Validate.notNull(tagName);
+        Validate.notNull(namespace);
+
+        HashMap<String, Tag> nsTags = tags.get(namespace);
+        if (nsTags == null) return null;
+        return nsTags.get(tagName);
+    }
+
+    /** Tag.valueOf with the normalName via the token.normalName, to save redundant lower-casing passes. */
+    Tag valueOf(String tagName, String normalName, String namespace, boolean preserveTagCase) {
+        Validate.notNull(tagName);
+        Validate.notNull(namespace);
+        tagName = tagName.trim();
+        Validate.notEmpty(tagName);
+        Tag tag = get(tagName, namespace);
+        if (tag != null) return tag;
+
+        // not found by tagName, try by normal
+        tagName = preserveTagCase ? tagName : normalName;
+        tag = get(normalName, namespace);
+        if (tag != null) {
+            if (preserveTagCase && !tagName.equals(normalName)) {
+                tag = tag.clone(); // copy so that the name update doesn't reset all instances
+                tag.tagName = tagName;
+                doAdd(tag);
+            }
+            return tag;
+        }
+
+        // not defined: return a new one
+        tag = new Tag(tagName, normalName, namespace);
+        doAdd(tag);
+
+        return tag;
+    }
+
+    /**
+     Get a Tag by name from this TagSet. If not previously defined (unknown), returns a new tag.
+     <p>New tags will be added to this TagSet.</p>
+
+     @param tagName Name of tag, e.g. "p".
+     @param namespace the namespace for the tag.
+     @param settings used to control tag name sensitivity
+     @return The tag, either defined or new generic.
+     */
+    public Tag valueOf(String tagName, String namespace, ParseSettings settings) {
+        return valueOf(tagName, ParseSettings.normalName(tagName), namespace, settings.preserveTagCase());
+    }
+
+    /**
+     Get a Tag by name from this TagSet. If not previously defined (unknown), returns a new tag.
+     <p>New tags will be added to this TagSet.</p>
+
+     @param tagName Name of tag, e.g. "p". <b>Case-sensitive</b>.
+     @param namespace the namespace for the tag.
+     @return The tag, either defined or new generic.
+     @see #valueOf(String tagName, String namespace, ParseSettings settings)
+     */
+    public Tag valueOf(String tagName, String namespace) {
+        return valueOf(tagName, namespace, ParseSettings.preserveCase);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (!(o instanceof TagSet)) return false;
+        TagSet tagSet = (TagSet) o;
+        return Objects.equals(tags, tagSet.tags);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hashCode(tags);
+    }
+
+    // Default HTML initialization
+
+    /**
+     Initialize the default HTML tag set.
+     */
+    static TagSet initHtmlDefault() {
+        String[] blockTags = {
+            "html", "head", "body", "frameset", "script", "noscript", "style", "meta", "link", "title", "frame",
+            "noframes", "section", "nav", "aside", "hgroup", "header", "footer", "p", "h1", "h2", "h3", "h4", "h5",
+            "h6", "br", "button",
+            "ul", "ol", "pre", "div", "blockquote", "hr", "address", "figure", "figcaption", "form", "fieldset", "ins",
+            "del", "dl", "dt", "dd", "li", "table", "caption", "thead", "tfoot", "tbody", "colgroup", "col", "tr", "th",
+            "td", "video", "audio", "canvas", "details", "menu", "plaintext", "template", "article", "main",
+            "center", "template",
+            "dir", "applet", "marquee", "listing", // deprecated but still known / special handling
+            "#root" // the outer Document
+        };
+        String[] inlineTags = {
+            "object", "base", "font", "tt", "i", "b", "u", "big", "small", "em", "strong", "dfn", "code", "samp", "kbd",
+            "var", "cite", "abbr", "time", "acronym", "mark", "ruby", "rt", "rp", "rtc", "a", "img", "wbr", "map",
+            "q",
+            "sub", "sup", "bdo", "iframe", "embed", "span", "input", "select", "textarea", "label", "optgroup",
+            "option", "legend", "datalist", "keygen", "output", "progress", "meter", "area", "param", "source", "track",
+            "summary", "command", "device", "area", "basefont", "bgsound", "menuitem", "param", "source", "track",
+            "data", "bdi", "s", "strike", "nobr",
+            "rb", // deprecated but still known / special handling
+        };
+        String[] inlineContainers = { // can only contain inline; aka phrasing content
+            "title", "a", "p", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "address", "li", "th", "td", "script", "style",
+            "ins", "del", "s", "button"
+        };
+        String[] voidTags = {
+            "meta", "link", "base", "frame", "img", "br", "wbr", "embed", "hr", "input", "keygen", "col", "command",
+            "device", "area", "basefont", "bgsound", "menuitem", "param", "source", "track"
+        };
+        String[] preserveWhitespaceTags = {
+            "pre", "plaintext", "title", "textarea", "script"
+        };
+        String[] rcdataTags = { "title", "textarea" };
+        String[] dataTags = { "iframe", "noembed", "noframes", "script", "style", "xmp" };
+        String[] formSubmitTags = SharedConstants.FormSubmitTags;
+        String[] blockMathTags = {"math"};
+        String[] inlineMathTags = {"mi", "mo", "msup", "mn", "mtext"};
+        String[] blockSvgTags = {"svg", "femerge", "femergenode"}; // note these are LC versions, but actually preserve case
+        String[] inlineSvgTags = {"text"};
+
+        return new TagSet()
+            .setupTags(NamespaceHtml, blockTags, tag -> tag.set(Tag.Block))
+            .setupTags(NamespaceHtml, inlineTags, tag -> tag.set(0))
+            .setupTags(NamespaceHtml, inlineContainers, tag -> tag.set(Tag.InlineContainer))
+            .setupTags(NamespaceHtml, voidTags, tag -> tag.set(Tag.Void))
+            .setupTags(NamespaceHtml, preserveWhitespaceTags, tag -> tag.set(Tag.PreserveWhitespace))
+            .setupTags(NamespaceHtml, rcdataTags, tag -> tag.set(Tag.RcData))
+            .setupTags(NamespaceHtml, dataTags, tag -> tag.set(Tag.Data))
+            .setupTags(NamespaceHtml, formSubmitTags, tag -> tag.set(Tag.FormSubmittable))
+            .setupTags(NamespaceMathml, blockMathTags, tag -> tag.set(Tag.Block))
+            .setupTags(NamespaceMathml, inlineMathTags, tag -> tag.set(0))
+            .setupTags(NamespaceSvg, blockSvgTags, tag -> tag.set(Tag.Block))
+            .setupTags(NamespaceSvg, inlineSvgTags, tag -> tag.set(0))
+            ;
+    }
+
+    private TagSet setupTags(String namespace, String[] tagNames, Consumer<Tag> tagModifier) {
+        for (String tagName : tagNames) {
+            Tag tag = get(tagName, namespace);
+            if (tag == null) {
+                tag = new Tag(tagName, tagName, namespace); // normal name is already normal here
+                tag.options = 0; // clear defaults
+                add(tag);
+            }
+            tagModifier.accept(tag);
+        }
+        return this;
+    }
+}
diff --git a/src/main/java/org/jsoup/parser/TokenQueue.java b/src/main/java/org/jsoup/parser/TokenQueue.java
index 3ef301be..cef93c66 100644
--- a/src/main/java/org/jsoup/parser/TokenQueue.java
+++ b/src/main/java/org/jsoup/parser/TokenQueue.java
@@ -5,14 +5,15 @@ import org.jsoup.helper.Validate;
 
 /**
  * A character queue with parsing helpers.
- *
- * @author Jonathan Hedley
  */
 public class TokenQueue {
     private String queue;
     private int pos = 0;
     
-    private static final char ESC = '\\'; // escape char for chomp balanced.
+    private static final char Esc = '\\'; // escape char for chomp balanced.
+    private static final char Hyphen_Minus = '-';
+    private static final char Unicode_Null = '\u0000';
+    private static final char Replacement = '\uFFFD';
 
     /**
      Create a new TokenQueue.
@@ -116,6 +117,10 @@ public class TokenQueue {
         if (!isEmpty()) pos++;
     }
 
+    private char current() {
+        return queue.charAt(pos);
+    }
+
     /**
      * Consume one character off queue.
      * @return first character on queue.
@@ -238,7 +243,7 @@ public class TokenQueue {
         do {
             if (isEmpty()) break;
             char c = consume();
-            if (last != ESC) {
+            if (last != Esc) {
                 if (c == '\'' && c != open && !inDoubleQuote)
                     inSingleQuote = !inSingleQuote;
                 else if (c == '"' && c != open && !inSingleQuote)
@@ -281,8 +286,8 @@ public class TokenQueue {
         StringBuilder out = StringUtil.borrowBuilder();
         char last = 0;
         for (char c : in.toCharArray()) {
-            if (c == ESC) {
-                if (last == ESC) {
+            if (c == Esc) {
+                if (last == Esc) {
                     out.append(c);
                     c = 0;
                 }
@@ -305,7 +310,7 @@ public class TokenQueue {
             if (q.matchesCssIdentifier(CssIdentifierChars)) {
                 out.append(q.consume());
             } else {
-                out.append(ESC).append(q.consume());
+                out.append(Esc).append(q.consume());
             }
         }
         return StringUtil.releaseBuilder(out);
@@ -335,7 +340,6 @@ public class TokenQueue {
         return queue.substring(start, pos);
     }
 
-    
     /**
      * Consume a CSS element selector (tag name, but | instead of : for namespaces (or *| for wildcard namespace), to not conflict with :pseudo selects).
      * 
@@ -347,13 +351,149 @@ public class TokenQueue {
     private static final String[] ElementSelectorChars = {"*", "|", "_", "-"};
 
     /**
-     Consume a CSS identifier (ID or class) off the queue (letter, digit, -, _)
-     http://www.w3.org/TR/CSS2/syndata.html#value-def-identifier
-     @return identifier
+     Consume a CSS identifier (ID or class) off the queue.
+     <p>Note: For backwards compatibility this method supports improperly formatted CSS identifiers, e.g. {@code 1} instead
+     of {@code \31}.</p>
+
+     @return The unescaped identifier.
+     @throws IllegalArgumentException if an invalid escape sequence was found. Afterward, the state of the TokenQueue
+     is undefined.
+     @see <a href="https://www.w3.org/TR/css-syntax-3/#consume-name">CSS Syntax Module Level 3, Consume an ident sequence</a>
+     @see <a href="https://www.w3.org/TR/css-syntax-3/#typedef-ident-token">CSS Syntax Module Level 3, ident-token</a>
      */
     public String consumeCssIdentifier() {
-        return consumeEscapedCssIdentifier(CssIdentifierChars);
+        if (isEmpty()) throw new IllegalArgumentException("CSS identifier expected, but end of input found");
+        int start = pos;
+
+        // Fast path for CSS identifiers that don't contain escape sequences.
+        while (!isEmpty()) {
+            char c = current();
+            if (isIdent(c)) {
+                advance();
+            } else if (c == Esc || c == Unicode_Null) {
+                // Exit fast path when an escape sequence or U+0000 is found.
+                break;
+            } else {
+                // End of identifier reached without encountering a sequence that requires special handling. The CSS
+                // identifier is a substring of the input.
+                return queue.substring(start, pos);
+            }
+        }
+
+        // An escape sequence was found. Use a StringBuilder to store the decoded CSS identifier.
+        StringBuilder out = StringUtil.borrowBuilder();
+
+        if (start < pos) {
+            // Copy the CSS identifier up to the first escape sequence.
+            out.append(queue, start, pos);
+        }
+
+        while (!isEmpty()) {
+            char c = current();
+            if (isIdent(c)) {
+                out.append(consume());
+            } else if (c == Unicode_Null) {
+                // https://www.w3.org/TR/css-syntax-3/#input-preprocessing
+                advance();
+                out.append(Replacement);
+            } else if (c == Esc) {
+                advance();
+                if (!isEmpty() && isNewline(current())) {
+                    // Not a valid escape sequence. This is treated as the end of the CSS identifier.
+                    pos--;
+                    break;
+                } else {
+                    consumeCssEscapeSequenceInto(out);
+                }
+            } else {
+                break;
+            }
+        }
+        return StringUtil.releaseBuilder(out);
     }
+
+    private void consumeCssEscapeSequenceInto(StringBuilder out) {
+        if (isEmpty()) {
+            out.append(Replacement);
+            return;
+        }
+        int start = pos;
+        char firstEscaped = consume();
+        if (!isHexDigit(firstEscaped)) {
+            out.append(firstEscaped);
+        } else {
+            for (int i = 0; i < 5 && !isEmpty(); i++) {
+                char escapedChar = current();
+                if (isHexDigit(escapedChar)) advance();
+                else break;
+            }
+            String hexString = queue.substring(start, pos);
+            int codePoint;
+            try {
+                codePoint = Integer.parseInt(hexString, 16);
+            } catch (NumberFormatException e) { // Won't happen as we confirmed hex above; just mollifying scanners
+                throw new IllegalArgumentException("Invalid escape sequence: " + hexString, e);
+            }
+            if (isValidCodePoint(codePoint)) {
+                out.appendCodePoint(codePoint);
+            } else {
+                out.append(Replacement);
+            }
+
+            if (!isEmpty()) {
+                char c = current();
+                if (c == '\r') {
+                    // Since there's currently no input preprocessing, check for CRLF here.
+                    // https://www.w3.org/TR/css-syntax-3/#input-preprocessing
+                    advance();
+                    if (!isEmpty() && current() == '\n') advance();
+                } else if (c == ' ' || c == '\t' || isNewline(c)) {
+                    advance();
+                }
+            }
+        }
+    }
+
+    // statics below specifically for CSS identifiers:
+
+    private static boolean isLetter(char c) {
+        return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z';
+    }
+
+    private static boolean isDigit(char c) {
+        return c >= '0' && c <= '9';
+    }
+
+    private static boolean isHexDigit(char c) {
+        return isDigit(c) || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F';
+    }
+
+    // https://www.w3.org/TR/css-syntax-3/#non-ascii-code-point
+    private static boolean isNonAscii(char c) {
+        return c >= '\u0080';
+    }
+
+    // https://www.w3.org/TR/css-syntax-3/#ident-start-code-point
+    private static boolean isIdentStart(char c) {
+        return c == '_' || isLetter(c) || isNonAscii(c);
+    }
+
+    // https://www.w3.org/TR/css-syntax-3/#ident-code-point
+    private static boolean isIdent(char c) {
+        return c == Hyphen_Minus || isDigit(c) || isIdentStart(c);
+    }
+
+    // https://www.w3.org/TR/css-syntax-3/#newline
+    // Note: currently there's no preprocessing happening.
+    private static boolean isNewline(char c) {
+        return c == '\n' || c == '\r' || c == '\f';
+    }
+
+    // https://www.w3.org/TR/css-syntax-3/#consume-an-escaped-code-point
+    private static boolean isValidCodePoint(int codePoint) {
+        return codePoint != 0 && Character.isValidCodePoint(codePoint) && !Character.isSurrogate((char) codePoint);
+    }
+
     private static final String[] CssIdentifierChars = {"-", "_"};
 
 
@@ -361,7 +501,7 @@ public class TokenQueue {
         int start = pos;
         boolean escaped = false;
         while (!isEmpty()) {
-            if (queue.charAt(pos) == ESC && remainingLength() >1 ) {
+            if (queue.charAt(pos) == Esc && remainingLength() >1 ) {
                 escaped = true;
                 pos+=2; // skip the escape and the escaped
             } else if (matchesCssIdentifier(matches)) {
diff --git a/src/main/java/org/jsoup/parser/TokeniserState.java b/src/main/java/org/jsoup/parser/TokeniserState.java
index dff907c6..beb628f5 100644
--- a/src/main/java/org/jsoup/parser/TokeniserState.java
+++ b/src/main/java/org/jsoup/parser/TokeniserState.java
@@ -1,6 +1,7 @@
 package org.jsoup.parser;
 
 import org.jsoup.nodes.DocumentType;
+import org.jsoup.nodes.XmlDeclaration;
 
 import static org.jsoup.nodes.Document.OutputSettings.Syntax.xml;
 
@@ -38,7 +39,7 @@ enum TokeniserState {
             readCharRef(t, Data);
         }
     },
-    Rcdata {
+    Rcdata { // Rcdata has text with character references
         /// handles data in title, textarea etc
         @Override void read(Tokeniser t, CharacterReader r) {
             switch (r.current()) {
@@ -891,6 +892,10 @@ enum TokeniserState {
                     t.eofError(this);
                     t.transition(Data);
                     break;
+                case '?': // Handle trailing ? in <?xml...?>
+                    if (t.tagPending instanceof Token.XmlDecl)
+                        break;
+                    // otherwise fall through to default
                 default:
                     r.unconsume();
                     t.error(this);
diff --git a/src/main/java/org/jsoup/parser/TreeBuilder.java b/src/main/java/org/jsoup/parser/TreeBuilder.java
index f06e2e44..c9e00628 100644
--- a/src/main/java/org/jsoup/parser/TreeBuilder.java
+++ b/src/main/java/org/jsoup/parser/TreeBuilder.java
@@ -31,7 +31,7 @@ abstract class TreeBuilder {
     String baseUri; // current base uri, for creating new elements
     Token currentToken; // currentToken is used for error and source position tracking. Null at start of fragment parse
     ParseSettings settings;
-    Map<String, Tag> seenTags; // tags we've used in this parse; saves tag GC for custom tags.
+    TagSet tagSet; // the tags we're using in this parse
     @Nullable NodeVisitor nodeListener; // optional listener for node add / removes
 
     private Token.StartTag start; // start tag to process
@@ -52,9 +52,10 @@ abstract class TreeBuilder {
         reader = new CharacterReader(input);
         trackSourceRange = parser.isTrackPosition();
         reader.trackNewlines(parser.isTrackErrors() || trackSourceRange); // when tracking errors or source ranges, enable newline tracking for better legibility
+        if (parser.isTrackErrors()) parser.getErrors().clear();
         tokeniser = new Tokeniser(this);
         stack = new ArrayList<>(32);
-        seenTags = new HashMap<>();
+        tagSet = parser.tagSet();
         start = new Token.StartTag(this);
         currentToken = start; // init current token to the virtual start token.
         this.baseUri = baseUri;
@@ -68,7 +69,6 @@ abstract class TreeBuilder {
         reader = null;
         tokeniser = null;
         stack = null;
-        seenTags = null;
     }
 
     Document parse(Reader input, String baseUri, Parser parser) {
@@ -158,7 +158,7 @@ abstract class TreeBuilder {
      Removes the last Element from the stack, hits onNodeClosed, and then returns it.
      * @return
      */
-    final Element pop() {
+    Element pop() {
         int size = stack.size();
         Element removed = stack.remove(size - 1);
         onNodeClosed(removed);
@@ -230,23 +230,12 @@ abstract class TreeBuilder {
             errors.add(new ParseError(reader, msg, args));
     }
 
-    /**
-     (An internal method, visible for Element. For HTML parse, signals that script and style text should be treated as
-     Data Nodes).
-     */
-    boolean isContentForTagData(String normalName) {
-        return false;
+    Tag tagFor(String tagName, String normalName, String namespace, ParseSettings settings) {
+        return tagSet.valueOf(tagName, normalName, namespace, settings.preserveTagCase());
     }
 
-    Tag tagFor(String tagName, String normalName, String namespace, ParseSettings settings) {
-        Tag cached = seenTags.get(tagName); // note that we don't normalize the cache key. But tag via valueOf may be normalized.
-        if (cached == null || !cached.namespace().equals(namespace)) {
-            // only return from cache if the namespace is the same. not running nested cache to save double hit on the common flow
-            Tag tag = Tag.valueOf(tagName, normalName, namespace, settings);
-            seenTags.put(tagName, tag);
-            return tag;
-        }
-        return cached;
+    Tag tagFor(Token.Tag token) {
+        return tagSet.valueOf(token.tagName, token.normalName, defaultNamespace(), settings.preserveTagCase());
     }
 
     /**
@@ -257,6 +246,10 @@ abstract class TreeBuilder {
         return NamespaceHtml;
     }
 
+    TagSet defaultTagSet() {
+        return TagSet.Html();
+    }
+
     /**
      Called by implementing TreeBuilders when a node has been inserted. This implementation includes optionally tracking
      the source range of the node.  @param node the node that was just inserted
diff --git a/src/main/java/org/jsoup/parser/XmlTreeBuilder.java b/src/main/java/org/jsoup/parser/XmlTreeBuilder.java
index 0abef080..a07b7e6b 100644
--- a/src/main/java/org/jsoup/parser/XmlTreeBuilder.java
+++ b/src/main/java/org/jsoup/parser/XmlTreeBuilder.java
@@ -1,8 +1,12 @@
 package org.jsoup.parser;
 
 import org.jsoup.helper.Validate;
+import org.jsoup.internal.SharedConstants;
+import org.jsoup.nodes.Attribute;
+import org.jsoup.nodes.Attributes;
 import org.jsoup.nodes.CDataNode;
 import org.jsoup.nodes.Comment;
+import org.jsoup.nodes.DataNode;
 import org.jsoup.nodes.Document;
 import org.jsoup.nodes.DocumentType;
 import org.jsoup.nodes.Element;
@@ -11,10 +15,15 @@ import org.jsoup.nodes.LeafNode;
 import org.jsoup.nodes.Node;
 import org.jsoup.nodes.TextNode;
 import org.jsoup.nodes.XmlDeclaration;
+import org.jsoup.select.Elements;
+import org.jspecify.annotations.Nullable;
 
 import java.io.Reader;
 import java.io.StringReader;
+import java.util.ArrayDeque;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 import static org.jsoup.parser.Parser.NamespaceXml;
 
@@ -26,6 +35,10 @@ import static org.jsoup.parser.Parser.NamespaceXml;
  * @author Jonathan Hedley
  */
 public class XmlTreeBuilder extends TreeBuilder {
+    static final String XmlnsKey = "xmlns";
+    static final String XmlnsPrefix = "xmlns:";
+    private final ArrayDeque<HashMap<String, String>> namespacesStack = new ArrayDeque<>(); // stack of namespaces, prefix => urn
+
     @Override ParseSettings defaultSettings() {
         return ParseSettings.preserveCase;
     }
@@ -37,6 +50,34 @@ public class XmlTreeBuilder extends TreeBuilder {
             .syntax(Document.OutputSettings.Syntax.xml)
             .escapeMode(Entities.EscapeMode.xhtml)
             .prettyPrint(false); // as XML, we don't understand what whitespace is significant or not
+
+        namespacesStack.clear();
+        HashMap<String, String> ns = new HashMap<>();
+        ns.put("xml", NamespaceXml);
+        ns.put("", NamespaceXml);
+        namespacesStack.push(ns);
+    }
+
+    @Override
+    void initialiseParseFragment(@Nullable Element context) {
+        super.initialiseParseFragment(context);
+        if (context == null) return;
+
+        // transition to the tag's text state if available
+        TokeniserState textState = context.tag().textState();
+        if (textState != null) tokeniser.transition(textState);
+
+        // reconstitute the namespace stack by traversing the element and its parents (top down)
+        Elements chain = context.parents();
+        chain.add(0, context);
+        for (int i = chain.size() - 1; i >= 0; i--) {
+            Element el = chain.get(i);
+            HashMap<String, String> namespaces = new HashMap<>(namespacesStack.peek());
+            namespacesStack.push(namespaces);
+            if (el.attributesSize() > 0) {
+                processNamespaces(el.attributes(), namespaces);
+            }
+        }
     }
 
     Document parse(Reader input, String baseUri) {
@@ -60,6 +101,11 @@ public class XmlTreeBuilder extends TreeBuilder {
         return NamespaceXml;
     }
 
+    @Override
+    TagSet defaultTagSet() {
+        return new TagSet(); // an empty tagset
+    }
+
     @Override
     protected boolean process(Token token) {
         currentToken = token;
@@ -93,20 +139,74 @@ public class XmlTreeBuilder extends TreeBuilder {
     }
 
     void insertElementFor(Token.StartTag startTag) {
-        Tag tag = tagFor(startTag.name(), startTag.normalName(), defaultNamespace(), settings);
-        if (startTag.attributes != null)
-            startTag.attributes.deduplicate(settings);
+        // handle namespace for tag
+        HashMap<String, String> namespaces = new HashMap<>(namespacesStack.peek());
+        namespacesStack.push(namespaces);
 
-        Element el = new Element(tag, null, settings.normalizeAttributes(startTag.attributes));
+        Attributes attributes = startTag.attributes;
+        if (attributes != null) {
+            attributes.deduplicate(settings);
+            processNamespaces(attributes, namespaces);
+            applyNamespacesToAttributes(attributes, namespaces);
+        }
+
+        String ns = resolveNamespace(startTag.tagName, namespaces);
+        Tag tag = tagFor(startTag.tagName, startTag.normalName, ns, settings);
+        Element el = new Element(tag, null, settings.normalizeAttributes(attributes));
         currentElement().appendChild(el);
         push(el);
 
         if (startTag.isSelfClosing()) {
-            tag.setSelfClosing();
+            tag.setSeenSelfClose();
             pop(); // push & pop ensures onNodeInserted & onNodeClosed
+        } else if (tag.isEmpty()) {
+            pop(); // custom defined void tag
+        } else {
+            TokeniserState textState = tag.textState();
+            if (textState != null) tokeniser.transition(textState);
         }
     }
 
+    private static void processNamespaces(Attributes attributes, HashMap<String, String> namespaces) {
+        // process attributes for namespaces (xmlns, xmlns:)
+        for (Attribute attr : attributes) {
+            String key = attr.getKey();
+            String value = attr.getValue();
+            if (key.equals(XmlnsKey)) {
+                namespaces.put("", value); // new default for this level
+            } else if (key.startsWith(XmlnsPrefix)) {
+                String nsPrefix = key.substring(XmlnsPrefix.length());
+                namespaces.put(nsPrefix, value);
+            }
+        }
+    }
+
+    private static void applyNamespacesToAttributes(Attributes attributes, HashMap<String, String> namespaces) {
+        // second pass, apply namespace to attributes. Collects them first then adds (as userData is an attribute)
+        Map<String, String> attrPrefix = new HashMap<>();
+        for (Attribute attr: attributes) {
+            String prefix = attr.prefix();
+            if (!prefix.isEmpty()) {
+                if (prefix.equals(XmlnsKey)) continue;
+                String ns = namespaces.get(prefix);
+                if (ns != null) attrPrefix.put(SharedConstants.XmlnsAttr + prefix, ns);
+            }
+        }
+        for (Map.Entry<String, String> entry : attrPrefix.entrySet())
+            attributes.userData(entry.getKey(), entry.getValue());
+    }
+
+    private static String resolveNamespace(String tagName, HashMap<String, String> namespaces) {
+        String ns = namespaces.get("");
+        int pos = tagName.indexOf(':');
+        if (pos > 0) {
+            String prefix = tagName.substring(0, pos);
+            if (namespaces.containsKey(prefix))
+                ns = namespaces.get(prefix);
+        }
+        return ns;
+    }
+
     void insertLeafNode(LeafNode node) {
         currentElement().appendChild(node);
         onNodeInserted(node);
@@ -114,21 +214,16 @@ public class XmlTreeBuilder extends TreeBuilder {
 
     void insertCommentFor(Token.Comment commentToken) {
         Comment comment = new Comment(commentToken.getData());
-        LeafNode insert = comment;
-        if (commentToken.bogus && comment.isXmlDeclaration()) {
-            // xml declarations are emitted as bogus comments (which is right for html, but not xml)
-            // so we do a bit of a hack and parse the data as an element to pull the attributes out
-            // todo - refactor this to parse more appropriately
-            XmlDeclaration decl = comment.asXmlDeclaration(); // else, we couldn't parse it as a decl, so leave as a comment
-            if (decl != null)
-                insert = decl;
-        }
-        insertLeafNode(insert);
+        insertLeafNode(comment);
     }
 
     void insertCharacterFor(Token.Character token) {
         final String data = token.getData();
-        insertLeafNode(token.isCData() ? new CDataNode(data) : new TextNode(data));
+        LeafNode node;
+        if      (token.isCData())                       node = new CDataNode(data);
+        else if (currentElement().tag().is(Tag.Data))   node = new DataNode(data);
+        else                                            node = new TextNode(data);
+        insertLeafNode(node);
     }
 
     void insertDoctypeFor(Token.Doctype token) {
@@ -143,6 +238,12 @@ public class XmlTreeBuilder extends TreeBuilder {
         insertLeafNode(decl);
     }
 
+    @Override
+    Element pop() {
+        namespacesStack.pop();
+        return super.pop();
+    }
+
     /**
      * If the stack contains an element with this tag's name, pop up the stack to remove the first occurrence. If not
      * found, skips.
diff --git a/src/main/java/org/jsoup/select/NodeVisitor.java b/src/main/java/org/jsoup/select/NodeVisitor.java
index decf9e2a..7d693d0e 100644
--- a/src/main/java/org/jsoup/select/NodeVisitor.java
+++ b/src/main/java/org/jsoup/select/NodeVisitor.java
@@ -14,7 +14,7 @@ import org.jsoup.nodes.Node;
  </p>
  <p><b>Example:</b></p>
  <pre><code>
- doc.body().traverse((node, depth) -> {
+ doc.body().traverse((node, depth) -&gt; {
      switch (node) {
          case Element el     -&gt; print(el.tag() + ": " + el.ownText());
          case DataNode data  -&gt; print("Data: " + data.getWholeData());


Test patch:
diff --git a/src/test/java/org/jsoup/helper/W3CDomTest.java b/src/test/java/org/jsoup/helper/W3CDomTest.java
index da24d7f0..b9a8daca 100644
--- a/src/test/java/org/jsoup/helper/W3CDomTest.java
+++ b/src/test/java/org/jsoup/helper/W3CDomTest.java
@@ -89,7 +89,7 @@ public class W3CDomTest {
     public void namespacePreservation() throws IOException {
         File in = ParseTest.getFile("/htmltests/namespaces.xhtml");
         org.jsoup.nodes.Document jsoupDoc;
-        jsoupDoc = Jsoup.parse(in, "UTF-8");
+        jsoupDoc = Jsoup.parse(in, "UTF-8", "", Parser.xmlParser());
 
         Document doc;
         org.jsoup.helper.W3CDom jDom = new org.jsoup.helper.W3CDom();
@@ -242,7 +242,7 @@ public class W3CDomTest {
         w3cDom.convert(document, customDocumentResult);
 
         String html = W3CDom.asString(customDocumentResult, W3CDom.OutputHtml());
-        assertEquals("<html><head><META http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\"></head><body><div></div></body></html>", html);
+        assertEquals("<html xmlns=\"http://www.w3.org/1999/xhtml\"><head><META http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\"></head><body><div></div></body></html>", html);
     }
 
     @Test
@@ -258,7 +258,7 @@ public class W3CDomTest {
         assertEquals("html", htmlEl.getNodeName());
 
         Node fb = htmlEl.getFirstChild().getNextSibling().getFirstChild();
-        assertNull(fb.getNamespaceURI());
+        assertEquals("http://www.w3.org/1999/xhtml", fb.getNamespaceURI());
         assertEquals("like", fb.getLocalName());
         assertEquals("fb:like", fb.getNodeName());
     }
diff --git a/src/test/java/org/jsoup/integration/ConnectTest.java b/src/test/java/org/jsoup/integration/ConnectTest.java
index eb7ae342..3cb2b4a6 100644
--- a/src/test/java/org/jsoup/integration/ConnectTest.java
+++ b/src/test/java/org/jsoup/integration/ConnectTest.java
@@ -4,6 +4,7 @@ import org.jsoup.Connection;
 import org.jsoup.HttpStatusException;
 import org.jsoup.Jsoup;
 import org.jsoup.Connection.Method;
+import org.jsoup.TextUtil;
 import org.jsoup.UnsupportedMimeTypeException;
 import org.jsoup.helper.DataUtil;
 import org.jsoup.helper.W3CDom;
@@ -21,19 +22,23 @@ import org.jsoup.parser.StreamParser;
 import org.jsoup.parser.XmlTreeBuilder;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.MethodSource;
 import org.junit.jupiter.params.provider.ValueSource;
 
 import javax.servlet.http.HttpServletResponse;
+import java.io.ByteArrayInputStream;
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.IOException;
+import java.io.InputStream;
 import java.net.Authenticator;
 import java.net.MalformedURLException;
 import java.net.URL;
 import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.List;
@@ -45,7 +50,6 @@ import java.util.stream.Stream;
 import static org.jsoup.helper.AuthenticationHandlerTest.MaxAttempts;
 import static org.jsoup.helper.HttpConnection.CONTENT_TYPE;
 import static org.jsoup.helper.HttpConnection.MULTIPART_FORM_DATA;
-import static org.jsoup.integration.UrlConnectTest.browserUa;
 import static org.junit.jupiter.api.Assertions.*;
 
 /**
@@ -165,7 +169,6 @@ public class ConnectTest {
     public void doesPostMultipartWithoutInputstream() throws IOException {
         Document doc = Jsoup.connect(echoUrl)
                 .header(CONTENT_TYPE, MULTIPART_FORM_DATA)
-                .userAgent(browserUa)
                 .data("uname", "Jsoup", "uname", "Jonathan", "百", "度一下")
                 .post();
 
@@ -211,7 +214,6 @@ public class ConnectTest {
         Document doc = Jsoup.connect(echoUrl)
             .requestBody(body)
             .header("Content-Type", "application/json")
-            .userAgent(browserUa)
             .data("foo", "true")
             .post();
         assertEquals("POST", ihVal("Method", doc));
@@ -226,7 +228,6 @@ public class ConnectTest {
         Document doc = Jsoup.connect(echoUrl)
             .requestBody(body)
             .header("Content-Type", "application/json")
-            .userAgent(browserUa)
             .post();
         assertEquals("POST", ihVal("Method", doc));
         assertEquals("application/json", ihVal("Content-Type", doc));
@@ -239,7 +240,6 @@ public class ConnectTest {
         Document doc = Jsoup.connect(echoUrl)
             .requestBody(body)
             .header("Content-Type", "text/plain")
-            .userAgent(browserUa)
             .post();
         assertEquals("POST", ihVal("Method", doc));
         assertEquals("text/plain", ihVal("Content-Type", doc));
@@ -253,13 +253,27 @@ public class ConnectTest {
             .requestBody(body)
             .data("uname", "Jsoup", "uname", "Jonathan", "百", "度一下")
             .header("Content-Type", "text/plain") // todo - if user sets content-type, we should append postcharset
-            .userAgent(browserUa)
             .post();
         assertEquals("POST", ihVal("Method", doc));
         assertEquals("uname=Jsoup&uname=Jonathan&%E7%99%BE=%E5%BA%A6%E4%B8%80%E4%B8%8B", ihVal("Query String", doc));
         assertEquals(body, ihVal("Post Data", doc));
     }
 
+    @Test void sendsRequestBodyStream() throws IOException {
+        final String body = "{key:value}";
+        InputStream stream = new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8));
+
+        Document doc = Jsoup.connect(echoUrl)
+            .requestBodyStream(stream)
+            .header("Content-Type", "application/json")
+            .data("foo", "true")
+            .post();
+        assertEquals("POST", ihVal("Method", doc));
+        assertEquals("application/json", ihVal("Content-Type", doc));
+        assertEquals("foo=true", ihVal("Query String", doc));
+        assertEquals(body, ihVal("Post Data", doc));
+    }
+
     @ParameterizedTest @MethodSource("echoUrls") // http and https
     public void doesGet(String url) throws IOException {
         Connection con = Jsoup.connect(url + "?what=the")
@@ -597,6 +611,26 @@ public class ConnectTest {
         assertEquals(Document.OutputSettings.Syntax.xml, doc.outputSettings().syntax());
     }
 
+    @Test
+    public void testSupplyParserToConnection() throws IOException {
+        String xmlUrl = FileServlet.urlTo("/htmltests/xml-test.xml");
+
+        // parse with both xml and html parser, ensure different
+        Document xmlDoc = Jsoup.connect(xmlUrl).parser(Parser.xmlParser()).get();
+        Document htmlDoc = Jsoup.connect(xmlUrl).parser(Parser.htmlParser()).get();
+        Document autoXmlDoc = Jsoup.connect(xmlUrl).get(); // check connection auto detects xml, uses xml parser
+
+        assertEquals("<doc><val>One<val>Two</val>Three</val></doc>",
+            TextUtil.stripNewlines(xmlDoc.html()));
+        assertNotEquals(htmlDoc, xmlDoc);
+        assertFalse(htmlDoc.hasSameValue(xmlDoc));
+        assertTrue(xmlDoc.hasSameValue(autoXmlDoc));
+
+        assertEquals(1, htmlDoc.select("head").size()); // html parser normalises
+        assertEquals(0, xmlDoc.select("head").size()); // xml parser does not
+        assertEquals(0, autoXmlDoc.select("head").size()); // xml parser does not
+    }
+
     @Test public void imageXmlMimeType() throws IOException {
         // test that we switch to XML, and that we support image/svg+xml
         String mimetype = "image/svg+xml";
@@ -708,12 +742,12 @@ public class ConnectTest {
     public void fetchHandlesXmlAsHtmlWhenParserSet() throws IOException {
         // should auto-detect xml and use XML parser, unless explicitly requested the html parser
         String xmlUrl = FileServlet.urlTo("/htmltests/xml-test.xml");
-        Connection con = Jsoup.connect(xmlUrl).parser(Parser.htmlParser());
+        Connection con = Jsoup.connect(xmlUrl).parser(Parser.htmlParser()); // which will also use the pretty printer by default
         con.data(FileServlet.ContentTypeParam, "application/xml");
         Document doc = con.get();
         Connection.Request req = con.request();
         assertTrue(req.parser().getTreeBuilder() instanceof HtmlTreeBuilder);
-        assertEquals("<html> <head></head> <body> <doc> <val> One <val> Two </val>Three </val> </doc> </body> </html>", StringUtil.normaliseWhitespace(doc.outerHtml()));
+        assertEquals("<html>\n <head></head>\n <body>\n  <doc>\n   <val>\n    One<val>Two</val>Three\n   </val>\n  </doc>\n </body>\n</html>", doc.outerHtml());
     }
 
     @Test
diff --git a/src/test/java/org/jsoup/integration/TestServer.java b/src/test/java/org/jsoup/integration/TestServer.java
index d36a34e2..3224a789 100644
--- a/src/test/java/org/jsoup/integration/TestServer.java
+++ b/src/test/java/org/jsoup/integration/TestServer.java
@@ -12,6 +12,8 @@ import org.eclipse.jetty.server.handler.HandlerWrapper;
 import org.eclipse.jetty.servlet.FilterHolder;
 import org.eclipse.jetty.servlet.FilterMapping;
 import org.eclipse.jetty.servlet.ServletHandler;
+import org.eclipse.jetty.util.log.Log;
+import org.eclipse.jetty.util.log.StdErrLog;
 import org.eclipse.jetty.util.ssl.SslContextFactory;
 import org.jsoup.integration.servlets.AuthFilter;
 import org.jsoup.integration.servlets.BaseServlet;
@@ -50,6 +52,11 @@ public class TestServer {
     private static final ProxySettings ProxySettings = new ProxySettings();
 
     private static Server newServer() {
+        // logs to stdout, so not highlighted as errors in Maven test runs
+        StdErrLog logger = new StdErrLog();
+        logger.setStdErrStream(System.out);
+        Log.setLog(logger);
+
         return new Server(new InetSocketAddress(Localhost, 0));
     }
 
diff --git a/src/test/java/org/jsoup/integration/UrlConnectTest.java b/src/test/java/org/jsoup/integration/UrlConnectTest.java
deleted file mode 100644
index cbc2bdb6..00000000
--- a/src/test/java/org/jsoup/integration/UrlConnectTest.java
+++ /dev/null
@@ -1,505 +0,0 @@
-package org.jsoup.integration;
-
-import org.jsoup.Connection;
-import org.jsoup.Jsoup;
-import org.jsoup.UnsupportedMimeTypeException;
-import org.jsoup.helper.W3CDom;
-import org.jsoup.internal.StringUtil;
-import org.jsoup.nodes.Document;
-import org.jsoup.nodes.FormElement;
-import org.jsoup.parser.HtmlTreeBuilder;
-import org.jsoup.parser.Parser;
-import org.jsoup.parser.XmlTreeBuilder;
-import org.junit.jupiter.api.Disabled;
-import org.junit.jupiter.api.Test;
-
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.IOException;
-import java.net.ConnectException;
-import java.net.InetSocketAddress;
-import java.net.Proxy;
-import java.net.URL;
-import java.util.List;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-/**
- Tests the URL connection. Not enabled by default, so tests don't require network connection.
-
- @author Jonathan Hedley, jonathan@hedley.net */
-@Disabled // ignored by default so tests don't require network access. comment out to enable.
-// todo: rebuild these into a local Jetty test server, so not reliant on the vagaries of the internet.
-public class UrlConnectTest {
-    private static final String WEBSITE_WITH_INVALID_CERTIFICATE = "https://certs.cac.washington.edu/CAtest/";
-    private static final String WEBSITE_WITH_SNI = "https://jsoup.org/";
-    public static String browserUa = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.112 Safari/537.36";
-
-    @Test
-    public void fetchBaidu() throws IOException {
-        Connection.Response res = Jsoup.connect("http://www.baidu.com/").timeout(10*1000).execute();
-        Document doc = res.parse();
-
-        assertEquals("GBK", doc.outputSettings().charset().displayName());
-        assertEquals("GBK", res.charset());
-        assert(res.hasCookie("BAIDUID"));
-        assertEquals("text/html;charset=gbk", res.contentType());
-    }
-
-    @Test
-    public void exceptOnUnknownContentType() {
-        String url = "http://direct.jsoup.org/rez/osi_logo.png"; // not text/* but image/png, should throw
-        boolean threw = false;
-        try {
-            Document doc = Jsoup.parse(new URL(url), 3000);
-        } catch (UnsupportedMimeTypeException e) {
-            threw = true;
-            assertEquals("org.jsoup.UnsupportedMimeTypeException: Unhandled content type. Must be text/*, application/xml, or application/xhtml+xml. Mimetype=image/png, URL=http://direct.jsoup.org/rez/osi_logo.png", e.toString());
-            assertEquals(url, e.getUrl());
-            assertEquals("image/png", e.getMimeType());
-        } catch (IOException e) {
-        }
-        assertTrue(threw);
-    }
-
-    @Test
-    public void ignoresContentTypeIfSoConfigured() throws IOException {
-        Document doc = Jsoup.connect("https://jsoup.org/rez/osi_logo.png").ignoreContentType(true).get();
-        assertEquals("", doc.title()); // this will cause an ugly parse tree
-    }
-
-    private static String ihVal(String key, Document doc) {
-        return doc.select("th:contains("+key+") + td").first().text();
-    }
-
-    @Test
-    public void followsTempRedirect() throws IOException {
-        Connection con = Jsoup.connect("http://direct.infohound.net/tools/302.pl"); // http://jsoup.org
-        Document doc = con.get();
-        assertTrue(doc.title().contains("jsoup"));
-    }
-
-    @Test
-    public void followsNewTempRedirect() throws IOException {
-        Connection con = Jsoup.connect("http://direct.infohound.net/tools/307.pl"); // http://jsoup.org
-        Document doc = con.get();
-        assertTrue(doc.title().contains("jsoup"));
-        assertEquals("https://jsoup.org/", con.response().url().toString());
-    }
-
-    @Test
-    public void postRedirectsFetchWithGet() throws IOException {
-        Connection con = Jsoup.connect("http://direct.infohound.net/tools/302.pl")
-                .data("Argument", "Riposte")
-                .method(Connection.Method.POST);
-        Connection.Response res = con.execute();
-        assertEquals("https://jsoup.org/", res.url().toExternalForm());
-        assertEquals(Connection.Method.GET, res.method());
-    }
-
-    @Test
-    public void followsRedirectToHttps() throws IOException {
-        Connection con = Jsoup.connect("http://direct.infohound.net/tools/302-secure.pl"); // https://www.google.com
-        con.data("id", "5");
-        Document doc = con.get();
-        assertTrue(doc.title().contains("Google"));
-    }
-
-    @Test
-    public void followsRelativeRedirect() throws IOException {
-        Connection con = Jsoup.connect("http://direct.infohound.net/tools/302-rel.pl"); // to /tidy/
-        Document doc = con.post();
-        assertTrue(doc.title().contains("HTML Tidy Online"));
-    }
-
-    @Test
-    public void followsRelativeDotRedirect() throws IOException {
-        // redirects to "./ok.html", should resolve to http://direct.infohound.net/tools/ok.html
-        Connection con = Jsoup.connect("http://direct.infohound.net/tools/302-rel-dot.pl"); // to ./ok.html
-        Document doc = con.post();
-        assertTrue(doc.title().contains("OK"));
-        assertEquals(doc.location(), "http://direct.infohound.net/tools/ok.html");
-    }
-
-    @Test
-    public void followsRelativeDotRedirect2() throws IOException {
-        //redirects to "esportspenedes.cat/./ep/index.php", should resolve to "esportspenedes.cat/ep/index.php"
-        Connection con = Jsoup.connect("http://esportspenedes.cat")  // note lack of trailing / - server should redir to / first, then to ./ep/...; but doesn't'
-                .timeout(10000);
-        Document doc = con.post();
-        assertEquals(doc.location(), "http://esportspenedes.cat/ep/index.php");
-    }
-
-    @Test
-    public void followsRedirectsWithWithespaces() throws IOException {
-        Connection con = Jsoup.connect("http://tinyurl.com/kgofxl8"); // to http://www.google.com/?q=white spaces
-        Document doc = con.get();
-        assertTrue(doc.title().contains("Google"));
-    }
-
-    @Test
-    public void gracefullyHandleBrokenLocationRedirect() throws IOException {
-        Connection con = Jsoup.connect("http://aag-ye.com"); // has Location: http:/temp/AAG_New/en/index.php
-        con.get(); // would throw exception on error
-        assertTrue(true);
-    }
-
-    @Test
-    public void ignores500tExceptionIfSoConfigured() throws IOException {
-        Connection con = Jsoup.connect("http://direct.infohound.net/tools/500.pl").ignoreHttpErrors(true);
-        Connection.Response res = con.execute();
-        Document doc = res.parse();
-        assertEquals(500, res.statusCode());
-        assertEquals("Application Error", res.statusMessage());
-        assertEquals("Woops", doc.select("h1").first().text());
-    }
-
-    @Test
-    public void ignores500WithNoContentExceptionIfSoConfigured() throws IOException {
-        Connection con = Jsoup.connect("http://direct.infohound.net/tools/500-no-content.pl").ignoreHttpErrors(true);
-        Connection.Response res = con.execute();
-        Document doc = res.parse();
-        assertEquals(500, res.statusCode());
-        assertEquals("Application Error", res.statusMessage());
-    }
-
-    @Test
-    public void ignores200WithNoContentExceptionIfSoConfigured() throws IOException {
-        Connection con = Jsoup.connect("http://direct.infohound.net/tools/200-no-content.pl").ignoreHttpErrors(true);
-        Connection.Response res = con.execute();
-        Document doc = res.parse();
-        assertEquals(200, res.statusCode());
-        assertEquals("All Good", res.statusMessage());
-    }
-
-    @Test
-    public void handles200WithNoContent() throws IOException {
-        Connection con = Jsoup
-            .connect("http://direct.infohound.net/tools/200-no-content.pl")
-            .userAgent(browserUa);
-        Connection.Response res = con.execute();
-        Document doc = res.parse();
-        assertEquals(200, res.statusCode());
-
-        con = Jsoup
-            .connect("http://direct.infohound.net/tools/200-no-content.pl")
-            .parser(Parser.xmlParser())
-            .userAgent(browserUa);
-        res = con.execute();
-        doc = res.parse();
-        assertEquals(200, res.statusCode());
-    }
-
-    @Test
-    public void doesntRedirectIfSoConfigured() throws IOException {
-        Connection con = Jsoup.connect("http://direct.infohound.net/tools/302.pl").followRedirects(false);
-        Connection.Response res = con.execute();
-        assertEquals(302, res.statusCode());
-        assertEquals("http://jsoup.org", res.header("Location"));
-    }
-
-    @Test
-    public void redirectsResponseCookieToNextResponse() throws IOException {
-        Connection con = Jsoup.connect("http://direct.infohound.net/tools/302-cookie.pl");
-        Connection.Response res = con.execute();
-        assertEquals("asdfg123", res.cookie("token")); // confirms that cookies set on 1st hit are presented in final result
-        Document doc = res.parse();
-        assertEquals("token=asdfg123; uid=jhy", ihVal("HTTP_COOKIE", doc)); // confirms that redirected hit saw cookie
-    }
-
-    @Test
-    public void maximumRedirects() {
-        boolean threw = false;
-        try {
-            Document doc = Jsoup.connect("http://direct.infohound.net/tools/loop.pl").get();
-        } catch (IOException e) {
-            assertTrue(e.getMessage().contains("Too many redirects"));
-            threw = true;
-        }
-        assertTrue(threw);
-    }
-
-    @Test
-    public void handlesDodgyCharset() throws IOException {
-        // tests that when we get back "UFT8", that it is recognised as unsupported, and falls back to default instead
-        String url = "http://direct.infohound.net/tools/bad-charset.pl";
-        Connection.Response res = Jsoup.connect(url).execute();
-        assertEquals("text/html; charset=UFT8", res.header("Content-Type")); // from the header
-        assertNull(res.charset()); // tried to get from header, not supported, so returns null
-        Document doc = res.parse(); // would throw an error if charset unsupported
-        assertTrue(doc.text().contains("Hello!"));
-        assertEquals("UTF-8", res.charset()); // set from default on parse
-    }
-
-    /**
-     * Verify that security disabling feature works properly.
-     * <p/>
-     * 1. try to hit url with invalid certificate and evaluate that exception is thrown
-     *
-     * @throws Exception
-     */
-    @Test
-    public void testUnsafeFail() throws Exception {
-        String url = WEBSITE_WITH_INVALID_CERTIFICATE;
-        assertThrows(IOException.class, () -> Jsoup.connect(url).execute());
-    }
-
-
-    /**
-     * Verify that requests to websites with SNI fail on jdk 1.6
-     * <p/>
-     * read for more details:
-     * http://en.wikipedia.org/wiki/Server_Name_Indication
-     *
-     * Test is ignored independent from others as it requires JDK 1.6
-     * @throws Exception
-     */
-    @Test
-    public void testSNIFail() throws Exception {
-        assertThrows(IOException.class, () -> Jsoup.connect(WEBSITE_WITH_SNI).execute());
-    }
-
-    @Test
-    public void shouldWorkForCharsetInExtraAttribute() throws IOException {
-        Connection.Response res = Jsoup.connect("https://www.creditmutuel.com/groupe/fr/").execute();
-        Document doc = res.parse(); // would throw an error if charset unsupported
-        assertEquals("ISO-8859-1", res.charset());
-    }
-
-    // The following tests were added to test specific domains if they work. All code paths
-    // which make the following test green are tested in other unit or integration tests, so the following lines
-    // could be deleted
-
-    @Test
-    public void shouldSelectFirstCharsetOnWeirdMultileCharsetsInMetaTags() throws IOException {
-        Connection.Response res = Jsoup.connect("http://aamo.info/").execute();
-        res.parse(); // would throw an error if charset unsupported
-        assertEquals("ISO-8859-1", res.charset());
-    }
-
-    @Test
-    public void shouldParseBrokenHtml5MetaCharsetTagCorrectly() throws IOException {
-        Connection.Response res = Jsoup.connect("http://9kuhkep.net").execute();
-        res.parse(); // would throw an error if charset unsupported
-        assertEquals("UTF-8", res.charset());
-    }
-
-    @Test
-    public void shouldEmptyMetaCharsetCorrectly() throws IOException {
-        Connection.Response res = Jsoup.connect("http://aastmultimedia.com").execute();
-        res.parse(); // would throw an error if charset unsupported
-        assertEquals("UTF-8", res.charset());
-    }
-
-    @Test
-    public void shouldWorkForDuplicateCharsetInTag() throws IOException {
-        Connection.Response res = Jsoup.connect("http://aaptsdassn.org").execute();
-        Document doc = res.parse(); // would throw an error if charset unsupported
-        assertEquals("ISO-8859-1", res.charset());
-    }
-
-    @Test
-    public void handles201Created() throws IOException {
-        Document doc = Jsoup.connect("http://direct.infohound.net/tools/201.pl").get(); // 201, location=jsoup
-        assertEquals("https://jsoup.org/", doc.location());
-    }
-
-    /*
-     Proxy tests. Assumes local proxy running on 8888, without system propery set (so that specifying it is required).
-     */
-
-    @Test
-    public void fetchViaHttpProxy() throws IOException {
-        String url = "https://jsoup.org";
-        Proxy proxy = new Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved("localhost", 8888));
-        Document doc = Jsoup.connect(url).proxy(proxy).get();
-        assertTrue(doc.title().contains("jsoup"));
-    }
-
-    @Test
-    public void fetchViaHttpProxySetByArgument() throws IOException {
-        String url = "https://jsoup.org";
-        Document doc = Jsoup.connect(url).proxy("localhost", 8888).get();
-        assertTrue(doc.title().contains("jsoup"));
-    }
-
-    @Test
-    public void invalidProxyFails() {
-        boolean caught = false;
-        String url = "https://jsoup.org";
-        try {
-            Document doc = Jsoup.connect(url).proxy("localhost", 8889).get();
-        } catch (IOException e) {
-            caught = e instanceof ConnectException;
-        }
-        assertTrue(caught);
-    }
-
-    @Test
-    public void proxyGetAndSet() throws IOException {
-        String url = "https://jsoup.org";
-        Proxy proxy = new Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved("localhost", 8889)); // invalid
-        final Connection con = Jsoup.connect(url).proxy(proxy);
-
-        assert con.request().proxy() == proxy;
-        con.request().proxy(null); // disable
-        Document doc = con.get();
-        assertTrue(doc.title().contains("jsoup")); // would fail if actually went via proxy
-    }
-
-    @Test
-    public void throwsIfRequestBodyForGet() throws IOException {
-        boolean caught = false;
-        String url = "https://jsoup.org";
-        try {
-            Document doc = Jsoup.connect(url).requestBody("fail").get();
-        } catch (IllegalArgumentException e) {
-            caught = true;
-        }
-        assertTrue(caught);
-    }
-
-    @Test
-    public void canSpecifyResponseCharset() throws IOException {
-        // both these docs have <80> in there as euro/control char depending on charset
-        String noCharsetUrl = "http://direct.infohound.net/tools/Windows-1252-nocharset.html";
-        String charsetUrl = "http://direct.infohound.net/tools/Windows-1252-charset.html";
-
-        // included in meta
-        Connection.Response res1 = Jsoup.connect(charsetUrl).execute();
-        assertNull(res1.charset()); // not set in headers
-        final Document doc1 = res1.parse();
-        assertEquals("windows-1252", doc1.charset().displayName()); // but determined at parse time
-        assertEquals("Cost is €100", doc1.select("p").text());
-        assertTrue(doc1.text().contains("€"));
-
-        // no meta, no override
-        Connection.Response res2 = Jsoup.connect(noCharsetUrl).execute();
-        assertNull(res2.charset()); // not set in headers
-        final Document doc2 = res2.parse();
-        assertEquals("UTF-8", doc2.charset().displayName()); // so defaults to utf-8
-        assertEquals("Cost is �100", doc2.select("p").text());
-        assertTrue(doc2.text().contains("�"));
-
-        // no meta, let's override
-        Connection.Response res3 = Jsoup.connect(noCharsetUrl).execute();
-        assertNull(res3.charset()); // not set in headers
-        res3.charset("windows-1252");
-        assertEquals("windows-1252", res3.charset()); // read back
-        final Document doc3 = res3.parse();
-        assertEquals("windows-1252", doc3.charset().displayName()); // from override
-        assertEquals("Cost is €100", doc3.select("p").text());
-        assertTrue(doc3.text().contains("€"));
-    }
-
-    @Test
-    public void handlesUnescapedRedirects() throws IOException {
-        // URL locations should be url safe (ascii) but are often not, so we should try to guess
-        // in this case the location header is utf-8, but defined in spec as iso8859, so detect, convert, encode
-        String url = "https://direct.infohound.net/tools/302-utf.pl";
-        String urlEscaped = "https://direct.infohound.net/tools/test%F0%9F%92%A9.html";
-
-        Connection.Response res = Jsoup.connect(url).execute();
-        Document doc = res.parse();
-        assertEquals(doc.body().text(), "\uD83D\uDCA9!");
-        assertEquals(doc.location(), urlEscaped);
-
-        Connection.Response res2 = Jsoup.connect(url).followRedirects(false).execute();
-        assertEquals("/tools/test\uD83D\uDCA9.html", res2.header("Location"));
-        // if we didn't notice it was utf8, would look like: Location: /tools/testð©.html
-    }
-
-    @Test public void handlesEscapesInRedirecct() throws IOException {
-        Document doc = Jsoup.connect("https://direct.infohound.net/tools/302-escaped.pl").get();
-        assertEquals("https://direct.infohound.net/tools/q.pl?q=one%20two", doc.location());
-
-        doc = Jsoup.connect("https://direct.infohound.net/tools/302-white.pl").get();
-        assertEquals("https://direct.infohound.net/tools/q.pl?q=one+two", doc.location());
-    }
-
-    @Test
-    public void handlesUt8fInUrl() throws IOException {
-        String url = "https://direct.infohound.net/tools/test\uD83D\uDCA9.html";
-        String urlEscaped = "https://direct.infohound.net/tools/test%F0%9F%92%A9.html";
-
-        Connection.Response res = Jsoup.connect(url).execute();
-        Document doc = res.parse();
-        assertEquals("\uD83D\uDCA9!", doc.body().text());
-        assertEquals(urlEscaped, doc.location());
-    }
-
-    @Test
-    public void inWildUtfRedirect() throws IOException {
-        Connection.Response res = Jsoup.connect("http://brabantn.ws/Q4F").execute();
-        Document doc = res.parse();
-        assertEquals(
-            "http://www.omroepbrabant.nl/?news/2474781303/Gestrande+ree+in+Oss+niet+verdoofd,+maar+doodgeschoten+%E2%80%98Dit+kan+gewoon+niet,+bizar%E2%80%99+[VIDEO].aspx",
-            doc.location()
-            );
-    }
-
-    @Test
-    public void inWildUtfRedirect2() throws IOException {
-        Connection.Response res = Jsoup.connect("https://ssl.souq.com/sa-en/2724288604627/s").execute();
-        Document doc = res.parse();
-        assertEquals(
-            "https://saudi.souq.com/sa-en/%D8%AE%D8%B2%D9%86%D8%A9-%D8%A2%D9%85%D9%86%D8%A9-3-%D8%B7%D8%A8%D9%82%D8%A7%D8%AA-%D8%A8%D9%86%D8%B8%D8%A7%D9%85-%D9%82%D9%81%D9%84-%D8%A5%D9%84%D9%83%D8%AA%D8%B1%D9%88%D9%86%D9%8A-bsd11523-6831477/i/?ctype=dsrch",
-            doc.location()
-        );
-    }
-
-   @Test public void handlesEscapedRedirectUrls() throws IOException {
-        String url = "http://www.altalex.com/documents/news/2016/12/06/questioni-civilistiche-conseguenti-alla-depenalizzazione";
-        // sends: Location:http://shop.wki.it/shared/sso/sso.aspx?sso=&url=http%3a%2f%2fwww.altalex.com%2fsession%2fset%2f%3freturnurl%3dhttp%253a%252f%252fwww.altalex.com%253a80%252fdocuments%252fnews%252f2016%252f12%252f06%252fquestioni-civilistiche-conseguenti-alla-depenalizzazione
-        // then to: http://www.altalex.com/session/set/?returnurl=http%3a%2f%2fwww.altalex.com%3a80%2fdocuments%2fnews%2f2016%2f12%2f06%2fquestioni-civilistiche-conseguenti-alla-depenalizzazione&sso=RDRG6T684G4AK2E7U591UGR923
-        // then : http://www.altalex.com:80/documents/news/2016/12/06/questioni-civilistiche-conseguenti-alla-depenalizzazione
-
-        // bug is that jsoup goes to
-        // 	GET /shared/sso/sso.aspx?sso=&url=http%253a%252f%252fwww.altalex.com%252fsession%252fset%252f%253freturnurl%253dhttp%25253a%25252f%25252fwww.altalex.com%25253a80%25252fdocuments%25252fnews%25252f2016%25252f12%25252f06%25252fquestioni-civilistiche-conseguenti-alla-depenalizzazione HTTP/1.1
-        // i.e. double escaped
-
-        Connection.Response res = Jsoup.connect(url)
-                .proxy("localhost", 8888)
-                .execute();
-        Document doc = res.parse();
-        assertEquals(200, res.statusCode());
-    }
-
-    @Test public void handlesUnicodeInQuery() throws IOException {
-        Document doc = Jsoup.connect("https://www.google.pl/search?q=gąska").get();
-        assertEquals("gąska - Szukaj w Google", doc.title());
-
-        doc = Jsoup.connect("http://mov-world.net/archiv/TV/A/%23No.Title/").get();
-        assertEquals("Index of /archiv/TV/A/%23No.Title", doc.title());
-    }
-
-    @Test public void handlesSuperDeepPage() throws IOException {
-        // https://github.com/jhy/jsoup/issues/955
-
-        long start = System.currentTimeMillis();
-        String url = "http://sv.stargate.wikia.com/wiki/M2J";
-        Document doc = Jsoup.connect(url).get();
-        assertEquals("M2J | Sv.stargate Wiki | FANDOM powered by Wikia", doc.title());
-        assertEquals(110160, doc.select("dd").size());
-        // those are all <dl><dd> stacked in each other. wonder how that got generated?
-        assertTrue(System.currentTimeMillis() - start < 1000);
-    }
-
-    @Test public void handles966() throws IOException {
-        // http://szshb.nxszs.gov.cn/
-        // https://github.com/jhy/jsoup/issues/966
-
-        Document doc = Jsoup.connect("http://szshb.nxszs.gov.cn/").get();
-
-        assertEquals("石嘴山市环境保护局", doc.title());
-    }
-
-    @Test public void canRequestIdn() throws IOException {
-        String url = "https://räksmörgås.josefsson.org/";
-        Document doc = Jsoup.connect(url).get();
-
-        assertEquals("https://xn--rksmrgs-5wao1o.josefsson.org/", doc.location());
-        assertTrue(doc.title().contains("Räksmörgås.josefßon.org"));
-    }
-
-}
diff --git a/src/test/java/org/jsoup/integration/servlets/FileServlet.java b/src/test/java/org/jsoup/integration/servlets/FileServlet.java
index 56ce7eb8..8a8730e1 100644
--- a/src/test/java/org/jsoup/integration/servlets/FileServlet.java
+++ b/src/test/java/org/jsoup/integration/servlets/FileServlet.java
@@ -19,14 +19,17 @@ public class FileServlet extends BaseServlet {
         TlsUrl = urls.tlsUrl;
     }
     public static final String ContentTypeParam = "contentType";
-    public static final String DefaultType = "text/html";
+    public static final String HtmlType = "text/html";
+    static final String XmlType = "text/xml";
     public static final String SuppressContentLength = "surpriseMe";
 
     @Override
     protected void doIt(HttpServletRequest req, HttpServletResponse res) throws IOException {
         String contentType = req.getParameter(ContentTypeParam);
-        if (contentType == null)
-            contentType = DefaultType;
+        if (contentType == null) {
+            contentType = HtmlType;
+            if (req.getPathInfo().contains(".xml")) contentType = XmlType;
+        }
         String location = req.getPathInfo();
 
         File file = ParseTest.getFile(location);
diff --git a/src/test/java/org/jsoup/nodes/BuildEntities.java b/src/test/java/org/jsoup/nodes/BuildEntities.java
index c5eb554a..d5837554 100644
--- a/src/test/java/org/jsoup/nodes/BuildEntities.java
+++ b/src/test/java/org/jsoup/nodes/BuildEntities.java
@@ -4,7 +4,6 @@ import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
 import org.jsoup.Connection;
 import org.jsoup.Jsoup;
-import org.jsoup.integration.UrlConnectTest;
 
 import java.io.File;
 import java.io.FileWriter;
@@ -24,7 +23,6 @@ class BuildEntities {
         String url = "https://www.w3.org/TR/2012/WD-html5-20121025/entities.json";
         Connection.Response res = Jsoup.connect(url)
             .ignoreContentType(true)
-            .userAgent(UrlConnectTest.browserUa)
             .execute();
 
         Gson gson = new Gson();
diff --git a/src/test/java/org/jsoup/nodes/CommentTest.java b/src/test/java/org/jsoup/nodes/CommentTest.java
index c622f184..675304ad 100644
--- a/src/test/java/org/jsoup/nodes/CommentTest.java
+++ b/src/test/java/org/jsoup/nodes/CommentTest.java
@@ -46,7 +46,7 @@ public class CommentTest {
         Document doc = Jsoup.parse(html);
         String out = doc.body().html();
         assertEquals("<div>\n" +
-            " <!-- comment --> Text\n" +
+            " <!-- comment -->\n Text\n" +
             "</div>\n" +
             "<p><!-- comment --> Text</p>", out);
 
diff --git a/src/test/java/org/jsoup/nodes/DataNodeTest.java b/src/test/java/org/jsoup/nodes/DataNodeTest.java
index a7b626de..c8c8934e 100644
--- a/src/test/java/org/jsoup/nodes/DataNodeTest.java
+++ b/src/test/java/org/jsoup/nodes/DataNodeTest.java
@@ -13,7 +13,7 @@ public class DataNodeTest {
         DataNode node = new DataNode("//<![CDATA[\nscript && <> data]]>");
         node.parentNode = new Element("script");
         StringBuilder accum = new StringBuilder();
-        node.outerHtmlHead(accum, 0, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
+        node.outerHtmlHead(accum, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
         assertEquals("//<![CDATA[\nscript && <> data]]>", accum.toString());
     }
 
@@ -22,7 +22,7 @@ public class DataNodeTest {
         DataNode node = new DataNode("script && <> data");
         node.parentNode = new Element("script");
         StringBuilder accum = new StringBuilder();
-        node.outerHtmlHead(accum, 0, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
+        node.outerHtmlHead(accum, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
         assertEquals("//<![CDATA[\nscript && <> data\n//]]>", accum.toString());
     }
 
@@ -31,7 +31,7 @@ public class DataNodeTest {
         DataNode node = new DataNode("/*<![CDATA[*/\nstyle && <> data]]>");
         node.parentNode = new Element("style");
         StringBuilder accum = new StringBuilder();
-        node.outerHtmlHead(accum, 0, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
+        node.outerHtmlHead(accum, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
         assertEquals("/*<![CDATA[*/\nstyle && <> data]]>", accum.toString());
     }
 
@@ -40,7 +40,7 @@ public class DataNodeTest {
         DataNode node = new DataNode("style && <> data");
         node.parentNode = new Element("style");
         StringBuilder accum = new StringBuilder();
-        node.outerHtmlHead(accum, 0, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
+        node.outerHtmlHead(accum, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
         assertEquals("/*<![CDATA[*/\nstyle && <> data\n/*]]>*/", accum.toString());
     }
 
@@ -49,7 +49,7 @@ public class DataNodeTest {
         DataNode node = new DataNode("<![CDATA[other && <> data]]>");
         node.parentNode = new Element("other");
         StringBuilder accum = new StringBuilder();
-        node.outerHtmlHead(accum, 0, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
+        node.outerHtmlHead(accum, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
         assertEquals("<![CDATA[other && <> data]]>", accum.toString());
     }
 
@@ -58,7 +58,7 @@ public class DataNodeTest {
         DataNode node = new DataNode("other && <> data");
         node.parentNode = new Element("other");
         StringBuilder accum = new StringBuilder();
-        node.outerHtmlHead(accum, 0, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
+        node.outerHtmlHead(accum, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
         assertEquals("<![CDATA[other && <> data]]>", accum.toString());
     }
 
@@ -66,7 +66,7 @@ public class DataNodeTest {
     public void xmlOutputOrphanWithoutCData() throws IOException {
         DataNode node = new DataNode("other && <> data");
         StringBuilder accum = new StringBuilder();
-        node.outerHtmlHead(accum, 0, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
+        node.outerHtmlHead(accum, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
         assertEquals("<![CDATA[other && <> data]]>", accum.toString());
     }
 
diff --git a/src/test/java/org/jsoup/nodes/DocumentTest.java b/src/test/java/org/jsoup/nodes/DocumentTest.java
index c8b4751b..d740805a 100644
--- a/src/test/java/org/jsoup/nodes/DocumentTest.java
+++ b/src/test/java/org/jsoup/nodes/DocumentTest.java
@@ -7,6 +7,7 @@ import org.jsoup.nodes.Document.OutputSettings;
 import org.jsoup.nodes.Document.OutputSettings.Syntax;
 import org.jsoup.parser.ParseSettings;
 import org.jsoup.parser.Parser;
+import org.jsoup.parser.Tag;
 import org.jsoup.select.Elements;
 import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
@@ -123,9 +124,11 @@ public class DocumentTest {
     }
 
     @Test void testBasicIndent() {
-        Document doc = Jsoup.parse("<title>Hello</title> <p>One<p>Two");
+        Document doc = Jsoup.parse("<title>Hello</title> <p>One\n<p>Two\n");
         String expect = "<html>\n <head>\n  <title>Hello</title>\n </head>\n <body>\n  <p>One</p>\n  <p>Two</p>\n </body>\n</html>";
-        assertEquals(expect, doc.html());
+        String html = doc.html();
+        System.out.println(html);
+        assertEquals(expect, html);
     }
 
     @Test public void testClonesDeclarations() {
@@ -156,14 +159,16 @@ public class DocumentTest {
 
     @Test public void testHtmlAndXmlSyntax() {
         String h = "<!DOCTYPE html><body><img async checked='checked' src='&<>\"'>&lt;&gt;&amp;&quot;<foo />bar";
-        Document doc = Jsoup.parse(h);
+        Parser parser = Parser.htmlParser();
+        parser.tagSet().valueOf("foo", Parser.NamespaceHtml).set(Tag.SelfClose); // customize foo to allow self close
+        Document doc = Jsoup.parse(h, parser);
 
         doc.outputSettings().syntax(Syntax.html);
         assertEquals("<!doctype html>\n" +
                 "<html>\n" +
                 " <head></head>\n" +
                 " <body>\n" +
-                "  <img async checked src=\"&amp;<>&quot;\">&lt;&gt;&amp;\"<foo />bar\n" +
+                "  <img async checked src=\"&amp;<>&quot;\">&lt;&gt;&amp;\"<foo></foo>bar\n" + // html won't include self-closing
                 " </body>\n" +
                 "</html>", doc.html());
 
@@ -172,7 +177,7 @@ public class DocumentTest {
                 "<html>\n" +
                 " <head></head>\n" +
                 " <body>\n" +
-                "  <img async=\"\" checked=\"checked\" src=\"&amp;&lt;>&quot;\" />&lt;&gt;&amp;\"<foo />bar\n" +
+                "  <img async=\"\" checked=\"checked\" src=\"&amp;&lt;>&quot;\" />&lt;&gt;&amp;\"<foo />bar\n" + // xml will
                 " </body>\n" +
                 "</html>", doc.html());
     }
@@ -347,10 +352,8 @@ public class DocumentTest {
         final Document doc = createXmlDocument("1.0", "changeThis", true);
         doc.charset(Charset.forName(charsetUtf8));
 
-        final String xmlCharsetUTF8 = "<?xml version=\"1.0\" encoding=\"" + charsetUtf8 + "\"?>\n" +
-                                        "<root>\n" +
-                                        " node\n" +
-                                        "</root>";
+        final String xmlCharsetUTF8 = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
+            "<root>node</root>";
         assertEquals(xmlCharsetUTF8, doc.toString());
 
         XmlDeclaration selectedNode = (XmlDeclaration) doc.childNode(0);
@@ -364,10 +367,8 @@ public class DocumentTest {
         final Document doc = createXmlDocument("1.0", "changeThis", true);
         doc.charset(Charset.forName(charsetIso8859));
 
-        final String xmlCharsetISO = "<?xml version=\"1.0\" encoding=\"" + charsetIso8859 + "\"?>\n" +
-                                        "<root>\n" +
-                                        " node\n" +
-                                        "</root>";
+        final String xmlCharsetISO = "<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?>\n" +
+            "<root>node</root>";
         assertEquals(xmlCharsetISO, doc.toString());
 
         XmlDeclaration selectedNode = (XmlDeclaration) doc.childNode(0);
@@ -381,10 +382,8 @@ public class DocumentTest {
         final Document doc = createXmlDocument("1.0", "none", false);
         doc.charset(Charset.forName(charsetUtf8));
 
-        final String xmlCharsetUTF8 = "<?xml version=\"1.0\" encoding=\"" + charsetUtf8 + "\"?>\n" +
-                                        "<root>\n" +
-                                        " node\n" +
-                                        "</root>";
+        final String xmlCharsetUTF8 = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
+            "<root>node</root>";
         assertEquals(xmlCharsetUTF8, doc.toString());
 
         XmlDeclaration selectedNode = (XmlDeclaration) doc.childNode(0);
@@ -395,9 +394,7 @@ public class DocumentTest {
     public void testMetaCharsetUpdateXmlDisabled() {
         final Document doc = createXmlDocument("none", "none", false);
 
-        final String xmlNoCharset = "<root>\n" +
-                                    " node\n" +
-                                    "</root>";
+        final String xmlNoCharset = "<root>node</root>";
         assertEquals(xmlNoCharset, doc.toString());
     }
 
@@ -406,9 +403,7 @@ public class DocumentTest {
         final Document doc = createXmlDocument("dontTouch", "dontTouch", true);
 
         final String xmlCharset = "<?xml version=\"dontTouch\" encoding=\"dontTouch\"?>\n" +
-                                    "<root>\n" +
-                                    " node\n" +
-                                    "</root>";
+            "<root>node</root>";
         assertEquals(xmlCharset, doc.toString());
 
         XmlDeclaration selectedNode = (XmlDeclaration) doc.childNode(0);
diff --git a/src/test/java/org/jsoup/nodes/ElementTest.java b/src/test/java/org/jsoup/nodes/ElementTest.java
index 7648dbd2..f3467974 100644
--- a/src/test/java/org/jsoup/nodes/ElementTest.java
+++ b/src/test/java/org/jsoup/nodes/ElementTest.java
@@ -3,6 +3,7 @@ package org.jsoup.nodes;
 import org.jsoup.Jsoup;
 import org.jsoup.TextUtil;
 import org.jsoup.helper.ValidationException;
+import org.jsoup.internal.StringUtil;
 import org.jsoup.parser.ParseSettings;
 import org.jsoup.parser.Parser;
 import org.jsoup.parser.Tag;
@@ -29,6 +30,7 @@ import java.util.stream.Stream;
 
 import static org.jsoup.nodes.NodeIteratorTest.assertIterates;
 import static org.jsoup.nodes.NodeIteratorTest.trackSeen;
+import static org.jsoup.parser.Parser.NamespaceHtml;
 import static org.jsoup.select.SelectorTest.assertSelectedOwnText;
 import static org.junit.jupiter.api.Assertions.*;
 
@@ -488,6 +490,13 @@ public class ElementTest {
         assertEquals("<p>Hello</p>", doc.getElementsByTag("div").get(0).html());
     }
 
+    @Test void formatNoTrailingSpace() {
+        // https://github.com/jhy/jsoup/issues/2215
+        String html = "<html>\n <head></head>\n <body>\n  a\n  <div>\n  </div>\n </body>\n</html>";
+        Document doc = Jsoup.parse(html);
+        assertEquals("<html>\n <head></head>\n <body>\n  a\n  <div></div>\n </body>\n</html>", doc.html());
+    }
+
     @Test
     public void testFormatHtml() {
         Document doc = Jsoup.parse("<title>Format test</title><div><p>Hello <span>jsoup <span>users</span></span></p><p>Good.</p></div>");
@@ -498,7 +507,7 @@ public class ElementTest {
     public void testFormatOutline() {
         Document doc = Jsoup.parse("<title>Format test</title><div><p>Hello <span>jsoup <span>users</span></span></p><p>Good.</p></div>");
         doc.outputSettings().outline(true);
-        assertEquals("<html>\n <head>\n  <title>Format test</title>\n </head>\n <body>\n  <div>\n   <p>\n    Hello \n    <span>\n     jsoup \n     <span>users</span>\n    </span>\n   </p>\n   <p>Good.</p>\n  </div>\n </body>\n</html>", doc.html());
+        assertEquals("<html>\n <head>\n  <title>Format test</title>\n </head>\n <body>\n  <div>\n   <p>\n    Hello\n    <span>\n     jsoup\n     <span>users</span>\n    </span>\n   </p>\n   <p>Good.</p>\n  </div>\n </body>\n</html>", doc.html());
     }
 
     @Test
@@ -521,23 +530,17 @@ public class ElementTest {
         int defaultMax = 30;
         assertEquals(defaultMax, settings.maxPaddingWidth());
         String html = doc.html();
-        assertTrue(html.contains("                              <div>\n" +
-            "                              Foo\n" +
-            "                              </div>"));
+        assertTrue(html.contains("\n" + StringUtil.padding(defaultMax, -1) + "<div>Foo</div>\n"));
 
         settings.maxPaddingWidth(32);
         assertEquals(32, settings.maxPaddingWidth());
         html = doc.html();
-        assertTrue(html.contains("                                <div>\n" +
-            "                                Foo\n" +
-            "                                </div>"));
+        assertTrue(html.contains("\n" + StringUtil.padding(32, -1) + "<div>Foo</div>\n"));
 
         settings.maxPaddingWidth(-1);
         assertEquals(-1, settings.maxPaddingWidth());
         html = doc.html();
-        assertTrue(html.contains("                                         <div>\n" +
-            "                                          Foo\n" +
-            "                                         </div>"));
+        assertTrue(html.contains("\n" + StringUtil.padding(41, -1) + "<div>Foo</div>\n"));
     }
 
     @Test
@@ -578,16 +581,14 @@ public class ElementTest {
 
     @Test
     public void testBasicFormats() {
-        String html = "<span>0</span>.<div><span>1</span>-<span>2</span><p><span>3</span>-<span>4</span><div>5</div>";
+        String html = "<span>0</span>.<div><span>1</span>-<span>2</span><p> <span>3</span>-<span>4</span><div> 5 </div>";
         Document doc = Jsoup.parse(html);
         assertEquals(
             "<span>0</span>.\n" +
                 "<div>\n" +
                 " <span>1</span>-<span>2</span>\n" +
                 " <p><span>3</span>-<span>4</span></p>\n" +
-                " <div>\n" +
-                "  5\n" +
-                " </div>\n" +
+                " <div>5</div>\n" +
                 "</div>", doc.body().html());
     }
 
@@ -610,7 +611,7 @@ public class ElementTest {
         Document doc = Jsoup.parse("<title>Hello there</title> <div><p>Hello</p><p>there</p></div> <div>Another</div>");
         assertEquals("<title>Hello there</title>", doc.select("title").first().outerHtml());
         assertEquals("<div>\n <p>Hello</p>\n <p>there</p>\n</div>", doc.select("div").first().outerHtml());
-        assertEquals("<div>\n <p>Hello</p>\n <p>there</p>\n</div>\n<div>\n Another\n</div>", doc.select("body").first().html());
+        assertEquals("<div>\n <p>Hello</p>\n <p>there</p>\n</div>\n<div>Another</div>", doc.select("body").first().html());
     }
 
     @Test
@@ -700,7 +701,7 @@ public class ElementTest {
         Element div = doc.getElementById("1");
         div.appendText(" there & now >");
         assertEquals ("Hello there & now >", div.text());
-        assertEquals("<p>Hello</p> there &amp; now &gt;", TextUtil.stripNewlines(div.html()));
+        assertEquals("<p>Hello</p>there &amp; now &gt;", TextUtil.stripNewlines(div.html()));
     }
 
     @Test
@@ -709,7 +710,7 @@ public class ElementTest {
         Element div = doc.getElementById("1");
         div.prependText("there & now > ");
         assertEquals("there & now > Hello", div.text());
-        assertEquals("there &amp; now &gt; <p>Hello</p>", TextUtil.stripNewlines(div.html()));
+        assertEquals("there &amp; now &gt;\n<p>Hello</p>", div.html());
     }
 
     @Test
@@ -835,10 +836,11 @@ public class ElementTest {
     public void testWrapArtificialStructure() {
         // div normally couldn't get into a p, but explicitly want to wrap
         Document doc = Jsoup.parse("<p>Hello <i>there</i> now.");
-        Element i = doc.selectFirst("i");
+        Element i = doc.expectFirst("i");
         i.wrap("<div id=id1></div> quite");
         assertEquals("div", i.parent().tagName());
-        assertEquals("<p>Hello <div id=\"id1\"><i>there</i></div> quite now.</p>", TextUtil.stripNewlines(doc.body().html()));
+        assertEquals("<p>Hello\n <div id=\"id1\">\n  <i>there</i>\n </div>\n quite now.</p>",(doc.body().html()));
+        // gives us a TextNode seq of "quite" and " now"; make sure not to collapse to "quitenow" when pretty print.
     }
 
     @Test
@@ -886,7 +888,7 @@ public class ElementTest {
         assertSame(div, p.parent());
         assertSame(body, div.parent());
 
-        assertEquals("<div><p>Hello</p></div> There", TextUtil.stripNewlines(doc.body().html()));
+        assertEquals("<div><p>Hello</p></div>There", TextUtil.stripNewlines(doc.body().html()));
     }
 
     @Test
@@ -948,7 +950,7 @@ public class ElementTest {
     @Test
     public void orphanDivToString() {
         Element orphan = new Element("div").id("foo").text("Hello");
-        assertEquals("<div id=\"foo\">\n Hello\n</div>", orphan.toString());
+        assertEquals("<div id=\"foo\">Hello</div>", orphan.toString());
     }
 
     @Test
@@ -1042,6 +1044,13 @@ public class ElementTest {
         assertEquals("<em>Hello</em>", doc.select("div").first().html());
     }
 
+    @Test void testSetTag() {
+        Document doc = Jsoup.parse("<div><em>Hello</em></div>");
+        Element el = doc.expectFirst("em");
+        el.tag(new Tag("I", NamespaceHtml));
+        assertEquals("<I>Hello</I>", el.outerHtml()); // case-sensitive path
+    }
+
     @Test
     public void testHtmlContainsOuter() {
         Document doc = Jsoup.parse("<title>Check</title> <div>Hello there</div>");
@@ -1140,8 +1149,7 @@ public class ElementTest {
         assertEquals(4, children.size()); // children is NOT backed by div1.childNodes but a wrapper, so should still be 4 (but re-parented)
         assertEquals(0, div1.childNodeSize());
         assertEquals(4, div2.childNodeSize());
-        assertEquals("<div id=\"1\"></div>\n<div id=\"2\">\n Text \n <p>One</p> Text \n <p>Two</p>\n</div>",
-            doc.body().html());
+        assertEquals("<div id=\"1\"></div>\n<div id=\"2\">\n Text\n <p>One</p>\n Text\n <p>Two</p>\n</div>", doc.body().html());
     }
 
     @Test
@@ -1211,8 +1219,10 @@ public class ElementTest {
 
         assertEquals(4, div1.childNodeSize()); // not moved -- cloned
         assertEquals(2, div2.childNodeSize());
-        assertEquals("<div id=\"1\">Text <p>One</p> Text <p>Two</p></div><div id=\"2\"><p>One cloned</p><p>Two</p></div>",
-            TextUtil.stripNewlines(doc.body().html()));
+        assertEquals(
+            "<div id=\"1\">\n Text\n <p>One</p>\n Text\n <p>Two</p>\n</div>\n<div id=\"2\">\n <p>One cloned</p>\n <p>Two</p>\n</div>",
+            doc.body().html()
+        );
     }
 
     @Test
@@ -1597,7 +1607,7 @@ public class ElementTest {
         assertEquals("<p><a>One</a></p>\n" +
             "<p>P3</p>\n" +
             "<p><a>Two</a></p>\n" +
-            "<p>P4</p>Three", div.html());
+            "<p>P4</p>\nThree", div.html());
         assertEquals("P3", els2.get(1).text());
         assertEquals("P4", els2.get(3).text());
 
@@ -1609,9 +1619,11 @@ public class ElementTest {
         assertEquals("Another", els3.get(2).text());
 
         assertEquals("<p><a>One</a></p>\n" +
-            "<p>P3</p><span>Another</span>\n" +
+            "<p>P3</p>\n" +
+            "<span>Another</span>\n" +
             "<p><a>Two</a></p>\n" +
-            "<p>P4</p>Three", div.html());
+            "<p>P4</p>\n" +
+            "Three", div.html());
     }
 
     @Test
@@ -2355,7 +2367,7 @@ public class ElementTest {
         // https://github.com/jhy/jsoup/issues/1858
         String html = "<p>Hello<br>there<br>now.</p>";
         Document doc = Jsoup.parse(html);
-        assertEquals("<p>Hello<br>\n there<br>\n now.</p>", doc.body().html());
+        assertEquals("<p>Hello\n <br>\n there\n <br>\n now.</p>", doc.body().html());
     }
 
     @Test void prettyprintBrInBlock() {
@@ -2368,11 +2380,8 @@ public class ElementTest {
         // https://github.com/jhy/jsoup/issues/1911
         String h = "<div><p><br>Foo</p><br></div>";
         Document doc = Jsoup.parse(h);
-        assertEquals("<div>\n" +
-            " <p><br>\n  Foo</p>\n" +
-            " <br>\n" +
-            "</div>", doc.body().html());
-        // br gets wrapped if in div, but not in p (block vs inline), but always wraps after
+        assertEquals("<div>\n <p>\n  <br>\n  Foo\n </p>\n <br>\n</div>", doc.body().html());
+        // br gets wrapped
     }
 
     @Test void preformatFlowsToChildTextNodes() {
@@ -2508,9 +2517,12 @@ public class ElementTest {
     }
 
     @Test void textnodeInBlockIndent() {
-        String html ="<div>\n{{ msg }} \n </div>\n<div>\n{{ msg }} \n </div>";
+        String html ="<div>\nmsg \n </div>\n<div>\nmsg \n </div><div><div>msg</div></div><div>msg<p>msg</p></div>";
         Document doc = Jsoup.parse(html);
-        assertEquals("<div>\n {{ msg }}\n</div>\n<div>\n {{ msg }}\n</div>", doc.body().html());
+        assertEquals(
+            "<div>msg</div>\n<div>msg</div>\n<div>\n <div>msg</div>\n</div>\n<div>\n msg\n <p>msg</p>\n</div>",
+            doc.body().html()
+        );
     }
 
     @Test void stripTrailing() {
@@ -2523,7 +2535,8 @@ public class ElementTest {
         String html = "<body><div> <p> One Two </p> <a>  Hello </a><p>\nSome text \n</p>\n </div>";
         Document doc = Jsoup.parse(html);
         assertEquals("<div>\n" +
-            " <p>One Two</p><a> Hello </a>\n" +
+            " <p>One Two</p>\n" +
+            " <a> Hello </a>\n" +
             " <p>Some text</p>\n" +
             "</div>", doc.body().html());
     }
@@ -2542,16 +2555,16 @@ public class ElementTest {
         Document doc = Jsoup.parse(html);
         assertEquals("<bar>\n <p></p>\n</bar>", doc.body().html());
 
-        html = "<foo>\n  <bar />\n</foo>";
+        html = "<foo>\n  <bar /></foo>";
         doc = Jsoup.parse(html);
-        assertEquals("<foo>\n <bar />\n</foo>", doc.body().html());
+        assertEquals("<foo>\n <bar></bar>\n</foo>", doc.body().html());
     }
 
     @Test void spanInBlockTrims() {
         String html = "<p>Lorem ipsum</p>\n<span>Thanks</span>";
         Document doc = Jsoup.parse(html);
         String outHtml = doc.body().html();
-        assertEquals("<p>Lorem ipsum</p><span>Thanks</span>", outHtml);
+        assertEquals("<p>Lorem ipsum</p>\n<span>Thanks</span>", outHtml);
     }
 
     @Test void replaceWithSelf() {
@@ -2845,7 +2858,8 @@ public class ElementTest {
                 "//]]></script>\n" +
                 "<style>/*<![CDATA[*/\n" +
                 "3 && 4\n" +
-                "/*]]>*/</style> 5 &amp;&amp; 6",
+                "/*]]>*/</style>\n" +
+                "5 &amp;&amp; 6",
             xml);
 
         Document xmlDoc = Jsoup.parse(xml, Parser.xmlParser());
@@ -2877,7 +2891,8 @@ public class ElementTest {
                 "//]]></script>\n" +
                 "<style>\n" +
                 "/*<![CDATA[*/3 && 4\n" +
-                "/*]]>*/</style> 5 &amp;&amp; 6",
+                "/*]]>*/</style>\n" +
+                "5 &amp;&amp; 6",
             xml);
 
         Document xmlDoc = Jsoup.parse(xml, Parser.xmlParser());
@@ -2894,10 +2909,10 @@ public class ElementTest {
         Document doc = Jsoup.parse("<div>One</div>");
         StringBuffer buffer = new StringBuffer();
         doc.body().outerHtml(buffer);
-        assertEquals("\n<body>\n <div>\n  One\n </div>\n</body>", buffer.toString());
+        assertEquals("<body>\n <div>One</div>\n</body>", buffer.toString());
         StringBuilder builder = new StringBuilder();
         doc.body().outerHtml(builder);
-        assertEquals("<body>\n <div>\n  One\n </div>\n</body>", builder.toString());
+        assertEquals("<body>\n <div>One</div>\n</body>", builder.toString());
     }
 
     @Test void rubyInline() {
@@ -2923,11 +2938,11 @@ public class ElementTest {
             "  <tr>\n" +
             "   <td>\n" +
             "    <p style=\"display:inline;\">A</p>\n" +
-            "    <p style=\"display:inline;\">B</p></td>\n" +
+            "    <p style=\"display:inline;\">B</p>\n" +
+            "   </td>\n" +
             "  </tr>\n" +
             " </tbody>\n" +
             "</table>", out);
-        // todo - I would prefer the </td> to wrap down there - but need to reimplement pretty printer to simplify and track indented state
     }
 
     @Test void emptyDetachesChildren() {
diff --git a/src/test/java/org/jsoup/nodes/NodeTest.java b/src/test/java/org/jsoup/nodes/NodeTest.java
index 727e6871..ae665774 100644
--- a/src/test/java/org/jsoup/nodes/NodeTest.java
+++ b/src/test/java/org/jsoup/nodes/NodeTest.java
@@ -7,7 +7,6 @@ import org.jsoup.select.Elements;
 import org.jsoup.select.NodeVisitor;
 import org.junit.jupiter.api.Test;
 
-import java.util.Collections;
 import java.util.List;
 
 import static org.jsoup.parser.Parser.*;
@@ -364,8 +363,7 @@ public class NodeTest {
         tn2.text("Text 1 updated");
         assertEquals("Text 1 ", tn1.text());
         div2.insertChildren(-1, divChildren);
-        assertEquals("<div id=\"1\">Text 1 <p>One</p> Text 2 <p>Two</p><p>Three</p></div><div id=\"2\">Text 1 updated"
-            +"<p>One</p> Text 2 <p>Two</p><p>Three</p></div>", TextUtil.stripNewlines(doc.body().html()));
+        assertEquals("<div id=\"1\">Text 1<p>One</p>Text 2<p>Two</p><p>Three</p></div><div id=\"2\">Text 1 updated<p>One</p>Text 2<p>Two</p><p>Three</p></div>", TextUtil.stripNewlines(doc.body().html()));
     }
 
     @Test public void supportsClone() {
diff --git a/src/test/java/org/jsoup/nodes/PrinterTest.java b/src/test/java/org/jsoup/nodes/PrinterTest.java
new file mode 100644
index 00000000..16d8cf5a
--- /dev/null
+++ b/src/test/java/org/jsoup/nodes/PrinterTest.java
@@ -0,0 +1,74 @@
+package org.jsoup.nodes;
+
+import org.jsoup.Jsoup;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.io.IOException;
+
+import static org.jsoup.integration.ParseTest.getFile;
+import static org.jsoup.integration.ParseTest.getFileAsString;
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ Test for the HTML / XML serialization of elements, including the Pretty Printer, Outline mode, and the base.
+ */
+public class PrinterTest {
+    @Test void pretty() throws IOException {
+        // parse /printertests/input-1.html, check formatted same as pretty-1.html
+        File in = getFile("/printertests/input-1.html");
+        Document doc = Jsoup.parse(in);
+
+        String expected = getFileAsString(getFile("/printertests/pretty-1.html"));
+        String html = doc.html();
+        assertEquals(expected, html);
+        assertEquals(html, doc.outerHtml());
+    }
+
+    @Test void passthru() throws IOException {
+        // disable pretty, should be almost 1:1 of input (other than a couple parse normalizations; doctype, pre)
+        File in = getFile("/printertests/input-1.html");
+        Document doc = Jsoup.parse(in);
+        doc.outputSettings().prettyPrint(false);
+
+        String expected = getFileAsString(getFile("/printertests/passthru-1.html"));
+        String html = doc.html();
+        assertEquals(expected, html);
+        assertEquals(html, doc.outerHtml());
+    }
+
+    @Test void outline() throws IOException {
+        // outline mode, most everything gets indented
+        File in = getFile("/printertests/input-1.html");
+        Document doc = Jsoup.parse(in);
+        doc.outputSettings().outline(true);
+
+        String expected = getFileAsString(getFile("/printertests/outline-1.html"));
+        String html = doc.html();
+        assertEquals(expected, html);
+        assertEquals(html, doc.outerHtml());
+    }
+
+    @Test void sequentialTextNodesDontCollapse() {
+        // tests that the pretty printer does not collapse (trim leading | trailing whitespace) when there are
+        // sequential textnodes. That doesn't happen in a parse, but can when manipulated.
+        Document doc = Jsoup.parse("<div><div></div>Hello</div>"); // needs to be text that would indent
+        Element div = doc.expectFirst("div");
+        TextNode hello = (TextNode) div.childNode(1);
+        hello.after(" there.");
+        assertEquals("Hello", hello.getWholeText());
+        assertEquals(" there.", ((TextNode) hello.nextSibling()).getWholeText());
+
+        assertEquals("Hello there.", div.text());
+        assertEquals("<div></div>\nHello there.", div.html());
+        assertEquals("<div>\n <div></div>\n Hello there.\n</div>", div.outerHtml());
+    }
+
+    @Test void dontCollapseTextAfterNonElements() {
+        Document doc = Jsoup.parse("<div><div></div>Hello <!-- -_- --> there</div>");
+        Element body = doc.body();
+        assertEquals("Hello there", body.text());
+        assertEquals("<div>\n <div></div>\n Hello <!-- -_- -->\n  there\n</div>", body.html());
+    }
+
+}
diff --git a/src/test/java/org/jsoup/parser/HtmlParserTest.java b/src/test/java/org/jsoup/parser/HtmlParserTest.java
index f769c511..f6402b0c 100644
--- a/src/test/java/org/jsoup/parser/HtmlParserTest.java
+++ b/src/test/java/org/jsoup/parser/HtmlParserTest.java
@@ -224,7 +224,7 @@ public class HtmlParserTest {
     @Test public void handlesTextAfterData() {
         String h = "<html><body>pre <script>inner</script> aft</body></html>";
         Document doc = Jsoup.parse(h);
-        assertEquals("<html><head></head><body>pre <script>inner</script> aft</body></html>", TextUtil.stripNewlines(doc.html()));
+        assertEquals("<html><head></head><body>pre<script>inner</script>aft</body></html>", TextUtil.stripNewlines(doc.html()));
     }
 
     @Test public void handlesTextArea() {
@@ -238,7 +238,7 @@ public class HtmlParserTest {
         // preserve because the tag is marked as preserve white space
         Document doc = Jsoup.parse("<textarea>\n\tOne\n\tTwo\n\tThree\n</textarea>");
         String expect = "One\n\tTwo\n\tThree"; // the leading and trailing spaces are dropped as a convenience to authors
-        Element el = doc.select("textarea").first();
+        Element el = doc.expectFirst("textarea");
         assertEquals(expect, el.text());
         assertEquals(expect, el.val());
         assertEquals(expect, el.html());
@@ -361,7 +361,7 @@ public class HtmlParserTest {
         assertEquals(0, div.children().size());
         assertEquals(1, div.childNodeSize()); // no elements, one text node
 
-        assertEquals("<div id=\"1\"><![CDATA[\n<html>\n <foo><&amp;]]>\n</div>", div.outerHtml());
+        assertEquals("<div id=\"1\"><![CDATA[\n<html>\n <foo><&amp;]]></div>", div.outerHtml());
 
         CDataNode cdata = (CDataNode) div.textNodes().get(0);
         assertEquals("\n<html>\n <foo><&amp;", cdata.text());
@@ -472,40 +472,51 @@ public class HtmlParserTest {
     }
 
     @Test public void handlesUnknownNamespaceTags() {
-        // note that the first foo:bar should not really be allowed to be self closing, if parsed in html mode.
         String h = "<foo:bar id='1' /><abc:def id=2>Foo<p>Hello</p></abc:def><foo:bar>There</foo:bar>";
-        Document doc = Jsoup.parse(h);
-        assertEquals("<foo:bar id=\"1\" /><abc:def id=\"2\">Foo<p>Hello</p></abc:def><foo:bar>There</foo:bar>", TextUtil.stripNewlines(doc.body().html()));
+        Parser parser = Parser.htmlParser();
+        parser.tagSet().valueOf("foo:bar", Parser.NamespaceHtml).set(Tag.SelfClose);
+        Document doc = Jsoup.parse(h, parser);
+        assertEquals("<foo:bar id=\"1\"></foo:bar><abc:def id=\"2\">Foo<p>Hello</p></abc:def><foo:bar>There</foo:bar>", TextUtil.stripNewlines(doc.body().html()));
     }
 
+    // we used to allow self-closing for any tag in html, but spec no longer allows
     @Test public void handlesKnownEmptyBlocks() {
-        // if a known tag, allow self closing outside of spec, but force an end tag. unknown tags can be self closing.
-        String h = "<div id='1' /><script src='/foo' /><div id=2><img /><img></div><a id=3 /><i /><foo /><foo>One</foo> <hr /> hr text <hr> hr text two";
+        // by default, self-closing flag has no impact (see detailed tests for self-closing below)
+        String h = "<div id='1' /><script src='/foo'></script><div id=2><img /><img></div><a id=3 /><i /><foo /><foo>One</foo> <hr /> hr text <hr> hr text two";
         Document doc = Jsoup.parse(h);
-        assertEquals("<div id=\"1\"></div><script src=\"/foo\"></script><div id=\"2\"><img><img></div><a id=\"3\"></a><i></i><foo /><foo>One</foo><hr> hr text <hr> hr text two", TextUtil.stripNewlines(doc.body().html()));
+        assertEquals("<div id=\"1\"><script src=\"/foo\"></script><div id=\"2\"><img><img></div><a id=\"3\"><i><foo><foo>One</foo><hr>hr text<hr>hr text two</foo></i></a></div>", TextUtil.stripNewlines(doc.body().html()));
     }
 
-    @Test public void handlesKnownEmptyNoFrames() {
+    @Test public void handlesEmptyNoFrames() {
+        // can modify parser to allow self closing
         String h = "<html><head><noframes /><meta name=foo></head><body>One</body></html>";
-        Document doc = Jsoup.parse(h);
+        Parser parser = Parser.htmlParser();
+        parser.tagSet().valueOf("noframes", Parser.NamespaceHtml).set(Tag.SelfClose);
+        Document doc = Jsoup.parse(h, parser);
         assertEquals("<html><head><noframes></noframes><meta name=\"foo\"></head><body>One</body></html>", TextUtil.stripNewlines(doc.html()));
     }
 
     @Test public void handlesKnownEmptyStyle() {
         String h = "<html><head><style /><meta name=foo></head><body>One</body></html>";
-        Document doc = Jsoup.parse(h);
+        Parser parser = Parser.htmlParser();
+        parser.tagSet().valueOf("style", Parser.NamespaceHtml).set(Tag.SelfClose);
+        Document doc = Jsoup.parse(h, parser);
         assertEquals("<html><head><style></style><meta name=\"foo\"></head><body>One</body></html>", TextUtil.stripNewlines(doc.html()));
     }
 
     @Test public void handlesKnownEmptyTitle() {
         String h = "<html><head><title /><meta name=foo></head><body>One</body></html>";
-        Document doc = Jsoup.parse(h);
+        Parser parser = Parser.htmlParser();
+        parser.tagSet().valueOf("title", Parser.NamespaceHtml).set(Tag.SelfClose);
+        Document doc = Jsoup.parse(h, parser);
         assertEquals("<html><head><title></title><meta name=\"foo\"></head><body>One</body></html>", TextUtil.stripNewlines(doc.html()));
     }
 
     @Test public void handlesKnownEmptyIframe() {
         String h = "<p>One</p><iframe id=1 /><p>Two";
-        Document doc = Jsoup.parse(h);
+        Parser parser = Parser.htmlParser();
+        parser.tagSet().valueOf("iframe", Parser.NamespaceHtml).set(Tag.SelfClose);
+        Document doc = Jsoup.parse(h, parser);
         assertEquals("<html><head></head><body><p>One</p><iframe id=\"1\"></iframe><p>Two</p></body></html>", TextUtil.stripNewlines(doc.html()));
     }
 
@@ -609,7 +620,7 @@ public class HtmlParserTest {
     @Test public void testHgroup() {
         // jsoup used to not allow hgroup in h{n}, but that's not in spec, and browsers are OK
         Document doc = Jsoup.parse("<h1>Hello <h2>There <hgroup><h1>Another<h2>headline</hgroup> <hgroup><h1>More</h1><p>stuff</p></hgroup>");
-        assertEquals("<h1>Hello</h1><h2>There <hgroup><h1>Another</h1><h2>headline</h2></hgroup><hgroup><h1>More</h1><p>stuff</p></hgroup></h2>", TextUtil.stripNewlines(doc.body().html()));
+        assertEquals("<h1>Hello</h1><h2>There<hgroup><h1>Another</h1><h2>headline</h2></hgroup><hgroup><h1>More</h1><p>stuff</p></hgroup></h2>", TextUtil.stripNewlines(doc.body().html()));
     }
 
     @Test public void testRelaxedTags() {
@@ -621,13 +632,13 @@ public class HtmlParserTest {
         // h* tags (h1 .. h9) in browsers can handle any internal content other than other h*. which is not per any
         // spec, which defines them as containing phrasing content only. so, reality over theory.
         Document doc = Jsoup.parse("<h1>Hello <div>There</div> now</h1> <h2>More <h3>Content</h3></h2>");
-        assertEquals("<h1>Hello <div>There</div> now</h1><h2>More</h2><h3>Content</h3>", TextUtil.stripNewlines(doc.body().html()));
+        assertEquals("<h1>Hello<div>There</div>now</h1><h2>More</h2><h3>Content</h3>", TextUtil.stripNewlines(doc.body().html()));
     }
 
     @Test public void testSpanContents() {
         // like h1 tags, the spec says SPAN is phrasing only, but browsers and publisher treat span as a block tag
         Document doc = Jsoup.parse("<span>Hello <div>there</div> <span>now</span></span>");
-        assertEquals("<span>Hello <div>there</div><span>now</span></span>", TextUtil.stripNewlines(doc.body().html()));
+        assertEquals("<span>Hello <div>there</div> <span>now</span></span>", TextUtil.stripNewlines(doc.body().html()));
     }
 
     @Test public void testNoImagesInNoScriptInHead() {
@@ -648,13 +659,13 @@ public class HtmlParserTest {
     @Test public void testAFlowContents() {
         // html5 has <a> as either phrasing or block
         Document doc = Jsoup.parse("<a>Hello <div>there</div> <span>now</span></a>");
-        assertEquals("<a>Hello <div>there</div><span>now</span></a>", TextUtil.stripNewlines(doc.body().html()));
+        assertEquals("<a>Hello \n <div>there</div> \n <span>now</span></a>", (doc.body().html()));
     }
 
     @Test public void testFontFlowContents() {
         // html5 has no definition of <font>; often used as flow
         Document doc = Jsoup.parse("<font>Hello <div>there</div> <span>now</span></font>");
-        assertEquals("<font>Hello <div>there</div><span>now</span></font>", TextUtil.stripNewlines(doc.body().html()));
+        assertEquals("<font>Hello <div>there</div> <span>now</span></font>", TextUtil.stripNewlines(doc.body().html()));
     }
 
     @Test public void handlesMisnestedTagsBI() {
@@ -732,7 +743,7 @@ public class HtmlParserTest {
         // and the <i> inside the table and does not leak out.
         String h = "<p><b>One</p> <table><tr><td><p><i>Three<p>Four</i></td></tr></table> <p>Five</p>";
         Document doc = Jsoup.parse(h);
-        String want = "<p><b>One</b></p><b><table><tbody><tr><td><p><i>Three</i></p><p><i>Four</i></p></td></tr></tbody></table><p>Five</p></b>";
+        String want = "<p><b>One</b></p><b> <table><tbody><tr><td><p><i>Three</i></p><p><i>Four</i></p></td></tr></tbody></table> <p>Five</p></b>";
         assertEquals(want, TextUtil.stripNewlines(doc.body().html()));
     }
 
@@ -856,16 +867,17 @@ public class HtmlParserTest {
         Document doc = Jsoup.parse(html, "http://example.com", parser);
 
         List<ParseError> errors = parser.getErrors();
-        assertEquals(9, errors.size());
+        assertEquals(10, errors.size());
         assertEquals("<1:21>: Attributes incorrectly present on end tag [/p]", errors.get(0).toString());
         assertEquals("<2:16>: Unexpected Doctype token [<!doctype html>] when in state [InBody]", errors.get(1).toString());
         assertEquals("<3:2>: Invalid character reference: invalid named reference [arrgh]", errors.get(2).toString());
-        assertEquals("<3:16>: Tag [font] cannot be self closing; not a void tag", errors.get(3).toString());
+        assertEquals("<3:16>: Tag [font] cannot be self-closing; not a void tag", errors.get(3).toString());
         assertEquals("<3:20>: Invalid character reference: missing semicolon on [&#33]", errors.get(4).toString());
         assertEquals("<3:25>: Invalid character reference: missing semicolon on [&amp]", errors.get(5).toString());
         assertEquals("<3:36>: Invalid character reference: character [1114112] outside of valid range", errors.get(6).toString());
         assertEquals("<3:48>: Unexpected EndTag token [</div>] when in state [InBody]", errors.get(7).toString());
         assertEquals("<3:53>: Unexpectedly reached end of file (EOF) in input state [TagName]", errors.get(8).toString());
+        assertEquals("<3:53>: Unexpected EOF token [] when in state [InBody]", errors.get(9).toString());
     }
 
     @Test public void tracksLimitedErrorsWhenRequested() {
@@ -960,7 +972,7 @@ public class HtmlParserTest {
     @Test public void handlesXmlDeclarationAsBogusComment() {
         String html = "<?xml encoding='UTF-8' ?><body>One</body>";
         Document doc = Jsoup.parse(html);
-        assertEquals("<!--?xml encoding='UTF-8' ?--> <html> <head></head> <body> One </body> </html>", StringUtil.normaliseWhitespace(doc.outerHtml()));
+        assertEquals("<!--?xml encoding='UTF-8' ?--> <html> <head></head> <body>One</body> </html>", StringUtil.normaliseWhitespace(doc.outerHtml()));
     }
 
     @Test public void handlesTagsInTextarea() {
@@ -1110,16 +1122,14 @@ public class HtmlParserTest {
 
         Document body = Jsoup.parseBodyFragment(html);
         assertEquals("<script type=\"text/javascript\">console.log('foo');</script>\n" +
-            "<div id=\"somecontent\">\n" +
-            " some content\n" +
-            "</div>\n" +
+            "<div id=\"somecontent\">some content</div>\n" +
             "<script type=\"text/javascript\">console.log('bar');</script>", body.body().html());
     }
 
     @Test public void testHtmlLowerCase() {
         String html = "<!doctype HTML><DIV ID=1>One</DIV>";
         Document doc = Jsoup.parse(html);
-        assertEquals("<!doctype html> <html> <head></head> <body> <div id=\"1\"> One </div> </body> </html>", StringUtil.normaliseWhitespace(doc.outerHtml()));
+        assertEquals("<!doctype html> <html> <head></head> <body> <div id=\"1\">One</div> </body> </html>", StringUtil.normaliseWhitespace(doc.outerHtml()));
 
         Element div = doc.selectFirst("#1");
         div.after("<TaG>One</TaG>");
@@ -1181,14 +1191,14 @@ public class HtmlParserTest {
         Parser parser = Parser.htmlParser();
         parser.settings(preserveCase);
         Document doc = parser.parseInput(html, "");
-        assertEquals("<r> <X> A </X> <y> B </y> </r>", StringUtil.normaliseWhitespace(doc.body().html()));
+        assertEquals("<r>\n <X>A</X><y>B</y>\n</r>", doc.body().html());
     }
 
     @Test public void caseInsensitiveParseTree() {
         String html = "<r><X>A</X><y>B</y></r>";
         Parser parser = Parser.htmlParser();
         Document doc = parser.parseInput(html, "");
-        assertEquals("<r> <x> A </x> <y> B </y> </r>", StringUtil.normaliseWhitespace(doc.body().html()));
+        assertEquals("<r>\n <x>A</x><y>B</y>\n</r>", doc.body().html());
     }
 
     @Test public void preservedCaseLinksCantNest() {
@@ -1196,13 +1206,12 @@ public class HtmlParserTest {
         Document doc = Parser.htmlParser()
             .settings(preserveCase)
             .parseInput(html, "");
-        //assertEquals("<A>ONE </A><A>Two</A>", StringUtil.normaliseWhitespace(doc.body().html()));
         assertEquals("<A>ONE </A><A>Two</A>", doc.body().html());
     }
 
     @Test public void normalizesDiscordantTags() {
         Document document = Jsoup.parse("<div>test</DIV><p></p>");
-        assertEquals("<div>\n test\n</div>\n<p></p>", document.body().html());
+        assertEquals("<div>test</div>\n<p></p>", document.body().html());
     }
 
     @Test public void selfClosingVoidIsNotAnError() {
@@ -1213,19 +1222,18 @@ public class HtmlParserTest {
 
         assertTrue(Jsoup.isValid(html, Safelist.basic()));
         String clean = Jsoup.clean(html, Safelist.basic());
-        assertEquals("<p>test<br>\n test<br></p>", clean);
+        assertEquals("<p>test\n <br>\n test\n <br></p>", clean);
     }
 
     @Test public void selfClosingOnNonvoidIsError() {
         String html = "<p>test</p>\n\n<div /><div>Two</div>";
         Parser parser = Parser.htmlParser().setTrackErrors(5);
         parser.parseInput(html, "");
-        assertEquals(1, parser.getErrors().size());
-        assertEquals("<3:8>: Tag [div] cannot be self closing; not a void tag", parser.getErrors().get(0).toString());
+        assertErrorsContain("<3:8>: Tag [div] cannot be self-closing; not a void tag", parser.getErrors());
 
         assertFalse(Jsoup.isValid(html, Safelist.relaxed()));
         String clean = Jsoup.clean(html, Safelist.relaxed());
-        assertEquals("<p>test</p> <div></div> <div> Two </div>", StringUtil.normaliseWhitespace(clean));
+        assertEquals("<p>test</p> <div> <div>Two</div> </div>", StringUtil.normaliseWhitespace(clean)); // did not close
     }
 
     @Test public void testTemplateInsideTable() throws IOException {
@@ -1268,8 +1276,23 @@ public class HtmlParserTest {
         File in = ParseTest.getFile("/htmltests/comments.html");
         Document doc = Jsoup.parse(in, "UTF-8");
 
-        assertEquals("<!--?xml version=\"1.0\" encoding=\"utf-8\"?--><!-- so --> <!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\"><!-- what --> <html xml:lang=\"en\" lang=\"en\" xmlns=\"http://www.w3.org/1999/xhtml\"> <!-- now --> <head> <!-- then --> <meta http-equiv=\"Content-type\" content=\"text/html; charset=utf-8\"> <title>A Certain Kind of Test</title> </head> <body> <h1>Hello</h1>h1&gt; (There is a UTF8 hidden BOM at the top of this file.) </body> </html>",
-            StringUtil.normaliseWhitespace(doc.html()));
+        // split out to confirm comment nodes indent correct
+        assertEquals("<!--?xml version=\"1.0\" encoding=\"utf-8\"?-->\n" +
+            "<!-- so -->\n" +
+            "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">\n" +
+            "<!-- what -->\n" +
+            "<html xml:lang=\"en\" lang=\"en\" xmlns=\"http://www.w3.org/1999/xhtml\">\n" +
+            " <!-- now -->\n" +
+            " <head>\n" +
+            "  <!-- then -->\n" +
+            "  <meta http-equiv=\"Content-type\" content=\"text/html; charset=utf-8\">\n" +
+            "  <title>A Certain Kind of Test</title>\n" +
+            " </head>\n" +
+            " <body>\n" +
+            "  <h1>Hello</h1>\n" +
+            "  (There is a UTF8 hidden BOM at the top of this file.)\n" +
+            " </body>\n" +
+            "</html>", doc.html());
 
         assertEquals("A Certain Kind of Test", doc.head().select("title").text());
     }
@@ -1302,7 +1325,11 @@ public class HtmlParserTest {
     @Test
     public void selfClosingTextAreaDoesntLeaveDroppings() {
         // https://github.com/jhy/jsoup/issues/1220
-        Document doc = Jsoup.parse("<div><div><textarea/></div></div>");
+        // must be configured to allow self closing
+        String html = "<div><div><textarea/></div></div>";
+        Parser parser = Parser.htmlParser();
+        parser.tagSet().valueOf("textarea", Parser.NamespaceHtml).set(Tag.SelfClose);
+        Document doc = Jsoup.parse(html, parser);
         assertFalse(doc.body().html().contains("&lt;"));
         assertFalse(doc.body().html().contains("&gt;"));
         assertEquals("<div><div><textarea></textarea></div></div>", TextUtil.stripNewlines(doc.body().html()));
@@ -1444,7 +1471,7 @@ public class HtmlParserTest {
         assertEquals("\tThree\tFour", span.wholeText()); // text normalizes, wholeText retains original spaces incl tabs
         assertEquals("One\tTwo Three Four", doc.body().text());
 
-        assertEquals("<pre>One\tTwo</pre><span> Three Four</span>", doc.body().html()); // html output provides normalized space, incl tab in pre but not in span
+        assertEquals("<pre>One\tTwo</pre>\n<span> Three Four</span>", doc.body().html()); // html output provides normalized space, incl tab in pre but not in span
 
         doc.outputSettings().prettyPrint(false);
         assertEquals(html, doc.body().html()); // disabling pretty-printing - round-trips the tab throughout, as no normalization occurs
@@ -1491,7 +1518,7 @@ public class HtmlParserTest {
         assertEquals(1, nodes.size());
         Node node = nodes.get(0);
         assertEquals("h2", node.nodeName());
-        assertEquals("<p>\n <h2>text</h2></p>", node.parent().outerHtml());
+        assertEquals("<p>\n <h2>text</h2>\n</p>", node.parent().outerHtml());
     }
 
     @Test public void nestedPFragments() {
@@ -1500,7 +1527,7 @@ public class HtmlParserTest {
         List<Node> nodes = new Document("").parser().parseFragmentInput(bareFragment, new Element("p"), "");
         assertEquals(2, nodes.size());
         Node node = nodes.get(0);
-        assertEquals("<p>\n <p></p><a></a></p>", node.parent().outerHtml()); // mis-nested because fragment forced into the element, OK
+        assertEquals("<p><p></p><a></a></p>", TextUtil.stripNewlines(node.parent().outerHtml())); // mis-nested because fragment forced into the element, OK
     }
 
     @Test public void nestedAnchorAdoption() {
@@ -1508,7 +1535,7 @@ public class HtmlParserTest {
         String html = "<a>\n<b>\n<div>\n<a>test</a>\n</div>\n</b>\n</a>";
         Document doc = Jsoup.parse(html);
         assertNotNull(doc);
-        assertEquals("<a> <b> </b></a><b><div><a> </a><a>test</a></div></b>", TextUtil.stripNewlines(doc.body().html()));
+        assertEquals("<a> <b> </b></a><b><div><a> </a><a>test</a></div> </b>", TextUtil.stripNewlines(doc.body().html()));
     }
 
     @Test public void adoption() throws IOException {
@@ -1666,7 +1693,7 @@ public class HtmlParserTest {
         // https://github.com/jhy/jsoup/issues/2258
         String html = "<ul><li>L1</li><li>L2 <template><li>T1</li><li>T2</template></li><li>L3</ul>";
         Document doc = Jsoup.parse(html);
-        assertEquals("<ul><li>L1</li><li>L2 <template><li>T1</li><li>T2</li></template></li><li>L3</li></ul>",
+        assertEquals("<ul><li>L1</li><li>L2<template><li>T1</li><li>T2</li></template></li><li>L3</li></ul>",
             TextUtil.stripNewlines(doc.body().html()));
     }
 
@@ -1826,13 +1853,7 @@ public class HtmlParserTest {
         assertMathNamespace(doc.expectFirst("ms"));
 
         String serialized = doc.expectFirst("div").html();
-        assertEquals("<math>\n" +
-            " <mi>\n" +
-            "  <p>One</p>\n" +
-            "  <svg>\n" +
-            "   <text>Blah</text>\n" +
-            "  </svg></mi><ms></ms>\n" +
-            "</math>", serialized);
+        assertEquals("<math>\n <mi>\n  <p>One</p>\n  <svg>\n   <text>Blah</text>\n  </svg>\n </mi><ms></ms>\n</math>", serialized);
     }
 
     private static void assertHtmlNamespace(Element el) {
@@ -1953,7 +1974,7 @@ public class HtmlParserTest {
         // this gives us an element "a<b", which is gross, but to the spec & browsers
         Document doc = Jsoup.parse(html);
         Element body = doc.body();
-        assertEquals("<div> Div <a>One<a<b> Hello </a<b></a></div>", TextUtil.normalizeSpaces(body.html()));
+        assertEquals("<div>\n Div <a>One<a<b>Hello</a<b></a>\n</div>", body.html());
 
         Elements abs = doc.getElementsByTag("a<b");
         assertEquals(1, abs.size());
@@ -1993,4 +2014,79 @@ public class HtmlParserTest {
         assertNotNull(doc);
         assertEquals("", doc.title());
     }
+
+    static void assertErrorsContain(String msg, ParseErrorList errors) {
+        assertFalse(errors.isEmpty());
+        for (ParseError error : errors) {
+            if (error.toString().contains(msg)) {
+                return;
+            }
+        }
+        fail("Expected to find error message [" + msg + "] in " + errors);
+    }
+
+    static void assertErrorsDoNotContain(String msg, ParseErrorList errors) {
+        for (ParseError error : errors) {
+            if (error.toString().contains(msg)) {
+                fail("Did not expect to find error message [" + msg + "] in " + errors);
+            }
+        }
+    }
+
+    @Test void selfClosing() {
+        // in HTML spec by default: void tags can be marked self-closing; foreign elements can self close; other instances are errors and the self-close is ignored
+        // voids are not serialized as self-closing
+        Parser parser = Parser.htmlParser().setTrackErrors(10);
+        String html = "<div id=1 /><p>Foo";
+        Document doc = Jsoup.parse(html, parser);
+        ParseErrorList errors = parser.getErrors();
+        assertErrorsContain("<1:13>: Tag [div] cannot be self-closing; not a void tag", errors);
+        assertEquals("<div id=\"1\"><p>Foo</p></div>", TextUtil.stripNewlines(doc.body().html()));
+
+        // voids are OK to be self close, but we don't emit them
+        html = "<img /><input />";
+        doc = Jsoup.parse(html, parser);
+        errors = parser.getErrors();
+        assertErrorsDoNotContain("cannot be self-closing", errors);
+        assertEquals("<img><input>", TextUtil.stripNewlines(doc.body().html()));
+
+        // unknown tags won't be self-closing by default
+        html = "<unknown />Foo";
+        doc = Jsoup.parse(html, parser);
+        errors = parser.getErrors();
+        assertErrorsContain("Tag [unknown] cannot be self-closing;", errors);
+        assertEquals("<unknown>Foo</unknown>", TextUtil.stripNewlines(doc.body().html()));
+
+        // foreign elements can self close
+        html = "<svg /><svg><femerge /><foo /></svg>"; // femerge is known to tagset, foo is not
+        doc = Jsoup.parse(html, parser);
+        errors = parser.getErrors();
+        assertEquals(0, errors.size());
+        assertEquals("<svg /><svg><femerge /><foo /></svg>", TextUtil.stripNewlines(doc.body().html()));
+        // check namespace of foo
+        Element foo = doc.expectFirst("foo");
+        assertEquals(Parser.NamespaceSvg, foo.tag().namespace());
+    }
+
+    @Test void canControlSelfClosing() {
+        // by supplying a customized tagset, can allow both known and custom tags to self close during parse
+        // to be valid HTML, the emit will not include the self-closing, but user can switch to xml
+        Parser parser = Parser.htmlParser().setTrackErrors(10).tagSet(TagSet.Html());
+        TagSet tags = parser.tagSet();
+        Tag custom = tags.valueOf("custom", Parser.NamespaceHtml).set(Tag.SelfClose);
+        Tag div = tags.valueOf("div", Parser.NamespaceHtml).set(Tag.SelfClose);
+
+        String html = "<div /><custom /><custom>Foo</custom>";
+        Document doc = Jsoup.parse(html, parser);
+        ParseErrorList errors = parser.getErrors();
+        assertEquals(0, errors.size());
+        assertEquals("<div></div><custom></custom><custom>Foo</custom>", TextUtil.stripNewlines(doc.body().html()));
+
+        assertTrue(custom.is(Tag.SeenSelfClose));
+        assertTrue(div.is(Tag.SeenSelfClose));
+
+        // in xml syntax will allow those self closes (with customized tagset)
+        doc.outputSettings().syntax(Document.OutputSettings.Syntax.xml);
+        assertEquals("<div /><custom /><custom>Foo</custom>", TextUtil.stripNewlines(doc.body().html()));
+    }
 }
diff --git a/src/test/java/org/jsoup/parser/HtmlTreeBuilderStateTest.java b/src/test/java/org/jsoup/parser/HtmlTreeBuilderStateTest.java
index cb09e458..b9c573b2 100644
--- a/src/test/java/org/jsoup/parser/HtmlTreeBuilderStateTest.java
+++ b/src/test/java/org/jsoup/parser/HtmlTreeBuilderStateTest.java
@@ -45,7 +45,7 @@ public class HtmlTreeBuilderStateTest {
     public void ensureArraysAreSorted() {
         List<Object[]> constants = findConstantArrays(Constants.class);
         ensureSorted(constants);
-        assertEquals(40, constants.size());
+        assertEquals(39, constants.size());
     }
 
     @Test public void ensureTagSearchesAreKnownTags() {
@@ -112,4 +112,4 @@ public class HtmlTreeBuilderStateTest {
             "</html>", s);
     }
 
-}
\ No newline at end of file
+}
diff --git a/src/test/java/org/jsoup/parser/HtmlTreeBuilderTest.java b/src/test/java/org/jsoup/parser/HtmlTreeBuilderTest.java
index 0b585aed..862fe1bc 100644
--- a/src/test/java/org/jsoup/parser/HtmlTreeBuilderTest.java
+++ b/src/test/java/org/jsoup/parser/HtmlTreeBuilderTest.java
@@ -1,6 +1,9 @@
 package org.jsoup.parser;
 
 
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.DataNode;
+import org.jsoup.nodes.Document;
 import org.jsoup.nodes.Element;
 import org.jspecify.annotations.NullMarked;
 import org.junit.jupiter.api.Test;
@@ -17,7 +20,7 @@ public class HtmlTreeBuilderTest {
     public void ensureSearchArraysAreSorted() {
         List<Object[]> constants = HtmlTreeBuilderStateTest.findConstantArrays(HtmlTreeBuilder.class);
         HtmlTreeBuilderStateTest.ensureSorted(constants);
-        assertEquals(13, constants.size());
+        assertEquals(14, constants.size());
     }
 
     @Test
@@ -61,4 +64,41 @@ public class HtmlTreeBuilderTest {
         Element notSvgEl = new Element(Tag.valueOf("not-svg", Parser.NamespaceSvg, settings), "");
         assertFalse(HtmlTreeBuilder.isSpecial(notSvgEl));
     }
+
+    @Test void customRcdataTag() {
+        String inner = "Blah\nblah\n<foo>Foo</foo>\n&quot;";
+        String innerText = "Blah\nblah\n<foo>Foo</foo>\n\"";
+        String html = "<div><x>" + inner + "</x></div><div><x id=2></x></div>";
+        TagSet custom = TagSet.Html();
+        Tag x = custom.valueOf("x", NamespaceHtml);
+        x.set(Tag.RcData);
+
+        Document doc = Jsoup.parse(html, Parser.htmlParser().tagSet(custom));
+        Element xEl = doc.expectFirst("x");
+        assertEquals(x, xEl.tag());
+        assertEquals(innerText, xEl.wholeText()); // <foo> is text no el
+
+        // fragment parse context
+        Element x2 = doc.expectFirst("#2");
+        x2.html(inner); // <foo> will be text not el, via custom fragment context element
+        assertEquals(innerText, x2.wholeText());
+    }
+
+    @Test void customDataTag() {
+        String inner = "Blah\nblah\n<foo>Foo</foo>\n&quot;"; // no character refs, will be as-is
+        String html = "<div><x>" + inner + "</x></div><div><x id=2></x></div>";
+        TagSet custom = TagSet.Html();
+        Tag x = custom.valueOf("x", NamespaceHtml);
+        x.set(Tag.Data);
+
+        Document doc = Jsoup.parse(html, Parser.htmlParser().tagSet(custom));
+        Element xEl = doc.expectFirst("x");
+        assertEquals(x, xEl.tag());
+        assertEquals(inner, xEl.data());
+
+        // fragment parse context
+        Element x2 = doc.expectFirst("#2");
+        x2.html(inner); // <foo> will be text not el, via custom fragment context element
+        assertEquals(inner, xEl.data());
+    }
 }
diff --git a/src/test/java/org/jsoup/parser/PositionTest.java b/src/test/java/org/jsoup/parser/PositionTest.java
index 57295720..fe7ad8f7 100644
--- a/src/test/java/org/jsoup/parser/PositionTest.java
+++ b/src/test/java/org/jsoup/parser/PositionTest.java
@@ -509,7 +509,11 @@ class PositionTest {
     @Test void tracksAfterPSelfClose() {
         // https://github.com/jhy/jsoup/issues/2175
         String html = "foo<p/>bar &amp; 2";
-        Document doc = Jsoup.parse(html, TrackingHtmlParser);
+
+        // force allow self-closing p
+        Parser parser = TrackingHtmlParser.clone();
+        parser.tagSet().valueOf("p", Parser.NamespaceHtml).set(Tag.SelfClose);
+        Document doc = Jsoup.parse(html, parser);
         StringBuilder track = new StringBuilder();
         doc.body().forEachNode(node -> accumulatePositions(node, track));
         assertEquals("body:0-0~18-18; #text:0-3; p:3-7~3-7; #text:7-18; ", track.toString());
diff --git a/src/test/java/org/jsoup/parser/TagSetTest.java b/src/test/java/org/jsoup/parser/TagSetTest.java
new file mode 100644
index 00000000..45450589
--- /dev/null
+++ b/src/test/java/org/jsoup/parser/TagSetTest.java
@@ -0,0 +1,108 @@
+package org.jsoup.parser;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.junit.jupiter.api.Test;
+
+import static org.jsoup.parser.Parser.NamespaceHtml;
+import static org.junit.jupiter.api.Assertions.*;
+
+public class TagSetTest {
+    @Test void canRetrieveNewTagsSensitive() {
+        Document doc = Jsoup.parse("<div><p>One</p></div>", "", Parser.htmlParser().settings(ParseSettings.preserveCase));
+        TagSet tags = doc.parser().tagSet();
+        // should be the full html set
+        Tag meta = tags.get("meta", NamespaceHtml);
+        assertNotNull(meta);
+        assertTrue(meta.isKnownTag());
+
+        Element p = doc.expectFirst("p");
+        assertTrue(p.tag().isKnownTag());
+
+        assertNull(tags.get("FOO", NamespaceHtml));
+        p.tagName("FOO");
+        Tag foo = p.tag();
+        assertEquals("FOO", foo.name());
+        assertEquals("foo", foo.normalName());
+        assertEquals(NamespaceHtml, foo.namespace());
+        assertFalse(foo.isKnownTag());
+
+        assertSame(foo, tags.get("FOO", NamespaceHtml));
+        assertSame(foo, tags.valueOf("FOO", NamespaceHtml));
+        assertNull(tags.get("FOO", "SomeOtherNamespace"));
+    }
+
+    @Test void canRetrieveNewTagsInsensitive() {
+        Document doc = Jsoup.parse("<div><p>One</p></div>");
+        TagSet tags = doc.parser().tagSet();
+        // should be the full html set
+        Tag meta = tags.get("meta", NamespaceHtml);
+        assertNotNull(meta);
+        assertTrue(meta.isKnownTag());
+
+        Element p = doc.expectFirst("p");
+        assertTrue(p.tag().isKnownTag());
+
+        assertNull(tags.get("FOO", NamespaceHtml));
+        p.tagName("FOO");
+        Tag foo = p.tag();
+        assertEquals("foo", foo.name());
+        assertEquals("foo", foo.normalName());
+        assertEquals(NamespaceHtml, foo.namespace());
+        assertFalse(foo.isKnownTag());
+
+        assertSame(foo, tags.get("foo", NamespaceHtml));
+        assertSame(foo, tags.valueOf("FOO", NamespaceHtml, doc.parser().settings()));
+        assertNull(tags.get("foo", "SomeOtherNamespace"));
+    }
+
+    @Test void supplyCustomTagSet() {
+        TagSet tags = TagSet.Html();
+        tags.valueOf("custom", NamespaceHtml).set(Tag.PreserveWhitespace).set(Tag.Block);
+        Parser parser = Parser.htmlParser().tagSet(tags);
+
+        Document doc = Jsoup.parse("<body><custom>\n\nFoo\n Bar</custom></body>", parser);
+        Element custom = doc.expectFirst("custom");
+        assertTrue(custom.tag().preserveWhitespace());
+        assertTrue(custom.tag().isBlock());
+        assertEquals("<custom>\n" +
+            "\n" +
+            "Foo\n" +
+            " Bar" +
+            "</custom>", custom.outerHtml());
+    }
+
+    @Test void knownTags() {
+        // tests that tags explicitly inserted via .add are 'known'; those that come implicitly via valueOf are not
+        TagSet tags = TagSet.Html();
+        Tag custom = new Tag("custom");
+        assertEquals("custom", custom.name());
+        assertEquals(NamespaceHtml, custom.namespace());
+        assertFalse(custom.isKnownTag()); // not yet
+
+        Tag br = tags.get("br", NamespaceHtml);
+        assertNotNull(br);
+        assertTrue(br.isKnownTag());
+        assertSame(br, tags.valueOf("br", NamespaceHtml));
+
+        Tag foo = tags.valueOf("foo", NamespaceHtml);
+        assertFalse(foo.isKnownTag());
+
+        tags.add(custom);
+        assertTrue(custom.isKnownTag());
+        assertSame(custom, tags.get("custom", NamespaceHtml));
+        assertSame(custom, tags.valueOf("custom", NamespaceHtml));
+        Tag capCustom = tags.valueOf("Custom", NamespaceHtml);
+        assertTrue(capCustom.isKnownTag()); // cloned from a known tag, so is still known
+
+        // known if set or clear called
+        Tag c1 = new Tag("bar");
+        assertFalse(c1.isKnownTag());
+        c1.set(Tag.Block);
+        assertTrue(c1.isKnownTag());
+        c1.clear(Tag.Block);
+        assertTrue(c1.isKnownTag());
+        c1.clear(Tag.Known);
+        assertFalse(c1.isKnownTag());
+    }
+}
diff --git a/src/test/java/org/jsoup/parser/TagTest.java b/src/test/java/org/jsoup/parser/TagTest.java
index 999cfd07..efe5a0d9 100644
--- a/src/test/java/org/jsoup/parser/TagTest.java
+++ b/src/test/java/org/jsoup/parser/TagTest.java
@@ -1,10 +1,14 @@
 package org.jsoup.parser;
 
+import org.jsoup.Jsoup;
 import org.jsoup.MultiLocaleExtension.MultiLocaleTest;
+import org.jsoup.nodes.Document;
 import org.junit.jupiter.api.Test;
 
 import java.util.Locale;
 
+import static org.jsoup.parser.Parser.NamespaceHtml;
+import static org.jsoup.parser.Parser.NamespaceSvg;
 import static org.junit.jupiter.api.Assertions.*;
 
 /**
@@ -21,9 +25,14 @@ public class TagTest {
     public void canBeInsensitive(Locale locale) {
         Locale.setDefault(locale);
 
-        Tag script1 = Tag.valueOf("script", ParseSettings.htmlDefault);
-        Tag script2 = Tag.valueOf("SCRIPT", ParseSettings.htmlDefault);
-        assertSame(script1, script2);
+        Tag script1 = Tag.valueOf("script", NamespaceHtml, ParseSettings.htmlDefault);
+        Tag script2 = Tag.valueOf("SCRIPT", NamespaceHtml, ParseSettings.htmlDefault);
+        assertEquals(script1, script2);
+
+        TagSet htmlTags = TagSet.Html();
+        Tag script3 = htmlTags.valueOf("script", NamespaceHtml, ParseSettings.htmlDefault);
+        Tag script4 = htmlTags.valueOf("SCRIPT", NamespaceHtml, ParseSettings.htmlDefault);
+        assertSame(script3, script4);
     }
 
     @Test public void trims() {
@@ -36,21 +45,38 @@ public class TagTest {
         Tag p1 = Tag.valueOf("p");
         Tag p2 = Tag.valueOf("p");
         assertEquals(p1, p2);
-        assertSame(p1, p2);
+        assertNotSame(p1, p2); // not same because Tag.valueOf creates new clone of the TagSet.Html, so changes don't clobber all
+
+        TagSet html1 = TagSet.Html();
+        TagSet html2 = TagSet.Html();
+        assertEquals(html1, html2);
+        assertNotSame(html1, html2);
+
+        Tag p3 = html1.valueOf("p", NamespaceHtml);
+        Tag p4 = html1.valueOf("p", NamespaceHtml);
+        Tag p5 = html2.valueOf("p", NamespaceHtml);
+        Tag p6 = html2.valueOf("p", NamespaceHtml);
+        assertEquals(p1, p3);
+        assertEquals(p3, p4);
+        assertEquals(p4, p5);
+        assertSame(p3, p4);
+        assertSame(p5, p6);
+        assertNotSame(p3, p5);
     }
 
     @Test public void divSemantics() {
         Tag div = Tag.valueOf("div");
 
         assertTrue(div.isBlock());
-        assertTrue(div.formatAsBlock());
+        assertFalse(div.isInline());
+        assertTrue(div.isKnownTag());
     }
 
     @Test public void pSemantics() {
         Tag p = Tag.valueOf("p");
-
+        assertTrue(p.isKnownTag());
         assertTrue(p.isBlock());
-        assertFalse(p.formatAsBlock());
+        assertFalse(p.isInline());
     }
 
     @Test public void imgSemantics() {
@@ -65,8 +91,11 @@ public class TagTest {
         Tag foo2 = Tag.valueOf("FOO");
 
         assertEquals(foo, foo2);
+        assertFalse(foo.isKnownTag());
         assertTrue(foo.isInline());
-        assertTrue(foo.formatAsBlock());
+        assertFalse(foo.isBlock());
+        assertFalse(foo.is(Tag.InlineContainer));
+        assertFalse(foo.preserveWhitespace());
     }
 
     @Test public void valueOfChecksNotNull() {
@@ -86,21 +115,45 @@ public class TagTest {
         Tag svgHtml = Tag.valueOf("svg"); // no namespace specified, defaults to html, so not the known tag
         Tag svg = Tag.valueOf("svg", Parser.NamespaceSvg, ParseSettings.htmlDefault);
 
-        assertEquals(Parser.NamespaceHtml, svgHtml.namespace());
+        assertEquals(NamespaceHtml, svgHtml.namespace());
         assertEquals(Parser.NamespaceSvg, svg.namespace());
 
-        assertFalse(svgHtml.isBlock()); // generated
-        assertTrue(svg.isBlock()); // known
+        assertFalse(svgHtml.isKnownTag()); // generated
+        assertTrue(svg.isKnownTag()); // known
     }
 
     @Test public void unknownTagNamespace() {
         Tag fooHtml = Tag.valueOf("foo"); // no namespace specified, defaults to html
         Tag foo = Tag.valueOf("foo", Parser.NamespaceSvg, ParseSettings.htmlDefault);
 
-        assertEquals(Parser.NamespaceHtml, fooHtml.namespace());
+        assertEquals(NamespaceHtml, fooHtml.namespace());
         assertEquals(Parser.NamespaceSvg, foo.namespace());
 
-        assertFalse(fooHtml.isBlock()); // generated
-        assertFalse(foo.isBlock()); // generated
+        assertFalse(fooHtml.isKnownTag()); // generated
+        assertFalse(foo.isKnownTag()); // generated
+    }
+
+    @Test void canSetOptions() {
+        Tag tag = new Tag("foo", NamespaceHtml);
+        assertFalse(tag.isKnownTag());
+        assertFalse(tag.isEmpty());
+        tag.set(Tag.Void);
+        assertTrue(tag.isEmpty());
+        assertTrue(tag.isKnownTag());
+    }
+
+    @Test void updateNameAndNamespace() {
+        Tag tag = new Tag("foo", NamespaceHtml);
+        tag.name("bar").namespace(NamespaceSvg);
+        tag.set(Tag.Block);
+        assertEquals("bar", tag.name());
+        assertEquals(NamespaceSvg, tag.namespace());
+        assertTrue(tag.isBlock()); // properties are unchanged
+
+        // test in a doc
+        Document doc = Jsoup.parse("<foo>One</foo><foo>Two</foo>");
+        Tag foo = doc.expectFirst("foo").tag();
+        foo.name("BAR");
+        assertEquals("<BAR>One</BAR><BAR>Two</BAR>", doc.body().html()); // is case-sensitive
     }
 }
diff --git a/src/test/java/org/jsoup/parser/TokenQueueTest.java b/src/test/java/org/jsoup/parser/TokenQueueTest.java
index 86da694e..f2367c39 100644
--- a/src/test/java/org/jsoup/parser/TokenQueueTest.java
+++ b/src/test/java/org/jsoup/parser/TokenQueueTest.java
@@ -3,8 +3,12 @@ package org.jsoup.parser;
 import org.jsoup.Jsoup;
 import org.jsoup.nodes.Document;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 
 import java.util.regex.Pattern;
+import java.util.stream.Stream;
 
 import static org.junit.jupiter.api.Assertions.*;
 
@@ -109,9 +113,9 @@ public class TokenQueueTest {
     @Test
     public void testQuotedPattern() {
         final Document doc = Jsoup.parse("<div>\\) foo1</div><div>( foo2</div><div>1) foo3</div>");
-        assertEquals("\n\\) foo1",doc.select("div:matches(" + Pattern.quote("\\)") + ")").get(0).childNode(0).toString());
-        assertEquals("\n( foo2",doc.select("div:matches(" + Pattern.quote("(") + ")").get(0).childNode(0).toString());
-        assertEquals("\n1) foo3",doc.select("div:matches(" + Pattern.quote("1)") + ")").get(0).childNode(0).toString());
+        assertEquals("\\) foo1",doc.select("div:matches(" + Pattern.quote("\\)") + ")").get(0).childNode(0).toString());
+        assertEquals("( foo2",doc.select("div:matches(" + Pattern.quote("(") + ")").get(0).childNode(0).toString());
+        assertEquals("1) foo3",doc.select("div:matches(" + Pattern.quote("1)") + ")").get(0).childNode(0).toString());
     }
 
     @Test public void consumeEscapedTag() {
@@ -139,4 +143,126 @@ public class TokenQueueTest {
         assertEquals("i\\d", q.consumeCssIdentifier());
         assertTrue(q.isEmpty());
     }
+
+    @ParameterizedTest
+    @MethodSource("cssIdentifiers")
+    @MethodSource("cssAdditionalIdentifiers")
+    void consumeCssIdentifier_WebPlatformTests(String expected, String cssIdentifier) {
+        assertParsedCssIdentifierEquals(expected, cssIdentifier);
+    }
+
+    private static Stream<Arguments> cssIdentifiers() {
+        return Stream.of(
+            // https://github.com/web-platform-tests/wpt/blob/36036fb5212a3fc15fc5750cecb1923ba4071668/dom/nodes/ParentNode-querySelector-escapes.html
+            // - escape hex digit
+            Arguments.of("0nextIsWhiteSpace", "\\30 nextIsWhiteSpace"),
+            Arguments.of("0nextIsNotHexLetters", "\\30nextIsNotHexLetters"),
+            Arguments.of("0connectHexMoreThan6Hex", "\\000030connectHexMoreThan6Hex"),
+            Arguments.of("0spaceMoreThan6Hex", "\\000030 spaceMoreThan6Hex"),
+
+            // - hex digit special replacement
+            // 1. zero points
+            Arguments.of("zero\uFFFD", "zero\\0"),
+            Arguments.of("zero\uFFFD", "zero\\000000"),
+            // 2. surrogate points
+            Arguments.of("\uFFFDsurrogateFirst", "\\d83d surrogateFirst"),
+            Arguments.of("surrogateSecond\uFFFd", "surrogateSecond\\dd11"),
+            Arguments.of("surrogatePair\uFFFD\uFFFD", "surrogatePair\\d83d\\dd11"),
+            // 3. out of range points
+            Arguments.of("outOfRange\uFFFD", "outOfRange\\110000"),
+            Arguments.of("outOfRange\uFFFD", "outOfRange\\110030"),
+            Arguments.of("outOfRange\uFFFD", "outOfRange\\555555"),
+            Arguments.of("outOfRange\uFFFD", "outOfRange\\ffffff"),
+
+            // - escape anything else
+            Arguments.of(".comma", "\\.comma"),
+            Arguments.of("-minus", "\\-minus"),
+            Arguments.of("g", "\\g"),
+
+            // non edge cases
+            Arguments.of("aBMPRegular", "\\61 BMPRegular"),
+            Arguments.of("\uD83D\uDD11nonBMP", "\\1f511 nonBMP"),
+            Arguments.of("00continueEscapes", "\\30\\30 continueEscapes"),
+            Arguments.of("00continueEscapes", "\\30 \\30 continueEscapes"),
+            Arguments.of("continueEscapes00", "continueEscapes\\30 \\30 "),
+            Arguments.of("continueEscapes00", "continueEscapes\\30 \\30"),
+            Arguments.of("continueEscapes00", "continueEscapes\\30\\30 "),
+            Arguments.of("continueEscapes00", "continueEscapes\\30\\30"),
+
+            // ident tests case from CSS tests of chromium source: https://goo.gl/3Cxdov
+            Arguments.of("hello", "hel\\6Co"),
+            Arguments.of("&B", "\\26 B"),
+            Arguments.of("hello", "hel\\6C o"),
+            Arguments.of("spaces", "spac\\65\r\ns"),
+            Arguments.of("spaces", "sp\\61\tc\\65\fs"),
+            Arguments.of("test\uD799", "test\\D799"),
+            Arguments.of("\uE000", "\\E000"),
+            Arguments.of("test", "te\\s\\t"),
+            Arguments.of("spaces in\tident", "spaces\\ in\\\tident"),
+            Arguments.of(".,:!", "\\.\\,\\:\\!"),
+            Arguments.of("null\uFFFD", "null\\0"),
+            Arguments.of("null\uFFFD", "null\\0000"),
+            Arguments.of("large\uFFFD", "large\\110000"),
+            Arguments.of("large\uFFFD", "large\\23456a"),
+            Arguments.of("surrogate\uFFFD", "surrogate\\D800"),
+            Arguments.of("surrogate\uFFFD", "surrogate\\0DBAC"),
+            Arguments.of("\uFFFDsurrogate", "\\00DFFFsurrogate"),
+            Arguments.of("\uDBFF\uDFFF", "\\10fFfF"),
+            Arguments.of("\uDBFF\uDFFF0", "\\10fFfF0"),
+            Arguments.of("\uDBC0\uDC0000", "\\10000000"),
+            Arguments.of("eof\uFFFD", "eof\\"),
+
+            Arguments.of("simple-ident", "simple-ident"),
+            Arguments.of("testing123", "testing123"),
+            Arguments.of("_underscore", "_underscore"),
+            Arguments.of("-text", "-text"),
+            Arguments.of("-m", "-\\6d"),
+            Arguments.of("--abc", "--abc"),
+            Arguments.of("--", "--"),
+            Arguments.of("--11", "--11"),
+            Arguments.of("---", "---"),
+            Arguments.of("\u2003", "\u2003"),
+            Arguments.of("\u00A0", "\u00A0"),
+            Arguments.of("\u1234", "\u1234"),
+            Arguments.of("\uD808\uDF45", "\uD808\uDF45"),
+            Arguments.of("\uFFFD", "\u0000"),
+            Arguments.of("ab\uFFFDc", "ab\u0000c")
+        );
+    }
+
+    private static Stream<Arguments> cssAdditionalIdentifiers() {
+        return Stream.of(
+            Arguments.of("1st", "\\31\r\nst"),
+            Arguments.of("1", "\\31\r"),
+            Arguments.of("1a", "\\31\ra"),
+            Arguments.of("1", "\\031"),
+            Arguments.of("1", "\\0031"),
+            Arguments.of("1", "\\00031"),
+            Arguments.of("1", "\\000031"),
+            Arguments.of("1", "\\000031"),
+            Arguments.of("a", "a\\\nb")
+        );
+    }
+
+    @Test void consumeCssIdentifierWithEmptyInput() {
+        TokenQueue emptyQueue = new TokenQueue("");
+        Exception exception = assertThrows(IllegalArgumentException.class, emptyQueue::consumeCssIdentifier);
+        assertEquals("CSS identifier expected, but end of input found", exception.getMessage());
+    }
+
+    // Some of jsoup's tests depend on this behavior
+    @Test public void consumeCssIdentifier_invalidButSupportedForBackwardsCompatibility() {
+        assertParsedCssIdentifierEquals("1", "1");
+        assertParsedCssIdentifierEquals("-", "-");
+        assertParsedCssIdentifierEquals("-1", "-1");
+    }
+
+    private static String parseCssIdentifier(String text) {
+        TokenQueue q = new TokenQueue(text);
+        return q.consumeCssIdentifier();
+    }
+
+    private void assertParsedCssIdentifierEquals(String expected, String cssIdentifier) {
+        assertEquals(expected, parseCssIdentifier(cssIdentifier));
+    }
 }
diff --git a/src/test/java/org/jsoup/parser/TokeniserStateTest.java b/src/test/java/org/jsoup/parser/TokeniserStateTest.java
index 66566398..dccaf3ca 100644
--- a/src/test/java/org/jsoup/parser/TokeniserStateTest.java
+++ b/src/test/java/org/jsoup/parser/TokeniserStateTest.java
@@ -258,7 +258,7 @@ public class TokeniserStateTest {
     @Test
     public void nullInTag() {
         Document doc = Jsoup.parse("<di\0v>One</di\0v>Two");
-        assertEquals("<di�v>\n One\n</di�v>Two", doc.body().html());
+        assertEquals("<di�v>One</di�v>Two", doc.body().html());
     }
 
     @Test
diff --git a/src/test/java/org/jsoup/parser/XmlTreeBuilderTest.java b/src/test/java/org/jsoup/parser/XmlTreeBuilderTest.java
index d2004294..cd60bc60 100644
--- a/src/test/java/org/jsoup/parser/XmlTreeBuilderTest.java
+++ b/src/test/java/org/jsoup/parser/XmlTreeBuilderTest.java
@@ -16,6 +16,8 @@ import java.nio.charset.StandardCharsets;
 import java.util.List;
 
 import static org.jsoup.nodes.Document.OutputSettings.Syntax;
+import static org.jsoup.parser.Parser.NamespaceHtml;
+import static org.jsoup.parser.Parser.NamespaceXml;
 import static org.junit.jupiter.api.Assertions.*;
 
 /**
@@ -61,25 +63,6 @@ public class XmlTreeBuilderTest {
                 TextUtil.stripNewlines(doc.html()));
     }
 
-    @Disabled
-    @Test
-    public void testSupplyParserToConnection() throws IOException {
-        String xmlUrl = "http://direct.infohound.net/tools/jsoup-xml-test.xml";
-
-        // parse with both xml and html parser, ensure different
-        Document xmlDoc = Jsoup.connect(xmlUrl).parser(Parser.xmlParser()).get();
-        Document htmlDoc = Jsoup.connect(xmlUrl).parser(Parser.htmlParser()).get();
-        Document autoXmlDoc = Jsoup.connect(xmlUrl).get(); // check connection auto detects xml, uses xml parser
-
-        assertEquals("<doc><val>One<val>Two</val>Three</val></doc>",
-                TextUtil.stripNewlines(xmlDoc.html()));
-        assertNotEquals(htmlDoc, xmlDoc);
-        assertEquals(xmlDoc, autoXmlDoc);
-        assertEquals(1, htmlDoc.select("head").size()); // html parser normalises
-        assertEquals(0, xmlDoc.select("head").size()); // xml parser does not
-        assertEquals(0, autoXmlDoc.select("head").size()); // xml parser does not
-    }
-
     @Test
     public void testSupplyParserToDataStream() throws IOException, URISyntaxException {
         File xmlFile = new File(XmlTreeBuilder.class.getResource("/htmltests/xml-test.xml").toURI());
@@ -327,7 +310,7 @@ public class XmlTreeBuilderTest {
         ParseSettings settings = doc.parser().settings();
         assertTrue(settings.preserveTagCase());
         assertTrue(settings.preserveAttributeCase());
-        assertEquals(Parser.NamespaceXml, doc.parser().defaultNamespace());
+        assertEquals(NamespaceXml, doc.parser().defaultNamespace());
     }
 
     @Test void xmlNamespace() {
@@ -349,7 +332,7 @@ public class XmlTreeBuilderTest {
     }
 
     private static void assertXmlNamespace(Element el) {
-        assertEquals(Parser.NamespaceXml, el.tag().namespace(), String.format("Element %s not in XML namespace", el.tagName()));
+        assertEquals(NamespaceXml, el.tag().namespace(), String.format("Element %s not in XML namespace", el.tagName()));
     }
 
     @Test void declarations() {
@@ -394,4 +377,279 @@ public class XmlTreeBuilderTest {
         assertEquals("<val2>", decl.attr("att2"));
     }
 
+    @Test void xmlHeaderIsValid() {
+        // https://github.com/jhy/jsoup/issues/2298
+        String xml = "<?xml version=\"1.0\"?>\n<root></root>";
+        String expect = xml;
+
+        Document doc = Jsoup.parse(xml, Parser.xmlParser().setTrackErrors(10));
+        assertEquals(0, doc.parser().getErrors().size());
+        assertEquals(expect, doc.html());
+
+        xml =  "<?xml version=\"1.0\" ?>\n<root></root>";
+        doc = Jsoup.parse(xml, Parser.xmlParser().setTrackErrors(10));
+        assertEquals(0, doc.parser().getErrors().size());
+        assertEquals(expect, doc.html());
+    }
+
+    @Test void canSetCustomRcdataTag() {
+        String inner = "Blah\nblah\n<foo></foo>&quot;";
+        String innerText = "Blah\nblah\n<foo></foo>\"";
+
+        String xml = "<x><y><z>" + inner + "</z></y></x><x><z id=2></z>";
+        TagSet custom = new TagSet();
+        Tag z = custom.valueOf("z", NamespaceXml, ParseSettings.preserveCase);
+        z.set(Tag.RcData);
+
+        Document doc = Jsoup.parse(xml, Parser.xmlParser().tagSet(custom));
+        Element zEl = doc.expectFirst("z");
+        assertNotSame(z, zEl.tag()); // not same because we copy the tagset
+        assertEquals(z, zEl.tag());
+
+        assertEquals(1, zEl.childNodeSize());
+        Node child = zEl.childNode(0);
+        assertTrue(child instanceof TextNode);
+        assertEquals(innerText, ((TextNode) child).getWholeText());
+
+        // test fragment context parse - should parse <foo> as text
+        Element z2 = doc.expectFirst("#2");
+        z2.html(inner);
+        assertEquals(innerText, z2.wholeText());
+    }
+
+    @Test void canSetCustomDataTag() {
+        String inner = "Blah\nblah\n<foo></foo>&quot;"; // no character refs, will be as-is
+
+        String xml = "<x><y><z>" + inner + "</z></y></x><x><z id=2></z>";
+        TagSet custom = new TagSet();
+        Tag z = custom.valueOf("z", NamespaceXml, ParseSettings.preserveCase);
+        z.set(Tag.Data);
+
+        Document doc = Jsoup.parse(xml, Parser.xmlParser().tagSet(custom));
+        Element zEl = doc.expectFirst("z");
+        assertNotSame(z, zEl.tag()); // not same because we copy the tagset
+        assertEquals(z, zEl.tag());
+
+        assertEquals(1, zEl.childNodeSize());
+        Node child = zEl.childNode(0);
+        assertTrue(child instanceof DataNode);
+        assertEquals(inner, ((DataNode) child).getWholeData());
+        assertEquals(inner, zEl.data());
+
+        // test fragment context parse - should parse <foo> as data
+        Element z2 = doc.expectFirst("#2");
+        z2.html(inner);
+        assertEquals(inner, ((DataNode) child).getWholeData());
+        assertEquals(inner, zEl.data());
+    }
+
+    @Test void canSetCustomVoid() {
+        String ns = "custom";
+        String xml = "<x xmlns=custom><foo><link><meta>";
+        TagSet custom = new TagSet();
+        custom.valueOf("link", ns).set(Tag.Void);
+        custom.valueOf("meta", ns).set(Tag.Void);
+        custom.valueOf("foo", "other").set(Tag.Void); // ns doesn't match, won't impact
+
+        Document doc = Jsoup.parse(xml, Parser.xmlParser().tagSet(custom));
+        String expect = "<x xmlns=\"custom\"><foo><link /><meta /></foo></x>";
+        assertEquals(expect, doc.html());
+    }
+
+    @Test void canSupplyWithHtmlTagSet() {
+        // use the properties of html tag set but without HtmlTreeBuilder rules
+        String xml = "<html xmlns=" + NamespaceHtml + "><div><script>a<b</script><img><p>";
+        Document doc = Jsoup.parse(xml, Parser.xmlParser().tagSet(TagSet.Html()));
+        doc.outputSettings().prettyPrint(true);
+        String expect = "<html xmlns=\"http://www.w3.org/1999/xhtml\">\n" +
+            " <div>\n" +
+            "  <script>//<![CDATA[\n" +
+            "a<b\n" +
+            "//]]></script>\n" +
+            "  <img />\n" +
+            "  <p></p>\n" +
+            " </div>\n" +
+            "</html>";
+        assertEquals(expect, doc.html());
+
+        doc.outputSettings().syntax(Syntax.html);
+        expect = "<html xmlns=\"http://www.w3.org/1999/xhtml\">\n" +
+            " <div>\n" +
+            "  <script>a<b</script>\n" +
+            "  <img>\n" +
+            "  <p></p>\n" +
+            " </div>\n" +
+            "</html>";
+        assertEquals(expect, doc.html());
+    }
+
+    @Test void prettyFormatsTextInline() {
+        // https://github.com/jhy/jsoup/issues/2141
+        String xml = "<package><metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n" +
+            "<dc:identifier id=\"pub-id\">id</dc:identifier>\n" +
+            "<dc:title>title</dc:title>\n" +
+            "<dc:language>ja</dc:language>\n" +
+            "<dc:description>desc</dc:description>\n" +
+            "</metadata></package>";
+        Document doc = Jsoup.parse(xml, Parser.xmlParser());
+        doc.outputSettings().prettyPrint(true);
+        assertEquals("<package>\n" +
+            " <metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n" +
+            "  <dc:identifier id=\"pub-id\">id</dc:identifier><dc:title>title</dc:title> <dc:language>ja</dc:language> <dc:description>desc</dc:description>\n" +
+            " </metadata>\n" +
+            "</package>", doc.html());
+
+        // can customize
+        Element meta = doc.expectFirst("metadata");
+        Tag metaTag = meta.tag();
+        metaTag.set(Tag.Block);
+        // set all the inner els of meta to be blocks
+        for (Element inner : meta) inner.tag().set(Tag.Block);
+
+        assertEquals("<package>\n" +
+            " <metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n" +
+            "  <dc:identifier id=\"pub-id\">id</dc:identifier>\n" +
+            "  <dc:title>title</dc:title>\n" +
+            "  <dc:language>ja</dc:language>\n" +
+            "  <dc:description>desc</dc:description>\n" +
+            " </metadata>\n" +
+            "</package>", doc.html());
+    }
+
+    // namespace tests
+    @Test void xmlns() {
+        // example from the xml namespace spec https://www.w3.org/TR/xml-names/
+        String xml = "<?xml version=\"1.0\"?>\n" +
+            "<!-- both namespace prefixes are available throughout -->\n" +
+            "<bk:book xmlns:bk=\"urn:loc.gov:books\" xmlns:isbn=\"urn:ISBN:0-395-36341-6\">\n" +
+            "    <bk:title>Cheaper by the Dozen</bk:title>\n" +
+            "    <isbn:number>1568491379</isbn:number>\n" +
+            "</bk:book>";
+        Document doc = Jsoup.parse(xml, Parser.xmlParser());
+
+        Element book = doc.expectFirst("bk|book");
+        assertEquals("bk:book", book.tag().name());
+        assertEquals("bk", book.tag().prefix());
+        assertEquals("book", book.tag().localName());
+        assertEquals("urn:loc.gov:books", book.tag().namespace());
+
+        Element title = doc.expectFirst("bk|title");
+        assertEquals("bk:title", title.tag().name());
+        assertEquals("urn:loc.gov:books", title.tag().namespace());
+
+        Element number = doc.expectFirst("isbn|number");
+        assertEquals("isbn:number", number.tag().name());
+        assertEquals("urn:ISBN:0-395-36341-6", number.tag().namespace());
+
+        // and we didn't modify the dom
+        assertEquals(xml, doc.html());
+    }
+
+    @Test void unprefixedDefaults() {
+        String xml = "<?xml version=\"1.0\"?>\n" +
+            "<!-- elements are in the HTML namespace, in this case by default -->\n" +
+            "<html xmlns=\"http://www.w3.org/1999/xhtml\">\n" +
+            "  <head><title>Frobnostication</title></head>\n" +
+            "  <body><p>Moved to \n" +
+            "    <a href=\"http://frob.example.com\">here</a>.</p></body>\n" +
+            "</html>";
+
+        Document doc = Jsoup.parse(xml, Parser.xmlParser());
+        Element html = doc.expectFirst("html");
+        assertEquals(NamespaceHtml, html.tag().namespace());
+        Element a = doc.expectFirst("a");
+        assertEquals(NamespaceHtml, a.tag().namespace());
+    }
+
+    @Test void emptyDefault() {
+        String xml = "<?xml version='1.0'?>\n" +
+            "<Beers>\n" +
+            "  <!-- the default namespace inside tables is that of HTML -->\n" +
+            "  <table xmlns='http://www.w3.org/1999/xhtml'>\n" +
+            "   <th><td>Name</td><td>Origin</td><td>Description</td></th>\n" +
+            "   <tr> \n" +
+            "     <!-- no default namespace inside table cells -->\n" +
+            "     <td><brandName xmlns=\"\">Huntsman</brandName></td>\n" +
+            "     <td><origin xmlns=\"\">Bath, UK</origin></td>\n" +
+            "     <td>\n" +
+            "       <details xmlns=\"\"><class>Bitter</class><hop>Fuggles</hop>\n" +
+            "         <pro>Wonderful hop, light alcohol, good summer beer</pro>\n" +
+            "         <con>Fragile; excessive variance pub to pub</con>\n" +
+            "         </details>\n" +
+            "        </td>\n" +
+            "      </tr>\n" +
+            "    </table>\n" +
+            "  </Beers>";
+
+        Document doc = Jsoup.parse(xml, Parser.xmlParser());
+        Element beers = doc.expectFirst("Beers");
+        assertEquals(NamespaceXml, beers.tag().namespace());
+        Element td = doc.expectFirst("td");
+        assertEquals(NamespaceHtml, td.tag().namespace());
+        Element origin = doc.expectFirst("origin");
+        assertEquals("", origin.tag().namespace());
+        Element pro = doc.expectFirst("pro");
+        assertEquals("", pro.tag().namespace());
+    }
+
+    @Test void namespacedAttribute() {
+        String xml = "<x xmlns:edi='http://ecommerce.example.org/schema'>\n" +
+            "  <!-- the 'taxClass' attribute's namespace is http://ecommerce.example.org/schema -->\n" +
+            "  <lineItem edi:taxClass=\"exempt\" other=foo>Baby food</lineItem>\n" +
+            "</x>";
+
+        Document doc = Jsoup.parse(xml, Parser.xmlParser());
+        Element lineItem = doc.expectFirst("lineItem");
+
+        Attribute taxClass = lineItem.attribute("edi:taxClass");
+        assertNotNull(taxClass);
+        assertEquals("edi", taxClass.prefix());
+        assertEquals("taxClass", taxClass.localName());
+        assertEquals("http://ecommerce.example.org/schema", taxClass.namespace());
+
+        Attribute other = lineItem.attribute("other");
+        assertNotNull(other);
+        assertEquals("foo", other.getValue());
+        assertEquals("", other.prefix());
+        assertEquals("other", other.localName());
+        assertEquals("", other.namespace());
+    }
+
+    @Test void elementsViaAppendHtmlAreNamespaced() {
+        // tests that when elements / attributes are added via a fragment parse, they inherit the namespace stack, and can still override
+        String xml = "<out xmlns='/out'><bk:book xmlns:bk='/books' xmlns:edi='/edi'><bk:title>Test</bk:title><li edi:foo='bar'></bk:book></out>";
+        Document doc = Jsoup.parse(xml, Parser.xmlParser());
+
+        // insert some parsed xml, inherit bk and edi, and with an inner node override bk
+        Element book = doc.expectFirst("bk|book");
+        book.append("<bk:content edi:foo=qux>Content</bk:content>");
+
+        Element out = doc.expectFirst("out");
+        assertEquals("/out", out.tag().namespace());
+
+        Element content = book.expectFirst("bk|content");
+        assertEquals("bk:content", content.tag().name());
+        assertEquals("/books", content.tag().namespace());
+        assertEquals("/edi", content.attribute("edi:foo").namespace());
+
+        content.append("<data>Data</data><html xmlns='/html' xmlns:bk='/update'><p>Foo</p><bk:news>News</bk:news></html>");
+        // p should be in /html, news in /update
+        Element p = content.expectFirst("p");
+        assertEquals("/html", p.tag().namespace());
+        Element news = content.expectFirst("bk|news");
+        assertEquals("/update", news.tag().namespace());
+        Element data = content.expectFirst("data");
+        assertEquals("/out", data.tag().namespace());
+    }
+
+    @Test void selfClosingOK() {
+        // In XML, all tags can be self-closing regardless of tag type
+        Parser parser = Parser.xmlParser().setTrackErrors(10);
+        String xml = "<div id='1'/><p/><div>Foo</div><div></div><foo></foo>";
+        Document doc = Jsoup.parse(xml, "", parser);
+        ParseErrorList errors = parser.getErrors();
+        assertEquals(0, errors.size());
+        assertEquals("<div id=\"1\" /><p /><div>Foo</div><div /><foo></foo>", TextUtil.stripNewlines(doc.outerHtml()));
+        // we infer that empty els can be represented with self-closing if seen in parse
+    }
 }
diff --git a/src/test/java/org/jsoup/safety/CleanerTest.java b/src/test/java/org/jsoup/safety/CleanerTest.java
index 0e822ea7..f84d0bdf 100644
--- a/src/test/java/org/jsoup/safety/CleanerTest.java
+++ b/src/test/java/org/jsoup/safety/CleanerTest.java
@@ -397,7 +397,7 @@ public class CleanerTest {
 
         Document result = new Cleaner(safelist).clean(orig);
         assertEquals(Document.OutputSettings.Syntax.xml, result.outputSettings().syntax());
-        assertEquals("<p>test<br /></p>", result.body().html());
+        assertEquals("<p>test\n <br /></p>", result.body().html());
     }
 
     @Test void preservesSourcePositionViaUserData() {
diff --git a/src/test/java/org/jsoup/select/ElementsTest.java b/src/test/java/org/jsoup/select/ElementsTest.java
index b0814c71..b1b9b3dd 100644
--- a/src/test/java/org/jsoup/select/ElementsTest.java
+++ b/src/test/java/org/jsoup/select/ElementsTest.java
@@ -518,7 +518,7 @@ public class ElementsTest {
         assertFalse(ps.removeAll(divs));
         assertEquals(2, ps.size());
 
-        assertEquals("<p>One</p>\n<p>Four</p>\n<div>\n Div\n</div>", doc.body().html());
+        assertEquals("<p>One</p>\n<p>Four</p>\n<div>Div</div>", doc.body().html());
     }
 
     @Test public void retainAll() {
@@ -533,12 +533,12 @@ public class ElementsTest {
         assertTrue(removed);
         assertEquals(2, midPs.size());
 
-        assertEquals("<p>Two</p>\n<p>Three</p>\n<div>\n Div\n</div>", doc.body().html());
+        assertEquals("<p>Two</p>\n<p>Three</p>\n<div>Div</div>", doc.body().html());
 
         Elements psAgain = doc.select("p");
         assertFalse(midPs.retainAll(psAgain));
 
-        assertEquals("<p>Two</p>\n<p>Three</p>\n<div>\n Div\n</div>", doc.body().html());
+        assertEquals("<p>Two</p>\n<p>Three</p>\n<div>Div</div>", doc.body().html());
     }
 
     @Test public void iteratorRemovesFromDom() {
@@ -597,7 +597,7 @@ public class ElementsTest {
         }
 
         // check dom
-        assertEquals("<div> One</div><div> Two</div><div> Three</div><div> Four</div>", TextUtil.normalizeSpaces(doc.body().html()));
+        assertEquals("<div>One</div><div>Two</div><div>Three</div><div>Four</div>", TextUtil.normalizeSpaces(doc.body().html()));
     }
 
     @Test void selectFirst() {
diff --git a/src/test/java/org/jsoup/select/SelectorTest.java b/src/test/java/org/jsoup/select/SelectorTest.java
index 2693893e..040b10cf 100644
--- a/src/test/java/org/jsoup/select/SelectorTest.java
+++ b/src/test/java/org/jsoup/select/SelectorTest.java
@@ -1450,4 +1450,15 @@ public class SelectorTest {
         assertTrue(b_needs_a.threadMemo.get().isEmpty());
         assertTrue(c_needs_b.threadMemo.get().isEmpty());
     }
+
+    @Test void hexDigitUnescape() {
+        // tests the select component of https://github.com/jhy/jsoup/pull/2297, with per-spec escapes
+        // literal is: #\30 \%\ Platform\ Image
+        String html = "<img id='0% Platform Image'>";
+        String q = "#\\30 \\%\\ Platform\\ Image";
+
+        Document doc = Jsoup.parse(html);
+        Element img = doc.expectFirst(q);
+        assertEquals("img", img.tagName());
+    }
 }
diff --git a/src/test/java/org/jsoup/select/TraversorTest.java b/src/test/java/org/jsoup/select/TraversorTest.java
index 3e08149c..dd4400be 100644
--- a/src/test/java/org/jsoup/select/TraversorTest.java
+++ b/src/test/java/org/jsoup/select/TraversorTest.java
@@ -97,7 +97,7 @@ public class TraversorTest {
                 return ("b".equals(node.nodeName())) ? FilterResult.REMOVE : FilterResult.CONTINUE;
             }
         }, doc.select("div"));
-        assertEquals("<div></div>\n<div>\n There be\n</div>", doc.select("body").html());
+        assertEquals("<div></div>\n<div>There be</div>", doc.select("body").html());
     }
 
     @Test
diff --git a/src/test/resources/htmltests/comments.html b/src/test/resources/htmltests/comments.html
index e071c636..6ac56771 100644
--- a/src/test/resources/htmltests/comments.html
+++ b/src/test/resources/htmltests/comments.html
@@ -11,6 +11,6 @@
   <meta http-equiv="Content-type" content="text/html; charset=utf-8"/>
   <title>A Certain Kind of Test</title>
 </head>
-<body><h1>Hello</h1>h1></body>
+<body><h1>Hello</h1></body>
 (There is a UTF8 hidden BOM at the top of this file.)
 </html>
diff --git a/src/test/resources/printertests/input-1.html b/src/test/resources/printertests/input-1.html
new file mode 100644
index 00000000..b060975a
--- /dev/null
+++ b/src/test/resources/printertests/input-1.html
@@ -0,0 +1,77 @@
+
+<!DOCTYPE html>
+<!-- Some formatting tests -->
+
+<html lang="en">
+
+<head>
+    <meta charset="UTF-8">
+    <title>HTML output test</title>
+    <!-- Another comment -->
+</head>
+
+
+<body>
+
+<header>
+    <h1>H1 text</h1> other text <a href="/foo/">link</a> text
+    <div>&nbsp;</div>
+</header>
+
+<section><header><h1>Header</h1></header>Text after header.</section>
+
+  <div>Standalone text in block</div>
+<div><span>content</span></div>
+<div><span></span></div>
+  <div>Text with <b>inline</b> <b>elements</b> in a div.</div>
+  <div>
+      <p>In a para</p>
+      <p>
+          Other in a para.
+      </p>
+      <!-- comment in a div -->
+
+  </div>
+
+<p>
+    Some text.
+</p>
+
+<p>Some text with a<br>line break.</p>
+<p>Some text with a couple<br>
+    <br>line breaks.</p>
+<p>Some text with a couple<br><br>line breaks.</p>
+
+<a><p>A becomes block</p><div>If it has block children.</div></a>
+
+<div>
+    <p><a href="/example"><b>A bold link.</b></a></p>
+    <p>Something <unknown>custom</unknown>.</p>
+</div>
+
+<script>
+    let foo = 2;
+    foo++;
+</script>
+
+<div>
+    <pre>
+        Some preformatted text.
+
+        <span>
+            A span in pre.
+            <pre>
+                Deeper.
+                Still.
+
+            </pre>
+
+        </span>
+    </pre>
+
+</div>
+
+<div>This is some <span>spanned</span> text. <p>Then in a P.</p>And out.</div>
+
+</body>
+</html>
diff --git a/src/test/resources/printertests/outline-1.html b/src/test/resources/printertests/outline-1.html
new file mode 100644
index 00000000..d9f98b81
--- /dev/null
+++ b/src/test/resources/printertests/outline-1.html
@@ -0,0 +1,100 @@
+<!doctype html>
+<!-- Some formatting tests -->
+<html lang="en">
+ <head>
+  <meta charset="UTF-8">
+  <title>HTML output test</title>
+  <!-- Another comment -->
+ </head>
+ <body>
+  <header>
+   <h1>H1 text</h1>
+   other text
+   <a href="/foo/">link</a>
+   text
+   <div>&nbsp;</div>
+  </header>
+  <section>
+   <header>
+    <h1>Header</h1>
+   </header>
+   Text after header.
+  </section>
+  <div>Standalone text in block</div>
+  <div>
+   <span>content</span>
+  </div>
+  <div>
+   <span></span>
+  </div>
+  <div>
+   Text with
+   <b>inline</b>
+   <b>elements</b>
+   in a div.
+  </div>
+  <div>
+   <p>In a para</p>
+   <p>Other in a para.</p>
+   <!-- comment in a div -->
+  </div>
+  <p>Some text.</p>
+  <p>
+   Some text with a
+   <br>
+   line break.
+  </p>
+  <p>
+   Some text with a couple
+   <br>
+   <br>
+   line breaks.
+  </p>
+  <p>
+   Some text with a couple
+   <br>
+   <br>
+   line breaks.
+  </p>
+  <a>
+   <p>A becomes block</p>
+   <div>If it has block children.</div>
+  </a>
+  <div>
+   <p>
+    <a href="/example">
+     <b>A bold link.</b>
+    </a>
+   </p>
+   <p>
+    Something
+    <unknown>custom</unknown>
+    .
+   </p>
+  </div>
+  <script>
+    let foo = 2;
+    foo++;
+</script>
+  <div>
+   <pre>        Some preformatted text.
+
+        <span>
+            A span in pre.
+            <pre>                Deeper.
+                Still.
+
+            </pre>
+
+        </span>
+    </pre>
+  </div>
+  <div>
+   This is some
+   <span>spanned</span>
+   text.
+   <p>Then in a P.</p>
+   And out.
+  </div>
+ </body>
+</html>
\ No newline at end of file
diff --git a/src/test/resources/printertests/passthru-1.html b/src/test/resources/printertests/passthru-1.html
new file mode 100644
index 00000000..06489d50
--- /dev/null
+++ b/src/test/resources/printertests/passthru-1.html
@@ -0,0 +1,74 @@
+<!doctype html>
+<!-- Some formatting tests -->
+
+<html lang="en">
+
+<head>
+    <meta charset="UTF-8">
+    <title>HTML output test</title>
+    <!-- Another comment -->
+</head>
+
+
+<body>
+
+<header>
+    <h1>H1 text</h1> other text <a href="/foo/">link</a> text
+    <div>&nbsp;</div>
+</header>
+
+<section><header><h1>Header</h1></header>Text after header.</section>
+
+  <div>Standalone text in block</div>
+<div><span>content</span></div>
+<div><span></span></div>
+  <div>Text with <b>inline</b> <b>elements</b> in a div.</div>
+  <div>
+      <p>In a para</p>
+      <p>
+          Other in a para.
+      </p>
+      <!-- comment in a div -->
+
+  </div>
+
+<p>
+    Some text.
+</p>
+
+<p>Some text with a<br>line break.</p>
+<p>Some text with a couple<br>
+    <br>line breaks.</p>
+<p>Some text with a couple<br><br>line breaks.</p>
+
+<a><p>A becomes block</p><div>If it has block children.</div></a>
+
+<div>
+    <p><a href="/example"><b>A bold link.</b></a></p>
+    <p>Something <unknown>custom</unknown>.</p>
+</div>
+
+<script>
+    let foo = 2;
+    foo++;
+</script>
+
+<div>
+    <pre>        Some preformatted text.
+
+        <span>
+            A span in pre.
+            <pre>                Deeper.
+                Still.
+
+            </pre>
+
+        </span>
+    </pre>
+
+</div>
+
+<div>This is some <span>spanned</span> text. <p>Then in a P.</p>And out.</div>
+
+</body>
+</html>
diff --git a/src/test/resources/printertests/pretty-1.html b/src/test/resources/printertests/pretty-1.html
new file mode 100644
index 00000000..eb5b450a
--- /dev/null
+++ b/src/test/resources/printertests/pretty-1.html
@@ -0,0 +1,79 @@
+<!doctype html>
+<!-- Some formatting tests -->
+<html lang="en">
+ <head>
+  <meta charset="UTF-8">
+  <title>HTML output test</title>
+  <!-- Another comment -->
+ </head>
+ <body>
+  <header>
+   <h1>H1 text</h1>
+   other text <a href="/foo/">link</a> text
+   <div>&nbsp;</div>
+  </header>
+  <section>
+   <header>
+    <h1>Header</h1>
+   </header>
+   Text after header.
+  </section>
+  <div>Standalone text in block</div>
+  <div>
+   <span>content</span>
+  </div>
+  <div>
+   <span></span>
+  </div>
+  <div>
+   Text with <b>inline</b> <b>elements</b> in a div.
+  </div>
+  <div>
+   <p>In a para</p>
+   <p>Other in a para.</p>
+   <!-- comment in a div -->
+  </div>
+  <p>Some text.</p>
+  <p>Some text with a
+   <br>
+   line break.</p>
+  <p>Some text with a couple
+   <br>
+   <br>
+   line breaks.</p>
+  <p>Some text with a couple
+   <br>
+   <br>
+   line breaks.</p>
+  <a>
+   <p>A becomes block</p>
+   <div>If it has block children.</div>
+  </a>
+  <div>
+   <p><a href="/example"><b>A bold link.</b></a></p>
+   <p>Something <unknown>custom</unknown>.</p>
+  </div>
+  <script>
+    let foo = 2;
+    foo++;
+</script>
+  <div>
+   <pre>        Some preformatted text.
+
+        <span>
+            A span in pre.
+            <pre>                Deeper.
+                Still.
+
+            </pre>
+
+        </span>
+    </pre>
+  </div>
+  <div>
+   This is some <span>spanned</span> text.
+   <p>Then in a P.</p>
+   And out.
+  </div>
+ </body>
+</html>
\ No newline at end of file