001    
002    /*
003     * Copyright (C) 2011 Archie L. Cobbs. All rights reserved.
004     *
005     * $Id: StringEncoder.java 96 2011-05-09 21:51:18Z archie.cobbs $
006     */
007    
008    package org.dellroad.stuff.string;
009    
010    import java.util.regex.Matcher;
011    import java.util.regex.Pattern;
012    
013    /**
014     * Encodes/decodes Java strings, escaping control and XML-invalid characters.
015     */
016    public final class StringEncoder {
017    
018        private static final char[] HEXDIGITS = "0123456789abcdef".toCharArray();
019    
020        private StringEncoder() {
021        }
022    
023        /**
024         * Encode a string, escaping control and XML-invalid characters.
025         * Whether tab, newline, and carriage return are escaped is optional;
026         * these are the three control characters that are valid inside XML documents.
027         * <p/>
028         * <p/>
029         * Characters are escaped using <code>&#92;uNNNN</code> notation like Java unicode characters,
030         * e.g., <code>0x001f</code> would appear in the encoded string as <code>&#92;u001f</code>.
031         * Normal Java backslash escapes are used for tab, newline, carriage return, backspace, and formfeed.
032         * Backslash characters are themselves encoded with a double backslash.
033         *
034         * @param value         string to encode (possibly null)
035         * @param escapeTABNLCR escape tab, newline, and carriage return characters as well
036         * @return the encoded version of {@code value}, or {@code null} if {@code value} was {@code null}
037         * @see #decode
038         * @see #isValidXMLChar
039         */
040        public static String encode(String value, boolean escapeTABNLCR) {
041            if (value == null)
042                return value;
043            StringBuilder buf = new StringBuilder(value.length() + 4);
044            final int limit = value.length();
045            for (int i = 0; i < limit; i++) {
046                final char ch = value.charAt(i);
047    
048                // Handle special escapes
049                switch (ch) {
050                    case '\\':
051                        buf.append('\\').append('\\');
052                        continue;
053                    case '\b':
054                        buf.append('\\').append('b');
055                        continue;
056                    case '\f':
057                        buf.append('\\').append('f');
058                        continue;
059                    case '\t':
060                        if (escapeTABNLCR) {
061                            buf.append('\\').append('t');
062                            continue;
063                        }
064                        break;
065                    case '\n':
066                        if (escapeTABNLCR) {
067                            buf.append('\\').append('n');
068                            continue;
069                        }
070                        break;
071                    case '\r':
072                        if (escapeTABNLCR) {
073                            buf.append('\\').append('r');
074                            continue;
075                        }
076                        break;
077                    default:
078                        break;
079                }
080    
081                // If character is an otherwise valid XML character, pass it through unchanged
082                if (isValidXMLChar(ch)) {
083                    buf.append(ch);
084                    continue;
085                }
086    
087                // Escape it using 4 digit hex
088                buf.append('\\');
089                buf.append('u');
090                for (int shift = 12; shift >= 0; shift -= 4)
091                    buf.append(HEXDIGITS[(ch >> shift) & 0x0f]);
092            }
093            return buf.toString();
094        }
095    
096        /**
097         * Decode a string encoded by {@link #encode}.
098         * <p/>
099         * <p/>
100         * The parsing is strict; any ill-formed backslash escape sequence (i.e., not of the form
101         * <code>&#92;uNNNN</code>, <code>\b</code>, <code>\t</code>, <code>\n</code>, <code>\f</code>, <code>\r</code>
102         * or <code>\\</code>) will cause an exception to be thrown.
103         *
104         * @param text string to decode (possibly null)
105         * @return the decoded version of {@code text}, or {@code null} if {@code text} was {@code null}
106         * @throws IllegalArgumentException if {@code text} contains an invalid escape sequence
107         * @see #encode
108         */
109        public static String decode(String text) {
110            if (text == null)
111                return null;
112            StringBuilder buf = new StringBuilder(text.length());
113            final int limit = text.length();
114            for (int i = 0; i < limit; i++) {
115                char ch = text.charAt(i);
116    
117                // Handle unescaped characters
118                if (ch != '\\') {
119                    buf.append(ch);
120                    continue;
121                }
122    
123                // Get next char
124                if (++i >= limit)
125                    throw new IllegalArgumentException("illegal trailing '\\' in encoded string");
126                ch = text.charAt(i);
127    
128                // Check for special escapes
129                switch (ch) {
130                    case '\\':
131                        buf.append('\\');
132                        continue;
133                    case 'b':
134                        buf.append('\b');
135                        continue;
136                    case 't':
137                        buf.append('\t');
138                        continue;
139                    case 'n':
140                        buf.append('\n');
141                        continue;
142                    case 'f':
143                        buf.append('\f');
144                        continue;
145                    case 'r':
146                        buf.append('\r');
147                        continue;
148                    default:
149                        break;
150                }
151    
152                // Must be unicode escape
153                if (ch != 'u')
154                    throw new IllegalArgumentException("illegal escape sequence '\\" + ch + "' in encoded string");
155    
156                // Decode hex value
157                int value = 0;
158                for (int j = 0; j < 4; j++) {
159                    if (++i >= limit)
160                        throw new IllegalArgumentException("illegal truncated '\\u' escape sequence in encoded string");
161                    int nibble = Character.digit(text.charAt(i), 16);
162                    if (nibble == -1) {
163                        throw new IllegalArgumentException(
164                          "illegal escape sequence '" + text.substring(i - j - 2, i - j + 4) + "' in encoded string");
165                    }
166                    assert nibble >= 0 && nibble <= 0xf;
167                    value = (value << 4) | nibble;
168                }
169    
170                // Append decodec character
171                buf.append((char)value);
172            }
173            return buf.toString();
174        }
175    
176        /**
177         * Enquote a string. Functions like {@link #encode encode(string, true)} but in addition the resulting
178         * string is surrounded by double quotes and double quotes in the string are backslash-escaped.
179         * <p/>
180         * <p>
181         * Note: the strings returned by this method are not suitable for decoding by {@link #decode}.
182         * Use {@link #dequote} instead.
183         * </p>
184         */
185        public static String enquote(String string) {
186            return '"' + encode(string, true).replaceAll(Pattern.quote("\""), Matcher.quoteReplacement("\\\"")) + '"';
187        }
188    
189        /**
190         * Enquote bytes, treating them as an ASCII string.
191         *
192         * @see #enquote(String)
193         */
194        public static String enquote(byte[] data, int off, int len) {
195            char[] chars = new char[len];
196            for (int i = 0; i < len; i++)
197                chars[i] = (char)(data[i] & 0xff);
198            return enquote(new String(chars));
199        }
200    
201        /**
202         * Dequote a string previously enquoted by {@link #enquote}.
203         *
204         * @param quotedString a string returned by {@link #enquote}
205         * @throws IllegalArgumentException if {@code quotedString} has an invalid format (i.e., it could not have
206         *  ever been returned by {@link #enquote})
207         */
208        public static String dequote(String quotedString) {
209            int len = quotedString.length();
210            if (len == 0 || quotedString.charAt(0) != '"' || quotedString.charAt(len - 1) != '"')
211                throw new IllegalArgumentException("invalid quoted string: not surrounded by quote characters");
212            quotedString = quotedString.substring(1, len - 1);
213            if (quotedString.matches("^(\"|.*[^\\\\]\").*$"))
214                throw new IllegalArgumentException("invalid quoted string: unescaped nested quote character");
215            quotedString = quotedString.replaceAll(Pattern.quote("\\\""), Matcher.quoteReplacement("\""));
216            return decode(quotedString);
217        }
218    
219        /**
220         * Determine the length of a string previously enquoted by {@link #enquote} when it appears
221         * as the prefix of a longer string. This method assumes that the prefix is a valid quoted
222         * string; use {@link #dequote} to verify.
223         *
224         * @param string a string containing a prefix returned by {@link #enquote}
225         * @throws IllegalArgumentException if a starting or terminating quote character is not found
226         */
227        public static int enquotedLength(String string) {
228            int len = string.length();
229            if (len == 0 || string.charAt(0) != '"')
230                throw new IllegalArgumentException("invalid quoted string prefix: string does not begin with a quote character");
231            for (int i = 1; i < len; i++) {
232                if (string.charAt(i) == '"' && string.charAt(i - 1) != '\\')
233                    return i + 1;
234            }
235            throw new IllegalArgumentException("invalid quoted string prefix: no terminating quote character found");
236        }
237    
238        /**
239         * Determine if the given character is a valid XML character according to the XML 1.0 specification.
240         * <p/>
241         * <p>
242         * Valid characters are tab, newline, carriage return, and characters in the ranges
243         * <code>&#92;u0020 - &#92;ud7ff</code> and <code>&#92;ue000 - &#92;fffdf</code> (inclusive).
244         * </p>
245         *
246         * @see <a href="http://www.w3.org/TR/REC-xml/#charsets">The XML 1.0 Specification</a>
247         */
248        public static boolean isValidXMLChar(char ch) {
249            return (ch >= '\u0020' && ch <= '\ud7ff') || ch == '\n' || ch == '\r' || ch == '\t' || (ch >= '\ue000' && ch <= '\ufffd');
250        }
251    }
252