Author: fanningpj
Date: Tue Jun 16 11:01:02 2026
New Revision: 1935402

Log:
escape tab, newline and carriage return in attribute. Thanks to aizu-m. This 
closes #48

Modified:
   xmlbeans/trunk/src/main/java/org/apache/xmlbeans/XmlOptions.java
   xmlbeans/trunk/src/main/java/org/apache/xmlbeans/impl/store/Cursor.java
   xmlbeans/trunk/src/main/java/org/apache/xmlbeans/impl/store/Saver.java
   xmlbeans/trunk/src/test/java/misc/checkin/XmlOptionsTest.java
   xmlbeans/trunk/src/test/java/misc/detailed/CharEscapeTest.java

Modified: xmlbeans/trunk/src/main/java/org/apache/xmlbeans/XmlOptions.java
==============================================================================
--- xmlbeans/trunk/src/main/java/org/apache/xmlbeans/XmlOptions.java    Tue Jun 
16 10:42:44 2026        (r1935401)
+++ xmlbeans/trunk/src/main/java/org/apache/xmlbeans/XmlOptions.java    Tue Jun 
16 11:01:02 2026        (r1935402)
@@ -107,6 +107,7 @@ public class XmlOptions implements java.
         SAVE_CDATA_LENGTH_THRESHOLD,
         SAVE_CDATA_ENTITY_COUNT_THRESHOLD,
         SAVE_SAX_NO_NSDECLS_IN_ATTRIBUTES,
+        SAVE_NO_ATTRIBUTE_WHITESPACE_ESCAPE,
         LOAD_REPLACE_DOCUMENT_ELEMENT,
         LOAD_STRIP_WHITESPACE,
         LOAD_STRIP_COMMENTS,
@@ -558,6 +559,47 @@ public class XmlOptions implements java.
         return hasOption(XmlOptionsKeys.SAVE_NO_XML_DECL);
     }
 
+    /**
+     * By default the saver now escapes a tab ({@code #x9}), newline ({@code 
#xA})
+     * and carriage return ({@code #xD}) inside an attribute value as a 
character
+     * reference ({@code 	}, {@code 
}, {@code 
}). Without that, 
the
+     * literal characters are normalised to spaces when the document is read 
back
+     * in, so a save followed by a load silently rewrites the value. Set this
+     * option to restore the long-standing behaviour of writing those 
characters
+     * literally.
+     *
+     * @return this
+     * @since 5.4.0
+     */
+    public XmlOptions setSaveNoAttributeWhitespaceEscape() {
+        return setSaveNoAttributeWhitespaceEscape(true);
+    }
+
+    /**
+     * Sets whether tab, newline and carriage return are written literally in
+     * attribute values instead of being escaped as character references. See
+     * {@link #setSaveNoAttributeWhitespaceEscape()}.
+     *
+     * @param b {@code true} to write those characters literally (pre-5.4.0 
behaviour)
+     * @return this
+     * @since 5.4.0
+     */
+    public XmlOptions setSaveNoAttributeWhitespaceEscape(boolean b) {
+        return set(XmlOptionsKeys.SAVE_NO_ATTRIBUTE_WHITESPACE_ESCAPE, b);
+    }
+
+    /**
+     * Returns whether tab, newline and carriage return are written literally 
in
+     * attribute values instead of being escaped. See
+     * {@link #setSaveNoAttributeWhitespaceEscape()}.
+     *
+     * @return {@code true} if those characters are written literally
+     * @since 5.4.0
+     */
+    public boolean isSaveNoAttributeWhitespaceEscape() {
+        return hasOption(XmlOptionsKeys.SAVE_NO_ATTRIBUTE_WHITESPACE_ESCAPE);
+    }
+
 
     /**
      * This option controls when saving will use CDATA blocks.

Modified: 
xmlbeans/trunk/src/main/java/org/apache/xmlbeans/impl/store/Cursor.java
==============================================================================
--- xmlbeans/trunk/src/main/java/org/apache/xmlbeans/impl/store/Cursor.java     
Tue Jun 16 10:42:44 2026        (r1935401)
+++ xmlbeans/trunk/src/main/java/org/apache/xmlbeans/impl/store/Cursor.java     
Tue Jun 16 11:01:02 2026        (r1935402)
@@ -179,7 +179,7 @@ public final class Cursor implements Xml
         assert isValid(that);
         assert isValid();
 
-        if (text != null && text.length() > 0) {
+        if (text != null && !text.isEmpty()) {
             that.next();
             that.insertString(text);
             that.toParent();
@@ -247,11 +247,11 @@ public final class Cursor implements Xml
             case PROCINST: {
                 validatePrefix(name.getLocalPart());
 
-                if (name.getNamespaceURI().length() > 0) {
+                if (!name.getNamespaceURI().isEmpty()) {
                     throw new IllegalArgumentException("Procinst name must 
have no URI");
                 }
 
-                if (name.getPrefix().length() > 0) {
+                if (!name.getPrefix().isEmpty()) {
                     throw new IllegalArgumentException("Procinst name must 
have no prefix");
                 }
 
@@ -585,7 +585,7 @@ public final class Cursor implements Xml
         }
 
         if (options != null && options.isSaveOptimizeForSpeed()) {
-            Saver.OptimizedForSpeedSaver.save(_cur, w); //ignore all other 
options
+            Saver.OptimizedForSpeedSaver.save(_cur, w, options); //ignore all 
other options bar attribute whitespace escaping
             return;
         }
 

Modified: xmlbeans/trunk/src/main/java/org/apache/xmlbeans/impl/store/Saver.java
==============================================================================
--- xmlbeans/trunk/src/main/java/org/apache/xmlbeans/impl/store/Saver.java      
Tue Jun 16 10:42:44 2026        (r1935401)
+++ xmlbeans/trunk/src/main/java/org/apache/xmlbeans/impl/store/Saver.java      
Tue Jun 16 11:01:02 2026        (r1935402)
@@ -52,6 +52,7 @@ abstract class Saver {
     private final boolean _useDefaultNamespace;
     private Map<String, String> _preComputedNamespaces;
     private final boolean _saveNamespacesFirst;
+    private final boolean _escapeAttrWhitespace;
 
     private final ArrayList<QName> _attrNames = new ArrayList<>();
     private final ArrayList<String> _attrValues = new ArrayList<>();
@@ -131,6 +132,8 @@ abstract class Saver {
 
         _saveNamespacesFirst = options.isSaveNamespacesFirst();
 
+        _escapeAttrWhitespace = !options.isSaveNoAttributeWhitespaceEscape();
+
 
         _suggestedPrefixes = options.getSaveSuggestedPrefixes();
 
@@ -273,6 +276,10 @@ abstract class Saver {
         return _saveNamespacesFirst;
     }
 
+    protected boolean escapeAttrWhitespace() {
+        return _escapeAttrWhitespace;
+    }
+
     protected final boolean process() {
         assert _locale.entered();
 
@@ -1370,6 +1377,12 @@ abstract class Saver {
                     i = replace(i, "&amp;");
                 } else if (ch == '"') {
                     i = replace(i, "&quot;");
+                } else if (ch == '\t' && escapeAttrWhitespace()) {
+                    i = replace(i, "&#9;");
+                } else if (ch == '\n' && escapeAttrWhitespace()) {
+                    i = replace(i, "&#10;");
+                } else if (ch == '\r' && escapeAttrWhitespace()) {
+                    i = replace(i, "&#13;");
                 } else if (isEscapedChar(ch)) {
                     if (replaceEscapedChar) {
                         i = replace(i, _replaceChar.getEscapedString(ch));
@@ -1576,7 +1589,7 @@ abstract class Saver {
                    (_out == _in && _free == 0)                               
// buffer full
                 : "_buf.length:" + _cbuf.length + " _in:" + _in + " _out:" + 
_out + " _free:" + _free;
 
-            long newLen = _cbuf == null ? _initialBufSize : _cbuf.length * 2;
+            long newLen = _cbuf == null ? _initialBufSize : _cbuf.length * 2L;
             int used = getAvailable();
 
             while (newLen - used < cch) {
@@ -1795,15 +1808,15 @@ abstract class Saver {
         }
 
 
-        OptimizedForSpeedSaver(Cur cur, Writer writer) {
-            super(cur, XmlOptions.maskNull(null));
+        OptimizedForSpeedSaver(Cur cur, Writer writer, XmlOptions options) {
+            super(cur, XmlOptions.maskNull(options));
             _w = writer;
         }
 
-        static void save(Cur cur, Writer writer)
+        static void save(Cur cur, Writer writer, XmlOptions options)
             throws IOException {
             try {
-                Saver saver = new OptimizedForSpeedSaver(cur, writer);
+                Saver saver = new OptimizedForSpeedSaver(cur, writer, options);
                 //noinspection StatementWithEmptyBody
                 while (saver.process()) {
                 }
@@ -2027,6 +2040,12 @@ abstract class Saver {
                     emit("&amp;");
                 } else if (ch == '"') {
                     emit("&quot;");
+                } else if (ch == '\t' && escapeAttrWhitespace()) {
+                    emit("&#9;");
+                } else if (ch == '\n' && escapeAttrWhitespace()) {
+                    emit("&#10;");
+                } else if (ch == '\r' && escapeAttrWhitespace()) {
+                    emit("&#13;");
                 } else {
                     emit(ch);
                 }
@@ -2481,7 +2500,7 @@ abstract class Saver {
             void resize(int cbyte) {
                 assert cbyte > _free : cbyte + " !> " + _free;
 
-                long newLen = _buf == null ? _initialBufSize : _buf.length * 2;
+                long newLen = _buf == null ? _initialBufSize : _buf.length * 
2L;
                 int used = getAvailable();
 
                 while (newLen - used < cbyte) {

Modified: xmlbeans/trunk/src/test/java/misc/checkin/XmlOptionsTest.java
==============================================================================
--- xmlbeans/trunk/src/test/java/misc/checkin/XmlOptionsTest.java       Tue Jun 
16 10:42:44 2026        (r1935401)
+++ xmlbeans/trunk/src/test/java/misc/checkin/XmlOptionsTest.java       Tue Jun 
16 11:01:02 2026        (r1935402)
@@ -40,4 +40,14 @@ public class XmlOptionsTest {
         xmlOptions.setLoadStrictFloatingPoint(false);
         assertFalse(xmlOptions.isLoadStrictFloatingPoint());
     }
+
+    @Test
+    void testSaveNoAttributeWhitespaceEscapeFlag() {
+        XmlOptions xmlOptions = new XmlOptions();
+        assertFalse(xmlOptions.isSaveNoAttributeWhitespaceEscape());
+        xmlOptions.setSaveNoAttributeWhitespaceEscape();
+        assertTrue(xmlOptions.isSaveNoAttributeWhitespaceEscape());
+        xmlOptions.setSaveNoAttributeWhitespaceEscape(false);
+        assertFalse(xmlOptions.isSaveNoAttributeWhitespaceEscape());
+    }
 }

Modified: xmlbeans/trunk/src/test/java/misc/detailed/CharEscapeTest.java
==============================================================================
--- xmlbeans/trunk/src/test/java/misc/detailed/CharEscapeTest.java      Tue Jun 
16 10:42:44 2026        (r1935401)
+++ xmlbeans/trunk/src/test/java/misc/detailed/CharEscapeTest.java      Tue Jun 
16 11:01:02 2026        (r1935402)
@@ -17,11 +17,13 @@ package misc.detailed;
 import jira.xmlbeans177.TestListDocument;
 import jira.xmlbeans177A.TestListADocument;
 import org.apache.xmlbeans.XmlException;
+import org.apache.xmlbeans.XmlObject;
 import org.apache.xmlbeans.XmlOptionCharEscapeMap;
 import org.apache.xmlbeans.XmlOptions;
 import org.junit.jupiter.api.Test;
 
 import java.io.File;
+import java.io.StringWriter;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -236,4 +238,45 @@ public class CharEscapeTest {
             end2;
         assertEquals(exp3, doc.xmlText(opts).replaceFirst("(?s)<!--.*-->", 
""));
     }
+
+    @Test
+    void testEscapeAttributeWhitespace() throws Exception {
+        // tab, newline and carriage return survive attribute-value 
normalisation
+        // only when written as character references; a literal char would be
+        // turned into a space when the document is read back in
+        XmlObject doc = XmlObject.Factory.parse("<r 
a=\"x&#9;y&#10;z&#13;w\"/>");
+
+        String expected = "<r a=\"x&#9;y&#10;z&#13;w\"/>";
+        assertEquals(expected, doc.xmlText());
+
+        StringWriter sw = new StringWriter();
+        doc.save(sw);
+        assertEquals(expected, sw.toString());
+
+        // round-trips with the value intact
+        org.apache.xmlbeans.XmlCursor c = 
XmlObject.Factory.parse(doc.xmlText()).newCursor();
+        c.toFirstChild();
+        assertEquals("x\ty\nz\rw", c.getAttributeText(new 
javax.xml.namespace.QName("a")));
+        c.dispose();
+    }
+
+    @Test
+    void testEscapeAttributeWhitespaceOptOut() throws Exception {
+        // setSaveNoAttributeWhitespaceEscape restores the pre-5.4.0 behaviour 
of
+        // writing tab, newline and carriage return literally
+        XmlObject doc = XmlObject.Factory.parse("<r 
a=\"x&#9;y&#10;z&#13;w\"/>");
+
+        XmlOptions opts = new 
XmlOptions().setSaveNoAttributeWhitespaceEscape();
+        String expected = "<r a=\"x\ty\nz\rw\"/>";
+        assertEquals(expected, doc.xmlText(opts));
+
+        StringWriter sw = new StringWriter();
+        doc.save(sw, opts);
+        assertEquals(expected, sw.toString());
+
+        // the optimize-for-speed writer honours the option too
+        StringWriter sw2 = new StringWriter();
+        doc.save(sw2, new XmlOptions(opts).setSaveOptimizeForSpeed(true));
+        assertEquals(expected, sw2.toString());
+    }
 }

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to