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>\uNNNN</code> notation like Java unicode characters,
030 * e.g., <code>0x001f</code> would appear in the encoded string as <code>\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>\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>\u0020 - \ud7ff</code> and <code>\ue000 - \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