Repository: jhy/jsoup
Pull request #1870 (2022-12-23 03:28:38)

Use Java 8's Consumer interface.
* Use the `Consumer` interface introduced in Java 8, as it can be backported for older versions of Android via [core library desugaring](https://developer.android.com/studio/write/java8-support#library-desugaring).

jhy: Thanks, but can't merge with the build failures. Is there an update mojo sniffer?

And would need to make sure the API change is backwards compatible too.
Isira-Seneviratne: > Thanks, but can't merge with the build failures. Is there an update mojo sniffer?

I didn't change anything else, so I'm not sure why the build is failing.

> And would need to make sure the API change is backwards compatible too.

With existing calls to `forEach`?
jhy: The build fails because we use the Mojo Animal Sniffer plugin to verify that all APIs used are in the appropriate Java and Android language levels. It doesn't believe that the Consumer interface is available on Android -- which is why I added that internal workaround.

The error in the build is:

```
Error:  /home/runner/work/jsoup/jsoup/src/main/java/org/jsoup/nodes/Node.java:665: Undefined reference: void java.util.function.Consumer.accept(Object)
Error:  /home/runner/work/jsoup/jsoup/src/main/java/org/jsoup/nodes/Element.java:1825: Undefined reference: void java.util.function.Consumer.accept(Object)
Error:  Failed to execute goal org.codehaus.mojo:animal-sniffer-maven-plugin:1.22:check (animal-sniffer) on project jsoup: Signature errors found. Verify them and ignore them with the proper annotation if needed. -> [Help 1]
```

The check is defined in the POM: https://github.com/jhy/jsoup/blob/29be991198d3b518d64ee36176ae415688468f61/pom.xml#L74-L78

I don't see an [update](https://mvnrepository.com/artifact/net.sf.androidscents.signature/android-api-level-10) to that signature definition that supports the APIs brought in by new desugaring implementation. So we would need to find a different way to do those tests. Or allow-list the API explicitly, or something.

Isira-Seneviratne: I added an ignore rule for the Mojo Animal Sniffer plugin.
jhy: Cool - the next issue to resolve is the API backwards compat issue - the interface has changed:

```
Error:  Failed to execute goal com.github.siom79.japicmp:japicmp-maven-plugin:0.17.1:cmp (default) on project jsoup: There is at least one incompatibility: org.jsoup.helper.Consumer.accept(java.lang.Object):METHOD_REMOVED,org.jsoup.helper.Consumer:SUPERCLASS_REMOVED,org.jsoup.helper.Consumer:CLASS_REMOVED,org.jsoup.helper.Consumer:CLASS_GENERIC_TEMPLATE_CHANGED,org.jsoup.nodes.CDataNode:METHOD_REMOVED_IN_SUPERCLASS,org.jsoup.nodes.Comment:METHOD_REMOVED_IN_SUPERCLASS,org.jsoup.nodes.DataNode:METHOD_REMOVED_IN_SUPERCLASS,org.jsoup.nodes.Document:METHOD_REMOVED_IN_SUPERCLASS,org.jsoup.nodes.DocumentType:METHOD_REMOVED_IN_SUPERCLASS,org.jsoup.nodes.Element.forEach(org.jsoup.helper.Consumer):METHOD_REMOVED,org.jsoup.nodes.Element.forEachNode(org.jsoup.helper.Consumer):METHOD_REMOVED,org.jsoup.nodes.Element:METHOD_REMOVED_IN_SUPERCLASS,org.jsoup.nodes.FormElement:METHOD_REMOVED_IN_SUPERCLASS,org.jsoup.nodes.Node.forEachNode(org.jsoup.helper.Consumer):METHOD_REMOVED,org.jsoup.nodes.PseudoTextElement:METHOD_REMOVED_IN_SUPERCLASS,org.jsoup.nodes.TextNode:METHOD_REMOVED_IN_SUPERCLASS,org.jsoup.nodes.XmlDeclaration:METHOD_REMOVED_IN_SUPERCLASS -> [Help 1]
```

I think we can deal with that by making the jsoup Consumer extend from the Java Consumer, and mark it deprecated for removal in the next release.
Isira-Seneviratne: > Cool - the next issue to resolve is the API backwards compat issue - the interface has changed:
> 
> ```
> Error:  Failed to execute goal com.github.siom79.japicmp:japicmp-maven-plugin:0.17.1:cmp (default) on project jsoup: There is at least one incompatibility: org.jsoup.helper.Consumer.accept(java.lang.Object):METHOD_REMOVED,org.jsoup.helper.Consumer:SUPERCLASS_REMOVED,org.jsoup.helper.Consumer:CLASS_REMOVED,org.jsoup.helper.Consumer:CLASS_GENERIC_TEMPLATE_CHANGED,org.jsoup.nodes.CDataNode:METHOD_REMOVED_IN_SUPERCLASS,org.jsoup.nodes.Comment:METHOD_REMOVED_IN_SUPERCLASS,org.jsoup.nodes.DataNode:METHOD_REMOVED_IN_SUPERCLASS,org.jsoup.nodes.Document:METHOD_REMOVED_IN_SUPERCLASS,org.jsoup.nodes.DocumentType:METHOD_REMOVED_IN_SUPERCLASS,org.jsoup.nodes.Element.forEach(org.jsoup.helper.Consumer):METHOD_REMOVED,org.jsoup.nodes.Element.forEachNode(org.jsoup.helper.Consumer):METHOD_REMOVED,org.jsoup.nodes.Element:METHOD_REMOVED_IN_SUPERCLASS,org.jsoup.nodes.FormElement:METHOD_REMOVED_IN_SUPERCLASS,org.jsoup.nodes.Node.forEachNode(org.jsoup.helper.Consumer):METHOD_REMOVED,org.jsoup.nodes.PseudoTextElement:METHOD_REMOVED_IN_SUPERCLASS,org.jsoup.nodes.TextNode:METHOD_REMOVED_IN_SUPERCLASS,org.jsoup.nodes.XmlDeclaration:METHOD_REMOVED_IN_SUPERCLASS -> [Help 1]
> ```
> 
> I think we can deal with that by making the jsoup Consumer extend from the Java Consumer, and mark it deprecated for removal in the next release.
> 

Yeah, I was thinking of adding overloaded methods for the separate consumer types, but your solution is much better.


Isira-Seneviratne: I tried removing the `accept` method in the Jsoup `Consumer`, but Mojo gave an error saying the method had been removed, even though it's there in the parent interface.

Patch:
diff --git a/CHANGES b/CHANGES
index 0c231694..97f0c936 100644
--- a/CHANGES
+++ b/CHANGES
@@ -3,11 +3,20 @@ jsoup changelog
 Release 1.15.4 [PENDING]
   * Improvement: when pretty-printing, wrap text that follows a <br> tag.
     <https://github.com/jhy/jsoup/issues/1858>
+
+  * Improvement: when pretty-printing, normalize newlines that follow self-closing tags in custom tags.
+    <https://github.com/jhy/jsoup/issues/1852>
+
+  * Improvement: when pretty-printing, collapse non-significant whitespace between a block and an inline tag.
+    <https://github.com/jhy/jsoup/issues/1802>
     
   * Bugfix: URLs containing characters such as [ and ] were not escaped correctly, and would throw a
     MalformedURLException when fetched.
     <https://github.com/jhy/jsoup/issues/1873>
 
+  * Bgufix: element.text() should have a space between a block and an inline element.
+    <https://github.com/jhy/jsoup/issues/1877>
+
 Release 1.15.3 [2022-Aug-24]
   * Security: fixed an issue where the jsoup cleaner may incorrectly sanitize crafted XSS attempts if
     SafeList.preserveRelativeLinks is enabled.
diff --git a/README.md b/README.md
index f2177eb0..7add2721 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,9 @@ jsoup is an open source project distributed under the liberal [MIT license](http
 2. Read the [cookbook](https://jsoup.org/cookbook/)
 3. Enjoy!
 
+### Android support
+When used in Android projects, [core library desugaring](https://developer.android.com/studio/write/java8-support#library-desugaring) should be enabled to support Java 8+ features.
+
 ## Development and support
 If you have any questions on how to use jsoup, or have ideas for future development, please get in touch via the [mailing list](https://jsoup.org/discussion).
 
diff --git a/pom.xml b/pom.xml
index 19fa0239..94396f36 100644
--- a/pom.xml
+++ b/pom.xml
@@ -76,6 +76,9 @@
                 <artifactId>android-api-level-10</artifactId>
                 <version>2.3.3_r2</version>
               </signature>
+              <ignores>java.util.function.Consumer</ignores>
+              <!-- ^ Provided by https://developer.android.com/studio/write/java8-support#library-desugaring
+               Possibly OK to remove androidscents; keep for now to validate other additions are supported. -->
             </configuration>
           </execution>
         </executions>
@@ -202,7 +205,7 @@
             <dependency>
               <groupId>org.jsoup</groupId>
               <artifactId>jsoup</artifactId>
-              <version>1.15.1</version>
+              <version>1.15.3</version>
               <type>jar</type>
             </dependency>
           </oldVersion>
@@ -211,9 +214,11 @@
             <onlyModified>false</onlyModified>
             <breakBuildOnBinaryIncompatibleModifications>true</breakBuildOnBinaryIncompatibleModifications>
             <breakBuildOnSourceIncompatibleModifications>true</breakBuildOnSourceIncompatibleModifications>
-            <!-- <excludes>
-              <exclude>@java.lang.Deprecated</exclude>
-            </excludes> -->
+            <excludes>
+              <!-- <exclude>@java.lang.Deprecated</exclude> -->
+              <exclude>org.jsoup.nodes.Element#forEachNode(org.jsoup.helper.Consumer)</exclude>
+            </excludes>
+            <!-- japicmp is tripping on a non-removed method when changed ancestor of Consumer in Element - have manually ensure binary and source compat for Consumer changes -->
             <overrideCompatibilityChangeParameters>
               <!-- allows new default and move to default methods. compatible as long as existing binaries aren't making calls via reflection. if so, they need to catch errors anyway. -->
               <overrideCompatibilityChangeParameter>
diff --git a/src/main/java/org/jsoup/helper/Consumer.java b/src/main/java/org/jsoup/helper/Consumer.java
index 0ded06e7..6c2075dc 100644
--- a/src/main/java/org/jsoup/helper/Consumer.java
+++ b/src/main/java/org/jsoup/helper/Consumer.java
@@ -1,16 +1,20 @@
 package org.jsoup.helper;
 
 /**
- A functional interface (ala Java's {@link java.util.function.Consumer} interface, implemented here for cross compatibility with Android.
+ A functional interface (ala Java's {@link java.util.function.Consumer} interface, previously implemented here for cross
+ compatibility with Android before desugaring support was introduced.
  @param <T> the input type
- */
+ @deprecated Use {@link java.util.function.Consumer} instead. This interface will be removed in the next release. If you
+ are targeting Android, see how to enable
+ <a href="https://developer.android.com/studio/write/java8-support#library-desugaring">desugaring</a>. */
+@Deprecated
 @FunctionalInterface
-public interface Consumer<T> {
-
+public interface Consumer<T> extends java.util.function.Consumer<T> {
     /**
      * Execute this operation on the supplied argument. It is expected to have side effects.
      *
      * @param t the input argument
      */
+    @Override
     void accept(T t);
 }
diff --git a/src/main/java/org/jsoup/nodes/Element.java b/src/main/java/org/jsoup/nodes/Element.java
index 111de008..b9ba7656 100644
--- a/src/main/java/org/jsoup/nodes/Element.java
+++ b/src/main/java/org/jsoup/nodes/Element.java
@@ -1,7 +1,6 @@
 package org.jsoup.nodes;
 
 import org.jsoup.helper.ChangeNotifyingArrayList;
-import org.jsoup.helper.Consumer;
 import org.jsoup.helper.Validate;
 import org.jsoup.internal.NonnullByDefault;
 import org.jsoup.internal.StringUtil;
@@ -27,10 +26,12 @@ import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.function.Consumer;
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
 
 import static org.jsoup.internal.Normalizer.normalize;
+import static org.jsoup.nodes.TextNode.lastCharIsWhitespace;
 
 /**
  * A HTML element consists of a tag name, attributes, and child nodes (including text nodes and
@@ -276,7 +277,7 @@ public class Element extends Node {
 
     private static void accumulateParents(Element el, Elements parents) {
         Element parent = el.parent();
-        if (parent != null && !parent.tagName().equals("#root")) {
+        if (parent != null && !parent.isNode("#root")) {
             parents.add(parent);
             accumulateParents(parent, parents);
         }
@@ -717,7 +718,7 @@ public class Element extends Node {
     /**
      * Create and prepend a new TextNode to this element.
      *
-     * @param text the unencoded text to add
+     * @param text the decoded text to add
      * @return this element
      */
     public Element prependText(String text) {
@@ -859,7 +860,7 @@ public class Element extends Node {
             selector.append(String.format(
                 ":nth-child(%d)", elementSiblingIndex() + 1));
 
-        return parent().cssSelector() + selector.toString();
+        return parent().cssSelector() + selector;
     }
 
     /**
@@ -1297,7 +1298,7 @@ public class Element extends Node {
      nodes (such as {@code <script>} tags are not considered text. Use {@link #data()} or {@link #html()} to retrieve
      that content.
 
-     @return unencoded, normalized text, or empty string if none.
+     @return decoded, normalized text, or empty string if none.
      @see #wholeText()
      @see #ownText()
      @see #textNodes()
@@ -1312,17 +1313,18 @@ public class Element extends Node {
                 } else if (node instanceof Element) {
                     Element element = (Element) node;
                     if (accum.length() > 0 &&
-                        (element.isBlock() || element.tag.normalName().equals("br")) &&
-                        !TextNode.lastCharIsWhitespace(accum))
+                        (element.isBlock() || element.isNode("br")) &&
+                        !lastCharIsWhitespace(accum))
                         accum.append(' ');
                 }
             }
 
             public void tail(Node node, int depth) {
-                // make sure there is a space between block tags and immediately following text nodes <div>One</div>Two should be "One Two".
+                // make sure there is a space between block tags and immediately following text nodes or inline elements <div>One</div>Two should be "One Two".
                 if (node instanceof Element) {
                     Element element = (Element) node;
-                    if (element.isBlock() && (node.nextSibling() instanceof TextNode) && !TextNode.lastCharIsWhitespace(accum))
+                    Node next = node.nextSibling();
+                    if (element.isBlock() && (next instanceof TextNode || next instanceof Element && !((Element) next).tag.formatAsBlock()) && !lastCharIsWhitespace(accum))
                         accum.append(' ');
                 }
 
@@ -1333,11 +1335,11 @@ public class Element extends Node {
     }
 
     /**
-     * Get the (unencoded) text of all children of this element, including any newlines and spaces present in the
-     * original.
-     *
-     * @return unencoded, un-normalized text
-     * @see #text()
+     Get the non-normalized, decoded text of this element and its children, including only any newlines and spaces
+     present in the original source.
+     @return decoded, non-normalized text
+     @see #text()
+     @see #wholeOwnText()
      */
     public String wholeText() {
         final StringBuilder accum = StringUtil.borrowBuilder();
@@ -1348,16 +1350,15 @@ public class Element extends Node {
     private static void appendWholeText(Node node, StringBuilder accum) {
         if (node instanceof TextNode) {
             accum.append(((TextNode) node).getWholeText());
-        } else if (node instanceof Element) {
-            appendNewlineIfBr((Element) node, accum);
+        } else if (node.isNode("br")) {
+            accum.append("\n");
         }
     }
 
     /**
-     Get the (unencoded) text of this element, <b>not including</b> any child elements, including any newlines and spaces
-     present in the original.
-
-     @return unencoded, un-normalized text that is a direct child of this Element
+     Get the non-normalized, decoded text of this element, <b>not including</b> any child elements, including only any
+     newlines and spaces present in the original source.
+     @return decoded, non-normalized text that is a direct child of this Element
      @see #text()
      @see #wholeText()
      @see #ownText()
@@ -1381,7 +1382,7 @@ public class Element extends Node {
      * whereas {@code p.text()} returns {@code "Hello there now!"}.
      * Note that the text within the {@code b} element is not returned, as it is not a direct child of the {@code p} element.
      *
-     * @return unencoded text, or empty string if none.
+     * @return decoded text, or empty string if none.
      * @see #text()
      * @see #textNodes()
      */
@@ -1397,31 +1398,18 @@ public class Element extends Node {
             if (child instanceof TextNode) {
                 TextNode textNode = (TextNode) child;
                 appendNormalisedText(accum, textNode);
-            } else if (child instanceof Element) {
-                appendWhitespaceIfBr((Element) child, accum);
+            } else if (child.isNode("br") && !lastCharIsWhitespace(accum)) {
+                accum.append(" ");
             }
         }
     }
 
     private static void appendNormalisedText(StringBuilder accum, TextNode textNode) {
         String text = textNode.getWholeText();
-
         if (preserveWhitespace(textNode.parentNode) || textNode instanceof CDataNode)
             accum.append(text);
         else
-            StringUtil.appendNormalisedWhitespace(accum, text, TextNode.lastCharIsWhitespace(accum));
-    }
-
-    /** For normalized text, treat a br element as a space, if there is not already a space. */
-    private static void appendWhitespaceIfBr(Element element, StringBuilder accum) {
-        if (element.tag.normalName().equals("br") && !TextNode.lastCharIsWhitespace(accum))
-            accum.append(" ");
-    }
-
-    /** For WholeText, treat a br element as a newline. */
-    private static void appendNewlineIfBr(Element element, StringBuilder accum) {
-        if (element.tag.normalName().equals("br"))
-            accum.append("\n");
+            StringUtil.appendNormalisedWhitespace(accum, text, lastCharIsWhitespace(accum));
     }
 
     static boolean preserveWhitespace(@Nullable Node node) {
@@ -1443,7 +1431,7 @@ public class Element extends Node {
      * Set the text of this element. Any existing contents (text or elements) will be cleared.
      * <p>As a special case, for {@code <script>} and {@code <style>} tags, the input text will be treated as data,
      * not visible text.</p>
-     * @param text unencoded text
+     * @param text decoded text
      * @return this element
      */
     public Element text(String text) {
@@ -1827,6 +1815,18 @@ public class Element extends Node {
         return this;
     }
 
+    /**
+     @deprecated Use {@link #forEach(Consumer)} instead.
+     */
+    public Element forEach(org.jsoup.helper.Consumer<? super Element> action) {
+        Validate.notNull(action);
+        NodeTraversor.traverse((node, depth) -> {
+            if (node instanceof Element)
+                action.accept((Element) node);
+        }, this);
+        return this;
+    }
+
     @Override
     public Element filter(NodeFilter nodeFilter) {
         return  (Element) super.filter(nodeFilter);
diff --git a/src/main/java/org/jsoup/nodes/Node.java b/src/main/java/org/jsoup/nodes/Node.java
index 0e7825fe..eee9291b 100644
--- a/src/main/java/org/jsoup/nodes/Node.java
+++ b/src/main/java/org/jsoup/nodes/Node.java
@@ -1,7 +1,6 @@
 package org.jsoup.nodes;
 
 import org.jsoup.SerializationException;
-import org.jsoup.helper.Consumer;
 import org.jsoup.helper.Validate;
 import org.jsoup.internal.StringUtil;
 import org.jsoup.select.NodeFilter;
@@ -16,6 +15,7 @@ import java.util.Collections;
 import java.util.Iterator;
 import java.util.LinkedList;
 import java.util.List;
+import java.util.function.Consumer;
 
 /**
  The base, abstract Node model. Elements, Documents, Comments etc are all Node instances.
@@ -39,6 +39,16 @@ public abstract class Node implements Cloneable {
      */
     public abstract String nodeName();
 
+    /**
+     Get the normalized name of this node. For node types other than Element, this is the same as {@link #nodeName()}.
+     For an Element, will be the lower-cased tag name.
+     @return normalized node name
+     @since 1.15.4.
+     */
+    public String normalName() {
+        return nodeName();
+    }
+
     /**
      * Check if this Node has an actual Attributes object.
      */
@@ -666,6 +676,15 @@ public abstract class Node implements Cloneable {
         return this;
     }
 
+    /**
+     @deprecated Use {@link #forEachNode(Consumer)} instead.
+     */
+    public Node forEachNode(org.jsoup.helper.Consumer<? super Node> action) {
+        Validate.notNull(action);
+        NodeTraversor.traverse((node, depth) -> action.accept(node), this);
+        return this;
+    }
+
     /**
      * Perform a depth-first filtering through this node and its descendants.
      * @param nodeFilter the filter callbacks to perform on each node
@@ -725,6 +744,16 @@ public abstract class Node implements Cloneable {
         return Range.of(this, true);
     }
 
+    /** Test if this node is not null and has the supplied normal name. */
+    static boolean isNode(@Nullable Node node, String normalName) {
+        return node != null && node.normalName().equals(normalName);
+    }
+
+    /** Test if this node has the supplied normal name. */
+    final boolean isNode(String normalName) {
+        return normalName().equals(normalName);
+    }
+
     /**
      * Gets this node's outer HTML.
      * @return outer HTML.
diff --git a/src/main/java/org/jsoup/nodes/TextNode.java b/src/main/java/org/jsoup/nodes/TextNode.java
index ecb39aa5..04cf453b 100644
--- a/src/main/java/org/jsoup/nodes/TextNode.java
+++ b/src/main/java/org/jsoup/nodes/TextNode.java
@@ -84,26 +84,27 @@ public class TextNode extends LeafNode {
         final boolean prettyPrint = out.prettyPrint();
         final Element parent = parentNode instanceof Element ? ((Element) parentNode) : null;
         final boolean normaliseWhite = prettyPrint && !Element.preserveWhitespace(parentNode);
+        final boolean trimLikeBlock = parent != null && (parent.tag().isBlock() || parent.tag().formatAsBlock());
+        boolean trimLeading = false, trimTrailing = false;
 
-        boolean trimLeading = false;
-        boolean trimTrailing = false;
         if (normaliseWhite) {
-            trimLeading = (siblingIndex == 0 && parent != null && parent.tag().isBlock()) ||
-                parentNode instanceof Document;
-            trimTrailing = nextSibling() == null && parent != null && parent.tag().isBlock();
+            trimLeading = (trimLikeBlock && siblingIndex == 0) || parentNode instanceof Document;
+            trimTrailing = trimLikeBlock && nextSibling() == null;
 
             // 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
+                || (next instanceof TextNode && (((TextNode) next).isBlank())) // next is blank text, from re-parenting
+                || (prev instanceof Element && ((Element) prev).isBlock())
+                ;
             if (couldSkip && isBlank) return;
 
-            Node prev = previousSibling();
             if (
                 (siblingIndex == 0 && parent != null && parent.tag().formatAsBlock() && !isBlank) ||
                 (out.outline() && siblingNodes().size() > 0 && !isBlank) ||
-                (siblingIndex > 0 && prev instanceof Element && ((Element) prev).normalName().equals("br")) // special case wrap on inline <br> - doesn't make sense as a block tag
+                (siblingIndex > 0 && isNode(prev, "br")) // special case wrap on inline <br> - doesn't make sense as a block tag
             )
                 indent(accum, depth, out);
         }


Test patch:
diff --git a/src/test/java/org/jsoup/nodes/ElementTest.java b/src/test/java/org/jsoup/nodes/ElementTest.java
index a0551f01..1d9b301f 100644
--- a/src/test/java/org/jsoup/nodes/ElementTest.java
+++ b/src/test/java/org/jsoup/nodes/ElementTest.java
@@ -3,7 +3,6 @@ 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;
@@ -16,7 +15,6 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.MethodSource;
 
-import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
@@ -178,6 +176,17 @@ public class ElementTest {
         assertEquals("Hello there now", doc.text());
     }
 
+    @Test public void textHasSpacesAfterBlock() {
+        Document doc = Jsoup.parse("<div>One</div><div>Two</div><span>Three</span><p>Fou<i>r</i></p>");
+        String text = doc.text();
+        String wholeText = doc.wholeText();
+
+        assertEquals("One Two Three Four", text);
+        assertEquals("OneTwoThreeFour",wholeText);
+
+        assertEquals("OneTwo",Jsoup.parse("<span>One</span><span>Two</span>").text());
+    }
+
     @Test
     public void testWholeText() {
         Document doc = Jsoup.parse("<p> Hello\nthere &nbsp;  </p>");
@@ -2359,7 +2368,7 @@ 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><a> Hello </a>\n" +
             " <p>Some text</p>\n" +
             "</div>", doc.body().html());
     }
@@ -2369,4 +2378,22 @@ public class ElementTest {
         Document doc = Jsoup.parse(html);
         assertEquals("<div><a>Text</a>\n</div>", doc.body().html());
     }
+
+    @Test void noDanglingSpaceAfterCustomElement() {
+        // https://github.com/jhy/jsoup/issues/1852
+        String html = "<bar><p/>\n</bar>";
+        Document doc = Jsoup.parse(html);
+        assertEquals("<bar>\n <p></p>\n</bar>", doc.body().html());
+
+        html = "<foo>\n  <bar />\n</foo>";
+        doc = Jsoup.parse(html);
+        assertEquals("<foo>\n <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);
+    }
 }
\ No newline at end of file
diff --git a/src/test/java/org/jsoup/nodes/NodeTest.java b/src/test/java/org/jsoup/nodes/NodeTest.java
index 37bc7027..5cd22c55 100644
--- a/src/test/java/org/jsoup/nodes/NodeTest.java
+++ b/src/test/java/org/jsoup/nodes/NodeTest.java
@@ -402,4 +402,17 @@ public class NodeTest {
         assertNull(firstEl.firstElementChild());
         assertNull(firstEl.lastElementChild());
     }
+
+    @Test void nodeName() {
+        Element div = new Element("DIV");
+        assertEquals("DIV", div.tagName());
+        assertEquals("DIV", div.nodeName());
+        assertEquals("div", div.normalName());
+        assertTrue(div.isNode("div"));
+        assertTrue(Node.isNode(div, "div"));
+
+        TextNode text = new TextNode("Some Text");
+        assertEquals("#text", text.nodeName());
+        assertEquals("#text", text.normalName());
+    }
 }
diff --git a/src/test/java/org/jsoup/parser/HtmlParserTest.java b/src/test/java/org/jsoup/parser/HtmlParserTest.java
index eba7744c..766bd1c2 100644
--- a/src/test/java/org/jsoup/parser/HtmlParserTest.java
+++ b/src/test/java/org/jsoup/parser/HtmlParserTest.java
@@ -617,7 +617,7 @@ public class HtmlParserTest {
     @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() {
@@ -638,13 +638,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 <div>there</div><span>now</span></a>", TextUtil.stripNewlines(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() {
@@ -727,7 +727,7 @@ public class HtmlParserTest {
             "    <td><p><i>Three</i></p><p><i>Four</i></p></td>\n" +
             "   </tr>\n" +
             "  </tbody>\n" +
-            " </table> <p>Five</p></b>";
+            " </table><p>Five</p></b>";
         assertEquals(want, doc.body().html());
     }
 
@@ -1487,7 +1487,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 tagsMustStartWithAscii() {