Repository: jhy/jsoup
Pull request #2321 (2025-05-06 04:54:45)

Add QuietAppender
The QuietAppender implements Appender, and rethrows any IOException as a runtime SerializationException.

This simplifies a bunch of throws and wraps that were otherwise scattered through the code.

As most cases of the appendable will be a StringBuilder, a specialized implementation just calls append without the try/catch.


Patch:
diff --git a/src/main/java/org/jsoup/internal/QuietAppendable.java b/src/main/java/org/jsoup/internal/QuietAppendable.java
new file mode 100644
index 00000000..b561e007
--- /dev/null
+++ b/src/main/java/org/jsoup/internal/QuietAppendable.java
@@ -0,0 +1,92 @@
+package org.jsoup.internal;
+
+import org.jsoup.SerializationException;
+
+import java.io.IOException;
+
+/**
+ A jsoup internal class to wrap an Appendable and throw IOExceptions as SerializationExceptions.
+ <p>Only implements the appendable methods we actually use.</p>
+ */
+public abstract class QuietAppendable {
+
+    public abstract QuietAppendable append(CharSequence csq);
+
+    public abstract QuietAppendable append(char c);
+
+    public abstract QuietAppendable append(char[] chars, int offset, int len); // via StringBuilder, not Appendable
+
+    static final class BaseAppendable extends QuietAppendable {
+        private final Appendable a;
+
+        @FunctionalInterface
+        private interface Action {
+            void append() throws IOException;
+        }
+
+        private BaseAppendable(Appendable appendable) {
+            this.a = appendable;
+        }
+
+        private BaseAppendable quiet(Action action) {
+            try {
+                action.append();
+            } catch (IOException e) {
+                throw new SerializationException(e);
+            }
+            return this;
+        }
+
+        @Override
+        public BaseAppendable append(CharSequence csq) {
+            return quiet(() -> a.append(csq));
+        }
+
+        @Override
+        public BaseAppendable append(char c) {
+            return quiet(() -> a.append(c));
+        }
+
+        @Override
+        public QuietAppendable append(char[] chars, int offset, int len) {
+            return quiet(() -> a.append(new String(chars, offset, len)));
+        }
+    }
+
+    /** A version that wraps a StringBuilder, and so doesn't need the exception wrap. */
+    static final class StringBuilderAppendable extends QuietAppendable {
+        private final StringBuilder sb;
+
+        private StringBuilderAppendable(StringBuilder sb) {
+            this.sb = sb;
+        }
+
+        @Override
+        public StringBuilderAppendable append(CharSequence csq) {
+            sb.append(csq);
+            return this;
+        }
+
+        @Override
+        public StringBuilderAppendable append(char c) {
+            sb.append(c);
+            return this;
+        }
+
+        @Override
+        public QuietAppendable append(char[] chars, int offset, int len) {
+            sb.append(chars, offset, len);
+            return this;
+        }
+
+        @Override
+        public String toString() {
+            return sb.toString();
+        }
+    }
+
+    public static QuietAppendable wrap(Appendable a) {
+        if (a instanceof StringBuilder) return new StringBuilderAppendable((StringBuilder) a);
+        else                            return new BaseAppendable(a);
+    }
+}
diff --git a/src/main/java/org/jsoup/nodes/Attribute.java b/src/main/java/org/jsoup/nodes/Attribute.java
index 96f13a9b..5a701ae1 100644
--- a/src/main/java/org/jsoup/nodes/Attribute.java
+++ b/src/main/java/org/jsoup/nodes/Attribute.java
@@ -1,8 +1,8 @@
 package org.jsoup.nodes;
 
-import org.jsoup.SerializationException;
 import org.jsoup.helper.Validate;
 import org.jsoup.internal.Normalizer;
+import org.jsoup.internal.QuietAppendable;
 import org.jsoup.internal.SharedConstants;
 import org.jsoup.internal.StringUtil;
 import org.jsoup.nodes.Document.OutputSettings.Syntax;
@@ -171,12 +171,7 @@ public class Attribute implements Map.Entry<String, String>, Cloneable  {
      */
     public String html() {
         StringBuilder sb = StringUtil.borrowBuilder();
-        
-        try {
-        	html(sb, (new Document("")).outputSettings());
-        } catch(IOException exception) {
-        	throw new SerializationException(exception);
-        }
+        html(QuietAppendable.wrap(sb), new Document.OutputSettings());
         return StringUtil.releaseBuilder(sb);
     }
 
@@ -197,17 +192,27 @@ public class Attribute implements Map.Entry<String, String>, Cloneable  {
         return parent.sourceRange(key);
     }
 
-    protected void html(Appendable accum, Document.OutputSettings out) throws IOException {
+    void html(QuietAppendable accum, Document.OutputSettings out) {
         html(key, val, accum, out);
     }
 
-    protected static void html(String key, @Nullable String val, Appendable accum, Document.OutputSettings out) throws IOException {
+    static void html(String key, @Nullable String val, QuietAppendable accum, Document.OutputSettings out) {
         key = getValidKey(key, out.syntax());
         if (key == null) return; // can't write it :(
         htmlNoValidate(key, val, accum, out);
     }
 
-    static void htmlNoValidate(String key, @Nullable String val, Appendable accum, Document.OutputSettings out) throws IOException {
+    /** @deprecated internal method and will be removed */ // todo @Deprecate
+    protected void html(Appendable accum, Document.OutputSettings out) throws IOException {
+        html(key, val, accum, out);
+    }
+
+    /** @deprecated internal method and will be removed */ // todo @Deprecate
+    protected static void html(String key, @Nullable String val, Appendable accum, Document.OutputSettings out) throws IOException {
+        html(key, val, QuietAppendable.wrap(accum), out);
+    }
+
+    static void htmlNoValidate(String key, @Nullable String val, QuietAppendable accum, Document.OutputSettings out) {
         // structured like this so that Attributes can check we can write first, so it can add whitespace correctly
         accum.append(key);
         if (!shouldCollapseAttribute(key, val, out)) {
diff --git a/src/main/java/org/jsoup/nodes/Attributes.java b/src/main/java/org/jsoup/nodes/Attributes.java
index 12460ee3..478cdf6e 100644
--- a/src/main/java/org/jsoup/nodes/Attributes.java
+++ b/src/main/java/org/jsoup/nodes/Attributes.java
@@ -1,13 +1,12 @@
 package org.jsoup.nodes;
 
-import org.jsoup.SerializationException;
 import org.jsoup.helper.Validate;
+import org.jsoup.internal.QuietAppendable;
 import org.jsoup.internal.SharedConstants;
 import org.jsoup.internal.StringUtil;
 import org.jsoup.parser.ParseSettings;
 import org.jspecify.annotations.Nullable;
 
-import java.io.IOException;
 import java.util.AbstractMap;
 import java.util.AbstractSet;
 import java.util.ArrayList;
@@ -486,15 +485,11 @@ public class Attributes implements Iterable<Attribute>, Cloneable {
      */
     public String html() {
         StringBuilder sb = StringUtil.borrowBuilder();
-        try {
-            html(sb, (new Document("")).outputSettings()); // output settings a bit funky, but this html() seldom used
-        } catch (IOException e) { // ought never happen
-            throw new SerializationException(e);
-        }
+        html(QuietAppendable.wrap(sb), new Document.OutputSettings()); // output settings a bit funky, but this html() seldom used
         return StringUtil.releaseBuilder(sb);
     }
 
-    final void html(final Appendable accum, final Document.OutputSettings out) throws IOException {
+    final void html(final QuietAppendable accum, final Document.OutputSettings out) {
         final int sz = size;
         for (int i = 0; i < sz; i++) {
             String key = keys[i];
diff --git a/src/main/java/org/jsoup/nodes/CDataNode.java b/src/main/java/org/jsoup/nodes/CDataNode.java
index 32594cca..cdc3e0a1 100644
--- a/src/main/java/org/jsoup/nodes/CDataNode.java
+++ b/src/main/java/org/jsoup/nodes/CDataNode.java
@@ -1,6 +1,6 @@
 package org.jsoup.nodes;
 
-import java.io.IOException;
+import org.jsoup.internal.QuietAppendable;
 
 /**
  * A Character Data node, to support CDATA sections.
@@ -25,7 +25,7 @@ public class CDataNode extends TextNode {
     }
 
     @Override
-    void outerHtmlHead(Appendable accum, Document.OutputSettings out) throws IOException {
+    void outerHtmlHead(QuietAppendable accum, Document.OutputSettings out) {
         accum
             .append("<![CDATA[")
             .append(getWholeText())
diff --git a/src/main/java/org/jsoup/nodes/Comment.java b/src/main/java/org/jsoup/nodes/Comment.java
index dba59512..7815ee69 100644
--- a/src/main/java/org/jsoup/nodes/Comment.java
+++ b/src/main/java/org/jsoup/nodes/Comment.java
@@ -1,10 +1,9 @@
 package org.jsoup.nodes;
 
-import org.jsoup.parser.ParseSettings;
+import org.jsoup.internal.QuietAppendable;
 import org.jsoup.parser.Parser;
 import org.jspecify.annotations.Nullable;
 
-import java.io.IOException;
 import java.util.List;
 
 /**
@@ -38,7 +37,7 @@ public class Comment extends LeafNode {
     }
 
     @Override
-    void outerHtmlHead(Appendable accum, Document.OutputSettings out) throws IOException {
+    void outerHtmlHead(QuietAppendable accum, Document.OutputSettings out) {
         accum
             .append("<!--")
             .append(getData())
diff --git a/src/main/java/org/jsoup/nodes/DataNode.java b/src/main/java/org/jsoup/nodes/DataNode.java
index 0cb72908..5f8688d2 100644
--- a/src/main/java/org/jsoup/nodes/DataNode.java
+++ b/src/main/java/org/jsoup/nodes/DataNode.java
@@ -1,6 +1,6 @@
 package org.jsoup.nodes;
 
-import java.io.IOException;
+import org.jsoup.internal.QuietAppendable;
 
 /**
  A data node, for contents of style, script tags etc, where contents should not show in text().
@@ -39,7 +39,7 @@ public class DataNode extends LeafNode {
     }
 
     @Override
-    void outerHtmlHead(Appendable accum, Document.OutputSettings out) throws IOException {
+    void outerHtmlHead(QuietAppendable accum, Document.OutputSettings out) {
         /* 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();
diff --git a/src/main/java/org/jsoup/nodes/DocumentType.java b/src/main/java/org/jsoup/nodes/DocumentType.java
index b3c93afc..279a91a6 100644
--- a/src/main/java/org/jsoup/nodes/DocumentType.java
+++ b/src/main/java/org/jsoup/nodes/DocumentType.java
@@ -1,11 +1,11 @@
 package org.jsoup.nodes;
 
+import org.jsoup.internal.QuietAppendable;
 import org.jsoup.internal.StringUtil;
 import org.jsoup.helper.Validate;
 import org.jsoup.nodes.Document.OutputSettings.Syntax;
 import org.jspecify.annotations.Nullable;
 
-import java.io.IOException;
 
 /**
  * A {@code <!DOCTYPE>} node.
@@ -78,7 +78,7 @@ public class DocumentType extends LeafNode {
     }
 
     @Override
-    void outerHtmlHead(Appendable accum, Document.OutputSettings out) throws IOException {
+    void outerHtmlHead(QuietAppendable accum, Document.OutputSettings out) {
         if (out.syntax() == Syntax.html && !has(PublicId) && !has(SystemId)) {
             // looks like a html5 doctype, go lowercase for aesthetics
             accum.append("<!doctype");
diff --git a/src/main/java/org/jsoup/nodes/Element.java b/src/main/java/org/jsoup/nodes/Element.java
index b49ab657..0204cc7f 100644
--- a/src/main/java/org/jsoup/nodes/Element.java
+++ b/src/main/java/org/jsoup/nodes/Element.java
@@ -2,6 +2,7 @@ package org.jsoup.nodes;
 
 import org.jsoup.helper.Validate;
 import org.jsoup.internal.Normalizer;
+import org.jsoup.internal.QuietAppendable;
 import org.jsoup.internal.StringUtil;
 import org.jsoup.parser.ParseSettings;
 import org.jsoup.parser.Parser;
@@ -16,7 +17,6 @@ import org.jsoup.select.NodeVisitor;
 import org.jsoup.select.Selector;
 import org.jspecify.annotations.Nullable;
 
-import java.io.IOException;
 import java.lang.ref.WeakReference;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -1821,7 +1821,7 @@ public class Element extends Node implements Iterable<Element> {
     }
 
     @Override
-    void outerHtmlHead(final Appendable accum, Document.OutputSettings out) throws IOException {
+    void outerHtmlHead(final QuietAppendable accum, Document.OutputSettings out) {
         String tagName = safeTagName(out.syntax());
         accum.append('<').append(tagName);
         if (attributes != null) attributes.html(accum, out);
@@ -1841,7 +1841,7 @@ public class Element extends Node implements Iterable<Element> {
     }
 
     @Override
-    void outerHtmlTail(Appendable accum, Document.OutputSettings out) throws IOException {
+    void outerHtmlTail(QuietAppendable accum, Document.OutputSettings out) {
         if (!childNodes.isEmpty())
             accum.append("</").append(safeTagName(out.syntax())).append('>');
         // if empty, we have already closed in htmlHead
@@ -1860,9 +1860,9 @@ public class Element extends Node implements Iterable<Element> {
      * @see #outerHtml()
      */
     public String html() {
-        StringBuilder accum = StringUtil.borrowBuilder();
-        html(accum);
-        String html = StringUtil.releaseBuilder(accum);
+        StringBuilder sb = StringUtil.borrowBuilder();
+        html(sb);
+        String html = StringUtil.releaseBuilder(sb);
         return NodeUtils.outputSettings(this).prettyPrint() ? html.trim() : html;
     }
 
@@ -1870,7 +1870,7 @@ public class Element extends Node implements Iterable<Element> {
     public <T extends Appendable> T html(T accum) {
         Node child = firstChild();
         if (child != null) {
-            Printer printer = Printer.printerFor(child, accum);
+            Printer printer = Printer.printerFor(child, QuietAppendable.wrap(accum));
             while (child != null) {
                 NodeTraversor.traverse(printer, child);
                 child = child.nextSibling();
diff --git a/src/main/java/org/jsoup/nodes/Entities.java b/src/main/java/org/jsoup/nodes/Entities.java
index 964a172c..ad379a74 100644
--- a/src/main/java/org/jsoup/nodes/Entities.java
+++ b/src/main/java/org/jsoup/nodes/Entities.java
@@ -1,14 +1,13 @@
 package org.jsoup.nodes;
 
-import org.jsoup.SerializationException;
 import org.jsoup.helper.DataUtil;
+import org.jsoup.internal.QuietAppendable;
 import org.jsoup.internal.StringUtil;
 import org.jsoup.helper.Validate;
 import org.jsoup.nodes.Document.OutputSettings;
 import org.jsoup.parser.CharacterReader;
 import org.jsoup.parser.Parser;
 
-import java.io.IOException;
 import java.nio.charset.Charset;
 import java.nio.charset.CharsetEncoder;
 import java.util.ArrayList;
@@ -183,23 +182,17 @@ public class Entities {
     }
 
     private static String escapeString(String data, EscapeMode escapeMode, Syntax syntax, Charset charset) {
-        if (data == null)
-            return "";
-        StringBuilder accum = StringUtil.borrowBuilder();
-        try {
-            doEscape(data, accum, escapeMode, syntax, charset, ForText | ForAttribute);
-        } catch (IOException e) {
-            throw new SerializationException(e); // doesn't happen
-        }
-        return StringUtil.releaseBuilder(accum);
+        if (data == null) return "";
+        StringBuilder sb = StringUtil.borrowBuilder();
+        doEscape(data, QuietAppendable.wrap(sb), escapeMode, syntax, charset, ForText | ForAttribute);
+        return StringUtil.releaseBuilder(sb);
     }
 
-
-    static void escape(Appendable accum, String data, OutputSettings out, int options) throws IOException {
+    static void escape(QuietAppendable accum, String data, OutputSettings out, int options) {
         doEscape(data, accum, out.escapeMode(), out.syntax(), out.charset(), options);
     }
 
-    private static void doEscape(String data, Appendable accum, EscapeMode mode, Syntax syntax, Charset charset, int options) throws IOException {
+    private static void doEscape(String data, QuietAppendable accum, EscapeMode mode, Syntax syntax, Charset charset, int options) {
         final CoreCharset coreCharset = CoreCharset.byName(charset.name());
         final CharsetEncoder fallback = encoderFor(charset);
         final int length = data.length();
@@ -235,8 +228,8 @@ public class Entities {
         }
     }
 
-    private static void appendEscaped(int codePoint, Appendable accum, int options, EscapeMode escapeMode,
-        Syntax syntax, CoreCharset coreCharset, CharsetEncoder fallback) throws IOException {
+    private static void appendEscaped(int codePoint, QuietAppendable accum, int options, EscapeMode escapeMode,
+        Syntax syntax, CoreCharset coreCharset, CharsetEncoder fallback) {
         // specific character range for xml 1.0; drop (not encode) if so
         if (EscapeMode.xhtml == escapeMode && !isValidXmlChar(codePoint)) {
             return;
@@ -284,10 +277,7 @@ public class Entities {
                 // reads into charBuf - we go through these steps to avoid GC objects as much as possible (would be a new String and a new char[2] for each character)
                 char[] chars = charBuf.get();
                 int len = Character.toChars(codePoint, chars, 0);
-                if (accum instanceof StringBuilder) // true unless the user supplied their own
-                    ((StringBuilder) accum).append(chars, 0, len);
-                else
-                    accum.append(new String(chars, 0, len));
+                accum.append(chars, 0, len);
             } else {
                 appendEncoded(accum, escapeMode, codePoint);
             }
@@ -296,17 +286,17 @@ public class Entities {
 
     private static final ThreadLocal<char[]> charBuf = ThreadLocal.withInitial(() -> new char[2]);
 
-    private static void appendNbsp(Appendable accum, EscapeMode escapeMode) throws IOException {
+    private static void appendNbsp(QuietAppendable accum, EscapeMode escapeMode) {
         if (escapeMode != EscapeMode.xhtml) accum.append("&nbsp;");
         else accum.append("&#xa0;");
     }
 
-    private static void appendLt(Appendable accum, int options, EscapeMode escapeMode, Syntax syntax) throws IOException {
+    private static void appendLt(QuietAppendable accum, int options, EscapeMode escapeMode, Syntax syntax) {
         if ((options & ForText) != 0 || escapeMode == EscapeMode.xhtml || syntax == Syntax.xml) accum.append("&lt;");
         else accum.append('<'); // no need to escape < when in an HTML attribute
     }
 
-    private static void appendApos(Appendable accum, int options, EscapeMode escapeMode) throws IOException {
+    private static void appendApos(QuietAppendable accum, int options, EscapeMode escapeMode) {
         if ((options & ForAttribute) != 0 && (options & ForText) != 0) {
             if (escapeMode == EscapeMode.xhtml) accum.append("&#x27;");
             else accum.append("&apos;");
@@ -315,7 +305,7 @@ public class Entities {
         }
     }
 
-    private static void appendEncoded(Appendable accum, EscapeMode escapeMode, int codePoint) throws IOException {
+    private static void appendEncoded(QuietAppendable accum, EscapeMode escapeMode, int codePoint) {
         final String name = escapeMode.nameForCodepoint(codePoint);
         if (!emptyName.equals(name)) // ok for identity check
             accum.append('&').append(name).append(';');
diff --git a/src/main/java/org/jsoup/nodes/LeafNode.java b/src/main/java/org/jsoup/nodes/LeafNode.java
index e23693c1..254266b3 100644
--- a/src/main/java/org/jsoup/nodes/LeafNode.java
+++ b/src/main/java/org/jsoup/nodes/LeafNode.java
@@ -1,8 +1,8 @@
 package org.jsoup.nodes;
 
 import org.jsoup.helper.Validate;
+import org.jsoup.internal.QuietAppendable;
 
-import java.io.IOException;
 import java.util.List;
 
 /**
@@ -110,7 +110,7 @@ public abstract class LeafNode extends Node {
     }
 
     @Override
-    void outerHtmlTail(Appendable accum, Document.OutputSettings out) throws IOException {}
+    void outerHtmlTail(QuietAppendable accum, Document.OutputSettings out) {}
 
     @Override
     protected LeafNode doClone(Node parent) {
diff --git a/src/main/java/org/jsoup/nodes/Node.java b/src/main/java/org/jsoup/nodes/Node.java
index 2bce8a1f..6fd3594a 100644
--- a/src/main/java/org/jsoup/nodes/Node.java
+++ b/src/main/java/org/jsoup/nodes/Node.java
@@ -1,6 +1,7 @@
 package org.jsoup.nodes;
 
 import org.jsoup.helper.Validate;
+import org.jsoup.internal.QuietAppendable;
 import org.jsoup.internal.StringUtil;
 import org.jsoup.parser.ParseSettings;
 import org.jsoup.select.NodeFilter;
@@ -759,12 +760,16 @@ public abstract class Node implements Cloneable {
      @see Element#text()
      */
     public String outerHtml() {
-        StringBuilder accum = StringUtil.borrowBuilder();
-        outerHtml(accum);
-        return StringUtil.releaseBuilder(accum);
+        StringBuilder sb = StringUtil.borrowBuilder();
+        outerHtml(QuietAppendable.wrap(sb));
+        return StringUtil.releaseBuilder(sb);
     }
 
     protected void outerHtml(Appendable accum) {
+        outerHtml(QuietAppendable.wrap(accum));
+    }
+
+    protected void outerHtml(QuietAppendable accum) {
         Printer printer = Printer.printerFor(this, accum);
         NodeTraversor.traverse(printer, this);
     }
@@ -774,17 +779,17 @@ public abstract class Node implements Cloneable {
 
      @param accum accumulator to place HTML into
      @param out
-     @throws IOException if appending to the given accumulator fails.
      */
-    abstract void outerHtmlHead(final Appendable accum, final Document.OutputSettings out) throws IOException;
+    abstract void outerHtmlHead(final QuietAppendable accum, final Document.OutputSettings out);
 
-    abstract void outerHtmlTail(final Appendable accum, final Document.OutputSettings out) throws IOException;
+    abstract void outerHtmlTail(final QuietAppendable accum, final Document.OutputSettings out);
 
     /**
-     * Write this node and its children to the given {@link Appendable}.
-     *
-     * @param appendable the {@link Appendable} to write to.
-     * @return the supplied {@link Appendable}, for chaining.
+     Write this node and its children to the given {@link Appendable}.
+
+     @param appendable the {@link Appendable} to write to.
+     @return the supplied {@link Appendable}, for chaining.
+     @throws org.jsoup.SerializationException if the appendable throws an IOException.
      */
     public <T extends Appendable> T html(T appendable) {
         outerHtml(appendable);
@@ -815,6 +820,7 @@ public abstract class Node implements Cloneable {
         return outerHtml();
     }
 
+    /** @deprecated internal method moved into Printer; will be removed. */ // todo @Deprecate
     protected void indent(Appendable accum, int depth, Document.OutputSettings out) throws IOException {
         accum.append('\n').append(StringUtil.padding(depth * out.indentAmount(), out.maxPaddingWidth()));
     }
diff --git a/src/main/java/org/jsoup/nodes/Printer.java b/src/main/java/org/jsoup/nodes/Printer.java
index c5e9f584..ec417f1e 100644
--- a/src/main/java/org/jsoup/nodes/Printer.java
+++ b/src/main/java/org/jsoup/nodes/Printer.java
@@ -1,66 +1,56 @@
 package org.jsoup.nodes;
 
-import org.jsoup.SerializationException;
+import org.jsoup.internal.QuietAppendable;
 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 QuietAppendable accum;
     final OutputSettings settings;
 
-    public Printer(Node root, Appendable accum, OutputSettings settings) {
+    Printer(Node root, QuietAppendable accum, OutputSettings settings) {
         this.root = root;
         this.accum = accum;
         this.settings = settings;
     }
 
-    void addHead(Element el, int depth) throws IOException {
+    void addHead(Element el, int depth) {
         el.outerHtmlHead(accum, settings);
     }
 
-    void addTail(Element el, int depth) throws IOException {
+    void addTail(Element el, int depth) {
         el.outerHtmlTail(accum, settings);
     }
 
-    void addText(TextNode textNode, int textOptions, int depth) throws IOException {
+    void addText(TextNode textNode, int textOptions, int depth) {
         int options = Entities.ForText | textOptions;
         Entities.escape(accum, textNode.coreValue(), settings, options);
     }
 
-    void addNode(LeafNode node, int depth) throws IOException  {
+    void addNode(LeafNode node, int depth) {
         node.outerHtmlHead(accum, settings);
     }
 
-    void indent(int depth) throws IOException {
+    void indent(int depth) {
         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);
-        }
+        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);
     }
 
     @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);
-            }
+            addTail((Element) node, depth);
         }
     }
 
@@ -68,7 +58,7 @@ class Printer implements NodeVisitor {
     static class Pretty extends Printer {
         boolean preserveWhitespace = false;
 
-        public Pretty(Node root, Appendable accum, OutputSettings settings) {
+        Pretty(Node root, QuietAppendable accum, OutputSettings settings) {
             super(root, accum, settings);
 
             // check if there is a pre on stack
@@ -81,7 +71,7 @@ class Printer implements NodeVisitor {
         }
 
         @Override
-        void addHead(Element el, int depth) throws IOException {
+        void addHead(Element el, int depth) {
             if (shouldIndent(el))
                 indent(depth);
             super.addHead(el, depth);
@@ -89,7 +79,7 @@ class Printer implements NodeVisitor {
         }
 
         @Override
-        void addTail(Element el, int depth) throws IOException {
+        void addTail(Element el, int depth) {
             if (shouldIndent(nextNonBlank(el.firstChild()))) {
                 indent(depth);
             }
@@ -105,14 +95,14 @@ class Printer implements NodeVisitor {
         }
 
         @Override
-        void addNode(LeafNode node, int depth) throws IOException {
+        void addNode(LeafNode node, int depth) {
             if (shouldIndent(node))
                 indent(depth);
             super.addNode(node, depth);
         }
 
         @Override
-        void addText(TextNode node, int textOptions, int depth) throws IOException {
+        void addText(TextNode node, int textOptions, int depth) {
             if (!preserveWhitespace) {
                 textOptions |= Entities.Normalise;
                 textOptions = textTrim(node, textOptions);
@@ -211,7 +201,7 @@ class Printer implements NodeVisitor {
 
     /** Outline Printer */
     static class Outline extends Pretty {
-        public Outline(Node root, Appendable accum, OutputSettings settings) {
+        Outline(Node root, QuietAppendable accum, OutputSettings settings) {
             super(root, accum, settings);
         }
 
@@ -231,7 +221,7 @@ class Printer implements NodeVisitor {
         }
     }
 
-    static Printer printerFor(Node root, Appendable accum) {
+    static Printer printerFor(Node root, QuietAppendable 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);
diff --git a/src/main/java/org/jsoup/nodes/PseudoTextElement.java b/src/main/java/org/jsoup/nodes/PseudoTextElement.java
index f51acb2b..94c0d1ab 100644
--- a/src/main/java/org/jsoup/nodes/PseudoTextElement.java
+++ b/src/main/java/org/jsoup/nodes/PseudoTextElement.java
@@ -1,5 +1,6 @@
 package org.jsoup.nodes;
 
+import org.jsoup.internal.QuietAppendable;
 import org.jsoup.parser.Tag;
 
 /**
@@ -13,10 +14,10 @@ public class PseudoTextElement extends Element {
     }
 
     @Override
-    void outerHtmlHead(Appendable accum, Document.OutputSettings out) {
+    void outerHtmlHead(QuietAppendable accum, Document.OutputSettings out) {
     }
 
     @Override
-    void outerHtmlTail(Appendable accum, Document.OutputSettings out) {
+    void outerHtmlTail(QuietAppendable 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 c45a9e14..0067a3fb 100644
--- a/src/main/java/org/jsoup/nodes/TextNode.java
+++ b/src/main/java/org/jsoup/nodes/TextNode.java
@@ -1,9 +1,9 @@
 package org.jsoup.nodes;
 
 import org.jsoup.helper.Validate;
+import org.jsoup.internal.QuietAppendable;
 import org.jsoup.internal.StringUtil;
 
-import java.io.IOException;
 
 /**
  A text node.
@@ -81,7 +81,7 @@ public class TextNode extends LeafNode {
     }
 
     @Override
-    void outerHtmlHead(Appendable accum, Document.OutputSettings out) throws IOException {
+    void outerHtmlHead(QuietAppendable accum, Document.OutputSettings out) {
         Entities.escape(accum, coreValue(), out, Entities.ForText);
     }
 
diff --git a/src/main/java/org/jsoup/nodes/XmlDeclaration.java b/src/main/java/org/jsoup/nodes/XmlDeclaration.java
index f7a3a92b..d9fbef50 100644
--- a/src/main/java/org/jsoup/nodes/XmlDeclaration.java
+++ b/src/main/java/org/jsoup/nodes/XmlDeclaration.java
@@ -1,9 +1,8 @@
 package org.jsoup.nodes;
 
-import org.jsoup.SerializationException;
+import org.jsoup.internal.QuietAppendable;
 import org.jsoup.internal.StringUtil;
 
-import java.io.IOException;
 
 /**
  * An XML Declaration. Includes support for treating the declaration contents as pseudo attributes.
@@ -44,15 +43,11 @@ public class XmlDeclaration extends LeafNode {
      */
     public String getWholeDeclaration() {
         StringBuilder sb = StringUtil.borrowBuilder();
-        try {
-            getWholeDeclaration(sb, new Document.OutputSettings());
-        } catch (IOException e) {
-            throw new SerializationException(e);
-        }
+        getWholeDeclaration(QuietAppendable.wrap(sb), new Document.OutputSettings());
         return StringUtil.releaseBuilder(sb).trim();
     }
 
-    private void getWholeDeclaration(Appendable accum, Document.OutputSettings out) throws IOException {
+    private void getWholeDeclaration(QuietAppendable accum, Document.OutputSettings out) {
         for (Attribute attribute : attributes()) {
             String key = attribute.getKey();
             String val = attribute.getValue();
@@ -70,7 +65,7 @@ public class XmlDeclaration extends LeafNode {
     }
 
     @Override
-    void outerHtmlHead(Appendable accum, Document.OutputSettings out) throws IOException {
+    void outerHtmlHead(QuietAppendable accum, Document.OutputSettings out) {
         accum
             .append("<")
             .append(isDeclaration ? "!" : "?")
@@ -82,7 +77,7 @@ public class XmlDeclaration extends LeafNode {
     }
 
     @Override
-    void outerHtmlTail(Appendable accum, Document.OutputSettings out) {
+    void outerHtmlTail(QuietAppendable accum, Document.OutputSettings out) {
     }
 
     @Override
diff --git a/src/main/java/org/jsoup/parser/Tag.java b/src/main/java/org/jsoup/parser/Tag.java
index b978a08f..c867ed9a 100644
--- a/src/main/java/org/jsoup/parser/Tag.java
+++ b/src/main/java/org/jsoup/parser/Tag.java
@@ -23,7 +23,7 @@ public class Tag implements Cloneable {
     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 />). */
+    /** Tag option: the tag can self-close (e.g. {@code <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;


Test patch:
diff --git a/src/test/java/org/jsoup/SerializationExceptionTest.java b/src/test/java/org/jsoup/SerializationExceptionTest.java
index 5a400353..52400615 100644
--- a/src/test/java/org/jsoup/SerializationExceptionTest.java
+++ b/src/test/java/org/jsoup/SerializationExceptionTest.java
@@ -1,10 +1,7 @@
 package org.jsoup;
 
-import org.jsoup.nodes.Document;
 import org.junit.jupiter.api.Test;
 
-import java.io.IOException;
-
 import static org.junit.jupiter.api.Assertions.*;
 
 public class SerializationExceptionTest {
@@ -21,30 +18,4 @@ public class SerializationExceptionTest {
         assertNull(e3.getMessage());
         assertNull(e3.getCause());
     }
-
-    private Appendable brokenAppender() {
-        // returns an Appendable that throws an IOException on any put
-        return new Appendable() {
-            @Override
-            public Appendable append(CharSequence csq) throws IOException {
-                throw new IOException("broken");
-            }
-
-            @Override
-            public Appendable append(CharSequence csq, int start, int end) throws IOException {
-                throw new IOException("broken");
-            }
-
-            @Override
-            public Appendable append(char c) throws IOException {
-                throw new IOException("broken");
-            }
-        };
-    }
-
-    @Test void appendThrowsSerializationException() {
-        Document doc = Jsoup.parse("<div>");
-        Appendable brokenWriter = brokenAppender();
-        assertThrows(SerializationException.class, () -> doc.html(brokenWriter));
-    }
 }
diff --git a/src/test/java/org/jsoup/internal/QuietAppendableTest.java b/src/test/java/org/jsoup/internal/QuietAppendableTest.java
new file mode 100644
index 00000000..018ac7b9
--- /dev/null
+++ b/src/test/java/org/jsoup/internal/QuietAppendableTest.java
@@ -0,0 +1,81 @@
+package org.jsoup.internal;
+
+import org.jsoup.Jsoup;
+import org.jsoup.SerializationException;
+import org.jsoup.nodes.Document;
+import org.junit.jupiter.api.Test;
+
+import java.io.CharArrayWriter;
+import java.io.IOException;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class QuietAppendableTest {
+    @Test void wrap() {
+        assertInstanceOf(
+            QuietAppendable.StringBuilderAppendable.class,
+            QuietAppendable.wrap(new StringBuilder())
+        );
+
+        assertInstanceOf(
+            QuietAppendable.BaseAppendable.class,
+            QuietAppendable.wrap(new CharArrayWriter())
+        );
+    }
+
+    @Test void supplemental() {
+        // hits append(char[] chars, int offset, int len) with len 2 for supplemental codepoint
+        String expect = "😀";
+        char[] chars = new char[2];
+        chars[0] = expect.charAt(0);
+        chars[1] = expect.charAt(1);
+        assertEquals(2, expect.length());
+
+        QuietAppendable sb = QuietAppendable.wrap(new StringBuilder());
+        sb.append(chars, 0, 2);
+        String s = sb.toString();
+        assertEquals(expect, s);
+
+        CharArrayWriter cw = new CharArrayWriter();
+        QuietAppendable qa = QuietAppendable.wrap(cw);
+        qa.append(chars, 0, 2);
+        String out = cw.toString();
+        assertEquals(expect, out);
+    }
+
+    private static Appendable brokenAppender() {
+        // returns an Appendable that throws an IOException on any put
+        return new Appendable() {
+            @Override
+            public Appendable append(CharSequence csq) throws IOException {
+                throw new IOException("broken");
+            }
+
+            @Override
+            public Appendable append(CharSequence csq, int start, int end) throws IOException {
+                throw new IOException("broken");
+            }
+
+            @Override
+            public Appendable append(char c) throws IOException {
+                throw new IOException("broken");
+            }
+        };
+    }
+
+    @Test void appendThrowsSerializationException() {
+        Document doc = Jsoup.parse("<div>");
+        Appendable brokenWriter = brokenAppender();
+        boolean threw = false;
+        try {
+            doc.html(brokenWriter);
+        } catch (SerializationException e) {
+            threw = true;
+            Throwable cause = e.getCause();
+            assertEquals("broken", cause.getMessage());
+            assertInstanceOf(IOException.class, cause);
+        }
+        assertTrue(threw);
+    }
+
+}
diff --git a/src/test/java/org/jsoup/nodes/DataNodeTest.java b/src/test/java/org/jsoup/nodes/DataNodeTest.java
index c8c8934e..70ce38e4 100644
--- a/src/test/java/org/jsoup/nodes/DataNodeTest.java
+++ b/src/test/java/org/jsoup/nodes/DataNodeTest.java
@@ -1,71 +1,74 @@
 package org.jsoup.nodes;
 
+import org.jsoup.internal.QuietAppendable;
 import org.junit.jupiter.api.Test;
 
-import java.io.IOException;
-
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
 public class DataNodeTest {
+    
+    static QuietAppendable appendable() {
+        return QuietAppendable.wrap(new StringBuilder());
+    }
 
     @Test
-    public void xmlOutputScriptWithCData() throws IOException {
+    public void xmlOutputScriptWithCData() {
         DataNode node = new DataNode("//<![CDATA[\nscript && <> data]]>");
         node.parentNode = new Element("script");
-        StringBuilder accum = new StringBuilder();
+        QuietAppendable accum = appendable();
         node.outerHtmlHead(accum, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
         assertEquals("//<![CDATA[\nscript && <> data]]>", accum.toString());
     }
 
     @Test
-    public void xmlOutputScriptWithoutCData() throws IOException {
+    public void xmlOutputScriptWithoutCData() {
         DataNode node = new DataNode("script && <> data");
         node.parentNode = new Element("script");
-        StringBuilder accum = new StringBuilder();
+        QuietAppendable accum = appendable();
         node.outerHtmlHead(accum, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
         assertEquals("//<![CDATA[\nscript && <> data\n//]]>", accum.toString());
     }
 
     @Test
-    public void xmlOutputStyleWithCData() throws IOException {
+    public void xmlOutputStyleWithCData() {
         DataNode node = new DataNode("/*<![CDATA[*/\nstyle && <> data]]>");
         node.parentNode = new Element("style");
-        StringBuilder accum = new StringBuilder();
+        QuietAppendable accum = appendable();
         node.outerHtmlHead(accum, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
         assertEquals("/*<![CDATA[*/\nstyle && <> data]]>", accum.toString());
     }
 
     @Test
-    public void xmlOutputStyleWithoutCData() throws IOException {
+    public void xmlOutputStyleWithoutCData() {
         DataNode node = new DataNode("style && <> data");
         node.parentNode = new Element("style");
-        StringBuilder accum = new StringBuilder();
+        QuietAppendable accum = appendable();
         node.outerHtmlHead(accum, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
         assertEquals("/*<![CDATA[*/\nstyle && <> data\n/*]]>*/", accum.toString());
     }
 
     @Test
-    public void xmlOutputOtherWithCData() throws IOException {
+    public void xmlOutputOtherWithCData() {
         DataNode node = new DataNode("<![CDATA[other && <> data]]>");
         node.parentNode = new Element("other");
-        StringBuilder accum = new StringBuilder();
+        QuietAppendable accum = appendable();
         node.outerHtmlHead(accum, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
         assertEquals("<![CDATA[other && <> data]]>", accum.toString());
     }
 
     @Test
-    public void xmlOutputOtherWithoutCData() throws IOException {
+    public void xmlOutputOtherWithoutCData() {
         DataNode node = new DataNode("other && <> data");
         node.parentNode = new Element("other");
-        StringBuilder accum = new StringBuilder();
+        QuietAppendable accum = appendable();
         node.outerHtmlHead(accum, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
         assertEquals("<![CDATA[other && <> data]]>", accum.toString());
     }
 
     @Test
-    public void xmlOutputOrphanWithoutCData() throws IOException {
+    public void xmlOutputOrphanWithoutCData() {
         DataNode node = new DataNode("other && <> data");
-        StringBuilder accum = new StringBuilder();
+        QuietAppendable accum = appendable();
         node.outerHtmlHead(accum, new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml));
         assertEquals("<![CDATA[other && <> data]]>", accum.toString());
     }