View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *      http://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  package org.argeo.jcr.fs;
18  
19  import java.io.ByteArrayOutputStream;
20  import java.io.UnsupportedEncodingException;
21  import java.security.MessageDigest;
22  import java.security.NoSuchAlgorithmException;
23  import java.util.ArrayList;
24  import java.util.BitSet;
25  import java.util.Properties;
26  
27  /**
28   * <b>Hacked from org.apache.jackrabbit.util.Text in Jackrabbit JCR Commons</b>
29   * This Class provides some text related utilities
30   */
31  class Text {
32  
33  	/**
34  	 * Hidden constructor.
35  	 */
36  	private Text() {
37  	}
38  
39  	/**
40  	 * used for the md5
41  	 */
42  	public static final char[] hexTable = "0123456789abcdef".toCharArray();
43  
44  	/**
45  	 * Calculate an MD5 hash of the string given.
46  	 *
47  	 * @param data
48  	 *            the data to encode
49  	 * @param enc
50  	 *            the character encoding to use
51  	 * @return a hex encoded string of the md5 digested input
52  	 */
53  	public static String md5(String data, String enc) throws UnsupportedEncodingException {
54  		try {
55  			return digest("MD5", data.getBytes(enc));
56  		} catch (NoSuchAlgorithmException e) {
57  			throw new InternalError("MD5 digest not available???");
58  		}
59  	}
60  
61  	/**
62  	 * Calculate an MD5 hash of the string given using 'utf-8' encoding.
63  	 *
64  	 * @param data
65  	 *            the data to encode
66  	 * @return a hex encoded string of the md5 digested input
67  	 */
68  	public static String md5(String data) {
69  		try {
70  			return md5(data, "utf-8");
71  		} catch (UnsupportedEncodingException e) {
72  			throw new InternalError("UTF8 digest not available???");
73  		}
74  	}
75  
76  	/**
77  	 * Digest the plain string using the given algorithm.
78  	 *
79  	 * @param algorithm
80  	 *            The alogrithm for the digest. This algorithm must be supported
81  	 *            by the MessageDigest class.
82  	 * @param data
83  	 *            The plain text String to be digested.
84  	 * @param enc
85  	 *            The character encoding to use
86  	 * @return The digested plain text String represented as Hex digits.
87  	 * @throws java.security.NoSuchAlgorithmException
88  	 *             if the desired algorithm is not supported by the
89  	 *             MessageDigest class.
90  	 * @throws java.io.UnsupportedEncodingException
91  	 *             if the encoding is not supported
92  	 */
93  	public static String digest(String algorithm, String data, String enc)
94  			throws NoSuchAlgorithmException, UnsupportedEncodingException {
95  
96  		return digest(algorithm, data.getBytes(enc));
97  	}
98  
99  	/**
100 	 * Digest the plain string using the given algorithm.
101 	 *
102 	 * @param algorithm
103 	 *            The algorithm for the digest. This algorithm must be supported
104 	 *            by the MessageDigest class.
105 	 * @param data
106 	 *            the data to digest with the given algorithm
107 	 * @return The digested plain text String represented as Hex digits.
108 	 * @throws java.security.NoSuchAlgorithmException
109 	 *             if the desired algorithm is not supported by the
110 	 *             MessageDigest class.
111 	 */
112 	public static String digest(String algorithm, byte[] data) throws NoSuchAlgorithmException {
113 
114 		MessageDigest md = MessageDigest.getInstance(algorithm);
115 		byte[] digest = md.digest(data);
116 		StringBuilder res = new StringBuilder(digest.length * 2);
117 		for (byte b : digest) {
118 			res.append(hexTable[(b >> 4) & 15]);
119 			res.append(hexTable[b & 15]);
120 		}
121 		return res.toString();
122 	}
123 
124 	/**
125 	 * returns an array of strings decomposed of the original string, split at
126 	 * every occurrence of 'ch'. if 2 'ch' follow each other with no
127 	 * intermediate characters, empty "" entries are avoided.
128 	 *
129 	 * @param str
130 	 *            the string to decompose
131 	 * @param ch
132 	 *            the character to use a split pattern
133 	 * @return an array of strings
134 	 */
135 	public static String[] explode(String str, int ch) {
136 		return explode(str, ch, false);
137 	}
138 
139 	/**
140 	 * returns an array of strings decomposed of the original string, split at
141 	 * every occurrence of 'ch'.
142 	 *
143 	 * @param str
144 	 *            the string to decompose
145 	 * @param ch
146 	 *            the character to use a split pattern
147 	 * @param respectEmpty
148 	 *            if <code>true</code>, empty elements are generated
149 	 * @return an array of strings
150 	 */
151 	public static String[] explode(String str, int ch, boolean respectEmpty) {
152 		if (str == null || str.length() == 0) {
153 			return new String[0];
154 		}
155 
156 		ArrayList<String> strings = new ArrayList<String>();
157 		int pos;
158 		int lastpos = 0;
159 
160 		// add snipples
161 		while ((pos = str.indexOf(ch, lastpos)) >= 0) {
162 			if (pos - lastpos > 0 || respectEmpty) {
163 				strings.add(str.substring(lastpos, pos));
164 			}
165 			lastpos = pos + 1;
166 		}
167 		// add rest
168 		if (lastpos < str.length()) {
169 			strings.add(str.substring(lastpos));
170 		} else if (respectEmpty && lastpos == str.length()) {
171 			strings.add("");
172 		}
173 
174 		// return string array
175 		return strings.toArray(new String[strings.size()]);
176 	}
177 
178 	/**
179 	 * Concatenates all strings in the string array using the specified
180 	 * delimiter.
181 	 * 
182 	 * @param arr
183 	 * @param delim
184 	 * @return the concatenated string
185 	 */
186 	public static String implode(String[] arr, String delim) {
187 		StringBuilder buf = new StringBuilder();
188 		for (int i = 0; i < arr.length; i++) {
189 			if (i > 0) {
190 				buf.append(delim);
191 			}
192 			buf.append(arr[i]);
193 		}
194 		return buf.toString();
195 	}
196 
197 	/**
198 	 * Replaces all occurrences of <code>oldString</code> in <code>text</code>
199 	 * with <code>newString</code>.
200 	 *
201 	 * @param text
202 	 * @param oldString
203 	 *            old substring to be replaced with <code>newString</code>
204 	 * @param newString
205 	 *            new substring to replace occurrences of <code>oldString</code>
206 	 * @return a string
207 	 */
208 	public static String replace(String text, String oldString, String newString) {
209 		if (text == null || oldString == null || newString == null) {
210 			throw new IllegalArgumentException("null argument");
211 		}
212 		int pos = text.indexOf(oldString);
213 		if (pos == -1) {
214 			return text;
215 		}
216 		int lastPos = 0;
217 		StringBuilder sb = new StringBuilder(text.length());
218 		while (pos != -1) {
219 			sb.append(text.substring(lastPos, pos));
220 			sb.append(newString);
221 			lastPos = pos + oldString.length();
222 			pos = text.indexOf(oldString, lastPos);
223 		}
224 		if (lastPos < text.length()) {
225 			sb.append(text.substring(lastPos));
226 		}
227 		return sb.toString();
228 	}
229 
230 	/**
231 	 * Replaces XML characters in the given string that might need escaping as
232 	 * XML text or attribute
233 	 *
234 	 * @param text
235 	 *            text to be escaped
236 	 * @return a string
237 	 */
238 	public static String encodeIllegalXMLCharacters(String text) {
239 		return encodeMarkupCharacters(text, false);
240 	}
241 
242 	/**
243 	 * Replaces HTML characters in the given string that might need escaping as
244 	 * HTML text or attribute
245 	 *
246 	 * @param text
247 	 *            text to be escaped
248 	 * @return a string
249 	 */
250 	public static String encodeIllegalHTMLCharacters(String text) {
251 		return encodeMarkupCharacters(text, true);
252 	}
253 
254 	private static String encodeMarkupCharacters(String text, boolean isHtml) {
255 		if (text == null) {
256 			throw new IllegalArgumentException("null argument");
257 		}
258 		StringBuilder buf = null;
259 		int length = text.length();
260 		int pos = 0;
261 		for (int i = 0; i < length; i++) {
262 			int ch = text.charAt(i);
263 			switch (ch) {
264 			case '<':
265 			case '>':
266 			case '&':
267 			case '"':
268 			case '\'':
269 				if (buf == null) {
270 					buf = new StringBuilder();
271 				}
272 				if (i > 0) {
273 					buf.append(text.substring(pos, i));
274 				}
275 				pos = i + 1;
276 				break;
277 			default:
278 				continue;
279 			}
280 			if (ch == '<') {
281 				buf.append("&lt;");
282 			} else if (ch == '>') {
283 				buf.append("&gt;");
284 			} else if (ch == '&') {
285 				buf.append("&amp;");
286 			} else if (ch == '"') {
287 				buf.append("&quot;");
288 			} else if (ch == '\'') {
289 				buf.append(isHtml ? "&#39;" : "&apos;");
290 			}
291 		}
292 		if (buf == null) {
293 			return text;
294 		} else {
295 			if (pos < length) {
296 				buf.append(text.substring(pos));
297 			}
298 			return buf.toString();
299 		}
300 	}
301 
302 	/**
303 	 * The list of characters that are not encoded by the <code>escape()</code>
304 	 * and <code>unescape()</code> METHODS. They contains the characters as
305 	 * defined 'unreserved' in section 2.3 of the RFC 2396 'URI generic syntax':
306 	 * <p>
307 	 * 
308 	 * <pre>
309 	 * unreserved  = alphanum | mark
310 	 * mark        = "-" | "_" | "." | "!" | "~" | "*" | "'" | "(" | ")"
311 	 * </pre>
312 	 */
313 	public static BitSet URISave;
314 
315 	/**
316 	 * Same as {@link #URISave} but also contains the '/'
317 	 */
318 	public static BitSet URISaveEx;
319 
320 	static {
321 		URISave = new BitSet(256);
322 		int i;
323 		for (i = 'a'; i <= 'z'; i++) {
324 			URISave.set(i);
325 		}
326 		for (i = 'A'; i <= 'Z'; i++) {
327 			URISave.set(i);
328 		}
329 		for (i = '0'; i <= '9'; i++) {
330 			URISave.set(i);
331 		}
332 		URISave.set('-');
333 		URISave.set('_');
334 		URISave.set('.');
335 		URISave.set('!');
336 		URISave.set('~');
337 		URISave.set('*');
338 		URISave.set('\'');
339 		URISave.set('(');
340 		URISave.set(')');
341 
342 		URISaveEx = (BitSet) URISave.clone();
343 		URISaveEx.set('/');
344 	}
345 
346 	/**
347 	 * Does an URL encoding of the <code>string</code> using the
348 	 * <code>escape</code> character. The characters that don't need encoding
349 	 * are those defined 'unreserved' in section 2.3 of the 'URI generic syntax'
350 	 * RFC 2396, but without the escape character.
351 	 *
352 	 * @param string
353 	 *            the string to encode.
354 	 * @param escape
355 	 *            the escape character.
356 	 * @return the escaped string
357 	 * @throws NullPointerException
358 	 *             if <code>string</code> is <code>null</code>.
359 	 */
360 	public static String escape(String string, char escape) {
361 		return escape(string, escape, false);
362 	}
363 
364 	/**
365 	 * Does an URL encoding of the <code>string</code> using the
366 	 * <code>escape</code> character. The characters that don't need encoding
367 	 * are those defined 'unreserved' in section 2.3 of the 'URI generic syntax'
368 	 * RFC 2396, but without the escape character. If <code>isPath</code> is
369 	 * <code>true</code>, additionally the slash '/' is ignored, too.
370 	 *
371 	 * @param string
372 	 *            the string to encode.
373 	 * @param escape
374 	 *            the escape character.
375 	 * @param isPath
376 	 *            if <code>true</code>, the string is treated as path
377 	 * @return the escaped string
378 	 * @throws NullPointerException
379 	 *             if <code>string</code> is <code>null</code>.
380 	 */
381 	public static String escape(String string, char escape, boolean isPath) {
382 		try {
383 			BitSet validChars = isPath ? URISaveEx : URISave;
384 			byte[] bytes = string.getBytes("utf-8");
385 			StringBuilder out = new StringBuilder(bytes.length);
386 			for (byte aByte : bytes) {
387 				int c = aByte & 0xff;
388 				if (validChars.get(c) && c != escape) {
389 					out.append((char) c);
390 				} else {
391 					out.append(escape);
392 					out.append(hexTable[(c >> 4) & 0x0f]);
393 					out.append(hexTable[(c) & 0x0f]);
394 				}
395 			}
396 			return out.toString();
397 		} catch (UnsupportedEncodingException e) {
398 			throw new InternalError(e.toString());
399 		}
400 	}
401 
402 	/**
403 	 * Does a URL encoding of the <code>string</code>. The characters that don't
404 	 * need encoding are those defined 'unreserved' in section 2.3 of the 'URI
405 	 * generic syntax' RFC 2396.
406 	 *
407 	 * @param string
408 	 *            the string to encode
409 	 * @return the escaped string
410 	 * @throws NullPointerException
411 	 *             if <code>string</code> is <code>null</code>.
412 	 */
413 	public static String escape(String string) {
414 		return escape(string, '%');
415 	}
416 
417 	/**
418 	 * Does a URL encoding of the <code>path</code>. The characters that don't
419 	 * need encoding are those defined 'unreserved' in section 2.3 of the 'URI
420 	 * generic syntax' RFC 2396. In contrast to the {@link #escape(String)}
421 	 * method, not the entire path string is escaped, but every individual part
422 	 * (i.e. the slashes are not escaped).
423 	 *
424 	 * @param path
425 	 *            the path to encode
426 	 * @return the escaped path
427 	 * @throws NullPointerException
428 	 *             if <code>path</code> is <code>null</code>.
429 	 */
430 	public static String escapePath(String path) {
431 		return escape(path, '%', true);
432 	}
433 
434 	/**
435 	 * Does a URL decoding of the <code>string</code> using the
436 	 * <code>escape</code> character. Please note that in opposite to the
437 	 * {@link java.net.URLDecoder} it does not transform the + into spaces.
438 	 *
439 	 * @param string
440 	 *            the string to decode
441 	 * @param escape
442 	 *            the escape character
443 	 * @return the decoded string
444 	 * @throws NullPointerException
445 	 *             if <code>string</code> is <code>null</code>.
446 	 * @throws IllegalArgumentException
447 	 *             if the 2 characters following the escape character do not
448 	 *             represent a hex-number or if not enough characters follow an
449 	 *             escape character
450 	 */
451 	public static String unescape(String string, char escape) {
452 		try {
453 			byte[] utf8 = string.getBytes("utf-8");
454 
455 			// Check whether escape occurs at invalid position
456 			if ((utf8.length >= 1 && utf8[utf8.length - 1] == escape)
457 					|| (utf8.length >= 2 && utf8[utf8.length - 2] == escape)) {
458 				throw new IllegalArgumentException("Premature end of escape sequence at end of input");
459 			}
460 
461 			ByteArrayOutputStream out = new ByteArrayOutputStream(utf8.length);
462 			for (int k = 0; k < utf8.length; k++) {
463 				byte b = utf8[k];
464 				if (b == escape) {
465 					out.write((decodeDigit(utf8[++k]) << 4) + decodeDigit(utf8[++k]));
466 				} else {
467 					out.write(b);
468 				}
469 			}
470 
471 			return new String(out.toByteArray(), "utf-8");
472 		} catch (UnsupportedEncodingException e) {
473 			throw new InternalError(e.toString());
474 		}
475 	}
476 
477 	/**
478 	 * Does a URL decoding of the <code>string</code>. Please note that in
479 	 * opposite to the {@link java.net.URLDecoder} it does not transform the +
480 	 * into spaces.
481 	 *
482 	 * @param string
483 	 *            the string to decode
484 	 * @return the decoded string
485 	 * @throws NullPointerException
486 	 *             if <code>string</code> is <code>null</code>.
487 	 * @throws ArrayIndexOutOfBoundsException
488 	 *             if not enough character follow an escape character
489 	 * @throws IllegalArgumentException
490 	 *             if the 2 characters following the escape character do not
491 	 *             represent a hex-number.
492 	 */
493 	public static String unescape(String string) {
494 		return unescape(string, '%');
495 	}
496 
497 	/**
498 	 * Escapes all illegal JCR name characters of a string. The encoding is
499 	 * loosely modeled after URI encoding, but only encodes the characters it
500 	 * absolutely needs to in order to make the resulting string a valid JCR
501 	 * name. Use {@link #unescapeIllegalJcrChars(String)} for decoding.
502 	 * <p>
503 	 * QName EBNF:<br>
504 	 * <xmp> simplename ::= onecharsimplename | twocharsimplename |
505 	 * threeormorecharname onecharsimplename ::= (* Any Unicode character
506 	 * except: '.', '/', ':', '[', ']', '*', '|' or any whitespace character *)
507 	 * twocharsimplename ::= '.' onecharsimplename | onecharsimplename '.' |
508 	 * onecharsimplename onecharsimplename threeormorecharname ::= nonspace
509 	 * string nonspace string ::= char | string char char ::= nonspace | ' '
510 	 * nonspace ::= (* Any Unicode character except: '/', ':', '[', ']', '*',
511 	 * '|' or any whitespace character *) </xmp>
512 	 *
513 	 * @param name
514 	 *            the name to escape
515 	 * @return the escaped name
516 	 */
517 	public static String escapeIllegalJcrChars(String name) {
518 		return escapeIllegalChars(name, "%/:[]*|\t\r\n");
519 	}
520 
521 	/**
522 	 * Escapes all illegal JCR 1.0 name characters of a string. Use
523 	 * {@link #unescapeIllegalJcrChars(String)} for decoding.
524 	 * <p>
525 	 * QName EBNF:<br>
526 	 * <xmp> simplename ::= onecharsimplename | twocharsimplename |
527 	 * threeormorecharname onecharsimplename ::= (* Any Unicode character
528 	 * except: '.', '/', ':', '[', ']', '*', ''', '"', '|' or any whitespace
529 	 * character *) twocharsimplename ::= '.' onecharsimplename |
530 	 * onecharsimplename '.' | onecharsimplename onecharsimplename
531 	 * threeormorecharname ::= nonspace string nonspace string ::= char | string
532 	 * char char ::= nonspace | ' ' nonspace ::= (* Any Unicode character
533 	 * except: '/', ':', '[', ']', '*', ''', '"', '|' or any whitespace
534 	 * character *) </xmp>
535 	 *
536 	 * @since Apache Jackrabbit 2.3.2 and 2.2.10
537 	 * @see <a href=
538 	 *      "https://issues.apache.org/jira/browse/JCR-3128">JCR-3128</a>
539 	 * @param name
540 	 *            the name to escape
541 	 * @return the escaped name
542 	 */
543 	public static String escapeIllegalJcr10Chars(String name) {
544 		return escapeIllegalChars(name, "%/:[]*'\"|\t\r\n");
545 	}
546 
547 	private static String escapeIllegalChars(String name, String illegal) {
548 		StringBuilder buffer = new StringBuilder(name.length() * 2);
549 		for (int i = 0; i < name.length(); i++) {
550 			char ch = name.charAt(i);
551 			if (illegal.indexOf(ch) != -1 || (ch == '.' && name.length() < 3)
552 					|| (ch == ' ' && (i == 0 || i == name.length() - 1))) {
553 				buffer.append('%');
554 				buffer.append(Character.toUpperCase(Character.forDigit(ch / 16, 16)));
555 				buffer.append(Character.toUpperCase(Character.forDigit(ch % 16, 16)));
556 			} else {
557 				buffer.append(ch);
558 			}
559 		}
560 		return buffer.toString();
561 	}
562 
563 	/**
564 	 * Escapes illegal XPath search characters at the end of a string.
565 	 * <p>
566 	 * Example:<br>
567 	 * A search string like 'test?' will run into a ParseException documented in
568 	 * http://issues.apache.org/jira/browse/JCR-1248
569 	 *
570 	 * @param s
571 	 *            the string to encode
572 	 * @return the escaped string
573 	 */
574 	public static String escapeIllegalXpathSearchChars(String s) {
575 		StringBuilder sb = new StringBuilder();
576 		sb.append(s.substring(0, (s.length() - 1)));
577 		char c = s.charAt(s.length() - 1);
578 		// NOTE: keep this in sync with _ESCAPED_CHAR below!
579 		if (c == '!' || c == '(' || c == ':' || c == '^' || c == '[' || c == ']' || c == '{' || c == '}' || c == '?') {
580 			sb.append('\\');
581 		}
582 		sb.append(c);
583 		return sb.toString();
584 	}
585 
586 	/**
587 	 * Unescapes previously escaped jcr chars.
588 	 * <p>
589 	 * Please note, that this does not exactly the same as the url related
590 	 * {@link #unescape(String)}, since it handles the byte-encoding
591 	 * differently.
592 	 *
593 	 * @param name
594 	 *            the name to unescape
595 	 * @return the unescaped name
596 	 */
597 	public static String unescapeIllegalJcrChars(String name) {
598 		StringBuilder buffer = new StringBuilder(name.length());
599 		int i = name.indexOf('%');
600 		while (i > -1 && i + 2 < name.length()) {
601 			buffer.append(name.toCharArray(), 0, i);
602 			int a = Character.digit(name.charAt(i + 1), 16);
603 			int b = Character.digit(name.charAt(i + 2), 16);
604 			if (a > -1 && b > -1) {
605 				buffer.append((char) (a * 16 + b));
606 				name = name.substring(i + 3);
607 			} else {
608 				buffer.append('%');
609 				name = name.substring(i + 1);
610 			}
611 			i = name.indexOf('%');
612 		}
613 		buffer.append(name);
614 		return buffer.toString();
615 	}
616 
617 	/**
618 	 * Returns the name part of the path. If the given path is already a name
619 	 * (i.e. contains no slashes) it is returned.
620 	 *
621 	 * @param path
622 	 *            the path
623 	 * @return the name part or <code>null</code> if <code>path</code> is
624 	 *         <code>null</code>.
625 	 */
626 	public static String getName(String path) {
627 		return getName(path, '/');
628 	}
629 
630 	/**
631 	 * Returns the name part of the path, delimited by the given
632 	 * <code>delim</code>. If the given path is already a name (i.e. contains no
633 	 * <code>delim</code> characters) it is returned.
634 	 *
635 	 * @param path
636 	 *            the path
637 	 * @param delim
638 	 *            the delimiter
639 	 * @return the name part or <code>null</code> if <code>path</code> is
640 	 *         <code>null</code>.
641 	 */
642 	public static String getName(String path, char delim) {
643 		return path == null ? null : path.substring(path.lastIndexOf(delim) + 1);
644 	}
645 
646 	/**
647 	 * Same as {@link #getName(String)} but adding the possibility to pass paths
648 	 * that end with a trailing '/'
649 	 *
650 	 * @see #getName(String)
651 	 */
652 	public static String getName(String path, boolean ignoreTrailingSlash) {
653 		if (ignoreTrailingSlash && path != null && path.endsWith("/") && path.length() > 1) {
654 			path = path.substring(0, path.length() - 1);
655 		}
656 		return getName(path);
657 	}
658 
659 	/**
660 	 * Returns the namespace prefix of the given <code>qname</code>. If the
661 	 * prefix is missing, an empty string is returned. Please note, that this
662 	 * method does not validate the name or prefix.
663 	 * </p>
664 	 * the qname has the format: qname := [prefix ':'] local;
665 	 *
666 	 * @param qname
667 	 *            a qualified name
668 	 * @return the prefix of the name or "".
669 	 *
670 	 * @see #getLocalName(String)
671 	 *
672 	 * @throws NullPointerException
673 	 *             if <code>qname</code> is <code>null</code>
674 	 */
675 	public static String getNamespacePrefix(String qname) {
676 		int pos = qname.indexOf(':');
677 		return pos >= 0 ? qname.substring(0, pos) : "";
678 	}
679 
680 	/**
681 	 * Returns the local name of the given <code>qname</code>. Please note, that
682 	 * this method does not validate the name.
683 	 * </p>
684 	 * the qname has the format: qname := [prefix ':'] local;
685 	 *
686 	 * @param qname
687 	 *            a qualified name
688 	 * @return the localname
689 	 *
690 	 * @see #getNamespacePrefix(String)
691 	 *
692 	 * @throws NullPointerException
693 	 *             if <code>qname</code> is <code>null</code>
694 	 */
695 	public static String getLocalName(String qname) {
696 		int pos = qname.indexOf(':');
697 		return pos >= 0 ? qname.substring(pos + 1) : qname;
698 	}
699 
700 	/**
701 	 * Determines, if two paths denote hierarchical siblins.
702 	 *
703 	 * @param p1
704 	 *            first path
705 	 * @param p2
706 	 *            second path
707 	 * @return true if on same level, false otherwise
708 	 */
709 	public static boolean isSibling(String p1, String p2) {
710 		int pos1 = p1.lastIndexOf('/');
711 		int pos2 = p2.lastIndexOf('/');
712 		return (pos1 == pos2 && pos1 >= 0 && p1.regionMatches(0, p2, 0, pos1));
713 	}
714 
715 	/**
716 	 * Determines if the <code>descendant</code> path is hierarchical a
717 	 * descendant of <code>path</code>.
718 	 *
719 	 * @param path
720 	 *            the current path
721 	 * @param descendant
722 	 *            the potential descendant
723 	 * @return <code>true</code> if the <code>descendant</code> is a descendant;
724 	 *         <code>false</code> otherwise.
725 	 */
726 	public static boolean isDescendant(String path, String descendant) {
727 		String pattern = path.endsWith("/") ? path : path + "/";
728 		return !pattern.equals(descendant) && descendant.startsWith(pattern);
729 	}
730 
731 	/**
732 	 * Determines if the <code>descendant</code> path is hierarchical a
733 	 * descendant of <code>path</code> or equal to it.
734 	 *
735 	 * @param path
736 	 *            the path to check
737 	 * @param descendant
738 	 *            the potential descendant
739 	 * @return <code>true</code> if the <code>descendant</code> is a descendant
740 	 *         or equal; <code>false</code> otherwise.
741 	 */
742 	public static boolean isDescendantOrEqual(String path, String descendant) {
743 		if (path.equals(descendant)) {
744 			return true;
745 		} else {
746 			String pattern = path.endsWith("/") ? path : path + "/";
747 			return descendant.startsWith(pattern);
748 		}
749 	}
750 
751 	/**
752 	 * Returns the n<sup>th</sup> relative parent of the path, where n=level.
753 	 * <p>
754 	 * Example:<br>
755 	 * <code>
756 	 * Text.getRelativeParent("/foo/bar/test", 1) == "/foo/bar"
757 	 * </code>
758 	 *
759 	 * @param path
760 	 *            the path of the page
761 	 * @param level
762 	 *            the level of the parent
763 	 */
764 	public static String getRelativeParent(String path, int level) {
765 		int idx = path.length();
766 		while (level > 0) {
767 			idx = path.lastIndexOf('/', idx - 1);
768 			if (idx < 0) {
769 				return "";
770 			}
771 			level--;
772 		}
773 		return (idx == 0) ? "/" : path.substring(0, idx);
774 	}
775 
776 	/**
777 	 * Same as {@link #getRelativeParent(String, int)} but adding the
778 	 * possibility to pass paths that end with a trailing '/'
779 	 *
780 	 * @see #getRelativeParent(String, int)
781 	 */
782 	public static String getRelativeParent(String path, int level, boolean ignoreTrailingSlash) {
783 		if (ignoreTrailingSlash && path.endsWith("/") && path.length() > 1) {
784 			path = path.substring(0, path.length() - 1);
785 		}
786 		return getRelativeParent(path, level);
787 	}
788 
789 	/**
790 	 * Returns the n<sup>th</sup> absolute parent of the path, where n=level.
791 	 * <p>
792 	 * Example:<br>
793 	 * <code>
794 	 * Text.getAbsoluteParent("/foo/bar/test", 1) == "/foo/bar"
795 	 * </code>
796 	 *
797 	 * @param path
798 	 *            the path of the page
799 	 * @param level
800 	 *            the level of the parent
801 	 */
802 	public static String getAbsoluteParent(String path, int level) {
803 		int idx = 0;
804 		int len = path.length();
805 		while (level >= 0 && idx < len) {
806 			idx = path.indexOf('/', idx + 1);
807 			if (idx < 0) {
808 				idx = len;
809 			}
810 			level--;
811 		}
812 		return level >= 0 ? "" : path.substring(0, idx);
813 	}
814 
815 	/**
816 	 * Performs variable replacement on the given string value. Each
817 	 * <code>${...}</code> sequence within the given value is replaced with the
818 	 * value of the named parser variable. If a variable is not found in the
819 	 * properties an IllegalArgumentException is thrown unless
820 	 * <code>ignoreMissing</code> is <code>true</code>. In the later case, the
821 	 * missing variable is replaced by the empty string.
822 	 *
823 	 * @param value
824 	 *            the original value
825 	 * @param ignoreMissing
826 	 *            if <code>true</code>, missing variables are replaced by the
827 	 *            empty string.
828 	 * @return value after variable replacements
829 	 * @throws IllegalArgumentException
830 	 *             if the replacement of a referenced variable is not found
831 	 */
832 	public static String replaceVariables(Properties variables, String value, boolean ignoreMissing)
833 			throws IllegalArgumentException {
834 		StringBuilder result = new StringBuilder();
835 
836 		// Value:
837 		// +--+-+--------+-+-----------------+
838 		// | |p|--> |q|--> |
839 		// +--+-+--------+-+-----------------+
840 		int p = 0, q = value.indexOf("${"); // Find first ${
841 		while (q != -1) {
842 			result.append(value.substring(p, q)); // Text before ${
843 			p = q;
844 			q = value.indexOf("}", q + 2); // Find }
845 			if (q != -1) {
846 				String variable = value.substring(p + 2, q);
847 				String replacement = variables.getProperty(variable);
848 				if (replacement == null) {
849 					if (ignoreMissing) {
850 						replacement = "";
851 					} else {
852 						throw new IllegalArgumentException("Replacement not found for ${" + variable + "}.");
853 					}
854 				}
855 				result.append(replacement);
856 				p = q + 1;
857 				q = value.indexOf("${", p); // Find next ${
858 			}
859 		}
860 		result.append(value.substring(p, value.length())); // Trailing text
861 
862 		return result.toString();
863 	}
864 
865 	private static byte decodeDigit(byte b) {
866 		if (b >= 0x30 && b <= 0x39) {
867 			return (byte) (b - 0x30);
868 		} else if (b >= 0x41 && b <= 0x46) {
869 			return (byte) (b - 0x37);
870 		} else if (b >= 0x61 && b <= 0x66) {
871 			return (byte) (b - 0x57);
872 		} else {
873 			throw new IllegalArgumentException("Escape sequence is not hexadecimal: " + (char) b);
874 		}
875 	}
876 
877 }